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 // 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 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 // 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, 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), 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 } vadSlider.doubleValue = vadSliderValue pttKeyLabel.stringValue = "(\(keyCodeName(mc.pttKeyCode)))" 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 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) } 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) } } }