diff --git a/clients/macos/vellum-assistant/Features/Voice/WakeWord/PorcupineWakeWordEngine.swift b/clients/macos/vellum-assistant/Features/Voice/WakeWord/PorcupineWakeWordEngine.swift index 74652a1f0cc..50eeeefb3a8 100644 --- a/clients/macos/vellum-assistant/Features/Voice/WakeWord/PorcupineWakeWordEngine.swift +++ b/clients/macos/vellum-assistant/Features/Voice/WakeWord/PorcupineWakeWordEngine.swift @@ -3,12 +3,12 @@ import os private let log = Logger(subsystem: "com.vellum.vellum-assistant", category: "PorcupineWakeWordEngine") -/// Placeholder wake word engine backed by Porcupine. +/// Wake word engine backed by Porcupine's C SDK via `PorcupineBinding`. /// -/// Currently a stub that conforms to `WakeWordEngine` so that -/// `AlwaysOnAudioMonitor` and `WakeWordCoordinator` can be wired -/// end-to-end. Swap in real Porcupine SDK calls when the dependency -/// is integrated. +/// Loads `libpv_porcupine.dylib` at runtime, resolves model and keyword +/// files from the app bundle, and processes 16 kHz Int16 PCM audio in +/// 512-sample frames. Thread-safe: `start()` and `stop()` are called from +/// the main thread; `processAudioFrame(_:)` runs on the audio thread. final class PorcupineWakeWordEngine: WakeWordEngine { var onWakeWordDetected: ((Float) -> Void)? @@ -18,24 +18,149 @@ final class PorcupineWakeWordEngine: WakeWordEngine { /// Detection sensitivity (0.0 = least sensitive, 1.0 = most sensitive). let sensitivity: Float - init(sensitivity: Float = 0.5) { + /// Built-in keyword name (e.g. "computer") or absolute path to a custom .ppn file. + let keyword: String + + private var binding: PorcupineBinding? + private var frameBuffer: [Int16] = [] + private let frameLength: Int = 512 + + /// Guards `binding` and `frameBuffer` for thread safety between + /// the main thread (`start`/`stop`) and the audio thread (`processAudioFrame`). + private var lock = os_unfair_lock() + + /// Whether an error has already been logged during `processAudioFrame` to + /// avoid flooding the log on every frame. + private var hasLoggedProcessError = false + + init(sensitivity: Float = 0.5, keyword: String = "computer") { self.sensitivity = sensitivity + self.keyword = keyword } + // MARK: - WakeWordEngine + func start() throws { guard !isRunning else { return } + + // 1. Access key + guard let accessKey = APIKeyManager.shared.getAPIKey(provider: "picovoice") else { + log.warning("Picovoice access key not found in keychain — wake word detection disabled") + return + } + + // 2. Dylib path + guard let frameworksPath = Bundle.main.privateFrameworksPath else { + log.warning("Bundle.main.privateFrameworksPath is nil — wake word detection disabled") + return + } + let dylibPath = (frameworksPath as NSString).appendingPathComponent("libpv_porcupine.dylib") + guard FileManager.default.fileExists(atPath: dylibPath) else { + log.warning("libpv_porcupine.dylib not found at \(dylibPath) — wake word detection disabled") + return + } + + // 3. Create binding (loads dylib, resolves symbols) + let newBinding: PorcupineBinding + do { + newBinding = try PorcupineBinding(dylibPath: dylibPath) + } catch { + log.error("Failed to load PorcupineBinding: \(error)") + return + } + + // 4. Model path + guard let resourceURL = Bundle.main.resourceURL else { + log.error("Bundle.main.resourceURL is nil — cannot locate Porcupine model") + return + } + let modelPath = resourceURL.appendingPathComponent("porcupine_params.pv").path + guard FileManager.default.fileExists(atPath: modelPath) else { + log.error("Porcupine model not found at \(modelPath)") + return + } + + // 5. Keyword path + let keywordPath: String + let keywordDir = resourceURL.appendingPathComponent("porcupine-keywords") + let builtinPath = keywordDir.appendingPathComponent(keyword.lowercased() + "_mac.ppn").path + if FileManager.default.fileExists(atPath: builtinPath) { + keywordPath = builtinPath + } else if keyword.hasPrefix("/") && FileManager.default.fileExists(atPath: keyword) { + // Treat keyword as an absolute path to a custom .ppn file + keywordPath = keyword + } else { + log.error("Keyword file not found: tried \(builtinPath) and absolute path \(keyword)") + return + } + + // 6. Initialize Porcupine engine + do { + try newBinding.initialize( + accessKey: accessKey, + modelPath: modelPath, + keywordPaths: [keywordPath], + sensitivities: [sensitivity] + ) + } catch { + log.error("Failed to initialize Porcupine engine: \(error)") + return + } + + // 7. Commit state + withLock { + self.binding = newBinding + self.frameBuffer = [] + self.hasLoggedProcessError = false + } isRunning = true - log.info("PorcupineWakeWordEngine started (sensitivity: \(self.sensitivity))") + log.info("PorcupineWakeWordEngine started (keyword: \(self.keyword), sensitivity: \(self.sensitivity), version: \(newBinding.version))") } func stop() { guard isRunning else { return } + withLock { + binding?.delete() + binding = nil + frameBuffer = [] + } isRunning = false log.info("PorcupineWakeWordEngine stopped") } - /// Feed a buffer of 16-bit PCM audio samples for wake word detection. + // MARK: - Audio processing (audio thread) + func processAudioFrame(_ frame: [Int16]) { - // Stub — real implementation will call Porcupine's process() here + withLock { + guard binding != nil else { return } + frameBuffer.append(contentsOf: frame) + + while frameBuffer.count >= frameLength { + let chunk = Array(frameBuffer.prefix(frameLength)) + frameBuffer.removeFirst(frameLength) + + do { + let keywordIndex = try binding!.process(pcm: chunk) + if keywordIndex >= 0 { + onWakeWordDetected?(1.0) + } + } catch { + if !hasLoggedProcessError { + hasLoggedProcessError = true + log.error("Porcupine process error (further errors suppressed): \(error)") + } + // Stop processing further frames this call + return + } + } + } + } + + // MARK: - Lock helpers + + private func withLock(_ body: () -> T) -> T { + os_unfair_lock_lock(&lock) + defer { os_unfair_lock_unlock(&lock) } + return body() } }