diff --git a/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.test.ts index a6a285d6785..7769331206d 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.test.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.test.ts @@ -402,7 +402,7 @@ describe('the shell capture', () => { it('does not open a capture for a start that lands after the session ended', async () => { const entered = gate() const blocked = gate() - const { engine, liveListeners, log } = createTestEngine({ + const { engine, liveListeners, log, calls } = createTestEngine({ permission: async () => { entered.release() await blocked.promise @@ -416,8 +416,8 @@ describe('the shell capture', () => { blocked.release() await expect(pending).resolves.toMatchObject({ started: false }) expect(liveListeners()).toEqual({ microphone: 0, interruptions: 0 }) - // A successful engine open needs teardown even if its capture was superseded; local end sees no capture and cannot release the native session later. - expect(log).toContain('end') + expect(calls).toEqual([]) + expect(log).not.toContain('end') }) it('tears the device down for a start that lost the race after opening', async () => { diff --git a/mobile/src/platform/native-audio-session-ownership.test.ts b/mobile/src/platform/native-audio-session-ownership.test.ts new file mode 100644 index 00000000000..be50402a191 --- /dev/null +++ b/mobile/src/platform/native-audio-session-ownership.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { createNativeAudioCapture, type NativeAudioEngine } from './native-audio' +import { audioReadResultSchema } from '../mobile-web-shell/bridge/bridge-audio-verbs' + +function gate() { + let release = () => {} + const promise = new Promise((resolve) => { + release = resolve + }) + return { promise, release } +} + +function device(blockAt?: 'permission' | 'open') { + const entered = gate() + const blocked = gate() + const listeners = new Set<(bytes: Uint8Array) => void>() + let permissions = 0 + let opens = 0 + let recording = false + let holds = 0 + const engine: NativeAudioEngine = { + requestPermission: async () => { + permissions++ + if (blockAt === 'permission' && permissions === 1) { + entered.release() + await blocked.promise + } + return 'granted' + }, + open: async (sampleRate) => { + opens++ + if (blockAt === 'open' && opens === 1) { + entered.release() + await blocked.promise + } + return { opened: true, sampleRate } + }, + begin: () => { + recording = true + return true + }, + end: () => { + recording = false + }, + screenLock: { + hold: () => { + holds++ + }, + release: () => { + holds-- + } + }, + onMicrophoneData: (listener) => { + listeners.add(listener) + return { + remove: () => { + listeners.delete(listener) + } + } + }, + onInterruption: () => ({ remove: () => {} }) + } + return { + engine, + entered, + blocked, + counts: () => ({ permissions, opens, recording, holds, listeners: listeners.size }), + emit: (bytes: Uint8Array) => { + if (recording) { + for (const listener of listeners) { + listener(bytes) + } + } + } + } +} + +const captures = new Set>() +function capture(engine: NativeAudioEngine) { + const next = createNativeAudioCapture(engine) + captures.add(next) + return next +} +afterEach(() => { + for (const current of captures) { + current.dispose() + } + captures.clear() +}) + +async function read(current: ReturnType) { + const reply = audioReadResultSchema.parse( + await current.serve('native.audio.read', { maxBytes: 1024 }) + ) + return Uint8Array.from(atob(reply.base64), (char) => char.charCodeAt(0)) +} + +describe('page sessions sharing the device microphone', () => { + it.each(['permission', 'open'] as const)( + 'keeps the successor recording after a retired %s finishes', + async (blockAt) => { + const mic = device(blockAt) + const retired = capture(mic.engine) + const obsolete = retired.serve('native.audio.start', { sampleRate: 16_000 }) + await mic.entered.promise + retired.dispose() + const successor = capture(mic.engine) + const live = successor.serve('native.audio.start', { sampleRate: 16_000 }) + // Let runnable starts settle while the older native operation remains explicitly blocked. + await new Promise((resolve) => setImmediate(resolve)) + mic.blocked.release() + await expect(obsolete).resolves.toMatchObject({ started: false }) + await expect(live).resolves.toMatchObject({ started: true }) + const speech = new Uint8Array([1, 3, 5, 7]) + mic.emit(speech) + expect(await read(successor)).toEqual(speech) + expect(mic.counts()).toMatchObject({ recording: true, holds: 1, listeners: 1 }) + successor.dispose() + expect(mic.counts()).toMatchObject({ recording: false, holds: 0, listeners: 0 }) + } + ) + + it('transfers a live microphone without leaving the old page listening or able to stop its successor', async () => { + const mic = device() + const previous = capture(mic.engine) + await previous.serve('native.audio.start', { sampleRate: 16_000 }) + const successor = capture(mic.engine) + await successor.serve('native.audio.start', { sampleRate: 16_000 }) + expect(mic.counts()).toMatchObject({ holds: 1, listeners: 1 }) + await expect(read(previous)).rejects.toThrow('no capture') + previous.dispose() + await previous.serve('native.audio.stop', {}) + const speech = new Uint8Array([2, 4, 6, 8]) + mic.emit(speech) + expect(await read(successor)).toEqual(speech) + }) + + it.each([10, 100])( + 'skips device admission for %i starts queued before retirement', + async (count) => { + const mic = device('permission') + const retired = capture(mic.engine) + const pending = Array.from({ length: count }, () => + retired.serve('native.audio.start', { sampleRate: 16_000 }) + ) + await mic.entered.promise + retired.dispose() + const successor = capture(mic.engine) + const live = successor.serve('native.audio.start', { sampleRate: 16_000 }) + mic.blocked.release() + await Promise.all(pending) + await expect(live).resolves.toMatchObject({ started: true }) + expect(mic.counts()).toEqual({ + permissions: 2, + opens: 1, + recording: true, + holds: 1, + listeners: 1 + }) + const speech = new Uint8Array([9, 10]) + mic.emit(speech) + expect(await read(successor)).toEqual(speech) + } + ) +}) diff --git a/mobile/src/platform/native-audio.ts b/mobile/src/platform/native-audio.ts index ffeee71a81f..ca47291624d 100644 --- a/mobile/src/platform/native-audio.ts +++ b/mobile/src/platform/native-audio.ts @@ -85,17 +85,27 @@ export type NativeAudioCapture = { readonly dispose: () => void } +type AudioDeviceOwner = { + queue: Promise + stop: (() => boolean) | null +} + +const audioDeviceOwners = new WeakMap() + export function createNativeAudioCapture(engine: NativeAudioEngine): NativeAudioCapture { const screen = engine.screenLock + const device: AudioDeviceOwner = audioDeviceOwners.get(engine) ?? { + queue: Promise.resolve(), + stop: null + } + audioDeviceOwners.set(engine, device) let capture: Capture | null = null let disposed = false - // Serialize starts/stops so overlapping opens cannot orphan listeners and a stop cannot run before its start finishes. Reads remain immediate and refuse without capture. - let queue: Promise = Promise.resolve() function enqueue(action: () => Promise): Promise { - // A failed start must not block later stops. - const run = queue.then(action, action) - queue = run.then( + // The microphone is shared across page sessions, including starts still awaiting native open. + const run = device.queue.then(action, action) + device.queue = run.then( () => undefined, () => undefined ) @@ -108,6 +118,9 @@ export function createNativeAudioCapture(engine: NativeAudioEngine): NativeAudio } capture.stopListening() capture = null + if (device.stop === end) { + device.stop = null + } screen.release() engine.end() return true @@ -141,10 +154,13 @@ export function createNativeAudioCapture(engine: NativeAudioEngine): NativeAudio async function start(params: unknown): Promise { const { sampleRate } = audioStartParamsSchema.parse(params) + if (disposed) { + return { started: false, sampleRate, permission: 'undetermined' } + } // Replace prior capture because its page may have navigated or disappeared without stopping it. - end() + device.stop?.() const permission = await engine.requestPermission() - if (permission !== 'granted') { + if (disposed || permission !== 'granted') { return { started: false, sampleRate, permission } } const opened = await engine.open(sampleRate) @@ -157,6 +173,7 @@ export function createNativeAudioCapture(engine: NativeAudioEngine): NativeAudio return { started: false, sampleRate: opened.sampleRate, permission } } capture = listen() + device.stop = end // Hold only after opening; every subsequent failure must release through end(). screen.hold() try {