Skip to content

Commit ff5744c

Browse files
authored
fix(voice): stop microphone capture during startup (#9696)
* test(voice): cover cancellation during startup * fix(voice): stop microphone capture during startup
1 parent de3ad2f commit ff5744c

2 files changed

Lines changed: 117 additions & 26 deletions

File tree

‎apps/sim/hooks/use-speech-to-text.ts‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ export function useSpeechToText({
223223
if (startingRef.current) return false
224224
startingRef.current = true
225225
const generation = ++generationRef.current
226+
setIsListening(true)
226227

227228
try {
228229
let tokenData: Awaited<ReturnType<typeof requestJson<typeof speechTokenContract>>>
@@ -236,6 +237,7 @@ export function useSpeechToText({
236237
if (!mountedRef.current || generation !== generationRef.current) return false
237238
if (isApiClientError(err) && err.status === 402) {
238239
const isMemberLimit = (err.body as { scope?: string } | null)?.scope === 'member'
240+
setIsListening(false)
239241
onUsageLimitExceededRef.current?.(err.message, isMemberLimit)
240242
return false
241243
}
@@ -262,6 +264,10 @@ export function useSpeechToText({
262264
}
263265

264266
streamRef.current = stream
267+
sessionTimerRef.current = setTimeout(() => {
268+
logger.info('Voice input session reached max duration, stopping')
269+
stopStreamingRef.current()
270+
}, MAX_SESSION_MS)
265271

266272
const params = new URLSearchParams({
267273
token,
@@ -349,19 +355,13 @@ export function useSpeechToText({
349355

350356
sendIntervalRef.current = setInterval(flushAudioBuffer, CHUNK_SEND_INTERVAL_MS)
351357

352-
setIsListening(true)
353-
354-
sessionTimerRef.current = setTimeout(() => {
355-
logger.info('Voice input session reached max duration, stopping')
356-
stopStreamingRef.current()
357-
}, MAX_SESSION_MS)
358-
359358
return true
360359
} catch (error) {
361360
if (generation !== generationRef.current) return false
362361
logger.error('Failed to start speech streaming', error)
363362
cleanup()
364363
if (mountedRef.current) {
364+
setIsListening(false)
365365
onErrorRef.current?.(classifyStartError(error))
366366
}
367367
return false
@@ -371,6 +371,14 @@ export function useSpeechToText({
371371
}, [cleanup, flushAudioBuffer])
372372

373373
const stopStreaming = useCallback(() => {
374+
if (startingRef.current) {
375+
generationRef.current++
376+
startingRef.current = false
377+
cleanup()
378+
setIsListening(false)
379+
return
380+
}
381+
374382
if (sessionTimerRef.current) {
375383
clearTimeout(sessionTimerRef.current)
376384
sessionTimerRef.current = null
@@ -426,7 +434,7 @@ export function useSpeechToText({
426434
}
427435

428436
setIsListening(false)
429-
}, [flushAudioBuffer])
437+
}, [cleanup, flushAudioBuffer])
430438

431439
stopStreamingRef.current = stopStreaming
432440

@@ -439,7 +447,7 @@ export function useSpeechToText({
439447
}, [cleanup])
440448

441449
const toggleListening = useCallback(() => {
442-
if (isListening) {
450+
if (startingRef.current || isListening) {
443451
stopStreaming()
444452
} else {
445453
startStreaming()

‎apps/sim/scripts/test-browser-extension-e2e.ts‎

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import { pageSnapshotSchema } from '@sim/browser-extension-protocol'
2222
import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
2323
import { createLogger } from '@sim/logger'
24+
import { createDeferred } from '@sim/testing/helpers/deferred'
2425
import { toNumberOrNull, toStringOrNull } from '@sim/utils/coerce'
2526
import { getErrorMessage } from '@sim/utils/errors'
2627
import { generateId } from '@sim/utils/id'
@@ -1416,19 +1417,27 @@ try {
14161417
}
14171418
)
14181419
await checkComposer(
1419-
'Voice input recovers permission, preserves drafts, and stops on Send or restart',
1420+
'Voice input cancels startup, recovers permission, and preserves drafts through Send or restart',
14201421
async () => {
14211422
const input = frame().getByRole('textbox', { name: 'Message', exact: true })
14221423
const mic = frame().getByRole('button', { name: 'Voice input', exact: true })
14231424
const stop = frame().getByRole('button', { name: 'Stop listening', exact: true })
14241425
const sessions: { socket: WebSocketRoute; chunks: number; closed: boolean }[] = []
1425-
await shell.route('**/api/speech/token', (route) => {
1426+
const connectionGate = createDeferred<void>()
1427+
const tokenGate = createDeferred<void>()
1428+
let holdToken = false
1429+
let tokenRequested = false
1430+
await shell.route('**/api/speech/token', async (route) => {
14261431
injectedRequests.add(route.request())
1432+
if (holdToken) {
1433+
tokenRequested = true
1434+
await tokenGate.promise
1435+
}
14271436
return route.fulfill({ json: { token: 'synthetic-extension-voice' } })
14281437
})
14291438
await shell.routeWebSocket(
14301439
'wss://api.elevenlabs.io/v1/speech-to-text/realtime*',
1431-
(socket) => {
1440+
async (socket) => {
14321441
const session = { socket, chunks: 0, closed: false }
14331442
sessions.push(session)
14341443
socket.onMessage((message) => {
@@ -1443,8 +1452,45 @@ try {
14431452
await socket.close()
14441453
session.closed = true
14451454
})
1455+
if (sessions.length === 1) await connectionGate.promise
14461456
}
14471457
)
1458+
type VoiceCaptureWindow = typeof window & {
1459+
__voiceCaptureProbe?: {
1460+
getUserMedia: MediaDevices['getUserMedia']
1461+
WebSocket: typeof WebSocket
1462+
tracks: MediaStreamTrack[]
1463+
sockets: WebSocket[]
1464+
}
1465+
}
1466+
await frame().evaluate(() => {
1467+
const probe = {
1468+
getUserMedia: navigator.mediaDevices.getUserMedia,
1469+
WebSocket: window.WebSocket,
1470+
tracks: [] as MediaStreamTrack[],
1471+
sockets: [] as WebSocket[],
1472+
}
1473+
;(window as VoiceCaptureWindow).__voiceCaptureProbe = probe
1474+
navigator.mediaDevices.getUserMedia = async (constraints) => {
1475+
const stream = await probe.getUserMedia.call(navigator.mediaDevices, constraints)
1476+
probe.tracks.push(...stream.getTracks())
1477+
return stream
1478+
}
1479+
window.WebSocket = class extends probe.WebSocket {
1480+
constructor(url: string | URL, protocols?: string | string[]) {
1481+
super(url, protocols)
1482+
probe.sockets.push(this)
1483+
}
1484+
}
1485+
})
1486+
const captureState = () =>
1487+
frame().evaluate(() => {
1488+
const probe = (window as VoiceCaptureWindow).__voiceCaptureProbe
1489+
return {
1490+
tracks: probe?.tracks.map((track) => track.readyState) ?? [],
1491+
sockets: probe?.sockets.map((socket) => socket.readyState) ?? [],
1492+
}
1493+
})
14481494
const microphone = await browserContext.newCDPSession(shell)
14491495
const setMicrophonePermission = async (setting: 'granted' | 'denied') => {
14501496
await microphone.send('Browser.setPermission', {
@@ -1490,9 +1536,34 @@ try {
14901536
await setPanelViewport(viewport.width, viewport.height)
14911537
await setMicrophonePermission('granted')
14921538
await mic.click()
1539+
await expect.poll(() => sessions.length).toBe(1)
1540+
await expect.poll(captureState).toEqual({ tracks: ['live'], sockets: [0] })
1541+
await screenshot('microphone-connecting')
1542+
await frame()
1543+
.getByRole('button', { name: /^(Voice input|Stop listening)$/ })
1544+
.click()
1545+
await expect.poll(captureState).toEqual({ tracks: ['ended'], sockets: [3] })
1546+
await expect.poll(() => sessions[0].closed).toBe(true)
1547+
voiceDiagnostics.push({ event: 'cancel-connecting', ...(await captureState()) })
1548+
connectionGate.resolve()
1549+
await expect(mic).toBeVisible()
1550+
await expect(input).toHaveValue('Keep my draft')
1551+
assert.equal(sessions[0].chunks, 0, 'Cancelled startup sent audio')
1552+
1553+
holdToken = true
1554+
await mic.click()
1555+
await expect.poll(() => tokenRequested).toBe(true)
1556+
await stop.click()
1557+
tokenGate.resolve()
1558+
holdToken = false
1559+
await expect(mic).toBeVisible()
1560+
await expect(input).toHaveValue('Keep my draft')
1561+
await mic.click()
14931562
await expect(stop).toBeVisible()
1494-
await expect.poll(() => sessions[0]?.chunks ?? 0).toBeGreaterThan(0)
1495-
sessions[0].socket.send(
1563+
await expect.poll(() => sessions[1]?.chunks ?? 0).toBeGreaterThan(0)
1564+
assert.equal(sessions.length, 2, 'Cancelled token request started another session')
1565+
assert.deepEqual((await captureState()).tracks, ['ended', 'live'])
1566+
sessions[1].socket.send(
14961567
JSON.stringify({ message_type: 'partial_transcript', text: 'first thought' })
14971568
)
14981569
await expect(input).toHaveValue('Keep my draft first thought')
@@ -1501,16 +1572,16 @@ try {
15011572
await expect(mic).toBeVisible()
15021573
await mic.click()
15031574
await expect(stop).toBeVisible()
1504-
await expect.poll(() => sessions.length).toBe(2)
1505-
sessions[0].socket.send(
1575+
await expect.poll(() => sessions.length).toBe(3)
1576+
sessions[1].socket.send(
15061577
JSON.stringify({ message_type: 'partial_transcript', text: 'stale thought' })
15071578
)
1508-
await expect.poll(() => sessions[0].closed).toBe(true)
1579+
await expect.poll(() => sessions[1].closed).toBe(true)
15091580
await expect(stop).toBeVisible()
15101581
await expect(input).toHaveValue('Keep my draft first thought')
1511-
const chunks = sessions[1].chunks
1512-
await expect.poll(() => sessions[1].chunks).toBeGreaterThan(chunks)
1513-
sessions[1].socket.send(
1582+
const chunks = sessions[2].chunks
1583+
await expect.poll(() => sessions[2].chunks).toBeGreaterThan(chunks)
1584+
sessions[2].socket.send(
15141585
JSON.stringify({ message_type: 'partial_transcript', text: 'second thought' })
15151586
)
15161587
await expect(input).toHaveValue('Keep my draft first thought second thought')
@@ -1519,31 +1590,43 @@ try {
15191590
assert.equal(submitted[0].message, 'Keep my draft first thought second thought')
15201591
await expect(input).toHaveValue('')
15211592
await expect(mic).toBeVisible()
1522-
await expect.poll(() => sessions[1].closed).toBe(true)
1593+
await expect.poll(() => sessions[2].closed).toBe(true)
15231594
await expect(
15241595
frame().getByRole('button', { name: 'Send message', exact: true })
15251596
).toBeVisible()
15261597
await input.fill('Another draft')
15271598
await mic.click()
15281599
await expect(stop).toBeVisible()
1529-
await expect.poll(() => sessions[2]?.chunks ?? 0).toBeGreaterThan(0)
1530-
sessions[2].socket.send(
1600+
await expect.poll(() => sessions[3]?.chunks ?? 0).toBeGreaterThan(0)
1601+
sessions[3].socket.send(
15311602
JSON.stringify({ message_type: 'partial_transcript', text: 'ready to send' })
15321603
)
15331604
await expect(input).toHaveValue('Another draft ready to send')
15341605
await screenshot('microphone-dictated-draft')
15351606
await stop.click()
15361607
await input.press('Enter')
15371608
await expect(input).toHaveValue('')
1538-
assert.equal(sessions[2].closed, false, 'Final-transcript grace must still be open')
1539-
sessions[2].socket.send(
1609+
assert.equal(sessions[3].closed, false, 'Final-transcript grace must still be open')
1610+
sessions[3].socket.send(
15401611
JSON.stringify({ message_type: 'partial_transcript', text: 'late words' })
15411612
)
1542-
await expect.poll(() => sessions[2].closed).toBe(true)
1613+
await expect.poll(() => sessions[3].closed).toBe(true)
15431614
await expect(input).toHaveValue('')
15441615
assert.equal(submitted.length, 2, 'Dictation submitted a message without Send')
15451616
await screenshot('microphone-after-send')
15461617
} finally {
1618+
voiceDiagnostics.push({ event: 'capture-finally', ...(await captureState()) })
1619+
connectionGate.resolve()
1620+
tokenGate.resolve()
1621+
await frame().evaluate(() => {
1622+
const probeWindow = window as VoiceCaptureWindow
1623+
const probe = probeWindow.__voiceCaptureProbe
1624+
if (!probe) return
1625+
navigator.mediaDevices.getUserMedia = probe.getUserMedia
1626+
window.WebSocket = probe.WebSocket
1627+
probe.tracks.forEach((track) => track.stop())
1628+
probeWindow.__voiceCaptureProbe = undefined
1629+
})
15471630
voiceDiagnostics.push({
15481631
event: 'finally',
15491632
sessions: sessions.map(({ chunks, closed }) => ({ chunks, closed })),

0 commit comments

Comments
 (0)