diff --git a/.github/workflows/build-dev.yml b/.github/workflows/build-dev.yml index d1ab9f3..a1a9761 100644 --- a/.github/workflows/build-dev.yml +++ b/.github/workflows/build-dev.yml @@ -4,6 +4,8 @@ on: push: branches: - '*' + paths: + - 'go/**' jobs: sanity: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6268c56..26ab9e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,8 +36,9 @@ jobs: - name: Build run: | + VERSION="${GITHUB_REF#refs/tags/v}"; VERSION="${VERSION%%-*}" cd go - wails build -platform darwin/arm64 -ldflags "-s -w" + wails build -platform darwin/arm64 -ldflags "-s -w -X main.currentVersion=${VERSION}" - name: Add FFmpeg to bundle run: | @@ -156,8 +157,9 @@ jobs: - name: Build run: | + VERSION="${GITHUB_REF#refs/tags/v}"; VERSION="${VERSION%%-*}" cd go - wails build -platform darwin/amd64 -ldflags "-s -w" + wails build -platform darwin/amd64 -ldflags "-s -w -X main.currentVersion=${VERSION}" - name: Add FFmpeg to bundle run: | @@ -277,7 +279,7 @@ jobs: $TAG = $env:GITHUB_REF -replace "refs/tags/v", "" $VERSION = $TAG.Split("-")[0] cd go - wails build -platform windows/amd64 -ldflags "-s -w -X main.version=$VERSION" + wails build -platform windows/amd64 -ldflags "-s -w -X main.currentVersion=$VERSION" shell: powershell - name: Create InnoSetup installer @@ -316,7 +318,7 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.OVH_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.OVH_SECRET }} run: | - aws s3 cp "Distribution\${{ steps.filename.outputs.exe_name }}" "s3://${{ secrets.OVH_BUCKET }}/win/amd/tnt.exe" --endpoint-url "${{ secrets.OVH_ENDPOINT }}" --acl public-read + aws s3 cp "Distribution\${{ steps.filename.outputs.exe_name }}" "s3://${{ secrets.OVH_BUCKET }}/win/tnt.exe" --endpoint-url "${{ secrets.OVH_ENDPOINT }}" --acl public-read shell: powershell - name: Upload Windows Installer Artifact @@ -355,8 +357,9 @@ jobs: - name: Build Linux binary run: | + VERSION="${GITHUB_REF#refs/tags/v}"; VERSION="${VERSION%%-*}" cd go - wails build -platform linux/amd64 -ldflags "-s -w" -tags webkit2_41 + wails build -platform linux/amd64 -ldflags "-s -w -X main.currentVersion=${VERSION}" -tags webkit2_41 cp build/bin/TNT ../tnt-linux-amd64 chmod +x ../tnt-linux-amd64 @@ -572,8 +575,9 @@ jobs: - name: Build Linux binary run: | + VERSION="${GITHUB_REF#refs/tags/v}"; VERSION="${VERSION%%-*}" cd go - wails build -platform linux/arm64 -ldflags "-s -w" -tags webkit2_41 + wails build -platform linux/arm64 -ldflags "-s -w -X main.currentVersion=${VERSION}" -tags webkit2_41 cp build/bin/TNT ../tnt-linux-arm64 chmod +x ../tnt-linux-arm64 @@ -716,6 +720,71 @@ jobs: ${{ env.APPIMAGE_FILE }} ${{ env.TAR_FILE }} + update-manifest: + needs: + - build-linux + - build-windows + - build-mac-intel + - build-mac-arm64 + if: always() && !cancelled() + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Update tnt-version.json + env: + AWS_ACCESS_KEY_ID: ${{ secrets.OVH_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.OVH_SECRET }} + OVH_BUCKET: ${{ secrets.OVH_BUCKET }} + OVH_ENDPOINT: ${{ secrets.OVH_ENDPOINT }} + MAC_ARM64_RESULT: ${{ needs.build-mac-arm64.result }} + MAC_INTEL_RESULT: ${{ needs.build-mac-intel.result }} + WINDOWS_RESULT: ${{ needs.build-windows.result }} + LINUX_RESULT: ${{ needs.build-linux.result }} + run: | + set -euo pipefail + + # Version comes from the tag (v1.4.0-macm -> 1.4.0). + TAG="${GITHUB_REF#refs/tags/v}" + VERSION="${TAG%%-*}" + TODAY="$(date +'%-d %B, %Y')" + + # Structure (download URLs, release notes, history) is taken from the + # repo template. Per-platform versions for platforms NOT built in this + # run are preserved from the live bucket manifest. + cp Distribution/tnt-version.json out.json + aws s3 cp "s3://${OVH_BUCKET}/tnt-version.json" live.json \ + --endpoint-url "${OVH_ENDPOINT}" 2>/dev/null || echo '{}' > live.json + + set_platform() { + key="$1"; result="$2" + if [ "$result" = "success" ]; then + ver="$VERSION" + date="$TODAY" + else + ver="$(jq -r --arg k "$key" '.platforms[$k].version // empty' live.json)" + date="$(jq -r --arg k "$key" '.platforms[$k].release_date // empty' live.json)" + [ -n "$ver" ] || ver="$(jq -r --arg k "$key" '.platforms[$k].version' out.json)" + [ -n "$date" ] || date="$(jq -r --arg k "$key" '.platforms[$k].release_date' out.json)" + fi + jq --arg k "$key" --arg v "$ver" --arg d "$date" \ + '.platforms[$k].version = $v | .platforms[$k].release_date = $d' \ + out.json > out.tmp && mv out.tmp out.json + } + + set_platform darwin-arm64 "$MAC_ARM64_RESULT" + set_platform darwin-amd64 "$MAC_INTEL_RESULT" + set_platform windows "$WINDOWS_RESULT" + set_platform linux "$LINUX_RESULT" + + cat out.json + + aws s3 cp out.json "s3://${OVH_BUCKET}/tnt-version.json" \ + --endpoint-url "${OVH_ENDPOINT}" \ + --content-type "application/json" \ + --acl public-read + release: needs: - build-linux diff --git a/.github/workflows/version-push.yml b/.github/workflows/version-push.yml index 9005292..ec06722 100644 --- a/.github/workflows/version-push.yml +++ b/.github/workflows/version-push.yml @@ -2,7 +2,7 @@ name: Upload TNT JSON on: push: - branches: [ main ] + branches: ["*"] paths: - 'Distribution/tnt-version.json' workflow_dispatch: @@ -15,9 +15,11 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 - - name: Upload to BunnyCDN + - name: Upload to OVH + env: + AWS_ACCESS_KEY_ID: ${{ secrets.OVH_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.OVH_SECRET }} run: | - curl -X PUT \ - -H "AccessKey: ${{ secrets.BUNNY_ACCESS_KEY }}" \ - --data-binary "@Distribution/tnt-version.json" \ - "https://se.storage.bunnycdn.com/sw-stor/tnt-version.json" + aws s3 cp "Distribution/tnt-version.json" "s3://${{ secrets.OVH_BUCKET }}/" \ + --endpoint-url "${{ secrets.OVH_ENDPOINT }}" \ + --acl public-read diff --git a/Distribution/tnt-version.json b/Distribution/tnt-version.json index 06725f3..68d1a09 100644 --- a/Distribution/tnt-version.json +++ b/Distribution/tnt-version.json @@ -1,33 +1,50 @@ { - "version": "1.2.3", - "os": ["darwin", "darwin-senior", "penguin", "orangutan"], - "download_url": [{ - "darwin": "https://software.collinsgroup.fi/TNT.dmg", - "darwin-senior": "https://software.collinsgroup.fi/TNT-Intel.dmg", - "orangutan": "https://software.collinsgroup.fi/TNT.exe", - "penguin": "https://software.collinsgroup.fi/tnt-amd64.deb" - }], - "release_notes": "Automatically check mono compatibility before processing.", - "release_date": "2 February, 2026", - "supported_platforms": "macOS (M-series), macOS 15- (Intel), Debian/Ubuntu (amd64)", - "history": [ - { - "version": "1.2.2", - "os": ["darwin", "orangutan", "penguin"], - "release_notes": "Library linking fixes affecting all Debian users.", - "release_date": "19 January, 2026", - "supported_platforms": "macOS (M-series), macOS 15- (Intel), Windows (Intel), Ubuntu/Debian (Intel)" - }, - { - "version": "1.2.1", - "release_notes": "Lets you update the version in the app.", - "release_date": "31 December, 2025" - }, - { - "version": "1.2.0", - "release_notes": "Adds powerful processing features.", - "release_date": "5 December, 2025" + "platforms": { + "darwin-arm64": { + "version": "1.2.3", + "download_url": "https://frm-sw-storage.s3.rbx.io.cloud.ovh.net/mac/m/tnt.dmg", + "supported_platforms": "macOS (M-series)", + "release_notes": "Automatically check mono compatibility before processing.", + "release_date": "2 February, 2026" + }, + "darwin-amd64": { + "version": "1.2.3", + "download_url": "https://frm-sw-storage.s3.rbx.io.cloud.ovh.net/mac/intel/tnt.dmg", + "supported_platforms": "macOS 15- (Intel)", + "release_notes": "Automatically check mono compatibility before processing.", + "release_date": "2 February, 2026" + }, + "windows": { + "version": "1.2.3", + "download_url": "https://frm-sw-storage.s3.rbx.io.cloud.ovh.net/win/tnt.exe", + "supported_platforms": "Windows (Intel)", + "release_notes": "Automatically check mono compatibility before processing.", + "release_date": "2 February, 2026" + }, + "linux": { + "version": "1.2.3", + "download_url": "https://frm-sw-storage.s3.rbx.io.cloud.ovh.net/linux/amd/tnt.deb", + "supported_platforms": "Debian/Ubuntu (amd64)", + "release_notes": "Automatically check mono compatibility before processing.", + "release_date": "2 February, 2026" + } }, + "history": [ + { + "version": "1.2.2", + "release_notes": "Library linking fixes affecting all Debian users.", + "release_date": "19 January, 2026" + }, + { + "version": "1.2.1", + "release_notes": "Lets you update the version in the app.", + "release_date": "31 December, 2025" + }, + { + "version": "1.2.0", + "release_notes": "Adds powerful processing features.", + "release_date": "5 December, 2025" + }, { "version": "1.1.0", "release_notes": "Adds FLAC encoder and data compression methods for FLAC and Opus.\nHides disabled items from UI based on encoding format.\nRewritten help section.", @@ -41,8 +58,7 @@ { "version": "1.0.1", "release_date": "31 October, 2025", - "release_notes": "macOS users get access to Apple AAC encoder.\nWindows users get access to Fraunhofer AAC encoder(deprecates native AAC).\nPreferences can now be saved.\nFixes library linking issues on macOS.\nReduces executable size on Windows.\nError reports can now be sent from the app.", - "easter-egg": "this field is an easter egg for developer purposes" + "release_notes": "macOS users get access to Apple AAC encoder.\nWindows users get access to Fraunhofer AAC encoder(deprecates native AAC).\nPreferences can now be saved.\nFixes library linking issues on macOS.\nReduces executable size on Windows.\nError reports can now be sent from the app..." } ] } diff --git a/go/app.go b/go/app.go index 88c4255..c7126e5 100644 --- a/go/app.go +++ b/go/app.go @@ -2,7 +2,6 @@ package main import ( "context" - "os" "runtime" "sync" @@ -19,7 +18,7 @@ type AudioNormalizer struct { mutex sync.Mutex advancedMode bool - normalizationStandard string + normalizationStandard Standard format string sampleRate string bitDepth string @@ -45,7 +44,8 @@ type AudioNormalizer struct { jobQueue chan string watcherMutex sync.Mutex - logFile *os.File + logFile *LogIntoFile + appLog *LogApp telemetry *telemetry.Client telemetryEnabled bool diff --git a/go/audio/amplification.go b/go/audio/amplification.go new file mode 100644 index 0000000..f973428 --- /dev/null +++ b/go/audio/amplification.go @@ -0,0 +1,161 @@ +package audio + +import ( + "bufio" + "encoding/binary" + "fmt" + "io" + "math" + "os" + "path/filepath" +) + +// Gain applies a linear gain of offsetDB decibels to every sample of the +// stereo WAV at path, rewriting the file in place as a 32-bit float WAV +// (pcm_f32le, audioFormat=3). The write is atomic: samples are written to a +// temp file in the same directory and then renamed over the original. +func Gain(path string, offsetDB float64) error { + left, right, sampleRate, err := readSamples(path) + if err != nil { + return fmt.Errorf("gain: %w", err) + } + GainSamples(left, right, offsetDB) + return WriteFloat32WAV(path, sampleRate, left, right) +} + +// GainSamples applies a linear gain of offsetDB decibels to both channels in +// place. It is the in-memory core of Gain. +func GainSamples(left, right []float64, offsetDB float64) { + gain := math.Pow(10, offsetDB/20) + for i := range left { + left[i] *= gain + } + for i := range right { + right[i] *= gain + } +} + +// WriteFloat32WAV writes stereo float64 samples to a 32-bit float WAV at path, +// atomically via a temp file in the same directory followed by a fsync and +// rename — the sync matters on servers, where a crash mid-pipeline must not +// leave a truncated file behind a completed rename. +func WriteFloat32WAV(path string, sampleRate uint32, left, right []float64) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".gain-*.wav.tmp") + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + tmpName := tmp.Name() + + if err := writeFloat32WAVTo(tmp, sampleRate, left, right); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("syncing temp file: %w", err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("closing temp file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return fmt.Errorf("renaming temp file over %s: %w", path, err) + } + return nil +} + +func writeFloat32WAVTo(w io.Writer, sampleRate uint32, left, right []float64) error { + const ( + numChannels = 2 + bitsPerSample = 32 + audioFormat = 3 // IEEE float + ) + nFrames := len(left) + nFrames = min(len(right), nFrames) + + blockAlign := uint16(numChannels * bitsPerSample / 8) + byteRate := sampleRate * uint32(blockAlign) + + // RIFF sizes are uint32: past 4 GiB the header silently wraps and every + // downstream reader sees a corrupt file. Refuse instead — callers with + // longer material must split or lower the intermediate sample rate. + dataSize64 := uint64(nFrames) * uint64(blockAlign) + if dataSize64+36 > math.MaxUint32 { + return fmt.Errorf("WAV data would be %d bytes — over the 4 GiB RIFF limit", dataSize64) + } + dataSize := uint32(dataSize64) + riffSize := 36 + dataSize + + // Buffer the writer: every header field and sample is emitted through bw as a + // tiny 2–4 byte write, so without buffering a long file turns into hundreds of + // millions of one-sample syscalls — slow enough to look like a hang at high + // internal sample rates (e.g. ~174M writes for a 4-minute 384 kHz stereo file). + buf := bufio.NewWriterSize(w, 1<<20) + bw := newByteWriter(buf) + + // RIFF header + bw.bytes([]byte("RIFF")) + bw.u32(riffSize) + bw.bytes([]byte("WAVE")) + + // fmt chunk + bw.bytes([]byte("fmt ")) + bw.u32(16) + bw.u16(audioFormat) + bw.u16(numChannels) + bw.u32(sampleRate) + bw.u32(byteRate) + bw.u16(blockAlign) + bw.u16(bitsPerSample) + + // data chunk + bw.bytes([]byte("data")) + bw.u32(dataSize) + + for i := 0; i < nFrames; i++ { + bw.u32(math.Float32bits(float32(left[i]))) + bw.u32(math.Float32bits(float32(right[i]))) + } + + if bw.err != nil { + return bw.err + } + return buf.Flush() +} + +// byteWriter is a tiny little-endian helper that defers error checking so the +// header/sample emission above reads cleanly. +type byteWriter struct { + w io.Writer + buf [4]byte + err error +} + +func newByteWriter(w io.Writer) *byteWriter { return &byteWriter{w: w} } + +func (b *byteWriter) bytes(p []byte) { + if b.err != nil { + return + } + _, b.err = b.w.Write(p) +} + +func (b *byteWriter) u16(v uint16) { + if b.err != nil { + return + } + binary.LittleEndian.PutUint16(b.buf[:2], v) + _, b.err = b.w.Write(b.buf[:2]) +} + +func (b *byteWriter) u32(v uint32) { + if b.err != nil { + return + } + binary.LittleEndian.PutUint32(b.buf[:4], v) + _, b.err = b.w.Write(b.buf[:4]) +} diff --git a/go/internal/audio/audio_test.go b/go/audio/audio_test.go similarity index 97% rename from go/internal/audio/audio_test.go rename to go/audio/audio_test.go index 46df162..6871ad7 100644 --- a/go/internal/audio/audio_test.go +++ b/go/audio/audio_test.go @@ -140,13 +140,10 @@ func TestParseAstatsOutput(t *testing.T) { if !approx(a.NoiseFloor, -77.123456, 1e-4) { t.Errorf("NoiseFloor = %v", a.NoiseFloor) } - // Crest/dyn come from the first match (Channel 1) + // Crest comes from the first match (Channel 1) if !approx(a.CrestFactor, 4.567890, 1e-4) { t.Errorf("CrestFactor = %v", a.CrestFactor) } - if !approx(a.DynamicRange, 25.678901, 1e-4) { - t.Errorf("DynamicRange = %v", a.DynamicRange) - } } func TestParseAstatsEmpty(t *testing.T) { 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/audio/dynamics.go b/go/audio/dynamics.go new file mode 100644 index 0000000..3811b89 --- /dev/null +++ b/go/audio/dynamics.go @@ -0,0 +1,565 @@ +package audio + +import ( + "fmt" + "math" + "runtime" + "sort" + "sync" +) + +// This file holds three purely time-domain dynamics processors: +// +// 1. LookaheadLimiter — anticipatory brick-wall (conformity) limiter. +// 2. CharacterLimiter — soft-knee limiter with caller-supplied time factors. +// 3. Compress — full-range compressor with caller-supplied time factors. +// +// Each comes in two flavours: a path wrapper that reads a WAV, processes it and +// writes it back as 32-bit float (matching Gain in amplification.go), and a +// *Samples core that mutates stereo []float64 buffers in place so the algorithm +// is unit-testable without touching disk. Detection is stereo-linked throughout +// (a single gain is derived from max(|L|,|R|) and applied to both channels) so +// the stereo image is never pulled off-centre by the gain riding. + +// onePoleCoef returns the one-pole smoothing coefficient for a time constant of +// timeMs at sampleRate. A state value multiplied by this coefficient each sample +// decays to 1/e of its distance from the target after timeMs. A non-positive +// timeMs yields 0, i.e. an instantaneous (single-sample) move. +func onePoleCoef(timeMs, sampleRate float64) float64 { + if timeMs <= 0 { + return 0 + } + return math.Exp(-1.0 / (timeMs / 1000.0 * sampleRate)) +} + +// clampMag hard-limits x to the symmetric range [-m, m]. +func clampMag(x, m float64) float64 { + if x > m { + return m + } + if x < -m { + return -m + } + return x +} + +// staticGainDb returns the gain (in dB, always <= 0) that the static transfer +// curve applies to an input whose level is levelDb, given a threshold, a +// compression ratio and a soft knee kneeDb wide centred on the threshold. This +// is the standard soft-knee characteristic from Reiss & McPherson, "Audio +// Effects: Theory, Implementation and Application". +// +// ratio == 1 is a no-op (slope 0); ratio == math.Inf(1) makes it a hard limiter, +// pinning everything above the threshold to the threshold. A kneeDb <= 0 gives a +// hard corner at the threshold. +func staticGainDb(levelDb, thresholdDb, ratio, kneeDb float64) float64 { + slope := 1.0/ratio - 1.0 // 0 for ratio=1, -1 for ratio=+Inf + over := levelDb - thresholdDb + + switch { + case kneeDb > 0 && 2*over < -kneeDb: + return 0 + case kneeDb > 0 && 2*math.Abs(over) <= kneeDb: + // Quadratic blend across the knee so the curve and its first + // derivative are continuous at both knee edges. + x := over + kneeDb/2 + return slope * x * x / (2 * kneeDb) + default: + if over <= 0 { + return 0 + } + return slope * over + } +} + +// LookaheadLimiter applies the anticipatory brick-wall limiter at path in place, +// rewriting it as a 32-bit float WAV. thresholdDb is the ceiling (e.g. -1.0), +// lookaheadMs is how far ahead the gain reduction is allowed to start ramping in +// (also doubles as the attack time), and releaseMs governs the recovery. +func LookaheadLimiter(path string, thresholdDb, lookaheadMs, releaseMs float64) error { + left, right, sampleRate, err := readStereo(path) + if err != nil { + return fmt.Errorf("lookahead limiter: %w", err) + } + LookaheadLimitSamples(left, right, float64(sampleRate), thresholdDb, lookaheadMs, releaseMs) + return WriteFloat32WAV(path, sampleRate, left, right) +} + +// LookaheadLimitSamples is the in-place core of LookaheadLimiter. +// +// It is a conformity limiter: the output is mathematically guaranteed never to +// exceed the threshold. The reduction needed at every sample is computed first, +// then a sliding-window minimum over the lookahead window pulls that reduction +// forward in time, so by the moment a peak is emitted the gain is already down — +// no peak slips through while the envelope is still ramping. The smoothed +// envelope is what makes that ramp gradual rather than a click-inducing step; a +// final per-sample clamp at the threshold is the brick-wall backstop that mops +// up the sub-dB overshoot smoothing can leave on the very first sample of a fast +// transient. On well-tuned lookahead the clamp engages on almost nothing, so the +// result stays transparent. +func LookaheadLimitSamples(left, right []float64, sampleRate, thresholdDb, lookaheadMs, releaseMs float64) { + n := min(len(left), len(right)) + if n == 0 { + return + } + thresh := DbToLinear(thresholdDb) + + look := int(math.Round(lookaheadMs / 1000.0 * sampleRate)) + look = max(1, look) + look = min(n-1, look) + + // required[i] is the gain that tames the inter-sample (TRUE) peak at sample i to + // the threshold. Detection runs through the BS.1770 4× polyphase FIR (tpFIR, + // shared with the LUFS true-peak meter), so the limiter constrains the + // reconstructed waveform — the real peaks between samples — not just the + // discrete sample values. That is what makes this a genuine true-peak limiter: + // the output's true peak is guaranteed under the ceiling at the working rate, + // no oversampled pipeline required to fake it. The per-channel envelopes are + // folded straight into required[] rather than materialized — on multi-hour + // files the two extra float64 arrays were gigabytes of transient memory — + // and the scan is chunk-parallel: each output depends only on the 12 + // preceding (read-only) input samples and the writes are disjoint. + required := make([]float64, n) + chunkedParallel(n, func(start, end int) { + requiredGainRange(left, right, required, thresh, start, end) + }) + + // winMin[i] = min(required[i .. i+look]); the gain therefore starts dropping + // as soon as a peak enters the lookahead window, never after it has passed. + winMin := slidingMin(required, look) + + relCoef := onePoleCoef(releaseMs, sampleRate) + + // Instant attack to the windowed minimum, smooth release. Because the gain + // snaps straight to winMin (which already anticipates every peak inside the + // lookahead) and only ever rises toward it on release, gain[i] <= winMin[i] <= + // required[i] at all times — so the output true peak is GUARANTEED <= ceiling. + // A one-pole attack would lag and overshoot; the lookahead (> FIR length) gives + // the step room to settle before the peak emerges, keeping it click-free enough. + gain := 1.0 + for i := range n { + target := winMin[i] + if target < gain { + gain = target // instant attack — the conformance guarantee + } else { + gain = relCoef*gain + (1-relCoef)*target + } + left[i] = clampMag(left[i]*gain, thresh) + right[i] = clampMag(right[i]*gain, thresh) + } +} + +// requiredGainRange fills required[start:end] with the gain that tames the +// stereo-linked true peak at each position to thresh (1.0 where no reduction +// is needed). The FIR reads input back to start-11; zero before index 0. +func requiredGainRange(left, right, required []float64, thresh float64, start, end int) { + const taps = 12 + for i := start; i < end; i++ { + var mx float64 + for phase := range 4 { + var accL, accR float64 + for k := range taps { + idx := i - k + if idx < 0 { + break + } + accL += tpFIR[phase][k] * left[idx] + accR += tpFIR[phase][k] * right[idx] + } + if a := math.Abs(accL); a > mx { + mx = a + } + if a := math.Abs(accR); a > mx { + mx = a + } + } + if mx > thresh { + required[i] = thresh / mx + } else { + required[i] = 1 + } + } +} + +// chunkedParallel splits [0, n) into one contiguous range per worker and +// runs fn over them concurrently. fn must be safe for disjoint ranges (the +// envelope/peak scans here only read shared input and write disjoint output). +// Small inputs run inline. +func chunkedParallel(n int, fn func(start, end int)) { + workers := runtime.GOMAXPROCS(0) + if workers > 8 { + workers = 8 + } + const minChunk = 1 << 16 + if n < minChunk*2 || workers < 2 { + fn(0, n) + return + } + chunk := (n + workers - 1) / workers + var wg sync.WaitGroup + for w := range workers { + start := w * chunk + if start >= n { + break + } + end := min(start+chunk, n) + wg.Add(1) + go func(start, end int) { + defer wg.Done() + fn(start, end) + }(start, end) + } + wg.Wait() +} + +// slidingMin returns, for every index i, the minimum of x over the forward +// window [i, i+look]. It runs in O(n) via a monotonic deque of candidate +// indices whose values increase from the front (so the front is always the +// window minimum). Scanning right-to-left lets each index be pushed once and the +// out-of-window front be dropped once. +func slidingMin(x []float64, look int) []float64 { + n := len(x) + out := make([]float64, n) + dq := make([]int, 0, look+1) // indices, x increasing from front to back + for i := n - 1; i >= 0; i-- { + for len(dq) > 0 && x[dq[len(dq)-1]] >= x[i] { + dq = dq[:len(dq)-1] + } + dq = append(dq, i) + for dq[0] > i+look { + dq = dq[1:] + } + out[i] = x[dq[0]] + } + return out +} + +// dsWindowSeconds is ffmpeg astats' default `length`: the time constant of the +// exponential moving average behind the windowed RMS-peak measurement. +const dsWindowSeconds = 0.05 + +// MeasureDynamicsScore computes the Dynamics Score for path natively in Go, +// without shelling out to ffmpeg. The score is a purely time-domain statistic +// (no FFT), so it needs nothing astats provides that we cannot compute directly. +// +// It reproduces astats' channel-1 figures: the overall RMS level, the crest +// factor (peak / RMS), and the RMS peak — the maximum over the signal of an +// exponential moving average of the squared samples, time constant +// dsWindowSeconds. That EMA matches astats' "RMS peak dB" to within ~0.001 dB +// across window lengths. The score is sqrt(crest) × (RMSpeak_dB − RMSlevel_dB), +// identical to ParseDynamicsScore so the native and ffmpeg paths agree. +func MeasureDynamicsScore(path string) (*DynamicsScoreAnalysis, error) { + left, _, sampleRate, err := readStereo(path) + if err != nil { + return nil, fmt.Errorf("dynamics score: %w", err) + } + return dynamicsScore(left, float64(sampleRate)), nil +} + +// MeasureDynamicsScoreSamples computes the Dynamics Score from in-memory +// channel-1 (left) samples — the buffer-level form of MeasureDynamicsScore for +// callers that keep the whole file resident across processing stages. +func MeasureDynamicsScoreSamples(left []float64, sampleRate float64) *DynamicsScoreAnalysis { + return dynamicsScore(left, sampleRate) +} + +// dynamicsScore is the in-memory core of MeasureDynamicsScore, operating on the +// channel-1 (left) samples — the channel astats' DS is parsed from. +func dynamicsScore(x []float64, sampleRate float64) *DynamicsScoreAnalysis { + r := &DynamicsScoreAnalysis{} + n := len(x) + if n == 0 { + return r + } + + alpha := 1.0 - math.Exp(-1.0/(dsWindowSeconds*sampleRate)) + var sumSq, peak, ema, maxEMA float64 + for _, s := range x { + sq := s * s + sumSq += sq + if a := math.Abs(s); a > peak { + peak = a + } + ema += alpha * (sq - ema) + if ema > maxEMA { + maxEMA = ema + } + } + + rms := math.Sqrt(sumSq / float64(n)) + if rms <= 0 { + return r // digital silence: leave the score at zero + } + r.RMSLevel = 20 * math.Log10(rms) + r.RMSPeak = 10 * math.Log10(maxEMA) // 10·log10 — maxEMA is already power + r.CrestFactor = peak / rms + r.DynamicsScore = math.Sqrt(r.CrestFactor) * (r.RMSPeak - r.RMSLevel) + return r +} + +// PeakPercentile returns the pct-th percentile (0–100) of per-window peak levels +// across the file, in dBFS. Each window is windowSec long and its level is the +// max |sample| over both channels. pct=95 yields the level just under the loudest +// ~5% of windows — a natural place to put a limiter threshold that should catch +// only the highest peaks and leave everything else alone. +func PeakPercentile(path string, windowSec, pct float64) (float64, error) { + left, right, sampleRate, err := readStereo(path) + if err != nil { + return 0, fmt.Errorf("peak percentile: %w", err) + } + n := min(len(left), len(right)) + if n == 0 { + return -100, nil + } + win := int(windowSec * float64(sampleRate)) + if win < 1 { + win = 1 + } + peaks := make([]float64, 0, n/win+1) + for i := 0; i < n; i += win { + end := min(i+win, n) + var pk float64 + for j := i; j < end; j++ { + if a := math.Abs(left[j]); a > pk { + pk = a + } + if a := math.Abs(right[j]); a > pk { + pk = a + } + } + peaks = append(peaks, pk) + } + sort.Float64s(peaks) + return LinearToDb(percentile(peaks, pct)), nil +} + +// limiterCalibration is the threshold/attack/release triple that drives the +// time-domain limiters at a given strength. These are the canonical pre-LUFS +// limiter values (they previously lived in the ffmpeg alimiter path, since +// replaced by the native conformity limiter). +// +// Calibrated for Apple AAC at bitrates 400 (no processing), 128 (strength 1), +// 96 (strength 2) and 64 kbit/s (strength 3); all land at -1.1...-1 dB TP. Other +// encoders and bitrates may differ. +type limiterCalibration struct { + thresholdDb float64 + attackMs float64 + releaseMs float64 +} + +func softLimiterCalibration(strength int) limiterCalibration { + c := limiterCalibration{thresholdDb: -3.2, attackMs: 20, releaseMs: 80} + switch strength { + case 1: + c = limiterCalibration{thresholdDb: -3.2, attackMs: 18, releaseMs: 75} + case 2: + c = limiterCalibration{thresholdDb: -4.2, attackMs: 10, releaseMs: 60} + case 3: + c = limiterCalibration{thresholdDb: -10.0, attackMs: 8, releaseMs: 16} + } + return c +} + +// characterLimiterKnee is the soft-knee width (dB) the strength-preset character +// limiter bends over; the conformity limiter is a hard brick wall and uses none. +const characterLimiterKnee = 6.0 + +// LimitConforming applies the lookahead brick-wall (conformity) limiter at the +// calibrated strength, rewriting path as a 32-bit float WAV. The calibration's +// attack time is used as the lookahead window. +func LimitConforming(path string, strength int) error { + c := softLimiterCalibration(strength) + left, right, sampleRate, err := readStereo(path) + if err != nil { + return fmt.Errorf("conformity limiter: %w", err) + } + LookaheadLimitSamples(left, right, float64(sampleRate), c.thresholdDb, c.attackMs, c.releaseMs) + return WriteFloat32WAV(path, sampleRate, left, right) +} + +// LimitCharacter applies the soft-knee character limiter at the calibrated +// strength, rewriting path as a 32-bit float WAV. +func LimitCharacter(path string, strength int) error { + c := softLimiterCalibration(strength) + left, right, sampleRate, err := readStereo(path) + if err != nil { + return fmt.Errorf("character limiter: %w", err) + } + CharacterLimitSamples(left, right, float64(sampleRate), c.thresholdDb, characterLimiterKnee, c.attackMs, c.releaseMs) + return WriteFloat32WAV(path, sampleRate, left, right) +} + +// CharacterLimiter applies a soft-knee limiter at path in place, rewriting it as +// a 32-bit float WAV. Unlike LookaheadLimiter it does not look ahead, so its +// finite attack lets transients punch through and its release pumps — that +// audible behaviour is the "character". The time factors are the caller's: +// attackMs / releaseMs shape how fast the gain dives and recovers, kneeDb sets +// how gently it bends into limiting around thresholdDb. +func CharacterLimiter(path string, thresholdDb, kneeDb, attackMs, releaseMs float64) error { + left, right, sampleRate, err := readStereo(path) + if err != nil { + return fmt.Errorf("character limiter: %w", err) + } + CharacterLimitSamples(left, right, float64(sampleRate), thresholdDb, kneeDb, attackMs, releaseMs) + return WriteFloat32WAV(path, sampleRate, left, right) +} + +// CharacterLimitSamples is the in-place core of CharacterLimiter: a feed-forward +// limiter is just a compressor with an infinite ratio and no make-up gain. +func CharacterLimitSamples(left, right []float64, sampleRate, thresholdDb, kneeDb, attackMs, releaseMs float64) { + processDynamics(left, right, sampleRate, thresholdDb, math.Inf(1), kneeDb, attackMs, releaseMs, 0, 0) +} + +// Compress applies a feed-forward compressor at path in place, rewriting it as a +// 32-bit float WAV. All the controls are the caller's: thresholdDb, ratio (>= 1), +// kneeDb, the attackMs / releaseMs time factors and makeupDb of output gain. The +// level detector is peak when rmsMs <= 0, or RMS with that averaging window when +// rmsMs > 0 (use RMS for loudness/LRA leveling). +func Compress(path string, thresholdDb, ratio, kneeDb, attackMs, releaseMs, makeupDb, rmsMs float64) error { + left, right, sampleRate, err := readStereo(path) + if err != nil { + return fmt.Errorf("compressor: %w", err) + } + CompressSamples(left, right, float64(sampleRate), thresholdDb, ratio, kneeDb, attackMs, releaseMs, makeupDb, rmsMs) + return WriteFloat32WAV(path, sampleRate, left, right) +} + +// CompressSamples is the in-place core of Compress. +func CompressSamples(left, right []float64, sampleRate, thresholdDb, ratio, kneeDb, attackMs, releaseMs, makeupDb, rmsMs float64) { + processDynamics(left, right, sampleRate, thresholdDb, ratio, kneeDb, attackMs, releaseMs, makeupDb, rmsMs) +} + +// upwardGainDb returns the boost (>= 0 dB) an upward compressor applies to a +// passage at levelDb sitting BELOW thresholdDb: (threshold - level)·(1 - 1/ratio), +// soft-kneed and capped at maxBoostDb so it can't lift the noise floor without +// bound. Levels at or above the threshold get 0. It is the mirror of staticGainDb. +func upwardGainDb(levelDb, thresholdDb, ratio, kneeDb, maxBoostDb float64) float64 { + slope := 1.0 - 1.0/ratio // 0 at ratio 1, -> 1 as ratio grows + under := thresholdDb - levelDb + + var boost float64 + switch { + case kneeDb > 0 && 2*under < -kneeDb: + return 0 + case kneeDb > 0 && 2*math.Abs(under) <= kneeDb: + x := under + kneeDb/2 + boost = slope * x * x / (2 * kneeDb) + default: + if under <= 0 { + return 0 + } + boost = slope * under + } + if boost > maxBoostDb { + boost = maxBoostDb + } + return boost +} + +// UpwardCompress raises passages below thresholdDb toward it, shrinking loudness +// range from the bottom — the complement of the downward Compress, which lowers +// passages above threshold. Sharing the work between the two lets you control LRA +// with far less gain reduction on the loud side, so fewer downward-compression +// artefacts. maxBoostDb caps the lift so quiet gaps / noise floor aren't dragged +// up indefinitely; detector and timing are as in Compress (use rmsMs>0 for +// loudness leveling). The attack is the rate the boost ramps IN, so a slow attack +// keeps brief gaps from being lifted. +func UpwardCompress(path string, thresholdDb, ratio, kneeDb, attackMs, releaseMs, maxBoostDb, rmsMs float64) error { + left, right, sampleRate, err := readStereo(path) + if err != nil { + return fmt.Errorf("upward compressor: %w", err) + } + UpwardCompressSamples(left, right, float64(sampleRate), thresholdDb, ratio, kneeDb, attackMs, releaseMs, maxBoostDb, rmsMs) + return WriteFloat32WAV(path, sampleRate, left, right) +} + +// UpwardCompressSamples is the in-place core of UpwardCompress. +func UpwardCompressSamples(left, right []float64, sampleRate, thresholdDb, ratio, kneeDb, attackMs, releaseMs, maxBoostDb, rmsMs float64) { + n := min(len(left), len(right)) + if n == 0 { + return + } + attCoef := onePoleCoef(attackMs, sampleRate) + relCoef := onePoleCoef(releaseMs, sampleRate) + rmsCoef := onePoleCoef(rmsMs, sampleRate) + useRMS := rmsMs > 0 + var meanSq float64 + + gain := 1.0 // >= 1: this stage only boosts + for i := range n { + var level float64 + if useRMS { + power := 0.5 * (left[i]*left[i] + right[i]*right[i]) + meanSq = rmsCoef*meanSq + (1-rmsCoef)*power + level = math.Sqrt(meanSq) + } else { + level = math.Max(math.Abs(left[i]), math.Abs(right[i])) + } + target := DbToLinear(upwardGainDb(LinearToDb(level), thresholdDb, ratio, kneeDb, maxBoostDb)) // >= 1 + + if target > gain { + gain = attCoef*gain + (1-attCoef)*target // boost ramping in (attack) + } else { + gain = relCoef*gain + (1-relCoef)*target // backing off (release) + } + + left[i] *= gain + right[i] *= gain + } +} + +// processDynamics is the shared feed-forward compressor/limiter engine. For each +// frame it derives a stereo-linked level, looks up the static gain reduction on +// the soft-knee curve, then smooths the gain with the attack coefficient when +// more reduction is wanted and the release coefficient when less is wanted (a +// decoupled detector — branch on the gain direction, not on the level). The +// smoothed gain plus a constant make-up gain is applied to both channels. +// +// The level detector is peak (instantaneous max|L|,|R|) when rmsMs <= 0 — correct +// for limiting — or RMS when rmsMs > 0: a one-pole-smoothed mean-square with that +// time constant, which tracks sustained loudness rather than transient peaks and +// is the right detector for loudness/LRA leveling. +func processDynamics(left, right []float64, sampleRate, thresholdDb, ratio, kneeDb, attackMs, releaseMs, makeupDb, rmsMs float64) { + n := min(len(left), len(right)) + if n == 0 { + return + } + attCoef := onePoleCoef(attackMs, sampleRate) + relCoef := onePoleCoef(releaseMs, sampleRate) + makeup := DbToLinear(makeupDb) + + rmsCoef := onePoleCoef(rmsMs, sampleRate) + useRMS := rmsMs > 0 + var meanSq float64 // smoothed mean-square for the RMS detector + + gain := 1.0 + for i := range n { + var level float64 + if useRMS { + power := 0.5 * (left[i]*left[i] + right[i]*right[i]) + meanSq = rmsCoef*meanSq + (1-rmsCoef)*power + level = math.Sqrt(meanSq) + } else { + level = math.Max(math.Abs(left[i]), math.Abs(right[i])) + } + reductionDb := staticGainDb(LinearToDb(level), thresholdDb, ratio, kneeDb) + target := DbToLinear(reductionDb) // <= 1 + + if target < gain { + gain = attCoef*gain + (1-attCoef)*target + } else { + gain = relCoef*gain + (1-relCoef)*target + } + + left[i] *= gain * makeup + right[i] *= gain * makeup + } +} + +// readStereo reads a WAV at path into stereo float64 buffers and also returns +// its sample rate. It is a thin alias for the package-central readSamples, +// kept for the time-based processors here that turn millisecond time factors +// into per-sample coefficients. +func readStereo(path string) (left, right []float64, sampleRate uint32, err error) { + return readSamples(path) +} diff --git a/go/internal/audio/dynaudnorm.go b/go/audio/dynaudnorm.go similarity index 50% rename from go/internal/audio/dynaudnorm.go rename to go/audio/dynaudnorm.go index 63c5d4f..ea61283 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 @@ -25,30 +26,30 @@ func CalculateDynaudnormParams(analysis *DynamicsAnalysis) *DynaudnormParams { params.Threshold = math.Pow(10, thresholdDB/20) // Clamp to valid ranges (0.0-1.0) - 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 - } + params.TargetRMS = max(min(params.TargetRMS, 1.0), 0.0) + params.Threshold = max(min(params.Threshold, 1.0), 0.0) return params } -// BuildDynaudnormFilter creates the dynaudnorm filter string from params -func BuildDynaudnormFilter(params *DynaudnormParams) string { - if params == nil { +// 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, isSpeech bool) string { + if params == nil || isSpeech { return "" } + // gausssize must be odd — ffmpeg coerced the old 36 to 37 with a warning, + // so 37 is spelled out to keep the effective behaviour and lose the warning. return fmt.Sprintf( - "dynaudnorm=framelen=650:gausssize=36:targetrms=%.6f:threshold=%.6f:altboundary=true:overlap=0.95", + "dynaudnorm=framelen=650:gausssize=37:targetrms=%.6f:threshold=%.6f:altboundary=true:overlap=0.95", params.TargetRMS, params.Threshold, ) 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..7c1863f --- /dev/null +++ b/go/audio/freq.go @@ -0,0 +1,521 @@ +package audio + +import ( + "fmt" + "math" + "os/exec" + "regexp" + "runtime" + "strconv" + "strings" + "sync" +) + +// AnalyzeFrequencyResponseBands analyzes the frequency response across 10 +// bands using lowpass, bandpass, and highpass filters with astats. Each +// band is an independent ffmpeg invocation reading the source file; they +// run concurrently up to min(numBands, GOMAXPROCS) goroutines to use +// available cores. A single failed band stops the analysis and returns +// the first error. +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"}, + } + + // Bound concurrency at GOMAXPROCS to avoid swamping the box on small + // hosts; 10 ffmpeg processes on a 4-core machine is worse than 4. + maxParallel := runtime.GOMAXPROCS(0) + if maxParallel < 1 { + maxParallel = 1 + } + if maxParallel > len(bands) { + maxParallel = len(bands) + } + + sem := make(chan struct{}, maxParallel) + errs := make([]error, len(bands)) + var wg sync.WaitGroup + + for i := range bands { + i := i + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + + 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": + 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", + "-", + ) + out, err := cmd.CombinedOutput() + if err != nil { + errs[i] = fmt.Errorf("ffmpeg analysis of %s band failed: %w", band.Frequency, err) + return + } + stats := parseFrequencyBandStats(string(out)) + band.RMSLevel = stats["rms"] + band.PeakLevel = stats["peak"] + band.CrestFactor = stats["crest"] + }() + } + wg.Wait() + + for _, err := range errs { + if err != nil { + return nil, err + } + } + 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/audio/lra.go b/go/audio/lra.go new file mode 100644 index 0000000..63f63e1 --- /dev/null +++ b/go/audio/lra.go @@ -0,0 +1,82 @@ +package audio + +// ReduceLRA shrinks the loudness range of the file at path toward targetLRA using +// a converging multipass: an eased downward compressor, an upward compressor, and +// a slow character limiter — all RMS-detecting and DS-driven. Each pass is gentle +// and keyed off the current overshoot, so the passes auto-taper and converge on +// target instead of overshooting. It stops at target, at maxPasses, or on +// diminishing returns (a pass buying < 0.3 LU). Returns the final LRA. +// +// This is the shared implementation used by both the app pipeline and the +// audition CLI, so they can never drift. +func ReduceLRA(path string, targetLRA float64, maxPasses int) (float64, error) { + left, right, sampleRate, err := readSamples(path) + if err != nil { + return 0, err + } + lra := ReduceLRASamples(left, right, float64(sampleRate), targetLRA, maxPasses) + if err := WriteFloat32WAV(path, sampleRate, left, right); err != nil { + return lra, err + } + return lra, nil +} + +// ReduceLRASamples is the in-memory core of ReduceLRA: it runs the same +// converging multipass directly on resident stereo buffers, so multi-pass +// processing costs one read and one write instead of ~ten file round-trips +// per pass. Returns the final LRA. +// +// The loop measures with loudnessRange directly — LRA is all it steers on, +// and skipping the integrated-loudness and true-peak legs of a full +// measurement saves most of each iteration's metering cost on long files. +func ReduceLRASamples(left, right []float64, sampleRate, targetLRA float64, maxPasses int) float64 { + lra := loudnessRange(left, right, sampleRate) + for pass := 0; pass < maxPasses && lra > targetLRA; pass++ { + before := lra + reduceLRAPass(left, right, sampleRate, lra-targetLRA) + lra = loudnessRange(left, right, sampleRate) + if before-lra < 0.3 { + break // diminishing returns + } + } + return lra +} + +// reduceLRAPass applies one gentle pass of the LRA chain, sized to the +// current overshoot (over = current LRA − target, measured by the caller so +// it isn't re-measured here). Thresholds come from the RMS statistics; all +// time constants are DS-driven via GetCompressionModifiers. +func reduceLRAPass(left, right []float64, sampleRate, over float64) { + if over <= 0 { + return + } + + ds := MeasureDynamicsScoreSamples(left, sampleRate) + + const wideKnee = 12.0 + const rmsWindowMs = 300.0 // RMS detection — track sustained loudness, not peaks + mods := GetCompressionModifiers(ds.DynamicsScore) + + // Downward comp, eased — the upward stage shares the load. + interp := clamp(0.6-over*0.05, 0.1, 0.6) + thresh := ds.RMSLevel + interp*(ds.RMSPeak-ds.RMSLevel) + ratio := clamp(1+over*0.15, 1, 6) + CompressSamples(left, right, sampleRate, thresh, ratio, wideKnee, + 100*mods.AttackMultiplier, 1500*mods.ReleaseMultiplier, 0, rmsWindowMs) + + // Upward comp — lift passages below RMS level toward it. + upRatio := clamp(1+over*0.1, 1, 4) + const upMaxBoost = 5.0 + UpwardCompressSamples(left, right, sampleRate, ds.RMSLevel, upRatio, wideKnee, + 300*mods.AttackMultiplier, 600*mods.ReleaseMultiplier, upMaxBoost, rmsWindowMs) + + // Slow character limiter — threshold from the post-comp DS, placed inside the + // RMS-to-RMSpeak span and pushed down the more dynamic the material is. + ds2 := MeasureDynamicsScoreSamples(left, sampleRate) + span := ds2.RMSPeak - ds2.RMSLevel + dyn := clamp((ds2.DynamicsScore-9)/12, 0, 1) + charThresh := ds2.RMSPeak - (0.2+0.3*dyn)*span + mods2 := GetCompressionModifiers(ds2.DynamicsScore) + CharacterLimitSamples(left, right, sampleRate, charThresh, 6, + 150*mods2.AttackMultiplier, 1200*mods2.ReleaseMultiplier) +} diff --git a/go/audio/lufs.go b/go/audio/lufs.go new file mode 100644 index 0000000..33029ef --- /dev/null +++ b/go/audio/lufs.go @@ -0,0 +1,431 @@ +package audio + +import ( + "fmt" + "math" + "runtime" + "sort" + "sync" +) + +// biquadFilter is a single Direct-Form-I biquad IIR section. +// Transfer function: H(z) = (b0 + b1·z⁻¹ + b2·z⁻²) / (1 + a1·z⁻¹ + a2·z⁻²) +// +// The pointer receiver on process() is essential: x1, x2, y1, y2 are the +// delay-line state. A value receiver would copy the struct on every call and +// discard the updated state, producing nonsense output. +type biquadFilter struct { + b0, b1, b2 float64 + a1, a2 float64 + x1, x2 float64 // input delay line + y1, y2 float64 // output delay line +} + +func (f *biquadFilter) process(x float64) float64 { + y := f.b0*x + f.b1*f.x1 + f.b2*f.x2 - f.a1*f.y1 - f.a2*f.y2 + f.x2, f.x1 = f.x1, x + f.y2, f.y1 = f.y1, y + return y +} + +// kWeightingFilters returns the two-stage K-weighting filter for sampleRate. +// K-weighting is defined in ITU-R BS.1770-4 Annex 1; coefficients are derived +// via the bilinear transform from the analog prototype, so any sample rate works. +// +// Named return values here are purely documentary — they tell the caller what +// they're getting without needing to read the function body. +func kWeightingFilters(sampleRate float64) (stage1, stage2 biquadFilter) { + // Bare {} blocks let us reuse short names (K, denom) for each stage + // without polluting the outer scope or adding ugly suffixes. + + // Stage 1: high-shelf pre-filter (+4 dB shelf at ~1.7 kHz). + // Models the acoustic effect of the listener's head on perceived loudness. + { + const ( + f0 = 1681.974450955533 + Q = 0.7071752369554196 + dbGain = 3.999843853973347 + ) + Vh := math.Pow(10, dbGain/20) // linear amplitude gain + Vb := math.Pow(10, dbGain/40) // geometric midpoint = sqrt(Vh) + K := math.Tan(math.Pi * f0 / sampleRate) + denom := 1 + K/Q + K*K + stage1 = biquadFilter{ + b0: (Vh + Vb*K/Q + K*K) / denom, + b1: 2 * (K*K - Vh) / denom, + b2: (Vh - Vb*K/Q + K*K) / denom, + a1: 2 * (K*K - 1) / denom, + a2: (1 - K/Q + K*K) / denom, + } + } + + // Stage 2: high-pass filter (RLB weighting, –3 dB at ~38 Hz). + // De-emphasises very low frequencies that contribute to loudness measurement + // but are rarely audible on consumer playback systems. + { + const ( + f0 = 38.13547087602444 + Q = 0.5003270373238773 + ) + K := math.Tan(math.Pi * f0 / sampleRate) + denom := 1 + K/Q + K*K + stage2 = biquadFilter{ + b0: 1 / denom, + b1: -2 / denom, + b2: 1 / denom, + a1: 2 * (K*K - 1) / denom, + a2: (1 - K/Q + K*K) / denom, + } + } + + return +} + +// kWeightChannel applies the two-stage K-weighting filter to a mono channel. +func kWeightChannel(samples []float64, sampleRate float64) []float64 { + s1, s2 := kWeightingFilters(sampleRate) + out := make([]float64, len(samples)) + for i, x := range samples { + out[i] = s2.process(s1.process(x)) + } + return out +} + +// kWeightStereo K-weights both channels concurrently. The biquads are +// IIR (serial per channel), so two goroutines — one per channel — is the +// available parallelism; on multi-hour files it halves the dominant cost of +// every loudness measurement. +func kWeightStereo(left, right []float64, sampleRate float64) (wL, wR []float64) { + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + wR = kWeightChannel(right, sampleRate) + }() + wL = kWeightChannel(left, sampleRate) + wg.Wait() + return wL, wR +} + +// LUFSResult holds integrated loudness, true-peak and loudness-range, +// per ITU-R BS.1770-5 and EBU Tech 3342. +type LUFSResult struct { + Integrated float64 // LUFS; math.Inf(-1) if the file contains only silence + TruePeak float64 // dBTP (highest across channels); math.Inf(-1) for silence + LRA float64 // Loudness Range in LU; 0 if fewer than two gated blocks +} + +const ( + blockSizeSec = 0.4 // 400 ms measurement window + hopSizeSec = 0.1 // 100 ms hop → 75% overlap between windows + absGateLevel = -70.0 // absolute gating threshold (LUFS) + + // LRA short-term parameters (EBU Tech 3342). + lraBlockSizeSec = 3.0 // 3 s short-term window + lraHopSizeSec = 0.75 // 750 ms hop → 75% overlap + lraRelGate = -20.0 // relative gate is −20 LU below the ungated mean +) + +// MeasureLUFS reads a WAV file and returns integrated loudness in LUFS. +func MeasureLUFS(path string) (*LUFSResult, error) { + left, right, sampleRate, err := readSamples(path) + if err != nil { + return nil, fmt.Errorf("measure LUFS: %w", err) + } + return MeasureLUFSSamples(left, right, float64(sampleRate)), nil +} + +// MeasureLUFSSamples measures integrated loudness, true peak and LRA from +// in-memory stereo buffers — the buffer-level form of MeasureLUFS for callers +// that keep the whole file resident across processing stages. +func MeasureLUFSSamples(left, right []float64, sampleRate float64) *LUFSResult { + return &LUFSResult{ + Integrated: integratedLUFS(left, right, sampleRate), + TruePeak: truePeak(left, right), + LRA: loudnessRange(left, right, sampleRate), + } +} + +// integratedLUFS implements the BS.1770-4 integrated loudness algorithm on +// pre-decoded stereo PCM samples. +func integratedLUFS(left, right []float64, sampleRate float64) float64 { + if len(left) == 0 { + return math.Inf(-1) + } + + // Step 1: K-weight both channels. + wL, wR := kWeightStereo(left, right, sampleRate) + + // Step 2: Slice into 400 ms blocks with 100 ms hops and collect the + // per-block power (sum of channel mean-squares, as BS.1770 specifies). + blockSize := int(math.Round(blockSizeSec * sampleRate)) + hopSize := int(math.Round(hopSizeSec * sampleRate)) + n := len(wL) + + var blockPowers []float64 + for start := 0; start+blockSize <= n; start += hopSize { + end := start + blockSize + msL := meanSquare(wL[start:end]) + msR := meanSquare(wR[start:end]) + // BS.1770 channel weights: 1.0 for L and R in stereo. + // The standard sums (not averages) so a dual-mono signal reads ~3 LU + // louder than a single channel — matching how humans perceive stereo width. + blockPowers = append(blockPowers, msL+msR) + } + + if len(blockPowers) == 0 { + return math.Inf(-1) + } + + // Step 3: Absolute gating — drop blocks below –70 LUFS. + // –70 LUFS absolute silence gate prevents noise floors from dragging down + // the mean. The constant –0.691 in the formula comes from the RMS-to-power + // conversion in the original BS.1770 derivation. + var aboveAbs []float64 + for _, p := range blockPowers { + if blockLUFS(p) >= absGateLevel { + aboveAbs = append(aboveAbs, p) + } + } + if len(aboveAbs) == 0 { + return math.Inf(-1) + } + + // Step 4: Relative gating — drop blocks more than 10 LU below the + // ungated mean of what survived the absolute gate. + ungatedMean := mean(aboveAbs) + relThreshold := blockLUFS(ungatedMean) - 10.0 + + var gated []float64 + for _, p := range aboveAbs { + if blockLUFS(p) >= relThreshold { + gated = append(gated, p) + } + } + if len(gated) == 0 { + return math.Inf(-1) + } + + // Step 5: Final loudness from the gated mean power. + return blockLUFS(mean(gated)) +} + +// blockLUFS converts a block's summed mean-square power to LUFS. +// The –0.691 offset is part of the ITU-R BS.1770 definition. +func blockLUFS(sumMeanSquare float64) float64 { + if sumMeanSquare <= 0 { + return math.Inf(-1) + } + return -0.691 + 10*math.Log10(sumMeanSquare) +} + +func meanSquare(s []float64) float64 { + var sum float64 + for _, v := range s { + sum += v * v + } + return sum / float64(len(s)) +} + +func mean(s []float64) float64 { + var sum float64 + for _, v := range s { + sum += v + } + return sum / float64(len(s)) +} + +// tpFIR holds the order-48, 4-phase FIR interpolating filter from +// ITU-R BS.1770-5 Annex 2 (the 4× over-sampling polyphase filter for +// true-peak estimation). Each phase has 12 taps; phase p produces the +// over-sampled output sample that sits at fraction p/4 between input samples. +// +// Indexing convention: outputs are sum over k of tpFIR[phase][k] · x[n-k], +// so tap 0 multiplies the most recent input sample. +var tpFIR = [4][12]float64{ + { // phase 0 + 0.0017089843750, 0.0109863281250, -0.0196533203125, 0.0332031250000, + -0.0594482421875, 0.1373291015625, 0.9721679687500, -0.1022949218750, + 0.0476074218750, -0.0266113281250, 0.0148925781250, -0.0083007812500, + }, + { // phase 1 + -0.0291748046875, 0.0292968750000, -0.0517578125000, 0.0891113281250, + -0.1665039062500, 0.4650878906250, 0.7797851562500, -0.2003173828125, + 0.1015625000000, -0.0582275390625, 0.0330810546875, -0.0189208984375, + }, + { // phase 2 + -0.0189208984375, 0.0330810546875, -0.0582275390625, 0.1015625000000, + -0.2003173828125, 0.7797851562500, 0.4650878906250, -0.1665039062500, + 0.0891113281250, -0.0517578125000, 0.0292968750000, -0.0291748046875, + }, + { // phase 3 + -0.0083007812500, 0.0148925781250, -0.0266113281250, 0.0476074218750, + -0.1022949218750, 0.9721679687500, 0.1373291015625, -0.0594482421875, + 0.0332031250000, -0.0196533203125, 0.0109863281250, 0.0017089843750, + }, +} + +// truePeak returns the inter-sample true-peak level in dBTP, taking the +// highest across the left and right channels (ITU-R BS.1770-5 Annex 2). +// Returns math.Inf(-1) for a fully silent signal. The two channels are +// scanned concurrently. +func truePeak(left, right []float64) float64 { + var pkL, pkR float64 + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + pkR = channelTruePeakLinear(right) + }() + pkL = channelTruePeakLinear(left) + wg.Wait() + pk := math.Max(pkL, pkR) + if pk <= 0 { + return math.Inf(-1) + } + // The 12.04 dB attenuate/restore pair in the standard cancels out in + // floating point, so the linear peak converts straight to dBTP. + return 20 * math.Log10(pk) +} + +// channelTruePeakLinear 4× over-samples one channel through the BS.1770-5 +// polyphase FIR and returns the maximum absolute interpolated value (linear). +// +// The scan is chunk-parallel: each output sample depends only on the 12 +// preceding input samples, and the input is read-only, so workers can take +// disjoint index ranges with no coordination beyond the final max-reduce. +// This is the hottest loop in the package (96 multiply-adds per frame) — +// on a long file the speedup is essentially linear in cores. +func channelTruePeakLinear(samples []float64) float64 { + n := len(samples) + if n == 0 { + return 0 + } + workers := runtime.GOMAXPROCS(0) + if workers > 8 { + workers = 8 + } + const minChunk = 1 << 16 + if n < minChunk*2 || workers < 2 { + return truePeakRange(samples, 0, n) + } + + chunk := (n + workers - 1) / workers + maxes := make([]float64, workers) + var wg sync.WaitGroup + for w := range workers { + start := w * chunk + if start >= n { + break + } + end := min(start+chunk, n) + wg.Add(1) + go func(w, start, end int) { + defer wg.Done() + maxes[w] = truePeakRange(samples, start, end) + }(w, start, end) + } + wg.Wait() + var maxAbs float64 + for _, m := range maxes { + if m > maxAbs { + maxAbs = m + } + } + return maxAbs +} + +// truePeakRange runs the polyphase FIR over output indices [start, end), +// reading input samples back to start-11 (zero before index 0). +func truePeakRange(samples []float64, start, end int) float64 { + const taps = 12 + var maxAbs float64 + for i := start; i < end; i++ { + for phase := range 4 { + var acc float64 + for k := range taps { + idx := i - k + if idx < 0 { + break // samples before the start are zero + } + acc += tpFIR[phase][k] * samples[idx] + } + if a := math.Abs(acc); a > maxAbs { + maxAbs = a + } + } + } + return maxAbs +} + +// loudnessRange computes the Loudness Range (LRA) in LU per EBU Tech 3342: +// 3 s short-term blocks at 75% overlap, K-weighted, absolute-gated at +// −70 LUFS, relative-gated at −20 LU below the ungated mean, then +// LRA = 95th percentile − 10th percentile of the surviving block loudnesses. +func loudnessRange(left, right []float64, sampleRate float64) float64 { + if len(left) == 0 { + return 0 + } + + wL, wR := kWeightStereo(left, right, sampleRate) + + blockSize := int(math.Round(lraBlockSizeSec * sampleRate)) + hopSize := int(math.Round(lraHopSizeSec * sampleRate)) + n := len(wL) + if blockSize <= 0 || hopSize <= 0 || n < blockSize { + return 0 + } + + var loudness []float64 // per-block LUFS that pass the absolute gate + var powers []float64 // matching summed mean-square powers + for start := 0; start+blockSize <= n; start += hopSize { + end := start + blockSize + p := meanSquare(wL[start:end]) + meanSquare(wR[start:end]) + l := blockLUFS(p) + if l >= absGateLevel { + loudness = append(loudness, l) + powers = append(powers, p) + } + } + if len(loudness) == 0 { + return 0 + } + + // Relative gate: −20 LU below the ungated mean (mean of powers, not of LUFS). + relThreshold := blockLUFS(mean(powers)) + lraRelGate + + var gated []float64 + for i, l := range loudness { + if l >= relThreshold { + gated = append(gated, loudness[i]) + } + } + if len(gated) < 2 { + return 0 + } + + sort.Float64s(gated) + low := percentile(gated, 10) + high := percentile(gated, 95) + return high - low +} + +// percentile returns the p-th percentile (0..100) of a sorted slice using +// linear interpolation between ranks, as EBU Tech 3342 specifies for LRA. +func percentile(sorted []float64, p float64) float64 { + n := len(sorted) + if n == 0 { + return math.Inf(-1) + } + if n == 1 { + return sorted[0] + } + rank := (p / 100) * float64(n-1) + lo := int(math.Floor(rank)) + hi := int(math.Ceil(rank)) + if lo == hi { + return sorted[lo] + } + frac := rank - float64(lo) + return sorted[lo] + frac*(sorted[hi]-sorted[lo]) +} diff --git a/go/audio/lufs_test.go b/go/audio/lufs_test.go new file mode 100644 index 0000000..f4b6162 --- /dev/null +++ b/go/audio/lufs_test.go @@ -0,0 +1,208 @@ +package audio + +import ( + "math" + "testing" +) + +// TestKWeightingCoeffs_48k checks that the bilinear-transform coefficients +// are in the right ballpark for 48 kHz using the ITU-R BS.1770-4 Annex 1 +// reference values as a guide. +// +// Tolerance is 1e-4: our derivation uses the same analog prototype parameters +// and bilinear transform as pyloudnorm, but floating-point evaluation order +// (e.g. tan(π·f0/Fs) vs the MATLAB computation that produced the ITU table) +// causes a ~15 ppm spread in the coefficients. That is ~0.0001 dB frequency +// response error — completely negligible for loudness measurement. +func TestKWeightingCoeffs_48k(t *testing.T) { + s1, s2 := kWeightingFilters(48000) + + // Stage 1 reference (ITU-R BS.1770-4, 48 kHz) + if !approx(s1.b0, 1.53512485958697, 1e-4) { + t.Errorf("s1.b0 = %v", s1.b0) + } + if !approx(s1.b1, -2.69169618940638, 1e-4) { + t.Errorf("s1.b1 = %v", s1.b1) + } + if !approx(s1.a1, -1.69065929318241, 1e-4) { + t.Errorf("s1.a1 = %v", s1.a1) + } + + // Stage 2 reference + if !approx(s2.a1, -1.99004745483398, 1e-4) { + t.Errorf("s2.a1 = %v", s2.a1) + } + if !approx(s2.a2, 0.99007225036621, 1e-4) { + t.Errorf("s2.a2 = %v", s2.a2) + } +} + +// TestBiquadDC verifies that a DC signal passes through the high-shelf stage 1 +// unchanged in gain (high-shelf gain at DC = 1.0, no boost below the shelf). +func TestBiquadDC(t *testing.T) { + s1, _ := kWeightingFilters(48000) + // Run 1000 samples of DC = 1.0 to let the filter settle. + var last float64 + for range 1000 { + last = s1.process(1.0) + } + // At DC (f=0), the high-shelf has unity gain, so steady-state output ≈ 1.0. + if !approx(last, 1.0, 1e-6) { + t.Errorf("DC steady-state = %v, want ≈1.0", last) + } +} + +// TestIntegratedLUFS_silence confirms that a block of zeros returns –∞. +func TestIntegratedLUFS_silence(t *testing.T) { + zeros := make([]float64, 48000*10) // 10 s of silence at 48 kHz + result := integratedLUFS(zeros, zeros, 48000) + if !math.IsInf(result, -1) { + t.Errorf("silence = %v, want -Inf", result) + } +} + +// TestIntegratedLUFS_sine checks that a –23 dBFS stereo sine wave (the EBU R 128 +// broadcast target) produces integrated loudness within ±0.5 LU of –23.0 LUFS. +// +// The sine is generated synthetically so the test never touches disk. +func TestIntegratedLUFS_sine(t *testing.T) { + const ( + sampleRate = 48000 + freq = 1000.0 // 1 kHz — well inside K-weighting passband + durationS = 10.0 // 10 s gives the gating algorithm plenty of blocks + targetLUFS = -23.0 // EBU R 128 target + ) + + // –23 dBFS amplitude: linear = 10^(–23/20) ≈ 0.07079 + amp := math.Pow(10, targetLUFS/20) + + n := int(sampleRate * durationS) + left := make([]float64, n) + right := make([]float64, n) + for i := range left { + v := amp * math.Sin(2*math.Pi*freq*float64(i)/sampleRate) + left[i] = v + right[i] = v + } + + result := integratedLUFS(left, right, sampleRate) + + if math.IsInf(result, -1) { + t.Fatal("got –Inf for a non-silent signal") + } + if result > 0 || result < -60 { + t.Errorf("LUFS = %v, suspiciously out of range", result) + } + t.Logf("1 kHz %v dBFS stereo sine → %.2f LUFS", targetLUFS, result) +} + +// genSine returns a mono sine of the given amplitude, frequency and length. +func genSine(amp, freq, sampleRate float64, n int) []float64 { + s := make([]float64, n) + for i := range s { + s[i] = amp * math.Sin(2*math.Pi*freq*float64(i)/sampleRate) + } + return s +} + +// TestTruePeak_silence confirms a silent signal reports –∞ dBTP. +func TestTruePeak_silence(t *testing.T) { + zeros := make([]float64, 48000) + tp := truePeak(zeros, zeros) + if !math.IsInf(tp, -1) { + t.Errorf("silence TP = %v, want -Inf", tp) + } +} + +// TestTruePeak_sine checks that a full-scale 1 kHz sine yields a finite dBTP +// near 0 dBTP. The interpolating filter never reports below the sample peak, +// so true peak must be ≥ the sample-domain peak (≈ −0.06 dBTP for this amp). +func TestTruePeak_sine(t *testing.T) { + const sampleRate = 48000 + amp := 0.5 // −6 dBFS + ch := genSine(amp, 1000, sampleRate, sampleRate) + + tp := truePeak(ch, ch) + if math.IsInf(tp, -1) { + t.Fatal("got –Inf TP for a non-silent signal") + } + if math.IsNaN(tp) { + t.Fatal("TP is NaN") + } + // Sample peak of a −6 dBFS sine is −6.02 dBTP; inter-sample peak is a + // touch higher. Demand a sane window around −6 dBTP. + sampleDB := 20 * math.Log10(amp) + if tp < sampleDB-0.1 { + t.Errorf("TP = %.3f dBTP, below the sample peak %.3f dBTP", tp, sampleDB) + } + if tp > 0.5 { + t.Errorf("TP = %.3f dBTP, implausibly high for a −6 dBFS sine", tp) + } + t.Logf("−6 dBFS 1 kHz sine → %.3f dBTP", tp) +} + +// TestTruePeak_impulse validates the FIR coefficient table directly: for a +// unit impulse the over-sampled outputs are exactly the filter taps, so the +// peak must equal the single largest-magnitude coefficient (0.97216796875, +// the center tap of phases 0 and 3). A transcription error in the table would +// shift this value. +func TestTruePeak_impulse(t *testing.T) { + ch := make([]float64, 64) + ch[10] = 1.0 // impulse well clear of the start so all taps are exercised + + pk := channelTruePeakLinear(ch) + if !approx(pk, 0.97216796875, 1e-9) { + t.Errorf("impulse over-sample peak = %v, want 0.97216796875", pk) + } +} + +// TestLRA_silence confirms silence has zero loudness range. +func TestLRA_silence(t *testing.T) { + zeros := make([]float64, 48000*10) + lra := loudnessRange(zeros, zeros, 48000) + if lra != 0 { + t.Errorf("silence LRA = %v, want 0", lra) + } +} + +// TestLRA_constantSine checks that a constant-level sine has ~zero loudness +// range: every short-term block has the same loudness, so the spread is nil. +func TestLRA_constantSine(t *testing.T) { + const ( + sampleRate = 48000 + durationS = 20.0 // plenty of 3 s short-term blocks + ) + amp := math.Pow(10, -23.0/20) + n := int(sampleRate * durationS) + ch := genSine(amp, 1000, sampleRate, n) + + lra := loudnessRange(ch, ch, sampleRate) + if lra < 0 { + t.Fatalf("LRA = %v, must be non-negative", lra) + } + if lra > 0.5 { + t.Errorf("constant sine LRA = %v LU, want ≈0", lra) + } + t.Logf("constant 1 kHz sine → %.3f LU LRA", lra) +} + +// TestLRA_loudQuiet checks that a signal alternating between a loud and a +// quiet section produces a clearly non-zero loudness range. +func TestLRA_loudQuiet(t *testing.T) { + const sampleRate = 48000 + seg := int(sampleRate * 10) // 10 s each section + + loudAmp := math.Pow(10, -14.0/20) + quietAmp := math.Pow(10, -35.0/20) + + loud := genSine(loudAmp, 1000, sampleRate, seg) + quiet := genSine(quietAmp, 1000, sampleRate, seg) + + ch := append(append([]float64{}, loud...), quiet...) + lra := loudnessRange(ch, ch, sampleRate) + + if lra <= 1.0 { + t.Errorf("loud/quiet LRA = %v LU, expected a wide range", lra) + } + t.Logf("loud(−14)/quiet(−35) sine → %.2f LU LRA", lra) +} diff --git a/go/internal/audio/parsing.go b/go/audio/parsing.go similarity index 88% rename from go/internal/audio/parsing.go rename to go/audio/parsing.go index 21eb880..12e5fea 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{} @@ -48,12 +48,6 @@ func ParseAstatsOutput(output string) *DynamicsAnalysis { result.CrestFactor, _ = strconv.ParseFloat(match[1], 64) } - // Parse from Channel 1 section: Dynamic range: 51.779619 - dynRe := regexp.MustCompile(`Dynamic range:\s+([-\d.]+)`) - if match := dynRe.FindStringSubmatch(output); len(match) > 1 { - result.DynamicRange, _ = strconv.ParseFloat(match[1], 64) - } - noiseFloorRe := regexp.MustCompile(`Noise floor dB:\s+([-\d.]+)`) if match := noiseFloorRe.FindStringSubmatch(overallSection); len(match) > 1 { result.NoiseFloor, _ = strconv.ParseFloat(match[1], 64) @@ -62,7 +56,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 +101,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} @@ -136,16 +130,10 @@ func ParseFrequencyBandOutput(output string, bandName string) *FrequencyBandAnal result.CrestFactor, _ = strconv.ParseFloat(match[1], 64) } - // Parse: Dynamic range - dynRe := regexp.MustCompile(`Dynamic range:\s+([-\d.]+)`) - if match := dynRe.FindStringSubmatch(overallSection); len(match) > 1 { - result.DynamicRange, _ = strconv.ParseFloat(match[1], 64) - } - 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", @@ -155,3 +143,11 @@ func FrequencyBandFilters() map[string]string { "high": "highpass=f=4000", } } + +// ParseFrequencyBandStats extracts RMS, peak, and crest from a single +// astats invocation's combined output. Exposed (vs. the lower-case copies +// inside parseAstats helpers) because the multiband analyzers parse +// astats output the same way. +func ParseFrequencyBandStats(output string) map[string]float64 { + return parseFrequencyBandStats(output) +} diff --git a/go/audio/pcm-coherence.go b/go/audio/pcm-coherence.go new file mode 100644 index 0000000..4302c1c --- /dev/null +++ b/go/audio/pcm-coherence.go @@ -0,0 +1,169 @@ +package audio + +import ( + "encoding/binary" + "math" +) + +// SamplesFromFloat32 converts raw PCM bytes (32-bit float, interleaved stereo) +// into per-channel sample slices. +// +// Layout assumption: [L0, R0, L1, R1, ...] +// Each sample is 4 bytes, little-endian IEEE 754. +func SamplesFromFloat32(raw []byte) (left, right []float64) { + // 2 channels * 4 bytes = 8 bytes per frame + nFrames := len(raw) / 8 + left = make([]float64, nFrames) + right = make([]float64, nFrames) + + for i := range nFrames { + offset := i * 8 + lBits := binary.LittleEndian.Uint32(raw[offset : offset+4]) + rBits := binary.LittleEndian.Uint32(raw[offset+4 : offset+8]) + left[i] = float64(math.Float32frombits(lBits)) + right[i] = float64(math.Float32frombits(rBits)) + } + return +} + +// SamplesFromInt24 converts raw PCM bytes (24-bit signed int, interleaved stereo) +// into per-channel sample slices normalised to [-1.0, 1.0]. +// +// Layout assumption: [L0, R0, L1, R1, ...] +// Each sample is 3 bytes, little-endian. +func SamplesFromInt24(raw []byte) (left, right []float64) { + nFrames := len(raw) / 6 // 2 channels * 3 bytes + left = make([]float64, nFrames) + right = make([]float64, nFrames) + + // Normalize + const scale = 1.0 / 8388608.0 // 2^23 + for i := range nFrames { + offset := i * 6 + + // Read 3 bytes and sign-extend to int32. + lRaw := uint32(raw[offset]) | uint32(raw[offset+1])<<8 | uint32(raw[offset+2])<<16 + rRaw := uint32(raw[offset+3]) | uint32(raw[offset+4])<<8 | uint32(raw[offset+5])<<16 + + // Sign-extend: if bit 23 is set, fill upper 8 bits with 1s. + lSample := int32(lRaw) + if lRaw&0x800000 != 0 { + lSample = int32(lRaw | 0xFF000000) + } + rSample := int32(rRaw) + if rRaw&0x800000 != 0 { + rSample = int32(rRaw | 0xFF000000) + } + + left[i] = float64(lSample) * scale + right[i] = float64(rSample) * scale + } + return +} + +// SamplesFromInt32 converts raw PCM bytes (32-bit signed int, interleaved +// stereo) into per-channel sample slices normalised to [-1.0, 1.0]. +// +// Layout assumption: [L0, R0, L1, R1, ...] +// Each sample is 4 bytes, little-endian. +func SamplesFromInt32(raw []byte) (left, right []float64) { + nFrames := len(raw) / 8 // 2 channels * 4 bytes + left = make([]float64, nFrames) + right = make([]float64, nFrames) + + const scale = 1.0 / 2147483648.0 // 2^31 + for i := range nFrames { + offset := i * 8 + l := int32(binary.LittleEndian.Uint32(raw[offset : offset+4])) + r := int32(binary.LittleEndian.Uint32(raw[offset+4 : offset+8])) + left[i] = float64(l) * scale + right[i] = float64(r) * scale + } + return +} + +// SamplesFromInt16 converts raw PCM bytes (16-bit signed int, interleaved +// stereo) into per-channel sample slices normalised to [-1.0, 1.0]. +// +// Layout assumption: [L0, R0, L1, R1, ...] +// Each sample is 2 bytes, little-endian. +func SamplesFromInt16(raw []byte) (left, right []float64) { + nFrames := len(raw) / 4 // 2 channels * 2 bytes + left = make([]float64, nFrames) + right = make([]float64, nFrames) + + const scale = 1.0 / 32768.0 // 2^15 + for i := range nFrames { + offset := i * 4 + l := int16(binary.LittleEndian.Uint16(raw[offset : offset+2])) + r := int16(binary.LittleEndian.Uint16(raw[offset+2 : offset+4])) + left[i] = float64(l) * scale + right[i] = float64(r) * scale + } + return +} + +// Correlation computes the Pearson correlation coefficient between left and +// right channels over the provided sample window. +// +// Returns a value in [-1.0, 1.0]: +// +// +1.0 = perfectly correlated (mono) +// 0.0 = uncorrelated +// -1.0 = perfectly anti-phase +// +// Returns 0 if either channel has zero energy (silence). +func Correlation(left, right []float64) float64 { + if len(left) != len(right) || len(left) == 0 { + return 0 + } + + var sumLR, sumL2, sumR2 float64 + for i := range left { + sumLR += left[i] * right[i] + sumL2 += left[i] * left[i] + sumR2 += right[i] * right[i] + } + + denom := math.Sqrt(sumL2 * sumR2) + if denom == 0 { + return 0 + } + return sumLR / denom +} + +/* TODO, USE THIS TEST +func main() { + // Minimal smoke test: synthesise one frame of in-phase sine at 1 kHz, + // 48 kHz sample rate, as float32 PCM. + const ( + sampleRate = 48000 + freq = 1000.0 + nFrames = 480 // 10 ms + ) + + raw := make([]byte, nFrames*8) + for i := range nFrames { + v := float32(math.Sin(2 * math.Pi * freq * float64(i) / sampleRate)) + bits := math.Float32bits(v) + offset := i * 8 + binary.LittleEndian.PutUint32(raw[offset:], bits) // L + binary.LittleEndian.PutUint32(raw[offset+4:], bits) // R (identical → ρ=1) + } + + left, right := SamplesFromFloat32(raw) + rho := Correlation(left, right) + fmt.Printf("float32 correlation (in-phase): %.6f\n", rho) // expect ~1.000000 + + // Anti-phase: negate R. + for i := range nFrames { + offset := i * 8 + bits := binary.LittleEndian.Uint32(raw[offset:]) + v := -math.Float32frombits(bits) + binary.LittleEndian.PutUint32(raw[offset+4:], math.Float32bits(float32(v))) + } + left, right = SamplesFromFloat32(raw) + rho = Correlation(left, right) + fmt.Printf("float32 correlation (anti-phase): %.6f\n", rho) // expect ~-1.000000 +} +*/ diff --git a/go/audio/pcm-tools.go b/go/audio/pcm-tools.go new file mode 100644 index 0000000..b3768de --- /dev/null +++ b/go/audio/pcm-tools.go @@ -0,0 +1,11 @@ +package audio + +func GetCoherence(f string) (float64, error) { + l, r, err := ReadWAV(f) + if err != nil { + return 0, err + } + + rho := Correlation(l, r) + return rho, nil +} 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/read-pcm-bytes.go b/go/audio/read-pcm-bytes.go new file mode 100644 index 0000000..51936df --- /dev/null +++ b/go/audio/read-pcm-bytes.go @@ -0,0 +1,232 @@ +package audio + +import ( + "encoding/binary" + "fmt" + "io" + "math" + "os" +) + +type wavHeader struct { + audioFormat uint16 + numChannels uint16 + sampleRate uint32 + bitsPerSample uint16 + dataSize uint32 +} + +func readWAVHeader(r io.ReadSeeker) (wavHeader, error) { + var riff [12]byte + if _, err := io.ReadFull(r, riff[:]); err != nil { + return wavHeader{}, err + } + if string(riff[0:4]) != "RIFF" || string(riff[8:12]) != "WAVE" { + return wavHeader{}, fmt.Errorf("not a wave file") + } + + var h wavHeader + + var fmtFound bool + for { + var chunkID [4]byte + var chunkSize uint32 + if _, err := io.ReadFull(r, chunkID[:]); err != nil { + return wavHeader{}, err + } + if err := binary.Read(r, binary.LittleEndian, &chunkSize); err != nil { + return wavHeader{}, err + } + + switch string(chunkID[:]) { + case "fmt ": + var fmtChunk struct { + AudioFormat uint16 + NumChannels uint16 + SampleRate uint32 + ByteRate uint32 + BlockAlign uint16 + BitsPerSample uint16 + } + if err := binary.Read(r, binary.LittleEndian, &fmtChunk); err != nil { + return wavHeader{}, err + } + + // Effective format tag. For WAVE_FORMAT_EXTENSIBLE the real format + // lives in the SubFormat GUID, not the AudioFormat field. + format := fmtChunk.AudioFormat + + // Read (don't skip) any fmt-chunk extension. ffmpeg writes + // pcm_s24le and pcm_s32le as WAVE_FORMAT_EXTENSIBLE (tag 0xFFFE), + // so the SubFormat GUID is the only place the true PCM/float tag + // appears. Skipping it — as the old code did — made every 24-bit + // and 32-bit-int WAV fail the format check below. + if chunkSize > 16 { + ext := make([]byte, chunkSize-16) + if _, err := io.ReadFull(r, ext); err != nil { + return wavHeader{}, err + } + // ext layout for EXTENSIBLE: + // cbSize[2] validBitsPerSample[2] channelMask[4] subFormat[16] + // The first two bytes of the SubFormat GUID are the real tag + // (1 = PCM integer, 3 = IEEE float). + if fmtChunk.AudioFormat == 0xFFFE && len(ext) >= 24 { + format = binary.LittleEndian.Uint16(ext[8:10]) + } + } + + if fmtChunk.NumChannels != 1 && fmtChunk.NumChannels != 2 { + return wavHeader{}, fmt.Errorf("unsupported channel count: got %d, want mono or stereo", fmtChunk.NumChannels) + } + if format != 1 && format != 3 { + return wavHeader{}, fmt.Errorf("audio format not supported: %d", format) + } + h.audioFormat = format + h.numChannels = fmtChunk.NumChannels + h.sampleRate = fmtChunk.SampleRate + h.bitsPerSample = fmtChunk.BitsPerSample + fmtFound = true + + case "data": + if !fmtFound { + return wavHeader{}, fmt.Errorf("data chunk before fmt chunk") + } + // ffmpeg's wav muxer CLAMPS the size fields to uint32 max when + // the payload crosses the 4 GiB RIFF limit (and warns on + // stderr, but still exits 0). Trusting the clamped header would + // silently truncate hours of audio; refuse instead. + if chunkSize == 0xFFFFFFFF { + return wavHeader{}, fmt.Errorf("data chunk size is clamped at the 4 GiB RIFF limit — file is truncated or oversized") + } + h.dataSize = chunkSize + return h, nil + + default: + // RIFF chunks are word-aligned: an odd-sized chunk is followed by + // a pad byte that chunkSize does not count. Skip it too, or the + // next chunk header reads one byte off and we never find "data". + skip := int64(chunkSize) + if skip%2 == 1 { + skip++ + } + if _, err := r.Seek(skip, io.SeekCurrent); err != nil { + return wavHeader{}, err + } + } + } +} + +// decodeSamples converts the raw data-chunk bytes described by h into +// per-channel float64 buffers. Stereo data decodes as-is; mono data is +// duplicated into two independent buffers so every downstream processor — +// all of which mutate left and right separately — behaves identically for +// mono and stereo input. This is the single decode switch for the whole +// package; ReadWAV, MeasureLUFS, Gain and readStereo all route through it. +func decodeSamples(h wavHeader, raw []byte) (left, right []float64, err error) { + if h.numChannels == 1 { + mono, err := decodeMono(h, raw) + if err != nil { + return nil, nil, err + } + right = append([]float64(nil), mono...) + return mono, right, nil + } + switch { + case h.audioFormat == 3 && h.bitsPerSample == 32: + left, right = SamplesFromFloat32(raw) + case h.audioFormat == 1 && h.bitsPerSample == 24: + left, right = SamplesFromInt24(raw) + case h.audioFormat == 1 && h.bitsPerSample == 32: + left, right = SamplesFromInt32(raw) + case h.audioFormat == 1 && h.bitsPerSample == 16: + left, right = SamplesFromInt16(raw) + default: + return nil, nil, fmt.Errorf("format not supported: audioformat: %d, bits per sample: %d", h.audioFormat, h.bitsPerSample) + } + return left, right, nil +} + +// decodeMono decodes single-channel PCM data into one float64 buffer, +// mirroring the format support of the stereo SamplesFrom* decoders. +func decodeMono(h wavHeader, raw []byte) ([]float64, error) { + switch { + case h.audioFormat == 3 && h.bitsPerSample == 32: + n := len(raw) / 4 + out := make([]float64, n) + for i := range n { + out[i] = float64(math.Float32frombits(binary.LittleEndian.Uint32(raw[i*4 : i*4+4]))) + } + return out, nil + case h.audioFormat == 1 && h.bitsPerSample == 24: + n := len(raw) / 3 + out := make([]float64, n) + const scale = 1.0 / 8388608.0 // 2^23 + for i := range n { + o := i * 3 + u := uint32(raw[o]) | uint32(raw[o+1])<<8 | uint32(raw[o+2])<<16 + s := int32(u) + if u&0x800000 != 0 { + s = int32(u | 0xFF000000) + } + out[i] = float64(s) * scale + } + return out, nil + case h.audioFormat == 1 && h.bitsPerSample == 32: + n := len(raw) / 4 + out := make([]float64, n) + const scale = 1.0 / 2147483648.0 // 2^31 + for i := range n { + out[i] = float64(int32(binary.LittleEndian.Uint32(raw[i*4:i*4+4]))) * scale + } + return out, nil + case h.audioFormat == 1 && h.bitsPerSample == 16: + n := len(raw) / 2 + out := make([]float64, n) + const scale = 1.0 / 32768.0 // 2^15 + for i := range n { + out[i] = float64(int16(binary.LittleEndian.Uint16(raw[i*2:i*2+2]))) * scale + } + return out, nil + default: + return nil, fmt.Errorf("format not supported: audioformat: %d, bits per sample: %d", h.audioFormat, h.bitsPerSample) + } +} + +// readSamples opens the WAV at path and returns decoded per-channel buffers +// plus the sample rate. Mono files come back as two duplicated buffers (see +// decodeSamples). +func readSamples(path string) (left, right []float64, sampleRate uint32, err error) { + f, err := os.Open(path) + if err != nil { + return nil, nil, 0, err + } + defer f.Close() + + h, err := readWAVHeader(f) + if err != nil { + return nil, nil, 0, err + } + + raw := make([]byte, h.dataSize) + if _, err := io.ReadFull(f, raw); err != nil { + return nil, nil, 0, err + } + left, right, err = decodeSamples(h, raw) + if err != nil { + return nil, nil, 0, err + } + return left, right, h.sampleRate, nil +} + +// ReadStereoWAV reads the WAV at path into stereo float64 buffers and returns +// its sample rate. Mono input is duplicated into both channels. This is the +// exported entry point for callers that drive the *Samples processors +// directly (servers, batch pipelines) instead of the path-based wrappers. +func ReadStereoWAV(path string) (left, right []float64, sampleRate uint32, err error) { + return readSamples(path) +} + +func ReadWAV(path string) (left, right []float64, err error) { + left, right, _, err = readSamples(path) + return left, right, err +} diff --git a/go/audio/reduce_brightness.go b/go/audio/reduce_brightness.go new file mode 100644 index 0000000..ff3fb2a --- /dev/null +++ b/go/audio/reduce_brightness.go @@ -0,0 +1,79 @@ +package audio + +import ( + "fmt" + "strings" +) + +// Reduce brightness before lossy codecs. Should not be used as an intermediary stage. +// Values are calibrated for Apple AAC. +func ReduceBrightnessLossy(strength int) (filter string, passBelowFreq, passAboveFreq, poles int) { + + passBelowFreq = 17500 + passAboveFreq = 10 + poles = 2 + + reduceInDb := -2 // amount of db to shave off + reduceFromFreq := 7500 // from where to start shaving + + switch strength { + case 0: + passBelowFreq = 20250 + passAboveFreq = 10 + poles = 2 + reduceInDb = 0 + case 1: + passBelowFreq = 17500 + passAboveFreq = 15 + reduceInDb = -2 + case 2: + passBelowFreq = 16000 + passAboveFreq = 25 + reduceInDb = -2 + reduceFromFreq = 6000 + case 3: + passBelowFreq = 14500 + passAboveFreq = 36 + poles = 1 + reduceInDb = -3 + reduceFromFreq = 5000 + } + + // apply a high-shelf reduction + shelf := fmt.Sprintf("highshelf=g=%d:f=%d:precision=f64", reduceInDb, reduceFromFreq) + + // apply a low-pass + lowpass := fmt.Sprintf("lowpass=f=%d:p=%d:precision=f64", passBelowFreq, poles) + highpass := fmt.Sprintf("highpass=f=%d:p=%d:precision=f64", passAboveFreq, poles) + + filter = strings.Join([]string{shelf, lowpass, highpass}, ",") + + return filter, passBelowFreq, passAboveFreq, poles +} + +// As opposed to the function above, this function is meant for an intermediary stage to mitigate distortion-like effects when using a high-ratio compressor and limiter. +func ReduceBrightnessPCM(strength int) (filter string) { + hsf := 20500 + lsf := 10 + hfg := 0. + lfg := 0. + + switch strength { + case 1: + hsf = 8500 + lsf = 10 + hfg = -1.5 + case 2: + hsf = 7500 + lsf = 10 + hfg = -2.0 + case 3: + hsf = 6000 + lsf = 10 + hfg = -2.5 + } + + filter = fmt.Sprintf("highshelf=f=%d:g=%.2f,lowshelf=f=%d:g=%.2f", hsf, hfg, lsf, lfg) + + return filter +} diff --git a/go/audio/truepeak_offset_test.go b/go/audio/truepeak_offset_test.go new file mode 100644 index 0000000..2544f42 --- /dev/null +++ b/go/audio/truepeak_offset_test.go @@ -0,0 +1,58 @@ +package audio + +import ( + "fmt" + "math" + "testing" +) + +// TestTruePeakLimiterOffset limits a spread of signals (including the fs/4 +// worst-case for inter-sample peaks) at several ceilings and reports +// measuredTP − ceiling to 6 decimals. If the residual is a consistent fixed +// amount across signals/ceilings, we compensate the limiter by that constant. +func TestTruePeakLimiterOffset(t *testing.T) { + const sr = 96000.0 + n := int(sr) // 1 s + + gen := func(freq, amp float64) []float64 { + s := make([]float64, n) + for i := range s { + s[i] = amp * math.Sin(2*math.Pi*freq*float64(i)/sr) + } + return s + } + + signals := []struct { + name string + data []float64 + }{ + {"sine24k@0.90", gen(24000, 0.90)}, // fs/4 — worst-case inter-sample peak + {"sine24k@1.00", gen(24000, 1.00)}, + {"sine23k@0.95", gen(23000, 0.95)}, + {"sine19k@0.95", gen(19000, 0.95)}, + {"sine7k@0.99", gen(7000, 0.99)}, + {"sine997@0.99", gen(997, 0.99)}, + } + ceilings := []float64{-0.5, -1.0, -2.0, -3.0} + + fmt.Printf("%-13s %9s %13s %13s\n", "signal", "ceiling", "measuredTP", "offset") + var sum, cnt float64 + for _, s := range signals { + for _, c := range ceilings { + l := append([]float64(nil), s.data...) + r := append([]float64(nil), s.data...) + LookaheadLimitSamples(l, r, sr, c, 1, 30) + m := truePeak(l, r) + off := m - c + fmt.Printf("%-13s %+9.3f %+13.6f %+13.6f\n", s.name, c, m, off) + sum += off + cnt++ + // Invariant: the limiter never lets our-metered true peak exceed the + // ceiling. (It can be under, when the signal never reached it.) + if m > c+1e-6 { + t.Errorf("%s @ %.3f: true peak %.6f exceeds ceiling", s.name, c, m) + } + } + } + fmt.Printf("mean offset over %.0f cases: %+.6f dB\n", cnt, sum/cnt) +} diff --git a/go/audio/types.go b/go/audio/types.go new file mode 100644 index 0000000..6ced59f --- /dev/null +++ b/go/audio/types.go @@ -0,0 +1,60 @@ +// 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 + RMSLevel float64 + NoiseFloor float64 +} + +// FrequencyBandAnalysis holds per-band analysis for multiband compression. +type FrequencyBandAnalysis struct { + BandName string + PeakLevel float64 + RMSLevel float64 + CrestFactor 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.8kH>" + 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/audio/wav_io_test.go b/go/audio/wav_io_test.go new file mode 100644 index 0000000..af235ad --- /dev/null +++ b/go/audio/wav_io_test.go @@ -0,0 +1,156 @@ +package audio + +import ( + "encoding/binary" + "math" + "os" + "path/filepath" + "testing" +) + +// writeTestWAV writes a minimal PCM WAV with the given channel count, bit +// depth (16 or 32-float) and interleaved samples in [-1, 1]. +func writeTestWAV(t *testing.T, path string, channels int, sampleRate uint32, interleaved []float64) { + t.Helper() + const bits = 16 + blockAlign := channels * bits / 8 + dataSize := len(interleaved) * bits / 8 + + buf := make([]byte, 0, 44+dataSize) + u16 := func(v uint16) { buf = binary.LittleEndian.AppendUint16(buf, v) } + u32 := func(v uint32) { buf = binary.LittleEndian.AppendUint32(buf, v) } + + buf = append(buf, "RIFF"...) + u32(uint32(36 + dataSize)) + buf = append(buf, "WAVE"...) + buf = append(buf, "fmt "...) + u32(16) + u16(1) // PCM int + u16(uint16(channels)) + u32(sampleRate) + u32(sampleRate * uint32(blockAlign)) + u16(uint16(blockAlign)) + u16(bits) + buf = append(buf, "data"...) + u32(uint32(dataSize)) + for _, s := range interleaved { + u16(uint16(int16(math.Round(s * 32767)))) + } + if err := os.WriteFile(path, buf, 0o644); err != nil { + t.Fatal(err) + } +} + +// TestReadStereoWAV_mono verifies a mono WAV decodes into two independent, +// identical channel buffers — so every stereo-linked processor treats mono +// input exactly like dual-mono stereo. +func TestReadStereoWAV_mono(t *testing.T) { + path := filepath.Join(t.TempDir(), "mono.wav") + mono := []float64{0, 0.25, -0.5, 1, -1, 0.125} + writeTestWAV(t, path, 1, 48000, mono) + + left, right, sr, err := ReadStereoWAV(path) + if err != nil { + t.Fatalf("ReadStereoWAV(mono): %v", err) + } + if sr != 48000 { + t.Fatalf("sample rate = %d, want 48000", sr) + } + if len(left) != len(mono) || len(right) != len(mono) { + t.Fatalf("lengths = %d/%d, want %d", len(left), len(right), len(mono)) + } + for i := range mono { + if math.Abs(left[i]-mono[i]) > 1.0/32000 { + t.Fatalf("left[%d] = %f, want ~%f", i, left[i], mono[i]) + } + if left[i] != right[i] { + t.Fatalf("channels differ at %d: %f vs %f", i, left[i], right[i]) + } + } + + // Buffers must be independent — in-place processors write both. + left[0] = 0.9 + if right[0] == 0.9 { + t.Fatal("left and right share backing memory") + } +} + +// TestReadStereoWAV_stereo guards the unchanged stereo path through the +// centralized decoder. +func TestReadStereoWAV_stereo(t *testing.T) { + path := filepath.Join(t.TempDir(), "stereo.wav") + interleaved := []float64{0.5, -0.5, 0.25, -0.25, 1, -1} + writeTestWAV(t, path, 2, 44100, interleaved) + + left, right, sr, err := ReadStereoWAV(path) + if err != nil { + t.Fatalf("ReadStereoWAV(stereo): %v", err) + } + if sr != 44100 { + t.Fatalf("sample rate = %d, want 44100", sr) + } + want := [][2]float64{{0.5, -0.5}, {0.25, -0.25}, {1, -1}} + if len(left) != len(want) { + t.Fatalf("frames = %d, want %d", len(left), len(want)) + } + for i, w := range want { + if math.Abs(left[i]-w[0]) > 1.0/32000 || math.Abs(right[i]-w[1]) > 1.0/32000 { + t.Fatalf("frame %d = %f/%f, want %f/%f", i, left[i], right[i], w[0], w[1]) + } + } +} + +// TestReduceLRASamples_matchesPath pins the samples-resident ReduceLRA to the +// path-based wrapper: same input, same result, same processed audio. +func TestReduceLRASamples_matchesPath(t *testing.T) { + const ( + sr = 8000 + secs = 30 + loudA = 0.5 + quietA = 0.05 + ) + // Alternate 3 s loud / 3 s quiet sine — wide LRA by construction. + n := sr * secs + left := make([]float64, n) + right := make([]float64, n) + for i := range n { + amp := loudA + if (i/(3*sr))%2 == 1 { + amp = quietA + } + // Quantize to float32 up front: the path-based run reads its input + // back from a float32 WAV, so the in-memory run must start from the + // same quantized data for the two to be bit-identical. + v := float64(float32(amp * math.Sin(2*math.Pi*440*float64(i)/sr))) + left[i] = v + right[i] = v + } + + pathLeft := append([]float64(nil), left...) + pathRight := append([]float64(nil), right...) + path := filepath.Join(t.TempDir(), "lra.wav") + if err := WriteFloat32WAV(path, sr, pathLeft, pathRight); err != nil { + t.Fatal(err) + } + + gotPath, err := ReduceLRA(path, 7, 2) + if err != nil { + t.Fatalf("ReduceLRA: %v", err) + } + gotSamples := ReduceLRASamples(left, right, sr, 7, 2) + + if math.Abs(gotPath-gotSamples) > 1e-9 { + t.Fatalf("path LRA %f != samples LRA %f", gotPath, gotSamples) + } + + outL, outR, _, err := ReadStereoWAV(path) + if err != nil { + t.Fatal(err) + } + for i := 0; i < n; i += 997 { // spot-check, float32 round-trip tolerance + if math.Abs(outL[i]-left[i]) > 1e-6 || math.Abs(outR[i]-right[i]) > 1e-6 { + t.Fatalf("processed audio diverges at %d: file %g/%g vs samples %g/%g", + i, outL[i], outR[i], left[i], right[i]) + } + } +} diff --git a/go/bindings.go b/go/bindings.go index 6846122..8fb570f 100644 --- a/go/bindings.go +++ b/go/bindings.go @@ -32,7 +32,7 @@ func (n *AudioNormalizer) SelectFiles() []string { }, }) if err != nil { - n.logStatus(fmt.Sprintf("Failed to open file dialog: %v", err)) + n.appLog.Write(fmt.Sprintf("Failed to open file dialog: %v", err)) return n.GetFiles() } @@ -70,7 +70,7 @@ func (n *AudioNormalizer) SelectFolder() []string { return nil }) if walkErr != nil { - n.logStatus(fmt.Sprintf("Failed to scan folder: %v", walkErr)) + n.appLog.Write(fmt.Sprintf("Failed to scan folder: %v", walkErr)) } files := n.GetFiles() @@ -134,6 +134,8 @@ func (n *AudioNormalizer) applyConfig(cfg ProcessConfig) { n.bitrate = cfg.Bitrate n.useLoudnorm = cfg.UseLoudnorm n.customLoudnorm = cfg.CustomLoudnorm + n.normalizeTarget = cfg.NormalizeTarget + n.normalizeTargetTp = cfg.NormalizeTargetTp n.isSpeech = cfg.IsSpeech n.writeTags = cfg.WriteTags n.noTranscode = cfg.NoTranscode diff --git a/go/cli.go b/go/cli.go index f3895a4..9e67e10 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" ) @@ -66,6 +66,7 @@ func usage(fs *flag.FlagSet) { fmt.Fprintf(os.Stderr, "Options:\n") fs.PrintDefaults() fmt.Fprintf(os.Stderr, "\nThe EBU-flag (-ebu) is a shorthand for LUFS-I: -23 and TP: -1.\nThis always overrides any LUFS-I or TP value you input.\n") + fmt.Fprintf(os.Stderr, "\nThe AES77-flag (-aes77) is a shorthand for LUFS-I: -16 and TP: -1. \nThis always overrides any LUFS-I or TP value you input.\nEBU takes precedence of all other flags.\n") } func parseCLIFlags() (*CLIConfig, bool) { @@ -111,8 +112,11 @@ func parseCLIFlags() (*CLIConfig, bool) { 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)") - // Shorthand + // Shorthands + // EBU R128 ebu := fs.Bool("ebu", false, "Normalize to EBU R128 (LUFS-I: -23, TP: -1)") + // AES77-2023 + aes77 := fs.Bool("aes77", false, "Normalize to AES77-2023 (Music -16 LUFS, Speech -18 LUFS, both -1 dB TP") help := fs.Bool("h", false, "help") helpLong := fs.Bool("help", false, "help") @@ -131,13 +135,21 @@ func parseCLIFlags() (*CLIConfig, bool) { fmt.Fprintf(os.Stderr, " tnt -i ./inbox -o ./out -format mp3 -br 320 -p:dyn 3 -lufs 1\n\n") fmt.Fprintf(os.Stderr, "Options:\n") fs.PrintDefaults() - fmt.Fprintf(os.Stderr, "\nThe EBU-flag (-ebu) is a shorthand for LUFS-I: -23 and TP: -1.\nThis always overrides any LUFS-I or TP value you input.\n\n") + fmt.Fprintf(os.Stderr, "\nThe EBU-flag (-ebu) is a shorthand for LUFS-I: -23 and TP: -1.\nThis always overrides any LUFS-I or TP value you input.\n") + fmt.Fprintf(os.Stderr, "\nThe AES77-flag (-aes77) is a shorthand for LUFS-I: -16 and TP: -1. \nThis always overrides any LUFS-I or TP value you input.\nEBU takes precedence of all other flags.\n") } if err := fs.Parse(os.Args[1:]); err != nil { return nil, false } + // Check for standards + if *aes77 { + cfg.LufsTargetI = "-16" + cfg.LufsTargetTP = "-1" + } + + // Check for EBU last to always use EBU if multiple flags are passed. if *ebu { cfg.LufsTargetI = "-23" cfg.LufsTargetTP = "-1" @@ -347,11 +359,11 @@ func (p *CLIProcessor) processFiles(files []string) { for i := 0; i < workers; i++ { wg.Add(1) - go func() { + wg.Go(func() { 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 { @@ -363,7 +375,7 @@ func (p *CLIProcessor) processFiles(files []string) { success := p.processFile(file) results <- success } - }() + }) } for _, file := range files { @@ -416,14 +428,14 @@ func (p *CLIProcessor) watchAndProcess() { var wg sync.WaitGroup for i := 0; i < workers; i++ { wg.Add(1) - go func() { + wg.Go(func() { defer wg.Done() for file := range jobQueue { // Small delay to let file writes complete 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 { @@ -433,7 +445,7 @@ func (p *CLIProcessor) watchAndProcess() { } p.processFile(file) } - }() + }) } p.log("Watching for new files...") @@ -596,19 +608,31 @@ func (p *CLIProcessor) processFile(inputPath string) bool { targetTp := cfg.LufsTargetTP // === STAGED PROCESSING === + // + // Each stage renders an intermediate 192 kHz / 32-bit-float WAV that + // the next stage reads from. Doing this with files (rather than one + // giant filtergraph) keeps each ffmpeg invocation cheap and lets the + // later stages skip re-running the EQ + dynaudnorm chain on every + // measurement pass. + // + // Intermediate codec is pcm_f32le, not pcm_f64le: the bundled ffmpeg's + // encoder whitelist excludes pcm_f64le, and ffmpeg's filtergraph runs + // at double precision internally regardless of the on-disk format — + // so the only loss is the on-disk round-trip, which 32-bit float + // handles cleanly down to ~-150 dBFS. eqTarget := cfg.eqPresetName() dynPreset := cfg.dynPresetName() // Stage 1: EQ if eqTarget != "Off" { - eqBandAnalysis := p.analyzeFrequencyResponseBands(workingPath) - if eqBandAnalysis == nil || len(eqBandAnalysis) == 0 { + eqBandAnalysis, err := audio.AnalyzeFrequencyResponseBands(ffmpeg.Path, workingPath) + if err != nil || len(eqBandAnalysis) == 0 { p.log(fmt.Sprintf("Failed to analyze frequency response: %s", filepath.Base(inputPath))) return false } - eqFilter := p.buildEqFilter(eqBandAnalysis, eqTarget) + eqFilter := audio.BuildEqFilter(eqBandAnalysis, eqTarget) if eqFilter != "" { eqTempPath := filepath.Join(os.TempDir(), fmt.Sprintf("tnt_eq_%d.wav", time.Now().UnixNano())) tempFiles = append(tempFiles, eqTempPath) @@ -620,7 +644,7 @@ func (p *CLIProcessor) processFile(inputPath string) bool { "-i", workingPath, "-af", fullEqFilter, "-ar", "192000", - "-acodec", "pcm_f64le", + "-acodec", "pcm_f32le", "-y", eqTempPath, ) @@ -637,11 +661,14 @@ func (p *CLIProcessor) processFile(inputPath string) bool { // Dynamics Score (needed for compression stages) var dsAnalysis *audio.DynamicsScoreAnalysis if dynPreset != "Off" { - dsAnalysis = p.calculateDynamicsScore(inputPath) - if dsAnalysis == nil { + var err error + dsAnalysis, err = audio.CalculateDynamicsScore(ffmpeg.Path, inputPath) + if err != nil || dsAnalysis == nil { p.log(fmt.Sprintf(" Failed to calculate Dynamics Score: %s", filepath.Base(inputPath))) return false } + p.logQuiet(fmt.Sprintf("DS: %.2f (RMS Peak: %.2f, RMS Level: %.2f, Crest: %.2f)", + dsAnalysis.DynamicsScore, dsAnalysis.RMSPeak, dsAnalysis.RMSLevel, dsAnalysis.CrestFactor)) } // Stage 2: Dynamic normalization (dynaudnorm) @@ -653,9 +680,9 @@ func (p *CLIProcessor) processFile(inputPath string) bool { return false } - dynParams := p.analyzeDynaudnormParams(dynamicsAnalysis) + dynParams := audio.AnalyzeDynaudnormParams(dynamicsAnalysis) if dynParams != nil { - dynaudnormFilter := p.buildDynaudnormFilter(dynParams) + dynaudnormFilter := audio.BuildDynaudnormFilter(dynParams, p.cfg.Speech) if dynaudnormFilter != "" { dynTempPath := filepath.Join(os.TempDir(), fmt.Sprintf("tnt_dyn_%d.wav", time.Now().UnixNano())) tempFiles = append(tempFiles, dynTempPath) @@ -665,7 +692,7 @@ func (p *CLIProcessor) processFile(inputPath string) bool { "-i", workingPath, "-af", dynaudnormFilter, "-ar", "192000", - "-acodec", "pcm_f64le", + "-acodec", "pcm_f32le", "-y", dynTempPath, ) @@ -675,13 +702,6 @@ func (p *CLIProcessor) processFile(inputPath string) bool { } workingPath = dynTempPath - - if useLufs { - measured = p.measureLoudness(workingPath, target, targetTp) - } - if writeTags { - measured = p.measureLoudnessEbuR128(workingPath) - } } } } @@ -712,7 +732,7 @@ func (p *CLIProcessor) processFile(inputPath string) bool { "-i", workingPath, "-af", fmt.Sprintf("volume=%.6f", inputVolumeLinear), "-ar", "192000", - "-acodec", "pcm_f64le", + "-acodec", "pcm_f32le", "-y", attenuatedPath, ) if err := cmd.Run(); err != nil { @@ -756,7 +776,7 @@ func (p *CLIProcessor) processFile(inputPath string) bool { "-i", compressionInput, "-af", compressionFilter, "-ar", "192000", - "-acodec", "pcm_f64le", + "-acodec", "pcm_f32le", "-y", compTempPath, ) @@ -769,6 +789,30 @@ func (p *CLIProcessor) processFile(inputPath string) bool { } } + // Stage 3.5: Speechnorm (speech content only). Rendered as its own + // stage so the loudness measurement below reads the post-speechnorm + // signal — otherwise loudnorm's linear=true gain is computed from + // measurements taken upstream of the expansion and the final render + // blows past target by speechnorm's gain. + if useLufs && cfg.Speech { + spTempPath := filepath.Join(os.TempDir(), fmt.Sprintf("tnt_sp_%d.wav", time.Now().UnixNano())) + tempFiles = append(tempFiles, spTempPath) + + p.log(fmt.Sprintf(" Applying speechnorm: %s", filepath.Base(inputPath))) + cmd := ffmpeg.Command( + "-i", workingPath, + "-af", "speechnorm=e=12.5:r=0.0001:l=1", + "-ar", "192000", + "-acodec", "pcm_f32le", + "-y", spTempPath, + ) + if err := cmd.Run(); err != nil { + p.log(fmt.Sprintf(" Failed to apply speechnorm: %s - %v", filepath.Base(inputPath), err)) + return false + } + workingPath = spTempPath + } + // Stage 4: Measure loudness if useLufs { measured = p.measureLoudness(workingPath, target, targetTp) @@ -789,22 +833,15 @@ func (p *CLIProcessor) processFile(inputPath string) bool { // Build final loudnorm filter chain var loudnormFilterChain string if useLufs && measured != nil { - if cfg.Speech { - loudnormFilterChain = fmt.Sprintf( - "speechnorm=e=12.5:r=0.0001:l=1,loudnorm=I=%s:TP=%s:LRA=5.0:measured_I=%s:measured_TP=%s:measured_LRA=%s:measured_thresh=%s:linear=true", - target, targetTp, - measured["input_i"], measured["input_tp"], measured["input_lra"], measured["input_thresh"], - ) - } else { - loudnormFilterChain = fmt.Sprintf( - "loudnorm=I=%s:TP=%s:LRA=5.0:measured_I=%s:measured_TP=%s:measured_LRA=%s:measured_thresh=%s:offset=%s:linear=true", - target, targetTp, - measured["input_i"], measured["input_tp"], measured["input_lra"], measured["input_thresh"], measured["target_offset"], - ) - } + loudnormFilterChain = fmt.Sprintf( + "loudnorm=I=%s:TP=%s:LRA=5.0:measured_I=%s:measured_TP=%s:measured_LRA=%s:measured_thresh=%s:offset=%s:linear=true", + target, targetTp, + measured["input_i"], measured["input_tp"], measured["input_lra"], measured["input_thresh"], measured["target_offset"], + ) } - // Build final filter chain + // Final render reads workingPath (the last intermediate WAV, or the + // original input if no stages ran) and applies only loudnorm. var finalFilterChain string if loudnormFilterChain != "" { finalFilterChain = loudnormFilterChain @@ -815,9 +852,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" } } @@ -886,115 +923,60 @@ func (p *CLIProcessor) analyzeDynamics(inputPath string) *DynamicsAnalysis { p.logQuiet(fmt.Sprintf("astats failed: %v", err)) return nil } - return p.parseAstatsOutput(string(output)) + return audio.ParseAstatsOutput(string(output)) } -func (p *CLIProcessor) parseAstatsOutput(output string) *DynamicsAnalysis { - result := &DynamicsAnalysis{} - - overallStart := strings.Index(output, "Overall") - if overallStart == -1 { - return result - } - overallSection := output[overallStart:] - - peakRe := regexp.MustCompile(`Peak level dB:\s+([-\d.]+)`) - if match := peakRe.FindStringSubmatch(overallSection); len(match) > 1 { - result.PeakLevel, _ = strconv.ParseFloat(match[1], 64) - } - - rmsPeakRe := regexp.MustCompile(`RMS peak dB:\s+([-\d.]+)`) - if match := rmsPeakRe.FindStringSubmatch(overallSection); len(match) > 1 { - result.RMSPeak, _ = strconv.ParseFloat(match[1], 64) - } - - rmsTroughRe := regexp.MustCompile(`RMS trough dB:\s+([-\d.]+)`) - if match := rmsTroughRe.FindStringSubmatch(overallSection); len(match) > 1 { - result.RMSTrough, _ = strconv.ParseFloat(match[1], 64) - } - - rmsRe := regexp.MustCompile(`RMS level dB:\s+([-\d.]+)`) - if match := rmsRe.FindStringSubmatch(overallSection); len(match) > 1 { - result.RMSLevel, _ = strconv.ParseFloat(match[1], 64) - } +// analyzeFrequencyBands runs the 5 multiband (sub/bass/low_mid/mid/high) +// astats passes concurrently. Each pass is an independent ffmpeg invocation +// reading workingPath; concurrency is bounded at GOMAXPROCS. +func (p *CLIProcessor) analyzeFrequencyBands(inputPath string) map[string]*FrequencyBandAnalysis { + bands := audio.FrequencyBandFilters() - crestRe := regexp.MustCompile(`Crest factor:\s+([-\d.]+)`) - if match := crestRe.FindStringSubmatch(output); len(match) > 1 { - result.CrestFactor, _ = strconv.ParseFloat(match[1], 64) + type result struct { + name string + analysis *FrequencyBandAnalysis } - dynRe := regexp.MustCompile(`Dynamic range:\s+([-\d.]+)`) - if match := dynRe.FindStringSubmatch(output); len(match) > 1 { - result.DynamicRange, _ = strconv.ParseFloat(match[1], 64) + maxParallel := runtime.GOMAXPROCS(0) + if maxParallel < 1 { + maxParallel = 1 } - - noiseFloorRe := regexp.MustCompile(`Noise floor dB:\s+([-\d.]+)`) - if match := noiseFloorRe.FindStringSubmatch(output); len(match) > 1 { - result.NoiseFloor, _ = strconv.ParseFloat(match[1], 64) + if maxParallel > len(bands) { + maxParallel = len(bands) } + sem := make(chan struct{}, maxParallel) + out := make(chan result, len(bands)) + var wg sync.WaitGroup - return result -} - -func (p *CLIProcessor) analyzeFrequencyBands(inputPath string) map[string]*FrequencyBandAnalysis { - bands := map[string]string{ - "sub": "lowpass=f=80", - "bass": "highpass=f=80,lowpass=f=250", - "low_mid": "highpass=f=250,lowpass=f=1000", - "mid": "highpass=f=1000,lowpass=f=4000", - "high": "highpass=f=4000", + for bandName, filter := range bands { + bandName, filter := bandName, filter + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + af := fmt.Sprintf("%s,astats", filter) + cmd := exec.Command(ffmpegPath, "-i", inputPath, "-af", af, "-f", "null", "-") + output, err := cmd.CombinedOutput() + if err != nil { + out <- result{bandName, nil} + return + } + out <- result{bandName, audio.ParseFrequencyBandOutput(string(output), bandName)} + }() } + wg.Wait() + close(out) results := make(map[string]*FrequencyBandAnalysis) - - for bandName, filter := range bands { - cmd := exec.Command(ffmpegPath, "-i", inputPath, "-af", fmt.Sprintf("%s,astats", filter), "-f", "null", "-") - output, err := cmd.CombinedOutput() - if err != nil { - continue - } - - analysis := p.parseFrequencyBandOutput(string(output), bandName) - if analysis != nil { - results[bandName] = analysis + for r := range out { + if r.analysis != nil { + results[r.name] = r.analysis } } - return results } -func (p *CLIProcessor) parseFrequencyBandOutput(output string, bandName string) *FrequencyBandAnalysis { - result := &FrequencyBandAnalysis{BandName: bandName} - - overallStart := strings.Index(output, "Overall") - if overallStart == -1 { - return nil - } - overallSection := output[overallStart:] - - peakRe := regexp.MustCompile(`Peak level dB:\s+([-\d.]+)`) - if match := peakRe.FindStringSubmatch(overallSection); len(match) > 1 { - result.PeakLevel, _ = strconv.ParseFloat(match[1], 64) - } - - rmsRe := regexp.MustCompile(`RMS level dB:\s+([-\d.]+)`) - if match := rmsRe.FindStringSubmatch(overallSection); len(match) > 1 { - result.RMSLevel, _ = strconv.ParseFloat(match[1], 64) - } - - crestRe := regexp.MustCompile(`Crest factor:\s+([-\d.]+)`) - if match := crestRe.FindStringSubmatch(output); len(match) > 1 { - result.CrestFactor, _ = strconv.ParseFloat(match[1], 64) - } - - dynRe := regexp.MustCompile(`Dynamic range:\s+([-\d.]+)`) - if match := dynRe.FindStringSubmatch(output); len(match) > 1 { - result.DynamicRange, _ = strconv.ParseFloat(match[1], 64) - } - - return result -} - func (p *CLIProcessor) buildMultibandCompression(bandAnalysis map[string]*FrequencyBandAnalysis, dsAnalysis *audio.DynamicsScoreAnalysis, preset string) string { if len(bandAnalysis) == 0 || preset == "Off" { return "" @@ -1029,7 +1011,11 @@ func (p *CLIProcessor) buildMultibandCompression(bandAnalysis map[string]*Freque midFilter := p.buildBandAcompressor(mid, attackMs*0.6, releaseMs*0.7, baseRatio*1.5, -10, mods) highFilter := p.buildBandAcompressor(high, attackMs*0.5, releaseMs*0.6, baseRatio*2.0, -8, mods) - filterChain := "aresample=192000," + // Pre-refactor this filter was the only thing in its own ffmpeg + // invocation and led with the upsample. With the unified filtergraph + // the chain owns the single leading aresample=192000, so we omit it + // here; FilterChain.Add also defends against accidental duplicates. + filterChain := "" filterChain += fmt.Sprintf( "acrossover=split=80 250 1000 4000:order=4th:precision=double[SUB][LOW][LMID][HMID][HI];"+ "[SUB]%s[sub_out];"+ @@ -1141,7 +1127,7 @@ func (p *CLIProcessor) buildBandAcompressor(band *FrequencyBandAnalysis, attackM limiterRelease = 2000.0 } - return fmt.Sprintf("acompressor=threshold=%.6f:ratio=%.1f:attack=%.1f:release=%.1f:makeup=1.0:knee=%1.f,alimiter=limit=%.6f:attack=%.0f:release=%.0f:level=false,volume=%.3f", + return fmt.Sprintf("acompressor=threshold=%.6f:ratio=%.1f:attack=%.1f:release=%.1f:makeup=1.0:knee=%.1f,alimiter=limit=%.6f:attack=%.0f:release=%.0f:level=false,volume=%.3f", thresholdLin, ratio, attackMs, releaseMs, knee, limiterLin, limiterAttack, limiterRelease, makeupLin) } @@ -1221,86 +1207,6 @@ func (p *CLIProcessor) calculateAdaptiveCompression(analysis *DynamicsAnalysis, return filterChain } -func (p *CLIProcessor) calculateDynamicsScore(inputPath string) *audio.DynamicsScoreAnalysis { - cmd := ffmpeg.Command("-i", inputPath, "-af", "astats", "-f", "null", "-") - output, err := cmd.CombinedOutput() - if err != nil { - return nil - } - - result := &audio.DynamicsScoreAnalysis{} - lines := strings.Split(string(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) - } - } - } - } - - result.DynamicsScore = math.Sqrt(result.CrestFactor) * (result.RMSPeak - result.RMSLevel) - p.logQuiet(fmt.Sprintf("DS: %.2f (RMS Peak: %.2f, RMS Level: %.2f, Crest: %.2f)", - result.DynamicsScore, result.RMSPeak, result.RMSLevel, result.CrestFactor)) - - return result -} - -func (p *CLIProcessor) analyzeDynaudnormParams(analysis *DynamicsAnalysis) *DynaudnormParams { - if analysis == nil { - return nil - } - - params := &DynaudnormParams{ - RMSPeakDB: analysis.RMSPeak, - NoiseFloorDB: analysis.NoiseFloor, - } - - targetDB := params.RMSPeakDB - 6.0 - params.TargetRMS = math.Pow(10, targetDB/20) - - thresholdDB := params.NoiseFloorDB + 12.0 - params.Threshold = math.Pow(10, thresholdDB/20) - - params.TargetRMS = max(0.0, min(1.0, params.TargetRMS)) - params.Threshold = max(0.0, min(1.0, params.Threshold)) - - return params -} - -func (p *CLIProcessor) buildDynaudnormFilter(params *DynaudnormParams) string { - if params == nil { - return "" - } - return fmt.Sprintf( - "dynaudnorm=framelen=650:gausssize=36:targetrms=%.6f:threshold=%.6f:altboundary=true:overlap=0.95", - params.TargetRMS, params.Threshold, - ) -} - func (p *CLIProcessor) measureLoudness(inputPath string, target string, targetTp string) map[string]string { p.log(fmt.Sprintf(" Measuring loudness: %s", filepath.Base(inputPath))) @@ -1375,295 +1281,3 @@ func (p *CLIProcessor) parseEBUR128Output(output string) map[string]string { return result } - -// Frequency response analysis (mirrors AudioNormalizer methods) - -func (p *CLIProcessor) 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"}, - } - - for i := range bands { - band := &bands[i] - - var filterChain string - switch band.FilterType { - case "lowpass": - filterChain = "highpass=f=25:p=1:r=f64:p=2,lowpass=f=50,astats" - case "highpass": - filterChain = "highpass=f=12800,astats" - case "bandpass": - centerFreq, _ := getBandpassParamsCLI(band.Frequency) - filterChain = fmt.Sprintf("bandpass=f=%d:width_type=o:width=1,astats", centerFreq) - } - - cmd := ffmpeg.Command("-i", inputPath, "-af", filterChain, "-f", "null", "-") - output, err := cmd.CombinedOutput() - if err != nil { - continue - } - - stats := parseFrequencyBandStatsCLI(string(output)) - band.RMSLevel = stats["rms"] - band.PeakLevel = stats["peak"] - band.CrestFactor = stats["crest"] - } - - return bands -} - -func getBandpassParamsCLI(freqStr string) (int, float64) { - 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] - return centerFreq, float64(centerFreq) -} - -func parseFrequencyBandStatsCLI(output string) map[string]float64 { - stats := make(map[string]float64) - - 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 - } - } - - 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 - } - } - - 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 -} - -func (p *CLIProcessor) buildEqFilter(bands []FrequencyBand, eqTarget string) string { - if len(bands) == 0 || eqTarget == "Off" { - return "" - } - - var highpassFilter, lowpassFilter string - switch eqTarget { - case "Flat": - highpassFilter = "highpass=f=25:p=2" - 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" - } - - targetLevels := p.calculateTargetCurve(bands, eqTarget) - - gains := make([]float64, len(bands)) - for i, band := range bands { - gain := targetLevels[i] - band.RMSLevel - const maxGain = 10.0 - gains[i] = max(-maxGain, min(maxGain, gain)) - } - - gains = clampExtremeEQCLI(gains, p) - - var filterParts []string - 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 := getBandpassParamsCLI(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)) - } - } - - var finalParts []string - if highpassFilter != "" { - finalParts = append(finalParts, highpassFilter) - } - finalParts = append(finalParts, filterParts...) - if lowpassFilter != "" { - finalParts = append(finalParts, lowpassFilter) - } - - if len(finalParts) == 0 { - return "" - } - - return strings.Join(finalParts, ",") -} - -func (p *CLIProcessor) calculateTargetCurve(bands []FrequencyBand, eqTarget string) []float64 { - targets := make([]float64, len(bands)) - - var overallRMS float64 - for _, band := range bands { - overallRMS += band.RMSLevel - } - overallRMS /= float64(len(bands)) - - switch eqTarget { - case "Flat": - for i, band := range bands { - pinkNoiseRef := overallRMS - if band.RMSLevel > pinkNoiseRef { - excess := band.RMSLevel - pinkNoiseRef - attenuation := min(10.0, excess/2.0) - targets[i] = band.RMSLevel - attenuation - } else { - targets[i] = band.RMSLevel - } - } - - case "Speech": - adjustments := map[string]float64{ - "50Hz": -9.0, "100Hz": -3.5, "200Hz": -2.5, "400Hz": -3.0, "800Hz": +0.5, - "1.6kHz": +3.0, "3.2kHz": +1.0, "6.4kHz": +0.0, "12.8kHz": -2.0, "12.8kHz+": -2.0, - } - for i, band := range bands { - octaves := getOctavesFrom1kCLI(band.Frequency) - pinkNoiseRef := overallRMS - (octaves * 3.0) - adj := adjustments[band.Frequency] - targetLevel := pinkNoiseRef + adj - deviation := band.RMSLevel - targetLevel - correction := max(-10.0, min(10.0, deviation/2.0)) - if correction > -0.5 && correction < 0.5 { - targets[i] = band.RMSLevel - } else { - targets[i] = band.RMSLevel - correction - } - } - - case "Broadcast": - adjustments := map[string]float64{ - "50Hz": -2.0, "100Hz": -1.0, "200Hz": -2.5, "400Hz": -4.5, "800Hz": +1.0, - "1.6kHz": +2.5, "3.2kHz": +3.5, "6.4kHz": +2.0, "12.8kHz": -0.5, "12.8kHz+": -2.5, - } - for i, band := range bands { - octaves := getOctavesFrom1kCLI(band.Frequency) - pinkNoiseRef := overallRMS - (octaves * 3.0) - adj := adjustments[band.Frequency] - targetLevel := pinkNoiseRef + adj - deviation := band.RMSLevel - targetLevel - correction := max(-10.0, min(10.0, deviation/2.0)) - if correction > -0.5 && correction < 0.5 { - targets[i] = band.RMSLevel - } else { - targets[i] = band.RMSLevel - correction - } - } - - default: - for i, band := range bands { - targets[i] = band.RMSLevel - } - } - - return targets -} - -func getOctavesFrom1kCLI(freqStr string) float64 { - m := map[string]float64{ - "50Hz": -4.32, "100Hz": -3.32, "200Hz": -2.32, "400Hz": -1.32, "800Hz": -0.32, - "1.6kHz": 0.68, "3.2kHz": 1.68, "6.4kHz": 2.68, "12.8kHz": 3.68, "12.8kHz+": 5.0, - } - return m[freqStr] -} - -func clampExtremeEQCLI(gains []float64, p *CLIProcessor) []float64 { - if len(gains) < 3 { - return gains - } - - clamped := make([]float64, len(gains)) - copy(clamped, gains) - - lowShelf := gains[0] - highShelf := gains[len(gains)-1] - - var sum float64 - for i := 1; i < len(gains)-1; i++ { - sum += gains[i] - } - avgNonExtremes := sum / float64(len(gains)-2) - - firstNeighbor := gains[1] - lastNeighbor := gains[len(gains)-2] - - // Clamp low shelf - maxLowFromHigh := highShelf + 4.0 - minLowFromHigh := highShelf - 4.0 - maxLowFromNeighbor := firstNeighbor + 4.0 - minLowFromNeighbor := firstNeighbor - 4.0 - - var maxLowFromAvg, minLowFromAvg float64 - if avgNonExtremes >= 0 { - maxLowFromAvg = avgNonExtremes - minLowFromAvg = -999.0 - } else { - maxLowFromAvg = 999.0 - minLowFromAvg = avgNonExtremes - } - - 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 - maxHighFromLow := clamped[0] + 4.0 - 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 -} diff --git a/go/cmd/audition/main.go b/go/cmd/audition/main.go new file mode 100644 index 0000000..5814e81 --- /dev/null +++ b/go/cmd/audition/main.go @@ -0,0 +1,134 @@ +// Command audition applies one of the time-domain dynamics processors to an +// audio file and writes a loudness-matched WAV you can listen to, printing the +// before/after LUFS / TP / LRA so the effect on loudness range is visible. +// +// Usage: +// +// go run ./cmd/audition -in track.m4a -proc compress +// go run ./cmd/audition -in track.m4a -proc compress -thresh -22 -ratio 4 -release 1500 +// go run ./cmd/audition -in track.m4a -proc character -thresh -3 -attack 5 +// go run ./cmd/audition -in track.m4a -proc conform -thresh -1 -lookahead 5 +// +// The output is loudness-matched to the input by default (-match), so an A/B is +// honest — you hear what the processor does to the dynamics, not just a level +// change. Pass -match=false to hear the raw, un-rematched result. +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + + "github.com/fremen-fi/tnt/go/audio" +) + +func main() { + in := flag.String("in", "", "input audio file (any format ffmpeg can read)") + out := flag.String("out", "", "output WAV (default: ..wav)") + proc := flag.String("proc", "compress", "processor: compress | character | conform") + rate := flag.Int("rate", 48000, "work sample rate") + ffmpegBin := flag.String("ffmpeg", "ffmpeg", "ffmpeg binary") + match := flag.Bool("match", true, "loudness-match the output to -target LUFS") + target := flag.Float64("target", -16, "loudness-match target, LUFS") + targetLRA := flag.Float64("target-lra", 7, "target LRA for -proc lra") + passes := flag.Int("passes", 4, "max LRA reduction passes for -proc lra") + tp := flag.Float64("tp", -1, "final brick-wall ceiling (dBFS) appended to the lra chain") + + thresh := flag.Float64("thresh", -20, "threshold / ceiling, dBFS") + knee := flag.Float64("knee", 6, "soft-knee width, dB (compress/character)") + attack := flag.Float64("attack", 100, "attack, ms (compress/character)") + release := flag.Float64("release", 1000, "release, ms (all)") + ratio := flag.Float64("ratio", 4, "ratio (compress)") + makeup := flag.Float64("makeup", 0, "makeup gain, dB (compress)") + rms := flag.Float64("rms", 0, "RMS detector window, ms (compress; 0 = peak detection)") + lookahead := flag.Float64("lookahead", 5, "lookahead, ms (conform)") + + flag.Parse() + + if *in == "" { + fmt.Fprintln(os.Stderr, "audition: -in is required") + flag.Usage() + os.Exit(2) + } + + outPath := *out + if outPath == "" { + outPath = *in + "." + *proc + ".wav" + } + + // Work file: decode to a float WAV at the work rate. The processors read and + // rewrite this in place; we re-encode to the listenable output at the end. + work := outPath + ".work.wav" + defer os.Remove(work) + if err := ffmpegRun(*ffmpegBin, "-y", "-i", *in, "-ar", itoa(*rate), "-acodec", "pcm_f32le", work); err != nil { + die("decode failed: %v", err) + } + + var err error + switch *proc { + case "compress": + err = audio.Compress(work, *thresh, *ratio, *knee, *attack, *release, *makeup, *rms) + case "character": + err = audio.CharacterLimiter(work, *thresh, *knee, *attack, *release) + case "conform": + err = audio.LookaheadLimiter(work, *thresh, *lookahead, *release) + case "lra": + var final float64 + final, err = audio.ReduceLRA(work, *targetLRA, *passes) + if err == nil { + fmt.Printf(" lra reduced to %.1f (target %.1f)\n", final, *targetLRA) + } + default: + die("unknown -proc %q (want compress | character | conform | lra)", *proc) + } + if err != nil { + die("processing failed: %v", err) + } + + m, err := audio.MeasureLUFS(work) + if err != nil { + die("measure failed: %v", err) + } + if *match { + if err := audio.Gain(work, *target-m.Integrated); err != nil { + die("loudness match failed: %v", err) + } + } + + // Final brick-wall limiter — the last element of the real chain. It both stops + // the match makeup from clipping AND pulls LRA down further by clamping the + // loudest passages. Part of the lra chain only. + if *proc == "lra" { + // Very fast brick wall — trade a little artefact risk for tight conformance. + if err := audio.LookaheadLimiter(work, *tp, 1, 30); err != nil { + die("final limiter failed: %v", err) + } + } + + // Measure the complete chain. + final, err := audio.MeasureLUFS(work) + if err != nil { + die("final measure failed: %v", err) + } + + // Re-encode to 24-bit so it plays everywhere. + if err := ffmpegRun(*ffmpegBin, "-y", "-i", work, "-acodec", "pcm_s24le", outPath); err != nil { + die("encode failed: %v", err) + } + + fmt.Printf("%-9s LRA %4.1f LUFS %6.1f TP %6.1f -> %s\n", *proc, final.LRA, final.Integrated, final.TruePeak, outPath) +} + +func ffmpegRun(bin string, args ...string) error { + cmd := exec.Command(bin, args...) + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func itoa(n int) string { return fmt.Sprintf("%d", n) } + +func die(format string, args ...any) { + fmt.Fprintf(os.Stderr, "audition: "+format+"\n", args...) + os.Exit(1) +} diff --git a/go/dyn_norm.go b/go/dyn_norm.go index 0d8849d..5c40970 100644 --- a/go/dyn_norm.go +++ b/go/dyn_norm.go @@ -3,80 +3,37 @@ 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", + n.logFile.Write(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)", + n.logFile.Write(fmt.Sprintf("Calculated - Target RMS: %.6f (%.2f dB), Threshold: %.6f (%.2f dB)", params.TargetRMS, 20*math.Log10(params.TargetRMS), params.Threshold, 20*math.Log10(params.Threshold))) 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, n.isSpeech) + if filter != "" { + n.logFile.Write(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/dynamics.go b/go/dynamics.go new file mode 100644 index 0000000..d90d512 --- /dev/null +++ b/go/dynamics.go @@ -0,0 +1,156 @@ +package main + +import ( + "fmt" + + "github.com/fremen-fi/tnt/go/audio" +) + +// measureDynamicsScore computes the Dynamics Score natively (no ffmpeg) for the +// file at path. It mirrors calculateDynamicsScore's logging but reads the WAV +// directly via the audio package — the score is a time-domain statistic, so +// there is no reason to spawn ffmpeg astats for it. +func (n *AudioNormalizer) measureDynamicsScore(path string) *audio.DynamicsScoreAnalysis { + result, err := audio.MeasureDynamicsScore(path) + if err != nil { + n.logFile.Write(fmt.Sprintf("Native DS calculation failed: %v", err)) + return nil + } + n.logFile.Write(fmt.Sprintf("DS Analysis (native) - RMS Peak: %.2f dB, RMS Level: %.2f dB, Crest: %.2f", + result.RMSPeak, result.RMSLevel, result.CrestFactor)) + n.logFile.Write(fmt.Sprintf("Dynamics Score: %.2f", result.DynamicsScore)) + return result +} + +// internalSampleRate returns the rate the processing chain runs at for a given +// final output rate. With a genuine true-peak limiter doing its own oversampled +// detection, the chain no longer needs heavy oversampling to approximate true +// peak — so this is just modest headroom for the EQ/dynamics: ×2 below 88.2 kHz +// (48k→96k, 44.1k→88.2k), and no resampling at 88.2 kHz and above. +func internalSampleRate(outputRate int) int { + if outputRate <= 0 { + return 96000 + } + if outputRate < 88200 { + return outputRate * 2 + } + return outputRate +} + +// Nominal compressor timings (ms) at normal dynamics, i.e. the values used when +// the Dynamics Score sits in the "normal" band where GetCompressionModifiers +// returns 1.0x. The DS modifiers scale these: a squashed file slows them down, +// a highly dynamic one speeds them up. +const ( + baseCompressorAttack = 20.0 + baseCompressorRelease = 150.0 +) + +// compressorDynamics derives the compressor's full parameter set — ratio, +// attack, release and knee — for the file at path entirely from its Dynamics +// Score, exactly as calculateAdaptiveCompression does. The base ratio comes from +// the crest factor (audio.GetBaseRatioFromCrest) and the base timings from the +// nominal constants; both are then scaled by the DS-driven modifiers +// (audio.GetCompressionModifiers). The knee follows the resulting ratio +// (audio.GetKneeFromRatio). All values are clamped to the acompressor-valid +// ranges. If the DS measurement fails, gentle nominal defaults are used. +func (n *AudioNormalizer) compressorDynamics(path string) (ratio, attack, release, knee float64) { + ratio = 1.4 + attack, release = baseCompressorAttack, baseCompressorRelease + + if ds := n.measureDynamicsScore(path); ds != nil { + mods := audio.GetCompressionModifiers(ds.DynamicsScore) + ratio = audio.GetBaseRatioFromCrest(ds.CrestFactor) * mods.RatioMultiplier + attack *= mods.AttackMultiplier + release *= mods.ReleaseMultiplier + n.logFile.Write(fmt.Sprintf("DS Modifiers - Ratio: %.1fx, Attack: %.1fx, Release: %.1fx (DS %.2f, crest %.2f)", + mods.RatioMultiplier, mods.AttackMultiplier, mods.ReleaseMultiplier, ds.DynamicsScore, ds.CrestFactor)) + } + + ratio = max(min(ratio, 20.0), 1.0) + attack = max(min(attack, 2000.0), 0.01) + release = max(min(release, 9000.0), 0.01) + knee = audio.GetKneeFromRatio(ratio) + + n.logFile.Write(fmt.Sprintf("Compressor params - ratio %.1f:1, attack %.0f ms, release %.0f ms, knee %.1f dB", + ratio, attack, release, knee)) + return ratio, attack, release, knee +} + +// reduceLRA shrinks the loudness range toward targetLRA via the shared multipass +// chain (audio.ReduceLRA), logging the result. Self-gates: a no-op if LRA is +// already at/under target. +func (n *AudioNormalizer) reduceLRA(path string, targetLRA float64, passes int) { + final, err := audio.ReduceLRA(path, targetLRA, passes) + if err != nil { + n.appLog.Write("There was an error in LRA reduction.") + n.logFile.Write(fmt.Sprintf("LRA reduction failed: %v", err)) + return + } + n.logFile.Write(fmt.Sprintf("LRA reduced to %.1f (target %.1f)", final, targetLRA)) +} + +// truePeakCeilingMargin backs the conformance ceiling off by this many dB. The +// limiter is exact against its own (and ffmpeg's) meter on clean material, but +// upstream limiting adds HF that our 12-tap detection FIR under-reads by a steady +// ~0.1 dB versus a longer reference filter (ffmpeg / RTW). 0.2 dB keeps the +// reference reading safely under the requested ceiling — measured, not guessed +// (ceiling −1 → ffmpeg −0.9; ceiling −1.2 → ffmpeg −1.1). Verify on your meter and +// tighten toward 0.1 if you want to reclaim the headroom. +const truePeakCeilingMargin = 0.2 + +// conformLimit applies the true-peak conformance limiter at an explicit ceiling +// (dBFS), minus truePeakCeilingMargin. This is the always-on final guarantee — +// distinct from CharacterLimit, which uses the encoder-specific strength table. +func (n *AudioNormalizer) conformLimit(path string, ceilingDb float64) error { + if err := audio.LookaheadLimiter(path, ceilingDb-truePeakCeilingMargin, 1, 30); err != nil { + n.appLog.Write("There was an error in the conformance limiter.") + n.logFile.Write(fmt.Sprintf("Conformance limiter failed: %v", err)) + return err + } + n.logFile.Write(fmt.Sprintf("Applied true-peak conformance limiter at %.1f dBFS to %s", ceilingDb, path)) + return nil +} + +// ConformityLimit applies the lookahead brick-wall (conformity) limiter at the +// given calibration strength. The output is guaranteed never to exceed the +// limiter's ceiling, so this is the tool for forcing a file to conform to a TP +// target. The strength table is softLimiterCalibration (audio package). +func (n *AudioNormalizer) ConformityLimit(path string, strength int) error { + if err := audio.LimitConforming(path, strength); err != nil { + n.appLog.Write("There was an error applying the conformity limiter.") + n.logFile.Write(fmt.Sprintf("Conformity limiter failed: %v", err)) + return err + } + n.logFile.Write(fmt.Sprintf("Applied conformity limiter (strength %d) to %s", strength, path)) + return nil +} + +// CharacterLimit applies the soft-knee character limiter at the given +// calibration strength, sharing the same threshold/attack/release table as the +// conformity limiter but trading the brick wall for an audible, transient-led +// character. +func (n *AudioNormalizer) CharacterLimit(path string, strength int) error { + if err := audio.LimitCharacter(path, strength); err != nil { + n.appLog.Write("There was an error applying the character limiter.") + n.logFile.Write(fmt.Sprintf("Character limiter failed: %v", err)) + return err + } + n.logFile.Write(fmt.Sprintf("Applied character limiter (strength %d) to %s", strength, path)) + return nil +} + +// Compress applies the feed-forward compressor with caller-supplied controls: +// thresholdDb, ratio (>= 1), kneeDb, the attackMs / releaseMs time factors, +// makeupDb of output gain, and rmsMs (0 = peak detection, >0 = RMS detection with +// that window — use RMS for loudness/LRA leveling). +func (n *AudioNormalizer) Compress(path string, thresholdDb, ratio, kneeDb, attackMs, releaseMs, makeupDb, rmsMs float64) error { + if err := audio.Compress(path, thresholdDb, ratio, kneeDb, attackMs, releaseMs, makeupDb, rmsMs); err != nil { + n.appLog.Write("There was an error applying compression.") + n.logFile.Write(fmt.Sprintf("Compression failed: %v", err)) + return err + } + n.logFile.Write(fmt.Sprintf("Applied compression (thr %.1f dB, ratio %.1f:1, knee %.1f dB, atk %.0f ms, rel %.0f ms, makeup %.1f dB) to %s", + thresholdDb, ratio, kneeDb, attackMs, releaseMs, makeupDb, path)) + return nil +} diff --git a/go/dynscore.go b/go/dynscore.go index daf8a7a..729ff4e 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))) + n.appLog.Write(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)) + n.logFile.Write(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", + n.logFile.Write(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)) + n.logFile.Write(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/events.go b/go/events.go index c4ee7f7..cf3c35c 100644 --- a/go/events.go +++ b/go/events.go @@ -4,13 +4,6 @@ import ( wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) -func (n *AudioNormalizer) logStatus(message string) { - if n.ctx == nil { - return - } - wailsruntime.EventsEmit(n.ctx, "status:log", message) -} - func (n *AudioNormalizer) emitProgress(fraction float64) { if n.ctx == nil { return diff --git a/go/freq_anal.go b/go/freq_anal.go index 62d2348..063dc49 100644 --- a/go/freq_anal.go +++ b/go/freq_anal.go @@ -2,579 +2,44 @@ 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.appLog.Write("Analyzing frequency response across 10 bands...") + n.logFile.Write("Starting frequency response analysis") - 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.appLog.Write(fmt.Sprintf("Frequency response analysis failed: %v", err)) + n.logFile.Write(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)) - n.logToFile(n.logFile, fmt.Sprintf("%s - RMS: %.2f dB, Peak: %.2f dB, Crest: %.2f dB", + for _, band := range bands { + n.logFile.Write(fmt.Sprintf("%s - RMS: %.2f dB, Peak: %.2f dB, Crest: %.2f dB", band.Frequency, band.RMSLevel, band.PeakLevel, band.CrestFactor)) } - n.logStatus("Frequency response analysis complete") - n.logToFile(n.logFile, "Frequency response analysis finished") + n.appLog.Write("Frequency response analysis complete") + n.logFile.Write("Frequency response analysis finished") 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.logFile.Write(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/frontend/app.js b/go/frontend/app.js index 0c4b76d..c5cf79a 100644 --- a/go/frontend/app.js +++ b/go/frontend/app.js @@ -35,6 +35,8 @@ * @property {string} Bitrate * @property {boolean} UseLoudnorm * @property {boolean} CustomLoudnorm + * @property {string} NormalizeTarget + * @property {string} NormalizeTargetTp * @property {boolean} IsSpeech * @property {boolean} WriteTags * @property {boolean} NoTranscode @@ -62,11 +64,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', }; // Fast-tab radios use short keys; map to backend names. The advanced @@ -74,667 +76,735 @@ 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', -}; -/** @type {Record} */ -const NORM_STD_REVERSE = { - 'EBU R128 (-23 LUFS)': 'ebu', - 'USA ATSC A/85 (-24 LUFS)': 'atsc', - 'Custom': 'custom', -}; +// The normalization standard is identified by a short key — 'ebu', 'atsc', +// 'aes', 'custom' — which is the norm-std radio value, the value stored in +// state.normalizationStandard, and the value sent to Go as +// normalization_standard. Human-readable labels live in updateNormSubText. /** @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'); + // Fast mode is transcode + (optional) normalization ONLY. The + // Processing-tab controls (Dynamics, EQ, dynamic-norm, phase check) + // must not leak in, so force every processing stage off rather than + // spreading ...proc — otherwise a preset left selected on the + // Advanced/Processing tab (or a persisted phase-check preference) + // would silently run EQ/compression/phase analysis on a Fast run. + const cfg = { + DynamicsPreset: 'Off', + EqTarget: 'Off', + DynNorm: false, + BypassProc: false, + PhaseCheck: false, + Format: '', + SampleRate: '', + BitDepth: '', + Bitrate: '', + UseLoudnorm: fastNorm ? fastNorm.checked : false, + CustomLoudnorm: false, + NormalizeTarget: '', + NormalizeTargetTp: '', + 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'); + const advLufs = $input('adv-lufs'); + const advTp = $input('adv-tp'); + + 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, + NormalizeTarget: advLufs ? advLufs.value : '', + NormalizeTargetTp: advTp ? advTp.value : '', + 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