diff --git a/clients/macos/vellum-assistant/Features/Voice/AudioAmplitudeTracker.swift b/clients/macos/vellum-assistant/Features/Voice/AudioAmplitudeTracker.swift deleted file mode 100644 index c1d854f78e4..00000000000 --- a/clients/macos/vellum-assistant/Features/Voice/AudioAmplitudeTracker.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation -import AVFoundation -import os - -private let log = Logger(subsystem: "com.vellum.vellum-assistant", category: "AudioAmplitudeTracker") - -/// Tracks microphone input amplitude by polling the audio engine's input node metering. -@MainActor -final class AudioAmplitudeTracker { - var onAmplitude: ((Float) -> Void)? - - private var timer: Timer? - private weak var audioEngine: AVAudioEngine? - - nonisolated init() {} - - func startTracking(audioEngine: AVAudioEngine) { - self.audioEngine = audioEngine - startPolling() - } - - func stopTracking() { - timer?.invalidate() - timer = nil - audioEngine = nil - } - - private func startPolling() { - timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self else { return } - // Since we can't directly access metering from AVAudioEngine input - // without an additional tap (and we already have one on bus 0), - // simulate amplitude based on a smoothed random for now. - // The waveform will still react when speaking due to the variation. - let simulated = Float.random(in: 0.2...0.7) - self.onAmplitude?(simulated) - } - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Voice/TTSEngine.swift b/clients/macos/vellum-assistant/Features/Voice/TTSEngine.swift deleted file mode 100644 index afd1567ced9..00000000000 --- a/clients/macos/vellum-assistant/Features/Voice/TTSEngine.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import AVFoundation -import os - -private let log = Logger(subsystem: "com.vellum.vellum-assistant", category: "TTSEngine") - -@MainActor -final class TTSEngine: NSObject, ObservableObject { - @Published var isSpeaking = false - @Published var currentAmplitude: Float = 0 - - private let synthesizer = AVSpeechSynthesizer() - private var onComplete: (() -> Void)? - private var amplitudeTimer: Timer? - private var delegateSet = false - - nonisolated override init() { - super.init() - } - - private func ensureDelegate() { - guard !delegateSet else { return } - delegateSet = true - synthesizer.delegate = self - } - - func speak(_ text: String, onComplete: (() -> Void)? = nil) { - ensureDelegate() - stop() - self.onComplete = onComplete - - let utterance = AVSpeechUtterance(string: text) - utterance.rate = AVSpeechUtteranceDefaultSpeechRate - utterance.pitchMultiplier = 1.0 - utterance.volume = 1.0 - - isSpeaking = true - startAmplitudePolling() - synthesizer.speak(utterance) - log.info("TTS started speaking") - } - - func stop() { - guard isSpeaking else { return } - synthesizer.stopSpeaking(at: .immediate) - stopAmplitudePolling() - isSpeaking = false - currentAmplitude = 0 - onComplete = nil - log.info("TTS stopped") - } - - private func startAmplitudePolling() { - amplitudeTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self, self.isSpeaking else { return } - // Simulate amplitude variation while speaking since AVSpeechSynthesizer - // doesn't expose audio levels directly. - self.currentAmplitude = Float.random(in: 0.3...0.8) - } - } - } - - private func stopAmplitudePolling() { - amplitudeTimer?.invalidate() - amplitudeTimer = nil - } -} - -extension TTSEngine: AVSpeechSynthesizerDelegate { - nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { - Task { @MainActor [weak self] in - guard let self else { return } - self.stopAmplitudePolling() - self.isSpeaking = false - self.currentAmplitude = 0 - log.info("TTS utterance finished") - let completion = self.onComplete - self.onComplete = nil - completion?() - } - } - - nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { - Task { @MainActor [weak self] in - guard let self else { return } - self.stopAmplitudePolling() - self.isSpeaking = false - self.currentAmplitude = 0 - log.info("TTS utterance cancelled") - self.onComplete = nil - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordDismissHandler.swift b/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordDismissHandler.swift deleted file mode 100644 index c2c521aed15..00000000000 --- a/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordDismissHandler.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Foundation -import Combine -import os - -private let log = Logger(subsystem: "com.vellum.vellum-assistant", category: "WakeWordDismissHandler") - -/// Handles dismissal of wake word activations: Escape key cancellation, -/// auto-cancel on silence, and cooldown to prevent rapid re-triggers. -@MainActor -final class WakeWordDismissHandler: ObservableObject { - /// Duration to wait for speech after wake word detection before auto-cancelling. - static let silenceTimeout: TimeInterval = 3.0 - /// Cooldown after a dismissal before accepting another wake word detection. - static let cooldownDuration: TimeInterval = 2.0 - - @Published private(set) var isInCooldown = false - - /// Total number of dismissals since app launch, useful for debugging. - private(set) var dismissCount = 0 - - /// Called when the handler dismisses an activation (Escape, silence, or programmatic). - var onDismiss: (() -> Void)? - - private var silenceTimer: Task? - private var cooldownTimer: Task? - /// Whether an activation is currently in progress and can be dismissed. - private var isActivationInProgress = false - - // MARK: - Activation Lifecycle - - /// Call when a wake word is detected and the system enters "activated" state. - /// Starts the silence timeout to auto-cancel if no speech is detected. - func activationStarted() { - guard !isInCooldown else { - log.debug("Ignoring activation during cooldown period") - return - } - isActivationInProgress = true - startSilenceTimer() - log.debug("Activation started, silence timer running") - } - - /// Call when speech is detected after activation. Cancels the silence timeout - /// since the user is actively speaking. - func speechDetected() { - cancelSilenceTimer() - log.debug("Speech detected, silence timer cancelled") - } - - /// Call when the activation completes normally (speech was processed). - /// No cooldown is applied for normal completions. - func activationCompleted() { - cancelSilenceTimer() - isActivationInProgress = false - log.debug("Activation completed normally") - } - - /// Dismiss the current activation via Escape key or programmatic request. - func dismiss() { - guard isActivationInProgress else { return } - performDismiss(reason: "user dismiss (Escape)") - } - - // MARK: - Silence Handling - - private func startSilenceTimer() { - cancelSilenceTimer() - silenceTimer = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(Self.silenceTimeout * 1_000_000_000)) - guard !Task.isCancelled else { return } - self?.handleSilenceTimeout() - } - } - - private func cancelSilenceTimer() { - silenceTimer?.cancel() - silenceTimer = nil - } - - private func handleSilenceTimeout() { - guard isActivationInProgress else { return } - performDismiss(reason: "silence timeout (\(Self.silenceTimeout)s)") - } - - // MARK: - Dismiss + Cooldown - - private func performDismiss(reason: String) { - cancelSilenceTimer() - isActivationInProgress = false - dismissCount += 1 - log.info("Dismissed activation: \(reason) (total dismissals: \(self.dismissCount))") - - onDismiss?() - startCooldown() - } - - private func startCooldown() { - cooldownTimer?.cancel() - isInCooldown = true - log.debug("Cooldown started (\(Self.cooldownDuration)s)") - - cooldownTimer = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(Self.cooldownDuration * 1_000_000_000)) - guard !Task.isCancelled else { return } - self?.isInCooldown = false - log.debug("Cooldown ended") - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordErrorRecovery.swift b/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordErrorRecovery.swift deleted file mode 100644 index 371a537ade8..00000000000 --- a/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordErrorRecovery.swift +++ /dev/null @@ -1,117 +0,0 @@ -import Foundation -import Combine -import os - -private let log = Logger(subsystem: "com.vellum.vellum-assistant", category: "WakeWordErrorRecovery") - -/// Handles automatic recovery from wake word engine errors: auto-restart on -/// engine failures, pause/resume on mic unavailability, and retry limits. -@MainActor -final class WakeWordErrorRecovery: ObservableObject { - /// Delay before attempting to restart the engine after an error. - static let restartDelay: TimeInterval = 5.0 - /// Maximum consecutive retries before giving up. - static let maxRetries = 5 - - @Published private(set) var hasGivenUp = false - @Published private(set) var consecutiveErrors = 0 - - /// Called when the engine has exceeded max retries and will not attempt further restarts. - var onGaveUp: (() -> Void)? - - /// Error history for debugging, stores timestamps and descriptions. - private(set) var errorHistory: [(date: Date, description: String)] = [] - - private let engine: WakeWordEngine - private var restartTask: Task? - - init(engine: WakeWordEngine) { - self.engine = engine - } - - // MARK: - Error Handling - - /// Call when the wake word engine encounters an error. - func handleEngineError(_ error: Error) { - let description = error.localizedDescription - errorHistory.append((date: Date(), description: description)) - consecutiveErrors += 1 - - log.error("Wake word engine error (\(self.consecutiveErrors)/\(Self.maxRetries)): \(description)") - - if consecutiveErrors >= Self.maxRetries { - giveUp() - } else { - scheduleRestart() - } - } - - /// Call when the microphone becomes unavailable (e.g., disconnected or claimed by another app). - func handleMicUnavailable() { - restartTask?.cancel() - restartTask = nil - engine.stop() - log.info("Mic unavailable — paused wake word engine") - errorHistory.append((date: Date(), description: "Microphone unavailable")) - } - - /// Call when the microphone becomes available again. - func handleMicAvailable() { - guard !hasGivenUp else { - log.info("Mic available but engine has given up, not restarting") - return - } - - log.info("Microphone available, restarting engine") - // Reset consecutive errors on mic restore since this is an external recovery - consecutiveErrors = 0 - attemptRestart() - } - - /// Call when the engine starts successfully to reset the error counter. - func handleEngineStarted() { - consecutiveErrors = 0 - } - - /// Reset the error state so the engine can be retried (e.g., after user intervention). - func reset() { - restartTask?.cancel() - restartTask = nil - consecutiveErrors = 0 - hasGivenUp = false - log.info("Error recovery state reset") - } - - // MARK: - Internal - - private func scheduleRestart() { - restartTask?.cancel() - log.info("Scheduling engine restart in \(Self.restartDelay)s") - - restartTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(Self.restartDelay * 1_000_000_000)) - guard !Task.isCancelled else { return } - self?.attemptRestart() - } - } - - private func attemptRestart() { - do { - try engine.start() - log.info("Engine restarted successfully") - consecutiveErrors = 0 - } catch { - log.error("Engine restart failed: \(error.localizedDescription)") - handleEngineError(error) - } - } - - private func giveUp() { - restartTask?.cancel() - restartTask = nil - hasGivenUp = true - engine.stop() - log.error("Gave up restarting engine after \(Self.maxRetries) consecutive failures") - onGaveUp?() - } -} diff --git a/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordPrivacyGuard.swift b/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordPrivacyGuard.swift deleted file mode 100644 index fb1931eb872..00000000000 --- a/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordPrivacyGuard.swift +++ /dev/null @@ -1,113 +0,0 @@ -import Foundation -import Combine -import AppKit -import os - -private let log = Logger(subsystem: "com.vellum.vellum-assistant", category: "WakeWordPrivacyGuard") - -/// Monitors system state and automatically pauses/resumes wake word listening -/// for privacy. Pauses on screen lock, system sleep, and app termination; -/// resumes on unlock and wake. -@MainActor -final class WakeWordPrivacyGuard { - private let audioMonitor: AlwaysOnAudioMonitor - private var cancellables = Set() - /// Whether listening was active before a privacy pause, so we only resume if it was. - private var wasListeningBeforePause = false - - init(audioMonitor: AlwaysOnAudioMonitor) { - self.audioMonitor = audioMonitor - observeSystemEvents() - } - - deinit { - // NotificationCenter observers are cleaned up via cancellables - } - - // MARK: - System Event Observation - - private func observeSystemEvents() { - let workspace = NSWorkspace.shared.notificationCenter - - // Screen sleep (display off / screensaver) - workspace.publisher(for: NSWorkspace.screensDidSleepNotification) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.handlePrivacyPause(reason: "screen sleep") - } - .store(in: &cancellables) - - // System sleep - workspace.publisher(for: NSWorkspace.willSleepNotification) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.handlePrivacyPause(reason: "system sleep") - } - .store(in: &cancellables) - - // Screen wake - workspace.publisher(for: NSWorkspace.screensDidWakeNotification) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.handlePrivacyResume(reason: "screen wake") - } - .store(in: &cancellables) - - // System wake - workspace.publisher(for: NSWorkspace.didWakeNotification) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.handlePrivacyResume(reason: "system wake") - } - .store(in: &cancellables) - - // Screen lock via DistributedNotificationCenter - DistributedNotificationCenter.default().publisher( - for: Notification.Name("com.apple.screenIsLocked") - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.handlePrivacyPause(reason: "screen lock") - } - .store(in: &cancellables) - - // Screen unlock via DistributedNotificationCenter - DistributedNotificationCenter.default().publisher( - for: Notification.Name("com.apple.screenIsUnlocked") - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.handlePrivacyResume(reason: "screen unlock") - } - .store(in: &cancellables) - - // App termination - NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.handleTermination() - } - .store(in: &cancellables) - } - - // MARK: - Privacy Actions - - private func handlePrivacyPause(reason: String) { - guard audioMonitor.isListening else { return } - wasListeningBeforePause = true - audioMonitor.stopMonitoring() - log.info("Paused wake word listening: \(reason)") - } - - private func handlePrivacyResume(reason: String) { - guard wasListeningBeforePause else { return } - wasListeningBeforePause = false - audioMonitor.startMonitoring() - log.info("Resumed wake word listening: \(reason)") - } - - private func handleTermination() { - audioMonitor.stopMonitoring() - log.info("Stopped wake word listening: app terminating") - } -}