Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions crates/coldvox-gui/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,77 @@
emit_and_resize(&app, &window, &snapshot, "settings-placeholder")
}

/// Feed a live partial transcript update from the STT pipeline.
/// The overlay stays in Listening state and displays the provisional text.
#[tauri::command]
fn update_partial_transcript(
text: String,
runtime: State<'_, OverlayRuntime>,
window: WebviewWindow,

Check warning on line 144 in crates/coldvox-gui/src-tauri/src/lib.rs

View workflow job for this annotation

GitHub Actions / Unit Tests & Golden Master (Hosted) (stable)

Diff in /home/runner/work/ColdVox/ColdVox/crates/coldvox-gui/src-tauri/src/lib.rs
app: AppHandle,
) -> CommandResult {
let snapshot =
runtime.with_model(|model| model.apply_partial_transcript(&text, None));
emit_and_resize(
&app,
&window,
&snapshot,
"stt-partial",
)
}

/// Feed a final transcript from the STT pipeline.
/// Moves partial to final and transitions to Ready.
#[tauri::command]
fn update_final_transcript(
text: String,
runtime: State<'_, OverlayRuntime>,
window: WebviewWindow,

Check warning on line 163 in crates/coldvox-gui/src-tauri/src/lib.rs

View workflow job for this annotation

GitHub Actions / Unit Tests & Golden Master (Hosted) (stable)

Diff in /home/runner/work/ColdVox/ColdVox/crates/coldvox-gui/src-tauri/src/lib.rs
app: AppHandle,
) -> CommandResult {
let snapshot =
runtime.with_model(|model| model.apply_final_transcript(&text, None));
emit_and_resize(
&app,
&window,
&snapshot,
"stt-final",
)
}

/// Transition the overlay to Processing state (STT is finalizing the utterance).
#[tauri::command]
fn set_overlay_processing(
runtime: State<'_, OverlayRuntime>,
window: WebviewWindow,
app: AppHandle,
) -> CommandResult {
let snapshot = runtime.with_model(|model| model.apply_processing_state(None));
emit_and_resize(&app, &window, &snapshot, "stt-processing")
}

/// Transition the overlay to Listening state (new speech segment started).
#[tauri::command]
fn set_overlay_listening(
runtime: State<'_, OverlayRuntime>,
window: WebviewWindow,
app: AppHandle,
) -> CommandResult {
let snapshot = runtime.with_model(|model| model.apply_listening_state(None));
emit_and_resize(&app, &window, &snapshot, "stt-listening")
}

/// Stop real capture and return to Idle, clearing transcript state.
#[tauri::command]
fn stop_overlay_capture(
runtime: State<'_, OverlayRuntime>,
window: WebviewWindow,
app: AppHandle,
) -> CommandResult {
let snapshot = runtime.with_model(|model| model.stop_capture());
emit_and_resize(&app, &window, &snapshot, "capture-stopped")
}

fn spawn_demo_driver(shared: Arc<Mutex<OverlayModel>>, app: AppHandle, token: u64) {
thread::spawn(move || {
for step in demo_script() {
Expand Down Expand Up @@ -210,6 +281,11 @@
stop_demo_driver,
clear_overlay_transcript,
open_settings_placeholder,
update_partial_transcript,
update_final_transcript,
set_overlay_processing,
set_overlay_listening,
stop_overlay_capture,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Expand Down
122 changes: 122 additions & 0 deletions crates/coldvox-gui/src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,88 @@ impl OverlayModel {
self.snapshot()
}

/// Apply a live partial transcript update from the STT pipeline.
/// Keeps the shell in Listening state and updates the provisional text.
pub fn apply_partial_transcript(
&mut self,
text: &str,
status_detail: Option<&str>,
) -> OverlaySnapshot {
self.snapshot.partial_transcript = text.to_string();
self.snapshot.status = OverlayStatus::Listening;
if let Some(detail) = status_detail {
self.snapshot.status_detail = detail.to_string();
} else {
self.snapshot.status_detail =
"Streaming partial words from the STT pipeline.".to_string();
}
self.snapshot.error_message = None;
self.snapshot.expanded = true;
self.snapshot()
}

/// Promote the current partial transcript to final and transition to Ready.
/// Called when the STT pipeline commits an utterance.
pub fn apply_final_transcript(
&mut self,
text: &str,
status_detail: Option<&str>,
) -> OverlaySnapshot {
self.snapshot.partial_transcript.clear();
self.snapshot.final_transcript = text.to_string();
self.snapshot.status = OverlayStatus::Ready;
if let Some(detail) = status_detail {
self.snapshot.status_detail = detail.to_string();
} else {
self.snapshot.status_detail =
"Final transcript staged. Real injection wiring lands in a later tranche."
.to_string();
}
self.snapshot.error_message = None;
self.snapshot.expanded = true;
self.snapshot()
}

/// Transition to Processing state (STT pipeline is finalizing the utterance).
pub fn apply_processing_state(&mut self, status_detail: Option<&str>) -> OverlaySnapshot {
self.snapshot.status = OverlayStatus::Processing;
if let Some(detail) = status_detail {
self.snapshot.status_detail = detail.to_string();
} else {
self.snapshot.status_detail =
"Processing the utterance into a committed transcript.".to_string();
}
Comment on lines +162 to +169
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

apply_processing_state sets status/status_detail but doesn’t clear error_message or reset paused. If the model was previously in Error or paused demo state, the UI can incorrectly keep showing a stale error/paused flag after transitioning to Processing. Recommend clearing error_message and forcing paused = false in this transition (similar to other state updates).

Suggested change
self.snapshot.status = OverlayStatus::Processing;
if let Some(detail) = status_detail {
self.snapshot.status_detail = detail.to_string();
} else {
self.snapshot.status_detail =
"Processing the utterance into a committed transcript.".to_string();
}
self.snapshot.status = OverlayStatus::Processing;
self.snapshot.paused = false;
if let Some(detail) = status_detail {
self.snapshot.status_detail = detail.to_string();
} else {
self.snapshot.status_detail =
"Processing the utterance into a committed transcript.".to_string();
}
self.snapshot.error_message = None;

Copilot uses AI. Check for mistakes.
self.snapshot.expanded = true;
self.snapshot()
}

/// Transition to Listening state (new utterance started).
pub fn apply_listening_state(&mut self, status_detail: Option<&str>) -> OverlaySnapshot {
self.snapshot.status = OverlayStatus::Listening;
self.snapshot.partial_transcript.clear();
self.snapshot.final_transcript.clear();
Comment on lines +176 to +178
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear error message when transitioning to listening

apply_listening_state updates status/text but leaves error_message untouched, so a previous command failure can persist as an error badge even after the overlay has successfully re-entered listening. Because the UI renders errorMessage whenever it is non-null, this produces a misleading mixed state (listening + old error) until another method explicitly clears it.

Useful? React with 👍 / 👎.

if let Some(detail) = status_detail {
self.snapshot.status_detail = detail.to_string();
} else {
self.snapshot.status_detail = "Listening for speech.".to_string();
}
self.snapshot.expanded = true;
self.snapshot()
Comment on lines +174 to +186
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

apply_listening_state clears transcripts but doesn’t clear error_message or reset paused. That can leave the overlay showing an old error/paused state even though it’s listening again. Consider setting error_message = None and paused = false when entering Listening via the real pipeline.

Copilot uses AI. Check for mistakes.
}

/// Stop capture and return to Idle, clearing all transcript state.
/// Unlike `stop()` which increments the demo token, this is used by the real pipeline.
pub fn stop_capture(&mut self) -> OverlaySnapshot {
self.snapshot.status = OverlayStatus::Idle;
self.snapshot.paused = false;
self.snapshot.partial_transcript.clear();
self.snapshot.final_transcript.clear();
self.snapshot.status_detail =
"Capture stopped. The seam is ready for the next session.".to_string();
self.snapshot.error_message = None;
self.snapshot()
}

fn reject_command(&mut self, message: &str, detail: &str) -> OverlaySnapshot {
self.demo_token += 1;
self.snapshot.expanded = true;
Expand Down Expand Up @@ -183,4 +265,44 @@ mod tests {
assert_eq!(resumed.status, OverlayStatus::Listening);
assert!(!resumed.paused);
}

#[test]
fn apply_partial_transcript_updates_text_and_keeps_listening() {
let mut model = OverlayModel::default();
model.apply_listening_state(None);

let snap1 = model.apply_partial_transcript("hello", None);
assert_eq!(snap1.partial_transcript, "hello");
assert_eq!(snap1.status, OverlayStatus::Listening);
assert!(snap1.final_transcript.is_empty());

let snap2 = model.apply_partial_transcript("hello world", None);
assert_eq!(snap2.partial_transcript, "hello world");
assert_eq!(snap2.status, OverlayStatus::Listening);
}

#[test]
fn apply_final_transcript_moves_partial_to_final_and_transitions_to_ready() {
let mut model = OverlayModel::default();
model.apply_listening_state(None);
model.apply_partial_transcript("hello world", None);

let snap = model.apply_final_transcript("hello world", None);
assert!(snap.partial_transcript.is_empty());
assert_eq!(snap.final_transcript, "hello world");
assert_eq!(snap.status, OverlayStatus::Ready);
}

#[test]
fn stop_capture_clears_all_transcript_state() {
let mut model = OverlayModel::default();
model.apply_listening_state(None);
model.apply_partial_transcript("partial text", None);

let snap = model.stop_capture();
assert_eq!(snap.status, OverlayStatus::Idle);
assert!(snap.partial_transcript.is_empty());
assert!(snap.final_transcript.is_empty());
assert!(snap.error_message.is_none());
}
Comment on lines +274 to +312
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

PR description says the five new OverlayModel pipeline methods “all have unit tests”, but the added tests only cover partial/final/stop_capture; there’s no unit test for apply_processing_state or apply_listening_state. Either add coverage for those transitions or update the PR description to match reality.

Copilot uses AI. Check for mistakes.
}
29 changes: 29 additions & 0 deletions crates/coldvox-gui/src/hooks/useOverlayShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,30 @@ const bridgeMocks = vi.hoisted(() => {
listener = null;
};
}),
// Pipeline wiring — real STT integration
updatePartialTranscript: vi.fn<(text: string) => Promise<OverlaySnapshot>>().mockResolvedValue({
...idleSnapshot,
expanded: true,
status: "listening",
partialTranscript: "partial text",
}),
updateFinalTranscript: vi.fn<(text: string) => Promise<OverlaySnapshot>>().mockResolvedValue({
...idleSnapshot,
expanded: true,
status: "ready",
finalTranscript: "final text",
}),
setOverlayProcessing: vi.fn<() => Promise<OverlaySnapshot>>().mockResolvedValue({
...idleSnapshot,
expanded: true,
status: "processing",
}),
setOverlayListening: vi.fn<() => Promise<OverlaySnapshot>>().mockResolvedValue({
...idleSnapshot,
expanded: true,
status: "listening",
}),
stopOverlayCapture: vi.fn<() => Promise<OverlaySnapshot>>().mockResolvedValue(idleSnapshot),
emit(event: OverlayEvent) {
listener?.(event);
},
Expand All @@ -59,6 +83,11 @@ vi.mock("../lib/overlayBridge", () => ({
clearOverlayTranscript: bridgeMocks.clearOverlayTranscript,
openSettingsPlaceholder: bridgeMocks.openSettingsPlaceholder,
subscribeToOverlayEvents: bridgeMocks.subscribeToOverlayEvents,
updatePartialTranscript: bridgeMocks.updatePartialTranscript,
updateFinalTranscript: bridgeMocks.updateFinalTranscript,
setOverlayProcessing: bridgeMocks.setOverlayProcessing,
setOverlayListening: bridgeMocks.setOverlayListening,
stopOverlayCapture: bridgeMocks.stopOverlayCapture,
}));

function HookHarness() {
Expand Down
56 changes: 55 additions & 1 deletion crates/coldvox-gui/src/hooks/useOverlayShell.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState, useRef } from "react";
import {
DEFAULT_SNAPSHOT,
type OverlaySnapshot,
Expand All @@ -12,6 +12,11 @@ import {
stopDemoDriver,
subscribeToOverlayEvents,
togglePauseState,
updatePartialTranscript,
updateFinalTranscript,
setOverlayProcessing,
setOverlayListening,
stopOverlayCapture,
} from "../lib/overlayBridge";

function messageFromError(error: unknown): string {
Expand Down Expand Up @@ -82,6 +87,48 @@ export function useOverlayShell() {
[],
);

// Debounce-flush partial transcript updates to avoid flooding the shell on rapid STT output.
const pendingPartialRef = useRef<string | null>(null);
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const flushPartial = useCallback(() => {
const text = pendingPartialRef.current;
if (text === null) return;
pendingPartialRef.current = null;
if (flushTimerRef.current !== null) {
clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
void runCommand(() => updatePartialTranscript(text));
}, [runCommand]);

const queuePartialTranscript = useCallback(
(text: string) => {
pendingPartialRef.current = text;
if (flushTimerRef.current !== null) {
clearTimeout(flushTimerRef.current);
}
// Flush after 80 ms of no new partials — balances latency vs. reduce repaints.
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

Minor grammar nit: “balances latency vs. reduce repaints” reads ungrammatical. Consider rewording (e.g., “balancing latency vs. reducing repaints”) to keep inline docs clear.

Suggested change
// Flush after 80 ms of no new partials — balances latency vs. reduce repaints.
// Flush after 80 ms of no new partials — balancing latency vs. reducing repaints.

Copilot uses AI. Check for mistakes.
flushTimerRef.current = setTimeout(flushPartial, 80);
},
[flushPartial],
);
Comment on lines +90 to +115
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

The new debounce/flush behavior for partial transcripts isn’t covered by tests in this file. Please add a test that verifies rapid queuePartialTranscript calls only invoke updatePartialTranscript once after ~80ms (with the latest text), and that unmount cleanup cancels a pending flush.

Copilot uses AI. Check for mistakes.

const cancelPendingPartial = useCallback(() => {
pendingPartialRef.current = null;
if (flushTimerRef.current !== null) {
clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
}, []);

// Cancel any pending partial flush when the component unmounts.
useEffect(() => {
return () => {
cancelPendingPartial();
};
}, [cancelPendingPartial]);

return {
snapshot,
setExpanded: (expanded: boolean) => runCommand(() => setOverlayExpanded(expanded)),
Expand All @@ -90,5 +137,12 @@ export function useOverlayShell() {
stopDemo: () => runCommand(stopDemoDriver),
clearTranscript: () => runCommand(clearOverlayTranscript),
openSettings: () => runCommand(openSettingsPlaceholder),
// Pipeline wiring — for real STT integration.
// queuePartialTranscript debounces rapid partials; flushPartial sends immediately.
queuePartialTranscript,
updateFinalTranscript: (text: string) => runCommand(() => updateFinalTranscript(text)),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Cancel pending partial flush before finalizing transcript

queuePartialTranscript schedules a delayed updatePartialTranscript call, but updateFinalTranscript is invoked directly without clearing that timer. If STT emits a final result within 80ms of the last partial (a common path), the queued partial is sent after the final and can overwrite the snapshot back to listening with stale partial text. This creates out-of-order state transitions and can hide the committed final transcript.

Useful? React with 👍 / 👎.

setOverlayProcessing: () => runCommand(setOverlayProcessing),
setOverlayListening: () => runCommand(setOverlayListening),
stopOverlayCapture: () => runCommand(stopOverlayCapture),
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

The return block comment says “flushPartial sends immediately”, but flushPartial isn’t returned from the hook, so callers can’t actually flush immediately. Either export flushPartial (and/or a cancel function) alongside queuePartialTranscript, or adjust the comment/API so they match.

Copilot uses AI. Check for mistakes.
};
}
25 changes: 25 additions & 0 deletions crates/coldvox-gui/src/lib/overlayBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,28 @@ export function subscribeToOverlayEvents(
onEvent(event.payload);
});
}

/// Feed a live partial transcript update from the STT pipeline.
export function updatePartialTranscript(text: string): Promise<OverlaySnapshot> {
return invoke<OverlaySnapshot>("update_partial_transcript", { text });
}

/// Feed a final transcript from the STT pipeline.
export function updateFinalTranscript(text: string): Promise<OverlaySnapshot> {
return invoke<OverlaySnapshot>("update_final_transcript", { text });
}

/// Transition the overlay to Processing state.
export function setOverlayProcessing(): Promise<OverlaySnapshot> {
return invoke<OverlaySnapshot>("set_overlay_processing");
}

/// Transition the overlay to Listening state.
export function setOverlayListening(): Promise<OverlaySnapshot> {
return invoke<OverlaySnapshot>("set_overlay_listening");
}

/// Stop real capture and return to Idle.
Comment on lines +47 to +67
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

TypeScript /// lines here are triple-slash-style comments, which are typically reserved for TS reference directives and are not used elsewhere in this codebase. Please switch these to normal // comments (or remove them) to avoid tooling/lint confusion and keep style consistent.

Suggested change
/// Feed a live partial transcript update from the STT pipeline.
export function updatePartialTranscript(text: string): Promise<OverlaySnapshot> {
return invoke<OverlaySnapshot>("update_partial_transcript", { text });
}
/// Feed a final transcript from the STT pipeline.
export function updateFinalTranscript(text: string): Promise<OverlaySnapshot> {
return invoke<OverlaySnapshot>("update_final_transcript", { text });
}
/// Transition the overlay to Processing state.
export function setOverlayProcessing(): Promise<OverlaySnapshot> {
return invoke<OverlaySnapshot>("set_overlay_processing");
}
/// Transition the overlay to Listening state.
export function setOverlayListening(): Promise<OverlaySnapshot> {
return invoke<OverlaySnapshot>("set_overlay_listening");
}
/// Stop real capture and return to Idle.
// Feed a live partial transcript update from the STT pipeline.
export function updatePartialTranscript(text: string): Promise<OverlaySnapshot> {
return invoke<OverlaySnapshot>("update_partial_transcript", { text });
}
// Feed a final transcript from the STT pipeline.
export function updateFinalTranscript(text: string): Promise<OverlaySnapshot> {
return invoke<OverlaySnapshot>("update_final_transcript", { text });
}
// Transition the overlay to Processing state.
export function setOverlayProcessing(): Promise<OverlaySnapshot> {
return invoke<OverlaySnapshot>("set_overlay_processing");
}
// Transition the overlay to Listening state.
export function setOverlayListening(): Promise<OverlaySnapshot> {
return invoke<OverlaySnapshot>("set_overlay_listening");
}
// Stop real capture and return to Idle.

Copilot uses AI. Check for mistakes.
export function stopOverlayCapture(): Promise<OverlaySnapshot> {
return invoke<OverlaySnapshot>("stop_overlay_capture");
}
Loading