From d0140f6c6c49f26be3dcafab662ece86298f8edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=20S=C3=A4ppi?= Date: Sat, 30 May 2026 22:18:13 +0300 Subject: [PATCH 01/32] enable shibata --- go/cli.go | 4 ++-- go/main.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go/cli.go b/go/cli.go index f3895a4..a40a1f6 100644 --- a/go/cli.go +++ b/go/cli.go @@ -815,9 +815,9 @@ func (p *CLIProcessor) processFile(inputPath string) bool { // Dithering for 16-bit PCM if actualCodec == "PCM" && cfg.BitDepth == "16" { if finalFilterChain != "" { - finalFilterChain += ",aresample=resampler=soxr:dither_method=triangular" + finalFilterChain += ",aresample=resampler=soxr:dither_method=high_shibata" } else { - finalFilterChain = "aresample=resampler=soxr:dither_method=triangular" + finalFilterChain = "aresample=resampler=soxr:dither_method=high_shibata" } } diff --git a/go/main.go b/go/main.go index 018ec43..10c8efb 100644 --- a/go/main.go +++ b/go/main.go @@ -1979,9 +1979,9 @@ func (n *AudioNormalizer) processFile(inputPath string, cfg ProcessConfig) bool // Add dithering for 16-bit PCM output if actualCodec == "PCM" && cfg.BitDepth == "16" { if finalFilterChain != "" { - finalFilterChain = finalFilterChain + ",aresample=resampler=soxr:dither_method=triangular" + finalFilterChain = finalFilterChain + ",aresample=resampler=soxr:dither_method=high_shibata" } else { - finalFilterChain = "aresample=resampler=soxr:dither_method=triangular" + finalFilterChain = "aresample=resampler=soxr:dither_method=high_shibata" } } From 3822a571d0a27f8a2a8a4097eb1e611511c31152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=20S=C3=A4ppi?= Date: Sat, 30 May 2026 22:55:06 +0300 Subject: [PATCH 02/32] allow audio module to be imported (in progress) --- go/{internal => }/audio/audio_test.go | 0 go/{internal => }/audio/compression.go | 22 +- go/{internal => }/audio/dynaudnorm.go | 13 +- go/audio/dynscore.go | 26 + go/audio/freq.go | 494 +++++++++++++++ go/{internal => }/audio/parsing.go | 8 +- .../audio/phase_check.go => audio/phase.go} | 21 +- go/audio/types.go | 62 ++ go/cli.go | 6 +- go/dyn_norm.go | 71 +-- go/dynscore.go | 126 +--- go/freq_anal.go | 571 +----------------- go/internal/audio/types.go | 44 -- go/main.go | 24 +- 14 files changed, 667 insertions(+), 821 deletions(-) rename go/{internal => }/audio/audio_test.go (100%) rename go/{internal => }/audio/compression.go (80%) rename go/{internal => }/audio/dynaudnorm.go (74%) create mode 100644 go/audio/dynscore.go create mode 100644 go/audio/freq.go rename go/{internal => }/audio/parsing.go (97%) rename go/{internal/audio/phase_check.go => audio/phase.go} (69%) create mode 100644 go/audio/types.go delete mode 100644 go/internal/audio/types.go diff --git a/go/internal/audio/audio_test.go b/go/audio/audio_test.go similarity index 100% rename from go/internal/audio/audio_test.go rename to go/audio/audio_test.go diff --git a/go/internal/audio/compression.go b/go/audio/compression.go similarity index 80% rename from go/internal/audio/compression.go rename to go/audio/compression.go index 7207aff..6d56479 100644 --- a/go/internal/audio/compression.go +++ b/go/audio/compression.go @@ -2,7 +2,8 @@ package audio import "math" -// GetCompressionModifiers returns compression parameter multipliers based on Dynamics Score +// GetCompressionModifiers returns compression parameter multipliers based on +// the Dynamics Score. func GetCompressionModifiers(ds float64) CompressionModifiers { mods := CompressionModifiers{ AttackMultiplier: 1.0, @@ -40,7 +41,7 @@ func GetCompressionModifiers(ds float64) CompressionModifiers { return mods } -// GetBaseRatioFromCrest returns base compression ratio based on crest factor +// GetBaseRatioFromCrest returns the base compression ratio based on crest factor. func GetBaseRatioFromCrest(crest float64) float64 { if crest <= 3.0 { return 1.4 @@ -56,7 +57,8 @@ func GetBaseRatioFromCrest(crest float64) float64 { } } -// CalculateMakeupGain computes makeup gain based on expected gain reduction +// CalculateMakeupGain computes makeup gain (linear) based on expected gain +// reduction for the given threshold (dB) and ratio. func CalculateMakeupGain(analysis *DynamicsAnalysis, thresholdDb float64, ratio float64) float64 { if analysis == nil || ratio <= 1.0 { return 1.0 @@ -76,12 +78,14 @@ func CalculateMakeupGain(analysis *DynamicsAnalysis, thresholdDb float64, ratio return 1.0 } -// Clamp returns the clamped value +// clamp returns x constrained to the inclusive range [lo, hi]. func clamp(x, lo, hi float64) float64 { return max(lo, min(hi, x)) } -// ClampCompressorParams ensures all compressor parameters are within valid ranges +// ClampCompressorParams ensures all compressor parameters are within valid +// ranges, returning the clamped threshold (linear), ratio, attack (ms), +// release (ms), and makeup gain (linear). func ClampCompressorParams(thresholdLin, ratio, attackMs, releaseMs, makeupLin float64) (float64, float64, float64, float64, float64) { thresholdLin = clamp(thresholdLin, 0.00097563, 1.) // -60dB to 0dB @@ -94,7 +98,8 @@ func ClampCompressorParams(thresholdLin, ratio, attackMs, releaseMs, makeupLin f } -// GetKneeFromRatio returns appropriate knee value based on compression ratio +// GetKneeFromRatio returns an appropriate knee value based on the compression +// ratio. func GetKneeFromRatio(ratio float64) float64 { switch { case ratio < 1.: @@ -114,12 +119,13 @@ func GetKneeFromRatio(ratio float64) float64 { } } -// DbToLinear converts decibels to linear amplitude +// DbToLinear converts decibels to linear amplitude. func DbToLinear(db float64) float64 { return math.Pow(10, db/20) } -// LinearToDb converts linear amplitude to decibels +// LinearToDb converts linear amplitude to decibels. Non-positive input returns +// -100.0. func LinearToDb(linear float64) float64 { if linear <= 0 { return -100.0 diff --git a/go/internal/audio/dynaudnorm.go b/go/audio/dynaudnorm.go similarity index 74% rename from go/internal/audio/dynaudnorm.go rename to go/audio/dynaudnorm.go index 63c5d4f..c30f088 100644 --- a/go/internal/audio/dynaudnorm.go +++ b/go/audio/dynaudnorm.go @@ -5,7 +5,8 @@ import ( "math" ) -// CalculateDynaudnormParams creates dynaudnorm parameters from dynamics analysis +// CalculateDynaudnormParams creates dynaudnorm parameters from dynamics +// analysis. Returns nil if analysis is nil. func CalculateDynaudnormParams(analysis *DynamicsAnalysis) *DynaudnormParams { if analysis == nil { return nil @@ -41,7 +42,15 @@ func CalculateDynaudnormParams(analysis *DynamicsAnalysis) *DynaudnormParams { return params } -// BuildDynaudnormFilter creates the dynaudnorm filter string from params +// AnalyzeDynaudnormParams derives dynaudnorm parameters from an existing +// dynamics analysis. It is the canonical name used by the TNT app and is +// equivalent to CalculateDynaudnormParams. +func AnalyzeDynaudnormParams(analysis *DynamicsAnalysis) *DynaudnormParams { + return CalculateDynaudnormParams(analysis) +} + +// BuildDynaudnormFilter creates the dynaudnorm filter string from params. +// Returns an empty string if params is nil. func BuildDynaudnormFilter(params *DynaudnormParams) string { if params == nil { return "" diff --git a/go/audio/dynscore.go b/go/audio/dynscore.go new file mode 100644 index 0000000..bbb9db1 --- /dev/null +++ b/go/audio/dynscore.go @@ -0,0 +1,26 @@ +package audio + +import ( + "fmt" + "os/exec" +) + +// CalculateDynamicsScore runs ffmpeg's astats filter over inputPath and returns +// the parsed Dynamics Score analysis. ffmpegPath is the path to the ffmpeg +// binary. +func CalculateDynamicsScore(ffmpegPath, inputPath string) (*DynamicsScoreAnalysis, error) { + cmd := exec.Command( + ffmpegPath, + "-i", inputPath, + "-af", "astats", + "-f", "null", + "-", + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("ffmpeg astats failed: %w", err) + } + + return ParseDynamicsScore(string(output)), nil +} diff --git a/go/audio/freq.go b/go/audio/freq.go new file mode 100644 index 0000000..fc233a6 --- /dev/null +++ b/go/audio/freq.go @@ -0,0 +1,494 @@ +package audio + +import ( + "fmt" + "math" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// AnalyzeFrequencyResponseBands analyzes the frequency response across 10 bands +// using lowpass, bandpass, and highpass filters with astats. It runs one ffmpeg +// pass per band itself; ffmpegPath is the path to the ffmpeg binary. +func AnalyzeFrequencyResponseBands(ffmpegPath, inputPath string) ([]FrequencyBand, error) { + bands := []FrequencyBand{ + {Frequency: "50Hz", FilterType: "lowpass"}, + {Frequency: "100Hz", FilterType: "bandpass"}, + {Frequency: "200Hz", FilterType: "bandpass"}, + {Frequency: "400Hz", FilterType: "bandpass"}, + {Frequency: "800Hz", FilterType: "bandpass"}, + {Frequency: "1.6kHz", FilterType: "bandpass"}, + {Frequency: "3.2kHz", FilterType: "bandpass"}, + {Frequency: "6.4kHz", FilterType: "bandpass"}, + {Frequency: "12.8kHz", FilterType: "bandpass"}, + {Frequency: "12.8kHz+", FilterType: "highpass"}, + } + + for i := range bands { + band := &bands[i] + + var filterChain string + switch band.FilterType { + case "lowpass": + // Everything below 50Hz + filterChain = "highpass=f=25:p=1:r=f64:p=2,lowpass=f=50,astats" + + case "highpass": + // Everything above 12.8kHz + filterChain = "highpass=f=12800,astats" + + case "bandpass": + // Extract center frequency and build a 1-octave bandpass + centerFreq, _ := getBandpassParams(band.Frequency) + filterChain = fmt.Sprintf("bandpass=f=%d:width_type=o:width=1,astats", centerFreq) + } + + cmd := exec.Command( + ffmpegPath, + "-i", inputPath, + "-af", filterChain, + "-f", "null", + "-", + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("ffmpeg analysis of %s band failed: %w", band.Frequency, err) + } + + // Parse astats output for this band + stats := parseFrequencyBandStats(string(output)) + band.RMSLevel = stats["rms"] + band.PeakLevel = stats["peak"] + band.CrestFactor = stats["crest"] + } + + return bands, nil +} + +// getBandpassParams returns the center frequency (Hz) and 1-octave bandwidth +// (Hz) for a named bandpass band. +func getBandpassParams(freqStr string) (int, float64) { + // Map frequency strings to actual Hz values + freqMap := map[string]int{ + "100Hz": 100, + "200Hz": 200, + "400Hz": 400, + "800Hz": 800, + "1.6kHz": 1600, + "3.2kHz": 3200, + "6.4kHz": 6400, + "12.8kHz": 12800, + } + + centerFreq := freqMap[freqStr] + + // 1 octave bandwidth means bandwidth = centerFreq (from lower -1/2 octave to upper +1/2 octave) + // But for bandpass filter with width_type=o (octave), we specify width=1 for 1 octave + bandwidth := float64(centerFreq) // Full octave bandwidth in Hz + + return centerFreq, bandwidth +} + +// parseFrequencyBandStats extracts RMS, peak, and crest factor from astats output. +func parseFrequencyBandStats(output string) map[string]float64 { + stats := make(map[string]float64) + + // Parse RMS level (dB), e.g. "RMS level dB: -23.45" + rmsRe := regexp.MustCompile(`RMS level dB:\s+([-\d.]+)`) + if match := rmsRe.FindStringSubmatch(output); len(match) > 1 { + if val, err := strconv.ParseFloat(match[1], 64); err == nil { + stats["rms"] = val + } + } + + // Parse Peak level (dB), e.g. "Peak level dB: -12.34" + peakRe := regexp.MustCompile(`Peak level dB:\s+([-\d.]+)`) + if match := peakRe.FindStringSubmatch(output); len(match) > 1 { + if val, err := strconv.ParseFloat(match[1], 64); err == nil { + stats["peak"] = val + } + } + + // Parse Crest factor (ratio, not dB), e.g. "Crest factor: 2.858335" + crestRe := regexp.MustCompile(`Crest factor:\s+([-\d.]+)`) + if match := crestRe.FindStringSubmatch(output); len(match) > 1 { + if val, err := strconv.ParseFloat(match[1], 64); err == nil { + stats["crest"] = val + } + } + + return stats +} + +// clampExtremeEQ applies restrictions to low-shelf and high-shelf EQ values +// based on the non-extreme band values: +// 1. Extreme-to-extreme difference <= 4 dB +// 2. Extreme-to-neighbor difference <= 4 dB +// 3. Neither extreme exceeds the average of non-extremes in magnitude +func clampExtremeEQ(gains []float64) []float64 { + if len(gains) < 3 { + return gains // Need at least 3 bands (2 extremes + 1 middle) + } + + clamped := make([]float64, len(gains)) + copy(clamped, gains) + + lowShelf := gains[0] // First band (sub 100Hz) + highShelf := gains[len(gains)-1] // Last band (above 12.8kHz) + + // Calculate average of non-extremes + var sum float64 + for i := 1; i < len(gains)-1; i++ { + sum += gains[i] + } + avgNonExtremes := sum / float64(len(gains)-2) + + // First neighbor (band index 1) + firstNeighbor := gains[1] + // Last neighbor (band index len-2) + lastNeighbor := gains[len(gains)-2] + + // Clamp low shelf + // Rule 1: Extreme-to-extreme + maxLowFromHigh := highShelf + 4.0 + minLowFromHigh := highShelf - 4.0 + + // Rule 2: Extreme-to-neighbor + maxLowFromNeighbor := firstNeighbor + 4.0 + minLowFromNeighbor := firstNeighbor - 4.0 + + // Rule 3: Extreme-to-average + var maxLowFromAvg, minLowFromAvg float64 + if avgNonExtremes >= 0 { + maxLowFromAvg = avgNonExtremes + minLowFromAvg = -999.0 // No lower limit when avg is positive + } else { + maxLowFromAvg = 999.0 // No upper limit when avg is negative + minLowFromAvg = avgNonExtremes + } + + // Take most restrictive limits + maxLow := math.Min(math.Min(maxLowFromHigh, maxLowFromNeighbor), maxLowFromAvg) + minLow := math.Max(math.Max(minLowFromHigh, minLowFromNeighbor), minLowFromAvg) + + if lowShelf > maxLow { + clamped[0] = maxLow + } else if lowShelf < minLow { + clamped[0] = minLow + } + + // Clamp high shelf (same logic, inverted) + maxHighFromLow := clamped[0] + 4.0 // Use clamped low value + minHighFromLow := clamped[0] - 4.0 + + maxHighFromNeighbor := lastNeighbor + 4.0 + minHighFromNeighbor := lastNeighbor - 4.0 + + var maxHighFromAvg, minHighFromAvg float64 + if avgNonExtremes >= 0 { + maxHighFromAvg = avgNonExtremes + minHighFromAvg = -999.0 + } else { + maxHighFromAvg = 999.0 + minHighFromAvg = avgNonExtremes + } + + maxHigh := math.Min(math.Min(maxHighFromLow, maxHighFromNeighbor), maxHighFromAvg) + minHigh := math.Max(math.Max(minHighFromLow, minHighFromNeighbor), minHighFromAvg) + + if highShelf > maxHigh { + clamped[len(clamped)-1] = maxHigh + } else if highShelf < minHigh { + clamped[len(clamped)-1] = minHigh + } + + return clamped +} + +// BuildEqFilter creates an ffmpeg EQ filter chain based on frequency response +// analysis for the given EQ target ("Flat", "Speech", "Broadcast", or "Off"). +// Returns an empty string when no adjustment is needed. +func BuildEqFilter(bands []FrequencyBand, eqTarget string) string { + if len(bands) == 0 || eqTarget == "Off" { + return "" + } + + // Define high-pass and low-pass filters per preset + var highpassFilter, lowpassFilter string + + switch eqTarget { + case "Flat": + highpassFilter = "highpass=f=25:p=2" + lowpassFilter = "" // No lowpass for Flat + case "Speech": + highpassFilter = "highpass=f=80:p=2" + lowpassFilter = "lowpass=f=13000:p=1" + case "Broadcast": + highpassFilter = "highpass=f=70:p=2" + lowpassFilter = "lowpass=f=14000:p=2" + default: + highpassFilter = "" + lowpassFilter = "" + } + + // Calculate target curve + targetLevels := CalculateTargetCurve(bands, eqTarget) + + // Build filter chain using bass/highshelf for extremes and anequalizer for middle + var filterParts []string + + // Collect all gains first + gains := make([]float64, len(bands)) + + for i, band := range bands { + targetLevel := targetLevels[i] + gain := targetLevel - band.RMSLevel + + const maxGain = 10.0 + if gain > maxGain { + gain = maxGain + } else if gain < -maxGain { + gain = -maxGain + } + + gains[i] = gain + } + + // Apply extreme band constraints + gains = clampExtremeEQ(gains) + + // Build filters using clamped gains + for i, band := range bands { + gain := gains[i] + + if gain > -0.5 && gain < 0.5 { + continue + } + + switch band.FilterType { + case "lowpass": + filterParts = append(filterParts, fmt.Sprintf("highpass=f=25:p=1:r=f64:p=2,lowshelf=f=50:g=%.2f:width_type=q:width=0.7", gain)) + case "highpass": + filterParts = append(filterParts, fmt.Sprintf("lowpass=f=17500:p=2:r=f64,highshelf=f=12800:g=%.2f:width_type=q:width=0.7", gain)) + case "bandpass": + centerFreq, bandwidth := getBandpassParams(band.Frequency) + filterParts = append(filterParts, fmt.Sprintf("anequalizer=c0 f=%d w=%.0f g=%.2f t=0|c1 f=%d w=%.0f g=%.2f t=0", + centerFreq, bandwidth, gain, centerFreq, bandwidth, gain)) + } + } + + // Build final chain with HPF, EQ bands, LPF + var finalParts []string + + if highpassFilter != "" { + finalParts = append(finalParts, highpassFilter) + } + + finalParts = append(finalParts, filterParts...) + + if lowpassFilter != "" { + finalParts = append(finalParts, lowpassFilter) + } + + if len(finalParts) == 0 { + return "" + } + + // Join all filter parts with commas + return strings.Join(finalParts, ",") +} + +// CalculateTargetCurve determines target RMS levels (dB) for each band based on +// the EQ target ("Flat", "Speech", "Broadcast"; any other value yields the +// measured levels unchanged). +func CalculateTargetCurve(bands []FrequencyBand, eqTarget string) []float64 { + targets := make([]float64, len(bands)) + + // Calculate overall average RMS across all bands + var overallRMS float64 + for _, band := range bands { + overallRMS += band.RMSLevel + } + overallRMS = overallRMS / float64(len(bands)) + + switch eqTarget { + case "Flat": + // Flat: Attenuate anything above pink noise curve + // Pink noise: -3 dB per octave rise (reference at 1kHz) + // Use overall RMS as base, adjust per octave from 1kHz + + for i, band := range bands { + // Calculate pink noise reference level for this band + pinkNoiseRef := overallRMS + + // If measured level exceeds reference, attenuate + if band.RMSLevel > pinkNoiseRef { + excess := band.RMSLevel - pinkNoiseRef + // Apply 2:1 ratio + attenuation := excess / 2.0 + // Limit to -10 dB max + if attenuation > 10.0 { + attenuation = 10.0 + } + targets[i] = band.RMSLevel - attenuation + } else { + // Below curve, leave it alone + targets[i] = band.RMSLevel + } + } + + case "Speech": + // Speech: Optimize for intelligibility (relative to pink noise curve) + + for i, band := range bands { + octavesFrom1k := getOctavesFrom1k(band.Frequency) + pinkNoiseRef := overallRMS - (octavesFrom1k * 3.0) + + // Target adjustments relative to pink noise + var adjustment float64 + switch band.Frequency { + case "50Hz": + adjustment = -9.0 + case "100Hz": + adjustment = -3.5 + case "200Hz": + adjustment = -2.5 + case "400Hz": + adjustment = -3.0 + case "800Hz": + adjustment = +0.5 + case "1.6kHz": + adjustment = +3.0 + case "3.2kHz": + adjustment = +1.0 + case "6.4kHz": + adjustment = +0.0 + case "12.8kHz": + adjustment = -2.0 + case "12.8kHz+": + adjustment = -2.0 + default: + adjustment = 0.0 + } + + targetLevel := pinkNoiseRef + adjustment + deviation := band.RMSLevel - targetLevel + + // Apply 2:1 ratio + correction := deviation / 2.0 + + // Limit correction to ±10 dB + if correction > 10.0 { + correction = 10.0 + } else if correction < -10.0 { + correction = -10.0 + } + + // Skip tiny adjustments + if correction > -0.5 && correction < 0.5 { + targets[i] = band.RMSLevel + continue + } + + targets[i] = band.RMSLevel - correction + } + + case "Broadcast": + // Broadcast: Aggressive clarity for small speakers/phones (relative to + // pink noise curve) + + for i, band := range bands { + octavesFrom1k := getOctavesFrom1k(band.Frequency) + pinkNoiseRef := overallRMS - (octavesFrom1k * 3.0) + + // Target adjustments relative to pink noise + var adjustment float64 + switch band.Frequency { + case "50Hz": + adjustment = -2.0 + case "100Hz": + adjustment = -1.0 + case "200Hz": + adjustment = -2.5 + case "400Hz": + adjustment = -4.5 + case "800Hz": + adjustment = +1.0 + case "1.6kHz": + adjustment = +2.5 + case "3.2kHz": + adjustment = +3.5 + case "6.4kHz": + adjustment = +2.0 + case "12.8kHz": + adjustment = -0.5 + case "12.8kHz+": + adjustment = -2.5 + default: + adjustment = 0.0 + } + + targetLevel := pinkNoiseRef + adjustment + deviation := band.RMSLevel - targetLevel + + // Apply 2:1 ratio + correction := deviation / 2.0 + + // Limit correction to ±10 dB + if correction > 10.0 { + correction = 10.0 + } else if correction < -10.0 { + correction = -10.0 + } + + // Skip tiny adjustments + if correction > -0.5 && correction < 0.5 { + targets[i] = band.RMSLevel + continue + } + + targets[i] = band.RMSLevel - correction + } + + default: + // No EQ + for i, band := range bands { + targets[i] = band.RMSLevel + } + } + + return targets +} + +// getOctavesFrom1k returns the number of octaves from 1kHz for a given +// frequency band. +func getOctavesFrom1k(freqStr string) float64 { + // Reference: 1kHz = 0 octaves; octaves = log2(freq / 1000) + switch freqStr { + case "50Hz": + return -4.32 // log2(50/1000) + case "100Hz": + return -3.32 // log2(100/1000) + case "200Hz": + return -2.32 // log2(200/1000) + case "400Hz": + return -1.32 // log2(400/1000) + case "800Hz": + return -0.32 // log2(800/1000) + case "1.6kHz": + return 0.68 // log2(1600/1000) + case "3.2kHz": + return 1.68 // log2(3200/1000) + case "6.4kHz": + return 2.68 // log2(6400/1000) + case "12.8kHz": + return 3.68 // log2(12800/1000) + case "12.8kHz+": + return 5.0 // Approximate for >12.8kHz + default: + return 0.0 + } +} diff --git a/go/internal/audio/parsing.go b/go/audio/parsing.go similarity index 97% rename from go/internal/audio/parsing.go rename to go/audio/parsing.go index 21eb880..da5ebf7 100644 --- a/go/internal/audio/parsing.go +++ b/go/audio/parsing.go @@ -7,7 +7,7 @@ import ( "strings" ) -// ParseAstatsOutput parses FFmpeg astats filter output into DynamicsAnalysis +// ParseAstatsOutput parses FFmpeg astats filter output into DynamicsAnalysis. func ParseAstatsOutput(output string) *DynamicsAnalysis { result := &DynamicsAnalysis{} @@ -62,7 +62,7 @@ func ParseAstatsOutput(output string) *DynamicsAnalysis { return result } -// ParseDynamicsScore parses astats output for Dynamics Score calculation +// ParseDynamicsScore parses astats output for Dynamics Score calculation. func ParseDynamicsScore(output string) *DynamicsScoreAnalysis { result := &DynamicsScoreAnalysis{} @@ -107,7 +107,7 @@ func ParseDynamicsScore(output string) *DynamicsScoreAnalysis { return result } -// ParseFrequencyBandOutput parses astats output for a single frequency band +// ParseFrequencyBandOutput parses astats output for a single frequency band. func ParseFrequencyBandOutput(output string, bandName string) *FrequencyBandAnalysis { result := &FrequencyBandAnalysis{BandName: bandName} @@ -145,7 +145,7 @@ func ParseFrequencyBandOutput(output string, bandName string) *FrequencyBandAnal return result } -// FrequencyBandFilters returns the filter strings for each frequency band +// FrequencyBandFilters returns the ffmpeg filter strings for each frequency band. func FrequencyBandFilters() map[string]string { return map[string]string{ "sub": "lowpass=f=80", diff --git a/go/internal/audio/phase_check.go b/go/audio/phase.go similarity index 69% rename from go/internal/audio/phase_check.go rename to go/audio/phase.go index bdf9873..fafb651 100644 --- a/go/internal/audio/phase_check.go +++ b/go/audio/phase.go @@ -2,15 +2,17 @@ package audio import ( "fmt" - "github.com/fremen-fi/tnt/go/internal/ffmpeg" "math" - "os" + "os/exec" "regexp" "strconv" ) -func PhaseCheck(inputPath string, logFile *os.File) (inverted bool, offset float64, err error) { - output, err := buildPhaseCheck(inputPath, logFile) +// PhaseCheck runs an astats pass over the first 30 seconds of inputPath and +// reports whether the two channels appear phase-inverted, along with the +// computed phase offset. ffmpegPath is the path to the ffmpeg binary. +func PhaseCheck(ffmpegPath, inputPath string) (inverted bool, offset float64, err error) { + output, err := runPhaseCheck(ffmpegPath, inputPath) if err != nil { return false, 0, err } @@ -21,19 +23,16 @@ func PhaseCheck(inputPath string, logFile *os.File) (inverted bool, offset float } offset = calculatePhaseOffset(ch1Min, ch1Max, ch2Min, ch2Max) - inverted = offset < 0.01 // or whatever threshold you want + inverted = offset < 0.01 return inverted, offset, nil } -func buildPhaseCheck(inputPath string, logFile *os.File) (string, error) { - cmd := ffmpeg.Command("-i", inputPath, "-t", "30", "-af", "astats", "-f", "null", "-") +func runPhaseCheck(ffmpegPath, inputPath string) (string, error) { + cmd := exec.Command(ffmpegPath, "-i", inputPath, "-t", "30", "-af", "astats", "-f", "null", "-") output, err := cmd.CombinedOutput() if err != nil { - if logFile != nil { - logFile.WriteString(fmt.Sprintf("astats failed: %v\n", err)) - } - return "", err + return "", fmt.Errorf("ffmpeg astats failed: %w", err) } return string(output), nil } diff --git a/go/audio/types.go b/go/audio/types.go new file mode 100644 index 0000000..5ba417e --- /dev/null +++ b/go/audio/types.go @@ -0,0 +1,62 @@ +// Package audio provides TNT's reusable audio analysis and processing logic: +// Dynamic Score calculation, DS-driven compression parameter derivation, +// 10-band frequency analysis and EQ filter building, dynaudnorm parameter +// calculation, and a stereo phase check. +// +// Functions that invoke ffmpeg take the path to the ffmpeg binary as their +// first argument; nothing in this package hardcodes "ffmpeg" or depends on +// TNT-internal packages. The package is stdlib-only and builds with +// CGO_ENABLED=0. +package audio + +// DynamicsAnalysis holds the results of astats analysis for a file. +type DynamicsAnalysis struct { + PeakLevel float64 + RMSPeak float64 + RMSTrough float64 + CrestFactor float64 + DynamicRange float64 + RMSLevel float64 + NoiseFloor float64 +} + +// FrequencyBandAnalysis holds per-band analysis for multiband compression. +type FrequencyBandAnalysis struct { + BandName string + PeakLevel float64 + RMSLevel float64 + CrestFactor float64 + DynamicRange float64 +} + +// DynamicsScoreAnalysis holds the calculated Dynamics Score. +type DynamicsScoreAnalysis struct { + RMSPeak float64 + RMSLevel float64 + CrestFactor float64 + DynamicsScore float64 +} + +// CompressionModifiers holds multipliers applied based on Dynamics Score. +type CompressionModifiers struct { + AttackMultiplier float64 + ReleaseMultiplier float64 + RatioMultiplier float64 +} + +// DynaudnormParams holds calculated parameters for the dynaudnorm filter. +type DynaudnormParams struct { + TargetRMS float64 // Linear scale (0.0-1.0) + Threshold float64 // Linear scale (0.0-1.0) + RMSPeakDB float64 // dB value for reference + NoiseFloorDB float64 // dB value for reference +} + +// FrequencyBand represents analyzed frequency response data for one band. +type FrequencyBand struct { + Frequency string // e.g. "50Hz", "100Hz", "12.8kHz+" + FilterType string // "lowpass", "bandpass", "highpass" + RMSLevel float64 // Average level in dB + PeakLevel float64 // Peak level in dB (for reference) + CrestFactor float64 // Peak-to-RMS ratio +} diff --git a/go/cli.go b/go/cli.go index a40a1f6..d928050 100644 --- a/go/cli.go +++ b/go/cli.go @@ -19,7 +19,7 @@ import ( "github.com/fsnotify/fsnotify" - "github.com/fremen-fi/tnt/go/internal/audio" + "github.com/fremen-fi/tnt/go/audio" "github.com/fremen-fi/tnt/go/internal/config" "github.com/fremen-fi/tnt/go/internal/ffmpeg" ) @@ -351,7 +351,7 @@ func (p *CLIProcessor) processFiles(files []string) { defer wg.Done() for file := range jobs { if p.cfg.PhaseCheck { - inverted, offset, err := audio.PhaseCheck(file, p.logFile) + inverted, offset, err := audio.PhaseCheck(ffmpeg.Path, file) if err != nil { p.log(fmt.Sprintf("Phase check failed for %s: %v", filepath.Base(file), err)) } else if inverted { @@ -423,7 +423,7 @@ func (p *CLIProcessor) watchAndProcess() { time.Sleep(500 * time.Millisecond) if p.cfg.PhaseCheck { - inverted, offset, err := audio.PhaseCheck(file, p.logFile) + inverted, offset, err := audio.PhaseCheck(ffmpeg.Path, file) if err != nil { p.log(fmt.Sprintf("Phase check failed for %s: %v", filepath.Base(file), err)) } else if inverted { diff --git a/go/dyn_norm.go b/go/dyn_norm.go index 0d8849d..41dcc2c 100644 --- a/go/dyn_norm.go +++ b/go/dyn_norm.go @@ -3,50 +3,23 @@ package main import ( "fmt" "math" + + "github.com/fremen-fi/tnt/go/audio" ) -// DynaudnormParams holds the calculated parameters for dynaudnorm -type DynaudnormParams struct { - TargetRMS float64 // Linear scale (0.0-1.0) - Threshold float64 // Linear scale (0.0-1.0) - RMSPeakDB float64 // dB value for reference - NoiseFloorDB float64 // dB value for reference -} +// DynaudnormParams is an alias for the public audio package type. The dynaudnorm +// logic lives in github.com/fremen-fi/tnt/go/audio; these receiver methods are +// thin wrappers that add app-level logging. +type DynaudnormParams = audio.DynaudnormParams -// analyzeDynaudnormParams measures the audio to calculate optimal dynaudnorm settings -// analyzeDynaudnormParams creates dynaudnorm parameters from existing dynamics analysis +// analyzeDynaudnormParams creates dynaudnorm parameters from existing dynamics +// analysis. func (n *AudioNormalizer) analyzeDynaudnormParams(analysis *DynamicsAnalysis) *DynaudnormParams { - if analysis == nil { + params := audio.AnalyzeDynaudnormParams(analysis) + if params == nil { return nil } - params := &DynaudnormParams{ - RMSPeakDB: analysis.RMSPeak, - NoiseFloorDB: analysis.NoiseFloor, - } - - // Calculate target RMS: RMS_peak - 6dB, converted to linear - targetDB := params.RMSPeakDB - 6.0 - params.TargetRMS = math.Pow(10, targetDB/20) - - // Calculate threshold: Noise_floor + 6dB, converted to linear - thresholdDB := params.NoiseFloorDB + 12.0 - params.Threshold = math.Pow(10, thresholdDB/20) - - // Clamp to valid ranges - if params.TargetRMS < 0.0 { - params.TargetRMS = 0.0 - } - if params.TargetRMS > 1.0 { - params.TargetRMS = 1.0 - } - if params.Threshold < 0.0 { - params.Threshold = 0.0 - } - if params.Threshold > 1.0 { - params.Threshold = 1.0 - } - n.logToFile(n.logFile, fmt.Sprintf("Dynaudnorm params - RMS Peak: %.2f dB, Noise Floor: %.2f dB", params.RMSPeakDB, params.NoiseFloorDB)) n.logToFile(n.logFile, fmt.Sprintf("Calculated - Target RMS: %.6f (%.2f dB), Threshold: %.6f (%.2f dB)", @@ -56,27 +29,11 @@ func (n *AudioNormalizer) analyzeDynaudnormParams(analysis *DynamicsAnalysis) *D return params } -// buildDynaudnormFilter creates the dynaudnorm filter string +// buildDynaudnormFilter creates the dynaudnorm filter string. func (n *AudioNormalizer) buildDynaudnormFilter(params *DynaudnormParams) string { - if params == nil { - return "" + filter := audio.BuildDynaudnormFilter(params) + if filter != "" { + n.logToFile(n.logFile, fmt.Sprintf("Dynaudnorm filter: %s", filter)) } - - // Build dynaudnorm with calculated parameters - // framelen: default 500ms (can experiment later) - // gausssize: default 31 (can experiment later) - // targetrms: calculated from RMS_peak - 6dB - // threshold: calculated from Noise_floor + 6dB - // altboundary: enabled for smooth fade in/out altboundary=1: - // overlap: 0.75 for smooth gain transitions - - filter := fmt.Sprintf( - "dynaudnorm=framelen=650:gausssize=36:targetrms=%.6f:threshold=%.6f:altboundary=true:overlap=0.95", - params.TargetRMS, - params.Threshold, - ) - - n.logToFile(n.logFile, fmt.Sprintf("Dynaudnorm filter: %s", filter)) - return filter } diff --git a/go/dynscore.go b/go/dynscore.go index daf8a7a..4d6ccc0 100644 --- a/go/dynscore.go +++ b/go/dynscore.go @@ -2,143 +2,27 @@ package main import ( "fmt" - "math" "path/filepath" - "regexp" - "strconv" - "strings" - "github.com/fremen-fi/tnt/go/internal/audio" + "github.com/fremen-fi/tnt/go/audio" "github.com/fremen-fi/tnt/go/internal/ffmpeg" ) +// calculateDynamicsScore runs ffmpeg astats and parses the Dynamics Score. The +// calculation lives in github.com/fremen-fi/tnt/go/audio; this is a thin +// wrapper that adds app-level logging. func (n *AudioNormalizer) calculateDynamicsScore(inputPath string) *audio.DynamicsScoreAnalysis { n.logStatus(fmt.Sprintf("→ Calculating Dynamics Score: %s", filepath.Base(inputPath))) - cmd := ffmpeg.Command( - "-i", inputPath, - "-af", "astats", - "-f", "null", - "-", - ) - - output, err := cmd.CombinedOutput() + result, err := audio.CalculateDynamicsScore(ffmpeg.Path, inputPath) if err != nil { n.logToFile(n.logFile, fmt.Sprintf("DS calculation failed: %v", err)) return nil } - return n.parseDynamicsScore(string(output)) -} - -func (n *AudioNormalizer) parseDynamicsScore(output string) *audio.DynamicsScoreAnalysis { - result := &audio.DynamicsScoreAnalysis{} - - // Parse Channel 1 section - lines := strings.Split(output, "\n") - inChannel1 := false - - for _, line := range lines { - if strings.Contains(line, "Channel: 1") { - inChannel1 = true - continue - } - if strings.Contains(line, "Channel: 2") { - break - } - - if inChannel1 { - if strings.Contains(line, "RMS peak dB:") { - re := regexp.MustCompile(`RMS peak dB:\s+([-\d.]+)`) - if match := re.FindStringSubmatch(line); len(match) > 1 { - result.RMSPeak, _ = strconv.ParseFloat(match[1], 64) - } - } - if strings.Contains(line, "RMS level dB:") && result.RMSLevel == 0 { - re := regexp.MustCompile(`RMS level dB:\s+([-\d.]+)`) - if match := re.FindStringSubmatch(line); len(match) > 1 { - result.RMSLevel, _ = strconv.ParseFloat(match[1], 64) - } - } - if strings.Contains(line, "Crest factor:") { - re := regexp.MustCompile(`Crest factor:\s+([-\d.]+)`) - if match := re.FindStringSubmatch(line); len(match) > 1 { - result.CrestFactor, _ = strconv.ParseFloat(match[1], 64) - } - } - } - } - - // Calculate DS = Crest × (RMS_peak - RMS_level) - result.DynamicsScore = math.Sqrt(result.CrestFactor) * (result.RMSPeak - result.RMSLevel) - n.logToFile(n.logFile, fmt.Sprintf("DS Analysis - RMS Peak: %.2f dB, RMS Level: %.2f dB, Crest: %.2f", result.RMSPeak, result.RMSLevel, result.CrestFactor)) n.logToFile(n.logFile, fmt.Sprintf("Dynamics Score: %.2f", result.DynamicsScore)) return result } - -type CompressionModifiers struct { - AttackMultiplier float64 - ReleaseMultiplier float64 - RatioMultiplier float64 // The target ratio (1.4, 2.1, 4.0, or over 4.0 up to 8.0 etc) -} - -func getCompressionModifiers(ds float64) CompressionModifiers { - mods := CompressionModifiers{ - AttackMultiplier: 1.0, - ReleaseMultiplier: 1.0, - RatioMultiplier: 1.0, // 0 = no change from preset - } - - if ds < 9.0 { - // Very compressed - slow down, barely compress - mods.AttackMultiplier = 4.0 - mods.ReleaseMultiplier = 4.0 - mods.RatioMultiplier = 0.15 - - } else if ds < 15.0 { - // Moderately compressed - slow down, gentle - mods.AttackMultiplier = 2.0 - mods.ReleaseMultiplier = 2.0 - mods.RatioMultiplier = 2.1 - - } else if ds <= 21.0 { - // Normal - use preset as-is - // No changes - - } else { - // Highly dynamic - speed up, more aggressive - // Linear scaling from DS=21 to DS=100 - // At DS=21: divide by 2, ratio 4:1 - // At DS=100: divide by 4, ratio 8:1 - - excess := math.Min(ds-21.0, 79.0) // Cap at 79 (21+79=100) - scaleFactor := 2.0 + (2.0 * excess / 79.0) // 2.0 to 4.0 - - mods.AttackMultiplier = 1.0 / scaleFactor - mods.ReleaseMultiplier = 1.0 / scaleFactor - mods.RatioMultiplier = 4.0 + (4.0 * excess / 79.0) // 4.0 to 8.0 - } - - return mods -} - -func getBaseRatioFromCrest(crest float64) float64 { - if crest <= 3.0 { - return 1.4 - } else if crest <= 5.0 { - return 2.0 - } else if crest <= 8.0 { - return 4.0 - } else { - // Linear scale from 4.0 to 8.0 for crest 8-16 - // Cap at 8.0 for crest >= 16 - if crest >= 16.0 { - return 8.0 - } - // crest is between 8 and 16 - return 4.0 + (4.0 * (crest - 8.0) / 8.0) - } -} diff --git a/go/freq_anal.go b/go/freq_anal.go index 62d2348..25af9f2 100644 --- a/go/freq_anal.go +++ b/go/freq_anal.go @@ -2,92 +2,29 @@ package main import ( "fmt" + + "github.com/fremen-fi/tnt/go/audio" "github.com/fremen-fi/tnt/go/internal/ffmpeg" - "math" - "regexp" - "strconv" - "strings" ) -// FrequencyBand represents analyzed frequency response data for one band -type FrequencyBand struct { - Frequency string // e.g. "50Hz", "100Hz", "12.8kHz+" - FilterType string // "lowpass", "bandpass", "highpass" - RMSLevel float64 // Average level in dB - PeakLevel float64 // Peak level in dB (for reference) - CrestFactor float64 // Peak-to-RMS ratio -} +// FrequencyBand is an alias for the public audio package type. The EQ analysis +// logic lives in github.com/fremen-fi/tnt/go/audio; these receiver methods are +// thin wrappers that add app-level logging. +type FrequencyBand = audio.FrequencyBand -// analyzeFrequencyResponseBands analyzes the frequency response across 10 bands -// using lowpass, bandpass, and highpass filters with astats +// analyzeFrequencyResponseBands analyzes the frequency response across 10 bands. func (n *AudioNormalizer) analyzeFrequencyResponseBands(inputPath string) []FrequencyBand { - bands := []FrequencyBand{ - {Frequency: "50Hz", FilterType: "lowpass"}, - {Frequency: "100Hz", FilterType: "bandpass"}, - {Frequency: "200Hz", FilterType: "bandpass"}, - {Frequency: "400Hz", FilterType: "bandpass"}, - {Frequency: "800Hz", FilterType: "bandpass"}, - {Frequency: "1.6kHz", FilterType: "bandpass"}, - {Frequency: "3.2kHz", FilterType: "bandpass"}, - {Frequency: "6.4kHz", FilterType: "bandpass"}, - {Frequency: "12.8kHz", FilterType: "bandpass"}, - {Frequency: "12.8kHz+", FilterType: "highpass"}, - } - n.logStatus("Analyzing frequency response across 10 bands...") n.logToFile(n.logFile, "Starting frequency response analysis") - for i := range bands { - band := &bands[i] - - var filterChain string - switch band.FilterType { - case "lowpass": - // Everything below 50Hz - filterChain = "highpass=f=25:p=1:r=f64:p=2,lowpass=f=50,astats" - - case "highpass": - // Everything above 12.8kHz - filterChain = "highpass=f=12800,astats" - - case "bandpass": - // Extract center frequency and calculate bandwidth - centerFreq, bandwidth := n.getBandpassParams(band.Frequency) - filterChain = fmt.Sprintf("bandpass=f=%d:width_type=o:width=1,astats", centerFreq) - n.logToFile(n.logFile, fmt.Sprintf("Band %s: center=%dHz, bandwidth=%.1fHz (1 octave)", - band.Frequency, centerFreq, bandwidth)) - } - - n.logStatus(fmt.Sprintf(" Measuring %s band...", band.Frequency)) - - cmd := ffmpeg.Command( - - "-i", inputPath, - "-af", filterChain, - "-f", "null", - "-", - ) - - output, err := cmd.CombinedOutput() - if err != nil { - n.logStatus(fmt.Sprintf(" Failed to analyze %s: %v", band.Frequency, err)) - n.logToFile(n.logFile, fmt.Sprintf("Failed %s analysis: %v", band.Frequency, err)) - continue - } - - // Log raw FFmpeg output for debugging - //n.logToFile(n.logFile, fmt.Sprintf("=== RAW OUTPUT for %s ===", band.Frequency)) - //n.logToFile(n.logFile, string(output)) - //n.logToFile(n.logFile, fmt.Sprintf("=== END RAW OUTPUT for %s ===", band.Frequency)) - - // Parse astats output for this band - stats := n.parseFrequencyBandStats(string(output)) - band.RMSLevel = stats["rms"] - band.PeakLevel = stats["peak"] - band.CrestFactor = stats["crest"] + bands, err := audio.AnalyzeFrequencyResponseBands(ffmpeg.Path, inputPath) + if err != nil { + n.logStatus(fmt.Sprintf("Frequency response analysis failed: %v", err)) + n.logToFile(n.logFile, fmt.Sprintf("Frequency response analysis failed: %v", err)) + return nil + } - n.logStatus(fmt.Sprintf(" %s: RMS=%.1f dB, Peak=%.1f dB, Crest=%.1f dB", - band.Frequency, band.RMSLevel, band.PeakLevel, band.CrestFactor)) + for _, band := range bands { n.logToFile(n.logFile, fmt.Sprintf("%s - RMS: %.2f dB, Peak: %.2f dB, Crest: %.2f dB", band.Frequency, band.RMSLevel, band.PeakLevel, band.CrestFactor)) } @@ -98,483 +35,11 @@ func (n *AudioNormalizer) analyzeFrequencyResponseBands(inputPath string) []Freq return bands } -// getBandpassParams returns center frequency and bandwidth in Hz for bandpass analysis -func (n *AudioNormalizer) getBandpassParams(freqStr string) (int, float64) { - // Map frequency strings to actual Hz values - freqMap := map[string]int{ - "100Hz": 100, - "200Hz": 200, - "400Hz": 400, - "800Hz": 800, - "1.6kHz": 1600, - "3.2kHz": 3200, - "6.4kHz": 6400, - "12.8kHz": 12800, - } - - centerFreq := freqMap[freqStr] - - // 1 octave bandwidth means bandwidth = centerFreq (from lower -1/2 octave to upper +1/2 octave) - // But for bandpass filter with width_type=o (octave), we specify width=1 for 1 octave - bandwidth := float64(centerFreq) // Full octave bandwidth in Hz - - return centerFreq, bandwidth -} - -// parseFrequencyBandStats extracts RMS, peak, and crest factor from astats output -func (n *AudioNormalizer) parseFrequencyBandStats(output string) map[string]float64 { - stats := make(map[string]float64) - - // Parse RMS level (dB) - // Example: "RMS level dB: -23.45" - rmsRe := regexp.MustCompile(`RMS level dB:\s+([-\d.]+)`) - if match := rmsRe.FindStringSubmatch(output); len(match) > 1 { - if val, err := strconv.ParseFloat(match[1], 64); err == nil { - stats["rms"] = val - } - } - - // Parse Peak level (dB) - // Example: "Peak level dB: -12.34" - peakRe := regexp.MustCompile(`Peak level dB:\s+([-\d.]+)`) - if match := peakRe.FindStringSubmatch(output); len(match) > 1 { - if val, err := strconv.ParseFloat(match[1], 64); err == nil { - stats["peak"] = val - } - } - - // Parse Crest factor (ratio, not dB) - // Example: "Crest factor: 2.858335" - crestRe := regexp.MustCompile(`Crest factor:\s+([-\d.]+)`) - if match := crestRe.FindStringSubmatch(output); len(match) > 1 { - if val, err := strconv.ParseFloat(match[1], 64); err == nil { - stats["crest"] = val - } - } - - return stats -} - -// clampExtremeEQ applies restrictions to low-shelf and high-shelf EQ values -// based on the non-extreme band values: -// 1. Extreme-to-extreme difference <= 4 dB -// 2. Extreme-to-neighbor difference <= 4 dB -// 3. Neither extreme exceeds the average of non-extremes in magnitude -func clampExtremeEQ(gains []float64, n *AudioNormalizer) []float64 { - if len(gains) < 3 { - n.logToFile(n.logFile, "clampExtremeEQ: Not enough bands to clamp (need at least 3)") - return gains // Need at least 3 bands (2 extremes + 1 middle) - } - - clamped := make([]float64, len(gains)) - copy(clamped, gains) - - lowShelf := gains[0] // First band (sub 100Hz) - highShelf := gains[len(gains)-1] // Last band (above 12.8kHz) - - n.logToFile(n.logFile, fmt.Sprintf("clampExtremeEQ: Original low extreme: %.2f dB, high extreme: %.2f dB", lowShelf, highShelf)) - - // Calculate average of non-extremes - var sum float64 - for i := 1; i < len(gains)-1; i++ { - sum += gains[i] - } - avgNonExtremes := sum / float64(len(gains)-2) - n.logToFile(n.logFile, fmt.Sprintf("clampExtremeEQ: Non-extremes average: %.2f dB", avgNonExtremes)) - - // First neighbor (band index 1) - firstNeighbor := gains[1] - // Last neighbor (band index len-2) - lastNeighbor := gains[len(gains)-2] - n.logToFile(n.logFile, fmt.Sprintf("clampExtremeEQ: First neighbor: %.2f dB, Last neighbor: %.2f dB", firstNeighbor, lastNeighbor)) - - // Clamp low shelf - // Rule 1: Extreme-to-extreme - maxLowFromHigh := highShelf + 4.0 - minLowFromHigh := highShelf - 4.0 - - // Rule 2: Extreme-to-neighbor - maxLowFromNeighbor := firstNeighbor + 4.0 - minLowFromNeighbor := firstNeighbor - 4.0 - - // Rule 3: Extreme-to-average - var maxLowFromAvg, minLowFromAvg float64 - if avgNonExtremes >= 0 { - maxLowFromAvg = avgNonExtremes - minLowFromAvg = -999.0 // No lower limit when avg is positive - } else { - maxLowFromAvg = 999.0 // No upper limit when avg is negative - minLowFromAvg = avgNonExtremes - } - - n.logToFile(n.logFile, fmt.Sprintf("clampExtremeEQ LOW: maxFromHigh=%.2f, minFromHigh=%.2f, maxFromNeighbor=%.2f, minFromNeighbor=%.2f, maxFromAvg=%.2f, minFromAvg=%.2f", - maxLowFromHigh, minLowFromHigh, maxLowFromNeighbor, minLowFromNeighbor, maxLowFromAvg, minLowFromAvg)) - - // Take most restrictive limits - maxLow := math.Min(math.Min(maxLowFromHigh, maxLowFromNeighbor), maxLowFromAvg) - minLow := math.Max(math.Max(minLowFromHigh, minLowFromNeighbor), minLowFromAvg) - - n.logToFile(n.logFile, fmt.Sprintf("clampExtremeEQ LOW: Final range [%.2f, %.2f]", minLow, maxLow)) - - if lowShelf > maxLow { - clamped[0] = maxLow - n.logToFile(n.logFile, fmt.Sprintf("clampExtremeEQ LOW: CLAMPED from %.2f to %.2f (exceeded max)", lowShelf, maxLow)) - } else if lowShelf < minLow { - clamped[0] = minLow - n.logToFile(n.logFile, fmt.Sprintf("clampExtremeEQ LOW: CLAMPED from %.2f to %.2f (below min)", lowShelf, minLow)) - } else { - n.logToFile(n.logFile, fmt.Sprintf("clampExtremeEQ LOW: No clamping needed (%.2f within range)", lowShelf)) - } - - // Clamp high shelf (same logic, inverted) - maxHighFromLow := clamped[0] + 4.0 // Use clamped low value - minHighFromLow := clamped[0] - 4.0 - - maxHighFromNeighbor := lastNeighbor + 4.0 - minHighFromNeighbor := lastNeighbor - 4.0 - - var maxHighFromAvg, minHighFromAvg float64 - if avgNonExtremes >= 0 { - maxHighFromAvg = avgNonExtremes - minHighFromAvg = -999.0 - } else { - maxHighFromAvg = 999.0 - minHighFromAvg = avgNonExtremes - } - - n.logToFile(n.logFile, fmt.Sprintf("clampExtremeEQ HIGH: maxFromLow=%.2f, minFromLow=%.2f, maxFromNeighbor=%.2f, minFromNeighbor=%.2f, maxFromAvg=%.2f, minFromAvg=%.2f", - maxHighFromLow, minHighFromLow, maxHighFromNeighbor, minHighFromNeighbor, maxHighFromAvg, minHighFromAvg)) - - maxHigh := math.Min(math.Min(maxHighFromLow, maxHighFromNeighbor), maxHighFromAvg) - minHigh := math.Max(math.Max(minHighFromLow, minHighFromNeighbor), minHighFromAvg) - - n.logToFile(n.logFile, fmt.Sprintf("clampExtremeEQ HIGH: Final range [%.2f, %.2f]", minHigh, maxHigh)) - - if highShelf > maxHigh { - clamped[len(clamped)-1] = maxHigh - n.logToFile(n.logFile, fmt.Sprintf("clampExtremeEQ HIGH: CLAMPED from %.2f to %.2f (exceeded max)", highShelf, maxHigh)) - } else if highShelf < minHigh { - clamped[len(clamped)-1] = minHigh - n.logToFile(n.logFile, fmt.Sprintf("clampExtremeEQ HIGH: CLAMPED from %.2f to %.2f (below min)", highShelf, minHigh)) - } else { - n.logToFile(n.logFile, fmt.Sprintf("clampExtremeEQ HIGH: No clamping needed (%.2f within range)", highShelf)) - } - - return clamped -} - -// buildEqFilter creates an EQ filter chain based on frequency response analysis +// buildEqFilter creates an EQ filter chain based on frequency response analysis. func (n *AudioNormalizer) buildEqFilter(bands []FrequencyBand, eqTarget string) string { - if len(bands) == 0 || eqTarget == "Off" { - return "" - } - - n.logToFile(n.logFile, fmt.Sprintf("Building EQ filter for target: %s", eqTarget)) - - // Define high-pass and low-pass filters per preset - var highpassFilter, lowpassFilter string - - switch eqTarget { - case "Flat": - highpassFilter = "highpass=f=25:p=2" - lowpassFilter = "" // No lowpass for Flat - case "Speech": - highpassFilter = "highpass=f=80:p=2" - lowpassFilter = "lowpass=f=13000:p=1" - case "Broadcast": - highpassFilter = "highpass=f=70:p=2" - lowpassFilter = "lowpass=f=14000:p=2" - default: - highpassFilter = "" - lowpassFilter = "" - } - - // Calculate target curve - targetLevels := n.calculateTargetCurve(bands, eqTarget) - - // Build filter chain using bass/highshelf for extremes and anequalizer for middle - var filterParts []string - - // Collect all gains first - gains := make([]float64, len(bands)) - - for i, band := range bands { - targetLevel := targetLevels[i] - gain := targetLevel - band.RMSLevel - - const maxGain = 10.0 - if gain > maxGain { - n.logToFile(n.logFile, fmt.Sprintf(" %s: calculated gain %.2f dB limited to +%.1f dB", band.Frequency, gain, maxGain)) - gain = maxGain - } else if gain < -maxGain { - n.logToFile(n.logFile, fmt.Sprintf(" %s: calculated gain %.2f dB limited to -%.1f dB", band.Frequency, gain, maxGain)) - gain = -maxGain - } - - gains[i] = gain - } - - // Apply extreme band constraints - gains = clampExtremeEQ(gains, n) - - // Build filters using clamped gains - for i, band := range bands { - gain := gains[i] - - if gain > -0.5 && gain < 0.5 { - n.logToFile(n.logFile, fmt.Sprintf(" %s: no adjustment needed (%.2f dB)", band.Frequency, gain)) - continue - } - - n.logToFile(n.logFile, fmt.Sprintf(" %s: RMS=%.2f dB, Target=%.2f dB, Gain=%.2f dB", - band.Frequency, band.RMSLevel, targetLevels[i], gain)) - - switch band.FilterType { - case "lowpass": - filterParts = append(filterParts, fmt.Sprintf("highpass=f=25:p=1:r=f64:p=2,lowshelf=f=50:g=%.2f:width_type=q:width=0.7", gain)) - case "highpass": - filterParts = append(filterParts, fmt.Sprintf("lowpass=f=17500:p=2:r=f64,highshelf=f=12800:g=%.2f:width_type=q:width=0.7", gain)) - case "bandpass": - centerFreq, bandwidth := n.getBandpassParams(band.Frequency) - filterParts = append(filterParts, fmt.Sprintf("anequalizer=c0 f=%d w=%.0f g=%.2f t=0|c1 f=%d w=%.0f g=%.2f t=0", - centerFreq, bandwidth, gain, centerFreq, bandwidth, gain)) - } - } - - // Build final chain with HPF, EQ bands, LPF - var finalParts []string - - if highpassFilter != "" { - finalParts = append(finalParts, highpassFilter) + eqChain := audio.BuildEqFilter(bands, eqTarget) + if eqChain != "" { + n.logToFile(n.logFile, fmt.Sprintf("Final EQ chain: %s", eqChain)) } - - finalParts = append(finalParts, filterParts...) - - if lowpassFilter != "" { - finalParts = append(finalParts, lowpassFilter) - } - - if len(finalParts) == 0 { - n.logToFile(n.logFile, "No EQ adjustments needed") - return "" - } - - // Join all filter parts with commas - eqChain := strings.Join(finalParts, ",") - - n.logToFile(n.logFile, fmt.Sprintf("Final EQ chain: %s", eqChain)) - return eqChain } - -// calculateTargetCurve determines target RMS levels for each band based on EQ target -func (n *AudioNormalizer) calculateTargetCurve(bands []FrequencyBand, eqTarget string) []float64 { - targets := make([]float64, len(bands)) - - // Calculate overall average RMS across all bands - var overallRMS float64 - for _, band := range bands { - overallRMS += band.RMSLevel - } - overallRMS = overallRMS / float64(len(bands)) - - n.logToFile(n.logFile, fmt.Sprintf("Overall average RMS: %.2f dB", overallRMS)) - - switch eqTarget { - case "Flat": - // Flat: Attenuate anything above pink noise curve - // Pink noise: -3 dB per octave rise (reference at 1kHz) - // Use overall RMS as base, adjust per octave from 1kHz - - for i, band := range bands { - // Calculate pink noise reference level for this band - // Pink noise: +3 dB per octave down from 1kHz (more energy in bass) - pinkNoiseRef := overallRMS - - // If measured level exceeds reference, attenuate - if band.RMSLevel > pinkNoiseRef { - excess := band.RMSLevel - pinkNoiseRef - // Apply 2:1 ratio - attenuation := excess / 2.0 - // Limit to -10 dB max - if attenuation > 10.0 { - attenuation = 10.0 - } - targets[i] = band.RMSLevel - attenuation - n.logToFile(n.logFile, fmt.Sprintf(" %s: %.2f dB exceeds pink curve (%.2f dB) by %.2f dB, attenuate %.2f dB", - band.Frequency, band.RMSLevel, pinkNoiseRef, excess, attenuation)) - } else { - // Below curve, leave it alone - targets[i] = band.RMSLevel - } - } - - case "Speech": - // Speech: Optimize for intelligibility - // Reference: Gemini tables (relative to pink noise curve) - - for i, band := range bands { - octavesFrom1k := n.getOctavesFrom1k(band.Frequency) - pinkNoiseRef := overallRMS - (octavesFrom1k * 3.0) - - // Target adjustments relative to pink noise - var adjustment float64 - switch band.Frequency { - case "50Hz": - adjustment = -9.0 // -6 to -12 dB cut (using -9 dB) - case "100Hz": - adjustment = -3.5 // -3 to -6 dB cut (using -4.5 dB) - case "200Hz": - adjustment = -2.5 // 0 to +2 dB (using +1 dB for slight warmth) - case "400Hz": - adjustment = -3.0 // -3 to -5 dB cut (reduce boxiness) - case "800Hz": - adjustment = +0.5 // 0 to +1 dB (slight boost for projection) - case "1.6kHz": - adjustment = +3.0 // +2 to +4 dB boost (intelligibility core) - case "3.2kHz": - adjustment = +1.0 // +1 to +3 dB boost (presence) - case "6.4kHz": - adjustment = +0.0 // 0 to +2 dB (add air) - case "12.8kHz": - adjustment = -2.0 // 0 to +3 dB (add openness) - case "12.8kHz+": - adjustment = -2.0 // Flat or slight high-pass - default: - adjustment = 0.0 - } - - targetLevel := pinkNoiseRef + adjustment - deviation := band.RMSLevel - targetLevel - - // Apply 2:1 ratio - correction := deviation / 2.0 - - // Limit correction to ±10 dB - if correction > 10.0 { - correction = 10.0 - } else if correction < -10.0 { - correction = -10.0 - } - - // Skip tiny adjustments - if correction > -0.5 && correction < 0.5 { - targets[i] = band.RMSLevel - continue - } - - targets[i] = band.RMSLevel - correction - - if correction > 0 { - n.logToFile(n.logFile, fmt.Sprintf(" %s: %.2f dB exceeds speech target (%.2f dB) by %.2f dB, attenuate %.2f dB", - band.Frequency, band.RMSLevel, targetLevel, deviation, correction)) - } else { - n.logToFile(n.logFile, fmt.Sprintf(" %s: %.2f dB below speech target (%.2f dB) by %.2f dB, boost %.2f dB", - band.Frequency, band.RMSLevel, targetLevel, -deviation, -correction)) - } - } - - case "Broadcast": - // Broadcast: Aggressive clarity for small speakers/phones - // Reference: Gemini tables (relative to pink noise curve) - - for i, band := range bands { - octavesFrom1k := n.getOctavesFrom1k(band.Frequency) - pinkNoiseRef := overallRMS - (octavesFrom1k * 3.0) - - // Target adjustments relative to pink noise - var adjustment float64 - switch band.Frequency { - case "50Hz": - adjustment = -2.0 // Aggressive high-pass - case "100Hz": - adjustment = -1.0 // -6 to -10 dB deep cut - case "200Hz": - adjustment = -2.5 // -2 to +1 dB (using -0.5 for clarity) - case "400Hz": - adjustment = -4.5 // -4 to -7 dB (eliminate boxiness) - case "800Hz": - adjustment = +1.0 // +1 to +3 dB (forwardness) - case "1.6kHz": - adjustment = +2.5 // +3 to +6 dB (maximize intelligibility) - case "3.2kHz": - adjustment = +3.5 // +2 to +5 dB (presence and crispness) - case "6.4kHz": - adjustment = +2.0 // +2 to +4 dB (sparkle and polish) - case "12.8kHz": - adjustment = -0.5 // -3 to 0 dB (reduce hiss) - case "12.8kHz+": - adjustment = -2.5 // Slight roll-off - default: - adjustment = 0.0 - } - - targetLevel := pinkNoiseRef + adjustment - deviation := band.RMSLevel - targetLevel - - // Apply 2:1 ratio - correction := deviation / 2.0 - - // Limit correction to ±10 dB - if correction > 10.0 { - correction = 10.0 - } else if correction < -10.0 { - correction = -10.0 - } - - // Skip tiny adjustments - if correction > -0.5 && correction < 0.5 { - targets[i] = band.RMSLevel - continue - } - - targets[i] = band.RMSLevel - correction - - if correction > 0 { - n.logToFile(n.logFile, fmt.Sprintf(" %s: %.2f dB exceeds broadcast target (%.2f dB) by %.2f dB, attenuate %.2f dB", - band.Frequency, band.RMSLevel, targetLevel, deviation, correction)) - } else { - n.logToFile(n.logFile, fmt.Sprintf(" %s: %.2f dB below broadcast target (%.2f dB) by %.2f dB, boost %.2f dB", - band.Frequency, band.RMSLevel, targetLevel, -deviation, -correction)) - } - } - - default: - // No EQ - for i, band := range bands { - targets[i] = band.RMSLevel - } - } - - return targets -} - -// getOctavesFrom1k returns the number of octaves from 1kHz for a given frequency band -func (n *AudioNormalizer) getOctavesFrom1k(freqStr string) float64 { - // Reference: 1kHz = 0 octaves - // Formula: octaves = log2(freq / 1000) - switch freqStr { - case "50Hz": - return -4.32 // log2(50/1000) - case "100Hz": - return -3.32 // log2(100/1000) - case "200Hz": - return -2.32 // log2(200/1000) - case "400Hz": - return -1.32 // log2(400/1000) - case "800Hz": - return -0.32 // log2(800/1000) - case "1.6kHz": - return 0.68 // log2(1600/1000) - case "3.2kHz": - return 1.68 // log2(3200/1000) - case "6.4kHz": - return 2.68 // log2(6400/1000) - case "12.8kHz": - return 3.68 // log2(12800/1000) - case "12.8kHz+": - return 5.0 // Approximate for >12.8kHz - default: - return 0.0 - } -} diff --git a/go/internal/audio/types.go b/go/internal/audio/types.go deleted file mode 100644 index e5c69d4..0000000 --- a/go/internal/audio/types.go +++ /dev/null @@ -1,44 +0,0 @@ -package audio - -// DynamicsAnalysis holds the results of astats analysis for a file -type DynamicsAnalysis struct { - PeakLevel float64 - RMSPeak float64 - RMSTrough float64 - CrestFactor float64 - DynamicRange float64 - RMSLevel float64 - NoiseFloor float64 -} - -// FrequencyBandAnalysis holds per-band analysis for multiband compression -type FrequencyBandAnalysis struct { - BandName string - PeakLevel float64 - RMSLevel float64 - CrestFactor float64 - DynamicRange float64 -} - -// DynamicsScoreAnalysis holds the calculated Dynamics Score -type DynamicsScoreAnalysis struct { - RMSPeak float64 - RMSLevel float64 - CrestFactor float64 - DynamicsScore float64 -} - -// CompressionModifiers holds multipliers applied based on Dynamics Score -type CompressionModifiers struct { - AttackMultiplier float64 - ReleaseMultiplier float64 - RatioMultiplier float64 -} - -// DynaudnormParams holds calculated parameters for dynaudnorm filter -type DynaudnormParams struct { - TargetRMS float64 // Linear scale (0.0-1.0) - Threshold float64 // Linear scale (0.0-1.0) - RMSPeakDB float64 // dB value for reference - NoiseFloorDB float64 // dB value for reference -} diff --git a/go/main.go b/go/main.go index 10c8efb..3f12fd2 100644 --- a/go/main.go +++ b/go/main.go @@ -26,7 +26,7 @@ import ( "github.com/wailsapp/wails/v2/pkg/options/assetserver" wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" - "github.com/fremen-fi/tnt/go/internal/audio" + "github.com/fremen-fi/tnt/go/audio" "github.com/fremen-fi/tnt/go/internal/config" "github.com/fremen-fi/tnt/go/internal/ffmpeg" "github.com/fremen-fi/tnt/go/internal/telemetry" @@ -65,23 +65,11 @@ type ProcessConfig struct { PhaseCheck bool } -type DynamicsAnalysis struct { - PeakLevel float64 - RMSPeak float64 - RMSTrough float64 - CrestFactor float64 - DynamicRange float64 - RMSLevel float64 - NoiseFloor float64 -} +// DynamicsAnalysis and FrequencyBandAnalysis are aliases for the public audio +// package types so app code keeps using the short names. +type DynamicsAnalysis = audio.DynamicsAnalysis -type FrequencyBandAnalysis struct { - BandName string - PeakLevel float64 - RMSLevel float64 - CrestFactor float64 - DynamicRange float64 -} +type FrequencyBandAnalysis = audio.FrequencyBandAnalysis func getPlatformKey() string { switch runtime.GOOS { @@ -1436,7 +1424,7 @@ func (n *AudioNormalizer) process() { shouldProcess := true if config.PhaseCheck { - inverted, offset, err := audio.PhaseCheck(file, n.logFile) + inverted, offset, err := audio.PhaseCheck(ffmpeg.Path, file) if err != nil { n.logStatus(fmt.Sprintf("✗ Phase check failed for %s: %v", filepath.Base(file), err)) } else if inverted { From a08c8dc522e0a29acae5449abc4c3e6958fa0f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=20S=C3=A4ppi?= Date: Sun, 31 May 2026 16:34:05 +0300 Subject: [PATCH 03/32] add aes77-2023 --- go/frontend/app.js | 1398 +++++++++++++++++++++------------------- go/frontend/index.html | 869 ++++++++++++------------- go/frontend/ui.js | 368 +++++------ 3 files changed, 1349 insertions(+), 1286 deletions(-) diff --git a/go/frontend/app.js b/go/frontend/app.js index 0c4b76d..d910774 100644 --- a/go/frontend/app.js +++ b/go/frontend/app.js @@ -62,11 +62,11 @@ /** @type {AppState} */ const state = { - files: [], - selectedIdx: null, - processing: false, - watching: false, - normalizationStandard: 'EBU R128 (-23 LUFS)', + files: [], + selectedIdx: null, + processing: false, + watching: false, + normalizationStandard: 'EBU R128 (-23 LUFS)', }; // Fast-tab radios use short keys; map to backend names. The advanced @@ -74,667 +74,728 @@ const state = { // names directly, so it doesn't need this map. /** @type {Record} */ const FAST_FORMAT_MAP = { - pcm: 'PCM', - flac: 'FLAC', - aac: 'AAC', - mp3: 'MPEG-II L3', - opus: 'Opus', + pcm: 'PCM', + flac: 'FLAC', + aac: 'AAC', + mp3: 'MPEG-II L3', + opus: 'Opus', }; /** @type {Record} */ const FORMAT_LABELS = { - 'PCM': 'PCM (WAV)', - 'FLAC': 'FLAC', - 'AAC': 'AAC', - 'AAC (Fraunhofer)': 'AAC — Fraunhofer FDK', - 'AAC (Apple)': 'AAC — Apple AudioToolbox', - 'MPEG-II L3': 'MP3', - 'Opus': 'Opus', + 'PCM': 'PCM (WAV)', + 'FLAC': 'FLAC', + 'AAC': 'AAC', + 'AAC (Fraunhofer)': 'AAC — Fraunhofer FDK', + 'AAC (Apple)': 'AAC — Apple AudioToolbox', + 'MPEG-II L3': 'MP3', + 'Opus': 'Opus', }; /** @returns {Promise} */ async function populateFormatDropdown() { - const sel = $select('adv-format'); - if (!sel) return; - /** @type {string[]} */ - let formats = []; - try { - formats = await window.go.main.AudioNormalizer.GetPlatformFormats(); - } catch (_) { - formats = ['Opus', 'AAC', 'MPEG-II L3', 'PCM', 'FLAC']; - } - sel.innerHTML = ''; - formats.forEach((name) => { - const opt = document.createElement('option'); - opt.value = name; - opt.textContent = FORMAT_LABELS[name] || name; - sel.appendChild(opt); - }); - // Default to PCM when present. - if (Array.from(sel.options).some((o) => o.value === 'PCM')) sel.value = 'PCM'; + const sel = $select('adv-format'); + if (!sel) return; + /** @type {string[]} */ + let formats = []; + try { + formats = await window.go.main.AudioNormalizer.GetPlatformFormats(); + } catch (_) { + formats = ['Opus', 'AAC', 'MPEG-II L3', 'PCM', 'FLAC']; + } + sel.innerHTML = ''; + formats.forEach((name) => { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = FORMAT_LABELS[name] || name; + sel.appendChild(opt); + }); + // Default to PCM when present. + if (Array.from(sel.options).some((o) => o.value === 'PCM')) sel.value = 'PCM'; } /** @type {Record} */ const NORM_STD_MAP = { - ebu: 'EBU R128 (-23 LUFS)', - atsc: 'USA ATSC A/85 (-24 LUFS)', - custom: 'Custom', + ebu: 'EBU R128 (-23 LUFS)', + atsc: 'USA ATSC A/85 (-24 LUFS)', + aes77: 'AES77-2023 (-16/-18 LUFS)', + custom: 'Custom', }; /** @type {Record} */ const NORM_STD_REVERSE = { - 'EBU R128 (-23 LUFS)': 'ebu', - 'USA ATSC A/85 (-24 LUFS)': 'atsc', - 'Custom': 'custom', + 'EBU R128 (-23 LUFS)': 'ebu', + 'USA ATSC A/85 (-24 LUFS)': 'atsc', + 'AES77-2023 (-16/-18 LUFS)': 'aes77', + 'Custom': 'custom', }; /** @type {Record} */ const META_MAP = { - 'mt-title': 'title', - 'mt-artist': 'artist', - 'mt-album': 'album', - 'mt-year': 'date', - 'mt-comment': 'comment', - 'mt-track': 'track', + 'mt-title': 'title', + 'mt-artist': 'artist', + 'mt-album': 'album', + 'mt-year': 'date', + 'mt-comment': 'comment', + 'mt-track': 'track', }; /* ── Helpers ── */ /** @param {string} path @returns {string} */ function basename(path) { - if (!path) return ''; - const parts = String(path).split(/[\\/]/); - return parts[parts.length - 1]; + if (!path) return ''; + const parts = String(path).split(/[\\/]/); + return parts[parts.length - 1]; } /** @param {string} path @returns {string} */ function ext(path) { - const m = /\.([^./\\]+)$/.exec(String(path)); - return m ? m[1].toUpperCase() : ''; + const m = /\.([^./\\]+)$/.exec(String(path)); + return m ? m[1].toUpperCase() : ''; } /** @param {string} s @returns {string} */ function parseSR(s) { return (s || '').replace(/\D/g, ''); } /** @param {string} s @returns {string} */ function parseBD(s) { - const t = (s || '').toLowerCase(); - if (t.startsWith('16')) return '16'; - if (t.startsWith('24')) return '24'; - if (t.startsWith('32')) return '32 (float)'; - if (t.startsWith('64')) return '64 (float)'; - return '24'; + const t = (s || '').toLowerCase(); + if (t.startsWith('16')) return '16'; + if (t.startsWith('24')) return '24'; + if (t.startsWith('32')) return '32 (float)'; + if (t.startsWith('64')) return '64 (float)'; + return '24'; } /* ── File list rendering ── */ /** @param {string[]} paths */ function renderFileList(paths) { - state.files = Array.isArray(paths) ? paths : []; - const list = $('file-list'); - if (!list) return; - list.innerHTML = ''; - - if (!state.files.length) { - state.selectedIdx = null; - const empty = document.createElement('div'); - empty.className = 'file-empty'; - empty.textContent = 'Drop audio files here\nor use the buttons above'; - list.appendChild(empty); - } else { - if (state.selectedIdx == null || state.selectedIdx >= state.files.length) { - state.selectedIdx = 0; - } - state.files.forEach((path, i) => { - const item = document.createElement('div'); - item.className = 'file-item' + (i === state.selectedIdx ? ' selected' : ''); - - const badge = document.createElement('div'); - badge.className = 'file-badge'; - const badgeText = document.createElement('span'); - badgeText.textContent = ext(path); - badge.appendChild(badgeText); - - const info = document.createElement('div'); - info.className = 'file-info'; - const nameEl = document.createElement('div'); - nameEl.className = 'file-name'; - nameEl.textContent = basename(path); - const metaEl = document.createElement('div'); - metaEl.className = 'file-meta'; - metaEl.textContent = path; - metaEl.title = path; - info.appendChild(nameEl); - info.appendChild(metaEl); - - const remove = document.createElement('button'); - remove.className = 'file-remove'; - remove.title = 'Remove'; - const removeIcon = document.createElement('span'); - removeIcon.className = 'nrk-icon icon-close'; - remove.appendChild(removeIcon); - - item.appendChild(badge); - item.appendChild(info); - item.appendChild(remove); - - item.addEventListener('click', () => { - state.selectedIdx = i; - renderFileList(state.files); - }); - remove.addEventListener('click', async (e) => { - e.stopPropagation(); - try { - const updated = await window.go.main.AudioNormalizer.RemoveFile(i); - if (state.selectedIdx === i) state.selectedIdx = null; - else if (state.selectedIdx != null && state.selectedIdx > i) state.selectedIdx -= 1; - renderFileList(updated); - } catch (err) { - addLog('Remove failed: ' + err, 'err'); + state.files = Array.isArray(paths) ? paths : []; + const list = $('file-list'); + if (!list) return; + list.innerHTML = ''; + + if (!state.files.length) { + state.selectedIdx = null; + const empty = document.createElement('div'); + empty.className = 'file-empty'; + empty.textContent = 'Drop audio files here\nor use the buttons above'; + list.appendChild(empty); + } else { + if (state.selectedIdx == null || state.selectedIdx >= state.files.length) { + state.selectedIdx = 0; } - }); + state.files.forEach((path, i) => { + const item = document.createElement('div'); + item.className = 'file-item' + (i === state.selectedIdx ? ' selected' : ''); + + const badge = document.createElement('div'); + badge.className = 'file-badge'; + const badgeText = document.createElement('span'); + badgeText.textContent = ext(path); + badge.appendChild(badgeText); + + const info = document.createElement('div'); + info.className = 'file-info'; + const nameEl = document.createElement('div'); + nameEl.className = 'file-name'; + nameEl.textContent = basename(path); + const metaEl = document.createElement('div'); + metaEl.className = 'file-meta'; + metaEl.textContent = path; + metaEl.title = path; + info.appendChild(nameEl); + info.appendChild(metaEl); + + const remove = document.createElement('button'); + remove.className = 'file-remove'; + remove.title = 'Remove'; + const removeIcon = document.createElement('span'); + removeIcon.className = 'nrk-icon icon-close'; + remove.appendChild(removeIcon); + + item.appendChild(badge); + item.appendChild(info); + item.appendChild(remove); + + item.addEventListener('click', () => { + state.selectedIdx = i; + renderFileList(state.files); + }); + remove.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + const updated = await window.go.main.AudioNormalizer.RemoveFile(i); + if (state.selectedIdx === i) state.selectedIdx = null; + else if (state.selectedIdx != null && state.selectedIdx > i) state.selectedIdx -= 1; + renderFileList(updated); + } catch (err) { + addLog('Remove failed: ' + err, 'err'); + } + }); + + list.appendChild(item); + }); + } - list.appendChild(item); - }); - } - - const has = state.files.length > 0; - const proc = $button('btn-process'); - const clr = $button('btn-clear'); - if (proc) proc.disabled = !has || state.processing; - if (clr) clr.disabled = !has; - updateMetaTab(); + const has = state.files.length > 0; + const proc = $button('btn-process'); + const clr = $button('btn-clear'); + if (proc) proc.disabled = !has || state.processing; + if (clr) clr.disabled = !has; + updateMetaTab(); } function updateMetaTab() { - const path = (state.selectedIdx != null) ? state.files[state.selectedIdx] : null; - const hint = $('meta-hint'); - if (hint) { - hint.textContent = path - ? `Editing: ${basename(path)}` - : 'Select a single file in the queue to edit its metadata.'; - } - const has = !!path; - const grid = $('meta-grid'); - if (grid) { - grid.classList.toggle('disabled', !has); - grid.querySelectorAll('input').forEach((i) => { - // Read-only fields (track total, RG values) stay non-editable but - // still enabled when a file is selected so they show their value. - if (i.hasAttribute('readonly')) { i.disabled = !has; return; } - i.disabled = !has; - }); - } - // Clear the RG rows when no file is selected. - if (!has) setRGRow('', ''); - const r = $button('meta-read'); - const w = $button('meta-write'); - if (r) r.disabled = !has; - if (w) w.disabled = !has; + const path = (state.selectedIdx != null) ? state.files[state.selectedIdx] : null; + const hint = $('meta-hint'); + if (hint) { + hint.textContent = path ? + `Editing: ${basename(path)}` : + 'Select a single file in the queue to edit its metadata.'; + } + const has = !!path; + const grid = $('meta-grid'); + if (grid) { + grid.classList.toggle('disabled', !has); + grid.querySelectorAll('input').forEach((i) => { + // Read-only fields (track total, RG values) stay non-editable but + // still enabled when a file is selected so they show their value. + if (i.hasAttribute('readonly')) { i.disabled = !has; return; } + i.disabled = !has; + }); + } + // Clear the RG rows when no file is selected. + if (!has) setRGRow('', ''); + const r = $button('meta-read'); + const w = $button('meta-write'); + if (r) r.disabled = !has; + if (w) w.disabled = !has; } /* ── Inline-handler entry points ── */ /** @param {'files'|'folder'} kind */ async function addMockFile(kind) { - try { - const files = kind === 'folder' - ? await window.go.main.AudioNormalizer.SelectFolder() - : await window.go.main.AudioNormalizer.SelectFiles(); - renderFileList(files || []); - } catch (err) { - addLog('Select failed: ' + err, 'err'); - } + try { + const files = kind === 'folder' ? + await window.go.main.AudioNormalizer.SelectFolder() : + await window.go.main.AudioNormalizer.SelectFiles(); + renderFileList(files || []); + } catch (err) { + addLog('Select failed: ' + err, 'err'); + } } async function clearFiles() { - try { - await window.go.main.AudioNormalizer.ClearFiles(); - state.selectedIdx = null; - renderFileList([]); - } catch (err) { - addLog('Clear failed: ' + err, 'err'); - } + try { + await window.go.main.AudioNormalizer.ClearFiles(); + state.selectedIdx = null; + renderFileList([]); + } catch (err) { + addLog('Clear failed: ' + err, 'err'); + } } async function setOutput() { - try { - const dir = await window.go.main.AudioNormalizer.SetOutputFolder(); - if (dir) { - const out = $('output-path'); - if (out) { out.textContent = dir; out.title = dir; } - addLog('Output folder set', 'info'); - } - } catch (err) { - addLog('Output folder failed: ' + err, 'err'); - } + try { + const dir = await window.go.main.AudioNormalizer.SetOutputFolder(); + if (dir) { + const out = $('output-path'); + if (out) { + out.textContent = dir; + out.title = dir; + } + addLog('Output folder set', 'info'); + } + } catch (err) { + addLog('Output folder failed: ' + err, 'err'); + } } async function handleProcess() { - if (!state.files.length || state.processing) return; - state.processing = true; - const procBtn = $button('btn-process'); - if (procBtn) procBtn.disabled = true; - setProgress(0); - try { - await window.go.main.AudioNormalizer.Process(buildConfig()); - } catch (err) { - addLog('Process failed: ' + err, 'err'); - state.processing = false; - clearProgress(); - if (procBtn) procBtn.disabled = state.files.length === 0; - } + if (!state.files.length || state.processing) return; + state.processing = true; + const procBtn = $button('btn-process'); + if (procBtn) procBtn.disabled = true; + setProgress(0); + try { + await window.go.main.AudioNormalizer.Process(buildConfig()); + } catch (err) { + addLog('Process failed: ' + err, 'err'); + state.processing = false; + clearProgress(); + if (procBtn) procBtn.disabled = state.files.length === 0; + } } async function previewSize() { - try { - await window.go.main.AudioNormalizer.PreviewSize(buildConfig()); - } catch (err) { - addLog('Preview failed: ' + err, 'err'); - } + try { + await window.go.main.AudioNormalizer.PreviewSize(buildConfig()); + } catch (err) { + addLog('Preview failed: ' + err, 'err'); + } } /* ── ProcessConfig builder from current DOM ── */ /** @returns {ProcessConfig} */ function buildConfig() { - const activeBtn = /** @type {HTMLElement|null} */ (document.querySelector('.tab-btn.active')); - const activeTab = (activeBtn && activeBtn.dataset.tab) || 'fast'; - - const dyn = $select('proc-dyn'); - const eq = $select('proc-eq'); - const dn = $input('proc-dn'); - const byp = $input('proc-bypass'); - const phs = $input('prefs-phase'); - - const proc = { - DynamicsPreset: dyn ? dyn.value : 'Off', - EqTarget: eq ? eq.value : 'Off', - DynNorm: dn ? dn.checked : false, - BypassProc: byp ? byp.checked : false, - PhaseCheck: phs ? phs.checked : false, - }; - - if (activeTab === 'fast') { - const presetEl = /** @type {HTMLInputElement|null} */ (document.querySelector('input[name="fast-preset"]:checked')); - const preset = presetEl ? presetEl.value : 'pcm'; - const fastNorm = $input('fast-norm'); - const cfg = { - ...proc, - Format: '', SampleRate: '', BitDepth: '', Bitrate: '', - UseLoudnorm: fastNorm ? fastNorm.checked : false, - CustomLoudnorm: false, - IsSpeech: false, - WriteTags: false, - NoTranscode: false, - OriginIsAAC: false, - DataCompLevel: 0, + const activeBtn = /** @type {HTMLElement|null} */ (document.querySelector('.tab-btn.active')); + const activeTab = (activeBtn && activeBtn.dataset.tab) || 'fast'; + + const dyn = $select('proc-dyn'); + const eq = $select('proc-eq'); + const dn = $input('proc-dn'); + const byp = $input('proc-bypass'); + const phs = $input('prefs-phase'); + + const proc = { + DynamicsPreset: dyn ? dyn.value : 'Off', + EqTarget: eq ? eq.value : 'Off', + DynNorm: dn ? dn.checked : false, + BypassProc: byp ? byp.checked : false, + PhaseCheck: phs ? phs.checked : false, + }; + + if (activeTab === 'fast') { + const presetEl = /** @type {HTMLInputElement|null} */ (document.querySelector('input[name="fast-preset"]:checked')); + const preset = presetEl ? presetEl.value : 'pcm'; + const fastNorm = $input('fast-norm'); + const cfg = { + ...proc, + Format: '', + SampleRate: '', + BitDepth: '', + Bitrate: '', + UseLoudnorm: fastNorm ? fastNorm.checked : false, + CustomLoudnorm: false, + IsSpeech: false, + WriteTags: false, + NoTranscode: false, + OriginIsAAC: false, + DataCompLevel: 0, + }; + switch (preset) { + case 'aac': + cfg.Format = FAST_FORMAT_MAP.aac; + cfg.Bitrate = '256'; + break; + case 'mp3': + cfg.Format = FAST_FORMAT_MAP.mp3; + cfg.Bitrate = '320'; + break; + case 'pcm': + default: + cfg.Format = FAST_FORMAT_MAP.pcm; + cfg.SampleRate = '48000'; + cfg.BitDepth = '24'; + break; + } + return cfg; + } + + const fmt = $select('adv-format'); + const sr = $select('adv-sr'); + const bd = $select('adv-bd'); + const br = $input('adv-br'); + const norm = $input('adv-norm'); + const clud = $input('adv-custom-loud'); + const sp = $input('adv-speech'); + const rg = $input('adv-rg'); + const noTr = $input('adv-no-transcode'); + const cl = $input('adv-cl'); + + return { + ...proc, + Format: fmt ? fmt.value : 'PCM', + SampleRate: parseSR(sr ? sr.value : ''), + BitDepth: parseBD(bd ? bd.value : ''), + Bitrate: br ? br.value : '', + UseLoudnorm: norm ? norm.checked : false, + CustomLoudnorm: clud ? clud.checked : false, + IsSpeech: sp ? sp.checked : false, + WriteTags: rg ? rg.checked : false, + NoTranscode: noTr ? noTr.checked : false, + OriginIsAAC: false, + DataCompLevel: parseInt(cl ? cl.value : '0', 10), }; - switch (preset) { - case 'aac': cfg.Format = FAST_FORMAT_MAP.aac; cfg.Bitrate = '256'; break; - case 'mp3': cfg.Format = FAST_FORMAT_MAP.mp3; cfg.Bitrate = '320'; break; - case 'pcm': - default: cfg.Format = FAST_FORMAT_MAP.pcm; cfg.SampleRate = '48000'; cfg.BitDepth = '24'; break; - } - return cfg; - } - - const fmt = $select('adv-format'); - const sr = $select('adv-sr'); - const bd = $select('adv-bd'); - const br = $input('adv-br'); - const norm = $input('adv-norm'); - const clud = $input('adv-custom-loud'); - const sp = $input('adv-speech'); - const rg = $input('adv-rg'); - const noTr = $input('adv-no-transcode'); - const cl = $input('adv-cl'); - - return { - ...proc, - Format: fmt ? fmt.value : 'PCM', - SampleRate: parseSR(sr ? sr.value : ''), - BitDepth: parseBD(bd ? bd.value : ''), - Bitrate: br ? br.value : '', - UseLoudnorm: norm ? norm.checked : false, - CustomLoudnorm: clud ? clud.checked : false, - IsSpeech: sp ? sp.checked : false, - WriteTags: rg ? rg.checked : false, - NoTranscode: noTr ? noTr.checked : false, - OriginIsAAC: false, - DataCompLevel: parseInt(cl ? cl.value : '0', 10), - }; } /* ── Metadata read/write ── */ /** @param {string} gain @param {string} target */ function setRGRow(gain, target) { - const rows = document.querySelectorAll('.rg-row'); - const showGain = !!gain; - const showTarget = !!target; - rows.forEach((el) => { - // Each .rg-row is either the