Skip to content

Commit 7d6a7c6

Browse files
committed
refactor(processor): reorder computation and simplify loudnorm signatures
- Move ceiling and pre-gain calculation before Pass 3 using Pass 2 measurements - Simplify buildLoudnormFilterSpec() to accept pre-computed values, return string only - Thread pre-computed values through applyLoudnormAndMeasure() - Remove redundant parameter passing and intermediate adjustments Signed-off-by: Martin Wimpress <code@wimpress.io>
1 parent 51f228f commit 7d6a7c6

2 files changed

Lines changed: 111 additions & 129 deletions

File tree

internal/processor/normalise.go

Lines changed: 65 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,14 @@ type LoudnormMeasurement struct {
129129
// Parameters:
130130
// - inputPath: Path to Pass 2 output file (the -processed file, before LUFS rename)
131131
// - config: Filter configuration (contains loudnorm targets)
132+
// - filterPrefix: Optional filter chain to prepend before loudnorm (e.g. volume+alimiter);
133+
// empty string preserves existing behaviour
132134
// - progressCallback: Optional progress updates (pass 3)
133135
//
134136
// Returns:
135137
// - measurement: Loudnorm measurements for second pass
136138
// - err: Error if measurement failed
137-
func measureWithLoudnorm(inputPath string, config *FilterChainConfig, progressCallback func(pass PassNumber, passName string, progress float64, level float64, measurements *AudioMeasurements)) (*LoudnormMeasurement, error) {
139+
func measureWithLoudnorm(inputPath string, config *FilterChainConfig, filterPrefix string, progressCallback func(pass PassNumber, passName string, progress float64, level float64, measurements *AudioMeasurements)) (*LoudnormMeasurement, error) {
138140
// Start capturing loudnorm log output
139141
startLoudnormCapture()
140142

@@ -163,6 +165,10 @@ func measureWithLoudnorm(inputPath string, config *FilterChainConfig, progressCa
163165
boolToString(config.LoudnormDualMono),
164166
)
165167

168+
if filterPrefix != "" {
169+
filterSpec = filterPrefix + "," + filterSpec
170+
}
171+
166172
// Create filter graph
167173
filterGraph, bufferSrcCtx, bufferSinkCtx, err := setupFilterGraph(
168174
reader.GetDecoderContext(),
@@ -454,9 +460,28 @@ func ApplyNormalisation(
454460
progressCallback(PassMeasuring, "Measuring", 0.0, 0.0, nil)
455461
}
456462

463+
// Compute ceiling/pre-gain from Pass 2 ebur128 measurements (before Pass 3)
464+
// This allows Pass 3 to measure through the same volume+alimiter prefix
465+
// that Pass 4 will apply, closing the measurement mismatch.
466+
limiterCeiling, limiterNeeded, limiterClamped := calculateLimiterCeiling(
467+
outputMeasurements.OutputI, outputMeasurements.OutputTP,
468+
config.LoudnormTargetI, config.LoudnormTargetTP,
469+
)
470+
preGainDB, reDerivedCeiling := calculatePreGain(
471+
outputMeasurements.OutputI, config.LoudnormTargetI, config.LoudnormTargetTP,
472+
)
473+
if limiterClamped {
474+
limiterCeiling = reDerivedCeiling
475+
}
476+
limiterGain := config.LoudnormTargetI - outputMeasurements.OutputI
477+
478+
// Build filter prefix for Pass 3 measurement
479+
filterPrefix := buildPreLimiterPrefix(preGainDB, limiterCeiling, limiterNeeded)
480+
457481
// Pass 3: Run loudnorm measurement pass on Pass 2 output
458-
// This reads the file through loudnorm without encoding to get measurements
459-
measurement, err := measureWithLoudnorm(inputPath, config, progressCallback)
482+
// When a prefix is active, loudnorm measures the post-limiter signal,
483+
// so its InputI/InputTP already reflect pre-gain and limiting.
484+
measurement, err := measureWithLoudnorm(inputPath, config, filterPrefix, progressCallback)
460485
if err != nil {
461486
return nil, fmt.Errorf("loudnorm measurement pass failed: %w", err)
462487
}
@@ -472,37 +497,13 @@ func ApplyNormalisation(
472497
progressCallback(PassNormalising, "Normalising", 0.0, 0.0, nil)
473498
}
474499

475-
// Calculate limiter ceiling (actual limiting happens in buildLoudnormFilterSpec)
476-
// clamped=true means ceiling was limited to alimiter's minimum (-24 dBTP),
477-
// so loudnorm may still need to adjust target for very quiet audio
478-
limiterCeiling, limiterNeeded, limiterClamped := calculateLimiterCeiling(
479-
measurement.InputI,
480-
measurement.InputTP,
481-
config.LoudnormTargetI,
482-
config.LoudnormTargetTP,
483-
)
484-
limiterGain := config.LoudnormTargetI - measurement.InputI
485-
486500
// Calculate effective target I that ensures linear mode (no dynamic fallback)
487-
// loudnorm requires: measured_TP + (target_I - measured_I) <= target_TP for linear mode
488-
//
489-
// When the limiter is enabled, loudnorm sees the LIMITED peaks (limiterCeiling),
490-
// not the original measured peaks. When clamped with pre-gain active, the
491-
// re-derived ceiling replaces the clamped value.
492-
preGainDB, reDerivedCeiling := calculatePreGain(
493-
measurement.InputI, config.LoudnormTargetI, config.LoudnormTargetTP,
494-
)
495-
effectiveTP := measurement.InputTP
496-
effectiveMeasuredI := measurement.InputI
497-
if limiterNeeded && !limiterClamped {
498-
effectiveTP = limiterCeiling
499-
} else if limiterNeeded && limiterClamped {
500-
effectiveTP = reDerivedCeiling
501-
effectiveMeasuredI = measurement.InputI + preGainDB
502-
}
501+
// Pass 3 measured through the same prefix chain as Pass 4, so
502+
// measurement.InputI and measurement.InputTP already reflect the
503+
// post-limiter signal. No effectiveMeasuredI/effectiveTP adjustment needed.
503504
effectiveTargetI, _, linearPossible := calculateLinearModeTarget(
504-
effectiveMeasuredI,
505-
effectiveTP,
505+
measurement.InputI,
506+
measurement.InputTP,
506507
config.LoudnormTargetI,
507508
config.LoudnormTargetTP,
508509
)
@@ -515,7 +516,7 @@ func ApplyNormalisation(
515516
effectiveConfig.LoudnormTargetI = effectiveTargetI
516517

517518
// Pass 4: Apply loudnorm with linear=true and the measurements
518-
finalLUFS, finalTP, finalMeasurements, loudnormStats, _, _, err := applyLoudnormAndMeasure(inputPath, &effectiveConfig, measurement, inputMeasurements, progressCallback)
519+
finalLUFS, finalTP, finalMeasurements, loudnormStats, err := applyLoudnormAndMeasure(inputPath, &effectiveConfig, measurement, inputMeasurements, preGainDB, limiterCeiling, limiterNeeded, progressCallback)
519520
if err != nil {
520521
return nil, fmt.Errorf("loudnorm application failed: %w", err)
521522
}
@@ -553,20 +554,25 @@ func ApplyNormalisation(
553554
// applyLoudnormAndMeasure applies loudnorm's second pass to the audio file and measures the result.
554555
// Uses in-place processing: reads input, applies loudnorm, writes to temp file, renames.
555556
//
556-
// Filter chain: loudnorm → astats → aspectralstats → ebur128 → resample
557+
// Filter chain: [volume+alimiter] → loudnorm → [adeclick] → astats → aspectralstats → ebur128 → resample
557558
//
558559
// This is the second pass of loudnorm's two-pass workflow. The first pass
559560
// measurements come from measureWithLoudnorm() (stored in LoudnormMeasurement).
561+
// Pre-computed limiter values (preGainDB, ceiling, needsLimiting) are passed through
562+
// from ApplyNormalisation, which derives them from Pass 2 ebur128 measurements.
560563
//
561564
// Returns the measured integrated loudness, true peak, full output measurements,
562-
// loudnorm diagnostic stats, pre-gain amount in dB, and whether the ceiling was clamped.
565+
// and loudnorm diagnostic stats.
563566
func applyLoudnormAndMeasure(
564567
inputPath string,
565568
config *FilterChainConfig,
566569
measurement *LoudnormMeasurement,
567570
inputMeasurements *AudioMeasurements,
571+
preGainDB float64,
572+
ceiling float64,
573+
needsLimiting bool,
568574
progressCallback func(pass PassNumber, passName string, progress float64, level float64, measurements *AudioMeasurements),
569-
) (float64, float64, *OutputMeasurements, *LoudnormStats, float64, bool, error) {
575+
) (float64, float64, *OutputMeasurements, *LoudnormStats, error) {
570576
// Start capturing loudnorm's JSON output for diagnostics
571577
startLoudnormCapture()
572578

@@ -579,7 +585,7 @@ func applyLoudnormAndMeasure(
579585
// Open input file
580586
reader, metadata, err := audio.OpenAudioFile(inputPath)
581587
if err != nil {
582-
return 0.0, 0.0, nil, getLoudnormStats(), 0.0, false, fmt.Errorf("failed to open input: %w", err)
588+
return 0.0, 0.0, nil, getLoudnormStats(), fmt.Errorf("failed to open input: %w", err)
583589
}
584590
defer reader.Close()
585591

@@ -591,16 +597,16 @@ func applyLoudnormAndMeasure(
591597
}
592598
tempPath := strings.TrimSuffix(inputPath, ext) + ".loudnorm.tmp" + ext
593599

594-
// Build Pass 3 filter graph: loudnorm (second pass with linear=true) → ebur128 (validation)
595-
filterSpec, preGainDB, limiterClamped := buildLoudnormFilterSpec(config, measurement)
600+
// Build Pass 4 filter graph: loudnorm (second pass with linear=true) → ebur128 (validation)
601+
filterSpec := buildLoudnormFilterSpec(config, measurement, preGainDB, ceiling, needsLimiting)
596602

597603
// Create filter graph
598604
filterGraph, bufferSrcCtx, bufferSinkCtx, err := createLoudnormFilterGraph(
599605
reader.GetDecoderContext(),
600606
filterSpec,
601607
)
602608
if err != nil {
603-
return 0.0, 0.0, nil, getLoudnormStats(), 0.0, false, fmt.Errorf("failed to create filter graph: %w", err)
609+
return 0.0, 0.0, nil, getLoudnormStats(), fmt.Errorf("failed to create filter graph: %w", err)
604610
}
605611
// Note: We free the filter graph explicitly before getting stats, not via defer.
606612
// loudnorm outputs its JSON when the filter graph is freed.
@@ -609,7 +615,7 @@ func applyLoudnormAndMeasure(
609615
encoder, err := createOutputEncoder(tempPath, metadata, bufferSinkCtx)
610616
if err != nil {
611617
ffmpeg.AVFilterGraphFree(&filterGraph)
612-
return 0.0, 0.0, nil, getLoudnormStats(), 0.0, false, fmt.Errorf("failed to create encoder: %w", err)
618+
return 0.0, 0.0, nil, getLoudnormStats(), fmt.Errorf("failed to create encoder: %w", err)
613619
}
614620
defer encoder.Close()
615621

@@ -652,25 +658,25 @@ func applyLoudnormAndMeasure(
652658
})
653659
if loopErr != nil {
654660
ffmpeg.AVFilterGraphFree(&filterGraph)
655-
return 0.0, 0.0, nil, getLoudnormStats(), 0.0, false, loopErr
661+
return 0.0, 0.0, nil, getLoudnormStats(), loopErr
656662
}
657663

658664
// Flush encoder
659665
if err := encoder.Flush(); err != nil {
660666
ffmpeg.AVFilterGraphFree(&filterGraph)
661-
return 0.0, 0.0, nil, getLoudnormStats(), 0.0, false, fmt.Errorf("failed to flush encoder: %w", err)
667+
return 0.0, 0.0, nil, getLoudnormStats(), fmt.Errorf("failed to flush encoder: %w", err)
662668
}
663669

664670
// Close encoder before rename
665671
if err := encoder.Close(); err != nil {
666672
ffmpeg.AVFilterGraphFree(&filterGraph)
667-
return 0.0, 0.0, nil, getLoudnormStats(), 0.0, false, fmt.Errorf("failed to close encoder: %w", err)
673+
return 0.0, 0.0, nil, getLoudnormStats(), fmt.Errorf("failed to close encoder: %w", err)
668674
}
669675

670676
// Atomic rename: temp file → original file (in-place update)
671677
if err := os.Rename(tempPath, inputPath); err != nil {
672678
ffmpeg.AVFilterGraphFree(&filterGraph)
673-
return 0.0, 0.0, nil, getLoudnormStats(), 0.0, false, fmt.Errorf("failed to rename output: %w", err)
679+
return 0.0, 0.0, nil, getLoudnormStats(), fmt.Errorf("failed to rename output: %w", err)
674680
}
675681

676682
// Free filter graph before getting stats — loudnorm outputs JSON on graph destruction
@@ -708,15 +714,17 @@ func applyLoudnormAndMeasure(
708714
}
709715
}
710716

711-
return acc.ebur128OutputI, acc.ebur128OutputTP, finalMeasurements, stats, preGainDB, limiterClamped, nil
717+
return acc.ebur128OutputI, acc.ebur128OutputTP, finalMeasurements, stats, nil
712718
}
713719

714720
// buildLoudnormFilterSpec constructs the filter chain for Pass 4 loudnorm application.
715721
//
716-
// Chain order: [alimiter] → loudnorm → [adeclick] → astats → aspectralstats → ebur128 → resample
722+
// Chain order: [volume+alimiter] → loudnorm → [adeclick] → astats → aspectralstats → ebur128 → resample
717723
//
718-
// The alimiter is inserted when needed to create headroom for loudnorm's linear mode.
719-
// It uses CBS Volumax-inspired parameters for transparent peak limiting.
724+
// The caller pre-computes preGainDB, ceiling, and needsLimiting from Pass 2 measurements.
725+
// This function builds the prefix via buildPreLimiterPrefix() and passes measurement.InputI
726+
// and measurement.InputTP directly to loudnorm as measured_I and measured_TP - no manual
727+
// adjustment is needed because Pass 3 already measured through the same prefix chain.
720728
//
721729
// The loudnorm filter in second pass mode:
722730
// - Uses measurements from measureWithLoudnorm() (LoudnormMeasurement)
@@ -727,68 +735,34 @@ func applyLoudnormAndMeasure(
727735
// 192kHz and outputs f64. We want spectral measurements at the original sample rate
728736
// to match Pass 2's measurements for accurate comparison.
729737
//
730-
// Key parameters:
731-
// - I/TP/LRA: Target values (from config)
732-
// - measured_I/TP/LRA/thresh: Measurements from loudnorm first pass
733-
// - offset: Target offset from first pass (critical for linear mode)
734-
// - dual_mono: CRITICAL for mono files (corrects -3 LU measurement error)
735-
// - linear: Enable linear mode (applies consistent gain, no adaptive EQ)
736-
//
737738
// Per ffmpeg-loudnorm-helper: the offset parameter MUST come from loudnorm's own
738739
// first pass measurement, not from external calculations.
739-
func buildLoudnormFilterSpec(config *FilterChainConfig, measurement *LoudnormMeasurement) (string, float64, bool) {
740+
func buildLoudnormFilterSpec(config *FilterChainConfig, measurement *LoudnormMeasurement, preGainDB float64, ceiling float64, needsLimiting bool) string {
740741
var filters []string
741742

742-
// 1. Pre-limiting with adaptive ceiling (CBS Volumax-inspired peak limiter)
743-
ceiling, needsLimiting, clamped := calculateLimiterCeiling(
744-
measurement.InputI,
745-
measurement.InputTP,
746-
config.LoudnormTargetI,
747-
config.LoudnormTargetTP,
748-
)
749-
750-
// 1a. Pre-gain: when the ceiling is clamped, raise signal by the deficit
751-
preGainDB, reDerivedCeiling := calculatePreGain(
752-
measurement.InputI, config.LoudnormTargetI, config.LoudnormTargetTP,
753-
)
754-
if clamped {
755-
ceiling = reDerivedCeiling
756-
}
757-
758-
// 1b. Build pre-limiter prefix (volume + alimiter)
743+
// 1. Build pre-limiter prefix (volume + alimiter) from pre-computed values
759744
prefix := buildPreLimiterPrefix(preGainDB, ceiling, needsLimiting)
760745
if prefix != "" {
761746
filters = append(filters, prefix)
762747
}
763748

764-
// Derive effective measurements for loudnorm
765-
effectiveMeasuredI := measurement.InputI
766-
effectiveMeasuredTP := measurement.InputTP
767-
if clamped {
768-
effectiveMeasuredI = measurement.InputI + preGainDB
769-
effectiveMeasuredTP = reDerivedCeiling
770-
} else if needsLimiting {
771-
effectiveMeasuredTP = ceiling
772-
}
773-
774749
// 2. loudnorm (second pass mode)
775750
// measured_i/tp/lra/thresh come from loudnorm's first pass measurement
776751
// offset: loudnorm's own calculated offset from first pass (critical!)
777752
// linear=true: Enable linear mode (applies consistent gain, no adaptive EQ)
778753
// dual_mono=true: CRITICAL - treats mono as dual-mono for correct loudness measurement
779754
// print_format=json: Outputs JSON with normalization_type, target_offset, output_i/tp/lra
780755
//
781-
// IMPORTANT: When pre-limiting is enabled, we pass the limiter ceiling as measured_TP
782-
// so loudnorm knows the actual peak level it will receive. This allows it to apply
783-
// full linear gain without falling back to dynamic mode.
784-
// When pre-gain is active, measured_I and measured_TP reflect the post-gain values.
756+
// Pass 3 now measures with the same volume+alimiter prefix, so measurement.InputI
757+
// and measurement.InputTP already reflect the post-limiter signal. No manual
758+
// effectiveMeasuredI/effectiveMeasuredTP adjustment needed.
785759
loudnormFilter := fmt.Sprintf(
786760
"loudnorm=I=%.2f:TP=%.2f:LRA=%.1f:measured_I=%.2f:measured_TP=%.2f:measured_LRA=%.2f:measured_thresh=%.2f:offset=%.2f:dual_mono=%s:linear=%s:print_format=json",
787761
config.LoudnormTargetI, // Using %.2f for precision on adjusted targets
788762
config.LoudnormTargetTP, // Also %.2f for consistency
789763
config.LoudnormTargetLRA,
790-
effectiveMeasuredI,
791-
effectiveMeasuredTP,
764+
measurement.InputI,
765+
measurement.InputTP,
792766
measurement.InputLRA,
793767
measurement.InputThresh,
794768
measurement.TargetOffset, // From first pass - critical for linear mode
@@ -833,7 +807,7 @@ func buildLoudnormFilterSpec(config *FilterChainConfig, measurement *LoudnormMea
833807
}
834808
config.ResampleEnabled = wasEnabled
835809

836-
return strings.Join(filters, ","), preGainDB, clamped
810+
return strings.Join(filters, ",")
837811
}
838812

839813
// boolToString converts bool to loudnorm's expected string format

0 commit comments

Comments
 (0)