fix(mobile): give phone audio back when dictation stops, and fail clearly when iOS can't record (#35214)

* fix(mobile): release the dictation audio session on stop and fail clearly without iOS voice processing

Stopping dictation only muted the native input, so the iOS voiceChat session
and Android audio focus stayed held until the screen unmounted and other apps'
audio stayed ducked. End now tears the engine down; the next open rebuilds it.
iOS tearDown stops I/O before deactivating and notifies other apps.

The iOS engine returned from setup before installing the mic tap when voice
processing could not be enabled, so recording reported success and captured
nothing. Setup now throws, so initialize fails and the phone shows the
microphone error.

* test(mobile): expect dictation stop to tear the audio engine down

* test(mobile): drop the duplicate release-throw case from native capture tests
This commit is contained in:
Kelvin Amoaba
2026-10-28 17:15:30 +00:00
committed by GitHub
parent 3416bda2ba
commit 247035b026
5 changed files with 44 additions and 63 deletions
@@ -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()
}
@@ -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')
+6 -32
View File
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const engine = vi.hoisted(() => ({
listeners: new Map<string, (event: { data: unknown }) => void>(),
calls: new Array<string>(),
throwOn: new Set<string>(),
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<string>(),
@@ -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'])
})
})
+13 -18
View File
@@ -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
@@ -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