perf: cap mobile dictation audio backlog (#4078)

This commit is contained in:
Neil 2026-05-31 03:33:00 -07:00 committed by GitHub
parent 66d9896cc7
commit 49153d933f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 120 additions and 2 deletions

View File

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import {
MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES,
MobileDictationPendingAudioBudget
} from './mobile-dictation-pending-audio-budget'
describe('MobileDictationPendingAudioBudget', () => {
it('caps pending raw PCM audio at five seconds', () => {
expect(MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES).toBe(160_000)
const budget = new MobileDictationPendingAudioBudget()
expect(budget.tryReserve(159_999)).toBe(true)
expect(budget.tryReserve(1)).toBe(true)
expect(budget.pendingAudioBytes).toBe(160_000)
expect(budget.tryReserve(1)).toBe(false)
expect(budget.pendingAudioBytes).toBe(160_000)
})
it('releases completed chunks and clamps stale releases after reset', () => {
const budget = new MobileDictationPendingAudioBudget(10)
expect(budget.tryReserve(6)).toBe(true)
budget.release(4)
expect(budget.pendingAudioBytes).toBe(2)
expect(budget.tryReserve(8)).toBe(true)
budget.reset()
budget.release(6)
expect(budget.pendingAudioBytes).toBe(0)
expect(budget.tryReserve(10)).toBe(true)
})
})

View File

@ -0,0 +1,46 @@
export const MOBILE_DICTATION_PCM_SAMPLE_RATE = 16000
const PCM_BYTES_PER_SAMPLE = 2
const MAX_PENDING_AUDIO_SECONDS = 5
// Why: dictation chunk RPCs can wait through reconnect before timing out, so
// cap retained raw microphone audio before it is expanded into base64 payloads.
export const MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES =
MOBILE_DICTATION_PCM_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_PENDING_AUDIO_SECONDS
export const MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE =
'Connection is too slow for voice dictation. Try again when the connection improves.'
export class MobileDictationPendingAudioBudget {
private pendingBytes = 0
constructor(private readonly maxPendingBytes = MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES) {}
get pendingAudioBytes(): number {
return this.pendingBytes
}
tryReserve(byteLength: number): boolean {
const normalizedByteLength = normalizeByteLength(byteLength)
if (this.pendingBytes + normalizedByteLength > this.maxPendingBytes) {
return false
}
this.pendingBytes += normalizedByteLength
return true
}
release(byteLength: number): void {
this.pendingBytes = Math.max(0, this.pendingBytes - normalizeByteLength(byteLength))
}
reset(): void {
this.pendingBytes = 0
}
}
function normalizeByteLength(byteLength: number): number {
if (!Number.isFinite(byteLength) || byteLength <= 0) {
return 0
}
return Math.floor(byteLength)
}

View File

@ -26,4 +26,26 @@ describe('useMobileDictation source invariants', () => {
expect(mirrorEffect).toContain('onErrorRef.current = onError')
expect(mirrorEffect).toContain('}, [client, enabled, onTranscript, onError])')
})
it('reserves pending audio bytes before encoding microphone chunks', () => {
const microphoneEffect = sliceBetween(
"addExpoTwoWayAudioEventListener('onMicrophoneData'",
'return () => sub.remove()'
)
const reserveIndex = microphoneEffect.indexOf('tryReserve(byteLength)')
const encodeIndex = microphoneEffect.indexOf('bytesToBase64(bytes)')
expect(reserveIndex).toBeGreaterThanOrEqual(0)
expect(encodeIndex).toBeGreaterThanOrEqual(0)
expect(reserveIndex).toBeLessThan(encodeIndex)
expect(microphoneEffect).toContain('MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE')
expect(microphoneEffect).toContain('pendingAudioBudgetRef.current.release(byteLength)')
})
it('resets pending audio bytes whenever pending chunk tracking is cleared', () => {
const pendingChunkClears = source.match(/pendingChunksRef\.current\.clear\(\)/g) ?? []
const pendingAudioResets = source.match(/pendingAudioBudgetRef\.current\.reset\(\)/g) ?? []
expect(pendingAudioResets).toHaveLength(pendingChunkClears.length)
})
})

View File

@ -11,6 +11,11 @@ import {
toggleRecording
} from '@orca/expo-two-way-audio'
import type { RpcClient } from '../transport/rpc-client'
import {
MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE,
MOBILE_DICTATION_PCM_SAMPLE_RATE,
MobileDictationPendingAudioBudget
} from './mobile-dictation-pending-audio-budget'
type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error'
@ -32,7 +37,6 @@ export type UseMobileDictationResult = {
cancel: () => Promise<void>
}
const MOBILE_PCM_SAMPLE_RATE = 16000
const DICTATION_FINISH_TIMEOUT_MS = 75_000
function bytesToBase64(bytes: Uint8Array): string {
@ -53,6 +57,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
const onTranscriptRef = useRef(onTranscript)
const onErrorRef = useRef(onError)
const pendingChunksRef = useRef<Set<Promise<void>>>(new Set())
const pendingAudioBudgetRef = useRef(new MobileDictationPendingAudioBudget())
const acceptingChunksRef = useRef(false)
const generationRef = useRef(0)
const finishingIdRef = useRef<string | null>(null)
@ -82,6 +87,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
activeIdRef.current = null
acceptingChunksRef.current = false
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
toggleRecording(false)
if (client && dictationId) {
void client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
@ -100,11 +106,16 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
}
const raw = event.data
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
const byteLength = bytes.byteLength
if (!pendingAudioBudgetRef.current.tryReserve(byteLength)) {
failActiveDictation(dictationId, new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE))
return
}
const sendChunk = client
.sendRequest('speech.dictation.chunk', {
dictationId,
audioBase64: bytesToBase64(bytes),
sampleRate: MOBILE_PCM_SAMPLE_RATE
sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE
})
.then((response) => {
if (!response.ok) {
@ -113,6 +124,9 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
})
.catch((err) => failActiveDictation(dictationId, err))
.finally(() => {
if (activeIdRef.current === dictationId || finishingIdRef.current === dictationId) {
pendingAudioBudgetRef.current.release(byteLength)
}
pendingChunksRef.current.delete(sendChunk)
})
pendingChunksRef.current.add(sendChunk)
@ -185,6 +199,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
acceptingChunksRef.current = true
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
toggleRecording(true)
setStatus('recording')
}, [])
@ -233,6 +248,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
activeIdRef.current = null
finishingIdRef.current = null
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
setStatus('idle')
if (text) {
onTranscriptRef.current(text)
@ -256,6 +272,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
finishingIdRef.current = null
acceptingChunksRef.current = false
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
toggleRecording(false)
if (client && dictationId) {
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
@ -287,6 +304,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
finishingIdRef.current = null
acceptingChunksRef.current = false
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
toggleRecording(false)
void tearDown()
if (clientRef.current && dictationId) {