2025-12-12 21:18:54 +00:00
|
|
|
|
import AVFAudio
|
|
|
|
|
|
import Foundation
|
2025-12-14 05:04:58 +00:00
|
|
|
|
import Observation
|
2026-02-08 18:08:13 +01:00
|
|
|
|
import OpenClawKit
|
2025-12-12 21:18:54 +00:00
|
|
|
|
import Speech
|
2025-12-23 01:30:40 +01:00
|
|
|
|
import SwabbleKit
|
2025-12-12 21:18:54 +00:00
|
|
|
|
|
2025-12-13 19:14:36 +00:00
|
|
|
|
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
|
2025-12-13 12:28:34 +00:00
|
|
|
|
{ buffer, _ in
|
|
|
|
|
|
// This callback is invoked on a realtime audio thread/queue. Keep it tiny and nonisolated.
|
|
|
|
|
|
queue.enqueueCopy(of: buffer)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-13 04:28:12 +00:00
|
|
|
|
private final class AudioBufferQueue: @unchecked Sendable {
|
|
|
|
|
|
private let lock = NSLock()
|
|
|
|
|
|
private var buffers: [AVAudioPCMBuffer] = []
|
|
|
|
|
|
|
|
|
|
|
|
func enqueueCopy(of buffer: AVAudioPCMBuffer) {
|
|
|
|
|
|
guard let copy = buffer.deepCopy() else { return }
|
|
|
|
|
|
self.lock.lock()
|
|
|
|
|
|
self.buffers.append(copy)
|
|
|
|
|
|
self.lock.unlock()
|
2025-12-13 00:26:05 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-13 04:28:12 +00:00
|
|
|
|
func drain() -> [AVAudioPCMBuffer] {
|
|
|
|
|
|
self.lock.lock()
|
|
|
|
|
|
let drained = self.buffers
|
|
|
|
|
|
self.buffers.removeAll(keepingCapacity: true)
|
|
|
|
|
|
self.lock.unlock()
|
|
|
|
|
|
return drained
|
|
|
|
|
|
}
|
2025-12-13 00:14:52 +00:00
|
|
|
|
|
2025-12-13 04:28:12 +00:00
|
|
|
|
func clear() {
|
|
|
|
|
|
self.lock.lock()
|
|
|
|
|
|
self.buffers.removeAll(keepingCapacity: false)
|
|
|
|
|
|
self.lock.unlock()
|
2025-12-13 00:14:52 +00:00
|
|
|
|
}
|
2025-12-13 04:28:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-13 10:59:48 +00:00
|
|
|
|
extension AVAudioPCMBuffer {
|
|
|
|
|
|
fileprivate func deepCopy() -> AVAudioPCMBuffer? {
|
2025-12-13 04:28:12 +00:00
|
|
|
|
let format = self.format
|
|
|
|
|
|
let frameLength = self.frameLength
|
|
|
|
|
|
guard let copy = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameLength) else {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
copy.frameLength = frameLength
|
|
|
|
|
|
|
|
|
|
|
|
if let src = self.floatChannelData, let dst = copy.floatChannelData {
|
|
|
|
|
|
let channels = Int(format.channelCount)
|
|
|
|
|
|
let frames = Int(frameLength)
|
|
|
|
|
|
for ch in 0..<channels {
|
2025-12-13 12:28:34 +00:00
|
|
|
|
dst[ch].update(from: src[ch], count: frames)
|
2025-12-13 04:28:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
return copy
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if let src = self.int16ChannelData, let dst = copy.int16ChannelData {
|
|
|
|
|
|
let channels = Int(format.channelCount)
|
|
|
|
|
|
let frames = Int(frameLength)
|
|
|
|
|
|
for ch in 0..<channels {
|
2025-12-13 12:28:34 +00:00
|
|
|
|
dst[ch].update(from: src[ch], count: frames)
|
2025-12-13 04:28:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
return copy
|
|
|
|
|
|
}
|
2025-12-13 00:14:52 +00:00
|
|
|
|
|
2025-12-13 04:28:12 +00:00
|
|
|
|
if let src = self.int32ChannelData, let dst = copy.int32ChannelData {
|
|
|
|
|
|
let channels = Int(format.channelCount)
|
|
|
|
|
|
let frames = Int(frameLength)
|
|
|
|
|
|
for ch in 0..<channels {
|
2025-12-13 12:28:34 +00:00
|
|
|
|
dst[ch].update(from: src[ch], count: frames)
|
2025-12-13 04:28:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
return copy
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
2025-12-13 00:14:52 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-12 21:18:54 +00:00
|
|
|
|
@MainActor
|
2025-12-14 05:04:58 +00:00
|
|
|
|
@Observable
|
|
|
|
|
|
final class VoiceWakeManager: NSObject {
|
|
|
|
|
|
var isEnabled: Bool = false
|
|
|
|
|
|
var isListening: Bool = false
|
|
|
|
|
|
var statusText: String = "Off"
|
|
|
|
|
|
var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
|
|
|
|
|
|
var lastTriggeredCommand: String?
|
2025-12-12 21:18:54 +00:00
|
|
|
|
|
|
|
|
|
|
private let audioEngine = AVAudioEngine()
|
|
|
|
|
|
private var speechRecognizer: SFSpeechRecognizer?
|
|
|
|
|
|
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
|
|
|
|
|
private var recognitionTask: SFSpeechRecognitionTask?
|
2025-12-13 04:28:12 +00:00
|
|
|
|
private var tapQueue: AudioBufferQueue?
|
|
|
|
|
|
private var tapDrainTask: Task<Void, Never>?
|
2025-12-12 21:18:54 +00:00
|
|
|
|
|
|
|
|
|
|
private var lastDispatched: String?
|
|
|
|
|
|
private var onCommand: (@Sendable (String) async -> Void)?
|
2025-12-20 01:48:14 +01:00
|
|
|
|
private var userDefaultsObserver: NSObjectProtocol?
|
2026-02-08 18:08:13 +01:00
|
|
|
|
private var suppressedByTalk: Bool = false
|
2025-12-12 21:18:54 +00:00
|
|
|
|
|
2025-12-13 23:49:18 +00:00
|
|
|
|
override init() {
|
|
|
|
|
|
super.init()
|
|
|
|
|
|
self.triggerWords = VoiceWakePreferences.loadTriggerWords()
|
2025-12-14 01:09:40 +00:00
|
|
|
|
self.userDefaultsObserver = NotificationCenter.default.addObserver(
|
|
|
|
|
|
forName: UserDefaults.didChangeNotification,
|
|
|
|
|
|
object: UserDefaults.standard,
|
|
|
|
|
|
queue: .main,
|
|
|
|
|
|
using: { [weak self] _ in
|
|
|
|
|
|
Task { @MainActor in
|
|
|
|
|
|
self?.handleUserDefaultsDidChange()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2025-12-13 23:49:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-20 01:48:14 +01:00
|
|
|
|
@MainActor deinit {
|
2025-12-14 01:09:40 +00:00
|
|
|
|
if let userDefaultsObserver = self.userDefaultsObserver {
|
|
|
|
|
|
NotificationCenter.default.removeObserver(userDefaultsObserver)
|
|
|
|
|
|
}
|
2025-12-13 23:49:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var activeTriggerWords: [String] {
|
|
|
|
|
|
VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 01:09:40 +00:00
|
|
|
|
private func handleUserDefaultsDidChange() {
|
2025-12-13 23:49:18 +00:00
|
|
|
|
let updated = VoiceWakePreferences.loadTriggerWords()
|
2025-12-14 01:09:40 +00:00
|
|
|
|
if updated != self.triggerWords {
|
|
|
|
|
|
self.triggerWords = updated
|
2025-12-13 23:49:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-12 21:18:54 +00:00
|
|
|
|
func configure(onCommand: @escaping @Sendable (String) async -> Void) {
|
|
|
|
|
|
self.onCommand = onCommand
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func setEnabled(_ enabled: Bool) {
|
|
|
|
|
|
self.isEnabled = enabled
|
|
|
|
|
|
if enabled {
|
|
|
|
|
|
Task { await self.start() }
|
|
|
|
|
|
} else {
|
|
|
|
|
|
self.stop()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 18:08:13 +01:00
|
|
|
|
func setSuppressedByTalk(_ suppressed: Bool) {
|
|
|
|
|
|
self.suppressedByTalk = suppressed
|
|
|
|
|
|
if suppressed {
|
|
|
|
|
|
_ = self.suspendForExternalAudioCapture()
|
|
|
|
|
|
if self.isEnabled {
|
|
|
|
|
|
self.statusText = "Paused"
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if self.isEnabled {
|
|
|
|
|
|
Task { await self.start() }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-12 21:18:54 +00:00
|
|
|
|
func start() async {
|
|
|
|
|
|
guard self.isEnabled else { return }
|
|
|
|
|
|
if self.isListening { return }
|
2026-02-08 18:08:13 +01:00
|
|
|
|
guard !self.suppressedByTalk else {
|
|
|
|
|
|
self.isListening = false
|
|
|
|
|
|
self.statusText = "Paused"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-12-12 21:18:54 +00:00
|
|
|
|
|
2025-12-13 20:52:31 +00:00
|
|
|
|
if ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil ||
|
|
|
|
|
|
ProcessInfo.processInfo.environment["SIMULATOR_UDID"] != nil
|
|
|
|
|
|
{
|
|
|
|
|
|
// The iOS Simulator’s audio stack is unreliable for long-running microphone capture.
|
|
|
|
|
|
// (We’ve observed CoreAudio deadlocks after TCC permission prompts.)
|
|
|
|
|
|
self.isListening = false
|
|
|
|
|
|
self.statusText = "Voice Wake isn’t supported on Simulator"
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-12 21:18:54 +00:00
|
|
|
|
self.statusText = "Requesting permissions…"
|
|
|
|
|
|
|
|
|
|
|
|
let micOk = await Self.requestMicrophonePermission()
|
|
|
|
|
|
guard micOk else {
|
2026-02-08 18:08:13 +01:00
|
|
|
|
self.statusText = Self.permissionMessage(
|
|
|
|
|
|
kind: "Microphone",
|
|
|
|
|
|
status: AVAudioSession.sharedInstance().recordPermission)
|
2025-12-12 21:18:54 +00:00
|
|
|
|
self.isListening = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let speechOk = await Self.requestSpeechPermission()
|
|
|
|
|
|
guard speechOk else {
|
2026-02-08 18:08:13 +01:00
|
|
|
|
self.statusText = Self.permissionMessage(
|
|
|
|
|
|
kind: "Speech recognition",
|
|
|
|
|
|
status: SFSpeechRecognizer.authorizationStatus())
|
2025-12-12 21:18:54 +00:00
|
|
|
|
self.isListening = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
self.speechRecognizer = SFSpeechRecognizer()
|
|
|
|
|
|
guard self.speechRecognizer != nil else {
|
|
|
|
|
|
self.statusText = "Speech recognizer unavailable"
|
|
|
|
|
|
self.isListening = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
|
try Self.configureAudioSession()
|
|
|
|
|
|
try self.startRecognition()
|
|
|
|
|
|
self.isListening = true
|
|
|
|
|
|
self.statusText = "Listening"
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
self.isListening = false
|
|
|
|
|
|
self.statusText = "Start failed: \(error.localizedDescription)"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func stop() {
|
|
|
|
|
|
self.isEnabled = false
|
|
|
|
|
|
self.isListening = false
|
|
|
|
|
|
self.statusText = "Off"
|
|
|
|
|
|
|
2025-12-13 04:28:12 +00:00
|
|
|
|
self.tapDrainTask?.cancel()
|
|
|
|
|
|
self.tapDrainTask = nil
|
|
|
|
|
|
self.tapQueue?.clear()
|
|
|
|
|
|
self.tapQueue = nil
|
|
|
|
|
|
|
2025-12-12 21:18:54 +00:00
|
|
|
|
self.recognitionTask?.cancel()
|
|
|
|
|
|
self.recognitionTask = nil
|
|
|
|
|
|
self.recognitionRequest = nil
|
|
|
|
|
|
|
|
|
|
|
|
if self.audioEngine.isRunning {
|
|
|
|
|
|
self.audioEngine.stop()
|
|
|
|
|
|
self.audioEngine.inputNode.removeTap(onBus: 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 00:48:58 +00:00
|
|
|
|
/// Temporarily releases the microphone so other subsystems (e.g. camera video capture) can record audio.
|
|
|
|
|
|
/// Returns `true` when listening was active and was suspended.
|
|
|
|
|
|
func suspendForExternalAudioCapture() -> Bool {
|
|
|
|
|
|
guard self.isEnabled, self.isListening else { return false }
|
|
|
|
|
|
|
|
|
|
|
|
self.isListening = false
|
|
|
|
|
|
self.statusText = "Paused"
|
|
|
|
|
|
|
|
|
|
|
|
self.tapDrainTask?.cancel()
|
|
|
|
|
|
self.tapDrainTask = nil
|
|
|
|
|
|
self.tapQueue?.clear()
|
|
|
|
|
|
self.tapQueue = nil
|
|
|
|
|
|
|
|
|
|
|
|
self.recognitionTask?.cancel()
|
|
|
|
|
|
self.recognitionTask = nil
|
|
|
|
|
|
self.recognitionRequest = nil
|
|
|
|
|
|
|
|
|
|
|
|
if self.audioEngine.isRunning {
|
|
|
|
|
|
self.audioEngine.stop()
|
|
|
|
|
|
self.audioEngine.inputNode.removeTap(onBus: 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func resumeAfterExternalAudioCapture(wasSuspended: Bool) {
|
|
|
|
|
|
guard wasSuspended else { return }
|
|
|
|
|
|
Task { await self.start() }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-12 21:18:54 +00:00
|
|
|
|
private func startRecognition() throws {
|
|
|
|
|
|
self.recognitionTask?.cancel()
|
|
|
|
|
|
self.recognitionTask = nil
|
2025-12-13 04:28:12 +00:00
|
|
|
|
self.tapDrainTask?.cancel()
|
|
|
|
|
|
self.tapDrainTask = nil
|
|
|
|
|
|
self.tapQueue?.clear()
|
|
|
|
|
|
self.tapQueue = nil
|
2025-12-12 21:18:54 +00:00
|
|
|
|
|
|
|
|
|
|
let request = SFSpeechAudioBufferRecognitionRequest()
|
|
|
|
|
|
request.shouldReportPartialResults = true
|
|
|
|
|
|
self.recognitionRequest = request
|
|
|
|
|
|
|
|
|
|
|
|
let inputNode = self.audioEngine.inputNode
|
|
|
|
|
|
inputNode.removeTap(onBus: 0)
|
|
|
|
|
|
|
|
|
|
|
|
let recordingFormat = inputNode.outputFormat(forBus: 0)
|
2025-12-13 04:28:12 +00:00
|
|
|
|
|
|
|
|
|
|
let queue = AudioBufferQueue()
|
|
|
|
|
|
self.tapQueue = queue
|
2025-12-13 19:14:36 +00:00
|
|
|
|
let tapBlock: @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void = makeAudioTapEnqueueCallback(queue: queue)
|
2025-12-13 12:28:34 +00:00
|
|
|
|
inputNode.installTap(
|
|
|
|
|
|
onBus: 0,
|
|
|
|
|
|
bufferSize: 1024,
|
|
|
|
|
|
format: recordingFormat,
|
2025-12-13 19:14:36 +00:00
|
|
|
|
block: tapBlock)
|
2025-12-12 21:18:54 +00:00
|
|
|
|
|
|
|
|
|
|
self.audioEngine.prepare()
|
|
|
|
|
|
try self.audioEngine.start()
|
|
|
|
|
|
|
2025-12-13 01:18:48 +00:00
|
|
|
|
let handler = self.makeRecognitionResultHandler()
|
|
|
|
|
|
self.recognitionTask = self.speechRecognizer?.recognitionTask(with: request, resultHandler: handler)
|
2025-12-13 04:28:12 +00:00
|
|
|
|
|
|
|
|
|
|
self.tapDrainTask = Task { [weak self] in
|
|
|
|
|
|
guard let self, let queue = self.tapQueue else { return }
|
|
|
|
|
|
while !Task.isCancelled {
|
|
|
|
|
|
try? await Task.sleep(nanoseconds: 40_000_000)
|
|
|
|
|
|
let drained = queue.drain()
|
|
|
|
|
|
if drained.isEmpty { continue }
|
|
|
|
|
|
for buf in drained {
|
|
|
|
|
|
request.append(buf)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-13 01:18:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
|
|
|
|
|
|
{ [weak self] result, error in
|
|
|
|
|
|
let transcript = result?.bestTranscription.formattedString
|
2025-12-23 01:30:40 +01:00
|
|
|
|
let segments = result.flatMap { result in
|
|
|
|
|
|
transcript.map { WakeWordSpeechSegments.from(transcription: result.bestTranscription, transcript: $0) }
|
|
|
|
|
|
} ?? []
|
2025-12-13 01:18:48 +00:00
|
|
|
|
let errorText = error?.localizedDescription
|
|
|
|
|
|
|
|
|
|
|
|
Task { @MainActor in
|
2025-12-23 01:30:40 +01:00
|
|
|
|
self?.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText)
|
2025-12-12 21:18:54 +00:00
|
|
|
|
}
|
2025-12-13 01:18:48 +00:00
|
|
|
|
}
|
2025-12-12 21:59:04 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 01:30:40 +01:00
|
|
|
|
private func handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) {
|
2025-12-13 01:18:48 +00:00
|
|
|
|
if let errorText {
|
|
|
|
|
|
self.statusText = "Recognizer error: \(errorText)"
|
2025-12-12 21:59:04 +00:00
|
|
|
|
self.isListening = false
|
|
|
|
|
|
|
|
|
|
|
|
let shouldRestart = self.isEnabled
|
|
|
|
|
|
if shouldRestart {
|
|
|
|
|
|
Task {
|
|
|
|
|
|
try? await Task.sleep(nanoseconds: 700_000_000)
|
|
|
|
|
|
await self.start()
|
2025-12-12 21:18:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-12 21:59:04 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-13 01:18:48 +00:00
|
|
|
|
guard let transcript else { return }
|
2025-12-23 01:30:40 +01:00
|
|
|
|
guard let cmd = self.extractCommand(from: transcript, segments: segments) else { return }
|
2025-12-12 21:59:04 +00:00
|
|
|
|
|
|
|
|
|
|
if cmd == self.lastDispatched { return }
|
|
|
|
|
|
self.lastDispatched = cmd
|
2025-12-14 03:00:45 +00:00
|
|
|
|
self.lastTriggeredCommand = cmd
|
2025-12-12 21:59:04 +00:00
|
|
|
|
self.statusText = "Triggered"
|
|
|
|
|
|
|
|
|
|
|
|
Task { [weak self] in
|
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
|
await self.onCommand?(cmd)
|
|
|
|
|
|
await self.startIfEnabled()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func startIfEnabled() async {
|
|
|
|
|
|
let shouldRestart = self.isEnabled
|
|
|
|
|
|
if shouldRestart {
|
|
|
|
|
|
await self.start()
|
2025-12-12 21:18:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 01:30:40 +01:00
|
|
|
|
private func extractCommand(from transcript: String, segments: [WakeWordSegment]) -> String? {
|
|
|
|
|
|
Self.extractCommand(from: transcript, segments: segments, triggers: self.activeTriggerWords)
|
2025-12-13 23:49:18 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-23 01:30:40 +01:00
|
|
|
|
nonisolated static func extractCommand(
|
|
|
|
|
|
from transcript: String,
|
|
|
|
|
|
segments: [WakeWordSegment],
|
|
|
|
|
|
triggers: [String],
|
|
|
|
|
|
minPostTriggerGap: TimeInterval = 0.45) -> String?
|
|
|
|
|
|
{
|
|
|
|
|
|
let config = WakeWordGateConfig(triggers: triggers, minPostTriggerGap: minPostTriggerGap)
|
|
|
|
|
|
return WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command
|
2025-12-12 21:18:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static func configureAudioSession() throws {
|
|
|
|
|
|
let session = AVAudioSession.sharedInstance()
|
|
|
|
|
|
try session.setCategory(.playAndRecord, mode: .measurement, options: [
|
|
|
|
|
|
.duckOthers,
|
|
|
|
|
|
.mixWithOthers,
|
2025-12-12 21:59:04 +00:00
|
|
|
|
.allowBluetoothHFP,
|
2025-12-12 21:18:54 +00:00
|
|
|
|
.defaultToSpeaker,
|
|
|
|
|
|
])
|
|
|
|
|
|
try session.setActive(true, options: [])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-12 23:06:17 +00:00
|
|
|
|
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
2026-02-08 18:08:13 +01:00
|
|
|
|
let session = AVAudioSession.sharedInstance()
|
|
|
|
|
|
switch session.recordPermission {
|
|
|
|
|
|
case .granted:
|
|
|
|
|
|
return true
|
|
|
|
|
|
case .denied:
|
|
|
|
|
|
return false
|
|
|
|
|
|
case .undetermined:
|
|
|
|
|
|
break
|
|
|
|
|
|
@unknown default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await self.requestPermissionWithTimeout { completion in
|
|
|
|
|
|
AVAudioSession.sharedInstance().requestRecordPermission { ok in
|
|
|
|
|
|
completion(ok)
|
2025-12-12 21:18:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-12 23:06:17 +00:00
|
|
|
|
private nonisolated static func requestSpeechPermission() async -> Bool {
|
2026-02-08 18:08:13 +01:00
|
|
|
|
let status = SFSpeechRecognizer.authorizationStatus()
|
|
|
|
|
|
switch status {
|
|
|
|
|
|
case .authorized:
|
|
|
|
|
|
return true
|
|
|
|
|
|
case .denied, .restricted:
|
|
|
|
|
|
return false
|
|
|
|
|
|
case .notDetermined:
|
|
|
|
|
|
break
|
|
|
|
|
|
@unknown default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await self.requestPermissionWithTimeout { completion in
|
|
|
|
|
|
SFSpeechRecognizer.requestAuthorization { authStatus in
|
|
|
|
|
|
completion(authStatus == .authorized)
|
2025-12-12 21:18:54 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-08 18:08:13 +01:00
|
|
|
|
|
|
|
|
|
|
private nonisolated static func requestPermissionWithTimeout(
|
|
|
|
|
|
_ operation: @escaping @Sendable (@escaping (Bool) -> Void) -> Void) async -> Bool
|
|
|
|
|
|
{
|
|
|
|
|
|
do {
|
|
|
|
|
|
return try await AsyncTimeout.withTimeout(
|
|
|
|
|
|
seconds: 8,
|
|
|
|
|
|
onTimeout: { NSError(domain: "VoiceWake", code: 6, userInfo: [
|
|
|
|
|
|
NSLocalizedDescriptionKey: "permission request timed out",
|
|
|
|
|
|
]) },
|
|
|
|
|
|
operation: {
|
|
|
|
|
|
await withCheckedContinuation(isolation: nil) { cont in
|
|
|
|
|
|
Task { @MainActor in
|
|
|
|
|
|
operation { ok in
|
|
|
|
|
|
cont.resume(returning: ok)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static func permissionMessage(
|
|
|
|
|
|
kind: String,
|
|
|
|
|
|
status: AVAudioSession.RecordPermission) -> String
|
|
|
|
|
|
{
|
|
|
|
|
|
switch status {
|
|
|
|
|
|
case .denied:
|
|
|
|
|
|
return "\(kind) permission denied"
|
|
|
|
|
|
case .undetermined:
|
|
|
|
|
|
return "\(kind) permission not granted"
|
|
|
|
|
|
case .granted:
|
|
|
|
|
|
return "\(kind) permission denied"
|
|
|
|
|
|
@unknown default:
|
|
|
|
|
|
return "\(kind) permission denied"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static func permissionMessage(
|
|
|
|
|
|
kind: String,
|
|
|
|
|
|
status: SFSpeechRecognizerAuthorizationStatus) -> String
|
|
|
|
|
|
{
|
|
|
|
|
|
switch status {
|
|
|
|
|
|
case .denied:
|
|
|
|
|
|
return "\(kind) permission denied"
|
|
|
|
|
|
case .restricted:
|
|
|
|
|
|
return "\(kind) permission restricted"
|
|
|
|
|
|
case .notDetermined:
|
|
|
|
|
|
return "\(kind) permission not granted"
|
|
|
|
|
|
case .authorized:
|
|
|
|
|
|
return "\(kind) permission denied"
|
|
|
|
|
|
@unknown default:
|
|
|
|
|
|
return "\(kind) permission denied"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-12 21:18:54 +00:00
|
|
|
|
}
|
2025-12-24 20:00:45 +01:00
|
|
|
|
|
|
|
|
|
|
#if DEBUG
|
|
|
|
|
|
extension VoiceWakeManager {
|
|
|
|
|
|
func _test_handleRecognitionCallback(transcript: String?, segments: [WakeWordSegment], errorText: String?) {
|
|
|
|
|
|
self.handleRecognitionCallback(transcript: transcript, segments: segments, errorText: errorText)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
#endif
|