rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
2bb7b0f
Β·
1 Parent(s): 5b67f14

fix(#53+#54): page.tsx SPACE-hold PTT delegates to warm-stream hook

Browse files

Final wiring of the warm-stream/pre-roll engine (#53/#54). The SPACE-hold
push-to-talk path no longer cold-starts its own getUserMedia +
MediaRecorder per press; it delegates to streamingVoice.beginPushToTalk()
/ endPushToTalk(). The hook keeps the OS mic warm and prepends an 800ms
pre-roll, so the first word ('Sir.') is always at the head of one valid
webm blob (head-clip #53 eliminated) and capture begins instantly (no
multi-second start delay #54). All prior semantics preserved: e.repeat/
modifier/shouldSuppressSpace guards, recording/busy gate (+ new
spaceHoldPttInFlightRef re-entrancy guard), spaceHoldOwnsRec /
setSpaceHoldActive, bot-TTS interrupt, voicePhase UX, busy ownership left
to send() (avoids silent turn-drop), maybeResumeLive/userPrefersLive,
mic_permission_denied banner, sub-threshold tap = clean no-op, no
double-submit. On-screen PTT button left on legacy path (separate
handler, out of reported scope β€” tracked for follow-up).

tsc clean; build green; pytest 233 passed (+7 new
test_space_hold_ptt_delegation.py).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

frontend/src/app/page.tsx CHANGED
@@ -1306,6 +1306,93 @@ export default function Page() {
1306
  }
1307
  function stopRecording() { mediaRecorderRef.current?.stop(); }
1308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1309
  // KI-258 β€” Hold-SPACE-to-talk. Fixes from KI-257 first ship:
1310
  // (a) textarea ALWAYS had focus β†’ isInputFocused() was always true β†’
1311
  // SPACE never fired. Fix: allow SPACE-hold when the textarea is
@@ -1314,14 +1401,20 @@ export default function Page() {
1314
  // handlers mid-press, splitting keydown/keyup across closures
1315
  // with stale values. Fix: read all state via refs; deps reduced
1316
  // to [voiceMasterOn] so handlers bind once.
 
 
 
 
 
 
1317
  const startRecordingRef = useRef<(() => Promise<void>) | null>(null);
1318
- const stopRecordingRef = useRef<(() => void) | null>(null);
1319
  const recordingRef = useRef<boolean>(recording);
1320
  const busyRef = useRef<boolean>(busy);
1321
  const spaceHoldOwnsRecRef = useRef<boolean>(false);
1322
  useEffect(() => {
1323
- startRecordingRef.current = startRecording;
1324
- stopRecordingRef.current = stopRecording;
1325
  recordingRef.current = recording;
1326
  busyRef.current = busy;
1327
  });
@@ -1368,7 +1461,13 @@ export default function Page() {
1368
  spaceHoldOwnsRecRef.current = false;
1369
  setSpaceHoldActive(false);
1370
  const sp = stopRecordingRef.current;
1371
- if (sp && recordingRef.current) sp();
 
 
 
 
 
 
1372
  };
1373
  window.addEventListener("keydown", onKeyDown);
1374
  window.addEventListener("keyup", onKeyUp);
 
1306
  }
1307
  function stopRecording() { mediaRecorderRef.current?.stop(); }
1308
 
1309
+ // #53 (head-clip) / #54 (PTT start latency) β€” the SPACE-hold path now
1310
+ // DELEGATES to useStreamingVoice's warm-stream + pre-roll API instead of
1311
+ // cold-starting its own getUserMedia + MediaRecorder per press. The hook
1312
+ // keeps the OS mic hot and a rolling 800ms pre-roll, so the first word
1313
+ // ("Sir." that previously transcribed as "S A R") is always at the head of
1314
+ // the blob and there is no multi-second per-press start delay.
1315
+ //
1316
+ // Why delegation (not "prepend pre-roll blobs to page.tsx's recorder"):
1317
+ // splicing slices from two independent MediaRecorder streams produces a
1318
+ // corrupt webm container. The hook owns the single recorder that captured
1319
+ // BOTH the pre-roll and the held utterance, so only it can assemble a valid
1320
+ // blob. `endPushToTalk()` already runs the exact Sarvam submit+retry path
1321
+ // and, on success, delivers the transcript via the hook's onFinalTranscript
1322
+ // callback β€” which page.tsx wires (see useStreamingVoice config above) to
1323
+ // `voiceSubmitRef.current` β†’ `send()`, i.e. the IDENTICAL downstream path a
1324
+ // successful /api/transcribe result used in recorder.onstop. We therefore
1325
+ // MUST NOT re-submit the returned string here (that would double-fire); the
1326
+ // returned value is used only to distinguish a deliberate hold from a
1327
+ // sub-threshold tap (null) for clean state reset.
1328
+ const spaceHoldPttInFlightRef = useRef<boolean>(false);
1329
+ async function startSpaceHoldPTT() {
1330
+ // Re-entrancy guard: a second SPACE keydown while a delegated PTT capture
1331
+ // (or its in-flight transcription) is still resolving must be ignored,
1332
+ // mirroring the original recordingRef/busyRef "don't start twice" intent.
1333
+ if (spaceHoldPttInFlightRef.current) return;
1334
+ spaceHoldPttInFlightRef.current = true;
1335
+ // Preserve recorder.onstop / startRecording semantics: silence any prior
1336
+ // bot TTS the instant the user starts talking (KI-222 FIX 1 / V3 FIX 2).
1337
+ try { interruptBotAudio("ptt-start"); } catch { /* ignore */ }
1338
+ // STT-in-flight UX: the original path flipped voicePhase to "transcribing"
1339
+ // while Sarvam ran. endPushToTalk() runs that same Sarvam call internally,
1340
+ // so set the phase here. NOTE: we deliberately do NOT setBusy(true) β€” the
1341
+ // hook's onFinalTranscript β†’ voiceSubmitRef β†’ send() owns busy, and send()
1342
+ // early-returns if busy is already true (would silently drop the turn).
1343
+ setVoicePhase("transcribing"); // KI-038 β€” STT in flight on PTT
1344
+ try {
1345
+ streamingVoice.beginPushToTalk();
1346
+ } catch (e) {
1347
+ // beginPushToTalk itself is non-throwing by contract, but stay defensive
1348
+ // and route any unexpected failure to the same red banner the cold path
1349
+ // used. Warm-stream / permission failures surface via the hook's
1350
+ // onVoiceError β†’ setVoiceErrorBanner (mic_permission_denied) already.
1351
+ console.error(e);
1352
+ spaceHoldPttInFlightRef.current = false;
1353
+ setVoicePhase(null);
1354
+ setSpaceHoldActive(false);
1355
+ setVoiceErrorBanner({ type: "mic_permission_denied", ts: Date.now() });
1356
+ }
1357
+ }
1358
+ async function stopSpaceHoldPTT() {
1359
+ if (!spaceHoldPttInFlightRef.current) return;
1360
+ // KI-028 β€” resume Live ONLY if the user's persistent preference is still
1361
+ // "on" (matches the original recorder.onstop maybeResumeLive semantics).
1362
+ const maybeResumeLive = () => { if (userPrefersLive) live.setLive(true); };
1363
+ try {
1364
+ // endPushToTalk(): on a deliberate hold it transcribes (pre-roll + held
1365
+ // utterance) AND delivers via onFinalTranscript β†’ voiceSubmitRef β†’
1366
+ // send() β€” the EXACT downstream path the old recorder.onstop success
1367
+ // branch used. On a sub-threshold tap / empty capture it resolves to
1368
+ // null and submits nothing. All transport / Sarvam failures surface via
1369
+ // the hook's onVoiceError (transcribe_failed banner) β€” never silent.
1370
+ const text = await streamingVoice.endPushToTalk();
1371
+ if (text === null) {
1372
+ // Sub-threshold tap or nothing captured: clean no-op. No empty submit;
1373
+ // just clear the cosmetic "transcribing" phase we set on keydown.
1374
+ setVoicePhase(null);
1375
+ maybeResumeLive();
1376
+ } else {
1377
+ // Deliberate hold: the hook has ALREADY pushed `text` through
1378
+ // onFinalTranscript β†’ send(). send() owns busy + flips voicePhase to
1379
+ // "thinking" and clears both in its own finally, so we must NOT reset
1380
+ // voicePhase here (that would stomp send()'s "thinking") and must NOT
1381
+ // re-submit. Just resume Live per the user's preference.
1382
+ maybeResumeLive();
1383
+ }
1384
+ } catch (e) {
1385
+ // endPushToTalk's internal Sarvam path swallows its own errors and
1386
+ // surfaces them via onVoiceError; reaching here means an unexpected
1387
+ // throw. Reset cosmetic state so the UI doesn't stick in "transcribing".
1388
+ console.error(e);
1389
+ setVoicePhase(null);
1390
+ maybeResumeLive();
1391
+ } finally {
1392
+ spaceHoldPttInFlightRef.current = false;
1393
+ }
1394
+ }
1395
+
1396
  // KI-258 β€” Hold-SPACE-to-talk. Fixes from KI-257 first ship:
1397
  // (a) textarea ALWAYS had focus β†’ isInputFocused() was always true β†’
1398
  // SPACE never fired. Fix: allow SPACE-hold when the textarea is
 
1401
  // handlers mid-press, splitting keydown/keyup across closures
1402
  // with stale values. Fix: read all state via refs; deps reduced
1403
  // to [voiceMasterOn] so handlers bind once.
1404
+ // #53/#54 β€” these refs now point at the DELEGATED SPACE-hold handlers
1405
+ // (startSpaceHoldPTT / stopSpaceHoldPTT), which route through the hook's
1406
+ // warm-stream + pre-roll API. The on-screen Push-to-talk *button* still
1407
+ // uses startRecording/stopRecording directly (separate onClick handler,
1408
+ // intentionally untouched β€” it does not share this code path), so the
1409
+ // legacy recorder path is preserved for that control.
1410
  const startRecordingRef = useRef<(() => Promise<void>) | null>(null);
1411
+ const stopRecordingRef = useRef<(() => Promise<void>) | null>(null);
1412
  const recordingRef = useRef<boolean>(recording);
1413
  const busyRef = useRef<boolean>(busy);
1414
  const spaceHoldOwnsRecRef = useRef<boolean>(false);
1415
  useEffect(() => {
1416
+ startRecordingRef.current = startSpaceHoldPTT;
1417
+ stopRecordingRef.current = stopSpaceHoldPTT;
1418
  recordingRef.current = recording;
1419
  busyRef.current = busy;
1420
  });
 
1461
  spaceHoldOwnsRecRef.current = false;
1462
  setSpaceHoldActive(false);
1463
  const sp = stopRecordingRef.current;
1464
+ // Original guard was `recordingRef.current` (the legacy `recording`
1465
+ // state). The delegated path doesn't drive `recording` (the hook owns
1466
+ // capture state), so gate on the delegated in-flight flag instead β€” the
1467
+ // exact equivalent of "a capture this keydown started is still active".
1468
+ // Still resolve a deliberate hold even on a sub-threshold tap:
1469
+ // stopSpaceHoldPTT is a clean no-op when nothing is in flight.
1470
+ if (sp && spaceHoldPttInFlightRef.current) void sp();
1471
  };
1472
  window.addEventListener("keydown", onKeyDown);
1473
  window.addEventListener("keyup", onKeyUp);
tests/test_space_hold_ptt_delegation.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression test for #53 (PTT head-clip) + #54 (PTT start latency) β€”
2
+ the page.tsx SPACE-hold DELEGATION wiring β€” 2026-05-18.
3
+
4
+ WHY A SEPARATE TEST FROM test_ptt_preroll_warm_stream.py
5
+ -----------------------------------------------------------------------------
6
+ test_ptt_preroll_warm_stream.py pins the *hook's* pure pre-roll + hold-gate
7
+ logic (PreRollRing / evaluateHoldGate in useStreamingVoice.ts) and the
8
+ backend STT head-survival contract. It does NOT pin the remaining surface:
9
+ that page.tsx's SPACE-hold path actually DELEGATES to that hook instead of
10
+ cold-starting its own getUserMedia + MediaRecorder per press. Naive prepending
11
+ of the hook's pre-roll blobs onto page.tsx's *separate* recorder chunks would
12
+ produce a corrupt webm (two independent MediaRecorder streams); the only
13
+ correct fix is delegation. This file pins that delegation + every
14
+ non-negotiable semantic it must preserve.
15
+
16
+ WHY A SOURCE-ASSERTION TEST (no JS runner)
17
+ -----------------------------------------------------------------------------
18
+ This repo has NO JS/React test harness β€” frontend/package.json has no `test`
19
+ script, no jest/vitest, and `tests/` is a pure pytest (Python) suite. The
20
+ SPACE-hold delegation is browser DOM event wiring inside a React client
21
+ component; it cannot be exercised from Python, and standing up a
22
+ jest/RTL/jsdom toolchain would be a large out-of-scope change to shared
23
+ frontend tooling. The existing sibling test (test_ptt_preroll_warm_stream.py)
24
+ explicitly acknowledges this same constraint ("There is no JS test runner in
25
+ this repo, so the two contracts are pinned at the layers a Python test can
26
+ reach honestly") and pins frontend contracts via source assertions. This file
27
+ follows that established repo idiom: it asserts the structural wiring
28
+ contract in the SHIPPED page.tsx so a future refactor that silently
29
+ re-introduces the per-press cold start (or drops a preserved semantic) fails
30
+ CI loudly.
31
+
32
+ Run:
33
+ cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
34
+ PYTHONPATH=$PWD .venv/bin/python -m pytest \
35
+ tests/test_space_hold_ptt_delegation.py -v
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import re
41
+ from pathlib import Path
42
+
43
+ import pytest
44
+
45
+ REPO = Path(__file__).resolve().parents[1]
46
+ PAGE = REPO / "frontend" / "src" / "app" / "page.tsx"
47
+
48
+
49
+ @pytest.fixture(scope="module")
50
+ def src() -> str:
51
+ return PAGE.read_text()
52
+
53
+
54
+ def _strip_comments(code: str) -> str:
55
+ """Drop // line-comment content (and blank-after-strip lines) so negative
56
+ assertions ("must NOT call send()/setBusy(true)") match only real CODE,
57
+ not the explanatory comments that legitimately *name* the avoided trap."""
58
+ out = []
59
+ for line in code.splitlines():
60
+ # Remove an inline/standalone // comment. No string literal in the
61
+ # delegated handlers contains "//", so a plain split is safe here.
62
+ if "//" in line:
63
+ line = line[: line.index("//")]
64
+ if line.strip():
65
+ out.append(line)
66
+ return "\n".join(out)
67
+
68
+
69
+ def _slice(src: str, start_marker: str, end_marker: str) -> str:
70
+ """Return the source between (and including) two anchor substrings.
71
+ Asserts both anchors exist exactly once so the test fails loudly if the
72
+ region is renamed rather than silently asserting against the wrong code.
73
+ """
74
+ assert src.count(start_marker) == 1, f"anchor not unique: {start_marker!r}"
75
+ assert src.count(end_marker) == 1, f"anchor not unique: {end_marker!r}"
76
+ i = src.index(start_marker)
77
+ j = src.index(end_marker, i) + len(end_marker)
78
+ assert j > i, f"end anchor precedes start anchor: {end_marker!r}"
79
+ return src[i:j]
80
+
81
+
82
+ def test_space_handler_refs_point_at_delegated_funcs(src: str):
83
+ """The SPACE keydown/keyup refs must be wired to the DELEGATED handlers
84
+ (startSpaceHoldPTT / stopSpaceHoldPTT), NOT the legacy per-press
85
+ cold-start startRecording/stopRecording. This is the core #53/#54 fix."""
86
+ assert "startRecordingRef.current = startSpaceHoldPTT;" in src, (
87
+ "SPACE keydown ref no longer points at the delegated handler β€” the "
88
+ "per-press getUserMedia cold start (head-clip #53 + latency #54) may "
89
+ "have been re-introduced."
90
+ )
91
+ assert "stopRecordingRef.current = stopSpaceHoldPTT;" in src
92
+ # The legacy refs must NOT be re-bound to the cold-start path.
93
+ assert "startRecordingRef.current = startRecording;" not in src
94
+ assert "stopRecordingRef.current = stopRecording;" not in src
95
+
96
+
97
+ def test_delegated_start_calls_hook_beginPushToTalk(src: str):
98
+ """startSpaceHoldPTT must engage the hook's warm-stream PTT, never spin
99
+ its own getUserMedia / MediaRecorder."""
100
+ fn = _slice(
101
+ src,
102
+ "async function startSpaceHoldPTT() {",
103
+ "async function stopSpaceHoldPTT() {",
104
+ )
105
+ assert "streamingVoice.beginPushToTalk()" in fn, (
106
+ "startSpaceHoldPTT does not delegate to the hook's beginPushToTalk"
107
+ )
108
+ # No independent capture device may be opened on the delegated path.
109
+ assert "getUserMedia" not in fn, "delegated start must not cold-start a mic"
110
+ assert "new MediaRecorder" not in fn, (
111
+ "delegated start must not create its own recorder (corrupt-webm trap)"
112
+ )
113
+
114
+
115
+ def test_delegated_stop_calls_hook_endPushToTalk_and_does_not_resubmit(src: str):
116
+ """stopSpaceHoldPTT must finalize via the hook's endPushToTalk().
117
+
118
+ The hook ALREADY delivers a deliberate-hold transcript via
119
+ onFinalTranscript -> voiceSubmitRef -> send() (the exact downstream path
120
+ the old recorder.onstop success branch used). Re-feeding the returned
121
+ string into send() here would double-submit, so the delegated stop must
122
+ NOT call send()/voiceSubmitRef itself."""
123
+ fn = _slice(
124
+ src,
125
+ "async function stopSpaceHoldPTT() {",
126
+ " // #53/#54 β€” these refs now point at the DELEGATED SPACE-hold",
127
+ )
128
+ assert "await streamingVoice.endPushToTalk()" in fn
129
+ # No re-submission on the delegated stop path (hook already submitted).
130
+ # Check against CODE only β€” the comments legitimately name send() while
131
+ # explaining WHY we must not call it.
132
+ code = _strip_comments(fn)
133
+ assert "send(" not in code, (
134
+ "delegated stop re-submits the transcript β€” double-fire; the hook's "
135
+ "onFinalTranscript already routed it through send()"
136
+ )
137
+ assert "voiceSubmitRef" not in code
138
+
139
+
140
+ def test_sub_threshold_tap_is_clean_noop(src: str):
141
+ """endPushToTalk() resolves null on a sub-threshold tap / empty capture.
142
+ The delegated stop must treat null as a clean no-op (no empty submit,
143
+ cosmetic state reset) β€” a non-negotiable semantic."""
144
+ fn = _slice(
145
+ src,
146
+ "async function stopSpaceHoldPTT() {",
147
+ " // #53/#54 β€” these refs now point at the DELEGATED SPACE-hold",
148
+ )
149
+ assert "text === null" in fn, (
150
+ "delegated stop does not branch on the null (tap) result β€” a "
151
+ "sub-threshold tap may submit empty / leave state stuck"
152
+ )
153
+ # On the null branch the cosmetic phase set on keydown is cleared.
154
+ assert "setVoicePhase(null)" in fn
155
+
156
+
157
+ def test_preserved_semantics_present_on_delegated_path(src: str):
158
+ """Enumerate the non-negotiable semantics the original SPACE path had and
159
+ assert each survives on the delegated path."""
160
+ start = _slice(
161
+ src,
162
+ "async function startSpaceHoldPTT() {",
163
+ "async function stopSpaceHoldPTT() {",
164
+ )
165
+ stop = _slice(
166
+ src,
167
+ "async function stopSpaceHoldPTT() {",
168
+ " // #53/#54 β€” these refs now point at the DELEGATED SPACE-hold",
169
+ )
170
+
171
+ # interruptBotAudio("ptt-start") β€” silence prior bot TTS the instant the
172
+ # user starts talking (was startRecording's first action).
173
+ assert 'interruptBotAudio("ptt-start")' in start
174
+
175
+ # voicePhase "transcribing" UX while STT is in flight (was recorder.onstop).
176
+ assert 'setVoicePhase("transcribing")' in start
177
+
178
+ # busy is NOT force-set true before delegating β€” send() (invoked by the
179
+ # hook) owns busy and early-returns if busy is already true. Setting it
180
+ # here would silently drop the turn. Pin that this trap is avoided
181
+ # (CODE only; the comment legitimately names setBusy(true)).
182
+ assert "setBusy(true)" not in _strip_comments(start), (
183
+ "delegated start sets busy=true before send() runs β€” send() will "
184
+ "early-return on `busy` and the spoken turn is silently dropped"
185
+ )
186
+
187
+ # maybeResumeLive with the userPrefersLive gate (KI-028) survives.
188
+ assert "userPrefersLive" in stop and "live.setLive(true)" in stop
189
+
190
+ # mic_permission_denied banner surfacing is still reachable on the
191
+ # delegated start (defensive path) β€” matches the cold path's catch.
192
+ assert 'setVoiceErrorBanner({ type: "mic_permission_denied"' in start
193
+
194
+ # Re-entrancy guard equivalent to the old recordingRef/busyRef
195
+ # "don't start twice" intent.
196
+ assert "spaceHoldPttInFlightRef" in start
197
+
198
+
199
+ def test_keydown_keyup_guards_unchanged(src: str):
200
+ """e.repeat / modifier guards, shouldSuppressSpace(), spaceHoldOwnsRecRef
201
+ ownership tracking, setSpaceHoldActive, and preventDefault must all remain
202
+ on the SPACE handlers (non-negotiable: a refactor must not nuke a
203
+ legitimately-typed space in the textarea)."""
204
+ handlers = _slice(
205
+ src,
206
+ "const onKeyDown = (e: KeyboardEvent) => {",
207
+ "window.addEventListener(\"keydown\", onKeyDown);",
208
+ )
209
+ # e.repeat + all modifier guards.
210
+ assert "if (e.repeat) return;" in handlers
211
+ assert "e.metaKey || e.ctrlKey || e.altKey || e.shiftKey" in handlers
212
+ # textarea guard still gates SPACE-hold.
213
+ assert "if (shouldSuppressSpace()) return;" in handlers
214
+ # ownership tracking + visual state both directions.
215
+ assert "spaceHoldOwnsRecRef.current = true;" in handlers
216
+ assert "spaceHoldOwnsRecRef.current = false;" in handlers
217
+ assert "setSpaceHoldActive(true);" in handlers
218
+ assert "setSpaceHoldActive(false);" in handlers
219
+ # the "did THIS keydown own the press" guard before stopping.
220
+ assert "if (!spaceHoldOwnsRecRef.current) return;" in handlers
221
+ # preventDefault on both keydown + keyup (stop a stray typed space).
222
+ assert handlers.count("e.preventDefault();") >= 2
223
+ # busy still blocks a fresh SPACE-hold start (turn in flight) and the
224
+ # delegated in-flight flag gates the stop call.
225
+ assert "if (recordingRef.current || busyRef.current) return;" in handlers
226
+ assert "spaceHoldPttInFlightRef.current) void sp();" in handlers
227
+
228
+
229
+ def test_onscreen_button_path_left_intact(src: str):
230
+ """The on-screen Push-to-talk *button* uses a separate onClick handler
231
+ (startRecording/stopRecording) and does NOT share the SPACE code path, so
232
+ per the task it must be left untouched. Assert the legacy recorder
233
+ functions still exist and the button still uses them."""
234
+ assert "async function startRecording() {" in src
235
+ assert "function stopRecording() {" in src
236
+ # Button onClick still toggles the legacy recorder path.
237
+ assert re.search(
238
+ r"onClick=\{recording \? stopRecording : startRecording\}", src
239
+ ), "on-screen Push-to-talk button onClick wiring changed unexpectedly"