From 95f1fb70b0b6dabb7ed6f1fd163498916038bdd1 Mon Sep 17 00:00:00 2001 From: Talon Date: Tue, 23 Jun 2026 03:35:26 +0200 Subject: [PATCH] feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input mode (VAD/PTT/Always-On), VAD threshold, and the new mic gain were applied to the core + UI but never saved, so every relaunch reset to VAD defaults. Each client now persists them and re-applies on connect: - iOS: UserDefaults (SessionState.loadAndApplyVoiceSettings + setter writes) - macOS: UserDefaults via MainWindowController didSet + loadPersistedAudioSettings (settings window also restores the VAD slider from the stored threshold) - Windows: new Models/VoiceSettings.cs (JSON at %AppData%\VoiceCat\voice.json, mirrors FeedbackSettings) loaded/applied in MainForm Add global send-side mic gain API vc_set_input_gain (applied to MIC PCM in on_capture_frame before the VAD gate, clamped to int16) + Swift/C# bindings, and a 0-300% (default 100%) mic-volume slider on all three clients. Fix iOS chat: ChatView called sendText(scope:.channel) with no targetId (0), so channel messages went nowhere; now passes session.currentChannelId. Fix iOS per-user tuning for VoiceOver: the tuning sheet was long-press .contextMenu only (invisible to VoiceOver); UserRow now also exposes the same buttons via .accessibilityActions (no visual change). Verified: core builds clean; ctest 24/27 (3 pre-existing teardown crashes, reproduced with changes stashed); VoiceCatMac + VoiceCatiOS (arm64 sim) build SUCCEEDED; VoiceCat.Interop dotnet build succeeded. Windows App not built (WinForms can't build on macOS) — follows existing patterns. --- PROGRESS.md | 30 +++++++++++ .../Sources/VoiceCatCore/VoiceCatClient.swift | 8 +++ .../apple/iOS/VoiceCatiOS/SessionState.swift | 37 +++++++++++++ .../iOS/VoiceCatiOS/Views/ChatView.swift | 2 +- .../iOS/VoiceCatiOS/Views/SettingsView.swift | 14 +++++ .../apple/iOS/VoiceCatiOS/Views/UserRow.swift | 4 ++ .../Windows/MainWindowController.swift | 46 ++++++++++++++-- .../Windows/SettingsWindowController.swift | 52 +++++++++++++++++- .../VoiceCat.App/Forms/MainForm.Designer.cs | 23 ++++++++ .../windows/VoiceCat.App/Forms/MainForm.cs | 48 +++++++++++++++++ .../VoiceCat.App/Models/VoiceSettings.cs | 53 +++++++++++++++++++ .../windows/VoiceCat.Interop/NativeMethods.cs | 3 ++ .../VoiceCat.Interop/VoiceCatClient.cs | 5 ++ core/include/voicecat.h | 6 +++ core/src/core/client.cpp | 22 ++++++++ core/src/core/client.h | 2 + core/src/voicecat.cpp | 5 ++ 17 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 clients/windows/VoiceCat.App/Models/VoiceSettings.cs diff --git a/PROGRESS.md b/PROGRESS.md index b2faad1..01338c8 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,36 @@ up instantly. Newest status at the top. ## ▶ Where we left off / next action +- **Done (2026-06-23):** **Input-settings persistence, mic input gain, + two iOS bugs (all 3 + clients).** Four fixes: + 1. **Input settings now persist.** Transmission mode (VAD/PTT/Always-On), VAD threshold, and the + new mic gain were applied to the core + UI but never saved, so every relaunch reset to VAD + defaults. Each client now persists them and re-applies on connect: iOS via `UserDefaults` + (`SessionState.loadAndApplyVoiceSettings` + setter writes, keys `voice.*`); macOS via + `UserDefaults` (`MainWindowController` `didSet` + `loadPersistedAudioSettings`, also restores + the VAD slider from the stored threshold); Windows via new + `VoiceCat.App/Models/VoiceSettings.cs` (JSON at `%AppData%\VoiceCat\voice.json`, mirrors + `FeedbackSettings`) loaded/applied in `MainForm`. + 2. **Microphone input gain.** New global send-side API `vc_set_input_gain` (voicecat.h → + `client.cpp::on_capture_frame`, applied to MIC PCM before the VAD gate, clamped to int16) plus + Swift (`setInputGain`) and C# (`SetInputGain`) bindings. Mic-volume slider (0–300 %, default + 100 %) added to all three clients' input settings, persisted with the rest. + 3. **iOS chat send fixed.** `ChatView` called `sendText(scope:.channel)` with no `targetId` (→ 0), + so channel messages went nowhere; now passes `session.currentChannelId`. + 4. **iOS per-user tuning reachable via VoiceOver.** The tuning sheet was long-press + `.contextMenu` only (invisible to VoiceOver); `UserRow` now also exposes the same buttons as + `.accessibilityActions` (no visual change), so the actions rotor reaches tuning + admin actions. + - **Verified:** core `cmake --build --preset dev` clean; `ctest --preset dev` = 24/27 (the 3 + failures — `external_pcm`, `frame_ms_reframe`, `channel_samplerate` — are a pre-existing + teardown crash on this machine, reproduced identically with the changes stashed). xcframework + rebuilt (`--all`); **VoiceCatMac** and **VoiceCatiOS** (arm64 sim) → BUILD SUCCEEDED; + `VoiceCat.Interop` (`dotnet build`) succeeded. **Windows App not built** (WinForms + net10.0-windows can't build on macOS) — changes follow existing patterns; needs a Windows + build + manual check. + - **Next (manual):** on each client, set PTT + non-default VAD/mic-gain, relaunch → settings + restored; boost a quiet mic and confirm others hear it louder; iOS send a channel message; + iOS VoiceOver → focus a user → actions rotor opens tuning. + - **Done (2026-06-22):** **Fixed growing voice latency (jitter-buffer depth ratchet).** Symptom: end-to-end latency grew to multiple seconds and "drifted backward," reset only by leaving/ rejoining voice (DTX/FEC/DRED on, 10% loss). Root cause was **not** the codec settings (10% loss diff --git a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift index a1d9c1d..74e7a30 100644 --- a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift +++ b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift @@ -409,6 +409,14 @@ public final class VoiceCatClient { VoiceCatResult(vc_set_output_volume(handle, gain < 0 ? 0 : gain)) } + /// Send-side microphone input gain. Applied to captured MIC PCM before the VAD/PTT gate and + /// Opus encode (so boosting a quiet mic also helps it cross the VAD threshold). gain 0.0 = + /// silent, 1.0 = unity (default), >1.0 amplifies (clamped to int16). Always LOCAL. + @discardableResult + public func setInputGain(_ gain: Float) -> VoiceCatResult { + VoiceCatResult(vc_set_input_gain(handle, gain < 0 ? 0 : gain)) + } + // 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 0c7cb77..3ac8c92 100644 --- a/clients/apple/iOS/VoiceCatiOS/SessionState.swift +++ b/clients/apple/iOS/VoiceCatiOS/SessionState.swift @@ -26,6 +26,7 @@ struct VoiceState { var serverDeafened = false var inputMode: VoiceCatInputMode = .voiceActivation var vadThreshold: Float = 0.025 + var inputGain: Float = 1.0 var level: Float = 0.0 var currentDeviceId: String? var localStreamId: UInt32 = 0 @@ -59,6 +60,7 @@ final class SessionState { self.client = client self.selfUserId = selfUserId self.permissions = permissions + loadAndApplyVoiceSettings() refreshChannels() refreshUsers() syncSelfChannel() @@ -336,11 +338,46 @@ final class SessionState { func setInputMode(_ mode: VoiceCatInputMode) { client.setInputMode(mode) voiceState.inputMode = mode + UserDefaults.standard.set(Int(mode.rawValue), forKey: DefaultsKey.inputMode) } func setVadThreshold(_ threshold: Float) { client.setVadThreshold(threshold) voiceState.vadThreshold = threshold + UserDefaults.standard.set(threshold, forKey: DefaultsKey.vadThreshold) + } + + func setInputGain(_ gain: Float) { + client.setInputGain(gain) + voiceState.inputGain = gain + UserDefaults.standard.set(gain, forKey: DefaultsKey.inputGain) + } + + // MARK: - Persisted input settings + + private enum DefaultsKey { + static let inputMode = "voice.inputMode" + static let vadThreshold = "voice.vadThreshold" + static let inputGain = "voice.inputGain" + } + + /// Restore the saved input mode / VAD threshold / mic gain and push them into the core so a + /// relaunch keeps the user's transmission settings instead of resetting to VAD defaults. + private func loadAndApplyVoiceSettings() { + let d = UserDefaults.standard + if d.object(forKey: DefaultsKey.inputMode) != nil { + let raw = UInt32(d.integer(forKey: DefaultsKey.inputMode)) + voiceState.inputMode = VoiceCatInputMode(rawValue: raw) ?? .voiceActivation + } + if d.object(forKey: DefaultsKey.vadThreshold) != nil { + voiceState.vadThreshold = d.float(forKey: DefaultsKey.vadThreshold) + } + if d.object(forKey: DefaultsKey.inputGain) != nil { + voiceState.inputGain = d.float(forKey: DefaultsKey.inputGain) + } + client.setInputMode(voiceState.inputMode) + client.setVadThreshold(voiceState.vadThreshold) + client.setInputGain(voiceState.inputGain) } private var pttEngaged = false diff --git a/clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift b/clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift index 590f0d6..e72de55 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/ChatView.swift @@ -91,7 +91,7 @@ struct ChatView: View { private func sendMessage() { let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } - session.sendText(text, scope: .channel) + session.sendText(text, scope: .channel, targetId: session.currentChannelId) composeText = "" } } diff --git a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift index 2b42936..d4001db 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift @@ -237,6 +237,20 @@ struct SettingsView: View { .accessibilityLabel("Voice activation threshold") } } + + VStack(alignment: .leading, spacing: 4) { + Text("Mic Volume: \(Int((session.voiceState.inputGain * 100).rounded()))%") + .font(.caption) + Slider( + value: Binding( + get: { Double(session.voiceState.inputGain) }, + set: { session.setInputGain(Float($0)) } + ), + in: 0...3, step: 0.05 + ) + .accessibilityLabel("Microphone volume") + .accessibilityValue("\(Int((session.voiceState.inputGain * 100).rounded())) percent") + } } // MARK: - Notifications diff --git a/clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift b/clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift index 907e7fe..343a41a 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/UserRow.swift @@ -18,6 +18,10 @@ struct UserRow: View { var body: some View { UserRowView(user: user, isSelf: user.id == session.selfUserId) .contextMenu { contextMenu } + // The context menu is long-press only, which VoiceOver doesn't surface — mirror the + // same buttons as accessibility actions so VoiceOver users can reach per-user tuning + // (and the admin actions) via the actions rotor on the focused row. + .accessibilityActions { contextMenu } .sheet(item: $activeSheet) { sheet in switch sheet { case .tuning: PerUserTuningView(user: user, session: session) diff --git a/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift index 03717c0..3e53b6e 100644 --- a/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift +++ b/clients/apple/macOS/VoiceCatMac/Windows/MainWindowController.swift @@ -33,7 +33,9 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { private var screenCapture: ScreenAudioCapture? // Last app/exclusion choice from the share picker; reused as the default next time. private var screenAudioSelection: ScreenAudioSelection = .default - internal var pttKeyCode: UInt16 = 0x60 // F8 + internal var pttKeyCode: UInt16 = 0x60 { // F8 + didSet { UserDefaults.standard.set(Int(pttKeyCode), forKey: AudioDefaults.pttKeyCode) } + } private var pttMonitor: Any? private var pttEngaged = false // guards the PTT cue against key-repeat private var serverMuted = false @@ -49,9 +51,26 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { private var settingsWindowController: SettingsWindowController? // MARK: - Audio settings state (source of truth — read/written by SettingsWindowController) + // The input mode / VAD threshold / mic gain / PTT key persist via UserDefaults (didSet below) + // so they survive relaunch; loadPersistedAudioSettings() restores them at startup and they are + // pushed into the core when the mic stream starts (micToggleClicked). - internal var selectedInputMode: VoiceCatInputMode = .voiceActivation - internal var vadThresholdValue: Float = 0.05 + enum AudioDefaults { + static let inputMode = "voice.inputMode" + static let vadThreshold = "voice.vadThreshold" + static let inputGain = "voice.inputGain" + static let pttKeyCode = "voice.pttKeyCode" + } + + internal var selectedInputMode: VoiceCatInputMode = .voiceActivation { + didSet { UserDefaults.standard.set(Int(selectedInputMode.rawValue), forKey: AudioDefaults.inputMode) } + } + internal var vadThresholdValue: Float = 0.05 { + didSet { UserDefaults.standard.set(vadThresholdValue, forKey: AudioDefaults.vadThreshold) } + } + internal var inputGain: Float = 1.0 { + didSet { UserDefaults.standard.set(inputGain, forKey: AudioDefaults.inputGain) } + } internal var selectedInputDeviceId: String? // MARK: - UI components @@ -97,12 +116,32 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { super.init(window: window) window.delegate = self + loadPersistedAudioSettings() buildUI() buildToolbar() wireEvents() bootstrap() } + /// Restore the saved input mode / VAD threshold / mic gain / PTT key from UserDefaults so a + /// relaunch keeps the user's transmission settings instead of resetting to VAD defaults. + private func loadPersistedAudioSettings() { + let d = UserDefaults.standard + if d.object(forKey: AudioDefaults.inputMode) != nil { + let raw = UInt32(d.integer(forKey: AudioDefaults.inputMode)) + selectedInputMode = VoiceCatInputMode(rawValue: raw) ?? .voiceActivation + } + if d.object(forKey: AudioDefaults.vadThreshold) != nil { + vadThresholdValue = d.float(forKey: AudioDefaults.vadThreshold) + } + if d.object(forKey: AudioDefaults.inputGain) != nil { + inputGain = d.float(forKey: AudioDefaults.inputGain) + } + if d.object(forKey: AudioDefaults.pttKeyCode) != nil { + pttKeyCode = UInt16(d.integer(forKey: AudioDefaults.pttKeyCode)) + } + } + required init?(coder: NSCoder) { fatalError() } deinit { @@ -712,6 +751,7 @@ final class MainWindowController: NSWindowController, NSWindowDelegate { if selectedInputMode == .voiceActivation { client.setVadThreshold(vadThresholdValue) } + client.setInputGain(inputGain) 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 0432888..91343c2 100644 --- a/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift +++ b/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift @@ -50,6 +50,14 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { // Cached VAD slider position so we can restore it when the window reopens. private var vadSliderValue: Double = 50 + // Mic input gain: 0–300 % (100 = unity). Persisted via MainWindowController.inputGain. + private let inputGainSlider: NSSlider = { + let s = NSSlider(value: 100, minValue: 0, maxValue: 300, target: nil, action: nil) + s.numberOfTickMarks = 0 + return s + }() + private let inputGainValueLabel = NSTextField(labelWithString: "100%") + // Notification feedback controls. Read/write UserDefaults with the same keys VoiceCatCore's // FeedbackSettings reads, so EventFeedback honours these immediately. private let soundsCheckbox = NSButton(checkboxWithTitle: "Event sounds", target: nil, action: nil) @@ -135,6 +143,14 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { levelMeter.setAccessibilityLabel("Microphone input level") levelMeter.setAccessibilityHelp("Shows current microphone volume level") + let inputGainLabel = NSTextField(labelWithString: "Mic volume:") + inputGainLabel.setAccessibilityLabel("Microphone volume") + inputGainSlider.target = self + inputGainSlider.action = #selector(inputGainChanged) + inputGainSlider.setAccessibilityLabel("Microphone volume") + inputGainSlider.setAccessibilityHelp("Boost a quiet microphone. 100% is unity.") + inputGainValueLabel.setAccessibilityLabel("Microphone volume value") + let inputModeRow = NSStackView(views: [inputModeLabel, inputModeControl]) inputModeRow.orientation = .horizontal inputModeRow.spacing = 8 @@ -155,6 +171,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { levelRow.orientation = .horizontal levelRow.spacing = 8 + let inputGainRow = NSStackView(views: [inputGainLabel, inputGainSlider, inputGainValueLabel]) + inputGainRow.orientation = .horizontal + inputGainRow.spacing = 8 + // Notifications let notificationsHeader = NSTextField(labelWithString: "Notifications") notificationsHeader.font = .boldSystemFont(ofSize: NSFont.systemFontSize) @@ -177,8 +197,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { volumeRow.orientation = .horizontal volumeRow.spacing = 8 - let stack = NSStackView(views: [inputModeRow, vadRow, pttRow, deviceRow, levelRow, - notificationsHeader, soundsCheckbox, volumeRow, + let stack = NSStackView(views: [inputModeRow, vadRow, inputGainRow, pttRow, deviceRow, + levelRow, notificationsHeader, soundsCheckbox, volumeRow, speechCheckbox, selfTalkCheckbox, pttSoundCheckbox]) stack.orientation = .vertical stack.spacing = 12 @@ -194,6 +214,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), vadSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200), + inputGainSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), levelMeter.widthAnchor.constraint(equalToConstant: 200), devicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), soundsVolumeSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 200), @@ -235,9 +256,15 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { case .alwaysOn: inputModeControl.selectedSegment = 2 } + // Restore the slider from the persisted threshold (invert vadThresholdFromSlider) so a + // relaunch shows the saved sensitivity, not the default mid-point. + vadSliderValue = vadSliderFromThreshold(mc.vadThresholdValue) vadSlider.doubleValue = vadSliderValue pttKeyLabel.stringValue = "(\(keyCodeName(mc.pttKeyCode)))" + inputGainSlider.doubleValue = Double(mc.inputGain * 100) + updateInputGainLabel() + updateConditionalControls() } @@ -275,6 +302,21 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { } } + @objc private func inputGainChanged() { + let gain = Float(inputGainSlider.doubleValue) / 100.0 + mainController?.inputGain = gain + updateInputGainLabel() + if let mc = mainController, mc.micStreamId != 0 { + client.setInputGain(gain) + } + } + + private func updateInputGainLabel() { + let pct = Int(inputGainSlider.doubleValue.rounded()) + inputGainValueLabel.stringValue = "\(pct)%" + inputGainSlider.setAccessibilityValue("\(pct) percent") + } + @objc private func changePttClicked() { guard let mc = mainController else { return } let sheet = PttKeyCaptureSheet(currentKeyCode: mc.pttKeyCode) @@ -346,6 +388,12 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { 0.1 * (1.0 - Float(vadSlider.doubleValue - 1.0) / 99.0) } + /// Inverse of vadThresholdFromSlider: map a stored threshold back to a 1…100 slider position. + private func vadSliderFromThreshold(_ threshold: Float) -> Double { + let clamped = min(max(threshold, 0.0), 0.1) + return Double(1.0 + (1.0 - clamped / 0.1) * 99.0) + } + private func presentSheet(_ vc: NSViewController) { if let cvc = window?.contentViewController { cvc.presentAsSheet(vc) diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs b/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs index 69f0c4c..1edb4dd 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.Designer.cs @@ -51,6 +51,8 @@ partial class MainForm private ProgressBar pbLevel = null!; private Label lblVadThreshold = null!; private TrackBar trkVadThreshold = null!; + private Label lblMicGain = null!; + private TrackBar trkMicGain = null!; protected override void Dispose(bool disposing) { @@ -94,6 +96,8 @@ partial class MainForm pbLevel = new ProgressBar(); lblVadThreshold = new Label(); trkVadThreshold = new TrackBar(); + lblMicGain = new Label(); + trkMicGain = new TrackBar(); menuStrip = new MenuStrip(); toolStrip = new ToolStrip(); tsbJoinVoice = new ToolStripButton(); @@ -336,6 +340,23 @@ partial class MainForm trkVadThreshold.TabIndex = 9; trkVadThreshold.Visible = true; + lblMicGain.Text = "Mic volume:"; + lblMicGain.AutoSize = true; + lblMicGain.Margin = new Padding(12, 5, 4, 0); + + trkMicGain.AccessibleName = "Microphone volume"; + trkMicGain.AccessibleDescription = + "Boost a quiet microphone. 100 is unity; range 0–300 percent."; + trkMicGain.Minimum = 0; + trkMicGain.Maximum = 300; + trkMicGain.Value = 100; + trkMicGain.TickFrequency = 25; + trkMicGain.SmallChange = 5; + trkMicGain.LargeChange = 25; + trkMicGain.Width = 120; + trkMicGain.Margin = new Padding(0, 2, 0, 0); + trkMicGain.TabIndex = 10; + flpVoiceBottom.Dock = DockStyle.Fill; flpVoiceBottom.Padding = new Padding(4, 0, 4, 2); flpVoiceBottom.Controls.Add(lblInputDevice); @@ -345,6 +366,8 @@ partial class MainForm flpVoiceBottom.Controls.Add(pbLevel); flpVoiceBottom.Controls.Add(lblVadThreshold); flpVoiceBottom.Controls.Add(trkVadThreshold); + flpVoiceBottom.Controls.Add(lblMicGain); + flpVoiceBottom.Controls.Add(trkMicGain); pnlVoice.Dock = DockStyle.Bottom; pnlVoice.Height = 68; diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.cs b/clients/windows/VoiceCat.App/Forms/MainForm.cs index f401a5e..00d2883 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.cs @@ -1,4 +1,5 @@ using VoiceCat.App.Audio; +using VoiceCat.App.Models; using VoiceCat.App.Notifications; using VoiceCat.Interop; @@ -14,6 +15,7 @@ public partial class MainForm : Form private readonly string _nickname; private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 }; private readonly EventFeedback _feedback = new(FeedbackSettings.Load()); + private readonly VoiceSettings _voiceSettings = VoiceSettings.Load(); // Channel / user state private uint _currentChannelId; @@ -85,6 +87,7 @@ public partial class MainForm : Form radioPtt.CheckedChanged += RadioPtt_CheckedChanged; radioAlwaysOn.CheckedChanged += RadioAlwaysOn_CheckedChanged; trkVadThreshold.Scroll += TrkVadThreshold_Scroll; + trkMicGain.Scroll += TrkMicGain_Scroll; btnChangePtt.Click += BtnChangePtt_Click; btnRefreshDevices.Click += (_, _) => LoadInputDevices(); cboInputDevice.SelectedIndexChanged += CboInputDevice_SelectedIndexChanged; @@ -98,9 +101,33 @@ public partial class MainForm : Form if (_micStreamId != 0) _client.SetPushToTalk(false); }; + ApplyPersistedVoiceSettings(); BootstrapFromServer(); } + /// + /// Restore the saved transmission mode / VAD sensitivity / mic gain / PTT key into the UI so a + /// relaunch keeps the user's input settings instead of resetting to the designer defaults. The + /// values are pushed into the core when the mic stream starts (). + /// Setting the radio fires CheckedChanged, which only adjusts visibility while the mic is idle. + /// + private void ApplyPersistedVoiceSettings() + { + _pttKey = (Keys)_voiceSettings.PttKey; + + trkVadThreshold.Value = Math.Clamp(_voiceSettings.VadThresholdSlider, + trkVadThreshold.Minimum, trkVadThreshold.Maximum); + trkMicGain.Value = Math.Clamp(_voiceSettings.MicGain, + trkMicGain.Minimum, trkMicGain.Maximum); + + switch ((VcInputMode)_voiceSettings.InputMode) + { + case VcInputMode.PushToTalk: radioPtt.Checked = true; break; + case VcInputMode.AlwaysOn: radioAlwaysOn.Checked = true; break; + default: radioVad.Checked = true; break; + } + } + // ── Startup ────────────────────────────────────────────────────────────── private void BootstrapFromServer() @@ -661,6 +688,7 @@ public partial class MainForm : Form _client.SetInputDevice(streamId, dev.Id); _client.SetInputMode(CurrentInputMode()); if (radioVad.Checked) _client.SetVadThreshold(VadThresholdFromSlider()); + _client.SetInputGain(trkMicGain.Value / 100f); SetVoiceJoinedState(true); AddActivity("Joined voice — microphone active"); _feedback.PlaySound(SoundEvent.VoiceOn); @@ -779,6 +807,7 @@ public partial class MainForm : Form btnChangePtt.Visible = false; lblVadThreshold.Visible = true; trkVadThreshold.Visible = true; + SaveInputMode(VcInputMode.VoiceActivation); if (_micStreamId != 0) { _client.SetInputMode(VcInputMode.VoiceActivation); @@ -794,6 +823,7 @@ public partial class MainForm : Form btnChangePtt.Visible = true; lblVadThreshold.Visible = false; trkVadThreshold.Visible = false; + SaveInputMode(VcInputMode.PushToTalk); if (_micStreamId != 0) { _client.SetInputMode(VcInputMode.PushToTalk); @@ -808,15 +838,31 @@ public partial class MainForm : Form btnChangePtt.Visible = false; lblVadThreshold.Visible = false; trkVadThreshold.Visible = false; + SaveInputMode(VcInputMode.AlwaysOn); if (_micStreamId != 0) _client.SetInputMode(VcInputMode.AlwaysOn); } private void TrkVadThreshold_Scroll(object? sender, EventArgs e) { + _voiceSettings.VadThresholdSlider = trkVadThreshold.Value; + _voiceSettings.Save(); if (_micStreamId != 0 && radioVad.Checked) _client.SetVadThreshold(VadThresholdFromSlider()); } + private void TrkMicGain_Scroll(object? sender, EventArgs e) + { + _voiceSettings.MicGain = trkMicGain.Value; + _voiceSettings.Save(); + if (_micStreamId != 0) _client.SetInputGain(trkMicGain.Value / 100f); + } + + private void SaveInputMode(VcInputMode mode) + { + _voiceSettings.InputMode = (int)mode; + _voiceSettings.Save(); + } + private float VadThresholdFromSlider() => 0.1f * (1f - (trkVadThreshold.Value - 1f) / 99f); @@ -832,6 +878,8 @@ public partial class MainForm : Form { _pttKey = dlg.CapturedKey; lblPttKey.Text = $"({_pttKey})"; + _voiceSettings.PttKey = (int)_pttKey; + _voiceSettings.Save(); } } diff --git a/clients/windows/VoiceCat.App/Models/VoiceSettings.cs b/clients/windows/VoiceCat.App/Models/VoiceSettings.cs new file mode 100644 index 0000000..6ff639d --- /dev/null +++ b/clients/windows/VoiceCat.App/Models/VoiceSettings.cs @@ -0,0 +1,53 @@ +using System.Text.Json; + +namespace VoiceCat.App.Models; + +/// +/// User's send-side voice input preferences — transmission mode, VAD sensitivity, mic input +/// gain, and the push-to-talk key. Persisted to %AppData%\VoiceCat\voice.json, same pattern as +/// and FeedbackSettings: a missing or corrupt file yields defaults +/// rather than throwing. Slider-position values are stored as-is so MainForm can restore the +/// TrackBars directly. +/// +public sealed class VoiceSettings +{ + /// Transmission mode: 0 = voice activation, 1 = push-to-talk, 2 = always on + /// (matches Interop's VcInputMode). + public int InputMode { get; set; } = 0; + + /// VAD sensitivity slider position, 1–100 (default mirrors the designer's 76). + public int VadThresholdSlider { get; set; } = 76; + + /// Microphone input gain slider position, 0–300 percent (100 = unity). + public int MicGain { get; set; } = 100; + + /// Push-to-talk key, stored as the integer value of System.Windows.Forms.Keys. + public int PttKey { get; set; } = (int)Keys.F8; + + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private static string AppDataDir => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VoiceCat"); + + private static string FilePath => Path.Combine(AppDataDir, "voice.json"); + + public static VoiceSettings Load() + { + try + { + if (!File.Exists(FilePath)) return new VoiceSettings(); + string json = File.ReadAllText(FilePath); + return JsonSerializer.Deserialize(json) ?? new VoiceSettings(); + } + catch + { + return new VoiceSettings(); + } + } + + public void Save() + { + Directory.CreateDirectory(AppDataDir); + File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions)); + } +} diff --git a/clients/windows/VoiceCat.Interop/NativeMethods.cs b/clients/windows/VoiceCat.Interop/NativeMethods.cs index 1f88deb..cc54aaf 100644 --- a/clients/windows/VoiceCat.Interop/NativeMethods.cs +++ b/clients/windows/VoiceCat.Interop/NativeMethods.cs @@ -80,6 +80,9 @@ internal static partial class NativeMethods [LibraryImport(LibName)] internal static partial VcResult vc_set_output_volume(nint c, float gain); + [LibraryImport(LibName)] + internal static partial VcResult vc_set_input_gain(nint c, float gain); + [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 869ad45..bee65d7 100644 --- a/clients/windows/VoiceCat.Interop/VoiceCatClient.cs +++ b/clients/windows/VoiceCat.Interop/VoiceCatClient.cs @@ -227,6 +227,11 @@ public sealed class VoiceCatClient : IDisposable public VcResult SetOutputVolume(float gain) => NativeMethods.vc_set_output_volume(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain); + /// Send-side microphone input gain, applied to captured MIC PCM before VAD/encode. + /// 0.0 = silent, 1.0 = unity (default), >1.0 amplifies (clamped to int16). LOCAL only. + public VcResult SetInputGain(float gain) => + NativeMethods.vc_set_input_gain(_handle.DangerousGetHandle(), gain < 0f ? 0f : gain); + 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); diff --git a/core/include/voicecat.h b/core/include/voicecat.h index b24e84c..c2a9358 100644 --- a/core/include/voicecat.h +++ b/core/include/voicecat.h @@ -386,6 +386,12 @@ VC_API vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened); * 1.0 = unity (default), >1.0 amplifies. Always LOCAL — no protocol traffic. */ VC_API vc_result vc_set_output_volume(vc_client* c, float gain); +/* Send-side microphone input gain. Applied to captured MIC PCM before the VAD/PTT gate and + * Opus encode (so boosting a quiet mic also helps it cross the VAD threshold). gain 0.0 = silent, + * 1.0 = unity (default), >1.0 amplifies; the boosted signal is clamped to int16. MIC stream only; + * always LOCAL — no protocol traffic. */ +VC_API vc_result vc_set_input_gain(vc_client* c, float gain); + /* Receive-side, per remote stream, all LOCAL (no protocol traffic) — docs/voice.md §10: * gain (0..) , mute, and listener-chosen noise reduction on a specific user's stream. */ VC_API vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id, diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp index 55e7462..f1f610c 100644 --- a/core/src/core/client.cpp +++ b/core/src/core/client.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include #include @@ -1008,6 +1009,21 @@ void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples, int (self_mic_muted_.load(std::memory_order_acquire) || server_muted_.load(std::memory_order_acquire))) return; + // Send-side mic input gain (vc_set_input_gain) — MIC only. Applied in place before the gate + // so a boosted quiet mic also helps cross the VAD threshold. EnergyVadProcessor never writes + // through its pointer, so the const_cast (same as the VAD path below) is safe; no allocation. + if (kind == static_cast(VC_STREAM_MIC)) { + const float gain = input_gain_.load(std::memory_order_relaxed); + if (gain != 1.0f) { + int16_t* w = const_cast(pcm); + const int n = samples * std::max(1, channels); + for (int i = 0; i < n; ++i) { + int32_t v = static_cast(std::lround(w[i] * gain)); + w[i] = static_cast(std::clamp(v, -32768, 32767)); + } + } + } + // Send-side input gate (docs/voice.md §11) — MIC only. SCREEN_AUDIO/AUX_DEVICE always // bypass this: gating a screen-share on the user's own voice activity would silently drop // shared music/video audio whenever the user isn't talking, which defeats the feature. @@ -1452,6 +1468,11 @@ vc_result vc_client::set_output_volume(float gain) { return VC_OK; } +vc_result vc_client::set_input_gain(float gain) { + input_gain_.store(gain < 0.0f ? 0.0f : gain, std::memory_order_relaxed); + return VC_OK; +} + vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted, bool noise_reduction) { if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED; @@ -1968,6 +1989,7 @@ vc_result vc_client::set_input_device(uint32_t, const char*) { return VC_ERR_NOT vc_result vc_client::set_capture_channels(uint32_t, uint32_t) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_input_mode(vc_input_mode) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_vad_threshold(float) { return VC_ERR_NOT_IMPLEMENTED; } +vc_result vc_client::set_input_gain(float) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_push_to_talk(bool) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_self_mute(bool, bool) { return VC_ERR_NOT_IMPLEMENTED; } vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) { diff --git a/core/src/core/client.h b/core/src/core/client.h index f61b5e9..9b5d789 100644 --- a/core/src/core/client.h +++ b/core/src/core/client.h @@ -55,6 +55,7 @@ struct vc_client { vc_result set_push_to_talk(bool active); vc_result set_self_mute(bool mic_muted, bool deafened); vc_result set_output_volume(float gain); + vc_result set_input_gain(float gain); vc_result set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted, bool noise_reduction); vc_result get_remote_stream(uint32_t user_id, uint32_t stream_id, @@ -311,6 +312,7 @@ struct vc_client { std::atomic current_input_mode_{VC_INPUT_VOICE_ACTIVATION}; std::atomic ptt_active_{false}; std::atomic vad_threshold_{0.025f}; // remembered across mode switches + std::atomic input_gain_{1.0f}; // send-side MIC gain (vc_set_input_gain) // External-playback mode (iOS VPIO): when true, ensure_audio_running() configures the // AudioEngine to skip its hardware playback device and drive the mixer on a timer instead, diff --git a/core/src/voicecat.cpp b/core/src/voicecat.cpp index 4a41b26..ec2630a 100644 --- a/core/src/voicecat.cpp +++ b/core/src/voicecat.cpp @@ -122,6 +122,11 @@ vc_result vc_set_output_volume(vc_client* c, float gain) { return c->set_output_volume(gain); } +vc_result vc_set_input_gain(vc_client* c, float gain) { + if (c == nullptr) return VC_ERR_INVALID_ARG; + return c->set_input_gain(gain); +} + vc_result vc_set_remote_stream(vc_client* c, uint32_t user_id, uint32_t stream_id, float gain, int muted, int noise_reduction) { if (c == nullptr) return VC_ERR_INVALID_ARG;