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: 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%") // Send-side mic noise reduction (RNNoise). MIC-only. Persisted via // MainWindowController.inputNoiseReduction. private let nrCheckbox = NSButton(checkboxWithTitle: "Noise reduction (RNNoise)", target: nil, action: nil) // Capture the mic in stereo (interleaved L/R) instead of mono. Real stereo only reaches the // wire on a stereo channel; the core folds a stereo mic to mono on a mono channel. Persisted // via MainWindowController.stereoMic. private let stereoMicCheckbox = NSButton(checkboxWithTitle: "Stereo microphone", target: nil, action: nil) // Aux input stream: a second outgoing stream from another hardware input device (e.g. line-in // / aux), captured client-side. Device + volume only — aux is always-on. Persisted via // MainWindowController.auxEnabled / auxDeviceUID / auxGain. private let auxCheckbox = NSButton(checkboxWithTitle: "Aux input stream (second device)", target: nil, action: nil) private let auxDevicePicker = NSPopUpButton() private let auxRefreshButton = NSButton() private let auxGainSlider: NSSlider = { let s = NSSlider(value: 100, minValue: 0, maxValue: 300, target: nil, action: nil) s.numberOfTickMarks = 0 return s }() private let auxGainValueLabel = NSTextField(labelWithString: "100%") private let auxDeviceLabel = NSTextField(labelWithString: "Aux device:") private let auxGainLabel = NSTextField(labelWithString: "Aux volume:") // 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() loadAuxDevices() } 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") nrCheckbox.target = self nrCheckbox.action = #selector(nrChanged) nrCheckbox.setAccessibilityLabel("Microphone noise reduction") nrCheckbox.setAccessibilityHelp("RNNoise denoising of your microphone. Cleans your signal for everyone.") stereoMicCheckbox.target = self stereoMicCheckbox.action = #selector(stereoMicChanged) stereoMicCheckbox.setAccessibilityLabel("Stereo microphone") stereoMicCheckbox.setAccessibilityHelp("Capture your microphone in stereo. Only transmitted in stereo on a stereo channel.") 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 // ── Aux input stream ────────────────────────────────────────────────── let auxHeader = NSTextField(labelWithString: "Aux input stream") auxHeader.font = .boldSystemFont(ofSize: NSFont.systemFontSize) auxCheckbox.target = self auxCheckbox.action = #selector(auxEnabledChanged) auxCheckbox.setAccessibilityLabel("Enable aux input stream") auxCheckbox.setAccessibilityHelp("Transmit a second hardware input device alongside your microphone.") auxDeviceLabel.setAccessibilityLabel("Aux input device") auxDevicePicker.setAccessibilityLabel("Aux input device") auxDevicePicker.target = self auxDevicePicker.action = #selector(auxDeviceChanged) auxRefreshButton.title = "↺" auxRefreshButton.bezelStyle = .rounded auxRefreshButton.target = self auxRefreshButton.action = #selector(refreshAuxDevicesClicked) auxRefreshButton.setAccessibilityLabel("Refresh aux device list") auxRefreshButton.toolTip = "Refresh" auxGainLabel.setAccessibilityLabel("Aux volume") auxGainSlider.target = self auxGainSlider.action = #selector(auxGainChanged) auxGainSlider.setAccessibilityLabel("Aux volume") auxGainSlider.setAccessibilityHelp("Volume of the aux input stream. 100% is unity.") auxGainValueLabel.setAccessibilityLabel("Aux volume value") let auxDeviceRow = NSStackView(views: [auxDeviceLabel, auxDevicePicker, auxRefreshButton]) auxDeviceRow.orientation = .horizontal auxDeviceRow.spacing = 8 let auxGainRow = NSStackView(views: [auxGainLabel, auxGainSlider, auxGainValueLabel]) auxGainRow.orientation = .horizontal auxGainRow.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, nrCheckbox, stereoMicCheckbox, pttRow, deviceRow, levelRow, auxHeader, auxCheckbox, auxDeviceRow, auxGainRow, 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), auxDevicePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), auxGainSlider.widthAnchor.constraint(greaterThanOrEqualToConstant: 180), ]) 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() nrCheckbox.state = mc.inputNoiseReduction ? .on : .off stereoMicCheckbox.state = mc.stereoMic ? .on : .off auxCheckbox.state = mc.auxEnabled ? .on : .off auxGainSlider.doubleValue = Double(mc.auxGain * 100) updateAuxGainLabel() updateAuxControlsEnabled() 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) } } @objc private func nrChanged() { let on = nrCheckbox.state == .on mainController?.inputNoiseReduction = on if let mc = mainController, mc.micStreamId != 0 { client.setInputNoiseReduction(on) } } @objc private func stereoMicChanged() { let on = stereoMicCheckbox.state == .on mainController?.stereoMic = on // Channel count only takes effect when the capture device (re)starts, so restart it live. if let mc = mainController, mc.micStreamId != 0 { client.setCaptureChannels(streamId: mc.micStreamId, channels: on ? 2 : 1) client.audioRestart() } } 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: - Aux input stream actions @objc private func auxEnabledChanged() { let on = auxCheckbox.state == .on updateAuxControlsEnabled() mainController?.applyAuxEnabled(on) } @objc private func refreshAuxDevicesClicked() { loadAuxDevices() } @objc private func auxDeviceChanged() { let uid = auxDevicePicker.selectedItem?.representedObject as? String mainController?.applyAuxDevice(uid) } @objc private func auxGainChanged() { mainController?.auxGain = Float(auxGainSlider.doubleValue) / 100.0 updateAuxGainLabel() } private func updateAuxGainLabel() { let pct = Int(auxGainSlider.doubleValue.rounded()) auxGainValueLabel.stringValue = "\(pct)%" auxGainSlider.setAccessibilityValue("\(pct) percent") } private func updateAuxControlsEnabled() { let on = auxCheckbox.state == .on auxDeviceLabel.isEnabled = on auxDevicePicker.isEnabled = on auxRefreshButton.isEnabled = on auxGainLabel.isEnabled = on auxGainSlider.isEnabled = on auxGainValueLabel.isEnabled = on } private func loadAuxDevices() { let devices = InputDeviceEnumerator.list() let prevSelected = (auxDevicePicker.selectedItem?.representedObject as? String) ?? mainController?.auxDeviceUID auxDevicePicker.removeAllItems() for d in devices { let item = NSMenuItem(title: d.name, action: nil, keyEquivalent: "") item.representedObject = d.uid auxDevicePicker.menu?.addItem(item) } if let prev = prevSelected, let item = auxDevicePicker.itemArray.first(where: { ($0.representedObject as? String) == prev }) { auxDevicePicker.select(item) } else if let def = devices.first(where: { $0.isDefault }), let item = auxDevicePicker.itemArray.first(where: { ($0.representedObject as? String) == def.uid }) { auxDevicePicker.select(item) } else if auxDevicePicker.numberOfItems > 0 { auxDevicePicker.selectItem(at: 0) } // Persist the resolved selection so a relaunch (or Join Voice) opens the same device. mainController?.auxDeviceUID = auxDevicePicker.selectedItem?.representedObject as? String } // 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 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) } else { let cvc = NSViewController() cvc.view = window!.contentView! window?.contentViewController = cvc cvc.presentAsSheet(vc) } } }