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.
This commit is contained in:
liji 2026-01-27 16:40:59 +08:00
parent d7a00dc823
commit 46aa3205e4
6 changed files with 109 additions and 10 deletions

View File

@ -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"),

View File

@ -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) }
}

View File

@ -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<Void, Never>?
private var isStarting: Bool = false
private var triggerOnlyTask: Task<Void, Never>?
private var startRetryCount: Int = 0
private var pendingRetryTask: Task<Void, Never>?
// 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

View File

@ -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;
}
}

View File

@ -0,0 +1,17 @@
#import <Foundation/Foundation.h>
#import <AVFoundation/AVFoundation.h>
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

View File

@ -1 +1 @@
2567ca5bbc065b922d96717a488d5db3120b5b033c5d0508682d1aa8fbba470a
f0a3035f3a5b211ef6b494227eb9195535cd90aae4d22834810185d0191a5c98