Skip to content

Commit 44e6abe

Browse files
committed
wip(processor): add BleedGate to suppress amplified headphone bleed
- Rename PostGate to BleedGate for clarity of purpose - Implement measurement-informed tuning using crest factor and peak-to-floor ratio - Detect bleed presence in silence samples and adapt gate parameters accordingly - Position BleedGate after speechnorm/dynaudnorm to catch amplified crosstalk - Add adaptive ratio (4:1 mild, 6:1 severe) and range (-18dB to -24dB) based on bleed severity - Update report output to show BleedGate settings and reasoning BREAKING CHANGE: PostGate config fields renamed to BleedGate*. Any existing configurations using PostGate* will need to be updated to use BleedGate*. Note: BleedGate is limited for severe bleed cases where silence is amplified >40dB. See docs/BLEED_GATE.md for detailed assessment and recommended pre-emptive approaches.
1 parent 649f757 commit 44e6abe

5 files changed

Lines changed: 658 additions & 24 deletions

File tree

internal/logging/report.go

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,23 @@ func GenerateReport(data ReportData) error {
182182
cfg := data.Result.Config
183183
gateThresholdDB := 20.0 * math.Log10(cfg.GateThreshold)
184184

185-
fmt.Fprintln(f, "Gate:")
185+
fmt.Fprintln(f, "Gate (pre-normalisation):")
186186
fmt.Fprintf(f, " - Threshold: %.1f dB (adaptive, based on noise floor)\n", gateThresholdDB)
187187
fmt.Fprintf(f, " - Ratio: %.1f:1\n", cfg.GateRatio)
188188
fmt.Fprintf(f, " - Attack/Release: %.0fms/%.0fms\n", cfg.GateAttack, cfg.GateRelease)
189189
fmt.Fprintln(f, "")
190190

191+
// Bleed gate (catches amplified bleed/crosstalk after normalisation)
192+
if cfg.BleedGateEnabled {
193+
bleedGateThresholdDB := 20.0 * math.Log10(cfg.BleedGateThreshold)
194+
fmt.Fprintln(f, "Bleed Gate (post-normalisation):")
195+
fmt.Fprintf(f, " - Threshold: %.1f dB (adaptive, based on predicted output bleed)\n", bleedGateThresholdDB)
196+
fmt.Fprintf(f, " - Ratio: %.1f:1\n", cfg.BleedGateRatio)
197+
fmt.Fprintf(f, " - Range: %.1f dB reduction\n", 20.0*math.Log10(cfg.BleedGateRange))
198+
fmt.Fprintf(f, " - Attack/Release: %.0fms/%.0fms\n", cfg.BleedGateAttack, cfg.BleedGateRelease)
199+
fmt.Fprintln(f, "")
200+
}
201+
191202
compThresholdDB := cfg.CompThreshold
192203
fmt.Fprintln(f, "Compression:")
193204
fmt.Fprintf(f, " - Threshold: %.0f dB\n", compThresholdDB)
@@ -241,12 +252,61 @@ func GenerateReport(data ReportData) error {
241252
fmt.Fprintln(f, "")
242253
}
243254

244-
// Output Analysis
245-
fmt.Fprintln(f, "Output Analysis")
246-
fmt.Fprintln(f, "---------------")
255+
// Pass 2: Output Analysis
256+
fmt.Fprintln(f, "Pass 2: Output Analysis")
257+
fmt.Fprintln(f, "-----------------------")
247258
if data.Result != nil {
248259
fmt.Fprintf(f, "Output File: %s\n", filepath.Base(data.OutputPath))
249-
fmt.Fprintln(f, "Note: Output LUFS not measured (would require third-pass analysis)")
260+
261+
if data.Result.OutputMeasurements != nil {
262+
om := data.Result.OutputMeasurements
263+
fmt.Fprintf(f, "Integrated Loudness: %.1f LUFS\n", om.OutputI)
264+
fmt.Fprintf(f, "True Peak: %.1f dBTP\n", om.OutputTP)
265+
fmt.Fprintf(f, "Loudness Range: %.1f LU\n", om.OutputLRA)
266+
fmt.Fprintf(f, "Dynamic Range: %.1f dB\n", om.DynamicRange)
267+
fmt.Fprintf(f, "RMS Level: %.1f dBFS\n", om.RMSLevel)
268+
fmt.Fprintf(f, "Peak Level: %.1f dBFS\n", om.PeakLevel)
269+
fmt.Fprintf(f, "Spectral Centroid: %.0f Hz\n", om.SpectralCentroid)
270+
fmt.Fprintf(f, "Spectral Rolloff: %.0f Hz\n", om.SpectralRolloff)
271+
272+
// Show deltas vs input for easy comparison
273+
if data.Result.Measurements != nil {
274+
m := data.Result.Measurements
275+
fmt.Fprintln(f, "")
276+
fmt.Fprintln(f, "Changes from Input:")
277+
fmt.Fprintf(f, " LUFS: %+.1f dB\n", om.OutputI-m.InputI)
278+
fmt.Fprintf(f, " True Peak: %+.1f dB\n", om.OutputTP-m.InputTP)
279+
fmt.Fprintf(f, " Loudness Range: %+.1f LU\n", om.OutputLRA-m.InputLRA)
280+
fmt.Fprintf(f, " Dynamic Range: %+.1f dB\n", om.DynamicRange-m.DynamicRange)
281+
fmt.Fprintf(f, " Spectral Centroid: %+.0f Hz\n", om.SpectralCentroid-m.SpectralCentroid)
282+
}
283+
284+
// Show silence sample comparison (same region as Pass 1)
285+
if om.SilenceSample != nil && data.Result.Measurements != nil && data.Result.Measurements.NoiseProfile != nil {
286+
ss := om.SilenceSample
287+
np := data.Result.Measurements.NoiseProfile
288+
fmt.Fprintln(f, "")
289+
fmt.Fprintf(f, "Silence Sample: %.1fs at %.1fs\n", ss.Duration.Seconds(), ss.Start.Seconds())
290+
fmt.Fprintf(f, " Noise Floor: %.1f dBFS (was %.1f dBFS, %+.1f dB)\n",
291+
ss.NoiseFloor, np.MeasuredNoiseFloor, ss.NoiseFloor-np.MeasuredNoiseFloor)
292+
fmt.Fprintf(f, " Peak Level: %.1f dBFS (was %.1f dBFS, %+.1f dB)\n",
293+
ss.PeakLevel, np.PeakLevel, ss.PeakLevel-np.PeakLevel)
294+
fmt.Fprintf(f, " Crest Factor: %.1f dB (was %.1f dB)\n",
295+
ss.CrestFactor, np.CrestFactor)
296+
if ss.Entropy > 0 {
297+
// Classify noise type based on entropy
298+
noiseType := "broadband (hiss)"
299+
if ss.Entropy < 0.7 {
300+
noiseType = "tonal (hum/buzz)"
301+
} else if ss.Entropy < 0.9 {
302+
noiseType = "mixed"
303+
}
304+
fmt.Fprintf(f, " Entropy: %.3f (%s)\n", ss.Entropy, noiseType)
305+
}
306+
}
307+
} else {
308+
fmt.Fprintln(f, "Note: Output measurements not available")
309+
}
250310
}
251311
fmt.Fprintln(f, "")
252312

internal/processor/adaptive.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,19 @@ const (
9999
speechnormPeakTarget = 0.95 // Headroom for limiter
100100
speechnormSmoothingFast = 0.001 // Fast response time
101101

102+
// Bleed gate parameters
103+
// Catches bleed/crosstalk that was amplified by speechnorm/dynaudnorm
104+
// Threshold is calculated from: predicted_output_bleed = silence_peak_level + worst_case_gain
105+
bleedGateMarginDB = 6.0 // dB above predicted bleed to set threshold (safety margin)
106+
bleedGateEnableThresholdDB = -40.0 // dBFS - only enable if predicted output bleed is above this
107+
bleedGateMinThresholdDB = -50.0 // dBFS - minimum threshold (never gate below this)
108+
bleedGateMaxThresholdDB = -20.0 // dBFS - maximum threshold (never gate above this, would cut speech)
109+
bleedGateDefaultRatio = 4.0 // Gentler than pre-gate (suppress rather than cut)
110+
bleedGateDefaultAttack = 15.0 // ms - faster than pre-gate
111+
bleedGateDefaultRelease = 200.0 // ms - smooth release
112+
bleedGateDefaultRange = 0.125 // -18dB reduction (less aggressive than pre-gate)
113+
bleedGateDefaultKnee = 3.0 // Soft knee
114+
102115
// Mains hum filter parameters
103116
humEntropyThreshold = 0.7 // Below this = tonal noise detected (hum/buzz)
104117
humFreq50Hz = 50.0 // UK/EU mains fundamental frequency
@@ -167,6 +180,7 @@ func AdaptConfig(config *FilterChainConfig, measurements *AudioMeasurements) {
167180
tuneCompression(config, measurements)
168181
tuneDynaudnorm(config)
169182
tuneSpeechnorm(config, measurements, lufsGap)
183+
tuneBleedGate(config, measurements, lufsGap) // Bleed gate for amplified bleed/crosstalk
170184

171185
// Final safety checks
172186
sanitizeConfig(config)
@@ -608,6 +622,106 @@ func tuneSpeechnormDenoise(config *FilterChainConfig, expansion float64) {
608622
}
609623
}
610624

625+
// tuneBleedGate adapts the bleed gate based on predicted output bleed level.
626+
//
627+
// The bleed gate catches bleed/crosstalk that was amplified by speechnorm/dynaudnorm
628+
// after the denoisers have run. Denoisers preserve speech-like content (which is what
629+
// they're designed to do), but headphone bleed IS speech-like content from other speakers.
630+
//
631+
// Strategy:
632+
// - Use silence sample PEAK level (not noise floor) - captures bleed bursts, not just hiss
633+
// - Calculate worst-case gain: speechnorm normalises each half-cycle to peak target
634+
// - For silence with bleed, this can mean 40-50dB of gain applied
635+
// - Use crest factor to detect presence of bleed (high crest = impulsive content in silence)
636+
// - Adjust ratio/range based on how much bleed is detected
637+
//
638+
// Key insight: Speechnorm applies VARIABLE gain per half-cycle. For quiet sections
639+
// (like silence with bleed), it applies much more gain than the "expansion" factor
640+
// suggests. The actual gain on silence can be:
641+
//
642+
// silence_input_peak → target_peak (0.95 = -0.45 dBFS)
643+
//
644+
// Measurements used:
645+
// - NoiseProfile.PeakLevel: captures the loudest bleed burst
646+
// - NoiseProfile.MeasuredNoiseFloor: captures the background hiss
647+
// - NoiseProfile.CrestFactor: high crest = impulsive content (bleed), low = steady hiss
648+
// - NoiseProfile.Entropy: low entropy = tonal (hum), high = broadband (hiss/bleed)
649+
func tuneBleedGate(config *FilterChainConfig, measurements *AudioMeasurements, lufsGap float64) {
650+
// Need noise profile with measurements to calculate threshold
651+
if measurements.NoiseProfile == nil {
652+
config.BleedGateEnabled = false
653+
return
654+
}
655+
656+
np := measurements.NoiseProfile
657+
658+
// Calculate worst-case gain: speechnorm can apply gain to bring quiet content to peak
659+
// The target peak is typically 0.95 (-0.45 dBFS)
660+
targetPeakDB := -0.45 // 20 * log10(0.95)
661+
if config.SpeechnormEnabled && config.SpeechnormPeak > 0 {
662+
targetPeakDB = 20.0 * math.Log10(config.SpeechnormPeak)
663+
}
664+
665+
// Worst-case gain = what's needed to bring silence peak to target peak
666+
// This is the maximum gain speechnorm could apply to the silence content
667+
worstCaseGainDB := targetPeakDB - np.PeakLevel
668+
669+
// Calculate predicted output level for the silence PEAK (the bleed content)
670+
predictedOutputPeakDB := np.PeakLevel + worstCaseGainDB
671+
672+
// Calculate predicted output noise floor
673+
predictedOutputNoiseDB := np.MeasuredNoiseFloor + worstCaseGainDB
674+
675+
// Detect bleed presence using crest factor and peak-to-floor ratio
676+
// Crest factor = peak - RMS; high crest means impulsive content in silence
677+
// For pure hiss, crest factor is ~10-12dB; for bleed it's typically 20-30dB
678+
peakToFloorDB := np.PeakLevel - np.MeasuredNoiseFloor
679+
hasSignificantBleed := np.CrestFactor > 15.0 || peakToFloorDB > 20.0
680+
681+
// Determine threshold strategy based on bleed detection
682+
var thresholdDB float64
683+
if hasSignificantBleed {
684+
// Bleed detected - use peak-based threshold (more aggressive)
685+
// Set threshold to catch the amplified bleed peaks
686+
thresholdDB = predictedOutputPeakDB - 3.0 // 3dB below predicted peak
687+
} else {
688+
// No significant bleed - use noise floor based threshold (standard approach)
689+
thresholdDB = predictedOutputNoiseDB + bleedGateMarginDB
690+
}
691+
692+
// Only enable bleed gate if predicted output would be audible
693+
if thresholdDB < bleedGateEnableThresholdDB {
694+
config.BleedGateEnabled = false
695+
return
696+
}
697+
698+
// Enable bleed gate
699+
config.BleedGateEnabled = true
700+
701+
// Clamp threshold to safety limits
702+
thresholdDB = clamp(thresholdDB, bleedGateMinThresholdDB, bleedGateMaxThresholdDB)
703+
704+
// Convert to linear for agate filter
705+
config.BleedGateThreshold = dbToLinear(thresholdDB)
706+
707+
// Adapt ratio and range based on bleed severity
708+
if hasSignificantBleed {
709+
// Significant bleed - use stronger settings
710+
config.BleedGateRatio = 6.0 // Stronger ratio for bleed
711+
config.BleedGateRange = 0.063 // -24dB reduction (more aggressive)
712+
config.BleedGateAttack = 10.0 // Faster attack to catch bleed transients
713+
config.BleedGateRelease = 150.0
714+
} else {
715+
// Mild bleed or just noise amplification - use gentler settings
716+
config.BleedGateRatio = bleedGateDefaultRatio
717+
config.BleedGateRange = bleedGateDefaultRange
718+
config.BleedGateAttack = bleedGateDefaultAttack
719+
config.BleedGateRelease = bleedGateDefaultRelease
720+
}
721+
722+
config.BleedGateKnee = bleedGateDefaultKnee
723+
}
724+
611725
// sanitizeConfig ensures no NaN or Inf values remain after adaptive tuning
612726
func sanitizeConfig(config *FilterChainConfig) {
613727
config.HighpassFreq = sanitizeFloat(config.HighpassFreq, defaultHighpassFreq)
@@ -631,6 +745,11 @@ func sanitizeConfig(config *FilterChainConfig) {
631745

632746
// ArnnDn second pass mix sanitization
633747
config.ArnnDnMix2 = sanitizeFloat(config.ArnnDnMix2, defaultArnnDnMix2)
748+
749+
// BleedGateThreshold needs additional check for zero/negative (like pre-gate)
750+
if math.IsNaN(config.BleedGateThreshold) || math.IsInf(config.BleedGateThreshold, 0) || config.BleedGateThreshold <= 0 {
751+
config.BleedGateThreshold = defaultGateThreshold // Use same default as pre-gate
752+
}
634753
}
635754

636755
// sanitizeFloat returns defaultVal if val is NaN or Inf

0 commit comments

Comments
 (0)