Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand All @@ -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
Comment thread
alex-nork marked this conversation as resolved.

/// 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
}
Comment thread
alex-nork marked this conversation as resolved.

// 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
}
}
}
Comment thread
alex-nork marked this conversation as resolved.
}

// MARK: - Lock helpers

private func withLock<T>(_ body: () -> T) -> T {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
return body()
}
}