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" "veza-backend-api/internal/utils" ) 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 { if !utils.ValidateExecPath(inputPath) { return fmt.Errorf("invalid input path") } 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 { if !utils.ValidateExecPath(inputPath) { return fmt.Errorf("invalid input path") } 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 }