feat(preview): floating transcription overlay with listening→live→cleanup→final phases
Ported the best bits of OpenWhispr's TranscriptionPreviewOverlay into Kon's window conventions. Off by default — toggle in Settings → Output → "Floating preview when Kon is unfocused". Opens only when the main window isn't focused at the start of a recording, so it never adds noise when the user can already see the transcript in the main surface. Phase state machine (src/routes/preview/+page.svelte): - listening — pulsing dot, no text yet - live — animated bars + streaming raw Whisper output - cleanup — accent bars while the LLM cleanup pass runs - final — checkmark + formatted text + 4s auto-hide Data plumbing: raw segment text is captured before post_process_segments in live.rs (new raw_text field on LiveResultMessage) and in transcription.rs (new raw_text in the transcription-result payload). DictationPage forwards raw_text to the overlay via Tauri global events — preview-listening on start, preview-append per chunk, preview-cleanup before the LLM pass, preview-final with the formatted text, preview-hide when a run produced no transcript (empty recording / cancel). Window is always_on_top, skip_taskbar, focused=false so it never steals focus from whatever the user is dictating into. open_preview_window shows an existing hidden preview or builds it fresh; close_preview_window hides without destroying so the next open is instant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,11 @@ pub struct LiveResultMessage {
|
||||
pub language: String,
|
||||
pub inference_ms: u64,
|
||||
pub segments: Vec<Segment>,
|
||||
/// Concatenated text BEFORE post-processing (no filler removal, no
|
||||
/// British conversion, no LLM cleanup). Used by the transcription
|
||||
/// preview overlay so the user can see raw Whisper output as it
|
||||
/// streams in.
|
||||
pub raw_text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -616,6 +621,14 @@ fn poll_inference(
|
||||
Ok(Ok(timed)) => {
|
||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||
trim_overlap_segments(&mut segments, task.trim_before_secs);
|
||||
// Capture raw text BEFORE any post-processing so the preview
|
||||
// overlay can show what Whisper actually returned.
|
||||
let raw_text = segments
|
||||
.iter()
|
||||
.map(|segment| segment.text.trim())
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
post_process_segments(
|
||||
&mut segments,
|
||||
&PostProcessOptions {
|
||||
@@ -645,6 +658,7 @@ fn poll_inference(
|
||||
language: timed.transcript.language().to_string(),
|
||||
inference_ms: timed.inference_ms,
|
||||
segments,
|
||||
raw_text,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
remember_recent_segments(recent_segments, &delivered_segments, chunk_start_secs);
|
||||
|
||||
@@ -188,6 +188,7 @@ pub async fn transcribe_pcm(
|
||||
let dictionary_terms = profile_terms.clone();
|
||||
|
||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||
let raw_text = join_segment_text(&segments);
|
||||
post_process_segments(
|
||||
&mut segments,
|
||||
&PostProcessOptions {
|
||||
@@ -209,6 +210,7 @@ pub async fn transcribe_pcm(
|
||||
"duration": timed.transcript.duration(),
|
||||
"chunk_id": chunk_id,
|
||||
"inference_ms": timed.inference_ms,
|
||||
"raw_text": raw_text,
|
||||
}),
|
||||
)
|
||||
.map_err(|e| format!("Failed to emit result: {e}"))?;
|
||||
@@ -216,6 +218,15 @@ pub async fn transcribe_pcm(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn join_segment_text(segments: &[Segment]) -> String {
|
||||
segments
|
||||
.iter()
|
||||
.map(|segment| segment.text.trim())
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Transcribe an audio file by path. Decodes, resamples to 16kHz, runs Whisper.
|
||||
#[tauri::command]
|
||||
pub async fn transcribe_file(
|
||||
@@ -279,6 +290,7 @@ pub async fn transcribe_file(
|
||||
let dictionary_terms = profile_terms.clone();
|
||||
|
||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||
let raw_text = join_segment_text(&segments);
|
||||
post_process_segments(
|
||||
&mut segments,
|
||||
&PostProcessOptions {
|
||||
@@ -298,6 +310,7 @@ pub async fn transcribe_file(
|
||||
"language": timed.transcript.language(),
|
||||
"duration": timed.transcript.duration(),
|
||||
"inference_ms": timed.inference_ms,
|
||||
"raw_text": raw_text,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -344,6 +357,7 @@ pub async fn transcribe_pcm_parakeet(
|
||||
let dictionary_terms = profile_terms.clone();
|
||||
|
||||
let mut segments: Vec<Segment> = timed.transcript.segments().to_vec();
|
||||
let raw_text = join_segment_text(&segments);
|
||||
post_process_segments(
|
||||
&mut segments,
|
||||
&PostProcessOptions {
|
||||
@@ -365,6 +379,7 @@ pub async fn transcribe_pcm_parakeet(
|
||||
"duration": timed.transcript.duration(),
|
||||
"chunk_id": chunk_id,
|
||||
"inference_ms": timed.inference_ms,
|
||||
"raw_text": raw_text,
|
||||
}),
|
||||
)
|
||||
.map_err(|e| format!("Failed to emit result: {e}"))?;
|
||||
|
||||
@@ -41,6 +41,58 @@ pub async fn open_task_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the always-on-top transcription preview window. Idempotent — an
|
||||
/// existing window is shown and focused; otherwise a new one is built.
|
||||
/// The preview is passive: it subscribes to the `transcription-result`
|
||||
/// event and the cross-window `preview-*` events that DictationPage fires
|
||||
/// as it moves through the phases of a dictation run.
|
||||
#[tauri::command]
|
||||
pub async fn open_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("transcription-preview") {
|
||||
window.show().map_err(|e| e.to_string())?;
|
||||
// Intentionally NOT set_focus — the preview window is meant to
|
||||
// appear next to whatever the user is typing into; stealing focus
|
||||
// defeats the whole point.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let use_native_decorations = cfg!(target_os = "linux");
|
||||
|
||||
let mut builder = WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"transcription-preview",
|
||||
WebviewUrl::App("/preview".into()),
|
||||
)
|
||||
.title("Kon — Preview")
|
||||
.inner_size(420.0, 200.0)
|
||||
.min_inner_size(360.0, 140.0)
|
||||
.max_inner_size(520.0, 360.0)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.focused(false)
|
||||
.decorations(use_native_decorations)
|
||||
.resizable(true);
|
||||
|
||||
if let Some(script) = app.try_state::<PreferencesScript>() {
|
||||
if !script.0.is_empty() {
|
||||
builder = builder.initialization_script(&script.0);
|
||||
}
|
||||
}
|
||||
|
||||
builder.build().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hide the transcription preview window without destroying it so the next
|
||||
/// open is instant. Returns Ok even when no preview window exists.
|
||||
#[tauri::command]
|
||||
pub async fn close_preview_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("transcription-preview") {
|
||||
window.hide().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open the transcript viewer window.
|
||||
#[tauri::command]
|
||||
pub async fn open_viewer_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
|
||||
@@ -305,6 +305,8 @@ pub fn run() {
|
||||
// Windows
|
||||
commands::windows::open_task_window,
|
||||
commands::windows::open_viewer_window,
|
||||
commands::windows::open_preview_window,
|
||||
commands::windows::close_preview_window,
|
||||
// Clipboard
|
||||
commands::clipboard::copy_to_clipboard,
|
||||
// Paste (auto-insert at cursor)
|
||||
|
||||
Reference in New Issue
Block a user