feat(harmony-client): live PTT voice recognition in Practice (ZEB-152 slice 3) - #48
Conversation
…ce 3) The payoff slice. Wires the existing AudioCapture + WasmPipeline.process() into FlashcardView's PTT handlers so holding the button captures PCM, releasing it flushes through the classifier to get syllable nibbles, and feeds those into the existing handleRowComplete() evaluation logic. Design choices: * Lazy-async capture start on first PTT press, one AudioCapture instance reused across holds for the component's lifetime. Releasing the mic on unmount (tab switch out of Practice) is handled via $effect cleanup. getUserMedia latency on first press means the first hold may capture less PCM than expected; falls through to the existing "cancel row, break combo" path cleanly. * PTT release = row-attempt commit. If PCM was captured and processPcm returns non-empty syllables, call handleRowComplete(nibbles) and let its existing pass/fail/combo logic drive. Empty buffer, empty syllables, or a thrown processPcm use the pre-Slice-3 cancel path — one fallback covers "didn't hold long enough", "mic permission still resolving", "classifier heard nothing" and "WASM error" consistently. * PTT disabled when !stq8Service.isCalibrated(), with a hint pointing to the Calibrate tab. Prevents garbage nibbles from an uncalibrated classifier. SpellbookMode remounts FlashcardView on tab switch, so the gate re-evaluates fresh after the user finishes calibrating. * FlashcardView's prop type widened from an inline 3-method shape to Stq8ServiceLike so we can reach isCalibrated + processPcm without duplicating the interface contract. Out of scope for this slice (Slice 4 polish): * Mismatch display (expected vs heard side-by-side with caret under first-differing byte) * 2-second momentum timeout during held PTT * "Couldn't hear that clearly" UX for empty-syllable releases * Permission denial recovery flow (currently just surfaces the error message below the button) Verification: 1121 tests pass (+2 for new calibration-gate UI tests), svelte-check clean on touched files, vite build clean (main bundle +2.8 KB gzipped, mostly the capture wiring). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
PR author is in the excluded authors list. |
|
CodeAnt AI is reviewing your PR. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
📜 Recent review details⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
🔇 Additional comments (9)
📝 WalkthroughWalkthroughAdded async push-to-talk with lazy AudioCapture and PCM buffering; PTT start/stop now classify captured audio via Changes
Sequence DiagramsequenceDiagram
actor User
participant FlashcardView
participant AudioCapture
participant stq8Service
User->>FlashcardView: Press PTT
activate FlashcardView
FlashcardView->>FlashcardView: clear pcmBuffer, set pttActive/segmentStart
FlashcardView->>AudioCapture: ensureCapture()/start()
activate AudioCapture
AudioCapture-->>FlashcardView: streaming PCM frames
deactivate AudioCapture
Note over User,AudioCapture: PCM buffered while PTT held
User->>FlashcardView: Release PTT
activate FlashcardView
FlashcardView->>FlashcardView: flush pcmBuffer -> pcm
FlashcardView->>stq8Service: processPcm(pcm)
activate stq8Service
stq8Service-->>FlashcardView: heardNibbles (or empty / error)
deactivate stq8Service
alt heardNibbles non-empty
FlashcardView->>FlashcardView: handleRowComplete(heardNibbles)
else empty or error
FlashcardView->>FlashcardView: cancel/reset combo (fallback)
end
deactivate FlashcardView
Estimated code review effort🎯 4 (Complex) | ⏱️ ~40 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Review Summary by QodoIntegrate live PTT voice recognition into Practice mode
WalkthroughsDescription• Integrates live voice capture into Practice mode PTT button • Flushes captured PCM through classifier on button release • Disables PTT with calibration hint when classifier uncalibrated • Adds lazy-async AudioCapture initialization and cleanup on unmount Diagramflowchart LR
PTTPress["PTT Button Press"] -->|ensureCapture| AudioCapture["AudioCapture Start"]
AudioCapture -->|onPcmFrame| PCMBuffer["PCM Buffer"]
PTTRelease["PTT Button Release"] -->|flushAndClassify| Classifier["processPcm"]
Classifier -->|syllables| RowEval["handleRowComplete"]
RowEval -->|pass/fail| RowState["Update Row State"]
Unmount["Component Unmount"] -->|cleanup| StopCapture["AudioCapture Stop"]
CalibCheck["isCalibrated Check"] -->|false| DisablePTT["Disable PTT + Show Hint"]
File Changes1. src/lib/components/__tests__/FlashcardView.test.ts
|
Code Review by Qodo
1.
|
User descriptionSummary
Design decisions worth flaggingPTT release as the row-attempt commit point. If PCM was captured and Lazy capture vs prefetch on mount. Considered starting capture when the Practice tab mounts, but that keeps the mic indicator on even when the user is just reading cards. Lazy-on-first-press means first hold may catch less PCM than expected (getUserMedia latency), but the fallback path handles that cleanly and subsequent holds are tight. Prop type widened to Out of scope (Slice 4 polish)
Verification
Test plan
Related: ZEB-152, closes part of ZEB-152 (Slice 1 already landed as #47; Slice 4 is follow-up polish). 🤖 Generated with Claude Code Note Medium Risk Overview PTT is now gated on calibration ( Updates Reviewed by Cursor Bugbot for commit dc46408. Bugbot is set up for automated code reviews on this repo. Configure here. CodeAnt-AI DescriptionUse push-to-talk voice input in Practice, with calibration required first What Changed
Impact
Details💡 Usage GuideChecking Your Pull RequestEvery time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later. Talking to CodeAnt AIGot a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask: This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code. ExamplePreserve Org Learnings with CodeAntYou can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input: This helps CodeAnt AI learn and adapt to your team's coding style and standards. ExampleRetrigger reviewAsk CodeAnt AI to review the PR again, by typing: Check Your Repository HealthTo analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health. |
PR Code Suggestions ✨Latest suggestions up to commit
|
| Category | Suggestion | Severity |
| Race condition |
✅
| Major |
|
CodeAnt AI finished reviewing your PR. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/lib/components/FlashcardView.svelte`:
- Around line 56-69: ensureCapture can leave an AudioCapture running if the
component unmounts while capture.start() is pending; fix by making ensureCapture
use a local capture variable and, after await capture.start(...), check a
mounted/destroyed flag (set by the teardown logic that runs in the teardown
block referenced at lines 237-243) before assigning to the module-level
audioCapture—if the component is already unmounted, call capture.stop() and do
not set audioCapture; also update the teardown to stop any in-progress local
capture if it exists (use the same mounted/destroyed boolean or check
audioCapture/local capture) so capture.start/started instances are always
cleaned up.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: c73d3a3a-8017-4e8a-822a-1a643a87bf38
📒 Files selected for processing (2)
src/lib/components/FlashcardView.sveltesrc/lib/components/__tests__/FlashcardView.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cursor Bugbot
🔇 Additional comments (1)
src/lib/components/__tests__/FlashcardView.test.ts (1)
83-111: Good coverage for the calibration gate.These assertions pin both visible outcomes of
isCalibrated()in the template: the PTT disabled state and the hint copy. That should catch regressions if those branches drift.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit dc46408. Configure here.
Two PR #48 review findings, both real correctness bugs. ensureCapture() only guarded on `audioCapture`, which is assigned *after* the getUserMedia / worklet-addModule await chain. Two rapid press/release cycles before the first start() resolved would each sail past the null guard, create separate AudioCapture instances, and the second assignment would orphan the first with an open mic that cleanup couldn't reach. Unmount during the pending window had the same problem from the other side: cleanup saw audioCapture=null and didn't stop anything, then start() resolved into a live capture on an already-unmounted component. Fixed by serializing concurrent callers on a shared captureStart promise (so the second press awaits the first rather than racing it) plus a `destroyed` flag. When start() resolves, the IIFE checks destroyed first — if true, it stops the capture itself rather than assigning to a dead component. Both captureStart and destroyed are plain lets, not $state, since they're internal to the capture lifecycle and don't drive any reactivity. PttButton.deactivate() returned early when `disabled` was true, which silently swallowed releases for already-activated inputs. If a parent flipped `disabled` true mid-hold — FlashcardView now does this via `!isCalibrated()`, and voice-comms could do similar on permission revoke or rate limit — onPttStop would never fire and the parent's pttActive would stick true forever. Fixed by dropping the `disabled` guard in deactivate() and handleKeyUp(); the existing `activeInputs.has(source)` check already rejects releases for inputs that were never activated. activate() keeps its disabled guard — rejecting new interactions on a disabled control is still correct. Added regression tests covering the mid-hold disabled transition for both mouse and keyboard paths. 1125 tests pass (+4), build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Addressed as `aae96eb`. Both findings had real correctness bugs under the framing — took them in order. Finding 1: Mic start race + unmount leakVerified the trace. `ensureCapture()`'s only guard was `if (audioCapture) return`, but `audioCapture` gets assigned after `await capture.start(...)` — which internally chains `getUserMedia` + `audioWorklet.addModule` + `createMediaStreamSource`. Two rapid press/release cycles before the first start resolves both pass the null guard, both call `capture.start()`, and the second assignment overwrites the first — orphaning a live AudioCapture with an open MediaStream and AudioContext that cleanup can't ever reach. The unmount case is the same shape from the other side: `$effect` cleanup sees `audioCapture=null` (start hasn't resolved yet), does nothing. Then start() resolves on a dead component, assigns a live capture to a variable nobody holds. Fix: serialize on a shared `captureStart: Promise | null`. First caller creates the promise and kicks off the IIFE; concurrent callers get handed the same promise to await instead of racing a second start. When the IIFE's `await capture.start()` resolves, it checks a `destroyed` flag (set by cleanup) — if true, it stops the capture itself and returns without assigning. The `captureStart = null` runs in `finally` so a failed start clears the slot and the next PTT press retries fresh. `destroyed` and `captureStart` are plain lets, not `$state` — they're internal plumbing that doesn't drive any reactivity. Finding 2: disabled blocks PTT stopVerified the trace. `PttButton.deactivate()` opened with `if (disabled) return`, which ran before removing the input from `activeInputs` and calling `onPttStop`. If a parent flipped `disabled` true mid-hold — FlashcardView now does exactly this via `!stq8Service.isCalibrated()`, and voice-comms could do similar on permission revoke or rate-limit trip — the release would be silently swallowed, and the parent's `pttActive` would stick true forever because onPttStop is the only signal that ever clears it. Same bug in `handleKeyUp` via the `|| disabled` in its early return — if Space was pressed while enabled and `disabled` flipped true before keyup, the release silently vanished. Fix: dropped the `disabled` guards from `deactivate()` and `handleKeyUp()`. The existing `activeInputs.has(source)` check already rejects releases for inputs that were never activated, so no spurious stops fire. `activate()` keeps its `disabled` guard intact — rejecting new interactions on a disabled control is still correct; the asymmetry is the point. This is a shared component (used by voice-comms too via `CodecToggle`), so the fix pays dividends beyond Practice. Added two regression tests covering the mid-hold transition (mouse + keyboard) so this can't silently regress later. Verification
Still awaiting manual end-to-end verification from @jenglund on the audio loop feel — the test plan in the PR description walks through it. The race fix won't be observable in normal use (would require rapid-fire press cycles in <300ms to trigger), but the disabled-mid-hold fix shores up an invariant for any future flow that might toggle the gate during a hold. |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/lib/components/__tests__/PttButton.test.ts`:
- Around line 155-187: Add a test to cover the missing touch-regression: in
src/lib/components/__tests__/PttButton.test.ts add a case mirroring the
mouse/keyboard tests that verifies a touchstart followed by the component being
rerendered with disabled: true will still call onPttStop on touchend (and
touchcancel). Use the same setup pattern (render PttButton with
onPttStart/onPttStop fns, fireEvent.touchStart on the button, rerender with
active: true, disabled: true, then fireEvent.touchEnd and fireEvent.touchCancel
and assert onPttStop was called); ensure this exercises the shared deactivate()
path in PttButton so mobile touch unwinds correctly.
In `@src/lib/components/PttButton.svelte`:
- Around line 35-44: The mouse/pointer release can be lost when the button
becomes disabled mid-press; update the PttButton logic so releases always
unwind: on mousedown call setPointerCapture(e.pointerId) (and release on
deactivate) and/or add a window-level pointerup/pointercancel listener (mirror
the existing <svelte:window> keyboard listener) that calls deactivate('mouse')
and clears activeInputs; ensure deactivate, activeInputs.delete(source) and the
onPttStop() check are used so pttActive cannot stay stuck when the element is
disabled.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3504d9f7-7ec7-487f-891c-1d7c4ddef1f0
📒 Files selected for processing (3)
src/lib/components/FlashcardView.sveltesrc/lib/components/PttButton.sveltesrc/lib/components/__tests__/PttButton.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cursor Bugbot
🔇 Additional comments (2)
src/lib/components/FlashcardView.svelte (2)
52-96: The capture-start serialization and teardown race look solid.
captureStartplusdestroyedcloses the overlapping-start / late-resolving-unmount hole cleanly, and the cleanup path now releases both assigned captures and starts that resolve after teardown.Also applies to: 259-273
119-180: The release-to-classify flow stays well aligned with the existing scoring path.Routing non-empty classifier output through
handleRowComplete()and falling back to the prior cancel/combo-reset behavior on empty/error cases keeps the new PTT flow consistent with the rest of Practice. The calibration gate and capture error hint are also surfaced clearly.Also applies to: 324-335
…EB-152)
CodeRabbit was right about the mouse-on-disabled gap — and the
previous fix's test was a false positive. Modern browsers (Chrome
M120+, Safari 17+, Firefox 124+) filter mouseup/click on disabled
form controls per HTML spec, so my earlier `disabled` guard
removal in deactivate() didn't actually reach real-browser mouse
releases when disabled flipped mid-hold. jsdom doesn't replicate
that filtering, which is why the regression test passed while the
production bug persisted.
Fix: added a window-level onmouseup listener that calls
deactivate('mouse'), mirroring the existing keyboard pattern where
window listeners bypass the disabled-target filter. The has('mouse')
guard in deactivate() keeps the listener a no-op when the user wasn't
holding PTT before clicking elsewhere on the page.
Kept button-local onmousedown/onmouseup/onmouseleave untouched —
the window handler covers the disabled case; the button handlers
still work for the normal enabled path and add a tiny amount of
redundancy that deactivate()'s has() check makes harmless.
Touch wasn't affected (touch events aren't subject to the same
disabled-button filtering since they aren't click-chain events),
but added regression tests for touchend + touchcancel mid-hold
anyway per CodeRabbit's follow-up — shared deactivate() path, same
invariant, worth covering explicitly. Also added a genuine
real-browser-simulation test that dispatches mouseup only on
window (not button) to exercise the disabled-filter workaround
directly.
1128 tests pass (+3: touchend-disabled, touchcancel-disabled,
window-mouseup-disabled), build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Addressed as `feafe90`. CodeRabbit caught something I'd genuinely missed — and my previous regression test was a false positive. Finding 1: mouse release lost on disabled mid-hold (Critical)Verified the claim. Modern browsers (Chrome M120+, Safari 17+, Firefox 124+) filter `mouseup`/`click` on disabled form controls per the HTML spec. My earlier `deactivate()` fix dropped the `disabled` guard so the handler body would run if reached — but on modern browsers the button-local `onmouseup` never fires on a disabled target, so the handler body wasn't reached in the real production scenario. The reason the regression test passed anyway: jsdom doesn't replicate browser-level disabled-button event filtering. `fireEvent.mouseUp(btn)` on a disabled button in jsdom happily dispatches to the target's onmouseup. So the test was exercising jsdom's permissive event model, not Chrome's actual behavior. Test green, production bug still there. Fix: added a window-level `onmouseup` handler that calls `deactivate('mouse')`, mirroring the existing keyboard pattern. Window listeners bypass the disabled-target filter since they're bound to `window`, not the button. The `activeInputs.has('mouse')` check in `deactivate()` keeps it a no-op when the user wasn't holding PTT (releasing mouse elsewhere on the page). Declined `setPointerCapture` as an alternative — it'd be a bigger refactor from mouse events to pointer events in a component shared with voice-comms, and the minimal window-mouseup fix closes the same gap. Also added a genuine real-browser-simulation test that dispatches `mouseup` only on window (not on the button), exercising the disabled-filter workaround directly. That test would have caught the original bug. Finding 2: touch regression coverage (Nitpick)Touch events aren't subject to the same disabled-button filtering (they aren't click-chain), so the existing `deactivate()` fix already worked for touch — no production fix needed. But the test coverage was mouse+keyboard only, and the shared `deactivate()` path deserves explicit touch coverage too. Added `touchend` and `touchcancel` mid-hold regression tests. Verification
Three inputs (mouse, keyboard, touch), one consistent rule now: release listeners live on `window` so they can't be intercepted by the target. Touch is the exception where target-local listeners still work, but putting all three through window-level paths (keyboard + mouse already there) would be the cleaner long-term direction if we ever refactor to pointer events for voice-comms. |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/lib/components/PttButton.svelte (1)
87-98:⚠️ Potential issue | 🟠 MajorDon't stop mouse PTT on pointer drift.
With the new window-level
mouseup,onmouseleaveis no longer needed to avoid a stuck hold, but it still callsdeactivate('mouse')as soon as the cursor slips off the button. That makes mouse PTT stop before the actual release, which contradicts the PR’s “release is the commit point” behavior.Suggested fix
<button type="button" class="ptt-button" class:active class:processing aria-label="Push to talk" onmousedown={() => activate('mouse')} onmouseup={() => deactivate('mouse')} - onmouseleave={() => deactivate('mouse')} ontouchstart={(e) => { e.preventDefault(); activate('touch'); }} ontouchend={(e) => { e.preventDefault(); deactivate('touch'); }} ontouchcancel={() => deactivate('touch')} {disabled} >🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/components/PttButton.svelte` around lines 87 - 98, The button's onmouseleave handler prematurely calls deactivate('mouse') on pointer drift — remove the onmouseleave={() => deactivate('mouse')} attribute from the PttButton.svelte button so mouse PTT only ends on the global/window mouseup handler (handleMouseUp/deactivate) and not when the cursor slips off the element; ensure activate('mouse') remains on mousedown and the svelte:window onmouseup still calls handleMouseUp/deactivate as intended.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@src/lib/components/PttButton.svelte`:
- Around line 87-98: The button's onmouseleave handler prematurely calls
deactivate('mouse') on pointer drift — remove the onmouseleave={() =>
deactivate('mouse')} attribute from the PttButton.svelte button so mouse PTT
only ends on the global/window mouseup handler (handleMouseUp/deactivate) and
not when the cursor slips off the element; ensure activate('mouse') remains on
mousedown and the svelte:window onmouseup still calls handleMouseUp/deactivate
as intended.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: be787891-d4c2-434a-88e0-8b4d1652d112
📒 Files selected for processing (2)
src/lib/components/PttButton.sveltesrc/lib/components/__tests__/PttButton.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Cursor Bugbot
🔇 Additional comments (2)
src/lib/components/__tests__/PttButton.test.ts (1)
155-245: Nice regression matrix for disabled-mid-hold unwinding.This covers the important release paths: button mouseup, window mouseup, keyboard, touchend, and touchcancel. Good protection for the shared
deactivate()contract.src/lib/components/PttButton.svelte (1)
35-44: Good fix: release paths now unwind independently ofdisabled.Keeping
deactivate()keyed offactiveInputs.has(source)preserves the stop invariant without letting stray release events fireonPttStop.
The onmouseleave handler was a safety net for stuck holds when the user dragged off the button and released outside — a case the button-local onmouseup could not catch. With the window-level onmouseup handler added in the previous commit, that safety net is now redundant and actively harmful: it deactivates PTT the instant the cursor drifts off the button, contradicting the "release is the commit point" semantics. Caught by CodeRabbit on PR #48. Added a regression test that mousedowns on the button, mouseleaves, and asserts PTT is still held — only a window-level mouseup ends it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Good catch — fixed in 5c94cf0. CodeRabbit is right: with the window-level The broader principle worth noting for the next review round: when you add a broader safety net (global window listener), the local safety net it displaces often flips from defense to bug. Worth auditing narrower handlers whenever a global one goes in. |
|
CodeAnt AI is running the review. |
User descriptionSummary
Design decisions worth flaggingPTT release as the row-attempt commit point. If PCM was captured and Lazy capture vs prefetch on mount. Considered starting capture when the Practice tab mounts, but that keeps the mic indicator on even when the user is just reading cards. Lazy-on-first-press means first hold may catch less PCM than expected (getUserMedia latency), but the fallback path handles that cleanly and subsequent holds are tight. Prop type widened to Out of scope (Slice 4 polish)
Verification
Test plan
Related: ZEB-152, closes part of ZEB-152 (Slice 1 already landed as #47; Slice 4 is follow-up polish). 🤖 Generated with Claude Code Note Medium Risk Overview Calibration and mic UX tightened. PTT is disabled until PTT input handling hardened. Reviewed by Cursor Bugbot for commit 5c94cf0. Bugbot is set up for automated code reviews on this repo. Configure here. Summary by CodeRabbit
CodeAnt-AI DescriptionAdd calibrated push-to-talk voice practice with mic feedback What Changed
Impact
Details💡 Usage GuideChecking Your Pull RequestEvery time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later. Talking to CodeAnt AIGot a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask: This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code. ExamplePreserve Org Learnings with CodeAntYou can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input: This helps CodeAnt AI learn and adapt to your team's coding style and standards. ExampleRetrigger reviewAsk CodeAnt AI to review the PR again, by typing: Check Your Repository HealthTo analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health. |
Sequence DiagramThis PR wires the Practice flashcard view's push to talk button into live microphone capture and the STQ8 classifier, gated by calibration, so releasing the button commits a spoken row attempt into the existing evaluation and progression logic. sequenceDiagram
participant User
participant PracticeView
participant AudioCapture
participant Stq8Service
User->>PracticeView: Open Practice tab
PracticeView->>Stq8Service: Check calibrated status
Stq8Service-->>PracticeView: Return calibrated state and enable or disable PTT
User->>PracticeView: Hold push to talk
PracticeView->>AudioCapture: Start capture and buffer PCM while held
AudioCapture-->>PracticeView: Deliver PCM frames
User->>PracticeView: Release push to talk
PracticeView->>Stq8Service: Flush buffered PCM for classification
Stq8Service-->>PracticeView: Return syllable nibbles
PracticeView->>PracticeView: Evaluate row, update stats, advance card
Generated by CodeAnt AI |
|
CodeAnt AI finished running the review. |
|
CodeAnt AI is running the review. |
User descriptionSummary
Design decisions worth flaggingPTT release as the row-attempt commit point. If PCM was captured and Lazy capture vs prefetch on mount. Considered starting capture when the Practice tab mounts, but that keeps the mic indicator on even when the user is just reading cards. Lazy-on-first-press means first hold may catch less PCM than expected (getUserMedia latency), but the fallback path handles that cleanly and subsequent holds are tight. Prop type widened to Out of scope (Slice 4 polish)
Verification
Test plan
Related: ZEB-152, closes part of ZEB-152 (Slice 1 already landed as #47; Slice 4 is follow-up polish). 🤖 Generated with Claude Code Note Medium Risk Overview Calibration and mic UX tightened. PTT is disabled until PTT input handling hardened. Reviewed by Cursor Bugbot for commit 5c94cf0. Bugbot is set up for automated code reviews on this repo. Configure here. Summary by CodeRabbit
CodeAnt-AI DescriptionRequire voice calibration before Practice PTT, and keep push-to-talk release working even if the control becomes disabled mid-hold What Changed
Impact
Details💡 Usage GuideChecking Your Pull RequestEvery time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later. Talking to CodeAnt AIGot a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask: This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code. ExamplePreserve Org Learnings with CodeAntYou can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input: This helps CodeAnt AI learn and adapt to your team's coding style and standards. ExampleRetrigger reviewAsk CodeAnt AI to review the PR again, by typing: Check Your Repository HealthTo analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health. |
Sequence DiagramThis PR wires the Practice push-to-talk button through a shared AudioCapture instance and the STQ8 classifier so that releasing PTT commits a row attempt based on recognized syllable nibbles, or falls back to canceling the row and breaking the combo when nothing usable is heard. PTT is gated by classifier calibration, but the diagram focuses on the main calibrated success and fallback paths. sequenceDiagram
participant User
participant PttButton
participant FlashcardView
participant AudioCapture
participant Stq8Service
User->>PttButton: Hold push to talk
PttButton->>FlashcardView: onPttStart
FlashcardView->>AudioCapture: Ensure capture and start streaming
AudioCapture-->>FlashcardView: Buffer PCM frames while held
User->>PttButton: Release push to talk
PttButton->>FlashcardView: onPttStop via window mouse and key handlers
FlashcardView->>Stq8Service: Flush buffered PCM and call processPcm
alt Syllables detected
FlashcardView->>FlashcardView: Run handleRowComplete and advance card
else No syllables or error
FlashcardView->>FlashcardView: Cancel current row and reset combo
end
Generated by CodeAnt AI |
PR Code Suggestions ✨Latest suggestions up to commit
|
| Category | Suggestion | Severity |
| Logic error |
Global mouseup deactivates on non-primary button releases, which can end a hold prematurelyThe window-level mouseup handler deactivates PTT for every mouse button release, not src/lib/components/PttButton.svelte [76-83] Why it matters? 🤔
Steps of Reproduction ✅1. Open Practice view in the Harmony client so `FlashcardView.svelte` is mounted and renders `<PttButton>` (see `src/lib/components/FlashcardView.svelte:25-31` where `<PttButton>` is wired with `onPttStart={handlePttStart}` and `onPttStop={handlePttStop}`).
2. Press and hold the primary (left) mouse button on the PTT button, which triggers `onmousedown={() => activate('mouse')}` in `src/lib/components/PttButton.svelte:96-97`. This adds `'mouse'` to `activeInputs` and calls `onPttStart`, which in `FlashcardView.handlePttStart` at `FlashcardView.svelte:119-127` sets `pttActive = true` and starts audio capture.
3. While still holding the primary button down on the PTT control, click and then release a secondary or middle mouse button anywhere on the page (for example, middle-click to scroll), causing a `mouseup` event on `window`. The `<svelte:window onmouseup={handleMouseUp} />` binding at `PttButton.svelte:87` invokes `handleMouseUp` at `PttButton.svelte:76-83`, which currently deactivates the `'mouse'` source for every mouseup without checking which button changed.
4. `deactivate('mouse')` at `PttButton.svelte:34-44` removes `'mouse'` from `activeInputs` and, because the set becomes empty, calls `onPttStop`, which runs `FlashcardView.handlePttStop` at `FlashcardView.svelte:12-39`. This ends the PTT segment and commits or cancels the row attempt even though the primary button is still physically held, prematurely terminating voice capture and row evaluation.Fix in Cursor | Fix in VSCode Claude (Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/lib/components/PttButton.svelte
**Line:** 76:83
**Comment:**
*Logic Error: The window-level mouseup handler deactivates PTT for every mouse button release, not just the primary button that started the hold. Releasing a secondary/middle button anywhere on the page can therefore terminate an active hold early and trigger an unintended commit/cancel. Accept the mouse event and ignore non-primary releases when unwinding the `mouse` source.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major |
|
CodeAnt AI finished running the review. |
|
CodeAnt AI is running the review. |
Sequence DiagramThis PR wires the Practice flashcard PTT button into audio capture and the STQ8 classifier, gating use on calibration and driving existing row evaluation from recognized syllables. sequenceDiagram
participant User
participant FlashcardView
participant PttButton
participant AudioCapture
participant Stq8Service
User->>FlashcardView: View practice card
FlashcardView->>Stq8Service: Read calibration state
FlashcardView-->>User: Configure PTT enabled or disabled with hint
User->>PttButton: Hold and release push to talk
PttButton->>FlashcardView: Notify PTT start and stop
FlashcardView->>AudioCapture: Capture PCM during hold
FlashcardView->>Stq8Service: Send buffered PCM for processing
Stq8Service-->>FlashcardView: Return syllable nibbles and match info
FlashcardView->>FlashcardView: Evaluate row and update stats
FlashcardView-->>User: Show updated grid and streak progress
Generated by CodeAnt AI |
PR Code Suggestions ✨Latest suggestions up to commit
|
| Category | Suggestion | Severity |
| Race condition |
Releasing and flushing immediately can drop late-arriving audio frames and truncate recognized speech
src/lib/components/FlashcardView.svelte [130-138] Why it matters? 🤔
Steps of Reproduction ✅1. Run the harmony-client UI and navigate to the Spellbook Practice tab, which (per `docs/plans/2026-03-11-flashcard-ui-plan.md:7`) renders `FlashcardView.svelte` for voice-driven flashcards. Ensure calibration is completed so `stq8Service.isCalibrated()` is true and the PTT button is enabled (`FlashcardView.svelte:325–333`).
2. Start a practice attempt by holding the PTT button rendered by `PttButton.svelte` (`src/lib/components/PttButton.svelte:89–103`). Holding the button triggers `activate('mouse' | 'touch' | 'keyboard')` (`PttButton.svelte:27–44`), which calls the `onPttStart` prop and thereby `handlePttStart()` in `FlashcardView.svelte` (`lines 119–128`), setting `pttActive = true` and starting to buffer audio frames in `onPcmFrame()` (`FlashcardView.svelte:65–67`).
3. While still holding PTT, speak a syllable or short phrase. The `AudioCapture` instance (`src/lib/voice/audio-capture.ts`) is active after `ensureCapture()` resolves (`FlashcardView.svelte:69–96`), and its `AudioWorkletNode.port.onmessage` handler (`audio-capture.ts:50–52`) asynchronously calls `onFrame(pcm)`, which is wired to `onPcmFrame(pcm)` in `FlashcardView.svelte`. As long as `pttActive` is true, `onPcmFrame` pushes each `Float32Array` into `pcmBuffer` (`FlashcardView.svelte:65–67`).
4. Release the PTT button. This causes `deactivate(...)` in `PttButton.svelte` to call the `onPttStop` prop once all active inputs are released (`PttButton.svelte:34–45`), which invokes `handlePttStop()` in `FlashcardView.svelte` (`lines 130–157`). Inside `handlePttStop`, `pttActive` is immediately set to `false` (`line 131`), then `flushAndClassify()` is called (`line 138`), which copies and clears `pcmBuffer` before passing it to `stq8Service.processPcm` (`lines 159–176). Because `AudioWorkletNode.port.onmessage` delivery is asynchronous, any audio frames that were already produced by the worklet but whose `onmessage` events fire after `pttActive` was set to `false` (and after `flushAndClassify` ran) will be dropped by `onPcmFrame` (the `if (pttActive)` guard at `line 65`). In practice you can confirm this by instrumenting `onPcmFrame` and `handlePttStop` with logging: you will observe `onPcmFrame` events occurring after `handlePttStop` completes where `pttActive` is already `false`, and those frames never make it into the `pcmBuffer` that was just flushed. This systematically truncates the tail of each utterance by up to a frame (or more, depending on scheduling), meaning the classifier at `stq8Service.processPcm` sometimes runs on incomplete audio and can yield fewer or mismatched `syllables`, causing occasional false negatives in row evaluation via `handleRowComplete()` (`FlashcardView.svelte:182–219`).Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/lib/components/FlashcardView.svelte
**Line:** 130:138
**Comment:**
*Race Condition: `handlePttStop` flushes and classifies the buffer immediately after release, but AudioWorklet `postMessage` delivery is asynchronous, so frames captured right before release can arrive just after this flush and get dropped. This truncates utterances at the tail and causes false negatives/mismatches. Delay flush until pending frame messages are drained (or add an explicit end-of-utterance/frame-boundary mechanism) before calling `processPcm`.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major |
|
CodeAnt AI finished running the review. |
|
CodeAnt AI is running the review. |
Sequence DiagramThis PR wires the Practice push to talk button through shared audio capture and the STQ8 classifier so that releasing PTT commits a spoken row attempt, while uncalibrated or empty captures fall back to the existing cancel and combo reset behavior. sequenceDiagram
participant User
participant FlashcardView
participant PttButton
participant AudioCapture
participant Stq8Service
User->>FlashcardView: Open Practice card
FlashcardView->>Stq8Service: Check calibration and prepare challenge
User->>PttButton: Press and hold push to talk
PttButton-->>FlashcardView: onPttStart
FlashcardView->>AudioCapture: Start capture and buffer PCM while held
User->>PttButton: Release push to talk
PttButton-->>FlashcardView: onPttStop
FlashcardView->>Stq8Service: Process buffered PCM to syllable nibbles
alt Nibbles detected
FlashcardView->>FlashcardView: Evaluate row, update stats, load next card
else No audio or error
FlashcardView->>FlashcardView: Cancel row and reset combo
end
Generated by CodeAnt AI |
PR Code Suggestions ✨Latest suggestions up to commit
|
| Category | Suggestion | Severity |
| Logic error |
Classification still runs after calibration is lost mid-hold, allowing invalid audio results to drive progression logic
src/lib/components/FlashcardView.svelte [138-145] Why it matters? 🤔
Steps of Reproduction ✅1. Render the main app (`src/App.svelte:878-881`), which mounts `SpellbookMode` (`src/lib/components/SpellbookMode.svelte:48-83`) and, on the Practice tab, `FlashcardView` (`SpellbookMode.svelte:25-32`) with a `stq8Service` whose `isCalibrated()` initially returns true.
2. Within a test or dev harness, use a `Stq8ServiceLike` stub where `isCalibrated()` reads from an internal flag and `processPcm()` returns some non-empty syllable list; start a PTT hold by dispatching `mousedown` on the `PttButton` in `FlashcardView` (`FlashcardView.svelte:325-330`), which calls `PttButton`'s `activate('mouse')` (`PttButton.svelte:27-32`) and then `handlePttStart()` in `FlashcardView` (`FlashcardView.svelte:119-128`), setting `pttActive=true` and buffering PCM via `onPcmFrame()` (`FlashcardView.svelte:65-67`).
3. While the hold is still active (mouse button or spacebar still down), flip the stub's internal calibrated flag to false so that subsequent calls to `stq8Service.isCalibrated()` return false; Svelte reactivity then re-renders `FlashcardView` so that `PttButton` receives `disabled={!stq8Service.isCalibrated()}` as true (`FlashcardView.svelte:325-327`) and the "Calibrate your voice…" hint appears (`FlashcardView.svelte:331-333`), but the hold remains active because `PttButton`'s `deactivate()` is intentionally not guarded by `disabled` (`PttButton.svelte:34-45`).
4. Release the PTT input (e.g., dispatch `mouseup` on window), which triggers `PttButton`'s `handleMouseUp()` and `deactivate('mouse')` (`PttButton.svelte:76-85`), causing `onPttStop` to fire and call `handlePttStop()` in `FlashcardView` (`FlashcardView.svelte:130-157); inside `handlePttStop`, the code unconditionally invokes `flushAndClassify()` (`FlashcardView.svelte:138-139`), which calls `stq8Service.processPcm(pcm)` (`FlashcardView.svelte:173-175`) and, if any nibbles are returned, feeds them into `handleRowComplete(nibbles)` (`FlashcardView.svelte:139-145, 182-219`) even though `stq8Service.isCalibrated()` is now false and the UI has disabled PTT, demonstrating that uncalibrated/stale classification still drives row progression.Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** src/lib/components/FlashcardView.svelte
**Line:** 138:145
**Comment:**
*Logic Error: `handlePttStop` always runs classification even if calibration became invalid during an active hold (a case this PR explicitly supports by allowing release while `disabled` flips). That can feed uncalibrated/stale output into row progression and incorrectly mark attempts as pass/fail. Re-check calibration at release and skip `flushAndClassify()` when not calibrated so release falls through the cancel-row path.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix | Major |
|
CodeAnt AI finished running the review. |

User description
Summary
AudioCapture→WasmPipeline.process()intoFlashcardView's PTT handlers. Holding the button captures PCM, releasing flushes it through the classifier, and the resulting syllable nibbles feed the existinghandleRowComplete()evaluation path. This is the ZEB-152 payoff slice: Calibrate-then-Practice now works end-to-end for the first time.AudioCaptureinstance reused across holds for the component's lifetime,$effectcleanup on unmount releases the mic when you leave the Practice tab.!stq8Service.isCalibrated(), so an uncalibrated classifier can't emit garbage nibbles at the row-progression logic.SpellbookModealready remountsFlashcardViewon tab switch, so the gate re-evaluates after a fresh calibration.Design decisions worth flagging
PTT release as the row-attempt commit point. If PCM was captured and
processPcmreturned non-empty syllables,handleRowComplete(nibbles)runs and its existing pass/fail/combo logic takes over untouched. Empty buffer, empty syllables, or a thrownprocessPcmall fall through to the pre-Slice-3 "cancel row, break combo" path — one fallback covers "didn't hold long enough," "mic permission still resolving," "classifier heard nothing," and "WASM error" consistently instead of four bespoke paths.Lazy capture vs prefetch on mount. Considered starting capture when the Practice tab mounts, but that keeps the mic indicator on even when the user is just reading cards. Lazy-on-first-press means first hold may catch less PCM than expected (getUserMedia latency), but the fallback path handles that cleanly and subsequent holds are tight.
Prop type widened to
Stq8ServiceLike.FlashcardViewpreviously had an inline 3-method shape forstq8Service; with Slice 3 needingisCalibrated+processPcmthe inline shape became duplication. Switched to the exported interface to keep the contract in one place.Out of scope (Slice 4 polish)
flashcard-design.md:70-78(expected vs heard with caret under first-differing byte)Verification
FlashcardView.test.ts)svelte-checkclean on all touched files (two pre-existing initial-value warnings onisCalibrated/stq8Serviceunchanged)vite buildclean — main bundle +2.8 KB gzipped (capture wiring)Test plan
npm run dev, calibrate once on the Calibrate tabRelated: ZEB-152, closes part of ZEB-152 (Slice 1 already landed as #47; Slice 4 is follow-up polish).
🤖 Generated with Claude Code
Note
Medium Risk
Adds microphone capture and real-time PCM classification into the Practice flow, which can affect permissions, resource cleanup, and input/state handling across browsers.
Overview
Practice PTT now performs live voice recognition.
FlashcardViewlazy-starts a singleAudioCaptureinstance on first PTT press, buffers PCM only while held, and on release callsstq8Service.processPcm(); non-empty syllable nibbles are routed through existinghandleRowComplete()logic, otherwise it falls back to the prior “cancel row/break combo” behavior.Calibration and mic UX tightened. PTT is disabled until
stq8Service.isCalibrated()and shows a calibrate hint; capture start failures surface a microphone error message. Component unmount now reliably stops an in-flight or active capture to avoid orphaned microphone usage.PTT input handling hardened.
PttButtonensures releases always unwind even ifdisabledflips mid-hold and adds a window-levelmouseuplistener (and removes mouse-leave cancellation) to handle browsers that suppress events on disabled buttons; tests were expanded to cover these cases plus the calibration gate UI.Reviewed by Cursor Bugbot for commit 5c94cf0. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit
New Features
Improvements
Tests
CodeAnt-AI Description
Practice now requires voice calibration and keeps push-to-talk release behavior reliable
What Changed
Impact
✅ Prevented stuck push-to-talk sessions✅ Fewer Practice errors from uncalibrated voice input✅ Clearer microphone setup guidance🔄 Retrigger CodeAnt AI Review
Details
💡 Usage Guide
Checking Your Pull Request
Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.
Talking to CodeAnt AI
Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:
This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.
Example
Preserve Org Learnings with CodeAnt
You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:
This helps CodeAnt AI learn and adapt to your team's coding style and standards.
Example
Retrigger review
Ask CodeAnt AI to review the PR again, by typing:
Check Your Repository Health
To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.