diff --git a/README.md b/README.md index ca7a453..5d406f8 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ Normalization alters audio data. Tagging writes metadata only. Choose one. See [advanced mode in manuals](https://www.fremen.fi/tnt-manual/advanced-mode) for more information. +### Video input +TNT also accepts video files (MP4, MOV, MKV, and others). When a video file is queued, choose whether to drop the video and process the audio only, or keep the video and remux the processed audio back into the original container. In CLI mode, use the `-video-action` flag. + ### Encoders TNT ships with five encoders (six for macOS). The encoders are: @@ -29,6 +32,11 @@ For more information about the encoders, see the [manual entry](https://www.frem ## Processing Configure dynamics processing and EQ to shape your audio for different broadcast scenarios. TNT uses a [proprietary equation](https://www.fremen.fi/guru/dynamic-score) to find the best processing values for each audio file. For more information, see [processing in the manual](https://www.fremen.fi/tnt-manual/processing). +### Dynamics processing and FFmpeg +Loudness and dynamics (compression, upward compression, lookahead/conformity limiting, LRA reduction) run in an **in-house, time-domain DSP engine written in Go** — they are not FFmpeg filters. FFmpeg is used only to decode the source to raw PCM, encode the processed result, resample, and apply Shibata dithering on 16-bit output. Keeping the DSP native lets the encoder pre-limiter be calibrated per encoder (e.g. Apple AAC's overshoot at a given bitrate) and keeps processing behavior fully within TNT's control rather than an external filter graph's. + +An optional FFmpeg `dynaudnorm` pass (`-dyn-norm`) is still available alongside the native engine for cases that want frame-based dynamic normalization instead. + ## CLI Mode TNT can run as a headless daemon that watches a directory and processes audio files automatically. Launch it with flags instead of the GUI. @@ -85,6 +93,7 @@ tnt -i ./inbox -o ./out -format opus -br 128 -speech 1 -lufs 1 -lufs-target-i -1 | `-comp` | Data compression level `0`–`10` (FLAC/Opus only) | `0` | | `-phase-check` | Check for phase inversion before processing: `1`=on, `0`=off | `0` | | `-workers` | Number of parallel worker threads (`0`=auto: CPU cores − 1) | `0` | +| `-video-action` | Video input handling: `drop` (audio only) or `remux` (keep video, replace audio) | `drop` | | `-ebu` | Use EBU R128 -defined values for loudness (-23 LUFS-I, -1 dBTP) | `0`/false | | `-h`/`--help` | Print help and defaults | | | `-v`/`--version` | Print version | | @@ -101,5 +110,9 @@ Range is from 0 (no data compression) to 10 (as much data compression as the enc - Logs are written to `~/.config/TNT/tnt-cli.log`. - Running the binary without any flags launches the GUI as usual. +## Streaming module + +[`normalizer/`](normalizer/) is a self-contained, stdlib-only Go module holding a **streaming** port of the loudness/dynamics engine — `io.Reader → io.Writer`, with bounded, O(lookahead) memory instead of holding the whole file resident. This is what lets TNT's processing run on **low-memory systems**, such as constrained server/cloud instances, where loading an entire multi-GiB PCM buffer into RAM isn't viable. It mirrors the math in `go/audio` (used by the desktop app, which needs random access for its editor/preview) rather than depending on it directly — see [`normalizer/README.md`](normalizer/README.md) for the two-implementations tradeoff and how they're kept in sync. + ## License [LICENSE.md](https://github.com/fremen-fi/TNT/blob/main/LICENSE.md), or view the most up-to-date [General License](https://www.fremen.fi/terms-of-use) on our website. diff --git a/go/app.go b/go/app.go index c7126e5..5ca0071 100644 --- a/go/app.go +++ b/go/app.go @@ -38,6 +38,8 @@ type AudioNormalizer struct { phaseCheck bool simplePreset string multibandFilter string + videoAction string + allowIllegalRemux bool watching bool watcherStop chan bool diff --git a/go/audio/dynscore.go b/go/audio/dynscore.go index bbb9db1..de5f1c0 100644 --- a/go/audio/dynscore.go +++ b/go/audio/dynscore.go @@ -12,6 +12,7 @@ func CalculateDynamicsScore(ffmpegPath, inputPath string) (*DynamicsScoreAnalysi cmd := exec.Command( ffmpegPath, "-i", inputPath, + "-vn", "-af", "astats", "-f", "null", "-", diff --git a/go/audio/freq.go b/go/audio/freq.go index 7c1863f..164727e 100644 --- a/go/audio/freq.go +++ b/go/audio/freq.go @@ -70,6 +70,7 @@ func AnalyzeFrequencyResponseBands(ffmpegPath, inputPath string) ([]FrequencyBan cmd := exec.Command( ffmpegPath, "-i", inputPath, + "-vn", "-af", filterChain, "-f", "null", "-", diff --git a/go/audio/phase.go b/go/audio/phase.go index fafb651..a5acdb9 100644 --- a/go/audio/phase.go +++ b/go/audio/phase.go @@ -29,7 +29,7 @@ func PhaseCheck(ffmpegPath, inputPath string) (inverted bool, offset float64, er } func runPhaseCheck(ffmpegPath, inputPath string) (string, error) { - cmd := exec.Command(ffmpegPath, "-i", inputPath, "-t", "30", "-af", "astats", "-f", "null", "-") + cmd := exec.Command(ffmpegPath, "-i", inputPath, "-vn", "-t", "30", "-af", "astats", "-f", "null", "-") output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("ffmpeg astats failed: %w", err) diff --git a/go/bindings.go b/go/bindings.go index 8fb570f..7e6ace8 100644 --- a/go/bindings.go +++ b/go/bindings.go @@ -23,12 +23,16 @@ func (n *AudioNormalizer) GetFiles() []string { func (n *AudioNormalizer) SelectFiles() []string { paths, err := wailsruntime.OpenMultipleFilesDialog(n.ctx, wailsruntime.OpenDialogOptions{ - Title: "Select Audio Files", + Title: "Select Audio or Video Files", Filters: []wailsruntime.FileFilter{ { DisplayName: "Audio Files", Pattern: "*.mp3;*.wav;*.flac;*.m4a;*.aac;*.ogg;*.opus;*.aiff;*.aif;*.ape", }, + { + DisplayName: "Video Files", + Pattern: "*.mp4;*.mov;*.mkv;*.avi;*.webm;*.m4v;*.mpg;*.mpeg;*.wmv;*.flv;*.ts;*.3gp", + }, }, }) if err != nil { @@ -64,7 +68,7 @@ func (n *AudioNormalizer) SelectFolder() []string { if info.IsDir() { return nil } - if isAudioFile(path) { + if isMediaFile(path) { n.addFile(path) } return nil @@ -116,7 +120,7 @@ func (n *AudioNormalizer) ClearFiles() []string { func (n *AudioNormalizer) AddFiles(paths []string) []string { for _, p := range paths { - if isAudioFile(p) { + if isMediaFile(p) { n.addFile(p) } } @@ -145,6 +149,8 @@ func (n *AudioNormalizer) applyConfig(cfg ProcessConfig) { n.bypassProc = cfg.BypassProc n.dynNorm = cfg.DynNorm n.phaseCheck = cfg.PhaseCheck + n.videoAction = cfg.VideoAction + n.allowIllegalRemux = cfg.AllowIllegalRemux } func (n *AudioNormalizer) Process(cfg ProcessConfig) { @@ -159,8 +165,8 @@ func (n *AudioNormalizer) PreviewSize(cfg ProcessConfig) { // ----- Watch mode ----- -func (n *AudioNormalizer) StartWatching() { - n.startWatching() +func (n *AudioNormalizer) StartWatching() bool { + return n.startWatching() } func (n *AudioNormalizer) StopWatching() { @@ -209,6 +215,7 @@ func (n *AudioNormalizer) LoadPreferences() Preferences { DynNorm: n.dynNorm, PhaseCheck: n.phaseCheck, TelemetryEnabled: n.telemetryEnabled, + AllowIllegalRemux: n.allowIllegalRemux, } } @@ -231,6 +238,7 @@ func (n *AudioNormalizer) SavePreferences(prefs Preferences) { n.dynNorm = prefs.DynNorm n.phaseCheck = prefs.PhaseCheck n.telemetryEnabled = prefs.TelemetryEnabled + n.allowIllegalRemux = prefs.AllowIllegalRemux if n.telemetry != nil { n.telemetry.SetEnabled(prefs.TelemetryEnabled) } diff --git a/go/cli.go b/go/cli.go index 9e67e10..cd9ad06 100644 --- a/go/cli.go +++ b/go/cli.go @@ -44,6 +44,11 @@ type CLIConfig struct { DataComp int PhaseCheck bool Workers int + VideoAction string // "drop" or "remux" + // AllowIllegalRemux skips the container-compatibility check when + // VideoAction is "remux", letting ffmpeg attempt any format/container + // combination even ones it's likely to reject (e.g. FLAC into .m4v). + AllowIllegalRemux bool } // CLIProcessor handles CLI-mode processing without any GUI dependencies @@ -111,6 +116,8 @@ func parseCLIFlags() (*CLIConfig, bool) { dataComp := fs.Int("comp", 0, "Data compression level 0-10 (FLAC/Opus)") phaseCheck := fs.Int("phase-check", 0, "Phase check before processing: 1=on, 0=off") workers := fs.Int("workers", 0, "Number of worker threads (0=auto: CPU cores - 1)") + videoAction := fs.String("video-action", "drop", "Video input handling: drop (audio only) or remux (keep video, replace audio)") + allowIllegalRemux := fs.Int("allow-illegal-remux", 0, "Skip the remux container-compatibility check and mux any format into any container: 1=on, 0=off") // Shorthands // EBU R128 @@ -199,6 +206,15 @@ func parseCLIFlags() (*CLIConfig, bool) { cfg.PhaseCheck = *phaseCheck == 1 cfg.Workers = *workers + switch strings.ToLower(*videoAction) { + case "drop", "remux": + cfg.VideoAction = strings.ToLower(*videoAction) + default: + fmt.Fprintf(os.Stderr, "Unknown -video-action: %s (must be drop or remux)\n", *videoAction) + os.Exit(1) + } + cfg.AllowIllegalRemux = *allowIllegalRemux == 1 + // Map bit depth for display switch cfg.BitDepth { case "32": @@ -334,7 +350,7 @@ func (p *CLIProcessor) processExistingFiles() { var files []string for _, e := range entries { - if !e.IsDir() && isAudioFile(e.Name()) { + if !e.IsDir() && isMediaFile(e.Name()) { files = append(files, filepath.Join(p.cfg.InputDir, e.Name())) } } @@ -453,7 +469,7 @@ func (p *CLIProcessor) watchAndProcess() { for { select { case event := <-watcher.Events: - if event.Op&fsnotify.Create == fsnotify.Create && isAudioFile(event.Name) { + if event.Op&fsnotify.Create == fsnotify.Create && isMediaFile(event.Name) { p.log(fmt.Sprintf("New file detected: %s", filepath.Base(event.Name))) jobQueue <- event.Name } @@ -523,6 +539,25 @@ func (p *CLIProcessor) processFile(inputPath string) bool { outputPath = filepath.Join(cfg.OutputDir, fmt.Sprintf("%s%s", baseName, ext)) } + // Video input with remux requested: keep the original container + // extension for the deliverable; encode audio to a temp file first, then + // remux it with the source video below. + isVideo := isVideoFile(inputPath) + videoRemux := isVideo && cfg.VideoAction == "remux" + if videoRemux && !cfg.AllowIllegalRemux && !remuxCompatible(actualCodec, originalExt) { + p.log(fmt.Sprintf(" FAILED: %s - %s audio can't be muxed into a %s container; pick a different format, use \"drop\" video action, or pass -allow-illegal-remux 1", filepath.Base(inputPath), cfg.Format, originalExt)) + return false + } + var finalOutputPath string + if videoRemux { + finalOutputPath = strings.TrimSuffix(outputPath, filepath.Ext(outputPath)) + originalExt + remuxAudioTempPath := filepath.Join(os.TempDir(), fmt.Sprintf("tnt_remux_audio_%d%s", time.Now().UnixNano(), ext)) + tempFiles = append(tempFiles, remuxAudioTempPath) + outputPath = remuxAudioTempPath + } else { + finalOutputPath = outputPath + } + p.logQuiet(fmt.Sprintf("Config: EqTarget='%s', DynamicsPreset='%s', Format=%s, Codec=%s", cfg.eqPresetName(), cfg.dynPresetName(), cfg.Format, actualCodec)) @@ -712,7 +747,7 @@ func (p *CLIProcessor) processFile(inputPath string) bool { if dynPreset == "Broadcast" { // Quick peak check for hot peaks - cmd := ffmpeg.Command("-i", workingPath, "-af", "astats", "-f", "null", "-") + cmd := ffmpeg.Command("-i", workingPath, "-vn", "-af", "astats", "-f", "null", "-") output, _ := cmd.CombinedOutput() peakRe := regexp.MustCompile(`Peak level dB:\s+([-\d.]+)`) @@ -907,7 +942,30 @@ func (p *CLIProcessor) processFile(inputPath string) bool { return false } - p.log(fmt.Sprintf(" OK: %s -> %s", filepath.Base(inputPath), filepath.Base(outputPath))) + if videoRemux { + // finalOutputPath can be identical to inputPath (output dir == source + // dir, no .normalized/.tagged suffix applied). Muxing straight into + // finalOutputPath would then truncate the file ffmpeg is still + // reading as input 1. Mux to a same-directory temp file and rename + // it into place once ffmpeg is done reading, instead. + remuxTempPath := strings.TrimSuffix(finalOutputPath, originalExt) + ".tnttmp" + originalExt + remuxArgs := []string{"-i", outputPath, "-i", inputPath, "-map", "0:a", "-map", "1:v", "-c", "copy", "-y", remuxTempPath} + p.logQuiet(fmt.Sprintf("Remux command: ffmpeg %s", strings.Join(remuxArgs, " "))) + remuxOutput, remuxErr := ffmpeg.RunCmd(ffmpeg.Command(remuxArgs...)) + p.logQuiet(fmt.Sprintf("FFmpeg remux output: %s", string(remuxOutput))) + if remuxErr != nil { + p.log(fmt.Sprintf(" REMUX FAILED: %s - %v", filepath.Base(inputPath), remuxErr)) + os.Remove(remuxTempPath) + return false + } + if err := os.Rename(remuxTempPath, finalOutputPath); err != nil { + p.log(fmt.Sprintf(" REMUX FAILED: %s - could not finalize output: %v", filepath.Base(inputPath), err)) + os.Remove(remuxTempPath) + return false + } + } + + p.log(fmt.Sprintf(" OK: %s -> %s", filepath.Base(inputPath), filepath.Base(finalOutputPath))) return true } @@ -917,7 +975,7 @@ func (p *CLIProcessor) processFile(inputPath string) bool { // ============================================================ func (p *CLIProcessor) analyzeDynamics(inputPath string) *DynamicsAnalysis { - cmd := ffmpeg.Command("-i", inputPath, "-af", "astats=metadata=1:length=0.05", "-f", "null", "-") + cmd := ffmpeg.Command("-i", inputPath, "-vn", "-af", "astats=metadata=1:length=0.05", "-f", "null", "-") output, err := cmd.CombinedOutput() if err != nil { p.logQuiet(fmt.Sprintf("astats failed: %v", err)) @@ -956,7 +1014,7 @@ func (p *CLIProcessor) analyzeFrequencyBands(inputPath string) map[string]*Frequ defer wg.Done() defer func() { <-sem }() af := fmt.Sprintf("%s,astats", filter) - cmd := exec.Command(ffmpegPath, "-i", inputPath, "-af", af, "-f", "null", "-") + cmd := exec.Command(ffmpegPath, "-i", inputPath, "-vn", "-af", af, "-f", "null", "-") output, err := cmd.CombinedOutput() if err != nil { out <- result{bandName, nil} @@ -1210,7 +1268,7 @@ func (p *CLIProcessor) calculateAdaptiveCompression(analysis *DynamicsAnalysis, func (p *CLIProcessor) measureLoudness(inputPath string, target string, targetTp string) map[string]string { p.log(fmt.Sprintf(" Measuring loudness: %s", filepath.Base(inputPath))) - cmd := exec.Command(ffmpegPath, "-i", inputPath, + cmd := exec.Command(ffmpegPath, "-i", inputPath, "-vn", "-af", fmt.Sprintf("loudnorm=linear=false:I=%s:TP=%s:LRA=5:print_format=json", target, targetTp), "-f", "null", "-") @@ -1223,7 +1281,7 @@ func (p *CLIProcessor) measureLoudness(inputPath string, target string, targetTp } func (p *CLIProcessor) measureLoudnessEbuR128(inputPath string) map[string]string { - cmd := exec.Command(ffmpegPath, "-i", inputPath, + cmd := exec.Command(ffmpegPath, "-i", inputPath, "-vn", "-af", "ebur128=framelog=quiet:peak=true", "-f", "null", "-") diff --git a/go/frontend/app.js b/go/frontend/app.js index c5cf79a..71f80c3 100644 --- a/go/frontend/app.js +++ b/go/frontend/app.js @@ -25,6 +25,7 @@ * @property {string} selected_tab * @property {boolean} phase_check_auto * @property {boolean} telemetry_enabled + * @property {boolean} allow_illegal_remux */ /** @@ -47,6 +48,8 @@ * @property {string} EqTarget * @property {boolean} DynNorm * @property {boolean} PhaseCheck + * @property {string} VideoAction + * @property {boolean} AllowIllegalRemux */ /** @typedef {Record} MetadataTags */ @@ -60,6 +63,7 @@ * @property {boolean} watching * @property {string} normalizationStandard * @property {string[]} [metadataFields] + * @property {string} videoAction */ /** @type {AppState} */ @@ -69,8 +73,12 @@ const state = { processing: false, watching: false, normalizationStandard: 'ebu', + videoAction: 'drop', }; +// Mirrors isVideoFile()'s extension list in go/main.go. +const VIDEO_EXTS = ['MP4', 'MOV', 'MKV', 'AVI', 'WEBM', 'M4V', 'MPG', 'MPEG', 'WMV', 'FLV', 'TS', '3GP']; + // Fast-tab radios use short keys; map to backend names. The advanced // dropdown is populated from GetPlatformFormats() and stores backend // names directly, so it doesn't need this map. @@ -143,6 +151,8 @@ function ext(path) { const m = /\.([^./\\]+)$/.exec(String(path)); return m ? m[1].toUpperCase() : ''; } +/** @param {string} path @returns {boolean} */ +function isVideoPath(path) { return VIDEO_EXTS.includes(ext(path)); } /** @param {string} s @returns {string} */ function parseSR(s) { return (s || '').replace(/\D/g, ''); } /** @param {string} s @returns {string} */ @@ -167,7 +177,7 @@ function renderFileList(paths) { state.selectedIdx = null; const empty = document.createElement('div'); empty.className = 'file-empty'; - empty.textContent = 'Drop audio files here\nor use the buttons above'; + empty.textContent = 'Drop audio or video files here\nor use the buttons above'; list.appendChild(empty); } else { if (state.selectedIdx == null || state.selectedIdx >= state.files.length) { @@ -231,9 +241,17 @@ function renderFileList(paths) { const clr = $button('btn-clear'); if (proc) proc.disabled = !has || state.processing; if (clr) clr.disabled = !has; + $('video-action-row')?.classList.toggle('hidden', !state.files.some(isVideoPath)); updateMetaTab(); } +/** @param {'drop'|'remux'} action */ +function setVideoAction(action) { + state.videoAction = action; + $('video-action-drop')?.classList.toggle('active', action === 'drop'); + $('video-action-remux')?.classList.toggle('active', action === 'remux'); +} + function updateMetaTab() { const path = (state.selectedIdx != null) ? state.files[state.selectedIdx] : null; const hint = $('meta-hint'); @@ -300,6 +318,41 @@ async function setOutput() { } } +/* ── Output folder confirmation ── */ +async function confirmAndProcess() { + if (!state.files.length || state.processing) return; + + let dir = ''; + try { + dir = await window.go.main.AudioNormalizer.GetOutputFolder(); + } catch (_) { /* ignore */ } + + const body = $('confirm-output-body'); + const goBtn = $button('confirm-output-go'); + const chooseBtn = $button('confirm-output-choose'); + if (!body || !goBtn || !chooseBtn) { handleProcess(); return; } + + if (dir) { + body.textContent = `Output files to ${dir}?`; + goBtn.style.display = ''; + } else { + body.textContent = 'No output folder has been set yet. Choose one to continue.'; + goBtn.style.display = 'none'; + } + + goBtn.onclick = () => { closeDialog('confirm-output-dialog'); handleProcess(); }; + chooseBtn.onclick = async () => { + await setOutput(); + const newDir = await window.go.main.AudioNormalizer.GetOutputFolder(); + if (newDir) { + closeDialog('confirm-output-dialog'); + handleProcess(); + } + }; + + openDialog('confirm-output-dialog'); +} + async function handleProcess() { if (!state.files.length || state.processing) return; state.processing = true; @@ -335,6 +388,12 @@ function buildConfig() { const dn = $input('proc-dn'); const byp = $input('proc-bypass'); const phs = $input('prefs-phase'); + const ar = $input('prefs-allow-illegal-remux'); + // Not a processing-stage control like the rest of `proc` — it's a + // remux safety override that applies the same way in Fast and + // Advanced mode, so it's read once here and included in both cfg + // branches below rather than zeroed out for Fast. + const allowIllegalRemux = ar ? ar.checked : false; const proc = { DynamicsPreset: dyn ? dyn.value : 'Off', @@ -373,6 +432,8 @@ function buildConfig() { NoTranscode: false, OriginIsAAC: false, DataCompLevel: 0, + VideoAction: state.videoAction, + AllowIllegalRemux: allowIllegalRemux, }; switch (preset) { case 'aac': @@ -421,6 +482,8 @@ function buildConfig() { NoTranscode: noTr ? noTr.checked : false, OriginIsAAC: false, DataCompLevel: parseInt(cl ? cl.value : '0', 10), + VideoAction: state.videoAction, + AllowIllegalRemux: allowIllegalRemux, }; } @@ -563,6 +626,7 @@ function applyPrefsToUI(prefs) { if (typeof prefs.dyn_norm_enabled === 'boolean') { const dn = $input('proc-dn'); if (dn) dn.checked = prefs.dyn_norm_enabled; } if (typeof prefs.phase_check_auto === 'boolean') { const ph = $input('prefs-phase'); if (ph) ph.checked = prefs.phase_check_auto; } if (typeof prefs.telemetry_enabled === 'boolean') { const t = $input('prefs-telemetry-enabled'); if (t) t.checked = prefs.telemetry_enabled; } + if (typeof prefs.allow_illegal_remux === 'boolean') { const ar = $input('prefs-allow-illegal-remux'); if (ar) ar.checked = prefs.allow_illegal_remux; } if (prefs.last_output_dir) { const out = $('output-path'); if (out) { @@ -613,6 +677,7 @@ function gatherPrefs() { const advTp = $input('adv-tp'); const prefsLufs = $input('prefs-lufs'); const prefsTp = $input('prefs-tp'); + const ar = $input('prefs-allow-illegal-remux'); return { advanced_mode: tab === 'advanced' || tab === 'processing', @@ -634,6 +699,7 @@ function gatherPrefs() { selected_tab: tab, phase_check_auto: ph ? ph.checked : false, telemetry_enabled: (function() { const t = $input('prefs-telemetry-enabled'); return t ? t.checked : false; })(), + allow_illegal_remux: ar ? ar.checked : false, }; } @@ -736,7 +802,12 @@ function bindRealHandlers() { const t = /** @type {HTMLInputElement} */ (e.target); try { if (t.checked) { - await window.go.main.AudioNormalizer.StartWatching(); + const started = await window.go.main.AudioNormalizer.StartWatching(); + if (!started) { + t.checked = false; + addLog('Watch mode failed to start: no output folder is set', 'err'); + return; + } state.watching = true; const warn = $('watcher-warn'); if (warn) warn.textContent = 'WATCHING'; diff --git a/go/frontend/index.html b/go/frontend/index.html index eda8260..8b91a70 100644 --- a/go/frontend/index.html +++ b/go/frontend/index.html @@ -36,17 +36,24 @@
+

Watch mode automatically processes files added to the input folder.

@@ -377,6 +390,21 @@

Update available

+ + +
+

Confirm output folder

+ +
+
+

+
+ + + +
+
+
diff --git a/go/frontend/style.css b/go/frontend/style.css index 9d53c57..f4ecdd8 100644 --- a/go/frontend/style.css +++ b/go/frontend/style.css @@ -227,6 +227,26 @@ button, input, select, textarea { font: inherit; } border-bottom: 1px solid var(--border); display: flex; gap: 6px; } + +.video-action-row { + display: flex; align-items: center; gap: 8px; + padding: 8px 10px; + border-top: 1px solid var(--border); + font-size: 12px; color: var(--muted); +} +.video-action-toggle { + display: flex; + background: var(--inset); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} +.video-action-toggle button { + padding: 3px 10px; font-size: 12px; border: 0; cursor: pointer; + background: transparent; color: var(--muted); font-weight: 400; + transition: background 0.15s, color 0.15s; +} +.video-action-toggle button.active { background: var(--accent); color: #fff; font-weight: 500; } .file-actions .btn { flex: 1; } .file-list-wrap { flex: 1; overflow-y: auto; padding: 4px 0; } diff --git a/go/frontend/wailsjs/go/main/AudioNormalizer.d.ts b/go/frontend/wailsjs/go/main/AudioNormalizer.d.ts index aee9cf4..b9d239a 100755 --- a/go/frontend/wailsjs/go/main/AudioNormalizer.d.ts +++ b/go/frontend/wailsjs/go/main/AudioNormalizer.d.ts @@ -62,7 +62,7 @@ export function SetOutputFolder():Promise; export function SetTelemetryEnabled(arg1:boolean):Promise; -export function StartWatching():Promise; +export function StartWatching():Promise; export function StopWatching():Promise; diff --git a/go/frontend/wailsjs/go/models.ts b/go/frontend/wailsjs/go/models.ts index 9f564a6..7893945 100755 --- a/go/frontend/wailsjs/go/models.ts +++ b/go/frontend/wailsjs/go/models.ts @@ -36,7 +36,8 @@ export namespace main { selected_tab: string; phase_check_auto: boolean; telemetry_enabled: boolean; - + allow_illegal_remux: boolean; + static createFrom(source: any = {}) { return new Preferences(source); } @@ -62,6 +63,7 @@ export namespace main { this.selected_tab = source["selected_tab"]; this.phase_check_auto = source["phase_check_auto"]; this.telemetry_enabled = source["telemetry_enabled"]; + this.allow_illegal_remux = source["allow_illegal_remux"]; } } export class ProcessConfig { @@ -83,7 +85,9 @@ export namespace main { EqTarget: string; DynNorm: boolean; PhaseCheck: boolean; - + VideoAction: string; + AllowIllegalRemux: boolean; + static createFrom(source: any = {}) { return new ProcessConfig(source); } @@ -108,9 +112,11 @@ export namespace main { this.EqTarget = source["EqTarget"]; this.DynNorm = source["DynNorm"]; this.PhaseCheck = source["PhaseCheck"]; + this.VideoAction = source["VideoAction"]; + this.AllowIllegalRemux = source["AllowIllegalRemux"]; } } - + export class VersionInfo { platform: string; version: string; diff --git a/go/main.go b/go/main.go index fa816b1..8da2b91 100644 --- a/go/main.go +++ b/go/main.go @@ -112,6 +112,11 @@ type ProcessConfig struct { EqTarget string DynNorm bool PhaseCheck bool + VideoAction string + // AllowIllegalRemux skips the container-compatibility check when + // VideoAction is "remux", letting ffmpeg attempt any format/container + // combination even ones it's likely to reject (e.g. FLAC into .m4v). + AllowIllegalRemux bool } // DynamicsAnalysis and FrequencyBandAnalysis are aliases for the public audio @@ -329,6 +334,7 @@ func (n *AudioNormalizer) sendLogReport() { func (n *AudioNormalizer) analyzeDynamics(inputPath string) *DynamicsAnalysis { cmd := ffmpeg.Command( "-i", inputPath, + "-vn", "-af", "astats=metadata=1:length=0.05", "-f", "null", "-", @@ -380,6 +386,7 @@ func (n *AudioNormalizer) analyzeFrequencyBands(inputPath string) map[string]*Fr cmd := exec.Command( ffmpegPath, "-i", inputPath, + "-vn", "-af", fmt.Sprintf("%s,astats", filter), "-f", "null", "-", @@ -869,7 +876,7 @@ func calculateMakeupGain(analysis *DynamicsAnalysis, threshold, ratio float64) f } func (n *AudioNormalizer) getDuration(inputPath string) (float64, error) { - cmd := ffmpeg.Command("-i", inputPath, "-f", "null", "-") + cmd := ffmpeg.Command("-i", inputPath, "-vn", "-f", "null", "-") output, _ := cmd.CombinedOutput() outputStr := string(output) @@ -986,6 +993,7 @@ type Preferences struct { SelectedTab string `json:"selected_tab"` PhaseCheck bool `json:"phase_check_auto"` TelemetryEnabled bool `json:"telemetry_enabled"` + AllowIllegalRemux bool `json:"allow_illegal_remux"` } func (n *AudioNormalizer) loadPreferences() { @@ -1018,6 +1026,7 @@ func (n *AudioNormalizer) loadPreferences() { n.dynNorm = prefs.DynNorm n.phaseCheck = prefs.PhaseCheck n.telemetryEnabled = prefs.TelemetryEnabled + n.allowIllegalRemux = prefs.AllowIllegalRemux } func (n *AudioNormalizer) savePreferences() { @@ -1040,6 +1049,7 @@ func (n *AudioNormalizer) savePreferences() { DynNorm: n.dynNorm, PhaseCheck: n.phaseCheck, TelemetryEnabled: n.telemetryEnabled, + AllowIllegalRemux: n.allowIllegalRemux, } configDir, _ := os.UserConfigDir() @@ -1059,11 +1069,17 @@ func (n *AudioNormalizer) resetPreferences() { n.appLog.Write("Preferences have been reset. Restart TNT to apply defaults.") } -func (n *AudioNormalizer) startWatching() { +func (n *AudioNormalizer) startWatching() bool { + if n.outputDir == "" { + n.appLog.Write("✗ No output folder is set — pick one before starting watch mode.") + n.logFile.Write("startWatching() aborted: outputDir is empty") + return false + } + n.watcherMutex.Lock() if n.watching { n.watcherMutex.Unlock() - return + return true } n.watching = true n.watcherStop = make(chan bool) @@ -1074,6 +1090,7 @@ func (n *AudioNormalizer) startWatching() { n.logFile.Write("started watching") go n.watchDirectory() go n.processWatchQueue() + return true } func (n *AudioNormalizer) stopWatching() { @@ -1110,7 +1127,7 @@ func (n *AudioNormalizer) watchDirectory() { for { select { case event := <-watcher.Events: - if event.Op&fsnotify.Create == fsnotify.Create && isAudioFile(event.Name) { + if event.Op&fsnotify.Create == fsnotify.Create && isMediaFile(event.Name) { select { case n.jobQueue <- event.Name: case <-n.watcherStop: @@ -1246,6 +1263,8 @@ func (n *AudioNormalizer) getProcessConfig() ProcessConfig { SampleRate: n.sampleRate, BitDepth: n.bitDepth, Bitrate: n.bitrate, + VideoAction: n.videoAction, + AllowIllegalRemux: n.allowIllegalRemux, } return config @@ -1254,6 +1273,13 @@ func (n *AudioNormalizer) getProcessConfig() ProcessConfig { func (n *AudioNormalizer) process() { n.emitProgress(0) + if n.outputDir == "" { + n.appLog.Write("✗ No output folder is set — pick one before processing.") + n.logFile.Write("process() aborted: outputDir is empty") + n.emitDone() + return + } + config := n.getProcessConfig() workers := runtime.NumCPU() - 1 @@ -1408,7 +1434,28 @@ func (n *AudioNormalizer) processFile(inputPath string, cfg ProcessConfig) bool outputPath = filepath.Join(outputDir, fmt.Sprintf("%s%s", baseName, ext)) } - n.appLog.Write(fmt.Sprintf("Processing: %s, outputting to %s", filepath.Base(inputPath), outputPath)) + // Video input with remux requested: the video track is kept, so the real + // deliverable takes the original container extension, not the audio + // codec's. The audio codec still gets encoded to outputPath first (now a + // temp file), then remuxed with the source video into finalOutputPath. + isVideo := isVideoFile(inputPath) + videoRemux := isVideo && cfg.VideoAction == "remux" + if videoRemux && !cfg.AllowIllegalRemux && !remuxCompatible(actualCodec, originalExt) { + n.appLog.Write(fmt.Sprintf("✗ Failed: %s - %s audio can't be muxed into a %s container; pick a different format, use \"drop\" video action, or enable \"Allow any format into any container\" in Preferences", filepath.Base(inputPath), cfg.Format, originalExt)) + n.logFile.Write(fmt.Sprintf("Remux precheck failed %s - incompatible format %s for container %s", inputPath, cfg.Format, originalExt)) + return false + } + var finalOutputPath string + if videoRemux { + finalOutputPath = strings.TrimSuffix(outputPath, filepath.Ext(outputPath)) + originalExt + remuxAudioTempPath := filepath.Join(os.TempDir(), fmt.Sprintf("tnt_remux_audio_%d%s", time.Now().UnixNano(), ext)) + tempFiles = append(tempFiles, remuxAudioTempPath) + outputPath = remuxAudioTempPath + } else { + finalOutputPath = outputPath + } + + n.appLog.Write(fmt.Sprintf("Processing: %s, outputting to %s", filepath.Base(inputPath), finalOutputPath)) var measured map[string]string @@ -1713,7 +1760,7 @@ func (n *AudioNormalizer) processFile(inputPath string, cfg ProcessConfig) bool var attenuatedPath string = workingPath if cfg.DynamicsPreset == "Broadcast" { // Quick peak check - cmd := ffmpeg.Command("-i", workingPath, "-af", "astats", "-f", "null", "-") + cmd := ffmpeg.Command("-i", workingPath, "-vn", "-af", "astats", "-f", "null", "-") output, _ := cmd.CombinedOutput() @@ -2049,6 +2096,31 @@ func (n *AudioNormalizer) processFile(inputPath string, cfg ProcessConfig) bool return false } + if videoRemux { + // finalOutputPath can be identical to inputPath (output dir == source + // dir, no .normalized/.tagged suffix applied). Muxing straight into + // finalOutputPath would then truncate the file ffmpeg is still + // reading as input 1. Mux to a same-directory temp file and rename + // it into place once ffmpeg is done reading, instead. + remuxTempPath := strings.TrimSuffix(finalOutputPath, originalExt) + ".tnttmp" + originalExt + remuxArgs := []string{"-i", outputPath, "-i", inputPath, "-map", "0:a", "-map", "1:v", "-c", "copy", "-y", remuxTempPath} + n.logFile.Write(fmt.Sprintf("Remux command: ffmpeg %s", strings.Join(remuxArgs, " "))) + remuxOutput, remuxErr := ffmpeg.RunCmd(ffmpeg.Command(remuxArgs...)) + n.logFile.Write(fmt.Sprintf("FFmpeg remux output: %s", string(remuxOutput))) + if remuxErr != nil { + n.appLog.Write(fmt.Sprintf("✗ Remux failed: %s - %v", filepath.Base(inputPath), remuxErr)) + n.logFile.Write(fmt.Sprintf("Remux failed %s - %v", filepath.Base(inputPath), remuxErr)) + os.Remove(remuxTempPath) + return false + } + if err := os.Rename(remuxTempPath, finalOutputPath); err != nil { + n.appLog.Write(fmt.Sprintf("✗ Remux failed: %s - could not finalize output: %v", filepath.Base(inputPath), err)) + n.logFile.Write(fmt.Sprintf("Remux rename failed %s - %v", filepath.Base(inputPath), err)) + os.Remove(remuxTempPath) + return false + } + } + if cfg.BitDepth != "" { n.logFile.Write(fmt.Sprintf("cfg.Bitdepth= %s", cfg.BitDepth)) } @@ -2130,6 +2202,7 @@ func (n *AudioNormalizer) measureLoudnessEbuR128(inputPath string) map[string]st cmd := exec.Command( ffmpegPath, "-i", inputPath, + "-vn", "-af", "ebur128=framelog=quiet:peak=true", "-f", "null", "-", @@ -2158,6 +2231,17 @@ func isAudioFile(path string) bool { return false } +func isVideoFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + videoExts := []string{".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v", ".mpg", ".mpeg", ".wmv", ".flv", ".ts", ".3gp"} + + return slices.Contains(videoExts, ext) +} + +func isMediaFile(path string) bool { + return isAudioFile(path) || isVideoFile(path) +} + func cleanupTempFiles(files []string) { for _, file := range files { if err := os.Remove(file); err != nil { diff --git a/go/remux.go b/go/remux.go new file mode 100644 index 0000000..818bda2 --- /dev/null +++ b/go/remux.go @@ -0,0 +1,70 @@ +package main + +import "strings" + +// remuxAudioFamily normalizes an actualCodec ffmpeg encoder/codec name (as +// resolved from cfg.Format via platformCodecMap/config.GetCodec) into the +// coarse audio family used to check remux container compatibility. +func remuxAudioFamily(actualCodec string) string { + switch actualCodec { + case "libopus": + return "opus" + case "libfdk_aac", "aac", "aac_at": + return "aac" + case "libmp3lame": + return "mp3" + case "PCM": + return "pcm" + case "flac": + return "flac" + default: + return "" + } +} + +// remuxContainerAudioFamilies lists, per video container extension +// (lowercase, with leading dot), which audio families ffmpeg's muxer will +// accept in a stream-copy ("-c copy") remux. Verified empirically against +// ffmpeg (n7.1.5) for every extension isVideoFile() recognizes: +// +// - mp4/mkv/ts are permissive containers and accept all five. +// - mov and the "ipod" muxer (.m4v) and 3gp are QuickTime/MPEG-4 family +// but each has a narrower codec tag table than plain mp4. +// - webm (matroska/webm muxer restricted to webm profile) only allows +// Vorbis or Opus audio - none of our other codecs are legal there. +// - mpg/mpeg (the "mpeg" program-stream muxer) only accepts mp1/mp2/mp3 +// (and pcm_dvd/pcm_s16be, which we don't produce). +// - flv rejects FLAC outright and rejects Opus at the 48kHz rate our +// encoder uses. +var remuxContainerAudioFamilies = map[string]map[string]bool{ + ".mp4": {"opus": true, "aac": true, "mp3": true, "pcm": true, "flac": true}, + ".mkv": {"opus": true, "aac": true, "mp3": true, "pcm": true, "flac": true}, + ".ts": {"opus": true, "aac": true, "mp3": true, "pcm": true, "flac": true}, + ".mov": {"aac": true, "mp3": true, "pcm": true}, + ".webm": {"opus": true}, + ".avi": {"aac": true, "mp3": true, "pcm": true, "flac": true}, + ".wmv": {"aac": true, "mp3": true, "pcm": true, "flac": true}, + ".flv": {"aac": true, "mp3": true, "pcm": true}, + ".m4v": {"aac": true}, + ".3gp": {"aac": true}, + ".mpg": {"mp3": true}, + ".mpeg": {"mp3": true}, +} + +// remuxCompatible reports whether actualCodec can be stream-copied into a +// container with the given file extension (e.g. ".mp4") when muxing the +// encoded audio back with the original video. An unrecognized codec or +// extension is allowed through rather than blocked, since the table isn't +// meant to be exhaustive beyond our own output formats and isVideoFile()'s +// extension list. +func remuxCompatible(actualCodec, containerExt string) bool { + family := remuxAudioFamily(actualCodec) + if family == "" { + return true + } + allowed, ok := remuxContainerAudioFamilies[strings.ToLower(containerExt)] + if !ok { + return true + } + return allowed[family] +} diff --git a/go/remux_test.go b/go/remux_test.go new file mode 100644 index 0000000..370cc48 --- /dev/null +++ b/go/remux_test.go @@ -0,0 +1,57 @@ +package main + +import "testing" + +func TestRemuxCompatible(t *testing.T) { + cases := []struct { + codec, ext string + want bool + }{ + // PCM into MP4/MOV/MKV is fine, but not into the stricter + // QuickTime-family and MPEG variants. + {"PCM", ".mp4", true}, + {"PCM", ".mov", true}, + {"PCM", ".mkv", true}, + {"PCM", ".m4v", false}, + {"PCM", ".3gp", false}, + {"PCM", ".mpg", false}, + {"PCM", ".mpeg", false}, + + // Opus is MP4/MKV/TS/WebM only. + {"libopus", ".mp4", true}, + {"libopus", ".webm", true}, + {"libopus", ".mov", false}, + {"libopus", ".avi", false}, + {"libopus", ".flv", false}, + + // FLAC works broadly except mov/webm/m4v/3gp/mpg/flv. + {"flac", ".mp4", true}, + {"flac", ".mkv", true}, + {"flac", ".mov", false}, + {"flac", ".webm", false}, + {"flac", ".flv", false}, + + // AAC is accepted almost everywhere, including webm's exception + // list, which is opus/vorbis-only. + {"libfdk_aac", ".mp4", true}, + {"aac_at", ".m4v", true}, + {"aac", ".3gp", true}, + {"libfdk_aac", ".webm", false}, + + // MP3 covers the legacy mpg/mpeg program-stream muxer that + // nothing else in our codec set is allowed into. + {"libmp3lame", ".mpg", true}, + {"libmp3lame", ".mpeg", true}, + {"libmp3lame", ".webm", false}, + + // Unrecognized codec/extension inputs are let through rather + // than blocked. + {"unknown_codec", ".mp4", true}, + {"PCM", ".unknownext", true}, + } + for _, c := range cases { + if got := remuxCompatible(c.codec, c.ext); got != c.want { + t.Errorf("remuxCompatible(%q, %q) = %v, want %v", c.codec, c.ext, got, c.want) + } + } +}