From 46aa3205e4b921334feb42358c848d543ce0c738 Mon Sep 17 00:00:00 2001 From: liji Date: Tue, 27 Jan 2026 16:40:59 +0800 Subject: [PATCH] feat(voice-wake): integrate ObjCExceptionCatcher for safe audio tap installation - Added ObjCExceptionCatcher to handle Objective-C exceptions during AVAudioNode tap installation, preventing crashes when the audio subsystem is not fully initialized. - Introduced a 0.5s delay before starting the voice wake runtime to ensure the audio subsystem is ready, reducing the risk of AVAudioEngine errors. - Updated VoiceWakeRuntime to utilize the new exception catcher and implemented retry logic for starting the runtime with exponential backoff. This enhances the stability of the voice wake feature during app launch. --- apps/macos/Package.swift | 5 ++ apps/macos/Sources/Clawdbot/AppState.swift | 7 +- .../Sources/Clawdbot/VoiceWakeRuntime.swift | 68 ++++++++++++++++--- .../ObjCExceptionCatcher.m | 20 ++++++ .../include/ObjCExceptionCatcher.h | 17 +++++ src/canvas-host/a2ui/.bundle.hash | 2 +- 6 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 apps/macos/Sources/ObjCExceptionCatcher/ObjCExceptionCatcher.m create mode 100644 apps/macos/Sources/ObjCExceptionCatcher/include/ObjCExceptionCatcher.h 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