feat(clients): wire RNNoise mic noise reduction into Windows, macOS, and iOS

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.
This commit is contained in:
2026-06-23 14:11:18 +02:00
parent bad9c7533a
commit 2e0e0caccb
11 changed files with 110 additions and 12 deletions

View File

@@ -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.

View File

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

View File

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

View File

@@ -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)

View File

@@ -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)%"