Spaces:
Sleeping
fix(#53+#54): page.tsx SPACE-hold PTT delegates to warm-stream hook
Browse filesFinal 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 +103 -4
- tests/test_space_hold_ptt_delegation.py +239 -0
|
@@ -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 =
|
| 1324 |
-
stopRecordingRef.current =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
|
@@ -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"
|