package main import ( "bufio" "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" "git.leaktechnologies.dev/stu/VideoTools/internal/logging" "git.leaktechnologies.dev/stu/VideoTools/internal/queue" "git.leaktechnologies.dev/stu/VideoTools/internal/ui" "git.leaktechnologies.dev/stu/VideoTools/internal/utils" ) // audioTrackInfo represents an audio track detected in a video type audioTrackInfo struct { Index int Codec string Channels int SampleRate int Bitrate int Language string Title string Default bool } // audioConfig stores persistent audio extraction settings type audioConfig struct { OutputFormat string `json:"outputFormat"` Quality string `json:"quality"` Bitrate string `json:"bitrate"` Normalize bool `json:"normalize"` NormTargetLUFS float64 `json:"normTargetLUFS"` NormTruePeak float64 `json:"normTruePeak"` OutputDir string `json:"outputDir"` } // defaultAudioConfig returns default audio extraction settings func defaultAudioConfig() audioConfig { return audioConfig{ OutputFormat: "MP3", Quality: "Medium", Bitrate: "192k", Normalize: false, NormTargetLUFS: -23.0, NormTruePeak: -1.0, OutputDir: "", } } // audioConfigPath returns the path to the audio config file func audioConfigPath() string { configDir, err := os.UserConfigDir() if err != nil || configDir == "" { home := os.Getenv("HOME") if home != "" { configDir = filepath.Join(home, ".config") } } return filepath.Join(configDir, "VideoTools", "audio.json") } // loadAudioConfig loads the persisted audio configuration func loadAudioConfig() (audioConfig, error) { var cfg audioConfig path := audioConfigPath() data, err := os.ReadFile(path) if err != nil { return defaultAudioConfig(), err } if err := json.Unmarshal(data, &cfg); err != nil { return defaultAudioConfig(), err } return cfg, nil } // saveAudioConfig saves the audio configuration to disk func saveAudioConfig(cfg audioConfig) error { path := audioConfigPath() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0o644) } func buildAudioView(state *appState) fyne.CanvasObject { audioColor := utils.MustHex("#FF8F00") // Dark Amber for audio // Top bar with back button backBtn := widget.NewButton("< AUDIO", func() { state.showMainMenu() }) backBtn.Importance = widget.LowImportance topBar := ui.TintedBar(audioColor, container.NewHBox(backBtn, layout.NewSpacer())) // Left panel - File selection and track list leftPanel := buildAudioLeftPanel(state) // Right panel - Extraction settings rightPanel := buildAudioRightPanel(state) // Main content split mainSplit := container.New(&fixedHSplitLayout{ratio: 0.5}, leftPanel, rightPanel) // Action buttons extractBtn := widget.NewButton("Extract Now", func() { state.startAudioExtraction(false) }) extractBtn.Importance = widget.HighImportance queueBtn := widget.NewButton("Add to Queue", func() { state.startAudioExtraction(true) }) actionBar := container.NewHBox( layout.NewSpacer(), extractBtn, queueBtn, ) bottomBar := moduleFooter(audioColor, actionBar, state.statsBar) return container.NewBorder(topBar, bottomBar, nil, nil, mainSplit) } func buildAudioLeftPanel(state *appState) fyne.CanvasObject { // Drop zone for video files dropLabel := widget.NewLabel("Drop video file here or click to browse") dropLabel.Alignment = fyne.TextAlignCenter dropZone := ui.NewDroppable(dropLabel, func(items []fyne.URI) { if len(items) > 0 { if state.audioBatchMode { // Add all dropped files to batch for _, item := range items { state.addAudioBatchFile(item.Path()) } } else { state.loadAudioFile(items[0].Path()) } } }) // Wrap drop zone in container with minimum size dropContainer := container.NewPadded(dropZone) browseBtn := widget.NewButton("Browse for Video", func() { if state.audioBatchMode { // Browse for multiple files dialog.ShowFileOpen(func(uc fyne.URIReadCloser, err error) { if err != nil || uc == nil { return } defer uc.Close() state.addAudioBatchFile(uc.URI().Path()) }, state.window) } else { dialog.ShowFileOpen(func(uc fyne.URIReadCloser, err error) { if err != nil || uc == nil { return } defer uc.Close() state.loadAudioFile(uc.URI().Path()) }, state.window) } }) // File info display fileInfoLabel := widget.NewLabel("No file loaded") fileInfoLabel.Wrapping = fyne.TextWrapWord state.audioFileInfoLabel = fileInfoLabel // Track list trackListLabel := widget.NewLabel("Audio Tracks:") trackListLabel.TextStyle = fyne.TextStyle{Bold: true} trackListContainer := container.NewVBox() state.audioTrackListContainer = trackListContainer // Select all/deselect all buttons selectAllBtn := widget.NewButton("Select All", func() { state.selectAllAudioTracks(true) }) selectAllBtn.Importance = widget.LowImportance deselectAllBtn := widget.NewButton("Deselect All", func() { state.selectAllAudioTracks(false) }) deselectAllBtn.Importance = widget.LowImportance trackControls := container.NewHBox(selectAllBtn, deselectAllBtn) // Batch mode toggle batchCheck := widget.NewCheck("Batch Mode (multiple videos)", func(checked bool) { state.audioBatchMode = checked state.refreshAudioView() }) // Batch files list batchFilesLabel := widget.NewLabel("Batch Files:") batchFilesLabel.TextStyle = fyne.TextStyle{Bold: true} batchListContainer := container.NewVBox() state.audioBatchListContainer = batchListContainer clearBatchBtn := widget.NewButton("Clear All", func() { state.audioBatchFiles = nil state.updateAudioBatchFilesList() }) clearBatchBtn.Importance = widget.DangerImportance batchContent := container.NewVBox( dropContainer, browseBtn, widget.NewSeparator(), batchFilesLabel, container.NewVScroll(batchListContainer), clearBatchBtn, widget.NewSeparator(), batchCheck, ) singleContent := container.NewVBox( dropContainer, browseBtn, widget.NewSeparator(), fileInfoLabel, widget.NewSeparator(), trackListLabel, trackControls, container.NewVScroll(trackListContainer), widget.NewSeparator(), batchCheck, ) // Choose which content to show based on batch mode leftContent := container.NewMax() if state.audioBatchMode { leftContent.Objects = []fyne.CanvasObject{batchContent} } else { leftContent.Objects = []fyne.CanvasObject{singleContent} } state.audioLeftPanel = leftContent state.audioSingleContent = singleContent state.audioBatchContent = batchContent return leftContent } func buildAudioRightPanel(state *appState) fyne.CanvasObject { // Output format selection formatLabel := widget.NewLabel("Output Format:") formatLabel.TextStyle = fyne.TextStyle{Bold: true} formatRadio := widget.NewRadioGroup([]string{"MP3", "AAC", "FLAC", "WAV"}, func(value string) { state.audioOutputFormat = value state.updateAudioBitrateVisibility() state.persistAudioConfig() }) formatRadio.Horizontal = true formatRadio.SetSelected(state.audioOutputFormat) // Quality preset qualityLabel := widget.NewLabel("Quality Preset:") qualityLabel.TextStyle = fyne.TextStyle{Bold: true} qualitySelect := widget.NewSelect([]string{"Low", "Medium", "High", "Lossless"}, func(value string) { state.audioQuality = value state.updateAudioBitrateFromQuality() state.persistAudioConfig() }) qualitySelect.SetSelected(state.audioQuality) // Bitrate entry bitrateLabel := widget.NewLabel("Bitrate:") bitrateEntry := widget.NewEntry() bitrateEntry.SetText(state.audioBitrate) bitrateEntry.OnChanged = func(value string) { state.audioBitrate = value state.persistAudioConfig() } state.audioBitrateEntry = bitrateEntry // Normalization section normalizeCheck := widget.NewCheck("Apply EBU R128 Normalization", func(checked bool) { state.audioNormalize = checked state.updateNormalizationVisibility() state.persistAudioConfig() }) normalizeCheck.SetChecked(state.audioNormalize) // Normalization options lufsLabel := widget.NewLabel(fmt.Sprintf("Target LUFS: %.1f", state.audioNormTargetLUFS)) lufsSlider := widget.NewSlider(-30, -10) lufsSlider.SetValue(state.audioNormTargetLUFS) lufsSlider.Step = 0.5 lufsSlider.OnChanged = func(value float64) { state.audioNormTargetLUFS = value lufsLabel.SetText(fmt.Sprintf("Target LUFS: %.1f", value)) state.persistAudioConfig() } peakLabel := widget.NewLabel(fmt.Sprintf("True Peak: %.1f dB", state.audioNormTruePeak)) peakSlider := widget.NewSlider(-3, 0) peakSlider.SetValue(state.audioNormTruePeak) peakSlider.Step = 0.1 peakSlider.OnChanged = func(value float64) { state.audioNormTruePeak = value peakLabel.SetText(fmt.Sprintf("True Peak: %.1f dB", value)) state.persistAudioConfig() } normOptions := container.NewVBox( lufsLabel, lufsSlider, peakLabel, peakSlider, ) state.audioNormOptionsContainer = normOptions // Output directory outputDirLabel := widget.NewLabel("Output Directory:") outputDirLabel.TextStyle = fyne.TextStyle{Bold: true} outputDirEntry := widget.NewEntry() if state.audioOutputDir == "" { home, _ := os.UserHomeDir() state.audioOutputDir = filepath.Join(home, "Music", "VideoTools", "AudioExtract") } outputDirEntry.SetText(state.audioOutputDir) outputDirEntry.OnChanged = func(value string) { state.audioOutputDir = value state.persistAudioConfig() } outputDirBrowseBtn := widget.NewButton("Browse", func() { dialog.ShowFolderOpen(func(uri fyne.ListableURI, err error) { if err != nil || uri == nil { return } state.audioOutputDir = uri.Path() outputDirEntry.SetText(uri.Path()) state.persistAudioConfig() }, state.window) }) outputDirRow := container.NewBorder(nil, nil, nil, outputDirBrowseBtn, outputDirEntry) // Status and progress statusLabel := widget.NewLabel("Ready") state.audioStatusLabel = statusLabel progressBar := widget.NewProgressBar() progressBar.Hide() state.audioProgressBar = progressBar rightContent := container.NewVBox( formatLabel, formatRadio, widget.NewSeparator(), qualityLabel, qualitySelect, widget.NewSeparator(), bitrateLabel, bitrateEntry, widget.NewSeparator(), normalizeCheck, normOptions, widget.NewSeparator(), outputDirLabel, outputDirRow, widget.NewSeparator(), statusLabel, progressBar, ) scrollable := ui.NewFastVScroll(rightContent) return scrollable } // Helper functions for audio module state // probeAudioTracks detects all audio tracks in a video file func (s *appState) probeAudioTracks(path string) ([]audioTrackInfo, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() cmd := exec.CommandContext(ctx, platformConfig.FFprobePath, "-v", "quiet", "-print_format", "json", "-show_streams", "-select_streams", "a", path, ) output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("ffprobe failed: %w", err) } var result struct { Streams []struct { Index int `json:"index"` CodecName string `json:"codec_name"` Channels int `json:"channels"` SampleRate string `json:"sample_rate"` BitRate string `json:"bit_rate"` Tags map[string]interface{} `json:"tags"` Disposition struct { Default int `json:"default"` } `json:"disposition"` } `json:"streams"` } if err := json.Unmarshal(output, &result); err != nil { return nil, fmt.Errorf("failed to parse ffprobe output: %w", err) } var tracks []audioTrackInfo for _, stream := range result.Streams { track := audioTrackInfo{ Index: stream.Index, Codec: stream.CodecName, Channels: stream.Channels, Default: stream.Disposition.Default == 1, } // Parse sample rate if sampleRate, err := strconv.Atoi(stream.SampleRate); err == nil { track.SampleRate = sampleRate } // Parse bitrate if bitrate, err := strconv.Atoi(stream.BitRate); err == nil { track.Bitrate = bitrate } // Extract language from tags if lang, ok := stream.Tags["language"].(string); ok { track.Language = lang } // Extract title from tags if title, ok := stream.Tags["title"].(string); ok { track.Title = title } tracks = append(tracks, track) } return tracks, nil } func (s *appState) loadAudioFile(path string) { logging.Debug(logging.CatUI, "loading audio file: %s", path) s.audioFileInfoLabel.SetText("Loading: " + filepath.Base(path)) // Probe the file for metadata src, err := probeVideo(path) if err != nil { logging.Debug(logging.CatUI, "failed to probe video: %v", err) dialog.ShowError(fmt.Errorf("Failed to load file: %v", err), s.window) s.audioFileInfoLabel.SetText("Failed to load file") return } s.audioFile = src // Detect audio tracks tracks, err := s.probeAudioTracks(path) if err != nil { logging.Debug(logging.CatUI, "failed to probe audio tracks: %v", err) dialog.ShowError(fmt.Errorf("Failed to detect audio tracks: %v", err), s.window) s.audioFileInfoLabel.SetText("Failed to detect audio tracks") return } if len(tracks) == 0 { dialog.ShowInformation("No Audio", "This file does not contain any audio tracks.", s.window) s.audioFileInfoLabel.SetText("No audio tracks found") return } s.audioTracks = tracks s.audioSelectedTracks = make(map[int]bool) // Auto-select all tracks by default for _, track := range tracks { s.audioSelectedTracks[track.Index] = true } // Update UI s.updateAudioFileInfo() s.updateAudioTrackList() logging.Debug(logging.CatUI, "loaded %d audio tracks from %s", len(tracks), filepath.Base(path)) } func (s *appState) updateAudioFileInfo() { if s.audioFile == nil { s.audioFileInfoLabel.SetText("No file loaded") return } info := fmt.Sprintf("File: %s\nDuration: %s\nFormat: %s", s.audioFile.DisplayName, formatShortDuration(s.audioFile.Duration), s.audioFile.Format, ) s.audioFileInfoLabel.SetText(info) } func (s *appState) updateAudioTrackList() { s.audioTrackListContainer.Objects = nil for _, track := range s.audioTracks { trackCopy := track // Capture for closure // Format track info channelStr := fmt.Sprintf("%dch", track.Channels) if track.Channels == 1 { channelStr = "Mono" } else if track.Channels == 2 { channelStr = "Stereo" } else if track.Channels == 6 { channelStr = "5.1" } sampleRateStr := fmt.Sprintf("%d Hz", track.SampleRate) bitrateStr := "" if track.Bitrate > 0 { bitrateStr = fmt.Sprintf("%d kbps", track.Bitrate/1000) } trackLabel := fmt.Sprintf("[Track %d] %s %s %s", track.Index, track.Codec, channelStr, sampleRateStr, ) if bitrateStr != "" { trackLabel += " " + bitrateStr } if track.Language != "" { trackLabel += fmt.Sprintf(" (%s)", track.Language) } if track.Title != "" { trackLabel += fmt.Sprintf(" - %s", track.Title) } check := widget.NewCheck(trackLabel, func(checked bool) { s.audioSelectedTracks[trackCopy.Index] = checked }) check.SetChecked(s.audioSelectedTracks[trackCopy.Index]) s.audioTrackListContainer.Add(check) } s.audioTrackListContainer.Refresh() } func (s *appState) selectAllAudioTracks(selectAll bool) { for _, track := range s.audioTracks { s.audioSelectedTracks[track.Index] = selectAll } s.updateAudioTrackList() } func (s *appState) refreshAudioView() { // Switch between single and batch UI if s.audioLeftPanel != nil { if s.audioBatchMode { s.audioLeftPanel.Objects = []fyne.CanvasObject{s.audioBatchContent} s.updateAudioBatchFilesList() } else { s.audioLeftPanel.Objects = []fyne.CanvasObject{s.audioSingleContent} s.updateAudioFileInfo() s.updateAudioTrackList() } s.audioLeftPanel.Refresh() } } func (s *appState) addAudioBatchFile(path string) { // Probe the file src, err := probeVideo(path) if err != nil { logging.Debug(logging.CatUI, "failed to probe video for batch: %v", err) dialog.ShowError(fmt.Errorf("Failed to load file: %v", err), s.window) return } // Check for duplicate for _, existing := range s.audioBatchFiles { if existing.Path == path { return // Already added } } s.audioBatchFiles = append(s.audioBatchFiles, src) s.updateAudioBatchFilesList() logging.Debug(logging.CatUI, "added batch file: %s", path) } func (s *appState) removeAudioBatchFile(index int) { if index >= 0 && index < len(s.audioBatchFiles) { s.audioBatchFiles = append(s.audioBatchFiles[:index], s.audioBatchFiles[index+1:]...) s.updateAudioBatchFilesList() } } func (s *appState) updateAudioBatchFilesList() { if s.audioBatchListContainer == nil { return } s.audioBatchListContainer.Objects = nil if len(s.audioBatchFiles) == 0 { s.audioBatchListContainer.Add(widget.NewLabel("No files added")) } else { for i, src := range s.audioBatchFiles { idx := i // Capture for closure fileLabel := widget.NewLabel(fmt.Sprintf("%d. %s", i+1, src.DisplayName)) removeBtn := widget.NewButton("Remove", func() { s.removeAudioBatchFile(idx) }) removeBtn.Importance = widget.LowImportance row := container.NewBorder(nil, nil, nil, removeBtn, fileLabel) s.audioBatchListContainer.Add(row) } s.audioBatchListContainer.Add(widget.NewLabel(fmt.Sprintf("Total: %d files", len(s.audioBatchFiles)))) } s.audioBatchListContainer.Refresh() } func (s *appState) updateAudioBitrateVisibility() { // Hide bitrate entry for lossless formats if s.audioOutputFormat == "FLAC" || s.audioOutputFormat == "WAV" { s.audioBitrateEntry.Disable() } else { s.audioBitrateEntry.Enable() } } func (s *appState) updateAudioBitrateFromQuality() { // Update bitrate based on quality preset bitrateMap := map[string]map[string]string{ "MP3": { "Low": "128k", "Medium": "192k", "High": "256k", "Lossless": "320k", }, "AAC": { "Low": "128k", "Medium": "192k", "High": "256k", "Lossless": "256k", }, "FLAC": { "Low": "", "Medium": "", "High": "", "Lossless": "", }, "WAV": { "Low": "", "Medium": "", "High": "", "Lossless": "", }, } if bitrate, ok := bitrateMap[s.audioOutputFormat][s.audioQuality]; ok { s.audioBitrate = bitrate s.audioBitrateEntry.SetText(bitrate) } } func (s *appState) updateNormalizationVisibility() { if s.audioNormalize { s.audioNormOptionsContainer.Show() } else { s.audioNormOptionsContainer.Hide() } } func (s *appState) startAudioExtraction(addToQueue bool) { // Get output directory outputDir := s.audioOutputDir if outputDir == "" { homeDir, _ := os.UserHomeDir() outputDir = filepath.Join(homeDir, "Music", "VideoTools", "AudioExtract") } // Create output directory if it doesn't exist if err := os.MkdirAll(outputDir, 0755); err != nil { dialog.ShowError(fmt.Errorf("Failed to create output directory: %v", err), s.window) return } jobsCreated := 0 if s.audioBatchMode { // Batch mode: process all batch files if len(s.audioBatchFiles) == 0 { dialog.ShowError(fmt.Errorf("No files added to batch"), s.window) return } // For each file in batch, extract first audio track (or all tracks if we want to expand this later) for _, src := range s.audioBatchFiles { // Detect audio tracks for this file tracks, err := s.probeAudioTracks(src.Path) if err != nil { logging.Debug(logging.CatUI, "failed to probe audio for %s: %v", src.Path, err) continue } if len(tracks) == 0 { logging.Debug(logging.CatUI, "no audio tracks in %s", src.Path) continue } // Extract first audio track (or all - configurable later) baseName := strings.TrimSuffix(filepath.Base(src.Path), filepath.Ext(src.Path)) track := tracks[0] // Extract first track ext := s.getAudioFileExtension() langSuffix := "" if track.Language != "" && track.Language != "und" { langSuffix = "_" + track.Language } outputPath := filepath.Join(outputDir, fmt.Sprintf("%s_track%d%s.%s", baseName, track.Index, langSuffix, ext)) config := map[string]interface{}{ "trackIndex": track.Index, "format": s.audioOutputFormat, "quality": s.audioQuality, "bitrate": s.audioBitrate, "normalize": s.audioNormalize, "targetLUFS": s.audioNormTargetLUFS, "truePeak": s.audioNormTruePeak, } job := &queue.Job{ Type: queue.JobTypeAudio, Title: fmt.Sprintf("Extract Audio: %s", baseName), Description: fmt.Sprintf("Track %d → %s", track.Index, filepath.Base(outputPath)), InputFile: src.Path, OutputFile: outputPath, Config: config, } if addToQueue { s.jobQueue.Add(job) } else { s.jobQueue.AddNext(job) } jobsCreated++ } } else { // Single file mode if s.audioFile == nil { dialog.ShowError(fmt.Errorf("No file loaded"), s.window) return } // Count selected tracks selectedCount := 0 for _, selected := range s.audioSelectedTracks { if selected { selectedCount++ } } if selectedCount == 0 { dialog.ShowError(fmt.Errorf("No audio tracks selected"), s.window) return } baseName := strings.TrimSuffix(filepath.Base(s.audioFile.Path), filepath.Ext(s.audioFile.Path)) for _, track := range s.audioTracks { if !s.audioSelectedTracks[track.Index] { continue } // Build output filename ext := s.getAudioFileExtension() langSuffix := "" if track.Language != "" && track.Language != "und" { langSuffix = "_" + track.Language } outputPath := filepath.Join(outputDir, fmt.Sprintf("%s_track%d%s.%s", baseName, track.Index, langSuffix, ext)) // Prepare job config config := map[string]interface{}{ "trackIndex": track.Index, "format": s.audioOutputFormat, "quality": s.audioQuality, "bitrate": s.audioBitrate, "normalize": s.audioNormalize, "targetLUFS": s.audioNormTargetLUFS, "truePeak": s.audioNormTruePeak, } // Create job job := &queue.Job{ Type: queue.JobTypeAudio, Title: fmt.Sprintf("Extract Audio Track %d", track.Index), Description: fmt.Sprintf("%s → %s", filepath.Base(s.audioFile.Path), filepath.Base(outputPath)), InputFile: s.audioFile.Path, OutputFile: outputPath, Config: config, } if addToQueue { s.jobQueue.Add(job) } else { s.jobQueue.AddNext(job) } jobsCreated++ } } // Start queue if not already running if !s.jobQueue.IsRunning() { s.jobQueue.Start() } // Update status s.audioStatusLabel.SetText(fmt.Sprintf("Queued %d extraction job(s)", jobsCreated)) // Navigate to queue view if starting immediately if !addToQueue { s.showQueue() } } func (s *appState) getAudioFileExtension() string { switch s.audioOutputFormat { case "MP3": return "mp3" case "AAC": return "m4a" case "FLAC": return "flac" case "WAV": return "wav" default: return "mp3" } } func (s *appState) persistAudioConfig() { cfg := audioConfig{ OutputFormat: s.audioOutputFormat, Quality: s.audioQuality, Bitrate: s.audioBitrate, Normalize: s.audioNormalize, NormTargetLUFS: s.audioNormTargetLUFS, NormTruePeak: s.audioNormTruePeak, OutputDir: s.audioOutputDir, } if err := saveAudioConfig(cfg); err != nil { logging.Debug(logging.CatSystem, "failed to persist audio config: %v", err) } } func (s *appState) executeAudioJob(ctx context.Context, job *queue.Job, progressCallback func(float64)) error { cfg := job.Config if cfg == nil { return fmt.Errorf("audio job config missing") } // Extract config trackIndex := int(cfg["trackIndex"].(float64)) format := cfg["format"].(string) bitrate := cfg["bitrate"].(string) normalize := cfg["normalize"].(bool) inputPath := job.InputFile outputPath := job.OutputFile logging.Debug(logging.CatFFMPEG, "Audio extraction: track %d from %s to %s (format: %s, bitrate: %s, normalize: %v)", trackIndex, inputPath, outputPath, format, bitrate, normalize) // If normalization is requested, do two-pass loudnorm if normalize { targetLUFS := cfg["targetLUFS"].(float64) truePeak := cfg["truePeak"].(float64) logging.Debug(logging.CatFFMPEG, "Running two-pass loudnorm normalization (target LUFS: %.1f, true peak: %.1f)", targetLUFS, truePeak) // Pass 1: Analyze audio progressCallback(10.0) normParams, err := s.analyzeLoudnorm(ctx, inputPath, trackIndex, targetLUFS, truePeak) if err != nil { return fmt.Errorf("loudnorm analysis failed: %w", err) } progressCallback(30.0) // Pass 2: Apply normalization with measured values if err := s.extractAudioWithNormalization(ctx, inputPath, outputPath, trackIndex, format, bitrate, targetLUFS, truePeak, normParams, progressCallback); err != nil { return err } } else { // Simple extraction without normalization if err := s.extractAudioSimple(ctx, inputPath, outputPath, trackIndex, format, bitrate, progressCallback); err != nil { return err } } progressCallback(100.0) logging.Debug(logging.CatFFMPEG, "Audio extraction completed: %s", outputPath) return nil } // loudnormParams holds measured values from loudnorm analysis type loudnormParams struct { MeasuredI float64 MeasuredTP float64 MeasuredLRA float64 MeasuredThresh float64 } // analyzeLoudnorm runs the first pass to analyze audio levels func (s *appState) analyzeLoudnorm(ctx context.Context, inputPath string, trackIndex int, targetI, targetTP float64) (*loudnormParams, error) { args := []string{ "-i", inputPath, "-map", fmt.Sprintf("0:a:%d", trackIndex), "-af", fmt.Sprintf("loudnorm=I=%.1f:TP=%.1f:print_format=json", targetI, targetTP), "-f", "null", "-", } cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, args...) logging.Debug(logging.CatFFMPEG, "Loudnorm analysis: %s %v", platformConfig.FFmpegPath, args) output, err := cmd.CombinedOutput() if err != nil { logging.Debug(logging.CatFFMPEG, "Loudnorm analysis output: %s", string(output)) return nil, fmt.Errorf("loudnorm analysis failed: %w", err) } // Parse JSON output from loudnorm // The output contains a JSON block at the end outputStr := string(output) jsonStart := strings.Index(outputStr, "{") if jsonStart == -1 { return nil, fmt.Errorf("no JSON output from loudnorm") } jsonData := outputStr[jsonStart:] jsonEnd := strings.LastIndex(jsonData, "}") if jsonEnd == -1 { return nil, fmt.Errorf("malformed JSON output from loudnorm") } jsonData = jsonData[:jsonEnd+1] var result struct { InputI string `json:"input_i"` InputTP string `json:"input_tp"` InputLRA string `json:"input_lra"` InputThresh string `json:"input_thresh"` } if err := json.Unmarshal([]byte(jsonData), &result); err != nil { logging.Debug(logging.CatFFMPEG, "Failed to parse JSON: %s", jsonData) return nil, fmt.Errorf("failed to parse loudnorm JSON: %w", err) } params := &loudnormParams{} params.MeasuredI, _ = strconv.ParseFloat(result.InputI, 64) params.MeasuredTP, _ = strconv.ParseFloat(result.InputTP, 64) params.MeasuredLRA, _ = strconv.ParseFloat(result.InputLRA, 64) params.MeasuredThresh, _ = strconv.ParseFloat(result.InputThresh, 64) logging.Debug(logging.CatFFMPEG, "Loudnorm measured: I=%.2f, TP=%.2f, LRA=%.2f, thresh=%.2f", params.MeasuredI, params.MeasuredTP, params.MeasuredLRA, params.MeasuredThresh) return params, nil } // extractAudioWithNormalization performs the second pass with normalization func (s *appState) extractAudioWithNormalization(ctx context.Context, inputPath, outputPath string, trackIndex int, format, bitrate string, targetI, targetTP float64, params *loudnormParams, progressCallback func(float64)) error { args := []string{ "-y", "-i", inputPath, "-map", fmt.Sprintf("0:a:%d", trackIndex), "-af", fmt.Sprintf("loudnorm=I=%.1f:TP=%.1f:measured_I=%.2f:measured_TP=%.2f:measured_LRA=%.2f:measured_thresh=%.2f", targetI, targetTP, params.MeasuredI, params.MeasuredTP, params.MeasuredLRA, params.MeasuredThresh), } // Add codec settings args = append(args, s.getAudioCodecArgs(format, bitrate)...) args = append(args, outputPath) return s.runFFmpegExtraction(ctx, args, progressCallback, 30.0, 100.0) } // extractAudioSimple performs simple extraction without normalization func (s *appState) extractAudioSimple(ctx context.Context, inputPath, outputPath string, trackIndex int, format, bitrate string, progressCallback func(float64)) error { args := []string{ "-y", "-i", inputPath, "-map", fmt.Sprintf("0:a:%d", trackIndex), } // Add codec settings args = append(args, s.getAudioCodecArgs(format, bitrate)...) args = append(args, outputPath) return s.runFFmpegExtraction(ctx, args, progressCallback, 0.0, 100.0) } // getAudioCodecArgs returns codec-specific arguments func (s *appState) getAudioCodecArgs(format, bitrate string) []string { switch format { case "MP3": args := []string{"-c:a", "libmp3lame"} if bitrate != "" { args = append(args, "-b:a", bitrate) } return args case "AAC": args := []string{"-c:a", "aac"} if bitrate != "" { args = append(args, "-b:a", bitrate) } return args case "FLAC": return []string{"-c:a", "flac"} case "WAV": return []string{"-c:a", "pcm_s16le"} default: return []string{"-c:a", "copy"} } } // runFFmpegExtraction executes FFmpeg and reports progress func (s *appState) runFFmpegExtraction(ctx context.Context, args []string, progressCallback func(float64), startPct, endPct float64) error { cmd := exec.CommandContext(ctx, platformConfig.FFmpegPath, args...) logging.Debug(logging.CatFFMPEG, "Running: %s %v", platformConfig.FFmpegPath, args) stderr, err := cmd.StderrPipe() if err != nil { return fmt.Errorf("failed to create stderr pipe: %w", err) } if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start FFmpeg: %w", err) } // Parse FFmpeg output for progress scanner := bufio.NewScanner(stderr) for scanner.Scan() { line := scanner.Text() logging.Debug(logging.CatFFMPEG, "FFmpeg: %s", line) // Report progress if strings.Contains(line, "time=") { // Report midpoint between start and end progressCallback(startPct + (endPct-startPct)/2) } } if err := cmd.Wait(); err != nil { return fmt.Errorf("FFmpeg failed: %w", err) } progressCallback(endPct) return nil } func (s *appState) showAudioView() { s.stopPreview() s.lastModule = s.active s.active = "audio" s.setContent(buildAudioView(s)) }