From c5f8db40b1dfd9c86fe1dd9826151abbc5105f2a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 21 Sep 2026 13:52:16 -0400 Subject: [PATCH] feat(mobile): carry the capture's tail on the stop reply (OTA phase C, ruling 36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `native.audio.stop` drains what the ring still holds into its own reply, so the page's `end()` is one verb: stop, hand the bytes on, done. The drain, await and read-once-more ordering goes with it, and so do `ending`, `reading` and `released` — three variables that existed only to order a last read against the stop and to stop a refused read re-entering `end`. The tail fields default rather than being required: the page updates over the air and the shell does not, so a page this new can meet a shell that answers `stopped` alone. That dictation loses its tail where a required field would have lost it the stop. The heap case from PR D's bot round cannot recur: `end` issues no read, and a stop reply carries no interruption, so the lane that re-entered is gone. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../bridge/bridge-audio-verbs.test.ts | 99 ++++++++++++++- .../bridge/bridge-audio-verbs.ts | 24 +++- .../bridge/use-native-verbs.ts | 8 +- .../platform/dictation-capture.web.test.tsx | 115 ++++++++++++++--- mobile/src/platform/dictation-capture.web.ts | 118 +++++------------- mobile/src/platform/native-audio.ts | 11 +- 6 files changed, 263 insertions(+), 112 deletions(-) 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 7eba09fabad..3986fa83b06 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 @@ -19,6 +19,7 @@ import { audioReadResultSchema, audioStartParamsSchema, audioStopParamsSchema, + audioStopResultSchema, wakelockSetParamsSchema } from './bridge-audio-verbs' import { BRIDGE_NATIVE_VERB_NAMES, BRIDGE_NATIVE_VERBS } from './bridge-native-verbs' @@ -336,13 +337,21 @@ describe('the shell capture', () => { const { engine } = createTestEngine() const capture = createNativeAudioCapture(engine) await capture.serve('native.audio.start', { sampleRate: 16_000 }) - await expect(capture.serve('native.audio.stop', {})).resolves.toEqual({ stopped: true }) + await expect(capture.serve('native.audio.stop', {})).resolves.toEqual({ + stopped: true, + base64: '', + droppedBytes: 0 + }) await expect(capture.serve('native.audio.read', { maxBytes: 1_024 })).rejects.toSatisfy( (error: unknown) => error instanceof BridgeNativeVerbRefusedError && error.code === 'native_audio_not_capturing' ) // A second stop is the state the page already has, not a fault. - await expect(capture.serve('native.audio.stop', {})).resolves.toEqual({ stopped: false }) + await expect(capture.serve('native.audio.stop', {})).resolves.toEqual({ + stopped: false, + base64: '', + droppedBytes: 0 + }) }) it('refuses a read before any start', async () => { @@ -483,7 +492,7 @@ describe('the shell capture', () => { await started // The stop runs after the start it followed, so it ends the capture that start opened rather // than finding nothing and leaving a live microphone behind it. - await expect(stopped).resolves.toEqual({ stopped: true }) + await expect(stopped).resolves.toEqual({ stopped: true, base64: '', droppedBytes: 0 }) expect(liveListeners()).toEqual({ microphone: 0, interruptions: 0 }) }) @@ -702,3 +711,87 @@ describe('the screen the shell holds awake while it is capturing', () => { expect(screen).toEqual(['+', '-', '+', '-']) }) }) + +describe('the tail the stop reply carries', () => { + it('answers with everything the ring still held', async () => { + const { engine, emit } = createTestEngine() + const capture = createNativeAudioCapture(engine) + await capture.serve('native.audio.start', { sampleRate: 16_000 }) + const tail = pcm(12_288, 11) + emit(tail) + const stopped = audioStopResultSchema.parse(await capture.serve('native.audio.stop', {})) + expect(stopped.stopped).toBe(true) + expect(Array.from(decode(stopped.base64))).toEqual(Array.from(tail)) + expect(stopped.droppedBytes).toBe(0) + }) + + it('hands a byte to the read or to the stop, never to both and never to neither', async () => { + const { engine, emit } = createTestEngine() + const capture = createNativeAudioCapture(engine) + await capture.serve('native.audio.start', { sampleRate: 16_000 }) + const spoken = pcm(2_048, 3) + emit(spoken) + const read = audioReadResultSchema.parse( + await capture.serve('native.audio.read', { maxBytes: BRIDGE_AUDIO_RING_MAX_BYTES }) + ) + // What the microphone produced between that read and the stop, which is the audio no timer is + // ever coming for. + const after = pcm(1_024, 7) + emit(after) + const stopped = audioStopResultSchema.parse(await capture.serve('native.audio.stop', {})) + expect(Array.from(decode(read.base64))).toEqual(Array.from(spoken)) + expect(Array.from(decode(stopped.base64))).toEqual(Array.from(after)) + }) + + it('carries what the ring refused since the last read', async () => { + const { engine, emit } = createTestEngine() + const capture = createNativeAudioCapture(engine) + await capture.serve('native.audio.start', { sampleRate: 16_000 }) + emit(pcm(BRIDGE_AUDIO_RING_MAX_BYTES)) + emit(pcm(2_048)) + const stopped = audioStopResultSchema.parse(await capture.serve('native.audio.stop', {})) + expect(stopped.droppedBytes).toBe(2_048) + }) + + it('answers no tail for a session that was not capturing', async () => { + const { engine } = createTestEngine() + const capture = createNativeAudioCapture(engine) + const stopped = audioStopResultSchema.parse(await capture.serve('native.audio.stop', {})) + expect(stopped).toEqual({ stopped: false, base64: '', droppedBytes: 0 }) + }) + + it('leaves nothing behind for a second stop to answer with', async () => { + const { engine, emit } = createTestEngine() + const capture = createNativeAudioCapture(engine) + await capture.serve('native.audio.start', { sampleRate: 16_000 }) + emit(pcm(512, 5)) + await capture.serve('native.audio.stop', {}) + const again = audioStopResultSchema.parse(await capture.serve('native.audio.stop', {})) + expect(again).toEqual({ stopped: false, base64: '', droppedBytes: 0 }) + }) + + it('reads a reply from a shell too old to carry a tail', () => { + // The page updates over the air and the shell does not, so the page parses a stop reply from a + // build that answers `stopped` alone. It loses that dictation's tail; it must not lose the + // stop, which is what a required field would have cost. + expect(audioStopResultSchema.parse({ stopped: true })).toEqual({ + stopped: true, + base64: '', + droppedBytes: 0 + }) + }) + + it('bounds the tail by the same budget a read is bounded by', () => { + expect( + audioStopResultSchema.safeParse({ + stopped: true, + base64: 'A'.repeat(BRIDGE_AUDIO_READ_MAX_BASE64_CHARS + 1), + droppedBytes: 0 + }).success + ).toBe(false) + expect( + audioStopResultSchema.safeParse({ stopped: true, base64: 'not base64!', droppedBytes: 0 }) + .success + ).toBe(false) + }) +}) diff --git a/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.ts b/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.ts index 015e36f11f3..8733b9df0c3 100644 --- a/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.ts +++ b/mobile/src/mobile-web-shell/bridge/bridge-audio-verbs.ts @@ -147,9 +147,27 @@ export type BridgeAudioChunk = z.infer /** No params: there is one capture per page session, so there is nothing to name. */ export const audioStopParamsSchema = z.strictObject({}) -/** False for a session that was not capturing, which is not a fault: a page that stops twice, or - * stops after an interruption already ended the capture, asked for the state it already has. */ -export const audioStopResultSchema = z.strictObject({ stopped: z.boolean() }) +/** + * The stop, and the tail it takes with it. + * + * `stopped` is false for a session that was not capturing, which is not a fault: a page that stops + * twice, or stops after an interruption already ended the capture, asked for the state it already + * has. + * + * The bytes are whatever the ring still held — up to one drain interval of what the user was still + * saying as they lifted the button, which no timer is coming for. Carried by the stop rather than + * fetched by a last read, because a page that has to read before it stops has an ordering to get + * right and a re-entry to guard; a reply that brings the tail with it has neither. + * + * Both tail fields default rather than being required. The page updates over the air and the shell + * does not, so a page this new can be talking to a shell that answers `stopped` alone: absent, that + * dictation loses its tail, where a required field would have lost it the stop itself. + */ +export const audioStopResultSchema = z.strictObject({ + stopped: z.boolean(), + base64: z.string().max(BRIDGE_AUDIO_READ_MAX_BASE64_CHARS).regex(BASE64_PATTERN).default(''), + droppedBytes: z.number().int().nonnegative().default(0) +}) export const wakelockSetParamsSchema = z.strictObject({ active: z.boolean(), diff --git a/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts b/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts index 5bb508f3e98..25ce19ca75b 100644 --- a/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts +++ b/mobile/src/mobile-web-shell/bridge/use-native-verbs.ts @@ -79,8 +79,10 @@ export type NativeVerbs = { /** One drain of the shell's ring. `maxBytes` above the ring is refused by the shell's schema, so * a caller bounds its own ask rather than discovering the bound as a rejection. */ readAudio: (maxBytes: number) => Promise - /** False for a session that was not capturing, which is not a fault. */ - stopAudio: () => Promise + /** Ends the capture and brings back what the shell's ring still held, which is the tail of the + * utterance no drain came back for. `stopped` is false for a session that was not capturing, + * which is not a fault. */ + stopAudio: () => Promise> /** Whether the tag is held after the call. The shell asks the device nothing for a tag it never * took, so releasing one twice is not a fault either. */ setWakelock: (active: boolean, tag: string) => Promise @@ -223,7 +225,7 @@ export function useNativeVerbs(): NativeVerbs { startAudio: (sampleRate) => call('native.audio.start', { sampleRate }, audioStartResultSchema), readAudio: (maxBytes) => call('native.audio.read', { maxBytes }, audioReadResultSchema), - stopAudio: async () => (await call('native.audio.stop', {}, audioStopResultSchema)).stopped, + stopAudio: () => call('native.audio.stop', {}, audioStopResultSchema), setWakelock: async (active, tag) => (await call('native.wakelock.set', { active, tag }, wakelockSetResultSchema)).active } diff --git a/mobile/src/platform/dictation-capture.web.test.tsx b/mobile/src/platform/dictation-capture.web.test.tsx index 27f6d22aeed..8f09b0da751 100644 --- a/mobile/src/platform/dictation-capture.web.test.tsx +++ b/mobile/src/platform/dictation-capture.web.test.tsx @@ -242,11 +242,12 @@ describe('draining the shell ring', () => { expect(chunks[0]?.droppedBytes).toBe(2_048) }) - it('delivers the tail still in the ring before it stops the shell', async () => { + it('delivers the tail the stop reply carried', async () => { // The utterance's last 400 ms sits in the shell's ring when the user lifts the button: less // than one drain interval, so no timer will ever come for it. Natively that audio is already // in the hook's hands by the time recording stops, so a page that dropped it would transcribe - // a sentence with its ending cut off. + // a sentence with its ending cut off. It rides the stop's own reply, so the page has no last + // read to order against the stop. const shell = createAudioShell() const pair = createFakeBridgePortPair({ serveNativeVerb: shell.serveNativeVerb }) const capture = await mount(pair) @@ -271,19 +272,73 @@ describe('draining the shell ring', () => { expect(Array.from(chunks[0]?.data ?? [])).toEqual(Array.from(tail)) }) - it('stops the shell after the last read, never before it', async () => { + it('asks for nothing but the stop, which is what brings the tail', async () => { const shell = createAudioShell() const pair = createFakeBridgePortPair({ serveNativeVerb: shell.serveNativeVerb }) const capture = await mount(pair) + const chunks: DictationCaptureChunk[] = [] + capture.onChunk((chunk) => chunks.push(chunk)) await capture.open() capture.begin() - shell.speak(pcm(2_048, 12)) + const tail = pcm(2_048, 12) + shell.speak(tail) + const before = shell.calls.length await act(async () => { await capture.end() await pair.flush() }) - // A stop that landed first would have taken the capture away, and the read would be refused. - expect(shell.calls.slice(-2)).toEqual(['native.audio.read', 'native.audio.stop']) + // One verb, not a read and then a stop: there is no ordering here to get wrong. + expect(shell.calls.slice(before)).toEqual(['native.audio.stop']) + expect(Array.from(chunks.at(-1)?.data ?? [])).toEqual(Array.from(tail)) + }) + + it('loses nothing and duplicates nothing when the stop follows a read still in flight', async () => { + const shell = createAudioShell() + const pair = createFakeBridgePortPair({ serveNativeVerb: shell.serveNativeVerb }) + const capture = await mount(pair) + const chunks: DictationCaptureChunk[] = [] + capture.onChunk((chunk) => chunks.push(chunk)) + await capture.open() + capture.begin() + const spoken = pcm(1_024, 31) + const afterwards = pcm(512, 32) + shell.speak(spoken) + await act(async () => { + // The drain's read leaves the page, and the user lifts the button before its reply is back. + vi.advanceTimersByTime(DICTATION_CAPTURE_DRAIN_INTERVAL_MS) + shell.speak(afterwards) + await capture.end() + await pair.flush() + }) + // Every byte the microphone produced, once each and in the order it said them: whether the + // read or the stop carried a given byte is the shell's business and neither can carry it twice. + expect(chunks.flatMap((chunk) => Array.from(chunk.data))).toEqual([ + ...Array.from(spoken), + ...Array.from(afterwards) + ]) + }) + + it('delivers nothing for a second end, which the shell answers as already stopped', async () => { + const shell = createAudioShell() + const pair = createFakeBridgePortPair({ serveNativeVerb: shell.serveNativeVerb }) + const capture = await mount(pair) + const chunks: DictationCaptureChunk[] = [] + capture.onChunk((chunk) => chunks.push(chunk)) + await capture.open() + capture.begin() + shell.speak(pcm(1_024, 41)) + await act(async () => { + await capture.end() + await pair.flush() + }) + expect(chunks).toHaveLength(1) + await act(async () => { + await capture.end() + await pair.flush() + }) + // No latch on the page: the shell has no capture, so it answers an empty tail and the page + // hands nothing on. A second end that delivered would splice the last chunk in twice. + expect(chunks).toHaveLength(1) }) it('reads nothing once the capture has ended', async () => { @@ -300,9 +355,9 @@ describe('draining the shell ring', () => { }) const afterEnd = shell.calls.length await tick(pair, 3) - // The last read, the stop, and then nothing: a timer left running would keep asking a shell - // that no longer has a capture. - expect(shell.calls.slice(before, afterEnd)).toEqual(['native.audio.read', 'native.audio.stop']) + // The stop, and then nothing: a timer left running would keep asking a shell that no longer + // has a capture. + expect(shell.calls.slice(before, afterEnd)).toEqual(['native.audio.stop']) expect(shell.calls.slice(afterEnd)).toEqual([]) }) }) @@ -370,11 +425,13 @@ describe('a capture the page loses', () => { expect(interrupted).toBe(1) }) - it('does not loop when the interruption handler ends the capture, as the hook does', async () => { - // The hook's handler is `() => void cancel()`, and `cancel` reaches `capture.end()` - // synchronously through `closeDictationAudio`. So a refused read inside `end` re-enters `end`, - // whose own last read is refused too: without an idempotent `end` that recursion issues bridge - // reads until the page is torn down. + it('cannot re-enter end from an interruption raised while one is running', async () => { + // The heap case from PR D's bot round: the hook's handler is `() => void cancel()`, and + // `cancel` reaches `capture.end()` synchronously through `closeDictationAudio`. When `end` + // itself read, its refused read raised an interruption that called straight back into `end`, + // whose own read was refused for the same reason, and the recursion issued bridge reads until + // the page ran out of memory. There is no read inside `end` now, and a stop reply carries no + // interruption, so the lane that re-entered does not exist. const shell = createAudioShell({ refuse: (verb) => verb === 'native.audio.read' @@ -398,6 +455,36 @@ describe('a capture the page loses', () => { await tick(pair, 3) const reads = shell.calls.filter((verb) => verb === 'native.audio.read').length expect(reads).toBeLessThanOrEqual(2) + // And the stop is not the recursion's new shape either: one per `end` the hook asked for. + expect(shell.calls.filter((verb) => verb === 'native.audio.stop').length).toBeLessThanOrEqual(2) + }) + + it('goes on ending when an interruption lands while the stop is in flight', async () => { + const shell = createAudioShell() + const pair = createFakeBridgePortPair({ serveNativeVerb: shell.serveNativeVerb }) + const capture = await mount(pair) + let interrupted = 0 + capture.onInterruption(() => { + interrupted += 1 + void capture.end() + }) + await capture.open() + capture.begin() + shell.speak(pcm(1_024, 51)) + await act(async () => { + vi.advanceTimersByTime(DICTATION_CAPTURE_DRAIN_INTERVAL_MS) + // The OS takes the microphone away while the page's own stop is crossing the bridge. + const ending = capture.end() + shell.interrupt('began') + await ending + await pair.flush() + }) + await tick(pair, 3) + expect(interrupted).toBeGreaterThanOrEqual(0) + // Bounded either way: the interruption's own `end` finds a shell with no capture and is + // answered, rather than reaching a read that raises the interruption again. + expect(shell.calls.filter((verb) => verb === 'native.audio.stop').length).toBeLessThanOrEqual(3) + expect(shell.calls.filter((verb) => verb === 'native.audio.read').length).toBeLessThanOrEqual(2) }) it('reads again for the next dictation after an end, rather than staying ended', async () => { diff --git a/mobile/src/platform/dictation-capture.web.ts b/mobile/src/platform/dictation-capture.web.ts index 3eed2afa438..d0ec498270c 100644 --- a/mobile/src/platform/dictation-capture.web.ts +++ b/mobile/src/platform/dictation-capture.web.ts @@ -73,15 +73,6 @@ export function createPageDictationCapture( const chunkHandlers: Handlers<(chunk: DictationCaptureChunk) => void> = new Set() const interruptionHandlers: Handlers<() => void> = new Set() let timer: ReturnType | null = null - /** The end in flight; null when none is running. Cleared on settle rather than held, because - * `begin` is not guaranteed to run between two ends and a finished one must shadow neither. */ - let ending: Promise | null = null - /** The screen went away: no later end reads or stops again. Cleared by `begin`. */ - let released = false - /** The read in flight, if there is one. At most one: two would double the slots dictation spends - * and can settle out of order, which is a splice of two moments reaching the transcriber as - * speech. Held rather than flagged so a stop can wait for it before taking its own turn. */ - let reading: Promise | null = null function stopDraining(): void { if (timer !== null) { @@ -98,16 +89,20 @@ export function createPageDictationCapture( } } - function deliver(reply: BridgeAudioChunk): void { - if (reply.base64.length > 0 || reply.droppedBytes > 0) { - const chunk: DictationCaptureChunk = { - data: decodeBase64(reply.base64), - droppedBytes: reply.droppedBytes - } - for (const handler of chunkHandlers) { - handler(chunk) - } + /** Nothing for an interval the microphone was silent through: an empty chunk is audio the page + * would spend a budget and a send on. */ + function deliverBytes(base64: string, droppedBytes: number): void { + if (base64.length === 0 && droppedBytes === 0) { + return } + const chunk: DictationCaptureChunk = { data: decodeBase64(base64), droppedBytes } + for (const handler of chunkHandlers) { + handler(chunk) + } + } + + function deliver(reply: BridgeAudioChunk): void { + deliverBytes(reply.base64, reply.droppedBytes) // The same two kinds the native seam ends on: an `ended` on its own is the OS handing the // session back and leaves a live capture alone. `recording` is the shell's own state and ends // it whatever the kind — a capture it no longer has is gone however it went. @@ -130,74 +125,24 @@ export function createPageDictationCapture( } } - function drain(): Promise { - if (reading !== null) { - return reading - } - const run = readOnce().finally(() => { - reading = null - }) - reading = run - return run - } - - /** Best effort, and deliberately quiet, for the reason `end` never rejects. */ - async function stopShell(): Promise { - await verbs.stopAudio().catch(() => undefined) - } - /** - * The tail, then the stop, in that order. + * The stop, which brings the tail back with it. * * Whatever is in the ring when the user lifts the button is up to one interval of what they * actually said, and no timer is coming for it — `stopDraining` has just cancelled the one that - * was. Stopping first would take the capture away and the read after it would be refused, so the - * order here is the whole fix. An in-flight drain is awaited before the last read rather than - * raced with it, because two reads settling out of order splice two moments together. + * was. The shell drains it into the stop's own reply, so there is no read to order against the + * stop, no flight to latch and nothing for an interruption to re-enter: a stop reply carries no + * interruption, and a second end reaches a shell with no capture and is answered with nothing. + * + * Never rejects, which the contract promises: a capture that will not end is not the page's to + * fix, and every caller reaches this inside a synchronous try that could not see a rejection. */ - async function runEndCapture(): Promise { + async function endCapture(): Promise { stopDraining() - await reading - reading = null - await readOnce() - await stopShell() - } - - /** - * Idempotent while one end is in flight, which is what stops a refused read looping. - * - * The hook's interruption handler is `() => void cancel()`, and `cancel` reaches `end()` - * synchronously through `closeDictationAudio`. So the last read here can raise an interruption - * that calls straight back into this function, whose own last read is refused for the same - * reason — the shell has no capture — and the recursion issues bridge reads until the page runs - * out of memory. Returning the in-flight promise makes the re-entrant call a no-op rather than a - * second read; the recursion happens while that promise is still pending, so guarding the flight - * is enough and outliving it is not required. - * - * Cleared on settle, because a finished end must not answer for the next capture. `open` starts - * the shell recording and the hook can reach `end` before `begin` — a start that goes stale - * after `activeIdRef` is set cleans up that way, and `begin` is the only thing that would have - * cleared a latch — so an end held past its own flight would report a stop it never issued and - * leave the shell holding a live microphone. - */ - function endCapture(): Promise { - // A released capture has already stopped the shell and has nobody to hand a tail to. - if (released) { - return Promise.resolve() + const stopped = await verbs.stopAudio().catch(() => null) + if (stopped !== null) { + deliverBytes(stopped.base64, stopped.droppedBytes) } - if (ending !== null) { - return ending - } - // Assigned before anything can await, so a handler re-entering from inside the read below - // finds it set rather than starting a second end. - const run = runEndCapture().finally(() => { - // Only its own flight: `begin` may have started a newer capture while this one settled. - if (ending === run) { - ending = null - } - }) - ending = run - return run } return { @@ -213,23 +158,20 @@ export function createPageDictationCapture( }, begin: () => { // The shell began capturing inside `start`; this is the page's half, which is the drain. - ending = null - released = false stopDraining() timer = setInterval(() => { - void drain() + // Unguarded: replies cross one lane in the order the shell posted them, so a read that + // outlives its interval is followed by its successor and never overtaken by one. + void readOnce() }, drainIntervalMs) return true }, end: endCapture, - // No last read: a release is the screen going away, and there is nobody left to hand the tail - // to. The shell sweeps the ring with the capture. + // The tail is dropped rather than delivered: a release is the screen going away, and there is + // nobody left to hand it to. The shell sweeps the ring with the capture. release: () => { - // Marked released so a later `end` neither reads nor stops again: the screen is going away. - // A flag rather than a settled `ending`, which now clears itself and would unlatch this. - released = true stopDraining() - void stopShell() + void verbs.stopAudio().catch(() => undefined) }, onChunk: (handler) => subscribe(chunkHandlers, handler), onInterruption: (handler) => subscribe(interruptionHandlers, handler), diff --git a/mobile/src/platform/native-audio.ts b/mobile/src/platform/native-audio.ts index ba0e1e2ca8d..fbfbeb4f88d 100644 --- a/mobile/src/platform/native-audio.ts +++ b/mobile/src/platform/native-audio.ts @@ -255,7 +255,16 @@ export function createNativeAudioCapture(engine: NativeAudioEngine): NativeAudio audioStopParamsSchema.parse(params) // Queued so a stop that followed a start ends the capture that start opened, rather than // finding nothing and leaving a live microphone behind it. - return enqueue(async () => ({ stopped: end() })) + return enqueue(async () => { + // Drained before the capture goes, because ending it takes the ring with it. This is the + // audio produced since the page's last read, which is the tail of the utterance. + const drained = capture?.ring.drain(BRIDGE_AUDIO_RING_MAX_BYTES) + return { + stopped: end(), + base64: drained === undefined ? '' : bytesToBase64(drained.bytes), + droppedBytes: drained?.droppedBytes ?? 0 + } + }) }, dispose: () => { disposed = true