From ad9366fff4fa7a9ba7b90c84a5b052a8e9211f4f Mon Sep 17 00:00:00 2001 From: John Tennant Date: Thu, 5 Nov 2026 16:36:10 -0500 Subject: [PATCH] fix(voice): stop rendering before releasing audio observers Signed-off-by: John Tennant Co-authored-by: Darcy <756b3e94a13d7979c09d32291e4fd4da46848bdaa53340021ac24fb843e28d57@buzz.block.builderlab.xyz> --- .../crates/berd-call/native/siri_tts_bridge.m | 6 ++- .../berd-call/tests/native/render_health.m | 52 ++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src-tauri/crates/berd-call/native/siri_tts_bridge.m b/src-tauri/crates/berd-call/native/siri_tts_bridge.m index 6aca6d48a..79b22f3ee 100644 --- a/src-tauri/crates/berd-call/native/siri_tts_bridge.m +++ b/src-tauri/crates/berd-call/native/siri_tts_bridge.m @@ -906,13 +906,15 @@ - (void)stop { self.stopped = YES; self.pendingBuffers = 0; } + // Render-notify callbacks copy these blocks on the audio thread. Stop + // output before unregistering them so no callback can copy a freed block. + [self.player stop]; + [self.engine stop]; for (NSUInteger i = 0; i < self.renderObserverTokens.count; i++) { [self.renderUnits[i] removeRenderObserver:self.renderObserverTokens[i].integerValue]; } self.renderObserverTokens = nil; self.renderUnits = nil; - [self.player stop]; - [self.engine stop]; } - (void)dealloc { [self stop]; } diff --git a/src-tauri/crates/berd-call/tests/native/render_health.m b/src-tauri/crates/berd-call/tests/native/render_health.m index 7a7173a08..fb15a4ce5 100644 --- a/src-tauri/crates/berd-call/tests/native/render_health.m +++ b/src-tauri/crates/berd-call/tests/native/render_health.m @@ -13,6 +13,7 @@ @interface TestRunningEngine : NSObject @property(nonatomic, strong) TestAudioNode *mainMixerNode; @property(nonatomic, weak) BerdPocketAudioPlayer *player; @property(nonatomic, assign) BOOL running; +@property(nonatomic, copy) void (^onStop)(void); - (BOOL)isRunning; - (void)stop; @end @@ -28,9 +29,57 @@ - (BOOL)isRunning { dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)) == 0); return self.running; } -- (void)stop {} +- (void)stop { + if (self.onStop) self.onStop(); + self.running = NO; +} +@end + +@interface TestRenderUnit : NSObject +@property(nonatomic, weak) TestRunningEngine *engine; +@property(nonatomic, copy) AURenderObserver observer; +- (NSInteger)tokenByAddingRenderObserver:(AURenderObserver)observer; +- (void)removeRenderObserver:(NSInteger)token; +@end +@implementation TestRenderUnit +- (NSInteger)tokenByAddingRenderObserver:(AURenderObserver)observer { + self.observer = observer; + return 1; +} +- (void)removeRenderObserver:(NSInteger)token { + assert(!self.engine.running && "render observers must outlive active output"); + assert(token == 1 && self.observer); + self.observer = nil; +} @end +static void observersSurviveEngineShutdown(void) { + TestRunningEngine *engine = [TestRunningEngine new]; + engine.running = YES; + NSMutableArray *units = [NSMutableArray new]; + TestAudioNode *nodes[3]; + for (NSUInteger i = 0; i < 3; i++) { + TestRenderUnit *unit = [TestRenderUnit new]; + unit.engine = engine; + [units addObject:unit]; + nodes[i] = [TestAudioNode new]; + nodes[i].AUAudioUnit = (AUAudioUnit *)unit; + } + engine.outputNode = nodes[0]; + engine.mainMixerNode = nodes[2]; + engine.onStop = ^{ + // Output may finish a render callback while the engine is stopping. + for (TestRenderUnit *unit in units) assert(unit.observer); + }; + BerdPocketAudioPlayer *player = [BerdPocketAudioPlayer new]; + player.engine = (AVAudioEngine *)engine; + player.timePitch = (AVAudioUnitTimePitch *)nodes[1]; + [player observeRenderErrors]; + [player stop]; + for (TestRenderUnit *unit in units) assert(!unit.observer); + [player stop]; +} + static AUAudioUnit *offlineUnit(void) { AudioComponentDescription description = { kAudioUnitType_FormatConverter, kAudioUnitSubType_NewTimePitch, @@ -49,6 +98,7 @@ - (void)stop {} int main(void) { @autoreleasepool { + observersSurviveEngineShutdown(); for (NSUInteger failedNode = 0; failedNode < 3; failedNode++) { AUAudioUnit *units[] = {offlineUnit(), offlineUnit(), offlineUnit()}; TestAudioNode *nodes[3];