From c5e903f760c524c2e242a354d9c30dcac4dfbe53 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:47:42 +0000 Subject: [PATCH 1/2] chore: remove dead WakeWord and Voice files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove 6 unused files from the Voice/WakeWord subsystem: - WakeWordErrorRecovery.swift (117 lines) — never wired into WakeWordCoordinator - WakeWordActivationIndicator.swift (193 lines) — never wired into WakeWordCoordinator - WakeWordDismissHandler.swift (109 lines) — never used by any code - WakeWordPrivacyGuard.swift (113 lines) — never wired into WakeWordCoordinator - AudioAmplitudeTracker.swift (41 lines) — zero references outside file - TTSEngine.swift (94 lines) — zero references, likely obsoleted by voice simplification These files define types that are never instantiated or referenced by WakeWordCoordinator, VoiceModeManager, or any other production code. Co-Authored-By: ashlee@vellum.ai --- .../Voice/AudioAmplitudeTracker.swift | 41 ---- .../Features/Voice/TTSEngine.swift | 94 --------- .../WakeWordActivationIndicator.swift | 193 ------------------ .../WakeWord/WakeWordDismissHandler.swift | 109 ---------- .../WakeWord/WakeWordErrorRecovery.swift | 117 ----------- .../Voice/WakeWord/WakeWordPrivacyGuard.swift | 113 ---------- 6 files changed, 667 deletions(-) delete mode 100644 clients/macos/vellum-assistant/Features/Voice/AudioAmplitudeTracker.swift delete mode 100644 clients/macos/vellum-assistant/Features/Voice/TTSEngine.swift delete mode 100644 clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordActivationIndicator.swift delete mode 100644 clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordDismissHandler.swift delete mode 100644 clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordErrorRecovery.swift delete mode 100644 clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordPrivacyGuard.swift 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/WakeWordActivationIndicator.swift b/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordActivationIndicator.swift deleted file mode 100644 index e3be6599c1b..00000000000 --- a/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordActivationIndicator.swift +++ /dev/null @@ -1,193 +0,0 @@ -import AppKit -import SwiftUI -import os -import VellumAssistantShared - -private let log = Logger(subsystem: "com.vellum.vellum-assistant", category: "WakeWordActivationIndicator") - -/// The current state of the wake word indicator overlay. -enum WakeWordIndicatorState { - /// Wake word detected, voice mode is activating. - case activated - /// Voice mode ended, returning to passive wake word listening. - case listening -} - -/// A floating NSPanel that displays a small indicator pill in the top-right -/// area of the screen when the wake word is detected or the app returns to -/// passive listening. Uses AppKit + design system tokens to match the -/// DictationOverlayWindow style and respect light/dark mode. -@MainActor -final class WakeWordActivationWindow { - private var panel: NSPanel? - private var dismissTask: Task? - - private let panelWidth: CGFloat = 240 - private let panelHeight: CGFloat = 36 - private let margin: CGFloat = 16 - - /// Show the activation indicator for a given state. - /// Auto-dismisses after the specified duration. - func show(state: WakeWordIndicatorState, dismissAfter: TimeInterval = 1.5) { - close() - - let contentView = buildContentView(state: state) - - let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight), - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false - ) - - panel.isFloatingPanel = true - panel.level = .floating - panel.backgroundColor = .clear - panel.isOpaque = false - panel.hasShadow = true - panel.contentView = contentView - panel.isMovableByWindowBackground = false - panel.collectionBehavior = [.canJoinAllSpaces, .stationary] - - // Position top-right, slightly below where VoiceTranscriptionWindow appears - if let screen = NSScreen.main { - let screenFrame = screen.visibleFrame - let x = screenFrame.maxX - panelWidth - margin - let y = screenFrame.maxY - panelHeight - margin - panel.setFrameOrigin(NSPoint(x: x, y: y)) - } - - panel.orderFront(nil) - self.panel = panel - - log.debug("Showing wake word indicator: \(String(describing: state))") - - // Auto-dismiss after delay - dismissTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: UInt64(dismissAfter * 1_000_000_000)) - guard !Task.isCancelled else { return } - self.close() - } - } - - func close() { - dismissTask?.cancel() - dismissTask = nil - panel?.orderOut(nil) - panel = nil - } - - // MARK: - AppKit Content - - private func buildContentView(state: WakeWordIndicatorState) -> NSView { - let container = WakeWordOverlayBackgroundView() - container.wantsLayer = true - - let dot = makeDot(for: state) - let label = makeLabel(for: state) - - dot.translatesAutoresizingMaskIntoConstraints = false - label.translatesAutoresizingMaskIntoConstraints = false - - container.addSubview(dot) - container.addSubview(label) - - NSLayoutConstraint.activate([ - dot.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16), - dot.centerYAnchor.constraint(equalTo: container.centerYAnchor), - dot.widthAnchor.constraint(equalToConstant: 8), - dot.heightAnchor.constraint(equalToConstant: 8), - - label.leadingAnchor.constraint(equalTo: dot.trailingAnchor, constant: 8), - label.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -16), - label.centerYAnchor.constraint(equalTo: container.centerYAnchor), - ]) - - return container - } - - private func makeDot(for state: WakeWordIndicatorState) -> NSView { - let dot = NSView(frame: NSRect(x: 0, y: 0, width: 8, height: 8)) - dot.wantsLayer = true - let color: Color = state == .activated ? VColor.accent : VColor.success - dot.layer?.backgroundColor = NSColor(color).cgColor - dot.layer?.cornerRadius = 4 - return dot - } - - private func makeLabel(for state: WakeWordIndicatorState) -> NSTextField { - let text: String - switch state { - case .activated: - text = "Activated" - case .listening: - let keyword = UserDefaults.standard.string(forKey: "wakeWordKeyword") ?? "computer" - text = "Listening for \u{201C}\(keyword)\u{201D}" - } - - let field = NSTextField(labelWithString: text) - field.font = NSFont(name: "Inter", size: 11) ?? NSFont.systemFont(ofSize: 11) - field.textColor = NSColor(VColor.textSecondary) - field.lineBreakMode = .byTruncatingTail - field.maximumNumberOfLines = 1 - return field - } -} - -/// Rounded, semi-transparent background using design system tokens. -private class WakeWordOverlayBackgroundView: NSView { - override var wantsUpdateLayer: Bool { true } - - override func updateLayer() { - layer?.backgroundColor = NSColor(VColor.surface).withAlphaComponent(0.95).cgColor - layer?.cornerRadius = VRadius.lg - layer?.borderWidth = 1 - layer?.borderColor = NSColor(VColor.surfaceBorder).cgColor - } -} - -#Preview("Activated") { - ZStack { - VColor.background.ignoresSafeArea() - HStack(spacing: VSpacing.sm) { - Circle() - .fill(VColor.accent) - .frame(width: 8, height: 8) - Text("Activated") - .font(VFont.caption) - .foregroundColor(VColor.textSecondary) - } - .padding(.horizontal, VSpacing.lg) - .padding(.vertical, VSpacing.sm) - .background(VColor.surface.opacity(0.95)) - .clipShape(RoundedRectangle(cornerRadius: VRadius.lg)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.lg) - .stroke(VColor.surfaceBorder, lineWidth: 1) - ) - } - .frame(width: 300, height: 80) -} - -#Preview("Listening") { - ZStack { - VColor.background.ignoresSafeArea() - HStack(spacing: VSpacing.sm) { - Circle() - .fill(VColor.success) - .frame(width: 8, height: 8) - Text("Listening for \u{201C}computer\u{201D}") - .font(VFont.caption) - .foregroundColor(VColor.textSecondary) - } - .padding(.horizontal, VSpacing.lg) - .padding(.vertical, VSpacing.sm) - .background(VColor.surface.opacity(0.95)) - .clipShape(RoundedRectangle(cornerRadius: VRadius.lg)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.lg) - .stroke(VColor.surfaceBorder, lineWidth: 1) - ) - } - .frame(width: 300, height: 80) -} 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") - } -} From 0e9fcd97393056f9d63fbfdc774050c7822595b7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:21:04 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20restore=20WakeWordActivationIndicato?= =?UTF-8?q?r.swift=20=E2=80=94=20actively=20used=20by=20WakeWordCoordinato?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WakeWordActivationWindow is instantiated at WakeWordCoordinator.swift:27. The initial dead code scan missed this reference. The other 5 files (WakeWordErrorRecovery, WakeWordDismissHandler, WakeWordPrivacyGuard, AudioAmplitudeTracker, TTSEngine) remain confirmed dead. Co-Authored-By: ashlee@vellum.ai --- .../WakeWordActivationIndicator.swift | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordActivationIndicator.swift diff --git a/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordActivationIndicator.swift b/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordActivationIndicator.swift new file mode 100644 index 00000000000..e3be6599c1b --- /dev/null +++ b/clients/macos/vellum-assistant/Features/Voice/WakeWord/WakeWordActivationIndicator.swift @@ -0,0 +1,193 @@ +import AppKit +import SwiftUI +import os +import VellumAssistantShared + +private let log = Logger(subsystem: "com.vellum.vellum-assistant", category: "WakeWordActivationIndicator") + +/// The current state of the wake word indicator overlay. +enum WakeWordIndicatorState { + /// Wake word detected, voice mode is activating. + case activated + /// Voice mode ended, returning to passive wake word listening. + case listening +} + +/// A floating NSPanel that displays a small indicator pill in the top-right +/// area of the screen when the wake word is detected or the app returns to +/// passive listening. Uses AppKit + design system tokens to match the +/// DictationOverlayWindow style and respect light/dark mode. +@MainActor +final class WakeWordActivationWindow { + private var panel: NSPanel? + private var dismissTask: Task? + + private let panelWidth: CGFloat = 240 + private let panelHeight: CGFloat = 36 + private let margin: CGFloat = 16 + + /// Show the activation indicator for a given state. + /// Auto-dismisses after the specified duration. + func show(state: WakeWordIndicatorState, dismissAfter: TimeInterval = 1.5) { + close() + + let contentView = buildContentView(state: state) + + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + panel.isFloatingPanel = true + panel.level = .floating + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = true + panel.contentView = contentView + panel.isMovableByWindowBackground = false + panel.collectionBehavior = [.canJoinAllSpaces, .stationary] + + // Position top-right, slightly below where VoiceTranscriptionWindow appears + if let screen = NSScreen.main { + let screenFrame = screen.visibleFrame + let x = screenFrame.maxX - panelWidth - margin + let y = screenFrame.maxY - panelHeight - margin + panel.setFrameOrigin(NSPoint(x: x, y: y)) + } + + panel.orderFront(nil) + self.panel = panel + + log.debug("Showing wake word indicator: \(String(describing: state))") + + // Auto-dismiss after delay + dismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(dismissAfter * 1_000_000_000)) + guard !Task.isCancelled else { return } + self.close() + } + } + + func close() { + dismissTask?.cancel() + dismissTask = nil + panel?.orderOut(nil) + panel = nil + } + + // MARK: - AppKit Content + + private func buildContentView(state: WakeWordIndicatorState) -> NSView { + let container = WakeWordOverlayBackgroundView() + container.wantsLayer = true + + let dot = makeDot(for: state) + let label = makeLabel(for: state) + + dot.translatesAutoresizingMaskIntoConstraints = false + label.translatesAutoresizingMaskIntoConstraints = false + + container.addSubview(dot) + container.addSubview(label) + + NSLayoutConstraint.activate([ + dot.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16), + dot.centerYAnchor.constraint(equalTo: container.centerYAnchor), + dot.widthAnchor.constraint(equalToConstant: 8), + dot.heightAnchor.constraint(equalToConstant: 8), + + label.leadingAnchor.constraint(equalTo: dot.trailingAnchor, constant: 8), + label.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -16), + label.centerYAnchor.constraint(equalTo: container.centerYAnchor), + ]) + + return container + } + + private func makeDot(for state: WakeWordIndicatorState) -> NSView { + let dot = NSView(frame: NSRect(x: 0, y: 0, width: 8, height: 8)) + dot.wantsLayer = true + let color: Color = state == .activated ? VColor.accent : VColor.success + dot.layer?.backgroundColor = NSColor(color).cgColor + dot.layer?.cornerRadius = 4 + return dot + } + + private func makeLabel(for state: WakeWordIndicatorState) -> NSTextField { + let text: String + switch state { + case .activated: + text = "Activated" + case .listening: + let keyword = UserDefaults.standard.string(forKey: "wakeWordKeyword") ?? "computer" + text = "Listening for \u{201C}\(keyword)\u{201D}" + } + + let field = NSTextField(labelWithString: text) + field.font = NSFont(name: "Inter", size: 11) ?? NSFont.systemFont(ofSize: 11) + field.textColor = NSColor(VColor.textSecondary) + field.lineBreakMode = .byTruncatingTail + field.maximumNumberOfLines = 1 + return field + } +} + +/// Rounded, semi-transparent background using design system tokens. +private class WakeWordOverlayBackgroundView: NSView { + override var wantsUpdateLayer: Bool { true } + + override func updateLayer() { + layer?.backgroundColor = NSColor(VColor.surface).withAlphaComponent(0.95).cgColor + layer?.cornerRadius = VRadius.lg + layer?.borderWidth = 1 + layer?.borderColor = NSColor(VColor.surfaceBorder).cgColor + } +} + +#Preview("Activated") { + ZStack { + VColor.background.ignoresSafeArea() + HStack(spacing: VSpacing.sm) { + Circle() + .fill(VColor.accent) + .frame(width: 8, height: 8) + Text("Activated") + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + } + .padding(.horizontal, VSpacing.lg) + .padding(.vertical, VSpacing.sm) + .background(VColor.surface.opacity(0.95)) + .clipShape(RoundedRectangle(cornerRadius: VRadius.lg)) + .overlay( + RoundedRectangle(cornerRadius: VRadius.lg) + .stroke(VColor.surfaceBorder, lineWidth: 1) + ) + } + .frame(width: 300, height: 80) +} + +#Preview("Listening") { + ZStack { + VColor.background.ignoresSafeArea() + HStack(spacing: VSpacing.sm) { + Circle() + .fill(VColor.success) + .frame(width: 8, height: 8) + Text("Listening for \u{201C}computer\u{201D}") + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + } + .padding(.horizontal, VSpacing.lg) + .padding(.vertical, VSpacing.sm) + .background(VColor.surface.opacity(0.95)) + .clipShape(RoundedRectangle(cornerRadius: VRadius.lg)) + .overlay( + RoundedRectangle(cornerRadius: VRadius.lg) + .stroke(VColor.surfaceBorder, lineWidth: 1) + ) + } + .frame(width: 300, height: 80) +}