Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ All notable changes to this project are documented here.
### GUI
- Replaced the old `crates/coldvox-gui` Qt/QML placeholder with a Tauri v2 + React overlay shell.
- Added a demo-only typed command/event seam between the Rust host shell and the frontend to exercise collapsed/expanded states, transcript promotion, and visible `idle`/`listening`/`processing`/`ready`/`error` feedback without real STT integration.
- Added 5 Tauri commands (`update_partial_transcript`, `update_final_transcript`, `set_overlay_processing`, `set_overlay_listening`, `stop_overlay_capture`) wiring the STT pipeline to the overlay shell, with corresponding `OverlayModel` state transitions.
- Added 80ms debounced partial transcript queuing in `useOverlayShell` to reduce repaints during rapid STT output; pending partials are flushed and cancelled on state transitions.
- Fixed `stop_capture` to increment `demo_token` so in-flight demo driver loops exit correctly.
- Fixed pipeline state transitions to reset the `paused` flag so demo pause state does not leak into real capture sessions.
- Added focused Rust and frontend tests for the overlay state contract and React hook/component behavior.

### Nuclear Pruning & Documentation Cleanup
Expand Down
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

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

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
164 changes: 164 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,93 @@ 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;
self.snapshot.paused = false;
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;
self.snapshot.paused = false;
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;
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.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 👍 / 👎.

self.snapshot.paused = false;
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()
}

/// 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.demo_token += 1;
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 +270,81 @@ 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.

#[test]
fn stop_capture_bumps_demo_token_and_stops_demo_driver() {
let mut model = OverlayModel::default();
let (token, _snap) = model.start_demo();
assert_eq!(model.current_demo_token(), token);

// stop_capture must bump the token so the demo driver exits its loop
model.stop_capture();
assert_eq!(model.current_demo_token(), token + 1);
}

#[test]
fn pipeline_transitions_reset_paused_flag() {
let mut model = OverlayModel::default();

// Simulate paused state from demo
model.start_demo();
model.toggle_pause(); // now paused
assert!(model.snapshot.paused);

// Any pipeline transition must clear paused so real capture is not stuck
let snap1 = model.apply_partial_transcript("hello", None);
assert!(!snap1.paused);

model.toggle_pause(); // paused again
let snap2 = model.apply_final_transcript("hello world", None);
assert!(!snap2.paused);

model.toggle_pause(); // paused again
let snap3 = model.apply_processing_state(None);
assert!(!snap3.paused);

model.toggle_pause(); // paused again
let snap4 = model.apply_listening_state(None);
assert!(!snap4.paused);
}
}
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
Loading
Loading