@@ -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
612726func 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