Skip to content

Commit 7528ebe

Browse files
committed
feat(processor): add adeclick click/pop repair after loudnorm
- Add FilterAdeclick identifier and register buildAdeclickFilter in filterBuilders - Add Adeclick config fields to FilterChainConfig (enabled, threshold, window, overlap) - Enable conservative adeclick defaults in DefaultFilterConfig (Pass 4) - Implement buildAdeclickFilter() to build the adeclick FFmpeg spec - Insert adeclick into the Pass 4 loudnorm chain (after loudnorm, before measurements) - Add TestBuildAdeclickFilter and update newTestConfig defaults to include adeclick Repairs waveform discontinuities (clicks/pops) introduced by limiter or loudnorm gain transitions by running FFmpeg's adeclick after normalisation. No breaking changes. Signed-off-by: Martin Wimpress <martin@wimpress.org>
1 parent a80d892 commit 7528ebe

3 files changed

Lines changed: 114 additions & 5 deletions

File tree

internal/processor/filters.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const (
3434
FilterLA2ACompressor FilterID = "la2a_compressor" // Teletronix LA-2A style optical compressor
3535
FilterDeesser FilterID = "deesser"
3636
FilterVolumax FilterID = "volumax_limiter" // CBS Volumax-inspired transparent limiter
37+
FilterAdeclick FilterID = "adeclick" // Click/pop repair via interpolation
3738
)
3839

3940
// Pass1FilterOrder defines the filter chain for analysis pass.
@@ -99,6 +100,7 @@ var filterBuilders = map[FilterID]filterBuilderFunc{
99100
FilterLA2ACompressor: (*FilterChainConfig).buildLA2ACompressorFilter,
100101
FilterDeesser: (*FilterChainConfig).buildDeesserFilter,
101102
FilterVolumax: (*FilterChainConfig).buildVolumaxFilter,
103+
FilterAdeclick: (*FilterChainConfig).buildAdeclickFilter,
102104
}
103105

104106
// FilterChainConfig holds configuration for the audio processing filter chain
@@ -226,6 +228,14 @@ type FilterChainConfig struct {
226228
// When enabled, measurements are extracted from processed audio for comparison with Pass 1
227229
OutputAnalysisEnabled bool
228230

231+
// Adeclick - Click/pop repair filter (Pass 4)
232+
// Detects and repairs waveform discontinuities through interpolation
233+
// Applied after loudnorm to catch clicks from limiter and gain changes
234+
AdeclickEnabled bool // Enable adeclick filter (default: true in Pass 4)
235+
AdeclickThreshold float64 // Detection sensitivity (0.1-8.0, lower=more sensitive)
236+
AdeclickWindow float64 // Analysis window in ms (10-100)
237+
AdeclickOverlap float64 // Window overlap percentage (50-95)
238+
229239
// Loudnorm (Pass 3) - EBU R128 dynamic loudness normalisation
230240
// Replaces simple volume gain + limiting with integrated dynamic normalisation
231241
// Uses two-pass mode with measurements from Pass 2 for optimal transparency
@@ -333,6 +343,13 @@ func DefaultFilterConfig() *FilterChainConfig {
333343
VolumaxInputLevel: 1.0, // Unity input
334344
VolumaxOutputLevel: 1.0, // Unity output
335345

346+
// Adeclick - click/pop repair (Pass 4 only)
347+
// Conservative parameters for transparent repair
348+
AdeclickEnabled: true,
349+
AdeclickThreshold: 1.5, // Slightly more sensitive than FFmpeg default (2.0)
350+
AdeclickWindow: 55.0, // Default window, appropriate for speech
351+
AdeclickOverlap: 75.0, // Default overlap, good detection coverage
352+
336353
// Filter chain order - use default order
337354
FilterOrder: Pass2FilterOrder,
338355

@@ -709,6 +726,26 @@ func (cfg *FilterChainConfig) buildVolumaxFilter() string {
709726
return spec
710727
}
711728

729+
// buildAdeclickFilter builds the click/pop repair filter specification.
730+
// Uses interpolation to repair waveform discontinuities.
731+
// Applied in Pass 4 after loudnorm to catch clicks from limiter and gain changes.
732+
//
733+
// Parameters:
734+
// - t (threshold): Detection sensitivity (0.1-8.0, lower=more sensitive)
735+
// - w (window): Analysis window in ms (10-100, default 55)
736+
// - o (overlap): Window overlap percentage (50-95, default 75)
737+
func (cfg *FilterChainConfig) buildAdeclickFilter() string {
738+
if !cfg.AdeclickEnabled {
739+
return ""
740+
}
741+
return fmt.Sprintf(
742+
"adeclick=t=%.1f:w=%.0f:o=%.0f",
743+
cfg.AdeclickThreshold,
744+
cfg.AdeclickWindow,
745+
cfg.AdeclickOverlap,
746+
)
747+
}
748+
712749
// BuildFilterSpec builds the FFmpeg filter specification string for Pass 2 processing.
713750
// Filter order is determined by cfg.FilterOrder (or Pass2FilterOrder if empty).
714751
// Each filter checks its Enabled flag and returns empty string if disabled.

internal/processor/filters_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ func newTestConfig() *FilterChainConfig {
8282
LoudnormDualMono: true,
8383
LoudnormLinear: true,
8484

85+
// Adeclick defaults (Pass 4)
86+
AdeclickEnabled: true,
87+
AdeclickThreshold: 1.5,
88+
AdeclickWindow: 55.0,
89+
AdeclickOverlap: 75.0,
90+
8591
FilterOrder: Pass2FilterOrder,
8692
}
8793
}
@@ -686,6 +692,61 @@ func TestBuildVolumaxFilter(t *testing.T) {
686692
})
687693
}
688694

695+
func TestBuildAdeclickFilter(t *testing.T) {
696+
t.Run("default parameters", func(t *testing.T) {
697+
config := newTestConfig()
698+
config.AdeclickEnabled = true
699+
config.AdeclickThreshold = 1.5
700+
config.AdeclickWindow = 55.0
701+
config.AdeclickOverlap = 75.0
702+
703+
spec := config.buildAdeclickFilter()
704+
705+
wantIn := []string{
706+
"adeclick=",
707+
"t=1.5",
708+
"w=55",
709+
"o=75",
710+
}
711+
712+
for _, want := range wantIn {
713+
if !strings.Contains(spec, want) {
714+
t.Errorf("buildAdeclickFilter() = %q, want to contain %q", spec, want)
715+
}
716+
}
717+
})
718+
719+
t.Run("custom parameters", func(t *testing.T) {
720+
config := newTestConfig()
721+
config.AdeclickEnabled = true
722+
config.AdeclickThreshold = 2.0
723+
config.AdeclickWindow = 100.0
724+
config.AdeclickOverlap = 50.0
725+
726+
spec := config.buildAdeclickFilter()
727+
728+
if !strings.Contains(spec, "t=2.0") {
729+
t.Errorf("buildAdeclickFilter() = %q, want to contain t=2.0", spec)
730+
}
731+
if !strings.Contains(spec, "w=100") {
732+
t.Errorf("buildAdeclickFilter() = %q, want to contain w=100", spec)
733+
}
734+
if !strings.Contains(spec, "o=50") {
735+
t.Errorf("buildAdeclickFilter() = %q, want to contain o=50", spec)
736+
}
737+
})
738+
739+
t.Run("disabled returns empty", func(t *testing.T) {
740+
config := newTestConfig()
741+
config.AdeclickEnabled = false
742+
743+
spec := config.buildAdeclickFilter()
744+
if spec != "" {
745+
t.Errorf("buildAdeclickFilter() = %q, want empty when disabled", spec)
746+
}
747+
})
748+
}
749+
689750
func TestFilterOrderRespected(t *testing.T) {
690751
config := newTestConfig()
691752
// Enable filters that appear at start and end

internal/processor/normalise.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,7 @@ func applyLoudnormAndMeasure(
693693

694694
// buildLoudnormFilterSpec constructs the filter chain for Pass 4 loudnorm application.
695695
//
696-
// Chain order: [alimiter] → loudnorm → astats → aspectralstats → ebur128 → resample
696+
// Chain order: [alimiter] → loudnorm → [adeclick] → astats → aspectralstats → ebur128 → resample
697697
//
698698
// The alimiter is inserted when needed to create headroom for loudnorm's linear mode.
699699
// It uses CBS Volumax-inspired parameters for transparent peak limiting.
@@ -772,22 +772,33 @@ func buildLoudnormFilterSpec(config *FilterChainConfig, measurement *LoudnormMea
772772
)
773773
filters = append(filters, loudnormFilter)
774774

775-
// 3. astats for amplitude measurements (same as Pass 2)
775+
// 3. adeclick for click/pop repair
776+
// Repairs waveform discontinuities from limiter/loudnorm gain transitions
777+
// Must come after loudnorm (catches its clicks) and before measurement filters
778+
if config.AdeclickEnabled {
779+
filters = append(filters, fmt.Sprintf("adeclick=t=%.1f:w=%.0f:o=%.0f",
780+
config.AdeclickThreshold,
781+
config.AdeclickWindow,
782+
config.AdeclickOverlap,
783+
))
784+
}
785+
786+
// 4. astats for amplitude measurements (same as Pass 2)
776787
// Provides noise floor, dynamic range, RMS level, peak level, etc.
777788
// measure_perchannel=all requests all available per-channel statistics
778789
filters = append(filters, "astats=metadata=1:measure_perchannel=all")
779790

780-
// 4. aspectralstats for spectral analysis (same as Pass 2)
791+
// 5. aspectralstats for spectral analysis (same as Pass 2)
781792
// Provides centroid, spread, skewness, kurtosis, entropy, flatness, crest, rolloff, etc.
782793
// win_size=2048 and win_func=hann match Pass 2 settings for comparable measurements
783794
filters = append(filters, "aspectralstats=win_size=2048:win_func=hann:measure=all")
784795

785-
// 5. ebur128 for loudness validation (metadata only, no audio modification)
796+
// 6. ebur128 for loudness validation (metadata only, no audio modification)
786797
// dualmono=true ensures accurate mono loudness measurement
787798
// Note: ebur128 upsamples to 192kHz internally and outputs f64
788799
filters = append(filters, "ebur128=metadata=1:peak=sample+true:dualmono=true")
789800

790-
// 6. Resample back to output format (44.1kHz/s16/mono)
801+
// 7. Resample back to output format (44.1kHz/s16/mono)
791802
// Required because ebur128 outputs f64 at 192kHz; encoder expects s16 at 44.1kHz
792803
// Temporarily enable resample to get the filter spec, then restore
793804
wasEnabled := config.ResampleEnabled

0 commit comments

Comments
 (0)