From 5ac6703489bdabe629051e7b868bdfb6985b794c Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 28 Jun 2026 23:15:09 -0400 Subject: [PATCH] Use RNNoise for noise suppression --- README.md | 9 +- config/user_config.go | 58 +++------ gumble/gumbleopenal/stream.go | 4 - main.go | 1 - noise/suppression.go | 224 +++++++++------------------------- noise/suppression_test.go | 99 ++++----------- 6 files changed, 103 insertions(+), 292 deletions(-) diff --git a/README.md b/README.md index b210db4..2fa0261 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,10 @@ Voice effects are applied to your outgoing audio in real-time, after noise suppr ## Noise Suppression -Barnard includes real-time noise suppression for microphone input to filter out background noise such as keyboard typing, computer fans, and other environmental sounds. +Barnard includes RNNoise-based real-time noise suppression for microphone input to filter out background noise such as keyboard typing, computer fans, and other environmental sounds. ### Features - **Real-time processing**: Noise suppression is applied during audio capture with minimal latency -- **Configurable amount**: Adjustable suppression amount via threshold value (default: `0.08`) - **Persistent settings**: Noise suppression preferences are saved in your configuration file - **Multiple control methods**: Toggle via hotkey, command line flag, or FIFO commands @@ -52,16 +51,14 @@ Barnard includes real-time noise suppression for microphone input to filter out - **F9 key**: Toggle noise suppression on/off (configurable hotkey) - **Command line**: Use `--noise-suppression` flag to enable at startup - **FIFO command**: Send `noise` command to toggle during runtime -- **Configuration**: Set `noisesuppressionenabled` and `noisesuppressionthreshold` in `~/.barnard.toml` +- **Configuration**: Set `noisesuppressionenabled` in `~/.barnard.toml` ### Configuration Example ```toml noisesuppressionenabled = true -noisesuppressionthreshold = 0.08 ``` -`noisesuppressionthreshold` accepts values from `0.0` to `1.0`, where higher values apply stronger suppression. -The noise suppression algorithm uses adaptive noise-floor tracking, transient suppression, and smoothed gain reduction to reduce background noise while preserving voice quality. +RNNoise is a required build and runtime dependency. ## FIFO Control diff --git a/config/user_config.go b/config/user_config.go index c4f04e0..d62583e 100644 --- a/config/user_config.go +++ b/config/user_config.go @@ -18,21 +18,20 @@ type Config struct { } type exportableConfig struct { - Hotkeys *Hotkeys - AudioDriver *string - MicVolume *float32 - InputDevice *string - OutputDevice *string - Servers []*server - DefaultServer *string - Username *string - NotifyCommand *string - NoiseSuppressionEnabled *bool - NoiseSuppressionThreshold *float32 - VoiceEffect *int - Certificate *string - RecordingFormat *string - RecordingDirectory *string + Hotkeys *Hotkeys + AudioDriver *string + MicVolume *float32 + InputDevice *string + OutputDevice *string + Servers []*server + DefaultServer *string + Username *string + NotifyCommand *string + NoiseSuppressionEnabled *bool + VoiceEffect *int + Certificate *string + RecordingFormat *string + RecordingDirectory *string } type server struct { @@ -132,10 +131,6 @@ func (c *Config) LoadConfig() { enabled := false jc.NoiseSuppressionEnabled = &enabled } - if c.config.NoiseSuppressionThreshold == nil { - threshold := float32(0.08) - jc.NoiseSuppressionThreshold = &threshold - } if c.config.VoiceEffect == nil { effect := 0 // Default to EffectNone jc.VoiceEffect = &effect @@ -325,31 +320,6 @@ func (c *Config) SetNoiseSuppressionEnabled(enabled bool) { c.SaveConfig() } -func (c *Config) GetNoiseSuppressionThreshold() float32 { - if c.config.NoiseSuppressionThreshold == nil { - return 0.08 - } - threshold := *c.config.NoiseSuppressionThreshold - if threshold < 0.0 { - return 0.0 - } - if threshold > 1.0 { - return 1.0 - } - return threshold -} - -func (c *Config) SetNoiseSuppressionThreshold(threshold float32) { - if threshold < 0.0 { - threshold = 0.0 - } - if threshold > 1.0 { - threshold = 1.0 - } - c.config.NoiseSuppressionThreshold = &threshold - c.SaveConfig() -} - func (c *Config) GetVoiceEffect() int { if c.config.VoiceEffect == nil { return 0 diff --git a/gumble/gumbleopenal/stream.go b/gumble/gumbleopenal/stream.go index 114eb2e..f58082f 100644 --- a/gumble/gumbleopenal/stream.go +++ b/gumble/gumbleopenal/stream.go @@ -603,9 +603,6 @@ func (s *Stream) syncStereoProcessors() { if leftSuppressor.IsEnabled() != rightSuppressor.IsEnabled() { rightSuppressor.SetEnabled(leftSuppressor.IsEnabled()) } - if leftSuppressor.GetThreshold() != rightSuppressor.GetThreshold() { - rightSuppressor.SetThreshold(leftSuppressor.GetThreshold()) - } } leftEffects, leftOk := s.effectsProcessor.(*audio.EffectsProcessor) @@ -627,7 +624,6 @@ func cloneNoiseProcessor(np NoiseProcessor) NoiseProcessor { if suppressor, ok := np.(*noise.Suppressor); ok { clone := noise.NewSuppressor() clone.SetEnabled(suppressor.IsEnabled()) - clone.SetThreshold(suppressor.GetThreshold()) return clone } return nil diff --git a/main.go b/main.go index b005fe8..07a1e05 100644 --- a/main.go +++ b/main.go @@ -195,7 +195,6 @@ func main() { b.UserConfig.SetNoiseSuppressionEnabled(true) } b.NoiseSuppressor.SetEnabled(enabled) - b.NoiseSuppressor.SetThreshold(b.UserConfig.GetNoiseSuppressionThreshold()) // Configure voice effects b.VoiceEffects.SetEffect(audio.VoiceEffect(b.UserConfig.GetVoiceEffect())) diff --git a/noise/suppression.go b/noise/suppression.go index a67ca46..8ace9de 100644 --- a/noise/suppression.go +++ b/noise/suppression.go @@ -1,212 +1,106 @@ package noise +/* +#cgo pkg-config: rnnoise +#include +#include +*/ +import "C" + import ( "math" + "runtime" "sync" ) -// Ensure Suppressor implements the NoiseProcessor interface +// Ensure Suppressor implements the NoiseProcessor interface. var _ interface { ProcessSamples(samples []int16) IsEnabled() bool } = (*Suppressor)(nil) -// Suppressor handles noise suppression for audio samples +// Suppressor handles RNNoise-backed noise suppression for microphone samples. type Suppressor struct { - mu sync.Mutex - + mu sync.Mutex + state *C.DenoiseState enabled bool - threshold float32 - - // High-pass filter state for low-frequency rumble/DC removal. - prevInput float32 - prevOutput float32 - hpAlpha float32 - - // Adaptive suppression state. - envelope float32 - noiseFloor float32 - suppressionGain float32 - clickEnergy float32 - - // Tunables. - envelopeAttack float32 - envelopeRelease float32 - noiseAttack float32 - noiseRelease float32 - gainAttack float32 - gainRelease float32 - speechRatio float32 - clickDecay float32 - minNoiseFloor float32 + frameSize int } -// NewSuppressor creates a new noise suppressor +// NewSuppressor creates a new RNNoise suppressor. func NewSuppressor() *Suppressor { - s := &Suppressor{ - enabled: false, - threshold: 0.08, - hpAlpha: 0.995, - envelopeAttack: 0.18, - envelopeRelease: 0.02, - noiseAttack: 0.08, - noiseRelease: 0.002, - gainAttack: 0.35, - gainRelease: 0.02, - speechRatio: 4.0, - clickDecay: 0.93, - minNoiseFloor: 0.0008, - suppressionGain: 1.0, + state := C.rnnoise_create(nil) + if state == nil { + panic("rnnoise: failed to create denoise state") } - s.resetStateLocked() + + s := &Suppressor{ + state: state, + frameSize: int(C.rnnoise_get_frame_size()), + } + if s.frameSize <= 0 { + C.rnnoise_destroy(state) + panic("rnnoise: invalid frame size") + } + runtime.SetFinalizer(s, (*Suppressor).Close) return s } -// SetEnabled enables or disables noise suppression +// Close releases the RNNoise state. +func (s *Suppressor) Close() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.state == nil { + return + } + C.rnnoise_destroy(s.state) + s.state = nil +} + +// SetEnabled enables or disables noise suppression. func (s *Suppressor) SetEnabled(enabled bool) { s.mu.Lock() defer s.mu.Unlock() - if s.enabled == enabled { - return - } s.enabled = enabled - s.resetStateLocked() } -// IsEnabled returns whether noise suppression is enabled +// IsEnabled returns whether noise suppression is enabled. func (s *Suppressor) IsEnabled() bool { s.mu.Lock() defer s.mu.Unlock() return s.enabled } -// SetThreshold sets the noise threshold (0.0 to 1.0) -func (s *Suppressor) SetThreshold(threshold float32) { - s.mu.Lock() - defer s.mu.Unlock() - s.threshold = clampFloat32(threshold, 0.0, 1.0) -} - -// GetThreshold returns the current noise threshold -func (s *Suppressor) GetThreshold() float32 { - s.mu.Lock() - defer s.mu.Unlock() - return s.threshold -} - -// ProcessSamples applies noise suppression to audio samples +// ProcessSamples applies RNNoise suppression to complete frames in samples. func (s *Suppressor) ProcessSamples(samples []int16) { s.mu.Lock() defer s.mu.Unlock() - if !s.enabled || len(samples) == 0 { + if !s.enabled || s.state == nil || len(samples) < s.frameSize { return } - intensity := s.thresholdToIntensity() - minGain := 1.0 - (0.92 * intensity) - eps := float32(1e-6) - - for i, sample := range samples { - floatSample := float32(sample) / 32768.0 - filtered := s.highPassFilterLocked(floatSample) - absSample := float32(math.Abs(float64(filtered))) - - s.updateEnvelopeLocked(absSample) - s.updateNoiseFloorLocked() - - snr := s.envelope / (s.noiseFloor + eps) - voicePresence := clampFloat32((snr-1.0)/(s.speechRatio-1.0), 0.0, 1.0) - - targetGain := minGain + ((1.0 - minGain) * voicePresence) - targetGain = s.applyTransientSuppressionLocked(absSample, voicePresence, minGain, targetGain) - - s.applyGainSmoothingLocked(targetGain) - - processed := filtered * s.suppressionGain - processed = clampFloat32(processed, -1.0, 1.0) - samples[i] = int16(processed * 32767.0) - } -} - -func (s *Suppressor) highPassFilterLocked(input float32) float32 { - // Simple high-pass filter: y[n] = alpha * (y[n-1] + x[n] - x[n-1]) - output := s.hpAlpha * (s.prevOutput + input - s.prevInput) - s.prevInput = input - s.prevOutput = output - return output -} - -func (s *Suppressor) thresholdToIntensity() float32 { - // Keep lower legacy threshold values meaningful while allowing up to very aggressive suppression. - return 1.0 - float32(math.Exp(float64(-28.0*clampFloat32(s.threshold, 0.0, 1.0)))) -} - -func (s *Suppressor) updateEnvelopeLocked(absSample float32) { - if absSample > s.envelope { - s.envelope += s.envelopeAttack * (absSample - s.envelope) - } else { - s.envelope += s.envelopeRelease * (absSample - s.envelope) - } - if s.envelope < s.minNoiseFloor { - s.envelope = s.minNoiseFloor - } -} - -func (s *Suppressor) updateNoiseFloorLocked() { - coef := s.noiseRelease - if s.envelope < s.noiseFloor*2.2 { - coef = s.noiseAttack - } - s.noiseFloor += coef * (s.envelope - s.noiseFloor) - if s.noiseFloor < s.minNoiseFloor { - s.noiseFloor = s.minNoiseFloor - } -} - -func (s *Suppressor) applyTransientSuppressionLocked(absSample float32, voicePresence float32, minGain float32, targetGain float32) float32 { - s.clickEnergy = (s.clickEnergy * s.clickDecay) + (absSample * (1.0 - s.clickDecay)) - transient := absSample - s.clickEnergy - transientThreshold := 0.04 + (0.08 * (1.0 - voicePresence)) - if transient > transientThreshold && voicePresence < 0.65 { - clickGain := minGain * 0.55 - if clickGain < targetGain { - targetGain = clickGain + input := make([]C.float, s.frameSize) + output := make([]C.float, s.frameSize) + for offset := 0; offset+s.frameSize <= len(samples); offset += s.frameSize { + frame := samples[offset : offset+s.frameSize] + for i, sample := range frame { + input[i] = C.float(sample) + } + C.rnnoise_process_frame(s.state, &output[0], &input[0]) + for i, sample := range output { + frame[i] = floatToInt16(float32(sample)) } } - return clampFloat32(targetGain, 0.02, 1.0) } -func (s *Suppressor) applyGainSmoothingLocked(targetGain float32) { - if targetGain < s.suppressionGain { - s.suppressionGain += s.gainAttack * (targetGain - s.suppressionGain) - } else { - s.suppressionGain += s.gainRelease * (targetGain - s.suppressionGain) +func floatToInt16(sample float32) int16 { + if sample > math.MaxInt16 { + return math.MaxInt16 } - s.suppressionGain = clampFloat32(s.suppressionGain, 0.02, 1.0) -} - -func (s *Suppressor) resetStateLocked() { - s.prevInput = 0.0 - s.prevOutput = 0.0 - s.envelope = s.minNoiseFloor - s.noiseFloor = s.minNoiseFloor - s.suppressionGain = 1.0 - s.clickEnergy = 0.0 -} - -func clampFloat32(value float32, min float32, max float32) float32 { - if value < min { - return min + if sample < math.MinInt16 { + return math.MinInt16 } - if value > max { - return max - } - return value -} - -// ProcessSamplesAdvanced applies more sophisticated noise suppression -// Placeholder for future RNNoise integration. -func (s *Suppressor) ProcessSamplesAdvanced(samples []int16) { - s.ProcessSamples(samples) + return int16(sample) } diff --git a/noise/suppression_test.go b/noise/suppression_test.go index f787547..6c843bc 100644 --- a/noise/suppression_test.go +++ b/noise/suppression_test.go @@ -1,12 +1,11 @@ package noise -import ( - "math" - "testing" -) +import "testing" func TestSuppressorDisabledBypassesSamples(t *testing.T) { suppressor := NewSuppressor() + defer suppressor.Close() + samples := []int16{100, -200, 300, -400, 500} original := append([]int16(nil), samples...) @@ -19,85 +18,41 @@ func TestSuppressorDisabledBypassesSamples(t *testing.T) { } } -func TestSuppressorAttenuatesLowLevelNoise(t *testing.T) { +func TestSuppressorProcessesCompleteRNNoiseFrame(t *testing.T) { suppressor := NewSuppressor() + defer suppressor.Close() suppressor.SetEnabled(true) - suppressor.SetThreshold(0.08) - input := makeSineFrame(600, 700) - originalRMS := frameRMS(input) - processed := append([]int16(nil), input...) - suppressor.ProcessSamples(processed) - processedRMS := frameRMS(processed) - - if processedRMS >= originalRMS*0.8 { - t.Fatalf("expected low-level noise attenuation, got RMS %.2f from %.2f", processedRMS, originalRMS) + samples := make([]int16, suppressor.frameSize) + for i := range samples { + samples[i] = int16((i % 64) * 128) } + + suppressor.ProcessSamples(samples) } -func TestSuppressorPreservesSpeechLikeSignal(t *testing.T) { +func TestSuppressorLeavesIncompleteFrameUnchanged(t *testing.T) { suppressor := NewSuppressor() + defer suppressor.Close() suppressor.SetEnabled(true) - suppressor.SetThreshold(0.08) - voice := makeSineFrame(1000, 9000) - originalRMS := frameRMS(voice) - processed := append([]int16(nil), voice...) - suppressor.ProcessSamples(processed) - processedRMS := frameRMS(processed) + samples := make([]int16, suppressor.frameSize-1) + for i := range samples { + samples[i] = int16(i + 1) + } + original := append([]int16(nil), samples...) - if processedRMS <= originalRMS*0.6 { - t.Fatalf("expected speech-like signal to be mostly preserved, got RMS %.2f from %.2f", processedRMS, originalRMS) + suppressor.ProcessSamples(samples) + + for i := range samples { + if samples[i] != original[i] { + t.Fatalf("expected incomplete frame sample %d to remain unchanged, got %d want %d", i, samples[i], original[i]) + } } } -func TestHigherThresholdAppliesStrongerSuppression(t *testing.T) { - lowSuppressor := NewSuppressor() - lowSuppressor.SetEnabled(true) - lowSuppressor.SetThreshold(0.02) - - highSuppressor := NewSuppressor() - highSuppressor.SetEnabled(true) - highSuppressor.SetThreshold(0.20) - - noiseFrame := makeSineFrame(500, 700) - lowRMS := runFrameWarmup(lowSuppressor, noiseFrame, 8) - highRMS := runFrameWarmup(highSuppressor, noiseFrame, 8) - - if highRMS >= lowRMS*0.8 { - t.Fatalf("expected stronger suppression at higher threshold, got low %.2f high %.2f", lowRMS, highRMS) - } -} - -func runFrameWarmup(suppressor *Suppressor, frame []int16, repeats int) float64 { - var processed []int16 - for i := 0; i < repeats; i++ { - processed = append([]int16(nil), frame...) - suppressor.ProcessSamples(processed) - } - return frameRMS(processed) -} - -func makeSineFrame(frequency float64, amplitude float64) []int16 { - const sampleRate = 48000.0 - const frameSize = 480 - - frame := make([]int16, frameSize) - for i := 0; i < frameSize; i++ { - value := math.Sin((2.0 * math.Pi * frequency * float64(i)) / sampleRate) - frame[i] = int16(value * amplitude) - } - return frame -} - -func frameRMS(samples []int16) float64 { - if len(samples) == 0 { - return 0.0 - } - var sumSquares float64 - for _, sample := range samples { - normalized := float64(sample) / 32768.0 - sumSquares += normalized * normalized - } - return math.Sqrt(sumSquares / float64(len(samples))) +func TestSuppressorCloseIsIdempotent(t *testing.T) { + suppressor := NewSuppressor() + suppressor.Close() + suppressor.Close() }