Files
voice-cat/clients/apple/macOS/VoiceCatMac/Windows/SettingsWindowController.swift
Talon 95f1fb70b0
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
feat(clients): persist input settings, add mic input gain, fix iOS chat + VoiceOver
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.
2026-06-23 03:35:26 +02:00

408 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import AppKit
import VoiceCatCore
// SettingsWindowController a modeless window containing the audio input settings that used
// to live in the main window's bottom voice panel: input mode (VAD/PTT/Always On), VAD
// sensitivity, PTT key selection, input device picker, and the live microphone level meter.
//
// The main window is now just toolbar + channels + users + chat; audio settings live here
// and are accessed via the app menu's "Settings" item (,). The window is modeless so the
// user can keep it open while interacting with the main window essential for watching the
// level meter while adjusting VAD threshold or testing a device.
//
// Source-of-truth for the current settings lives in MainWindowController (so voice start can
// apply them even before this window has been opened). This window reads from and writes back
// to MainWindowController's stored properties, and applies changes to the client immediately
// when voice is active.
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
// MARK: - References
private let client: VoiceCatClient
weak var mainController: MainWindowController?
// MARK: - UI
private let inputModeControl = NSSegmentedControl(labels: ["VAD", "PTT", "Always On"],
trackingMode: .selectOne,
target: nil, action: nil)
private let vadSlider: NSSlider = {
let s = NSSlider(value: 50, minValue: 1, maxValue: 100, target: nil, action: nil)
s.numberOfTickMarks = 0
return s
}()
private let vadLabel = NSTextField(labelWithString: "Sensitivity:")
private let pttKeyLabel = NSTextField(labelWithString: "(F8)")
private let changePttButton = NSButton()
private let devicePicker = NSPopUpButton()
private let refreshDevicesButton = NSButton()
private let levelMeter: NSProgressIndicator = {
let p = NSProgressIndicator()
p.style = .bar
p.isIndeterminate = false
p.minValue = 0
p.maxValue = 100
p.doubleValue = 0
return p
}()
// 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)
private let soundsVolumeSlider: NSSlider = {
let s = NSSlider(value: 1, minValue: 0, maxValue: 1, target: nil, action: nil)
s.numberOfTickMarks = 0
return s
}()
private let speechCheckbox = NSButton(checkboxWithTitle: "Speak events (text-to-speech)",
target: nil, action: nil)
private let selfTalkCheckbox = NSButton(checkboxWithTitle: "Your own voice-activity sounds",
target: nil, action: nil)
private let pttSoundCheckbox = NSButton(checkboxWithTitle: "Push-to-talk cue",
target: nil, action: nil)
// MARK: - Init
init(client: VoiceCatClient, mainController: MainWindowController) {
self.client = client
self.mainController = mainController
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 380, height: 420),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
window.title = "Settings"
window.minSize = NSSize(width: 340, height: 380)
window.center()
super.init(window: window)
window.delegate = self
buildUI()
syncFromMainController()
loadInputDevices()
}
required init?(coder: NSCoder) { fatalError() }
// MARK: - UI construction
private func buildUI() {
guard let contentView = window?.contentView else { return }
let inputModeLabel = NSTextField(labelWithString: "Input mode:")
inputModeLabel.setAccessibilityLabel("Input mode")
inputModeControl.target = self
inputModeControl.action = #selector(inputModeChanged)
inputModeControl.selectedSegment = 0
inputModeControl.setAccessibilityLabel("Input mode: VAD, PTT, or Always On")
vadLabel.setAccessibilityLabel("VAD sensitivity")
vadSlider.target = self
vadSlider.action = #selector(vadSliderChanged)
vadSlider.setAccessibilityLabel("Voice activation sensitivity")
vadSlider.setAccessibilityHelp("Drag right for more sensitive, left for less")
pttKeyLabel.setAccessibilityLabel("Current PTT key")
changePttButton.title = "Change…"
changePttButton.bezelStyle = .rounded
changePttButton.target = self
changePttButton.action = #selector(changePttClicked)
changePttButton.setAccessibilityLabel("Change push-to-talk key")
pttKeyLabel.isHidden = true
changePttButton.isHidden = true
let deviceLabel = NSTextField(labelWithString: "Input device:")
deviceLabel.setAccessibilityLabel("Input device")
devicePicker.setAccessibilityLabel("Input audio device")
devicePicker.target = self
devicePicker.action = #selector(deviceChanged)
refreshDevicesButton.title = ""
refreshDevicesButton.bezelStyle = .rounded
refreshDevicesButton.target = self
refreshDevicesButton.action = #selector(refreshDevicesClicked)
refreshDevicesButton.setAccessibilityLabel("Refresh device list")
refreshDevicesButton.toolTip = "Refresh"
let levelLabel = NSTextField(labelWithString: "Level:")
levelLabel.setAccessibilityLabel("Microphone input level")
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
let vadRow = NSStackView(views: [vadLabel, vadSlider])
vadRow.orientation = .horizontal
vadRow.spacing = 8
let pttRow = NSStackView(views: [pttKeyLabel, changePttButton])
pttRow.orientation = .horizontal
pttRow.spacing = 8
let deviceRow = NSStackView(views: [deviceLabel, devicePicker, refreshDevicesButton])
deviceRow.orientation = .horizontal
deviceRow.spacing = 8
let levelRow = NSStackView(views: [levelLabel, levelMeter])
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)
for box in [soundsCheckbox, speechCheckbox, selfTalkCheckbox, pttSoundCheckbox] {
box.target = self
box.action = #selector(notificationSettingChanged)
}
soundsCheckbox.setAccessibilityLabel("Play event sounds")
speechCheckbox.setAccessibilityLabel("Speak events")
selfTalkCheckbox.setAccessibilityLabel("Your own voice-activity sounds")
pttSoundCheckbox.setAccessibilityLabel("Push-to-talk cue")
let volumeLabel = NSTextField(labelWithString: "Sound volume:")
volumeLabel.setAccessibilityLabel("Sound volume")
soundsVolumeSlider.target = self
soundsVolumeSlider.action = #selector(notificationSettingChanged)
soundsVolumeSlider.setAccessibilityLabel("Sound volume")
let volumeRow = NSStackView(views: [volumeLabel, soundsVolumeSlider])
volumeRow.orientation = .horizontal
volumeRow.spacing = 8
let stack = NSStackView(views: [inputModeRow, vadRow, inputGainRow, pttRow, deviceRow,
levelRow, notificationsHeader, soundsCheckbox, volumeRow,
speechCheckbox, selfTalkCheckbox, pttSoundCheckbox])
stack.orientation = .vertical
stack.spacing = 12
stack.alignment = .leading
stack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: contentView.topAnchor),
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
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),
])
syncNotificationControls()
}
/// Load the notification checkbox/slider states from UserDefaults. Touches EventFeedback.shared
/// first so its default values are registered before we read them.
private func syncNotificationControls() {
let s = FeedbackSettings.current
soundsCheckbox.state = s.sounds ? .on : .off
speechCheckbox.state = s.speech ? .on : .off
selfTalkCheckbox.state = s.selfTalkSounds ? .on : .off
pttSoundCheckbox.state = s.pttSound ? .on : .off
soundsVolumeSlider.doubleValue = Double(s.volume)
}
@objc private func notificationSettingChanged() {
let d = UserDefaults.standard
d.set(soundsCheckbox.state == .on, forKey: "feedback.sounds")
d.set(speechCheckbox.state == .on, forKey: "feedback.speech")
d.set(selfTalkCheckbox.state == .on, forKey: "feedback.selfTalk")
d.set(pttSoundCheckbox.state == .on, forKey: "feedback.ptt")
d.set(soundsVolumeSlider.doubleValue, forKey: "feedback.volume")
}
// MARK: - Sync from MainWindowController
/// Read the current settings from MainWindowController and update our UI to match.
/// Called on init and whenever the window is re-shown.
private func syncFromMainController() {
guard let mc = mainController else { return }
switch mc.selectedInputMode {
case .voiceActivation: inputModeControl.selectedSegment = 0
case .pushToTalk: inputModeControl.selectedSegment = 1
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()
}
/// Show/hide VAD and PTT controls based on the selected input mode.
private func updateConditionalControls() {
let seg = inputModeControl.selectedSegment
vadLabel.isHidden = seg != 0
vadSlider.isHidden = seg != 0
pttKeyLabel.isHidden = seg != 1
changePttButton.isHidden = seg != 1
}
// MARK: - Actions
@objc private func inputModeChanged() {
updateConditionalControls()
let mode = currentInputMode()
mainController?.selectedInputMode = mode
if let mc = mainController, mc.micStreamId != 0 {
client.setInputMode(mode)
if mode == .voiceActivation {
client.setVadThreshold(vadThresholdFromSlider())
} else if mode == .pushToTalk {
client.setPushToTalk(false)
}
}
}
@objc private func vadSliderChanged() {
vadSliderValue = vadSlider.doubleValue
let threshold = vadThresholdFromSlider()
mainController?.vadThresholdValue = threshold
if let mc = mainController, mc.micStreamId != 0, mc.selectedInputMode == .voiceActivation {
client.setVadThreshold(threshold)
}
}
@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)
sheet.onComplete = { [weak self] keyCode in
guard let self, let keyCode else { return }
self.mainController?.pttKeyCode = keyCode
self.pttKeyLabel.stringValue = "(\(keyCodeName(keyCode)))"
}
presentSheet(sheet)
}
@objc private func refreshDevicesClicked() { loadInputDevices() }
@objc private func deviceChanged() {
let devId = devicePicker.selectedItem?.representedObject as? String
mainController?.selectedInputDeviceId = devId
if let mc = mainController, mc.micStreamId != 0, let devId {
client.setInputDevice(streamId: mc.micStreamId, deviceId: devId)
}
}
// MARK: - Level meter (called by MainWindowController)
func updateLevel(rms: Float) {
levelMeter.doubleValue = min(100, Double(rms * 400))
levelMeter.setAccessibilityValue("\(Int(levelMeter.doubleValue)) percent")
}
func resetLevel() {
levelMeter.doubleValue = 0
}
// MARK: - Device enumeration
private func loadInputDevices() {
let devices = client.listDevices(.input)
let prevSelected = devicePicker.selectedItem?.representedObject as? String
devicePicker.removeAllItems()
for d in devices {
let item = NSMenuItem(title: d.name, action: nil, keyEquivalent: "")
item.representedObject = d.id
devicePicker.menu?.addItem(item)
}
// Restore previous selection, or pick default, or first
if let prev = prevSelected,
let item = devicePicker.itemArray.first(where: { ($0.representedObject as? String) == prev }) {
devicePicker.select(item)
} else if let def = devices.first(where: { $0.isDefault }) {
devicePicker.select(devicePicker.item(withTitle: def.name))
} else if devicePicker.numberOfItems > 0 {
devicePicker.selectItem(at: 0)
}
// Sync the selected device back to main controller
let devId = devicePicker.selectedItem?.representedObject as? String
mainController?.selectedInputDeviceId = devId
}
// MARK: - Helpers
private func currentInputMode() -> VoiceCatInputMode {
switch inputModeControl.selectedSegment {
case 1: return .pushToTalk
case 2: return .alwaysOn
default: return .voiceActivation
}
}
private func vadThresholdFromSlider() -> Float {
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)
} else {
let cvc = NSViewController()
cvc.view = window!.contentView!
window?.contentViewController = cvc
cvc.presentAsSheet(vc)
}
}
}