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;