Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
Expand Down Expand Up @@ -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 | |
Expand All @@ -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.
2 changes: 2 additions & 0 deletions go/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ type AudioNormalizer struct {
phaseCheck bool
simplePreset string
multibandFilter string
videoAction string
allowIllegalRemux bool

watching bool
watcherStop chan bool
Expand Down
1 change: 1 addition & 0 deletions go/audio/dynscore.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ func CalculateDynamicsScore(ffmpegPath, inputPath string) (*DynamicsScoreAnalysi
cmd := exec.Command(
ffmpegPath,
"-i", inputPath,
"-vn",
"-af", "astats",
"-f", "null",
"-",
Expand Down
1 change: 1 addition & 0 deletions go/audio/freq.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func AnalyzeFrequencyResponseBands(ffmpegPath, inputPath string) ([]FrequencyBan
cmd := exec.Command(
ffmpegPath,
"-i", inputPath,
"-vn",
"-af", filterChain,
"-f", "null",
"-",
Expand Down
2 changes: 1 addition & 1 deletion go/audio/phase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 13 additions & 5 deletions go/bindings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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() {
Expand Down Expand Up @@ -209,6 +215,7 @@ func (n *AudioNormalizer) LoadPreferences() Preferences {
DynNorm: n.dynNorm,
PhaseCheck: n.phaseCheck,
TelemetryEnabled: n.telemetryEnabled,
AllowIllegalRemux: n.allowIllegalRemux,
}
}

Expand All @@ -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)
}
Expand Down
74 changes: 66 additions & 8 deletions go/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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()))
}
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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.]+)`)
Expand Down Expand Up @@ -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
}

Expand All @@ -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))
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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", "-")

Expand All @@ -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", "-")

Expand Down
Loading
Loading