fix(mobile): share microphone ownership across page sessions (#34762)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-27 23:18:27 -07:00
committed by GitHub
co-authored by OrcaWin
parent 52a43ed99e
commit 9c094e16d2
3 changed files with 192 additions and 10 deletions
@@ -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 () => {
@@ -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<void>((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<ReturnType<typeof createNativeAudioCapture>>()
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<typeof createNativeAudioCapture>) {
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<void>((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)
}
)
})
+24 -7
View File
@@ -85,17 +85,27 @@ export type NativeAudioCapture = {
readonly dispose: () => void
}
type AudioDeviceOwner = {
queue: Promise<void>
stop: (() => boolean) | null
}
const audioDeviceOwners = new WeakMap<NativeAudioEngine, AudioDeviceOwner>()
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<unknown> = Promise.resolve()
function enqueue<Value>(action: () => Promise<Value>): Promise<Value> {
// 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<unknown> {
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 {