- S1-01: Add multi-bitrate streaming profiles (128k, 256k, 320k) - S1-02: Update master.m3u8 endpoint with 3-tier quality system - S1-03: Integrate hls.js with ABR + useHLSPlayer hook - S1-04: Add Cache-Control headers on HLS segments and manifests - S1-05: Create WaveformService with async generation (FFmpeg + audiowaveform) - S1-06: Add GET /tracks/:id/waveform endpoint with Redis cache - S1-07: Create WaveformDisplay component with story - S1-08: Add 4 Prometheus metrics for streaming monitoring
223 lines
6 KiB
Go
223 lines
6 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
|
|
"veza-backend-api/internal/models"
|
|
)
|
|
|
|
type WaveformData struct {
|
|
Version int `json:"version"`
|
|
Channels int `json:"channels"`
|
|
SampleRate int `json:"sample_rate"`
|
|
SamplesPerPixel int `json:"samples_per_pixel"`
|
|
Bits int `json:"bits"`
|
|
Length int `json:"length"`
|
|
Data []float64 `json:"data"`
|
|
}
|
|
|
|
type WaveformService struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
s3Service *S3StorageService
|
|
cacheService *CacheService
|
|
tempDir string
|
|
}
|
|
|
|
func NewWaveformService(db *gorm.DB, logger *zap.Logger, s3Service *S3StorageService) *WaveformService {
|
|
tempDir := os.TempDir()
|
|
return &WaveformService{
|
|
db: db,
|
|
logger: logger,
|
|
s3Service: s3Service,
|
|
tempDir: tempDir,
|
|
}
|
|
}
|
|
|
|
func (s *WaveformService) SetCacheService(cache *CacheService) {
|
|
s.cacheService = cache
|
|
}
|
|
|
|
func (s *WaveformService) GenerateWaveformAsync(ctx context.Context, trackID uuid.UUID, inputPath string) {
|
|
go func() {
|
|
genCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
if err := s.generateWaveform(genCtx, trackID, inputPath); err != nil {
|
|
s.logger.Error("Waveform generation failed",
|
|
zap.String("track_id", trackID.String()),
|
|
zap.Error(err),
|
|
)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (s *WaveformService) generateWaveform(ctx context.Context, trackID uuid.UUID, inputPath string) error {
|
|
wavPath := filepath.Join(s.tempDir, fmt.Sprintf("waveform_%s.wav", trackID))
|
|
jsonPath := filepath.Join(s.tempDir, fmt.Sprintf("waveform_%s.json", trackID))
|
|
defer os.Remove(wavPath)
|
|
defer os.Remove(jsonPath)
|
|
|
|
ffmpegCmd := exec.CommandContext(ctx, "ffmpeg",
|
|
"-y", "-i", inputPath,
|
|
"-ac", "1",
|
|
"-filter:a", "aresample=8000",
|
|
"-f", "wav", wavPath,
|
|
)
|
|
if out, err := ffmpegCmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("ffmpeg WAV conversion failed: %w (output: %s)", err, string(out))
|
|
}
|
|
|
|
awCmd := exec.CommandContext(ctx, "audiowaveform",
|
|
"--input-format", "wav",
|
|
"--output-format", "json",
|
|
"--pixels-per-second", "10",
|
|
"-i", wavPath,
|
|
"-o", jsonPath,
|
|
)
|
|
if _, err := awCmd.CombinedOutput(); err != nil {
|
|
return s.generateFallbackWaveform(ctx, trackID, inputPath)
|
|
}
|
|
|
|
jsonData, err := os.ReadFile(jsonPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read waveform JSON: %w", err)
|
|
}
|
|
|
|
s3Key := fmt.Sprintf("waveforms/%s.json", trackID)
|
|
if s.s3Service != nil {
|
|
if _, err := s.s3Service.UploadFile(ctx, jsonData, s3Key, "application/json"); err != nil {
|
|
return fmt.Errorf("failed to upload waveform to S3: %w", err)
|
|
}
|
|
}
|
|
|
|
result := s.db.WithContext(ctx).Model(&models.Track{}).
|
|
Where("id = ?", trackID).
|
|
Update("waveform_url", s3Key)
|
|
if result.Error != nil {
|
|
return fmt.Errorf("failed to update track waveform_url: %w", result.Error)
|
|
}
|
|
|
|
s.logger.Info("Waveform generated successfully",
|
|
zap.String("track_id", trackID.String()),
|
|
zap.String("s3_key", s3Key),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
func (s *WaveformService) generateFallbackWaveform(ctx context.Context, trackID uuid.UUID, inputPath string) error {
|
|
s.logger.Warn("audiowaveform not available, generating fallback waveform via FFmpeg",
|
|
zap.String("track_id", trackID.String()),
|
|
)
|
|
|
|
pcmPath := filepath.Join(s.tempDir, fmt.Sprintf("raw_%s.pcm", trackID))
|
|
defer os.Remove(pcmPath)
|
|
|
|
cmd := exec.CommandContext(ctx, "ffmpeg",
|
|
"-y", "-i", inputPath,
|
|
"-ac", "1",
|
|
"-filter:a", "aresample=800",
|
|
"-f", "f32le",
|
|
"-acodec", "pcm_f32le",
|
|
pcmPath,
|
|
)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("ffmpeg PCM extraction failed: %w (output: %s)", err, string(out))
|
|
}
|
|
|
|
pcmData, err := os.ReadFile(pcmPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read PCM data: %w", err)
|
|
}
|
|
|
|
samples := make([]float64, 0, len(pcmData)/4)
|
|
for i := 0; i+3 < len(pcmData); i += 4 {
|
|
bits := uint32(pcmData[i]) | uint32(pcmData[i+1])<<8 | uint32(pcmData[i+2])<<16 | uint32(pcmData[i+3])<<24
|
|
val := float64(math.Float32frombits(bits))
|
|
if val > 1.0 {
|
|
val = 1.0
|
|
} else if val < -1.0 {
|
|
val = -1.0
|
|
}
|
|
samples = append(samples, val)
|
|
}
|
|
|
|
waveform := WaveformData{
|
|
Version: 2,
|
|
Channels: 1,
|
|
SampleRate: 800,
|
|
SamplesPerPixel: 1,
|
|
Bits: 32,
|
|
Length: len(samples),
|
|
Data: samples,
|
|
}
|
|
|
|
jsonData, err := json.Marshal(waveform)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal waveform: %w", err)
|
|
}
|
|
|
|
s3Key := fmt.Sprintf("waveforms/%s.json", trackID)
|
|
if s.s3Service != nil {
|
|
if _, err := s.s3Service.UploadFile(ctx, jsonData, s3Key, "application/json"); err != nil {
|
|
return fmt.Errorf("failed to upload fallback waveform to S3: %w", err)
|
|
}
|
|
}
|
|
|
|
result := s.db.WithContext(ctx).Model(&models.Track{}).
|
|
Where("id = ?", trackID).
|
|
Update("waveform_url", s3Key)
|
|
if result.Error != nil {
|
|
return fmt.Errorf("failed to update track waveform_url: %w", result.Error)
|
|
}
|
|
|
|
s.logger.Info("Fallback waveform generated",
|
|
zap.String("track_id", trackID.String()),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
func (s *WaveformService) GetWaveform(ctx context.Context, trackID uuid.UUID) ([]byte, error) {
|
|
cacheKey := fmt.Sprintf("waveform:%s", trackID)
|
|
if s.cacheService != nil {
|
|
var cached []byte
|
|
if err := s.cacheService.Get(ctx, cacheKey, &cached); err == nil {
|
|
return cached, nil
|
|
}
|
|
}
|
|
|
|
var track models.Track
|
|
if err := s.db.WithContext(ctx).Select("waveform_url").Where("id = ?", trackID).First(&track).Error; err != nil {
|
|
return nil, fmt.Errorf("track not found: %w", err)
|
|
}
|
|
|
|
if track.WaveformURL == nil || *track.WaveformURL == "" {
|
|
return nil, fmt.Errorf("waveform not yet generated")
|
|
}
|
|
|
|
if s.s3Service == nil {
|
|
return nil, fmt.Errorf("S3 service not configured")
|
|
}
|
|
|
|
data, err := s.s3Service.DownloadFile(ctx, *track.WaveformURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to download waveform from S3: %w", err)
|
|
}
|
|
|
|
if s.cacheService != nil {
|
|
_ = s.cacheService.Set(ctx, cacheKey, data, 1*time.Hour)
|
|
}
|
|
|
|
return data, nil
|
|
}
|