feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled

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.
This commit is contained in:
2026-06-23 03:35:26 +02:00
parent d30c4ee2f5
commit 95f1fb70b0
17 changed files with 354 additions and 6 deletions

View File

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

View File

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

View File

@@ -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 = ""
}
}

View File

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

View File

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

View File

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

View File

@@ -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: 0300 % (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 1100 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)