diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 99ae0f991..906a269dd 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -24,6 +24,10 @@ let package = Package( .package(path: "../../Swabble"), ], targets: [ + .target( + name: "ObjCExceptionCatcher", + dependencies: [], + publicHeadersPath: "include"), .target( name: "ClawdbotIPC", dependencies: [], @@ -44,6 +48,7 @@ let package = Package( dependencies: [ "ClawdbotIPC", "ClawdbotDiscovery", + "ObjCExceptionCatcher", .product(name: "ClawdbotKit", package: "ClawdbotKit"), .product(name: "ClawdbotChatUI", package: "ClawdbotKit"), .product(name: "ClawdbotProtocol", package: "ClawdbotKit"), diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Clawdbot/AppState.swift index 6ccb83369..fe5645da5 100644 --- a/apps/macos/Sources/Clawdbot/AppState.swift +++ b/apps/macos/Sources/Clawdbot/AppState.swift @@ -316,7 +316,12 @@ final class AppState { } if !self.isPreview { - Task { await VoiceWakeRuntime.shared.refresh(state: self) } + // Delay voice wake startup to give audio subsystem time to initialize at app launch. + // This prevents crashes from AVAudioEngine tap installation when called too early. + Task { + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s delay + await VoiceWakeRuntime.shared.refresh(state: self) + } Task { await TalkModeController.shared.setEnabled(self.talkEnabled) } } diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift index 06ebfb7ae..38d3c2a53 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeRuntime.swift @@ -1,5 +1,6 @@ import AVFoundation import Foundation +import ObjCExceptionCatcher import OSLog import Speech import SwabbleKit @@ -47,6 +48,8 @@ actor VoiceWakeRuntime { private var preDetectTask: Task? private var isStarting: Bool = false private var triggerOnlyTask: Task? + private var startRetryCount: Int = 0 + private var pendingRetryTask: Task? // Tunables // Silence threshold once we've captured user speech (post-trigger). @@ -174,17 +177,37 @@ actor VoiceWakeRuntime { code: 1, userInfo: [NSLocalizedDescriptionKey: "No audio input available"]) } + + // Prepare the engine first to ensure the audio graph is properly configured. + audioEngine.prepare() + + // Remove any existing tap before installing a new one. input.removeTap(onBus: 0) - input.installTap(onBus: 0, bufferSize: 2048, format: format) { [weak self, weak request] buffer, _ in - request?.append(buffer) - guard let rms = Self.rmsLevel(buffer: buffer) else { return } - Task.detached { [weak self] in - await self?.noteAudioLevel(rms: rms) - await self?.noteAudioTap(rms: rms) - } + + // Use the ObjC exception catcher to safely install the tap. + // AVAudioNode.installTap can throw ObjC exceptions that Swift cannot catch, + // particularly when the audio subsystem isn't fully initialized at app launch. + var tapError: NSError? + let tapInstalled = CBTryInstallTap( + input, 0, 2048, nil, + { [weak self, weak request] buffer, _ in + request?.append(buffer) + guard let rms = Self.rmsLevel(buffer: buffer) else { return } + Task.detached { [weak self] in + await self?.noteAudioLevel(rms: rms) + await self?.noteAudioTap(rms: rms) + } + }, + &tapError) + + guard tapInstalled else { + let msg = tapError?.localizedDescription ?? "Unknown tap installation error" + throw NSError( + domain: "VoiceWakeRuntime", + code: 2, + userInfo: [NSLocalizedDescriptionKey: msg]) } - audioEngine.prepare() try audioEngine.start() self.currentConfig = config @@ -218,9 +241,35 @@ actor VoiceWakeRuntime { "locale": config.localeID ?? "", "micID": config.micID ?? "", ]) + // Reset retry counter on successful start. + self.startRetryCount = 0 } catch { self.logger.error("voicewake runtime failed to start: \(error.localizedDescription, privacy: .public)") self.stop() + // Schedule a retry with exponential backoff (max 5 retries, max 16s delay). + self.scheduleStartRetry(config: config) + } + } + + /// Schedules a retry to start the voice wake runtime with exponential backoff. + private func scheduleStartRetry(config: RuntimeConfig) { + let maxRetries = 5 + guard self.startRetryCount < maxRetries else { + self.logger.warning("voicewake runtime: max retries (\(maxRetries)) reached, giving up") + self.startRetryCount = 0 + return + } + + self.pendingRetryTask?.cancel() + self.startRetryCount += 1 + // Exponential backoff: 1s, 2s, 4s, 8s, 16s + let delaySeconds = min(16.0, pow(2.0, Double(self.startRetryCount - 1))) + self.logger.info("voicewake runtime: scheduling retry #\(self.startRetryCount) in \(delaySeconds)s") + + self.pendingRetryTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delaySeconds * 1_000_000_000)) + guard let self, !Task.isCancelled else { return } + await self.start(with: config) } } @@ -228,6 +277,9 @@ actor VoiceWakeRuntime { if cancelScheduledRestart { self.scheduledRestartTask?.cancel() self.scheduledRestartTask = nil + self.pendingRetryTask?.cancel() + self.pendingRetryTask = nil + self.startRetryCount = 0 } self.captureTask?.cancel() self.captureTask = nil diff --git a/apps/macos/Sources/ObjCExceptionCatcher/ObjCExceptionCatcher.m b/apps/macos/Sources/ObjCExceptionCatcher/ObjCExceptionCatcher.m new file mode 100644 index 000000000..12007cc90 --- /dev/null +++ b/apps/macos/Sources/ObjCExceptionCatcher/ObjCExceptionCatcher.m @@ -0,0 +1,20 @@ +#import "include/ObjCExceptionCatcher.h" + +BOOL CBTryInstallTap(AVAudioNode *node, AVAudioNodeBus bus, AVAudioFrameCount bufferSize, + AVAudioFormat * _Nullable format, AVAudioNodeTapBlock block, NSError **outError) { + @try { + [node installTapOnBus:bus bufferSize:bufferSize format:format block:block]; + return YES; + } + @catch (NSException *exception) { + if (outError) { + *outError = [NSError errorWithDomain:@"ObjCExceptionCatcher" + code:1 + userInfo:@{ + NSLocalizedDescriptionKey: exception.reason ?: @"Unknown exception", + @"NSException": exception + }]; + } + return NO; + } +} diff --git a/apps/macos/Sources/ObjCExceptionCatcher/include/ObjCExceptionCatcher.h b/apps/macos/Sources/ObjCExceptionCatcher/include/ObjCExceptionCatcher.h new file mode 100644 index 000000000..73413d047 --- /dev/null +++ b/apps/macos/Sources/ObjCExceptionCatcher/include/ObjCExceptionCatcher.h @@ -0,0 +1,17 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Safely tries to install a tap on an AVAudioNode, catching any Objective-C exceptions. +/// @param node The audio node to install the tap on. +/// @param bus The bus number to tap. +/// @param bufferSize The size of the audio buffer. +/// @param format The audio format (pass nil to use the node's native format). +/// @param block The block to call with audio buffers. +/// @param outError If non-nil and an exception occurs, contains error info about the exception. +/// @return YES if the tap was installed successfully, NO if an exception was thrown. +BOOL CBTryInstallTap(AVAudioNode *node, AVAudioNodeBus bus, AVAudioFrameCount bufferSize, + AVAudioFormat * _Nullable format, AVAudioNodeTapBlock block, NSError * _Nullable * _Nullable outError); + +NS_ASSUME_NONNULL_END diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index 19a232f5c..82b2d06e3 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a +f0a3035f3a5b211ef6b494227eb9195535cd90aae4d22834810185d0191a5c98