mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
feat(mobile): carry the capture's tail on the stop reply (OTA phase C, ruling 36)
`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
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -147,9 +147,27 @@ export type BridgeAudioChunk = z.infer<typeof audioReadResultSchema>
|
||||
/** 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(),
|
||||
|
||||
@@ -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<BridgeAudioChunk>
|
||||
/** False for a session that was not capturing, which is not a fault. */
|
||||
stopAudio: () => Promise<boolean>
|
||||
/** 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<z.infer<typeof audioStopResultSchema>>
|
||||
/** 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<boolean>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -73,15 +73,6 @@ export function createPageDictationCapture(
|
||||
const chunkHandlers: Handlers<(chunk: DictationCaptureChunk) => void> = new Set()
|
||||
const interruptionHandlers: Handlers<() => void> = new Set()
|
||||
let timer: ReturnType<typeof setInterval> | 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<void> | 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<void> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
async function endCapture(): Promise<void> {
|
||||
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<void> {
|
||||
// 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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user