Skip to content

feat(harmony-client): live PTT voice recognition in Practice (ZEB-152 slice 3) - #48

Merged
jenglund merged 4 commits into
mainfrom
zeblith/zeb-152-slice3-live-ptt
Apr 21, 2026
Merged

feat(harmony-client): live PTT voice recognition in Practice (ZEB-152 slice 3)#48
jenglund merged 4 commits into
mainfrom
zeblith/zeb-152-slice3-live-ptt

Conversation

@jenglund

@jenglund jenglund commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

User description

Summary

  • Wires AudioCaptureWasmPipeline.process() into FlashcardView's PTT handlers. Holding the button captures PCM, releasing flushes it through the classifier, and the resulting syllable nibbles feed the existing handleRowComplete() evaluation path. This is the ZEB-152 payoff slice: Calibrate-then-Practice now works end-to-end for the first time.
  • Lazy-async capture start on first press, one AudioCapture instance reused across holds for the component's lifetime, $effect cleanup on unmount releases the mic when you leave the Practice tab.
  • PTT disabled with a "Calibrate your voice..." hint when !stq8Service.isCalibrated(), so an uncalibrated classifier can't emit garbage nibbles at the row-progression logic. SpellbookMode already remounts FlashcardView on 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 processPcm returned non-empty syllables, handleRowComplete(nibbles) runs and its existing pass/fail/combo logic takes over untouched. Empty buffer, empty syllables, or a thrown processPcm all 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. FlashcardView previously had an inline 3-method shape for stq8Service; with Slice 3 needing isCalibrated + processPcm the inline shape became duplication. Switched to the exported interface to keep the contract in one place.

Out of scope (Slice 4 polish)

  • Mismatch display from flashcard-design.md:70-78 (expected vs heard with caret under first-differing byte)
  • 2-second momentum timeout during held PTT (spec: progress resets if no syllable advances for 2s)
  • "Couldn't hear that clearly" UX for the empty-syllable release case (currently just the silent cancel path)
  • Permission-denial recovery flow (currently surfaces error message text below the button)

Verification

  • 1121 tests pass (+2 new calibration-gate UI tests in FlashcardView.test.ts)
  • svelte-check clean on all touched files (two pre-existing initial-value warnings on isCalibrated/stq8Service unchanged)
  • vite build clean — main bundle +2.8 KB gzipped (capture wiring)
  • Manual end-to-end verification pending: calibrate on Ildwyn, switch to Practice, hold PTT, speak a Novice-level byte, confirm row-progression feels right

Test plan

  • Pull, npm run dev, calibrate once on the Calibrate tab
  • Switch to Practice tab, verify PTT button is enabled (no "Calibrate first" hint)
  • Hold PTT, say a single Q8 syllable matching the active row, release → row should evaluate (green on match, red flash + reset on mismatch)
  • Hold PTT without speaking, release → row cancels, combo breaks (pre-Slice-3 behavior preserved)
  • Switch tabs mid-session, verify mic indicator clears (capture stops on unmount)
  • Recalibrate mid-session, return to Practice, verify the new profile is in effect

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
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. FlashcardView lazy-starts a single AudioCapture instance on first PTT press, buffers PCM only while held, and on release calls stq8Service.processPcm(); non-empty syllable nibbles are routed through existing handleRowComplete() 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. PttButton ensures releases always unwind even if disabled flips mid-hold and adds a window-level mouseup listener (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

    • Push-to-talk voice input for flashcards with lazy mic capture and audio classification.
  • Improvements

    • PTT disabled until microphone calibration; shows calibration or error hints and updated layout/hint styling.
    • Improved error handling, serialized capture start to avoid conflicts, ensures active capture is stopped on exit.
    • Service prop contract standardized for clearer integration.
    • PTT button now reliably unwinds/releases if disabled mid-hold so stops fire once.
  • Tests

    • Added tests covering calibration UI and PTT behavior when disabled flips mid-hold.

CodeAnt-AI Description

Practice now requires voice calibration and keeps push-to-talk release behavior reliable

What Changed

  • Practice mode now disables push-to-talk until voice calibration is complete and shows a clear prompt to calibrate first
  • Voice input starts on the first press, stops when released, and is cleared when you leave Practice so the microphone does not stay active
  • Releasing push-to-talk now still ends the hold even if the control becomes disabled mid-press, which avoids getting stuck in an active state
  • Practice now shows a microphone error message when capture cannot start
  • Added coverage for calibration gating and the push-to-talk release edge cases

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:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

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:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

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.

…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>
@greptile-apps

greptile-apps Bot commented Apr 20, 2026

Copy link
Copy Markdown

PR author is in the excluded authors list.

@codeant-ai

codeant-ai Bot commented Apr 20, 2026

Copy link
Copy Markdown

CodeAnt AI is reviewing your PR.

@coderabbitai

coderabbitai Bot commented Apr 20, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d3220900-2898-4baa-9108-0fd4ada9cefe

📥 Commits

Reviewing files that changed from the base of the PR and between feafe90 and 5c94cf0.

📒 Files selected for processing (2)
  • src/lib/components/PttButton.svelte
  • src/lib/components/__tests__/PttButton.test.ts
📜 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)
  • GitHub Check: Cursor Bugbot
🔇 Additional comments (9)
src/lib/components/__tests__/PttButton.test.ts (5)

155-172: LGTM — regression test correctly validates disabled-mid-hold unwinding for mouse.

The test appropriately exercises that onPttStop fires when disabled flips true during a held press. In jsdom the button's local handler fires (since jsdom doesn't filter disabled elements), while the test at lines 244-266 specifically covers the real-browser path where only the window handler receives the event.


174-187: LGTM — keyboard release is window-level and unaffected by disabled filtering.

The test correctly validates that the window-level keyup handler still triggers onPttStop regardless of disabled state.


189-221: LGTM — touch regression tests complete the mobile coverage.

Both touchend and touchcancel paths are now validated for the disabled-mid-hold scenario. The comment at lines 190-194 correctly notes that touch events aren't subject to the same browser filtering as mouse events.


223-242: LGTM — regression test guards the "release is the commit point" design.

This correctly validates that pointer drift off the button does not prematurely end PTT, and that the window-level mouseup still captures the actual release.


244-266: LGTM — this test exercises the production fix path.

Dispatching mouseUp only on window (not the button) correctly simulates how modern browsers filter mouseup from disabled form controls. This validates that the window-level handler is the sole unwinding mechanism in that scenario.

src/lib/components/PttButton.svelte (4)

34-45: LGTM — well-documented rationale for removing the disabled guard.

The comment at lines 35-41 clearly explains why releases must always unwind state regardless of disabled. The has(source) check at line 42 correctly prevents spurious stops for sources that were never activated.


66-74: LGTM — handleKeyUp correctly mirrors the deactivate logic.

Removing the disabled guard here ensures keyboard releases always unwind, with the has() check in deactivate() handling spurious releases gracefully.


76-84: LGTM — window-level handler correctly bypasses disabled-element event filtering.

The comment accurately documents the browser behavior, and the has('mouse') check in deactivate() ensures this is a no-op when the user wasn't holding PTT.


87-100: LGTM — dual handler approach provides robust release coverage.

When the button is enabled, both the local onmouseup (line 97) and window handler (line 87) fire, but the has('mouse') check in deactivate() makes the second call a no-op. When disabled, only the window handler fires. This ensures onPttStop fires exactly once in all scenarios.


📝 Walkthrough

Walkthrough

Added async push-to-talk with lazy AudioCapture and PCM buffering; PTT start/stop now classify captured audio via stq8Service.processPcm() with fallbacks on error/empty capture; added calibration gating, capture error UI, unmount cleanup; adjusted PttButton release semantics and expanded tests for edge cases.

Changes

Cohort / File(s) Summary
Flashcard view (voice capture, classification, UI)
src/lib/components/FlashcardView.svelte
Introduce AudioCapture, pcmBuffer, captureError; make PTT start async and serialized via ensureCapture()/captureStart; on stop flush PCM and call stq8Service.processPcm(pcm) to obtain heardNibbles then route to handleRowComplete(...) if present; fallback to prior cancel/reset on errors or empty PCM; add destroyed/unmount cleanup; gate PTT by stq8Service.isCalibrated() and render calibration/error hints; minor layout/CSS tweaks.
PTT control logic
src/lib/components/PttButton.svelte
Do not early-return from deactivate(source) when disabled — always unwind active inputs; ensure Space key releases deactivate keyboard even if disabled; add window-level mouseup handler and remove onmouseleave mouse deactivation.
Flashcard tests & mocks
src/lib/components/__tests__/FlashcardView.test.ts
Expanded createMockService() surface with calibration-related methods (isCalibrated, validateRow, processPcm, addCalibrationSample, finalizeCalibration, exportProfile, importProfile, setCreatedEpochSecs); added tests asserting PTT disabled + calibration hint when uncalibrated and enabled without hint when calibrated.
PTT tests
src/lib/components/__tests__/PttButton.test.ts
Added regression tests ensuring onPttStop fires exactly once when disabled flips to true mid-hold across mouse, keyboard, and touch flows; include window-level mouseup handling and mouse-leave regression cases.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Possibly related PRs

Suggested labels

size:XL

Poem

🐰
I thumped my paw and listened near,
PCM giggles, tiny and clear,
Press to speak, release — the bytes take flight,
A rabbit cheers for calibrated light,
Hooray! the classifier hops delight. 🎙️✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing live PTT voice recognition in the Practice feature, with specific reference to the project context (ZEB-152 slice 3).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch zeblith/zeb-152-slice3-live-ptt

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Integrate live PTT voice recognition into Practice mode

✨ Enhancement

Grey Divider

Walkthroughs

Description
• 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
Diagram
flowchart 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"]
Loading

Grey Divider

File Changes

1. src/lib/components/__tests__/FlashcardView.test.ts 🧪 Tests +38/-0

Add calibration gate tests to FlashcardView

• Added isCalibrated, validateRow, processPcm, and calibration-related methods to mock service
• Added two new test cases for PTT calibration gate behavior
• Tests verify PTT is disabled with hint when uncalibrated, and enabled when calibrated

src/lib/components/tests/FlashcardView.test.ts


2. src/lib/components/FlashcardView.svelte ✨ Enhancement +109/-14

Wire AudioCapture and classifier into PTT handlers

• Changed stq8Service prop type from inline shape to Stq8ServiceLike interface
• Added AudioCapture import and lazy-async capture initialization on first PTT press
• Implemented flushAndClassify() to process buffered PCM through classifier on PTT release
• Added PTT disable gate when !stq8Service.isCalibrated() with user-facing hint text
• Added $effect cleanup to stop audio capture on component unmount
• Added error message display for microphone permission and capture failures
• Updated PTT container styling to support hint text display below button

src/lib/components/FlashcardView.svelte


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Apr 20, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Mic start race leak🐞 Bug ☼ Reliability
Description
ensureCapture() only guards on audioCapture, but audioCapture is assigned *after* `await
capture.start(...), so rapid PTT presses can start multiple AudioCapture` instances and orphan
earlier ones. If the component unmounts while start() is still pending, the unmount cleanup can’t
stop the mic because audioCapture is still null, so capture may remain active after leaving
Practice.
Code

src/lib/components/FlashcardView.svelte[R56-69]

+  async function ensureCapture(): Promise<void> {
+    if (audioCapture) return;
+    const capture = new AudioCapture();
+    try {
+      await capture.start(onPcmFrame);
+      audioCapture = capture;
+      captureError = '';
+    } catch (err) {
+      // Permission denied, no mic, AudioContext construction failure, etc.
+      // Leave `audioCapture` null so the next PTT press will retry; surface
+      // a short message in the UI so the user knows why nothing's landing.
+      captureError = err instanceof Error ? err.message : String(err);
+      console.warn('[harmony-client] flashcard PTT: capture start failed:', err);
+    }
Evidence
In ensureCapture(), the guard if (audioCapture) return; is ineffective while
AudioCapture.start() is in-flight because audioCapture is assigned only after the await; a
second call during that window will create and start another AudioCapture. On unmount, cleanup
only stops audioCapture (captured into c), but if unmount happens before the first start()
resolves, audioCapture is still null so no stop is attempted. Additionally, AudioCapture.stop()
is a no-op when active is still false, so solving this requires explicit in-flight bookkeeping and
post-start cancellation when unmounted.

src/lib/components/FlashcardView.svelte[56-70]
src/lib/components/FlashcardView.svelte[93-102]
src/lib/components/FlashcardView.svelte[233-243]
src/lib/voice/audio-capture.ts[73-75]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`FlashcardView.ensureCapture()` can be called multiple times while the first `AudioCapture.start()` is still pending. Because `audioCapture` is assigned only after the await, this can start multiple microphone captures and leak/strand earlier ones. If the component unmounts before `start()` resolves, cleanup sees `audioCapture === null` and cannot stop the mic.

## Issue Context
- `AudioCapture.start()` is async (`getUserMedia`, AudioContext/worklet setup).
- Current guard only checks `audioCapture`, not an in-flight start.
- Cleanup stops only the assigned `audioCapture`.

## Fix approach
- Add explicit in-flight tracking (e.g., `let captureStartPromise: Promise<void> | null` and/or `let pendingCapture: AudioCapture | null`).
- Ensure only one start attempt runs at a time; subsequent calls should await the same promise.
- Add an unmount/destroyed flag so that if unmount happens while start is pending, the code stops the capture immediately after `start()` resolves (since `stop()` can’t cancel a not-yet-active start).

## Fix Focus Areas
- src/lib/components/FlashcardView.svelte[44-71]
- src/lib/components/FlashcardView.svelte[93-102]
- src/lib/components/FlashcardView.svelte[233-243]
- src/lib/voice/audio-capture.ts[73-75]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Disabled blocks PTT stop🐞 Bug ≡ Correctness
Description
PttButton.deactivate() returns early when disabled is true, which suppresses onPttStop() and
can leave the parent’s pttActive stuck true if disabled flips during a hold. FlashcardView now
drives disabled from !stq8Service.isCalibrated(), so a calibration-state transition can prevent
release-time flush/cancel logic from running.
Code

src/lib/components/FlashcardView.svelte[R295-302]

      <PttButton
        active={pttActive}
+        disabled={!stq8Service.isCalibrated()}
        onPttStart={handlePttStart}
        onPttStop={handlePttStop}
      />
+      {#if !stq8Service.isCalibrated()}
+        <p class="ptt-hint">Calibrate your voice on the Calibrate tab to enable Practice.</p>
Evidence
FlashcardView newly passes a reactive disabled={!stq8Service.isCalibrated()} into PttButton.
In PttButton, both activate() and deactivate() immediately return when disabled is true;
notably, deactivate() bails out before removing the active input and before calling onPttStop(),
which is the only trigger for FlashcardView.handlePttStop() (where PCM is flushed/classified or
the row is canceled).

src/lib/components/FlashcardView.svelte[294-305]
src/lib/components/PttButton.svelte[27-39]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`PttButton.deactivate()` currently does nothing when `disabled` is true, which can suppress `onPttStop()` and strand the parent in an active PTT state if `disabled` changes during a hold.

## Issue Context
`FlashcardView` now binds `disabled` to `!stq8Service.isCalibrated()`, so `disabled` can change based on service state (e.g., profile reload/reset).

## Fix options
- Preferred: in `PttButton`, allow `deactivate()` to run even when `disabled` is true (at least for sources that are already active), so releases always unwind state and notify the parent.
- Alternative: add a reactive effect inside `PttButton` that, when `disabled` becomes true and `activeInputs.size > 0`, clears `activeInputs` and calls `onPttStop?.()`.

## Fix Focus Areas
- src/lib/components/PttButton.svelte[27-39]
- src/lib/components/FlashcardView.svelte[294-305]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@codeant-ai codeant-ai Bot added the size:L This PR changes 100-499 lines, ignoring generated files label Apr 20, 2026
@codeant-ai

codeant-ai Bot commented Apr 20, 2026

Copy link
Copy Markdown

User description

Summary

  • Wires AudioCaptureWasmPipeline.process() into FlashcardView's PTT handlers. Holding the button captures PCM, releasing flushes it through the classifier, and the resulting syllable nibbles feed the existing handleRowComplete() evaluation path. This is the ZEB-152 payoff slice: Calibrate-then-Practice now works end-to-end for the first time.
  • Lazy-async capture start on first press, one AudioCapture instance reused across holds for the component's lifetime, $effect cleanup on unmount releases the mic when you leave the Practice tab.
  • PTT disabled with a "Calibrate your voice..." hint when !stq8Service.isCalibrated(), so an uncalibrated classifier can't emit garbage nibbles at the row-progression logic. SpellbookMode already remounts FlashcardView on 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 processPcm returned non-empty syllables, handleRowComplete(nibbles) runs and its existing pass/fail/combo logic takes over untouched. Empty buffer, empty syllables, or a thrown processPcm all 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. FlashcardView previously had an inline 3-method shape for stq8Service; with Slice 3 needing isCalibrated + processPcm the inline shape became duplication. Switched to the exported interface to keep the contract in one place.

Out of scope (Slice 4 polish)

  • Mismatch display from flashcard-design.md:70-78 (expected vs heard with caret under first-differing byte)
  • 2-second momentum timeout during held PTT (spec: progress resets if no syllable advances for 2s)
  • "Couldn't hear that clearly" UX for the empty-syllable release case (currently just the silent cancel path)
  • Permission-denial recovery flow (currently surfaces error message text below the button)

Verification

  • 1121 tests pass (+2 new calibration-gate UI tests in FlashcardView.test.ts)
  • svelte-check clean on all touched files (two pre-existing initial-value warnings on isCalibrated/stq8Service unchanged)
  • vite build clean — main bundle +2.8 KB gzipped (capture wiring)
  • Manual end-to-end verification pending: calibrate on Ildwyn, switch to Practice, hold PTT, speak a Novice-level byte, confirm row-progression feels right

Test plan

  • Pull, npm run dev, calibrate once on the Calibrate tab
  • Switch to Practice tab, verify PTT button is enabled (no "Calibrate first" hint)
  • Hold PTT, say a single Q8 syllable matching the active row, release → row should evaluate (green on match, red flash + reset on mismatch)
  • Hold PTT without speaking, release → row cancels, combo breaks (pre-Slice-3 behavior preserved)
  • Switch tabs mid-session, verify mic indicator clears (capture stops on unmount)
  • Recalibrate mid-session, return to Practice, verify the new profile is in effect

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
Adds microphone capture and feeds classifier output into Practice row evaluation, which can impact core interaction flow and introduces permission/error-handling paths. Risk is mitigated by gating on isCalibrated() and falling back to the previous cancel behavior when capture/classification yields no result.

Overview
Enables live voice-driven Practice by buffering PCM while PTT is held, flushing it on release through stq8Service.processPcm(), and routing resulting syllable nibbles into the existing handleRowComplete() evaluation path.

PTT is now gated on calibration (disabled={!stq8Service.isCalibrated()}) with a user hint when uncalibrated, and microphone startup/classification failures surface as a small error message while preserving the prior “cancel row + break combo” fallback behavior.

Updates FlashcardView to consume the shared Stq8ServiceLike interface (instead of an inline subset) and adds tests covering the new calibration-based PTT enable/disable UI.

Reviewed by Cursor Bugbot for commit dc46408. Bugbot is set up for automated code reviews on this repo. Configure here.


CodeAnt-AI Description

Use push-to-talk voice input in Practice, with calibration required first

What Changed

  • Practice now sends held push-to-talk audio through voice recognition on release, so spoken input can complete the current row instead of always canceling it.
  • Push-to-talk is disabled until voice calibration is finished, with a clear hint telling users to calibrate first.
  • If the microphone cannot start, the screen shows a short microphone error message instead of failing silently.
  • Leaving Practice now releases the microphone, and the new behavior is covered by tests for both calibrated and uncalibrated states.

Impact

✅ Voice input works in Practice
✅ Fewer incorrect attempts from uncalibrated users
✅ Clearer microphone setup feedback

🔄 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:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

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:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

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.

@codeant-ai

codeant-ai Bot commented Apr 20, 2026

Copy link
Copy Markdown

PR Code Suggestions ✨

Latest suggestions up to commit dc46408
CategorySuggestion                                                                                                                                    Severity
Race condition
Concurrent async initialization can start multiple microphone capture instances and leak one of them
Suggestion Impact:Implemented an in-flight promise (captureStart) so concurrent ensureCapture() calls await the same start operation instead of creating multiple AudioCapture instances; also added a destroyed flag to stop a capture that finishes starting after unmount to avoid orphaned mic usage.

code diff:

+  // Serialize concurrent ensureCapture() calls. Without this, rapid
+  // press/release cycles before the first getUserMedia resolves can
+  // start multiple AudioCapture instances — the second overwrites
+  // audioCapture and orphans the first with an open mic that cleanup
+  // can't reach. captureStart pins the in-flight promise so subsequent
+  // callers await the same result instead of kicking off another start.
+  let captureStart: Promise<void> | null = null;
+  // destroyed lets a pending start know the component has unmounted. If
+  // start() resolves after cleanup ran, we stop the capture immediately
+  // rather than assigning it to the now-unmounted component — otherwise
+  // the mic stays live forever because cleanup saw audioCapture=null.
+  let destroyed = false;
+
   function onPcmFrame(pcm: Float32Array): void {
     if (pttActive) pcmBuffer.push(pcm);
   }
 
-  async function ensureCapture(): Promise<void> {
-    if (audioCapture) return;
+  function ensureCapture(): Promise<void> {
+    if (audioCapture) return Promise.resolve();
+    if (captureStart) return captureStart;
     const capture = new AudioCapture();
-    try {
-      await capture.start(onPcmFrame);
-      audioCapture = capture;
-      captureError = '';
-    } catch (err) {
-      // Permission denied, no mic, AudioContext construction failure, etc.
-      // Leave `audioCapture` null so the next PTT press will retry; surface
-      // a short message in the UI so the user knows why nothing's landing.
-      captureError = err instanceof Error ? err.message : String(err);
-      console.warn('[harmony-client] flashcard PTT: capture start failed:', err);
-    }
+    captureStart = (async () => {
+      try {
+        await capture.start(onPcmFrame);
+        if (destroyed) {
+          // Unmounted while start was pending — release the mic now.
+          // cleanup couldn't reach it because audioCapture was still null.
+          await capture.stop();
+          return;
+        }
+        audioCapture = capture;
+        captureError = '';
+      } catch (err) {
+        // Permission denied, no mic, AudioContext construction failure, etc.
+        // Leave `audioCapture` null so the next PTT press will retry; surface
+        // a short message in the UI so the user knows why nothing's landing.
+        if (destroyed) return;
+        captureError = err instanceof Error ? err.message : String(err);
+        console.warn('[harmony-client] flashcard PTT: capture start failed:', err);
+      } finally {
+        captureStart = null;
+      }
+    })();
+    return captureStart;
   }
 
   // Generate challenge when level changes (or on mount, since previousLevel starts null).
@@ -231,10 +257,14 @@
   }
 
   // Release the mic when this component unmounts (tab switch out of
-  // Practice, or navigation away from Spellbook). AudioCapture.stop()
-  // is safe to call on a never-started instance and internally handles
-  // already-stopped state.
+  // Practice, or navigation away from Spellbook). If a capture start is
+  // still in flight when we unmount, the `destroyed` flag tells the
+  // pending IIFE inside ensureCapture to stop the capture itself once
+  // start() resolves — cleanup here can only reach already-assigned
+  // captures, so the destroyed-flag path is the only way to guarantee
+  // no orphaned mic.
   $effect(() => () => {
+    destroyed = true;
     const c = audioCapture;
     audioCapture = null;
     pttActive = false;

ensureCapture() is vulnerable to concurrent calls: two rapid press cycles before the
first start() resolves can create two separate AudioCapture instances, both opening
the mic, with the later assignment overwriting the earlier reference and leaking
resources. Serialize initialization with a shared in-flight promise so only one
start attempt can run at a time.

src/lib/components/FlashcardView.svelte [48-70]

 let audioCapture: AudioCapture | null = null;
 let pcmBuffer: Float32Array[] = [];
 let captureError = $state('');
+let captureStartPromise: Promise<void> | null = null;
 
 function onPcmFrame(pcm: Float32Array): void {
   if (pttActive) pcmBuffer.push(pcm);
 }
 
 async function ensureCapture(): Promise<void> {
   if (audioCapture) return;
-  const capture = new AudioCapture();
-  try {
-    await capture.start(onPcmFrame);
-    audioCapture = capture;
-    captureError = '';
-  } catch (err) {
-    // Permission denied, no mic, AudioContext construction failure, etc.
-    // Leave `audioCapture` null so the next PTT press will retry; surface
-    // a short message in the UI so the user knows why nothing's landing.
-    captureError = err instanceof Error ? err.message : String(err);
-    console.warn('[harmony-client] flashcard PTT: capture start failed:', err);
-  }
+  if (captureStartPromise) return captureStartPromise;
+
+  captureStartPromise = (async () => {
+    const capture = new AudioCapture();
+    try {
+      await capture.start(onPcmFrame);
+      audioCapture = capture;
+      captureError = '';
+    } catch (err) {
+      // Permission denied, no mic, AudioContext construction failure, etc.
+      // Leave `audioCapture` null so the next PTT press will retry; surface
+      // a short message in the UI so the user knows why nothing's landing.
+      captureError = err instanceof Error ? err.message : String(err);
+      console.warn('[harmony-client] flashcard PTT: capture start failed:', err);
+    } finally {
+      captureStartPromise = null;
+    }
+  })();
+
+  return captureStartPromise;
 }
 
Why it matters? 🤔
  • ❌ Practice PTT can start multiple AudioCapture instances concurrently.
  • ❌ One capture instance may never be explicitly stopped.
  • ⚠️ Extra mic streams waste resources during Spellbook practice sessions.
Steps of Reproduction ✅
1. Open the Harmony client and switch to Spellbook mode using the "Spellbook" button wired in `src/lib/components/NavPanel.svelte:185-186` (found via Grep), which sets the app into Spellbook mode so that `spellbookContent()` in `src/App.svelte:10-15,878-14` renders `<SpellbookMode {stq8Service}>` (confirmed in `src/App.svelte:868-18`).

2. In `src/lib/components/SpellbookMode.svelte:86-109`, select the "Practice" tab so the `{:else if activeTab === 'practice'}` branch renders `<FlashcardView {level} {expressMode} {stq8Service} initialStats={lastStats} onStatsUpdate={handleStatsUpdate} />` at lines 125-131, wiring the live `stq8Service` instance down into `FlashcardView`.

3. With the PR's updated `FlashcardView` implementation (the diff hunk around lines 44-70), ensure `stq8Service.isReady()` is true so the `{#if !stq8Service.isReady()}` guard allows the main Practice UI to render, and complete the calibration flow in `CalibrationView.svelte` (referenced from `SpellbookMode.svelte:133-137`) so that `stq8Service.isCalibrated()` returns true and the PTT button is enabled in the diff's `<PttButton active={pttActive} disabled={!stq8Service.isCalibrated()} ... />` block.

4. On the Practice tab, press and hold the PTT button rendered by `FlashcardView` (see the `<PttButton ... onPttStart={handlePttStart} onPttStop={handlePttStop} />` markup in the PR diff around lines 295-300). The mouse-down event triggers `onmousedown={() => activate('mouse')}` in `src/lib/components/PttButton.svelte:71-83`, which calls `activate('mouse')` (lines 27-32). Because `activeInputs` is initially empty, `activate` calls `onPttStart?.()`, invoking `handlePttStart()` in `FlashcardView` (PR diff around lines 93-101). In the PR version, `handlePttStart` calls `await ensureCapture()`, which enters the existing `ensureCapture()` snippet at lines 48-70: `audioCapture` is null, so it constructs `const capture = new AudioCapture();` and awaits `capture.start(onPcmFrame)`, which internally calls `navigator.mediaDevices.getUserMedia(...)` in `src/lib/voice/audio-capture.ts:21-36`.

5. Before the first `capture.start(onPcmFrame)` resolves (for example, while the browser microphone permission prompt is still showing or `getUserMedia` is slow), release the PTT and then quickly press it again. The release path runs `onmouseup={() => deactivate('mouse')}` in `PttButton.svelte:78-80`; `deactivate('mouse')` at lines 34-39 sees `activeInputs` go back to empty and calls `onPttStop?.()`, which triggers `handlePttStop()` in `FlashcardView` (PR diff around lines 104-131) while the original `ensureCapture()` call is still awaiting `capture.start(...)`.

6. While that first `ensureCapture()` is still pending and `audioCapture` has not yet been assigned, the rapid second press again calls `activate('mouse')``onPttStart?.()``handlePttStart()``ensureCapture()`. Because `audioCapture` is still null, the second `ensureCapture()` invocation also constructs `const capture = new AudioCapture();` and calls `await capture.start(onPcmFrame)`. When both `start()` calls eventually succeed, each `try` block assigns `audioCapture = capture` in turn, so the later-resolving `start()` overwrites the reference to the earlier `AudioCapture` instance. That earlier instance never has `stop()` called (the unmount `$effect` in the PR diff only calls `c.stop()` on the instance currently stored in `audioCapture`), meaning that for some period there are two active `AudioCapture` graphs and one of them is effectively leaked until garbage collection, contrary to the intended "one AudioCapture instance for the lifetime of this component" design.

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/FlashcardView.svelte
**Line:** 48:70
**Comment:**
	*Race Condition: `ensureCapture()` is vulnerable to concurrent calls: two rapid press cycles before the first `start()` resolves can create two separate `AudioCapture` instances, both opening the mic, with the later assignment overwriting the earlier reference and leaking resources. Serialize initialization with a shared in-flight promise so only one start attempt can run at a time.

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

codeant-ai Bot commented Apr 20, 2026

Copy link
Copy Markdown

CodeAnt AI finished reviewing your PR.

Comment thread src/lib/components/FlashcardView.svelte Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 35b5740 and dc46408.

📒 Files selected for processing (2)
  • src/lib/components/FlashcardView.svelte
  • src/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.

Comment thread src/lib/components/FlashcardView.svelte Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread src/lib/components/FlashcardView.svelte
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>
@jenglund

Copy link
Copy Markdown
Contributor Author

Addressed as `aae96eb`. Both findings had real correctness bugs under the framing — took them in order.

Finding 1: Mic start race + unmount leak

Verified 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 stop

Verified 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

  • 1125 tests pass (+4: 2 PttButton regression tests for mid-hold disabled, plus the 2 calibration-gate tests from the earlier commit)
  • `svelte-check` clean on touched files
  • `vite build` clean (main bundle unchanged materially, +0.08 KB gz)

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between dc46408 and aae96eb.

📒 Files selected for processing (3)
  • src/lib/components/FlashcardView.svelte
  • src/lib/components/PttButton.svelte
  • src/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.

captureStart plus destroyed closes 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

Comment thread src/lib/components/__tests__/PttButton.test.ts
Comment thread src/lib/components/PttButton.svelte
…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>
@jenglund

Copy link
Copy Markdown
Contributor Author

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

  • 1128 tests pass (+3: touchend-disabled, touchcancel-disabled, window-mouseup-disabled)
  • `svelte-check` clean on touched files
  • `vite build` clean (+0.04 KB gz for the new handler)

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Don't stop mouse PTT on pointer drift.

With the new window-level mouseup, onmouseleave is no longer needed to avoid a stuck hold, but it still calls deactivate('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

📥 Commits

Reviewing files that changed from the base of the PR and between aae96eb and feafe90.

📒 Files selected for processing (2)
  • src/lib/components/PttButton.svelte
  • src/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 of disabled.

Keeping deactivate() keyed off activeInputs.has(source) preserves the stop invariant without letting stray release events fire onPttStop.

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>
@jenglund

Copy link
Copy Markdown
Contributor Author

Good catch — fixed in 5c94cf0.

CodeRabbit is right: with the window-level onmouseup handler from the previous round catching release anywhere in the document, onmouseleave stopped being a safety net and became a UX bug. Dragging off the button should never commit — only a real mouse release should. Removed the handler and added a regression test that mousedowns, mouseleaves, asserts PTT is still held, then mouseups on window to confirm the actual release path works.

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.

@jenglund
jenglund merged commit f7e0b7f into main Apr 21, 2026
2 checks passed
@jenglund
jenglund deleted the zeblith/zeb-152-slice3-live-ptt branch April 21, 2026 03:00
@codeant-ai

codeant-ai Bot commented Apr 30, 2026

Copy link
Copy Markdown

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot added size:L This PR changes 100-499 lines, ignoring generated files and removed size:L This PR changes 100-499 lines, ignoring generated files labels Apr 30, 2026
@codeant-ai

codeant-ai Bot commented Apr 30, 2026

Copy link
Copy Markdown

User description

Summary

  • Wires AudioCaptureWasmPipeline.process() into FlashcardView's PTT handlers. Holding the button captures PCM, releasing flushes it through the classifier, and the resulting syllable nibbles feed the existing handleRowComplete() evaluation path. This is the ZEB-152 payoff slice: Calibrate-then-Practice now works end-to-end for the first time.
  • Lazy-async capture start on first press, one AudioCapture instance reused across holds for the component's lifetime, $effect cleanup on unmount releases the mic when you leave the Practice tab.
  • PTT disabled with a "Calibrate your voice..." hint when !stq8Service.isCalibrated(), so an uncalibrated classifier can't emit garbage nibbles at the row-progression logic. SpellbookMode already remounts FlashcardView on 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 processPcm returned non-empty syllables, handleRowComplete(nibbles) runs and its existing pass/fail/combo logic takes over untouched. Empty buffer, empty syllables, or a thrown processPcm all 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. FlashcardView previously had an inline 3-method shape for stq8Service; with Slice 3 needing isCalibrated + processPcm the inline shape became duplication. Switched to the exported interface to keep the contract in one place.

Out of scope (Slice 4 polish)

  • Mismatch display from flashcard-design.md:70-78 (expected vs heard with caret under first-differing byte)
  • 2-second momentum timeout during held PTT (spec: progress resets if no syllable advances for 2s)
  • "Couldn't hear that clearly" UX for the empty-syllable release case (currently just the silent cancel path)
  • Permission-denial recovery flow (currently surfaces error message text below the button)

Verification

  • 1121 tests pass (+2 new calibration-gate UI tests in FlashcardView.test.ts)
  • svelte-check clean on all touched files (two pre-existing initial-value warnings on isCalibrated/stq8Service unchanged)
  • vite build clean — main bundle +2.8 KB gzipped (capture wiring)
  • Manual end-to-end verification pending: calibrate on Ildwyn, switch to Practice, hold PTT, speak a Novice-level byte, confirm row-progression feels right

Test plan

  • Pull, npm run dev, calibrate once on the Calibrate tab
  • Switch to Practice tab, verify PTT button is enabled (no "Calibrate first" hint)
  • Hold PTT, say a single Q8 syllable matching the active row, release → row should evaluate (green on match, red flash + reset on mismatch)
  • Hold PTT without speaking, release → row cancels, combo breaks (pre-Slice-3 behavior preserved)
  • Switch tabs mid-session, verify mic indicator clears (capture stops on unmount)
  • Recalibrate mid-session, return to Practice, verify the new profile is in effect

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
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. FlashcardView lazy-starts a single AudioCapture instance on first PTT press, buffers PCM only while held, and on release calls stq8Service.processPcm(); non-empty syllable nibbles are routed through existing handleRowComplete() 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. PttButton ensures releases always unwind even if disabled flips mid-hold and adds a window-level mouseup listener (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

    • Push-to-talk voice input for flashcards with lazy mic capture and audio classification.
  • Improvements

    • PTT disabled until microphone calibration; shows calibration or error hints and updated layout/hint styling.
    • Improved error handling, serialized capture start to avoid conflicts, ensures active capture is stopped on exit.
    • Service prop contract standardized for clearer integration.
    • PTT button now reliably unwinds/releases if disabled mid-hold so stops fire once.
  • Tests

    • Added tests covering calibration UI and PTT behavior when disabled flips mid-hold.

CodeAnt-AI Description

Add calibrated push-to-talk voice practice with mic feedback

What Changed

  • Practice mode now requires voice calibration before push-to-talk can be used, and shows a prompt to calibrate first when it is not ready.
  • Holding push-to-talk now captures speech and uses the heard syllables to complete the practice row; releasing with no usable audio still cancels the row and breaks the combo as before.
  • If microphone access fails, the screen shows a short microphone error message so users know why voice input is not working.
  • Releasing push-to-talk now always ends the hold, even if the control becomes disabled during the press, preventing the button from getting stuck.

Impact

✅ Fewer wrong practice attempts from uncalibrated voice input
✅ Clearer guidance before starting voice practice
✅ Fewer stuck push-to-talk sessions after calibration or permission changes

🔄 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:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

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:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

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.

@codeant-ai

codeant-ai Bot commented Apr 30, 2026

Copy link
Copy Markdown

Sequence Diagram

This 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
Loading

Generated by CodeAnt AI

@codeant-ai

codeant-ai Bot commented Apr 30, 2026

Copy link
Copy Markdown

CodeAnt AI finished running the review.

@codeant-ai

codeant-ai Bot commented Apr 30, 2026

Copy link
Copy Markdown

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot added size:L This PR changes 100-499 lines, ignoring generated files and removed size:L This PR changes 100-499 lines, ignoring generated files labels Apr 30, 2026
@codeant-ai

codeant-ai Bot commented Apr 30, 2026

Copy link
Copy Markdown

User description

Summary

  • Wires AudioCaptureWasmPipeline.process() into FlashcardView's PTT handlers. Holding the button captures PCM, releasing flushes it through the classifier, and the resulting syllable nibbles feed the existing handleRowComplete() evaluation path. This is the ZEB-152 payoff slice: Calibrate-then-Practice now works end-to-end for the first time.
  • Lazy-async capture start on first press, one AudioCapture instance reused across holds for the component's lifetime, $effect cleanup on unmount releases the mic when you leave the Practice tab.
  • PTT disabled with a "Calibrate your voice..." hint when !stq8Service.isCalibrated(), so an uncalibrated classifier can't emit garbage nibbles at the row-progression logic. SpellbookMode already remounts FlashcardView on 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 processPcm returned non-empty syllables, handleRowComplete(nibbles) runs and its existing pass/fail/combo logic takes over untouched. Empty buffer, empty syllables, or a thrown processPcm all 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. FlashcardView previously had an inline 3-method shape for stq8Service; with Slice 3 needing isCalibrated + processPcm the inline shape became duplication. Switched to the exported interface to keep the contract in one place.

Out of scope (Slice 4 polish)

  • Mismatch display from flashcard-design.md:70-78 (expected vs heard with caret under first-differing byte)
  • 2-second momentum timeout during held PTT (spec: progress resets if no syllable advances for 2s)
  • "Couldn't hear that clearly" UX for the empty-syllable release case (currently just the silent cancel path)
  • Permission-denial recovery flow (currently surfaces error message text below the button)

Verification

  • 1121 tests pass (+2 new calibration-gate UI tests in FlashcardView.test.ts)
  • svelte-check clean on all touched files (two pre-existing initial-value warnings on isCalibrated/stq8Service unchanged)
  • vite build clean — main bundle +2.8 KB gzipped (capture wiring)
  • Manual end-to-end verification pending: calibrate on Ildwyn, switch to Practice, hold PTT, speak a Novice-level byte, confirm row-progression feels right

Test plan

  • Pull, npm run dev, calibrate once on the Calibrate tab
  • Switch to Practice tab, verify PTT button is enabled (no "Calibrate first" hint)
  • Hold PTT, say a single Q8 syllable matching the active row, release → row should evaluate (green on match, red flash + reset on mismatch)
  • Hold PTT without speaking, release → row cancels, combo breaks (pre-Slice-3 behavior preserved)
  • Switch tabs mid-session, verify mic indicator clears (capture stops on unmount)
  • Recalibrate mid-session, return to Practice, verify the new profile is in effect

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
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. FlashcardView lazy-starts a single AudioCapture instance on first PTT press, buffers PCM only while held, and on release calls stq8Service.processPcm(); non-empty syllable nibbles are routed through existing handleRowComplete() 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. PttButton ensures releases always unwind even if disabled flips mid-hold and adds a window-level mouseup listener (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

    • Push-to-talk voice input for flashcards with lazy mic capture and audio classification.
  • Improvements

    • PTT disabled until microphone calibration; shows calibration or error hints and updated layout/hint styling.
    • Improved error handling, serialized capture start to avoid conflicts, ensures active capture is stopped on exit.
    • Service prop contract standardized for clearer integration.
    • PTT button now reliably unwinds/releases if disabled mid-hold so stops fire once.
  • Tests

    • Added tests covering calibration UI and PTT behavior when disabled flips mid-hold.

CodeAnt-AI Description

Require voice calibration before Practice PTT, and keep push-to-talk release working even if the control becomes disabled mid-hold

What Changed

  • Practice now blocks push-to-talk until voice calibration is complete and shows a clear prompt to calibrate first.
  • Releasing push-to-talk now still ends the hold even if calibration is lost or another disable state appears while the button is pressed, preventing the control from getting stuck active.
  • Cursor drift off the button no longer cancels a hold early; the release is only committed when the press actually ends.
  • Added coverage for mouse, keyboard, and touch releases, plus the calibration gate in Practice.

Impact

✅ Fewer stuck push-to-talk states
✅ Clearer Practice setup guidance
✅ More reliable voice input on desktop and mobile

🔄 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:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

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:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

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.

@codeant-ai

codeant-ai Bot commented Apr 30, 2026

Copy link
Copy Markdown

Sequence Diagram

This 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
Loading

Generated by CodeAnt AI

@codeant-ai

codeant-ai Bot commented Apr 30, 2026

Copy link
Copy Markdown

PR Code Suggestions ✨

Latest suggestions up to commit 5c94cf0
CategorySuggestion                                                                                                                                    Severity
Logic error
Global mouseup deactivates on non-primary button releases, which can end a hold prematurely

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.

src/lib/components/PttButton.svelte [76-83]

Why it matters? 🤔
  • ❌ Practice PTT capture can end while primary button held.
  • ⚠️ Flashcard row attempts may commit or cancel unexpectedly early.
  • ⚠️ Users can break combos via incidental middle/right clicks.
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

codeant-ai Bot commented Apr 30, 2026

Copy link
Copy Markdown

CodeAnt AI finished running the review.

@codeant-ai

codeant-ai Bot commented May 2, 2026

Copy link
Copy Markdown

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot added size:L This PR changes 100-499 lines, ignoring generated files and removed size:L This PR changes 100-499 lines, ignoring generated files labels May 2, 2026
@codeant-ai

codeant-ai Bot commented May 2, 2026

Copy link
Copy Markdown

Sequence Diagram

This 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
Loading

Generated by CodeAnt AI

@codeant-ai

codeant-ai Bot commented May 2, 2026

Copy link
Copy Markdown

PR Code Suggestions ✨

Latest suggestions up to commit 5c94cf0
CategorySuggestion                                                                                                                                    Severity
Race condition
Releasing and flushing immediately can drop late-arriving audio frames and truncate recognized speech

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.

src/lib/components/FlashcardView.svelte [130-138]

Why it matters? 🤔
  • ⚠️ Practice tab PTT uses truncated PCM for recognition.
  • ⚠️ Flashcard row evaluation may mis-score correct utterances.
  • ⚠️ Users see unexpected combo breaks despite accurate speech.
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

codeant-ai Bot commented May 2, 2026

Copy link
Copy Markdown

CodeAnt AI finished running the review.

@codeant-ai

codeant-ai Bot commented May 2, 2026

Copy link
Copy Markdown

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot added size:L This PR changes 100-499 lines, ignoring generated files and removed size:L This PR changes 100-499 lines, ignoring generated files labels May 2, 2026
@codeant-ai

codeant-ai Bot commented May 2, 2026

Copy link
Copy Markdown

Sequence Diagram

This 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
Loading

Generated by CodeAnt AI

@codeant-ai

codeant-ai Bot commented May 2, 2026

Copy link
Copy Markdown

PR Code Suggestions ✨

Latest suggestions up to commit 5c94cf0
CategorySuggestion                                                                                                                                    Severity
Logic error
Classification still runs after calibration is lost mid-hold, allowing invalid audio results to drive progression logic

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.

src/lib/components/FlashcardView.svelte [138-145]

Why it matters? 🤔
  • ❌ Practice PTT may accept attempts after calibration flagged invalid.
  • ⚠️ Stats/combo tracking use results from uncalibrated recognition.
  • ⚠️ UI says Practice disabled while row progression still runs.
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

codeant-ai Bot commented May 2, 2026

Copy link
Copy Markdown

CodeAnt AI finished running the review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant