@@ -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.
563566func 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