diff --git a/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift b/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift index 8153bd0a3b9..2e85bf06ddc 100644 --- a/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift +++ b/mobile/packages/expo-two-way-audio/ios/AudioEngine.swift @@ -63,7 +63,13 @@ class AudioEngine { } self.setupAudioSession() - self.setup() + do { + try self.setup() + } catch { + // Hand the session back so a failed initialize leaves no other app's audio ducked. + tearDown() + throw error + } self.start() } @@ -101,14 +107,10 @@ class AudioEngine { } } - func setup() { + func setup() throws { let input = avAudioEngine.inputNode - do { - try input.setVoiceProcessingEnabled(true) - } catch { - print("Could not enable voice processing \(error)") - return - } + // Throw rather than return: an engine with no mic tap would report recording and capture nothing. + try input.setVoiceProcessingEnabled(true) avAudioEngine.inputNode.isVoiceProcessingInputMuted = !isRecording @@ -269,7 +271,7 @@ class AudioEngine { func stopRecordingAndPlayer(clearInterruptionResume: Bool = true){ do { - try AVAudioSession.sharedInstance().setActive(false) + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } catch { print("Could not set the audio session to inactive: \(error)") } @@ -288,8 +290,9 @@ class AudioEngine { func tearDown() { guard !isTornDown else { return } isTornDown = true - stopRecordingAndPlayer() + // Stop I/O first; deactivating a session with running I/O returns a busy error. avAudioEngine.stop() + stopRecordingAndPlayer() } var isPlaying: Bool { @@ -355,7 +358,14 @@ class AudioEngine { private func handleMediaServicesWereReset() { guard !isTornDown else { return } self.avAudioEngine.stop() - self.setup() + do { + try self.setup() + } catch { + print("Could not rebuild the audio engine: \(error)") + tearDown() + onAudioInterruptionCallback?("blocked") + return + } self.start() } diff --git a/mobile/src/hooks/mobile-dictation-native-start-failure.test.tsx b/mobile/src/hooks/mobile-dictation-native-start-failure.test.tsx index 1b7e700ebe3..0b88c61dbf2 100644 --- a/mobile/src/hooks/mobile-dictation-native-start-failure.test.tsx +++ b/mobile/src/hooks/mobile-dictation-native-start-failure.test.tsx @@ -124,7 +124,7 @@ describe('a native start the desktop refuses', () => { await started }) expect(device.calls).toContain('initialize') - expect(device.calls).toContain('toggleRecording(false)') + expect(device.calls).toContain('tearDown') // The open capture owns a wake tag; no outer start catch releases it. expect(device.screen).toEqual(['+', '-']) expect(dictation().status).toBe('idle') diff --git a/mobile/src/platform/dictation-capture.test.ts b/mobile/src/platform/dictation-capture.test.ts index ab671db8113..adfb857b076 100644 --- a/mobile/src/platform/dictation-capture.test.ts +++ b/mobile/src/platform/dictation-capture.test.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const engine = vi.hoisted(() => ({ listeners: new Map void>(), calls: new Array(), - throwOn: new Set(), + tearDownFails: false, openFails: false, // Record queued screen-lock calls separately; their microtask order relative to synchronous engine calls is not a contract. screen: new Array(), @@ -30,15 +30,12 @@ vi.mock('@orca/expo-two-way-audio', () => ({ }, tearDown: () => { engine.calls.push('tearDown') - if (engine.throwOn.has('tearDown')) { + if (engine.tearDownFails) { throw new Error('the audio session would not tear down') } }, toggleRecording: (on: boolean) => { engine.calls.push(`toggleRecording(${String(on)})`) - if (engine.throwOn.has(`toggleRecording(${String(on)})`)) { - throw new Error('the audio engine would not stop') - } return true } })) @@ -63,7 +60,7 @@ beforeEach(async () => { await flushScreen() engine.listeners.clear() engine.calls.length = 0 - engine.throwOn.clear() + engine.tearDownFails = false engine.openFails = false engine.screen.length = 0 engine.screenTags.clear() @@ -100,14 +97,8 @@ describe('the calls the native half makes', () => { expect(engine.calls).toEqual(['permission', 'initialize']) expect(capture.begin()).toBe(true) await capture.end() - capture.release() - expect(engine.calls).toEqual([ - 'permission', - 'initialize', - 'toggleRecording(true)', - 'toggleRecording(false)', - 'tearDown' - ]) + // Muting alone would keep the session and leave other apps' audio ducked until unmount. + expect(engine.calls).toEqual(['permission', 'initialize', 'toggleRecording(true)', 'tearDown']) }) it('hands every microphone event over with nothing dropped', () => { @@ -166,26 +157,9 @@ describe('the screen the native microphone holds awake', () => { describe('a device whose audio session will not shut down', () => { it('resolves `end` rather than rejecting it, which the contract promises', async () => { // Synchronous try cannot catch void end() rejection; swallow/log binding failures inside cleanup to prevent unhandled rejections. - engine.throwOn.add('toggleRecording(false)') + engine.tearDownFails = true const capture = useDictationCapture() await expect(capture.end()).resolves.toBeUndefined() - expect(engine.calls).toEqual(['toggleRecording(false)']) - }) - - it('does not throw out of `release`, which runs bare in the unmount path', async () => { - engine.throwOn.add('tearDown') - const capture = useDictationCapture() - expect(() => capture.release()).not.toThrow() - await Promise.resolve() expect(engine.calls).toEqual(['tearDown']) }) - - it('still stops the engine when the tear-down is the half that fails', async () => { - engine.throwOn.add('tearDown') - const capture = useDictationCapture() - await capture.end() - capture.release() - await Promise.resolve() - expect(engine.calls).toEqual(['toggleRecording(false)', 'tearDown']) - }) }) diff --git a/mobile/src/platform/dictation-capture.ts b/mobile/src/platform/dictation-capture.ts index 7fcadf16102..e3ef12486f3 100644 --- a/mobile/src/platform/dictation-capture.ts +++ b/mobile/src/platform/dictation-capture.ts @@ -11,6 +11,16 @@ import type { DictationCapture } from './dictation-capture-contract' const screen = nativeMicrophoneScreenLock +// Never throw: unmount calls this bare and `end` must not reject, so wake-tag release and desktop cancel still run. +function releaseMicrophone(): void { + screen.release() + try { + tearDown() + } catch (error) { + console.error('Failed to tear down the audio session', error) + } +} + const nativeDictationCapture: DictationCapture = { open: async () => { const permission = await requestMicrophonePermissionsAsync() @@ -25,24 +35,9 @@ const nativeDictationCapture: DictationCapture = { return { ok: true } }, begin: () => toggleRecording(true), - // Native events already delivered the tail. Catch binding throws inside this async cleanup because void callers’ synchronous try cannot catch rejections. - end: async () => { - screen.release() - try { - toggleRecording(false) - } catch (error) { - console.error('Failed to stop microphone recording', error) - } - }, - // Unmount calls release bare; catch failures so wake-tag release and desktop cancel can still run. - release: () => { - screen.release() - try { - tearDown() - } catch (error) { - console.error('Failed to tear down the audio session', error) - } - }, + // Native events already delivered the tail. Tear down, not just mute, so other apps' audio resumes; the next open re-initializes. + end: async () => releaseMicrophone(), + release: releaseMicrophone, onChunk: (handler) => addExpoTwoWayAudioEventListener('onMicrophoneData', (event) => { const raw = event.data diff --git a/mobile/src/session/mobile-dictation-agent-chat-start.test.tsx b/mobile/src/session/mobile-dictation-agent-chat-start.test.tsx index 62efa945c3f..337828ed29e 100644 --- a/mobile/src/session/mobile-dictation-agent-chat-start.test.tsx +++ b/mobile/src/session/mobile-dictation-agent-chat-start.test.tsx @@ -25,7 +25,9 @@ vi.mock('@orca/expo-two-way-audio', () => ({ return true }, requestMicrophonePermissionsAsync: async () => ({ granted: true }), - tearDown: () => {}, + tearDown: () => { + device.recording = false + }, toggleRecording: (recording: boolean) => { device.recording = recording return true