Skip to content

Windows dev - #13

Open
GuixJoy wants to merge 6 commits into
IntegerAlex:masterfrom
GuixJoy:Windows-Dev
Open

Windows dev#13
GuixJoy wants to merge 6 commits into
IntegerAlex:masterfrom
GuixJoy:Windows-Dev

Conversation

@GuixJoy

@GuixJoy GuixJoy commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Stable version for window

Note

Add Windows-compatible overlay window and cross-platform text insertion for PTT workflow

  • Introduces a dedicated always-on-top transparent Tauri overlay window (overlay) that displays a pill UI driven by overlay:command events, cycling through listening, processing, inserting, and success states with mic waveform animation and spring-based transitions.
  • Adds a cross-platform insert_text Tauri command that pastes or types transcribed text into the previously focused window on Windows (clipboard + SendInput Unicode fallback), Linux (wtype/Ctrl+V), and macOS (AppleScript).
  • Replaces the in-app PttOverlay React component with the dedicated overlay window; App.tsx now coordinates a full PTT state machine that waits for backend idle before committing, then drives overlay states and awaits cleanup acknowledgement.
  • Separates stdout/stderr buffering in api-tauri.ts to prevent JSON event corruption from interleaved streams.
  • Adds force_end to StreamingEndpointDetector and updates the orchestrator to flush in-progress speech on PTT stop, wait for transcription threads, and schedule ASR model release after idle.
  • Risk: the overlay window is always created at app startup (invisible) and the new blocking ASR semaphore wait (120s) may delay idle state if the backend hangs.

Macroscope summarized 9963f29.

GuixJoy added 3 commits June 29, 2026 19:04
…ut fallback

PTT Lifecycle Rewrite:
- Merged stop+commit into atomic stopAndCommit() to prevent race conditions
- Added isCommittingRef to block new starts during commit wait
- All stop paths (Hotkey, MicButton, SpaceBar, Widget, Tray) now commit text
- Settings save uses commit=false to avoid unwanted typing

Real-Time Typing:
- Text committed on raw ASR events during recording (immediate feedback)
- Text committed on processed LLM events (final version)
- User sees text appear as they speak, not just on release

Typing Compatibility:
- Added SendInput with KEYEVENTF_UNICODE as fallback for clipboard+Ctrl+V
- Works in terminals, Electron apps, and UWP apps that ignore Ctrl+V
- Character-by-character Unicode typing with surrogate pair support

UI Fixes:
- Fixed duplicate React keys (live-1 collision) with session-scoped IDs
- Lines cleared on each new PTT session start
- Added audio feedback beeps on PTT press (880Hz) and release (440Hz)

Build Fixes:
- Fixed pre-existing TS errors (unlistenShortcut, loadHistory, totalWords)
- Created tsconfig.app.json to exclude test files from build
- Fixed pnpm build to only check app code

Hotkey:
- Default: Ctrl+Shift+Space (valid Tauri format)
- Added Alt+K option to Settings
- Hotkey re-registration on change without restart
@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds a frontend PTT state machine, synchronized transcription completion, native text insertion, and an animated overlay. It also updates sidecar parsing, VAD orchestration, model cleanup, hotkey handling, build configuration, and repository settings.

Changes

PTT Platform and Overlay Flow

Layer / File(s) Summary
Transcription orchestration and VAD lifecycle
stt/orchestrator.py, stt/transcription.py, stt/vad.py
Synchronizes recording sessions, VAD endpoint finalization, transcription workers, JSON output, backend release, warm-up logging, and hysteresis behavior.
Native text insertion and overlay commands
stt-ui/src-tauri/src/commands.rs, stt-ui/src-tauri/src/lib.rs, stt-ui/src-tauri/{Cargo.toml,capabilities/default.json,tauri.conf.json}
Adds cross-platform text insertion, overlay show/hide commands, Windows Unicode SendInput, and the configured overlay window.
Overlay rendering and animation
stt-ui/src/main.tsx, stt-ui/src/overlay/*
Adds overlay routing, event-driven state visuals, microphone waveform rendering, and spring animations.
Frontend PTT state machine and integrations
stt-ui/src/App.tsx, stt-ui/src/lib/audio.ts
Adds guarded PTT transitions, start/stop beeps, backend-idle synchronization, transcript commit insertion, spawn cleanup, and trigger-specific controls.
Sidecar transport and supporting UI/configuration
stt-ui/src/api-tauri.ts, stt-ui/src/components/*, stt-ui/package.json, stt-ui/tsconfig.app.json, tests/test_sidecar_e2e.py, .gitattributes, .gitignore
Separates buffered stdout/stderr parsing, targets widget events, adds Alt+K, fixes UI handlers, excludes tests from app type-checking, and updates repository configuration.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Poem

🐇 Beeps guide the listening ear,
Waves and springs make states appear.
Threads settle, words softly land,
Unicode hops across the land.
The overlay fades when all is clear.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is too vague to clearly summarize the main change in the PR. Rename it to mention the Windows overlay and text insertion changes, e.g. "Add Windows overlay and insert_text support".
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description clearly relates to the overlay, text insertion, and PTT workflow changes in the PR.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Comment thread stt-ui/src-tauri/src/lib.rs Outdated
Comment thread stt/orchestrator.py
# --- Wait for pending transcription threads before going idle ---
# This ensures pttTextRef has the latest transcription when the
# frontend reads it after receiving state:idle.
if _active_xcribe_threads:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium stt/orchestrator.py:788

run() joins each worker thread with a per-thread timeout derived from a single 15s deadline, then unconditionally calls _active_xcribe_threads.clear() regardless of whether any thread is still alive. When a _transcribe_and_print worker exceeds the deadline, it is dropped from the tracking list while still running, so its later raw/processed events can fire after state: idle is emitted or during a new recording session. Consider keeping unfinished threads in the list (e.g., only removing threads where t.is_alive() is False) so they remain tracked across the next PTT cycle.

🤖 Copy this AI Prompt to have your agent fix this:
In file @stt/orchestrator.py around line 788:

`run()` joins each worker thread with a per-thread timeout derived from a single 15s deadline, then unconditionally calls `_active_xcribe_threads.clear()` regardless of whether any thread is still alive. When a `_transcribe_and_print` worker exceeds the deadline, it is dropped from the tracking list while still running, so its later `raw`/`processed` events can fire after `state: idle` is emitted or during a new recording session. Consider keeping unfinished threads in the list (e.g., only removing threads where `t.is_alive()` is `False`) so they remain tracked across the next PTT cycle.

Comment thread stt-ui/src/api-tauri.ts Outdated
Comment thread stt-ui/src/App.tsx
Comment thread stt-ui/src/App.tsx Outdated
Comment thread stt-ui/src/App.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@stt-ui/src-tauri/src/lib.rs`:
- Around line 1063-1103: The Windows Unicode input path in send_text_unicode is
ignoring SendInput failures, which can make type_text report success even when
nothing was injected. Update send_text_unicode to return a status/result from
each SendInput call, including the surrogate-pair branch, and have type_text
check and propagate that failure instead of always returning Ok(true). Use the
existing send_text_unicode and type_text symbols to wire the error/status back
to the caller.
- Around line 1219-1226: The paste flow currently returns success immediately
after set_clipboard and send_ctrl_v, even though Ctrl+V is fire-and-forget and
may be ignored by some targets. Update the clipboard paste branch to verify
delivery before returning Ok(true), or route known incompatible targets directly
to send_text_unicode instead of exiting early. Use the existing
win32::set_clipboard, win32::send_ctrl_v, and win32::send_text_unicode paths to
keep the behavior aligned with the focused app’s capabilities.

In `@stt-ui/src/api-tauri.ts`:
- Around line 43-53: The shared lineBuffer in api-tauri.ts is mixing stdout and
stderr chunks, which can corrupt partial JSON events across streams. Update the
streaming parser around handleLine and the chunk processing to maintain separate
buffers per source (for example, keyed by source or separate stdout/stderr
state) so incomplete data is only joined with future chunks from the same
stream. Keep notifyError and the existing listener flow unchanged, but make sure
each stream’s framing is preserved before JSON parsing.
- Around line 109-111: The write failure in the command path is only being
logged inside the child.write catch, so callers like the
start_recording/stop_recording flow still assume delivery succeeded. Update the
command-sending logic in api-tauri.ts to surface this failure to listeners,
using the same notification mechanism the app relies on for recording state
changes, so the UI can exit the recording/committing path when a write to child
fails. Keep the fix centered around the child.write call and the surrounding
command dispatch helper so all command types propagate the error consistently.

In `@stt-ui/src/App.tsx`:
- Around line 1041-1050: The real-time PTT commit logic is sending the full
transcript snapshot on every ASR event, which causes duplicated text when
`type_text` is called again during commit. Update the handling in App.tsx around
the real-time commit path and the stop/processed commit paths so `type_text`
only receives newly finalized text (not the entire `event.text` snapshot) and
does not re-send text already inserted into the focused window. Use the existing
`pttTextRef`, `connectedRef`, `pttHwndRef`, and `stopAndCommit` flow to track
what has already been typed and only append the delta once.
- Around line 1166-1167: The stop handler wiring in App.tsx is stale after
renaming the implementation to stopAndCommit, so FeedView is still receiving the
wrong stop reference and may fall back to the global stop. Update the render
path that passes props into FeedView so its stop prop uses the same handler
stored in stopRef and points to stopAndCommit, keeping the PTT mic button
connected to the correct stop flow.
- Around line 1035-1040: The session-prefixed line ID used when inserting rows
in App.tsx is not being used consistently by later updates. Update the
processed, llm_partial, and error handlers to compute the same session-scoped ID
helper used for the live row before matching against line.id, so they continue
to find the existing entry instead of comparing against the bare utterance_id.
- Around line 837-840: The api.spawn() completion handler in App.tsx can still
write a stale api into runtimeRef.current after mode or settingsVersion changes
and cleanup has already invalidated that sidecar. Update the spawn flow to guard
the .then() callback against stale resolution, using the existing
runtimeRef/current lifecycle in App or the spawn/start effect, so only the
latest active api instance is stored and older resolved spawns are ignored.

In `@stt/orchestrator.py`:
- Around line 785-795: The idle transition in orchestrator.py still treats
pending transcription threads as finished even when a join times out, which can
let late output arrive after state:idle. Update the pending-thread handling
around _active_xcribe_threads so that after the join deadline you check each
thread with t.is_alive() before clearing the list, and if any are still running
either suppress their late raw/processed emissions or emit a timeout/error
instead of proceeding to idle. Use the existing _active_xcribe_threads wait
block and the idle/state publish path to ensure idle is only sent once all
worker output is truly complete.
🪄 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: CHILL

Plan: Pro

Run ID: f20762e9-72fe-4430-95fc-4f6b41f4184c

📥 Commits

Reviewing files that changed from the base of the PR and between 81979b3 and 5bdeda2.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • .gitattributes
  • stt-ui/package.json
  • stt-ui/src-tauri/src/lib.rs
  • stt-ui/src/App.tsx
  • stt-ui/src/api-tauri.ts
  • stt-ui/src/components/HistoryPage.tsx
  • stt-ui/src/components/InsightsPage.tsx
  • stt-ui/src/components/SettingsPanel.tsx
  • stt-ui/src/lib/audio.ts
  • stt-ui/tsconfig.app.json
  • stt/orchestrator.py
  • tests/test_sidecar_e2e.py
💤 Files with no reviewable changes (1)
  • stt-ui/src/components/InsightsPage.tsx

Comment on lines +1063 to +1103
pub fn send_text_unicode(text: &str) {
for ch in text.chars() {
let code = ch as u32;
let mut inputs = [
Input {
r#type: INPUT_KEYBOARD,
ki: KeyBDInput {
wVk: 0,
wScan: code as u16,
dwFlags: KEYEVENTF_UNICODE,
time: 0,
dwExtraInfo: 0,
},
_pad: [0; 8],
},
Input {
r#type: INPUT_KEYBOARD,
ki: KeyBDInput {
wVk: 0,
wScan: code as u16,
dwFlags: KEYEVENTF_UNICODE | KEYEVENTF_KEYUP,
time: 0,
dwExtraInfo: 0,
},
_pad: [0; 8],
},
];
// Handle supplementary plane characters (surrogate pairs)
if code > 0xFFFF {
// Send as two UTF-16 surrogates
let hi = (0xD800 + ((code - 0x10000) >> 10)) as u16;
let lo = (0xDC00 + ((code - 0x10000) & 0x3FF)) as u16;
inputs[0].ki.wScan = hi;
inputs[1].ki.wScan = hi;
unsafe { SendInput(2, inputs.as_ptr(), std::mem::size_of::<Input>() as i32); }
inputs[0].ki.wScan = lo;
inputs[1].ki.wScan = lo;
unsafe { SendInput(2, inputs.as_ptr(), std::mem::size_of::<Input>() as i32); }
} else {
unsafe { SendInput(2, inputs.as_ptr(), std::mem::size_of::<Input>() as i32); }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Win32 SendInput return value number of INPUT events successfully inserted zero failure UIPI

💡 Result:

The Win32 SendInput function returns the number of events that were successfully inserted into the keyboard or mouse input stream [1][2]. If the function returns zero, it indicates that the input was blocked by another thread [1][3]. When SendInput is blocked by User Interface Privilege Isolation (UIPI), the behavior is distinct in the following ways: 1. Return Value: If UIPI blocks the input, SendInput may return zero [4][5]. However, it is important to note that the return value is not a dedicated error code and does not always explicitly identify UIPI as the cause of failure [3][4]. 2. GetLastError: Calling GetLastError after a failure typically returns 5 (ERROR_ACCESS_DENIED) [4], but neither the return value nor GetLastError is guaranteed to indicate that UIPI blocking was the specific cause of the failure [3][4]. Because UIPI restricts input based on integrity levels—where an application can generally only send input to processes of equal or lower integrity—the discrepancy between the number of requested events and the successful return value is often used by developers as an indirect indicator that UIPI has blocked the input [4][6][5]. To bypass these restrictions, applications typically require the 'uiAccess="true"' attribute in their manifest, must be digitally signed, and often need to be installed in a protected location like Program Files [6][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and inspect the cited region plus the caller.
FILE="stt-ui/src-tauri/src/lib.rs"

echo "== file exists =="
git ls-files "$FILE"

echo
echo "== line count =="
wc -l "$FILE"

echo
echo "== outline around relevant symbols =="
ast-grep outline "$FILE" --view expanded || true

echo
echo "== cited region around send_text_unicode =="
sed -n '1040,1135p' "$FILE" | cat -n

echo
echo "== caller region around type_text =="
sed -n '1200,1255p' "$FILE" | cat -n

echo
echo "== search for send_text_unicode usages =="
rg -n "send_text_unicode|type_text\(" "$FILE"

Repository: IntegerAlex/floure-core

Length of output: 11192


Propagate SendInput failures in the Windows Unicode fallback. send_text_unicode ignores SendInput’s return value, so type_text can still return Ok(true) even when no input was injected. Bubble the status back up at stt-ui/src-tauri/src/lib.rs:1063-1103 and 1226-1227.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt-ui/src-tauri/src/lib.rs` around lines 1063 - 1103, The Windows Unicode
input path in send_text_unicode is ignoring SendInput failures, which can make
type_text report success even when nothing was injected. Update
send_text_unicode to return a status/result from each SendInput call, including
the surrogate-pair branch, and have type_text check and propagate that failure
instead of always returning Ok(true). Use the existing send_text_unicode and
type_text symbols to wire the error/status back to the caller.

Comment thread stt-ui/src-tauri/src/lib.rs Outdated
Comment on lines +1219 to +1226
// Try clipboard+Ctrl+V first (fast, preserves clipboard)
if win32::set_clipboard(&text) {
std::thread::sleep(std::time::Duration::from_millis(30));
win32::send_ctrl_v();
return Ok(true);
}
// Small delay for clipboard to propagate
std::thread::sleep(std::time::Duration::from_millis(30));
// Send Ctrl+V via keybd_event (runs in Tauri's GUI thread — has active message loop)
win32::send_ctrl_v();
// Fallback: SendInput Unicode (works in apps that ignore Ctrl+V)
win32::send_text_unicode(&text);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Can Windows keybd_event or SendInput Ctrl+V report whether a target application accepted paste

💡 Result:

No, the Windows keybd_event and SendInput functions cannot directly report whether a target application has accepted or processed a "paste" (Ctrl+V) command [1][2]. These functions operate by injecting events into the system input stream [3][4]. Once the input is injected, the operating system treats it as synthetic hardware input and dispatches it to the appropriate window's message queue [1]. Because this process is asynchronous, the calling application has no built-in mechanism to know if or when the target application retrieves that message from its queue, validates the clipboard content, or successfully performs the paste operation [2]. Technical limitations include: - Asynchronous Delivery: SendInput returns the number of events successfully inserted into the input stream, not the outcome of the target application's processing [4][2]. - Lack of Feedback: There is no return path from the target application to the source of the synthetic input, meaning the target application provides no signal to the sender regarding whether the paste was handled [2]. To achieve a reliable result, you must use alternative strategies to monitor the state of the target application: - Monitoring UI Changes: You can use Microsoft UI Automation to observe changes in the target application's UI (e.g., checking if text content has updated) [5]. - Input Synchronization: In some cases, you can use WaitForInputIdle to detect when a process is idle, which may indicate it has finished processing messages, though this is not a guaranteed confirmation of a specific paste operation [5]. - Direct Data Injection: If the target application supports it, rather than simulating keys, it is significantly more robust to interact with the application directly via its object model, accessibility APIs, or by sending specific application-defined messages (e.g., WM_PASTE) directly to the target window handle [2]. If you are attempting to detect when a paste happens in an application you control, you can monitor local events such as WM_PASTE, TextChanged, or use AddClipboardFormatListener to track clipboard interactions [6][7][8][9]. However, these are internal application hooks, not feedback provided by the SendInput API itself.

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant area in the Tauri backend and locate the input helpers.
git ls-files stt-ui/src-tauri/src/lib.rs
rg -n "set_clipboard|send_ctrl_v|send_text_unicode|paste|Ctrl\\+V|SendInput|keybd_event" stt-ui/src-tauri/src/lib.rs stt-ui/src-tauri/src -S

# Read the nearby implementation with line numbers.
sed -n '1180,1265p' stt-ui/src-tauri/src/lib.rs

# Inspect the helper definitions if they live in the same crate.
rg -n "fn send_text_unicode|fn send_ctrl_v|fn set_clipboard" stt-ui/src-tauri/src -S

Repository: IntegerAlex/floure-core

Length of output: 9651


Unicode fallback never runs after a successful clipboard paste
send_ctrl_v() is fire-and-forget, so this branch returns Ok(true) even when the focused app ignores Ctrl+V. Apps like terminals, Electron, or UWP never reach send_text_unicode(), which means input can be dropped while the command reports success. Route those targets through the Unicode path up front, or add a delivery check before returning success.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt-ui/src-tauri/src/lib.rs` around lines 1219 - 1226, The paste flow
currently returns success immediately after set_clipboard and send_ctrl_v, even
though Ctrl+V is fire-and-forget and may be ignored by some targets. Update the
clipboard paste branch to verify delivery before returning Ok(true), or route
known incompatible targets directly to send_text_unicode instead of exiting
early. Use the existing win32::set_clipboard, win32::send_ctrl_v, and
win32::send_text_unicode paths to keep the behavior aligned with the focused
app’s capabilities.

Comment thread stt-ui/src/api-tauri.ts Outdated
Comment thread stt-ui/src/api-tauri.ts
Comment on lines +109 to +111
child.write(JSON.stringify(cmd) + "\n").catch((e) => {
console.warn("[Engine] Failed to write command:", e);
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Surface command write failures to the app.

The catch only logs, while callers update PTT state as if start_recording/stop_recording was delivered. Notify listeners so the UI can leave the recording/committing path instead of getting stuck.

🐛 Proposed fix to notify on write failure
-        child.write(JSON.stringify(cmd) + "\n").catch((e) => {
-          console.warn("[Engine] Failed to write command:", e);
+        child.write(JSON.stringify(cmd) + "\n").catch((e) => {
+          const message = e instanceof Error ? e.message : String(e);
+          console.warn("[Engine] Failed to write command:", e);
+          notifyError(`Failed to send command to engine: ${message}`);
         });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
child.write(JSON.stringify(cmd) + "\n").catch((e) => {
console.warn("[Engine] Failed to write command:", e);
}
});
child.write(JSON.stringify(cmd) + "\n").catch((e) => {
const message = e instanceof Error ? e.message : String(e);
console.warn("[Engine] Failed to write command:", e);
notifyError(`Failed to send command to engine: ${message}`);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt-ui/src/api-tauri.ts` around lines 109 - 111, The write failure in the
command path is only being logged inside the child.write catch, so callers like
the start_recording/stop_recording flow still assume delivery succeeded. Update
the command-sending logic in api-tauri.ts to surface this failure to listeners,
using the same notification mechanism the app relies on for recording state
changes, so the UI can exit the recording/committing path when a write to child
fails. Keep the fix centered around the child.write call and the surrounding
command dispatch helper so all command types propagate the error consistently.

Comment thread stt-ui/src/App.tsx
Comment thread stt-ui/src/App.tsx
Comment on lines +1035 to 1040
const rawId = event.utterance_id ?? nextLocalId.current++;
const id = sessionCounter.current * 100000 + rawId;
setLines((prev) => [
...prev,
{ id, raw: event.text, processed: "", status: "transcribing", createdAt: new Date().toISOString() },
].slice(-500));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep the session-prefixed line ID consistent across later events.

Line 1036 stores the live row under a session-scoped ID, but the processed, llm_partial, and error handlers still look rows up with the bare backend utterance_id. After this change, those updates can no longer find the inserted row, so entries stay stuck in transcribing.

Suggested fix
+ const toSessionLineId = (utteranceId: number) =>
+   sessionCounter.current * 100000 + utteranceId;

    if (event.type === "raw") {
      const rawId = event.utterance_id ?? nextLocalId.current++;
-     const id = sessionCounter.current * 100000 + rawId;
+     const id = toSessionLineId(rawId);
      setLines((prev) => [
        ...prev,
        { id, raw: event.text, processed: "", status: "transcribing", createdAt: new Date().toISOString() },
      ].slice(-500));

Apply the same helper in the processed, llm_partial, and error handlers before comparing against line.id.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt-ui/src/App.tsx` around lines 1035 - 1040, The session-prefixed line ID
used when inserting rows in App.tsx is not being used consistently by later
updates. Update the processed, llm_partial, and error handlers to compute the
same session-scoped ID helper used for the live row before matching against
line.id, so they continue to find the existing entry instead of comparing
against the bare utterance_id.

Comment thread stt-ui/src/App.tsx Outdated
Comment thread stt-ui/src/App.tsx
Comment on lines 1166 to +1167
startRef.current = start;
stopRef.current = stop;
stopRef.current = stopAndCommit;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Re-export the new stop handler to FeedView.

After renaming the implementation to stopAndCommit, the render path still passes stop={stop} into FeedView at Line 1352. In this module that can resolve to the global stop, so the mic button no longer calls the PTT stop flow.

Suggested fix
-            stop={stop}
+            stop={stopAndCommit}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
startRef.current = start;
stopRef.current = stop;
stopRef.current = stopAndCommit;
stop={stopAndCommit}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt-ui/src/App.tsx` around lines 1166 - 1167, The stop handler wiring in
App.tsx is stale after renaming the implementation to stopAndCommit, so FeedView
is still receiving the wrong stop reference and may fall back to the global
stop. Update the render path that passes props into FeedView so its stop prop
uses the same handler stored in stopRef and points to stopAndCommit, keeping the
PTT mic button connected to the correct stop flow.

Comment thread stt/orchestrator.py Outdated
Comment on lines +785 to +795
# --- Wait for pending transcription threads before going idle ---
# This ensures pttTextRef has the latest transcription when the
# frontend reads it after receiving state:idle.
if _active_xcribe_threads:
_echo(f"Waiting for {_active_xcribe_threads.__len__()} pending transcription(s)...")
_deadline = time.monotonic() + 15.0 # max 15 s wait
for t in _active_xcribe_threads:
remaining = max(0.1, _deadline - time.monotonic())
t.join(timeout=remaining)
_active_xcribe_threads.clear()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don’t emit idle while timed-out workers may still publish text.

The join has a timeout, but the code clears _active_xcribe_threads and emits state: idle even if a worker is still alive. Late raw/processed events can arrive after the frontend has committed stale pttTextRef; this is especially reachable because the frontend commit timeout shown in context is 10s while this backend deadline is 15s. Check t.is_alive() after the deadline and emit a timeout/error or suppress late worker output instead of treating idle as complete.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt/orchestrator.py` around lines 785 - 795, The idle transition in
orchestrator.py still treats pending transcription threads as finished even when
a join times out, which can let late output arrive after state:idle. Update the
pending-thread handling around _active_xcribe_threads so that after the join
deadline you check each thread with t.is_alive() before clearing the list, and
if any are still running either suppress their late raw/processed emissions or
emit a timeout/error instead of proceeding to idle. Use the existing
_active_xcribe_threads wait block and the idle/state publish path to ensure idle
is only sent once all worker output is truly complete.

GuixJoy added 2 commits June 30, 2026 10:18
- Created commands.rs: begin_capture, end_capture, insert_text, show_overlay, hide_overlay
- Removed Rust state machine (state.rs, typing.rs, overlay.rs)
- Rewrote App.tsx with PttState: idle→listening→processing→inserting→success
- Rewrote OverlayView.tsx to listen overlay:command events (pure rendering)
- Added overlay window to tauri.conf.json (280x60, transparent, alwaysOnTop)
- Added main.tsx routing for overlay window
- Positioned overlay centered above taskbar via primary_monitor()
- Backend now only executes commands, frontend owns all decisions
- Stop writing raw LLM tokens to stdout in json_mode (corrupted the
  Tauri JSON event channel, dropped processed events, and emitted
  non-UTF-8 bytes causing 'invalid utf-8' sidecar errors)
- Wait for in-flight transcription threads before emitting idle so the
  UI commits transcript text instead of 'Nothing to commit'
- Release ASR model on idle (STT_IDLE_RELEASE_SEC, default 20s) to free
  several GB of RAM/VRAM; re-warm on next session. Add release_backend()
- Gate few-shot embeddings behind STT_EMBEDDINGS=1 (was loading
  sentence-transformers on every utterance)
- Redirect whisper.cpp subprocess stderr to devnull instead of
  capture_output=True to avoid buffering child output in RAM
- Add stt-win11/ (1.8GB generated venv copy) to .gitignore
Comment thread stt-ui/src/App.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High

import { useEffect, useMemo, useRef, useState, useReducer, useCallback } from "react";

start sets the PTT state to listening before the awaited overlay/HWND operations complete, so if the user releases PTT during those awaits, stopAndCommit runs and sends stop_recording, then the suspended start resumes and sends start_recording. This leaves the backend recording while the frontend has already transitioned to processing (and will eventually time out), and because the PTT state is no longer listening, normal stop controls are blocked. Consider setting the state to listening immediately before runtimeRef.current.start() is called, or guarding the pending start against an intervening stop so that start_recording is not sent after stop_recording.

🤖 Copy this AI Prompt to have your agent fix this:
In file @stt-ui/src/App.tsx around line 1:

`start` sets the PTT state to `listening` before the awaited overlay/HWND operations complete, so if the user releases PTT during those awaits, `stopAndCommit` runs and sends `stop_recording`, then the suspended `start` resumes and sends `start_recording`. This leaves the backend recording while the frontend has already transitioned to `processing` (and will eventually time out), and because the PTT state is no longer `listening`, normal stop controls are blocked. Consider setting the state to `listening` immediately before `runtimeRef.current.start()` is called, or guarding the pending `start` against an intervening stop so that `start_recording` is not sent after `stop_recording`.

if let Ok(Some(monitor)) = win.primary_monitor() {
let m_size = monitor.size();
let m_pos = monitor.position();
let pill_w = 280;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium src/commands.rs:39

show_overlay positions the overlay using hard-coded 280x60 dimensions, but these are logical-pixel values. On a scaled display (e.g. 150% or 200% DPI), the window's physical size is larger, so the computed PhysicalPosition is visibly off-center and the bottom margin is wrong. The position is derived from pill_w and pill_h constants instead of the window's actual physical size, so set_position receives coordinates that don't match the real window dimensions. Consider scaling the logical dimensions by monitor.scale_factor() (or using LogicalPosition so the framework converts for you).

🤖 Copy this AI Prompt to have your agent fix this:
In file @stt-ui/src-tauri/src/commands.rs around line 39:

`show_overlay` positions the overlay using hard-coded `280x60` dimensions, but these are logical-pixel values. On a scaled display (e.g. 150% or 200% DPI), the window's physical size is larger, so the computed `PhysicalPosition` is visibly off-center and the bottom margin is wrong. The position is derived from `pill_w` and `pill_h` constants instead of the window's actual physical size, so `set_position` receives coordinates that don't match the real window dimensions. Consider scaling the logical dimensions by `monitor.scale_factor()` (or using `LogicalPosition` so the framework converts for you).


return {
/** Start capturing audio and emitting bar values. */
async start() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium overlay/waveform.ts:96

start() does not guard against concurrent calls while getUserMedia() is still resolving, so two calls before the first completes both pass the state.active check and each acquires its own microphone stream. They overwrite the shared state.stream, state.audioContext, and state.analyser fields, so stop() only tears down the last one — the earlier stream stays live, leaking microphone capture and audio resources. Repeated listening commands during a permission prompt can trigger this. Consider tracking a pending-start state so concurrent calls no-op while the first is in flight.

Also found in 1 other location(s)

stt-ui/src/overlay/OverlayView.tsx:65

Starting the waveform is asynchronous and is neither awaited nor cancelled. If a processing/inserting command arrives while start() is waiting for microphone permission, isActive() is still false so line 69 does not stop it; once permission resolves, capture and its animation loop start even though the overlay is no longer listening. An idle command during the wait can similarly make start() fall into its catch path after stop(), reactivating the fallback RAF while the overlay is idle.

🤖 Copy this AI Prompt to have your agent fix this:
In file @stt-ui/src/overlay/waveform.ts around line 96:

`start()` does not guard against concurrent calls while `getUserMedia()` is still resolving, so two calls before the first completes both pass the `state.active` check and each acquires its own microphone stream. They overwrite the shared `state.stream`, `state.audioContext`, and `state.analyser` fields, so `stop()` only tears down the last one — the earlier stream stays live, leaking microphone capture and audio resources. Repeated `listening` commands during a permission prompt can trigger this. Consider tracking a pending-start state so concurrent calls no-op while the first is in flight.

Also found in 1 other location(s):
- stt-ui/src/overlay/OverlayView.tsx:65 -- Starting the waveform is asynchronous and is neither awaited nor cancelled. If a `processing`/`inserting` command arrives while `start()` is waiting for microphone permission, `isActive()` is still false so line 69 does not stop it; once permission resolves, capture and its animation loop start even though the overlay is no longer listening. An `idle` command during the wait can similarly make `start()` fall into its catch path after `stop()`, reactivating the fallback RAF while the overlay is idle.

Comment thread stt/orchestrator.py
_ws_clients: list = []
_ws_loop = None # set by start_ws_server

# Pending idle model-release timer (see _schedule_idle_release / _cancel_idle_release).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High stt/orchestrator.py:82

_cancel_idle_release() sets _idle_release_timer to None unconditionally, so any code that later checks if _idle_release_timer is None to decide whether the model needs warm-up treats every session as cold-started. On the very first PTT session this races the startup warm-up, and because the model getters use an unsynchronized check-then-create, both threads load the same multi-GB model concurrently — risking RAM/VRAM exhaustion. Consider tracking model residency/warm-up state separately from the timer handle, or serializing warm-up and cache creation with a lock.

Also found in 1 other location(s)

stt/transcription.py:40

release_backend() clears the model caches without synchronizing with model warm-up/cache lookup. threading.Timer.cancel() does not stop a callback that has already begun, so a new PTT session can start warm_up_backend() while the old idle callback executes these clears. The callback can then remove the newly loaded model (and clear framework allocator caches), causing the session's first transcription to reload it again and incur the cold-start/multi-GB allocation that the warm-up is intended to avoid. Guard release and all cache creation with the same lock, and make the timer callback verify that it still owns the current idle generation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @stt/orchestrator.py around line 82:

`_cancel_idle_release()` sets `_idle_release_timer` to `None` unconditionally, so any code that later checks `if _idle_release_timer is None` to decide whether the model needs warm-up treats every session as cold-started. On the very first PTT session this races the startup warm-up, and because the model getters use an unsynchronized check-then-create, both threads load the same multi-GB model concurrently — risking RAM/VRAM exhaustion. Consider tracking model residency/warm-up state separately from the timer handle, or serializing warm-up and cache creation with a lock.

Also found in 1 other location(s):
- stt/transcription.py:40 -- `release_backend()` clears the model caches without synchronizing with model warm-up/cache lookup. `threading.Timer.cancel()` does not stop a callback that has already begun, so a new PTT session can start `warm_up_backend()` while the old idle callback executes these clears. The callback can then remove the newly loaded model (and clear framework allocator caches), causing the session's first transcription to reload it again and incur the cold-start/multi-GB allocation that the warm-up is intended to avoid. Guard release and all cache creation with the same lock, and make the timer callback verify that it still owns the current idle generation.

Comment thread stt-ui/src/App.tsx

// Tell overlay to clean up, wait for acknowledgement, then hide
overlayCleaningRef.current = true;
try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium src/App.tsx:1217

Both overlay cleanup paths in stopAndCommit call emitTo("overlay", "overlay:command", "idle") and then install overlayIdleResolveRef afterwards. If the overlay processes the command and emits overlay:idle_ready before that assignment runs, the listener (set up in a separate effect) finds overlayIdleResolveRef.current === null and discards the acknowledgement. The code then blocks on the promise until the 2-second fallback timeout fires, delaying the overlay hide and the return to idle state by the full timeout on every race win. Install the resolver before emitting the command so the acknowledgement is always captured.

🤖 Copy this AI Prompt to have your agent fix this:
In file @stt-ui/src/App.tsx around line 1217:

Both overlay cleanup paths in `stopAndCommit` call `emitTo("overlay", "overlay:command", "idle")` and then install `overlayIdleResolveRef` afterwards. If the overlay processes the command and emits `overlay:idle_ready` before that assignment runs, the listener (set up in a separate effect) finds `overlayIdleResolveRef.current === null` and discards the acknowledgement. The code then blocks on the promise until the 2-second fallback timeout fires, delaying the overlay hide and the return to `idle` state by the full timeout on every race win. Install the resolver before emitting the command so the acknowledgement is always captured.

Comment thread stt/orchestrator.py
# exists (pttTextRef still empty) → "Nothing to commit", nothing typed.
# Join with a bounded timeout so a hung ASR can't stall the loop
# forever, but a normal utterance finishes well within the window.
if _active_xcribe_threads:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High stt/orchestrator.py:1010

A start_recording command that arrives while the previous session's transcription threads are still being joined is silently lost. The join loop at line 1013 can block for up to 180 seconds; during that window _start_event is already set from the current session, so the stdin reader's _start_event.set() records no new state. Immediately after the join, lines 1018–1019 unconditionally clear both _start_event and _stop_event, discarding the pending start. The backend then emits idle and waits for another start_recording that the user already sent — the requested session never opens. Consider clearing _start_event (and _stop_event) before joining transcription threads, so a start_recording arriving during the join is preserved for the next loop iteration.

🤖 Copy this AI Prompt to have your agent fix this:
In file @stt/orchestrator.py around line 1010:

A `start_recording` command that arrives while the previous session's transcription threads are still being joined is silently lost. The join loop at line 1013 can block for up to 180 seconds; during that window `_start_event` is already set from the current session, so the stdin reader's `_start_event.set()` records no new state. Immediately after the join, lines 1018–1019 unconditionally clear both `_start_event` and `_stop_event`, discarding the pending start. The backend then emits `idle` and waits for another `start_recording` that the user already sent — the requested session never opens. Consider clearing `_start_event` (and `_stop_event`) *before* joining transcription threads, so a `start_recording` arriving during the join is preserved for the next loop iteration.

const svgRef = useRef<SVGSVGElement>(null);
const waveformRef = useRef<ReturnType<typeof createWaveform> | null>(null);
const rendererInitRef = useRef(false);
const scaleSpring = createSpring(SPRING_SNAPPY);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium overlay/OverlayView.tsx:44

The pill's scale animation never updates the DOM. scaleSpring is created with createSpring during render, so every setState call produces a new spring instance. The mount-only useEffect registers the onUpdate callback that writes transform to #overlay-pill on the initial spring, but the [state] effect calls setTarget on whatever spring the latest render created — an instance that has no onUpdate callback registered. As a result, the spring animates internally but el.style.transform is never written. Wrap scaleSpring in useRef (or useMemo) so the same instance persists across renders and the callback stays attached.

Suggested change
const scaleSpring = createSpring(SPRING_SNAPPY);
const scaleSpringRef = useRef<ReturnType<typeof createSpring> | null>(null);
if (!scaleSpringRef.current) {
scaleSpringRef.current = createSpring(SPRING_SNAPPY);
}
const scaleSpring = scaleSpringRef.current;
🤖 Copy this AI Prompt to have your agent fix this:
In file @stt-ui/src/overlay/OverlayView.tsx around line 44:

The pill's scale animation never updates the DOM. `scaleSpring` is created with `createSpring` during render, so every `setState` call produces a new spring instance. The mount-only `useEffect` registers the `onUpdate` callback that writes `transform` to `#overlay-pill` on the initial spring, but the `[state]` effect calls `setTarget` on whatever spring the latest render created — an instance that has no `onUpdate` callback registered. As a result, the spring animates internally but `el.style.transform` is never written. Wrap `scaleSpring` in `useRef` (or `useMemo`) so the same instance persists across renders and the callback stays attached.

Comment thread stt/orchestrator.py
# The ASR model is the dominant memory user; we free it a short grace period
# after the user stops dictating, then re-warm on the next session.
_idle_release_timer: threading.Timer | None = None
_IDLE_RELEASE_GRACE_SEC = float(os.environ.get("STT_IDLE_RELEASE_SEC", "20"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium stt/orchestrator.py:86

Parsing STT_IDLE_RELEASE_SEC with an unguarded float(...) at import time crashes module import when the env var is malformed (e.g. STT_IDLE_RELEASE_SEC=20s or empty), preventing the entire STT service from starting. Consider wrapping the parse in a try/except that falls back to the default on ValueError.

-_IDLE_RELEASE_GRACE_SEC = float(os.environ.get("STT_IDLE_RELEASE_SEC", "20"))
+_IDLE_RELEASE_GRACE_SEC = 20.0
+try:
+    _IDLE_RELEASE_GRACE_SEC = float(os.environ.get("STT_IDLE_RELEASE_SEC", "20"))
+except ValueError:
+    pass
🤖 Copy this AI Prompt to have your agent fix this:
In file @stt/orchestrator.py around line 86:

Parsing `STT_IDLE_RELEASE_SEC` with an unguarded `float(...)` at import time crashes module import when the env var is malformed (e.g. `STT_IDLE_RELEASE_SEC=20s` or empty), preventing the entire STT service from starting. Consider wrapping the parse in a `try`/`except` that falls back to the default on `ValueError`.

}, []);

// Initialize waveform and SVG renderer
useEffect(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium overlay/OverlayView.tsx:124

The waveform initialization effect never initializes waveformRef.current, so microphone capture and bar animation never start. The effect runs only once on mount with empty deps, but at that point state is "idle" so the component returns null and svgRef.current is null — the guard if (svgRef.current && !rendererInitRef.current) fails. When a later listening command renders the <svg>, the effect does not re-run, so waveformRef.current stays null and the command handler's call to waveformRef.current.start() does nothing. Consider depending the effect on state (or on svgRef.current becoming available) so initialization runs after the SVG is actually rendered.

🤖 Copy this AI Prompt to have your agent fix this:
In file @stt-ui/src/overlay/OverlayView.tsx around line 124:

The waveform initialization effect never initializes `waveformRef.current`, so microphone capture and bar animation never start. The effect runs only once on mount with empty deps, but at that point `state` is `"idle"` so the component returns `null` and `svgRef.current` is `null` — the guard `if (svgRef.current && !rendererInitRef.current)` fails. When a later `listening` command renders the `<svg>`, the effect does not re-run, so `waveformRef.current` stays `null` and the command handler's call to `waveformRef.current.start()` does nothing. Consider depending the effect on `state` (or on `svgRef.current` becoming available) so initialization runs after the SVG is actually rendered.

Comment thread stt/orchestrator.py
# Acquire the ASR semaphore (warmup holds it while loading the model).
# Use a blocking acquire with timeout so we wait for warmup instead of
# silently dropping the utterance.
if not _asr_semaphore.acquire(blocking=True, timeout=120):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High stt/orchestrator.py:1256

_asr_semaphore.acquire(blocking=True, timeout=120) queues every overlapping utterance, not just ones waiting for startup warm-up. The semaphore is held for the full decode, so when ASR is slower than real time (or speech is sustained), each VAD endpoint spawns a new thread that blocks up to 120s. Transcripts arrive stale and a growing backlog of blocked threads prevents prompt session shutdown. The old blocking=False call intentionally dropped overlap via asr_busy; the timeout-based acquire cannot distinguish warm-up from an in-progress decode. Consider gating the blocking wait on an explicit warm-up flag and dropping overlap otherwise.

🤖 Copy this AI Prompt to have your agent fix this:
In file @stt/orchestrator.py around line 1256:

`_asr_semaphore.acquire(blocking=True, timeout=120)` queues every overlapping utterance, not just ones waiting for startup warm-up. The semaphore is held for the full decode, so when ASR is slower than real time (or speech is sustained), each VAD endpoint spawns a new thread that blocks up to 120s. Transcripts arrive stale and a growing backlog of blocked threads prevents prompt session shutdown. The old `blocking=False` call intentionally dropped overlap via `asr_busy`; the timeout-based acquire cannot distinguish warm-up from an in-progress decode. Consider gating the blocking wait on an explicit warm-up flag and dropping overlap otherwise.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
stt-ui/src/App.tsx (1)

1077-1101: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Accumulate every utterance before committing.

VAD creates a new utterance_id for each endpoint, but every event overwrites pttTextRef. A PTT session containing pauses therefore inserts only whichever utterance produced the latest event.

Track text by utterance ID and join the ordered finalized segments at commit time.

Also applies to: 1292-1294

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt-ui/src/App.tsx` around lines 1077 - 1101, Update the PTT text handling in
the event-processing logic around the transcribing, processed, and llm_partial
branches to accumulate text per utterance ID instead of overwriting
pttTextRef.current on every event. Preserve segment order, update each segment
as events arrive, and join all finalized utterance segments when the PTT session
commits so pauses do not discard earlier speech.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@stt-ui/src-tauri/capabilities/default.json`:
- Line 5: Remove overlay from the windows list in the default capability and
create a dedicated overlay capability configuration for the OverlayView
component, granting only the required window and event permissions used by its
Tauri listen/emitTo calls. Preserve the existing permissions for main and widget
windows and ensure the overlay remains associated with its dedicated capability.

In `@stt-ui/src/App.tsx`:
- Around line 1215-1227: In both overlay cleanup sites in stt-ui/src/App.tsx
(anchor lines 1215-1227 and sibling lines 1341-1353), initialize the
acknowledgement promise and assign overlayIdleResolveRef before emitting the
"idle" command; retain the existing timeout fallback and cleanup behavior so
early overlay:idle_ready events resolve immediately.
- Around line 1145-1179: Update the PTT start flow around the listening-state
transition to create or advance the session ID, set the state, and invoke
runtimeRef.current.start() immediately without any await between them. Capture
that session ID and guard the subsequent overlay commands and foreground-HWND
updates so stale asynchronous work cannot affect a newer session; preserve the
existing setup and cleanup behavior.
- Around line 1296-1327: Update the PTT commit flow surrounding the insertion
logic so success is emitted only after text and hwnd are present, insert_text
completes without throwing, and returns ok === true. Track unsuccessful outcomes
from the ok === false, exception, missing-text, and missing-HWND branches, then
skip setPtt("success"), the success overlay event, and “Done” confirmation for
those cases while showing the existing error/no-result state and completing
normal cleanup.
- Around line 869-875: Update the catch handler for the engine spawn promise to
immediately return when spawnedRef.current !== api, before constructing the
message or calling setToast/addError. Preserve the existing cleanup for the
current API instance so stale spawn failures produce no user-facing errors.

In `@stt-ui/src/overlay/OverlayView.tsx`:
- Around line 123-162: Update the waveform initialization useEffect in
OverlayView so it depends on state and runs when the conditional SVG is mounted
in the "listening" state, rather than only on initial render. After creating the
waveform and registering onBars, start it for that state; when listening ends,
stop and dispose the existing waveform and reset rendererInitRef as needed so
future listening cycles reinitialize correctly.
- Line 44: Keep the spring instance in OverlayView stable across renders by
creating it through the component’s persistent state or memoization mechanism,
rather than invoking createSpring(SPRING_SNAPPY) on every render. Ensure the
mount effect’s onUpdate registration and subsequent setTarget calls both use
that same spring instance.
- Around line 106-120: Cancel asynchronous listener registration during cleanup
in OverlayView.tsx lines 106-120 and App.tsx lines 1397-1412. Add a cancellation
guard checked before retaining the listener, and immediately invoke the returned
unlisten function if registration completes after cleanup; preserve retry
behavior and existing cleanup for successfully registered listeners.

In `@stt-ui/src/overlay/spring.ts`:
- Around line 88-93: Update setValue() in the spring controller to cancel or
invalidate any active RAF animation before assigning the new value. Ensure the
current animation cannot continue updating state or invoke onUpdate after
setValue() resets the value and velocity.

In `@stt-ui/src/overlay/waveform.ts`:
- Around line 96-148: Update the waveform start/stop lifecycle around start()
and stop() to use a generation or cancellation token that invalidates pending
getUserMedia attempts. Acquire the AudioContext, analyser, stream, and related
resources in local variables, verify the start attempt is still current after
each await, and immediately stop tracks and close the context when invalidated;
only then assign shared state, set active, and schedule the RAF. Increment the
token in stop() before cleanup so a later getUserMedia resolution cannot restart
the waveform or leave the microphone open.

In `@stt/orchestrator.py`:
- Around line 739-750: The session-start warm-up logic currently relies on
_idle_release_timer, which is cleared by _cancel_idle_release and therefore
cannot indicate whether the backend was released. Add and maintain an explicit
_backend_released state, set it when idle release completes and clear it when
the backend is retained or successfully re-warmed; update the condition around
_warmup_target to start warm-up only when that state is true, preventing
duplicate concurrent warm-ups.
- Around line 111-124: Serialize ASR lifecycle operations so release, warm-up,
and model use cannot overlap. In stt/orchestrator.py lines 111-124, protect
timer cancellation and the _release callback with a shared lifecycle lock or
generation token, ensuring a canceled or stale timer cannot release backend
state after a new session begins. In stt/transcription.py lines 39-58, use the
same synchronization around cache clearing and allocator cleanup; update the
relevant release, warm-up, and model-use paths while preserving existing
behavior.
- Around line 949-958: Remove the logging-time call to
detector._compute_speech_score(chunk) in the VAD diagnostics block, since it
mutates spectral state before detector.update() processes the chunk. If the
score must remain logged, capture and expose it from detector.update() and log
that already-computed value without recomputing the chunk.

---

Outside diff comments:
In `@stt-ui/src/App.tsx`:
- Around line 1077-1101: Update the PTT text handling in the event-processing
logic around the transcribing, processed, and llm_partial branches to accumulate
text per utterance ID instead of overwriting pttTextRef.current on every event.
Preserve segment order, update each segment as events arrive, and join all
finalized utterance segments when the PTT session commits so pauses do not
discard earlier speech.
🪄 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: CHILL

Plan: Pro

Run ID: 045f2534-364a-427a-be9a-5f150732a5c3

📥 Commits

Reviewing files that changed from the base of the PR and between 5bdeda2 and 3bad50e.

⛔ Files ignored due to path filters (1)
  • stt-ui/src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • .gitignore
  • stt-ui/src-tauri/Cargo.toml
  • stt-ui/src-tauri/capabilities/default.json
  • stt-ui/src-tauri/src/commands.rs
  • stt-ui/src-tauri/src/lib.rs
  • stt-ui/src-tauri/tauri.conf.json
  • stt-ui/src/App.tsx
  • stt-ui/src/api-tauri.ts
  • stt-ui/src/components/PttOverlay.tsx
  • stt-ui/src/components/WidgetView.tsx
  • stt-ui/src/main.tsx
  • stt-ui/src/overlay/OverlayView.tsx
  • stt-ui/src/overlay/spring.ts
  • stt-ui/src/overlay/waveform.ts
  • stt/orchestrator.py
  • stt/transcription.py
  • stt/vad.py
💤 Files with no reviewable changes (1)
  • stt-ui/src/components/PttOverlay.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • stt-ui/src/api-tauri.ts

"identifier": "default",
"description": "Capability for the main window",
"windows": ["main", "widget"],
"windows": ["main", "widget", "overlay"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify which privileged APIs the overlay actually uses.
rg -n 'invoke|listen|emit|clipboard|shell|globalShortcut|updater' \
  stt-ui/src/overlay/OverlayView.tsx stt-ui/src/main.tsx

# Inspect existing capability assignments.
rg -n '"windows"|shell:|clipboard-manager:|global-shortcut:|updater:' \
  stt-ui/src-tauri/capabilities

Repository: IntegerAlex/floure-core

Length of output: 2747


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map capability files and overlay references.
printf '\n## capabilities files\n'
git ls-files 'stt-ui/src-tauri/capabilities/*' | sort

printf '\n## overlay references in source\n'
rg -n 'overlay:|OverlayView|window.*overlay|label.*overlay|windows": \["main", "widget", "overlay"\]' stt-ui/src-tauri stt-ui/src/overlay

printf '\n## default capability context\n'
sed -n '1,120p' stt-ui/src-tauri/capabilities/default.json

Repository: IntegerAlex/floure-core

Length of output: 2833


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,140p' stt-ui/src-tauri/src/commands.rs

Repository: IntegerAlex/floure-core

Length of output: 4477


Split the overlay into a least-privilege capability. stt-ui/src-tauri/capabilities/default.json currently gives overlay shell, clipboard, global-shortcut, and updater permissions even though stt-ui/src/overlay/OverlayView.tsx only uses Tauri events (listen/emitTo). Move it to a dedicated overlay capability with just the window/event permissions it needs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt-ui/src-tauri/capabilities/default.json` at line 5, Remove overlay from
the windows list in the default capability and create a dedicated overlay
capability configuration for the OverlayView component, granting only the
required window and event permissions used by its Tauri listen/emitTo calls.
Preserve the existing permissions for main and widget windows and ensure the
overlay remains associated with its dedicated capability.

Comment thread stt-ui/src/App.tsx
Comment on lines 869 to +875
}).catch((err) => {
const msg = err instanceof Error ? err.message : "Failed to start engine";
setToast(msg);
addError("connection", msg, true, "Check if stt-engine is installed");
runtimeRef.current = null;
if (spawnedRef.current === api) {
runtimeRef.current = null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file='stt-ui/src/App.tsx'

echo '=== file size ==='
wc -l "$file"

echo '=== outline ==='
ast-grep outline "$file" --view expanded | sed -n '1,220p'

echo '=== around reported lines ==='
sed -n '820,900p' "$file"

Repository: IntegerAlex/floure-core

Length of output: 5306


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '840,890p' stt-ui/src/App.tsx

Repository: IntegerAlex/floure-core

Length of output: 2156


Ignore failures from stale spawn attempts. Add the same spawnedRef.current !== api early return in the catch block so an outdated spawn failure doesn’t show a toast/error after a newer engine has already taken over.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt-ui/src/App.tsx` around lines 869 - 875, Update the catch handler for the
engine spawn promise to immediately return when spawnedRef.current !== api,
before constructing the message or calling setToast/addError. Preserve the
existing cleanup for the current API instance so stale spawn failures produce no
user-facing errors.

Comment thread stt-ui/src/App.tsx
Comment on lines +1145 to 1179
// Idle → Listening
setPtt("listening");

// Tell overlay to show in listening mode
try {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo("overlay", "overlay:command", "listening" as const);
console.log("[Overlay] Command: listening");
} catch { /* not in Tauri */ }

// Capture foreground window
try {
const { invoke } = await import("@tauri-apps/api/core");
const hwnd = await invoke<number>("get_foreground_hwnd");
pttHwndRef.current = hwnd;
console.log(`[PTT] Captured HWND: ${hwnd} (source=${source})`);
} catch {
pttHwndRef.current = null;
}

pttTextRef.current = "";
console.log(`[PTT] Start requested — source=${source}`);
runtimeRef.current.start(); // Sends start_recording to backend
sessionCounter.current++;
nextLocalId.current = 1;
setLines([]);
playStartBeep();

// Show overlay (positioned above taskbar)
try {
const { invoke } = await import("@tauri-apps/api/core");
await invoke("show_overlay");
console.log("[Overlay] Shown");
} catch { /* not in Tauri */ }

// Tell backend to begin capturing
runtimeRef.current.start();
setConnected(true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Send start_recording before yielding control.

The state becomes "listening" before several awaits, but Line 1178 sends the backend start afterward. A quick release can consequently send stop_recording first; the backend then clears that stale stop when the delayed start arrives and records without another matching stop.

Ensure there is no asynchronous gap between entering "listening" and sending start_recording, and guard the remaining overlay/HWND work with the session ID.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt-ui/src/App.tsx` around lines 1145 - 1179, Update the PTT start flow
around the listening-state transition to create or advance the session ID, set
the state, and invoke runtimeRef.current.start() immediately without any await
between them. Capture that session ID and guard the subsequent overlay commands
and foreground-HWND updates so stale asynchronous work cannot affect a newer
session; preserve the existing setup and cleanup behavior.

Comment thread stt-ui/src/App.tsx
Comment on lines +1215 to +1227
// Tell overlay to clean up, wait for acknowledgement, then hide
overlayCleaningRef.current = true;
try {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo("overlay", "overlay:command", "idle" as const);
} catch { /* not in Tauri */ }
await new Promise<void>((resolve) => {
overlayIdleResolveRef.current = resolve;
hideOverlayTimeoutRef.current = setTimeout(() => {
console.warn("[Overlay] idle_ready timeout — hiding anyway");
resolve();
}, 2000);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Install the idle acknowledgement resolver before emitting "idle".

The overlay can return overlay:idle_ready before overlayIdleResolveRef is assigned, losing the acknowledgement and forcing the two-second fallback.

  • stt-ui/src/App.tsx#L1215-L1227: create the acknowledgement promise before the no-commit "idle" emission.
  • stt-ui/src/App.tsx#L1341-L1353: do the same during success cleanup.
📍 Affects 1 file
  • stt-ui/src/App.tsx#L1215-L1227 (this comment)
  • stt-ui/src/App.tsx#L1341-L1353
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt-ui/src/App.tsx` around lines 1215 - 1227, In both overlay cleanup sites
in stt-ui/src/App.tsx (anchor lines 1215-1227 and sibling lines 1341-1353),
initialize the acknowledgement promise and assign overlayIdleResolveRef before
emitting the "idle" command; retain the existing timeout fallback and cleanup
behavior so early overlay:idle_ready events resolve immediately.

Comment thread stt-ui/src/App.tsx
Comment on lines +1296 to +1327
if (text && hwnd) {
// Processing → Inserting
setPtt("inserting");
console.log("[PTT] Inserting text");
// Tell overlay to show inserting state
try {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo("overlay", "overlay:command", "inserting" as const);
} catch { /* not in Tauri */ }
try {
const { invoke } = await import("@tauri-apps/api/core");
const ok = await invoke<boolean>("insert_text", { text, restoreHwnd: hwnd });
console.log(`[PTT] Inserted text to HWND ${hwnd} (source=${source}):`, ok);
if (!ok) setToast("Failed to insert text");
} catch (e) {
console.error("[PTT] insert_text failed:", e);
setToast("Failed to insert text");
}
} else {
console.log(`[PTT] Nothing to commit (source=${source})`);
}

// Inserting → Success (brief confirmation)
setPtt("success");
setStatus("idle");
console.log("[PTT] Success — showing confirmation");

// Tell overlay to show success state
try {
const { emitTo } = await import("@tauri-apps/api/event");
await emitTo("overlay", "overlay:command", "success" as const);
} catch { /* not in Tauri */ }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not report success when insertion fails or nothing is committed.

ok === false, invocation errors, missing text, and missing HWND all continue into the "success" state and show “Done”. Only emit the success state after a confirmed insertion; otherwise show an error/no-result state and perform normal cleanup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt-ui/src/App.tsx` around lines 1296 - 1327, Update the PTT commit flow
surrounding the insertion logic so success is emitted only after text and hwnd
are present, insert_text completes without throwing, and returns ok === true.
Track unsuccessful outcomes from the ok === false, exception, missing-text, and
missing-HWND branches, then skip setPtt("success"), the success overlay event,
and “Done” confirmation for those cases while showing the existing
error/no-result state and completing normal cleanup.

Comment on lines +88 to +93
/** Set the current value directly (no animation). */
setValue(value: number) {
state.value = value;
state.velocity = 0;
onUpdate?.(value);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make setValue() stop the current animation.

An active RAF continues toward the old target after this “no animation” update, which can undo the overlay reset during a quick PTT session.

Proposed fix
     setValue(value: number) {
+      if (rafId !== null) cancelAnimationFrame(rafId);
+      rafId = null;
+      lastTime = null;
       state.value = value;
+      state.target = value;
       state.velocity = 0;
       onUpdate?.(value);
     },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** Set the current value directly (no animation). */
setValue(value: number) {
state.value = value;
state.velocity = 0;
onUpdate?.(value);
},
/** Set the current value directly (no animation). */
setValue(value: number) {
if (rafId !== null) cancelAnimationFrame(rafId);
rafId = null;
lastTime = null;
state.value = value;
state.target = value;
state.velocity = 0;
onUpdate?.(value);
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt-ui/src/overlay/spring.ts` around lines 88 - 93, Update setValue() in the
spring controller to cancel or invalidate any active RAF animation before
assigning the new value. Ensure the current animation cannot continue updating
state or invoke onUpdate after setValue() resets the value and velocity.

Comment on lines +96 to +148
async start() {
if (state.active) return;

try {
// Create AudioContext
state.audioContext = new AudioContext();
state.analyser = state.audioContext.createAnalyser();
state.analyser.fftSize = fftSize;
state.analyser.minDecibels = minDecibels;
state.analyser.maxDecibels = maxDecibels;
state.analyser.smoothingTimeConstant = smoothingTimeConstant;

// Get microphone stream
state.stream = await navigator.mediaDevices.getUserMedia({ audio: true });

// Connect: mic → analyser
const source = state.audioContext.createMediaStreamSource(state.stream);
source.connect(state.analyser);

// Allocate buffers
state.dataArray = new Uint8Array(state.analyser.frequencyBinCount);
state.smoothed = new Float32Array(barCount);

state.active = true;
state.rafId = requestAnimationFrame(tick);
} catch (err) {
console.error("[waveform] Failed to start:", err);
// Fallback: start fake waveform
state.active = true;
state.smoothed = new Float32Array(barCount);
state.rafId = requestAnimationFrame(tick);
}
},

/** Stop capturing audio. */
stop() {
state.active = false;
if (state.rafId !== null) {
cancelAnimationFrame(state.rafId);
state.rafId = null;
}
if (state.stream) {
state.stream.getTracks().forEach((t) => t.stop());
state.stream = null;
}
if (state.audioContext) {
state.audioContext.close().catch(() => {});
state.audioContext = null;
}
state.analyser = null;
state.dataArray = null;
state.smoothed = null;
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Cancel pending microphone acquisition when stopping.

If stop() runs while getUserMedia() is pending, it sees no stream. A later resolution still sets active, starts the RAF, and can leave the microphone open after the overlay is hidden.

Use a generation/cancellation token, acquire resources into local variables, and immediately close them when the start attempt has been invalidated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt-ui/src/overlay/waveform.ts` around lines 96 - 148, Update the waveform
start/stop lifecycle around start() and stop() to use a generation or
cancellation token that invalidates pending getUserMedia attempts. Acquire the
AudioContext, analyser, stream, and related resources in local variables, verify
the start attempt is still current after each await, and immediately stop tracks
and close the context when invalidated; only then assign shared state, set
active, and schedule the RAF. Increment the token in stop() before cleanup so a
later getUserMedia resolution cannot restart the waveform or leave the
microphone open.

Comment thread stt/orchestrator.py
Comment on lines +111 to +124
def _release():
global _idle_release_timer
_idle_release_timer = None
try:
from stt.transcription import release_backend
release_backend()
logger.info("idle: released ASR model to free memory")
except Exception:
pass

try:
_idle_release_timer = threading.Timer(_IDLE_RELEASE_GRACE_SEC, _release)
_idle_release_timer.daemon = True
_idle_release_timer.start()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Serialize ASR release, warm-up, and model use. Timer cancellation can race with cache clearing, allowing a new session to start while backend state is being released.

  • stt/orchestrator.py#L111-L124: make cancellation/release race-safe using a lifecycle lock or generation token.
  • stt/transcription.py#L39-L58: use that lifecycle synchronization around cache and allocator cleanup.
📍 Affects 2 files
  • stt/orchestrator.py#L111-L124 (this comment)
  • stt/transcription.py#L39-L58
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt/orchestrator.py` around lines 111 - 124, Serialize ASR lifecycle
operations so release, warm-up, and model use cannot overlap. In
stt/orchestrator.py lines 111-124, protect timer cancellation and the _release
callback with a shared lifecycle lock or generation token, ensuring a canceled
or stale timer cannot release backend state after a new session begins. In
stt/transcription.py lines 39-58, use the same synchronization around cache
clearing and allocator cleanup; update the relevant release, warm-up, and
model-use paths while preserving existing behavior.

Comment thread stt/orchestrator.py
Comment on lines +739 to +750
# A new session is starting — keep the (possibly still warm) model.
_cancel_idle_release()

# If the model was released during the previous idle period, re-warm it
# in the background. The 1.5s calibration window covers most of the
# load time; if it isn't ready, the first transcription reloads cold.
try:
if _idle_release_timer is None:
_warmup_thread = threading.Thread(
target=_warmup_target, args=(config.transcription,), daemon=True
)
_warmup_thread.start()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Track backend release state separately from the timer handle.

Line 740 always makes _idle_release_timer None, so Line 746 starts another warm-up on every PTT session—including concurrently with the initial warm-up. Use an explicit _backend_released state instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt/orchestrator.py` around lines 739 - 750, The session-start warm-up logic
currently relies on _idle_release_timer, which is cleared by
_cancel_idle_release and therefore cannot indicate whether the backend was
released. Add and maintain an explicit _backend_released state, set it when idle
release completes and clear it when the backend is retained or successfully
re-warmed; update the condition around _warmup_target to start warm-up only when
that state is true, preventing duplicate concurrent warm-ups.

Comment thread stt/orchestrator.py
Comment on lines +949 to +958
if chunk_count % 8 == 0:
st, et = detector.thresholds()
noise = detector.noise_floor
snr_db = 20 * np.log10(rms / max(noise, 1e-10)) if noise > 0 else 0
state = detector.vad_state.name
_debug(config, f"rms={rms:.6f} noise={noise:.4f} snr={snr_db:.1f}dB state={state}")
in_speech = detector._in_speech
logger.info(f"VAD: rms={rms:.6f} noise={noise:.4f} snr={snr_db:.1f}dB state={state} in_speech={in_speech} onset_th={st:.4f}")
if config.vad.use_spectral_vad:
score = detector._compute_speech_score(chunk)
_debug(config, f" spectral: score={score:.4f}")
logger.info(f" spectral: score={score:.4f}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not mutate spectral VAD state while logging.

_compute_speech_score() updates _prev_spectrum; detector.update() then recomputes the same chunk, making spectral flux zero on every eighth frame and changing the actual VAD decision.

Minimal fix
-                    if config.vad.use_spectral_vad:
-                        score = detector._compute_speech_score(chunk)
-                        logger.info(f"  spectral: score={score:.4f}")

Log a score captured inside update() instead if needed.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if chunk_count % 8 == 0:
st, et = detector.thresholds()
noise = detector.noise_floor
snr_db = 20 * np.log10(rms / max(noise, 1e-10)) if noise > 0 else 0
state = detector.vad_state.name
_debug(config, f"rms={rms:.6f} noise={noise:.4f} snr={snr_db:.1f}dB state={state}")
in_speech = detector._in_speech
logger.info(f"VAD: rms={rms:.6f} noise={noise:.4f} snr={snr_db:.1f}dB state={state} in_speech={in_speech} onset_th={st:.4f}")
if config.vad.use_spectral_vad:
score = detector._compute_speech_score(chunk)
_debug(config, f" spectral: score={score:.4f}")
logger.info(f" spectral: score={score:.4f}")
if chunk_count % 8 == 0:
st, et = detector.thresholds()
noise = detector.noise_floor
snr_db = 20 * np.log10(rms / max(noise, 1e-10)) if noise > 0 else 0
state = detector.vad_state.name
in_speech = detector._in_speech
logger.info(f"VAD: rms={rms:.6f} noise={noise:.4f} snr={snr_db:.1f}dB state={state} in_speech={in_speech} onset_th={st:.4f}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stt/orchestrator.py` around lines 949 - 958, Remove the logging-time call to
detector._compute_speech_score(chunk) in the VAD diagnostics block, since it
mutates spectral state before detector.update() processes the chunk. If the
score must remain logged, capture and expose it from detector.update() and log
that already-computed value without recomputing the chunk.

Long sentences split into multiple utterances by the VAD were only
committing the last segment because pttTextRef was overwritten on
each new utterance. Changed to a Map<utterance_id, text> that
accumulates all segments and joins them at commit time.

Also fixed processed/llm_partial line-matching: they used
event.utterance_id directly as the line id, but raw events create
lines with sessionCounter*100000+utterance_id, so processed events
never matched and feed lines were never marked done.
Comment thread stt-ui/src/App.tsx
pttIdleResolveRef.current = null;

// Join all utterances' text in order for the PTT commit
const text = Array.from(pttPartsRef.current.values()).join(" ").trim();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium src/App.tsx:1298

The commit text is joined from pttPartsRef.current.values(), which yields entries in Map insertion order — i.e. the order raw/processed events arrive — not in utterance_id order. Because the backend dispatches each utterance on a separate transcription thread, a later utterance can emit its first event before an earlier one and be inserted into the map first, so multi-utterance dictation can be pasted with phrases in the wrong order. Sort the map entries by numeric utterance_id before joining.

-    const text = Array.from(pttPartsRef.current.values()).join(" ").trim();
+    const text = Array.from(pttPartsRef.current.entries())
+      .sort(([a], [b]) => a - b)
+      .map(([, v]) => v)
+      .join(" ").trim();
🤖 Copy this AI Prompt to have your agent fix this:
In file @stt-ui/src/App.tsx around line 1298:

The commit text is joined from `pttPartsRef.current.values()`, which yields entries in `Map` insertion order — i.e. the order `raw`/`processed` events arrive — not in `utterance_id` order. Because the backend dispatches each utterance on a separate transcription thread, a later utterance can emit its first event before an earlier one and be inserted into the map first, so multi-utterance dictation can be pasted with phrases in the wrong order. Sort the map entries by numeric `utterance_id` before joining.

Comment thread stt-ui/src/App.tsx
// reads stale text before transcription completes.
// Backend emits idle immediately after recording loop ends (no thread join).
// Transcription runs in background and emits events through the pipeline.
const IDLE_TIMEOUT_MS = 20000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium src/App.tsx:1266

stopAndCommit uses IDLE_TIMEOUT_MS = 20000 (20s), but the backend waits up to 180s for transcription threads to finish before emitting the idle state. When transcription takes longer than 20s, the frontend's idle timeout fires first, commits whatever partial text is in pttPartsRef, reports success, and returns to idle — while the backend is still processing. The committed text is empty or incomplete, and the user sees a false success indication. Either raise IDLE_TIMEOUT_MS above the backend's actual bound (180s), or on timeout, abort the commit rather than inserting stale data.

🤖 Copy this AI Prompt to have your agent fix this:
In file @stt-ui/src/App.tsx around line 1266:

`stopAndCommit` uses `IDLE_TIMEOUT_MS = 20000` (20s), but the backend waits up to 180s for transcription threads to finish before emitting the `idle` state. When transcription takes longer than 20s, the frontend's idle timeout fires first, commits whatever partial text is in `pttPartsRef`, reports success, and returns to idle — while the backend is still processing. The committed text is empty or incomplete, and the user sees a false success indication. Either raise `IDLE_TIMEOUT_MS` above the backend's actual bound (180s), or on timeout, abort the commit rather than inserting stale data.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant