veza/veza-backend-api/internal/services/waveform_service.go
senke 73533bea77 feat(v0.501): Sprint 2 -- HLS production-ready
- 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
2026-02-22 18:16:37 +01:00

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
}