From 2e0e0caccb5afb0595f14a54397bcb8784802847 Mon Sep 17 00:00:00 2001 From: Talon Date: Tue, 23 Jun 2026 14:11:18 +0200 Subject: [PATCH] feat(clients): wire RNNoise mic noise reduction into Windows, macOS, and iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the existing send-side vc_set_input_noise_reduction C ABI (MIC-only, mono, LOCAL — denoises captured mic PCM before input gain and VAD/PTT gate) as a persisted global toggle in each client's audio settings, applied live and re-applied on Join Voice. Mirrors the existing mic-gain wiring pattern. - Shared Swift (VoiceCatCore): add setInputNoiseReduction(_:) wrapper - Windows: P/Invoke + SetInputNoiseReduction wrapper, MicNoiseReduction in VoiceSettings, new checkbox in AudioSettingsForm (layout shifted +28px), apply on Join Voice; also fix stale 'planned - currently passthrough' label on the receive-side per-user NR checkbox (RNNoise now backs it) - macOS: inputNoiseReduction state + UserDefaults in MainWindowController, NR checkbox + nrChanged action in SettingsWindowController - iOS: inputNoiseReduction in VoiceState + setter + restore in SessionState, NR Toggle in SettingsView Voice section Aux/screen are out of scope by design (core's NR guards kind == MIC). Apple builds require a rebuilt VoiceCatCore.xcframework with VOICECAT_HAS_NS. --- .../Sources/VoiceCatCore/VoiceCatClient.swift | 9 ++++ .../apple/iOS/VoiceCatiOS/SessionState.swift | 12 +++++ .../iOS/VoiceCatiOS/Views/SettingsView.swift | 7 +++ .../Windows/MainWindowController.swift | 8 ++++ .../Windows/SettingsWindowController.swift | 21 ++++++++- .../VoiceCat.App/Forms/AudioSettingsForm.cs | 47 +++++++++++++++---- .../windows/VoiceCat.App/Forms/MainForm.cs | 1 + .../VoiceCat.App/Forms/PerUserTuningDialog.cs | 2 +- .../VoiceCat.App/Models/VoiceSettings.cs | 5 ++ .../windows/VoiceCat.Interop/NativeMethods.cs | 3 ++ .../VoiceCat.Interop/VoiceCatClient.cs | 7 +++ 11 files changed, 110 insertions(+), 12 deletions(-) diff --git a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift index 74e7a30..ddbaecb 100644 --- a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift +++ b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift @@ -417,6 +417,15 @@ public final class VoiceCatClient { VoiceCatResult(vc_set_input_gain(handle, gain < 0 ? 0 : gain)) } + /// Send-side microphone noise suppression (RNNoise). Denoises captured MIC PCM before the + /// input gain and VAD/PTT gate, so everyone hears the cleaned signal (one pass for all + /// listeners). MIC stream only, mono only; always LOCAL — no protocol traffic. Independent + /// of the per-listener receive-side NR in `setRemoteStream` (docs/voice.md §10). + @discardableResult + public func setInputNoiseReduction(_ enable: Bool) -> VoiceCatResult { + VoiceCatResult(vc_set_input_noise_reduction(handle, enable ? 1 : 0)) + } + // MARK: - AVAudioSession interruption hooks (iOS) /// Pause miniaudio device I/O. Call when AVAudioSession interruption begins. diff --git a/clients/apple/iOS/VoiceCatiOS/SessionState.swift b/clients/apple/iOS/VoiceCatiOS/SessionState.swift index 3ac8c92..798ff78 100644 --- a/clients/apple/iOS/VoiceCatiOS/SessionState.swift +++ b/clients/apple/iOS/VoiceCatiOS/SessionState.swift @@ -27,6 +27,7 @@ struct VoiceState { var inputMode: VoiceCatInputMode = .voiceActivation var vadThreshold: Float = 0.025 var inputGain: Float = 1.0 + var inputNoiseReduction: Bool = false var level: Float = 0.0 var currentDeviceId: String? var localStreamId: UInt32 = 0 @@ -353,12 +354,19 @@ final class SessionState { UserDefaults.standard.set(gain, forKey: DefaultsKey.inputGain) } + func setInputNoiseReduction(_ on: Bool) { + client.setInputNoiseReduction(on) + voiceState.inputNoiseReduction = on + UserDefaults.standard.set(on, forKey: DefaultsKey.inputNoiseReduction) + } + // MARK: - Persisted input settings private enum DefaultsKey { static let inputMode = "voice.inputMode" static let vadThreshold = "voice.vadThreshold" static let inputGain = "voice.inputGain" + static let inputNoiseReduction = "voice.inputNoiseReduction" } /// Restore the saved input mode / VAD threshold / mic gain and push them into the core so a @@ -375,9 +383,13 @@ final class SessionState { if d.object(forKey: DefaultsKey.inputGain) != nil { voiceState.inputGain = d.float(forKey: DefaultsKey.inputGain) } + if d.object(forKey: DefaultsKey.inputNoiseReduction) != nil { + voiceState.inputNoiseReduction = d.bool(forKey: DefaultsKey.inputNoiseReduction) + } client.setInputMode(voiceState.inputMode) client.setVadThreshold(voiceState.vadThreshold) client.setInputGain(voiceState.inputGain) + client.setInputNoiseReduction(voiceState.inputNoiseReduction) } private var pttEngaged = false diff --git a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift index d4001db..d680d8a 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift @@ -251,6 +251,13 @@ struct SettingsView: View { .accessibilityLabel("Microphone volume") .accessibilityValue("\(Int((session.voiceState.inputGain * 100).rounded())) percent") } + + Toggle("Noise Reduction (RNNoise)", isOn: Binding( + get: { session.voiceState.inputNoiseReduction }, + set: { session.setInputNoiseReduction($0) } + )) + .accessibilityLabel("Microphone noise reduction") + .accessibilityHint("Denoises your microphone signal for everyone listening.") } // MARK: - Notifications diff --git a/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift index 240d9f4..87a0eab 100644 --- a/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift +++ b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift @@ -61,6 +61,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { static let inputMode = "voice.inputMode" static let vadThreshold = "voice.vadThreshold" static let inputGain = "voice.inputGain" + static let inputNoiseReduction = "voice.inputNoiseReduction" static let pttKeyCode = "voice.pttKeyCode" static let auxEnabled = "voice.auxEnabled" static let auxDeviceUID = "voice.auxDeviceUID" @@ -76,6 +77,9 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { internal var inputGain: Float = 1.0 { didSet { UserDefaults.standard.set(inputGain, forKey: AudioDefaults.inputGain) } } + internal var inputNoiseReduction: Bool = false { + didSet { UserDefaults.standard.set(inputNoiseReduction, forKey: AudioDefaults.inputNoiseReduction) } + } internal var selectedInputDeviceId: String? // Aux outgoing stream: a second hardware input device the client captures itself and feeds to @@ -156,6 +160,9 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { if d.object(forKey: AudioDefaults.inputGain) != nil { inputGain = d.float(forKey: AudioDefaults.inputGain) } + if d.object(forKey: AudioDefaults.inputNoiseReduction) != nil { + inputNoiseReduction = d.bool(forKey: AudioDefaults.inputNoiseReduction) + } if d.object(forKey: AudioDefaults.pttKeyCode) != nil { pttKeyCode = UInt16(d.integer(forKey: AudioDefaults.pttKeyCode)) } @@ -779,6 +786,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { client.setVadThreshold(vadThresholdValue) } client.setInputGain(inputGain) + client.setInputNoiseReduction(inputNoiseReduction) setVoiceJoinedState(true) addActivity("Joined voice — microphone active") EventFeedback.shared.play(.voiceOn) diff --git a/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift b/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift index 12ca6c9..a962033 100644 --- a/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift +++ b/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift @@ -58,6 +58,11 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { }() private let inputGainValueLabel = NSTextField(labelWithString: "100%") + // Send-side mic noise reduction (RNNoise). MIC-only. Persisted via + // MainWindowController.inputNoiseReduction. + private let nrCheckbox = NSButton(checkboxWithTitle: "Noise reduction (RNNoise)", + target: nil, action: nil) + // Aux input stream: a second outgoing stream from another hardware input device (e.g. line-in // / aux), captured client-side. Device + volume only — aux is always-on. Persisted via // MainWindowController.auxEnabled / auxDeviceUID / auxGain. @@ -168,6 +173,11 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { inputGainSlider.setAccessibilityHelp("Boost a quiet microphone. 100% is unity.") inputGainValueLabel.setAccessibilityLabel("Microphone volume value") + nrCheckbox.target = self + nrCheckbox.action = #selector(nrChanged) + nrCheckbox.setAccessibilityLabel("Microphone noise reduction") + nrCheckbox.setAccessibilityHelp("RNNoise denoising of your microphone. Cleans your signal for everyone.") + let inputModeRow = NSStackView(views: [inputModeLabel, inputModeControl]) inputModeRow.orientation = .horizontal inputModeRow.spacing = 8 @@ -249,7 +259,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { volumeRow.orientation = .horizontal volumeRow.spacing = 8 - let stack = NSStackView(views: [inputModeRow, vadRow, inputGainRow, pttRow, deviceRow, + let stack = NSStackView(views: [inputModeRow, vadRow, inputGainRow, nrCheckbox, pttRow, deviceRow, levelRow, auxHeader, auxCheckbox, auxDeviceRow, auxGainRow, notificationsHeader, soundsCheckbox, volumeRow, speechCheckbox, selfTalkCheckbox, pttSoundCheckbox]) @@ -319,6 +329,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { inputGainSlider.doubleValue = Double(mc.inputGain * 100) updateInputGainLabel() + nrCheckbox.state = mc.inputNoiseReduction ? .on : .off auxCheckbox.state = mc.auxEnabled ? .on : .off auxGainSlider.doubleValue = Double(mc.auxGain * 100) @@ -371,6 +382,14 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } } + @objc private func nrChanged() { + let on = nrCheckbox.state == .on + mainController?.inputNoiseReduction = on + if let mc = mainController, mc.micStreamId != 0 { + client.setInputNoiseReduction(on) + } + } + private func updateInputGainLabel() { let pct = Int(inputGainSlider.doubleValue.rounded()) inputGainValueLabel.stringValue = "\(pct)%" diff --git a/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs b/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs index 3a85e01..691d098 100644 --- a/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs +++ b/clients/windows/VoiceCat.App/Forms/AudioSettingsForm.cs @@ -27,6 +27,7 @@ public sealed class AudioSettingsForm : Form private readonly VcInputMode _origMode; private readonly int _origVadSlider; private readonly int _origMicGain; + private readonly bool _origMicNoiseReduction; private readonly Keys _origPttKey; private readonly bool _origAuxEnabled; private readonly string? _origAuxDeviceId; @@ -42,6 +43,7 @@ public sealed class AudioSettingsForm : Form private readonly Label _lblSensitivity; private readonly TrackBar _trkVad; private readonly TrackBar _trkGain; + private readonly CheckBox _chkNoiseReduction; private readonly CheckBox _chkAux; private readonly Label _lblAuxDevice; private readonly ComboBox _cboAuxDevice; @@ -67,6 +69,7 @@ public sealed class AudioSettingsForm : Form _origMode = (VcInputMode)settings.InputMode; _origVadSlider = settings.VadThresholdSlider; _origMicGain = settings.MicGain; + _origMicNoiseReduction = settings.MicNoiseReduction; _origPttKey = _pttKey; _origAuxEnabled = settings.AuxEnabled; _origAuxDeviceId = settings.AuxDeviceId; @@ -78,7 +81,7 @@ public sealed class AudioSettingsForm : Form MinimizeBox = false; StartPosition = FormStartPosition.CenterParent; AutoScaleMode = AutoScaleMode.Font; - ClientSize = new Size(420, 555); + ClientSize = new Size(420, 583); // ── Device row ──────────────────────────────────────────────────────── var lblDevice = new Label @@ -199,6 +202,21 @@ public sealed class AudioSettingsForm : Form "Boost a quiet microphone. 100 is unity gain; range 0–300 percent."; _trkGain.Scroll += TrkGain_Scroll; + // ── Mic noise reduction ─────────────────────────────────────────────── + // Send-side RNNoise denoise of the mic stream. MIC-only (the core's NR runs before + // the input gain and VAD/PTT gate; aux/screen are excluded). One pass for all listeners. + _chkNoiseReduction = new CheckBox + { + Text = "Noise &reduction (RNNoise)", + Location = new Point(12, 340), + AutoSize = true, + Checked = settings.MicNoiseReduction, + AccessibleName = "Microphone noise reduction", + AccessibleDescription = + "RNNoise denoising of your microphone. Cleans your signal for everyone listening.", + }; + _chkNoiseReduction.CheckedChanged += ChkNoiseReduction_CheckedChanged; + // ── Aux input stream ────────────────────────────────────────────────── // A second outgoing stream from another hardware input device (e.g. line-in / aux), // captured client-side and fed to the core. Device + volume only — aux is always-on @@ -206,7 +224,7 @@ public sealed class AudioSettingsForm : Form _chkAux = new CheckBox { Text = "&Aux stream (second input device)", - Location = new Point(12, 348), + Location = new Point(12, 376), AutoSize = true, Checked = settings.AuxEnabled, AccessibleName = "Enable aux input stream", @@ -218,12 +236,12 @@ public sealed class AudioSettingsForm : Form _lblAuxDevice = new Label { Text = "Aux d&evice:", - Location = new Point(12, 378), + Location = new Point(12, 406), AutoSize = true, }; _cboAuxDevice = new ComboBox { - Location = new Point(12, 398), + Location = new Point(12, 426), Width = 300, DropDownStyle = ComboBoxStyle.DropDownList, DisplayMember = "Name", @@ -236,7 +254,7 @@ public sealed class AudioSettingsForm : Form _btnAuxRefresh = new Button { Text = "Re&fresh", - Location = new Point(320, 396), + Location = new Point(320, 424), Size = new Size(80, 26), }; _btnAuxRefresh.Click += (_, _) => LoadAuxDevices(); @@ -244,12 +262,12 @@ public sealed class AudioSettingsForm : Form _lblAuxGain = new Label { Text = "Aux vo&lume:", - Location = new Point(12, 434), + Location = new Point(12, 462), AutoSize = true, }; _trkAuxGain = new TrackBar { - Location = new Point(12, 454), + Location = new Point(12, 482), Size = new Size(200, 45), Minimum = 0, Maximum = 300, @@ -268,14 +286,14 @@ public sealed class AudioSettingsForm : Form { Text = "&OK", DialogResult = DialogResult.OK, - Location = new Point(228, 516), + Location = new Point(228, 544), Size = new Size(80, 27), }; var btnCancel = new Button { Text = "&Cancel", DialogResult = DialogResult.Cancel, - Location = new Point(316, 516), + Location = new Point(316, 544), Size = new Size(80, 27), }; @@ -292,7 +310,7 @@ public sealed class AudioSettingsForm : Form lblDevice, _cboDevice, _btnRefresh, lblMode, _radioVad, _lblSensitivity, _trkVad, _radioPtt, _lblPttKey, _btnChangePtt, _radioAlwaysOn, - lblGain, _trkGain, + lblGain, _trkGain, _chkNoiseReduction, _chkAux, _lblAuxDevice, _cboAuxDevice, _btnAuxRefresh, _lblAuxGain, _trkAuxGain, btnOk, btnCancel, ]); @@ -430,6 +448,13 @@ public sealed class AudioSettingsForm : Form _client.SetInputGain(_trkGain.Value / 100f); } + private void ChkNoiseReduction_CheckedChanged(object? sender, EventArgs e) + { + _settings.MicNoiseReduction = _chkNoiseReduction.Checked; + if (_micStreamId != 0) + _client.SetInputNoiseReduction(_chkNoiseReduction.Checked); + } + private void BtnChangePtt_Click(object? sender, EventArgs e) { using var dlg = new PttKeyCaptureDialog(_pttKey); @@ -450,6 +475,7 @@ public sealed class AudioSettingsForm : Form _settings.InputMode = (int)_origMode; _settings.VadThresholdSlider = _origVadSlider; _settings.MicGain = _origMicGain; + _settings.MicNoiseReduction = _origMicNoiseReduction; _settings.PttKey = (int)_origPttKey; if (_micStreamId != 0) @@ -459,6 +485,7 @@ public sealed class AudioSettingsForm : Form if (_origMode == VcInputMode.VoiceActivation) _client.SetVadThreshold(0.1f * (1f - (_origVadSlider - 1f) / 99f)); _client.SetInputGain(_origMicGain / 100f); + _client.SetInputNoiseReduction(_origMicNoiseReduction); } // Aux: restore originals to settings and re-apply live (order: device + gain first so a diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.cs b/clients/windows/VoiceCat.App/Forms/MainForm.cs index 1e81e74..4c3de16 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.cs @@ -650,6 +650,7 @@ public partial class MainForm : Form if (mode == VcInputMode.VoiceActivation) _client.SetVadThreshold(VadThresholdFromSettings()); _client.SetInputGain(_voiceSettings.MicGain / 100f); + _client.SetInputNoiseReduction(_voiceSettings.MicNoiseReduction); SetVoiceJoinedState(true); AddActivity("Joined voice — microphone active"); _feedback.PlaySound(SoundEvent.VoiceOn); diff --git a/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs b/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs index d1b78a1..b7de3c3 100644 --- a/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs +++ b/clients/windows/VoiceCat.App/Forms/PerUserTuningDialog.cs @@ -150,7 +150,7 @@ public sealed class PerUserTuningDialog : Form var chkNr = new CheckBox { - Text = "&Noise reduction (planned — currently passthrough)", + Text = "&Noise reduction", AutoSize = true, Location = new Point(96, y + 48), Checked = nr0, diff --git a/clients/windows/VoiceCat.App/Models/VoiceSettings.cs b/clients/windows/VoiceCat.App/Models/VoiceSettings.cs index 9b09795..7dbdc94 100644 --- a/clients/windows/VoiceCat.App/Models/VoiceSettings.cs +++ b/clients/windows/VoiceCat.App/Models/VoiceSettings.cs @@ -21,6 +21,11 @@ public sealed class VoiceSettings /// Microphone input gain slider position, 0–300 percent (100 = unity). public int MicGain { get; set; } = 100; + /// Send-side mic noise reduction (RNNoise) toggle. MIC stream only, mono only; + /// denoises captured mic PCM before input gain and VAD/PTT gate so everyone hears the + /// cleaned signal. Independent of the per-listener receive-side NR. + public bool MicNoiseReduction { get; set; } = false; + /// Push-to-talk key, stored as the integer value of System.Windows.Forms.Keys. public int PttKey { get; set; } = (int)Keys.F8; diff --git a/clients/windows/VoiceCat.Interop/NativeMethods.cs b/clients/windows/VoiceCat.Interop/NativeMethods.cs index cc54aaf..35f776b 100644 --- a/clients/windows/VoiceCat.Interop/NativeMethods.cs +++ b/clients/windows/VoiceCat.Interop/NativeMethods.cs @@ -83,6 +83,9 @@ internal static partial class NativeMethods [LibraryImport(LibName)] internal static partial VcResult vc_set_input_gain(nint c, float gain); + [LibraryImport(LibName)] + internal static partial VcResult vc_set_input_noise_reduction(nint c, int enable); + [LibraryImport(LibName)] internal static partial VcResult vc_set_remote_stream(nint c, uint userId, uint streamId, float gain, int muted, int noiseReduction); diff --git a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs index bee65d7..501a14e 100644 --- a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs +++ b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs @@ -232,6 +232,13 @@ public sealed class VoiceCatClient : IDisposable public VcResult SetInputGain(float gain) => NativeMethods.vc_set_input_gain(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain); + /// Send-side microphone noise suppression (RNNoise). Denoises captured MIC PCM before + /// the input gain and VAD/PTT gate, so everyone hears the cleaned signal. MIC stream only, + /// mono only; always LOCAL — no protocol traffic. Independent of per-listener receive-side + /// NR in . + public VcResult SetInputNoiseReduction(bool enable) => + NativeMethods.vc_set_input_noise_reduction(_handle.DangerousGetHandle(), enable ? 1 : 0); + public VcResult SetRemoteStream(uint userId, uint streamId, float gain, bool muted, bool noiseReduction) => NativeMethods.vc_set_remote_stream(_handle.DangerousGetHandle(), userId, streamId, gain, muted ? 1 : 0, noiseReduction ? 1 : 0);