diff --git a/src-tauri/src/commands/live.rs b/src-tauri/src/commands/live.rs index 7cc799d..4b58a14 100644 --- a/src-tauri/src/commands/live.rs +++ b/src-tauri/src/commands/live.rs @@ -112,6 +112,11 @@ pub struct LiveResultMessage { pub language: String, pub inference_ms: u64, pub segments: Vec, + /// 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 = 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::>() + .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); diff --git a/src-tauri/src/commands/transcription.rs b/src-tauri/src/commands/transcription.rs index 75dd6b5..d216ea8 100644 --- a/src-tauri/src/commands/transcription.rs +++ b/src-tauri/src/commands/transcription.rs @@ -188,6 +188,7 @@ pub async fn transcribe_pcm( let dictionary_terms = profile_terms.clone(); let mut segments: Vec = 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::>() + .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 = 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 = 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}"))?; diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 7ef4a5b..12ffe60 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -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::() { + 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> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 332fc25..279e04e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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) diff --git a/src/lib/pages/DictationPage.svelte b/src/lib/pages/DictationPage.svelte index cb2674e..079ec65 100644 --- a/src/lib/pages/DictationPage.svelte +++ b/src/lib/pages/DictationPage.svelte @@ -2,6 +2,8 @@ // @ts-nocheck import { onMount, onDestroy } from "svelte"; import { Channel, invoke } from "@tauri-apps/api/core"; + import { emit } from "@tauri-apps/api/event"; + import { getCurrentWindow } from "@tauri-apps/api/window"; import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js"; import { profilesStore } from "$lib/stores/profiles.svelte.ts"; import { toasts } from "$lib/stores/toasts.svelte.js"; @@ -154,6 +156,12 @@ } if (result.chunkId != null) processedChunks.add(result.chunkId); + // Forward raw Whisper output to the preview overlay (if it's enabled + // and opened). `raw_text` was captured in live.rs before post-processing. + if (result.rawText && settings.transcriptionPreview) { + emit("preview-append", { text: result.rawText }).catch(() => {}); + } + const text = result.segments.map((segment) => segment.text).join(" ").trim(); if (text) { if (insertPos >= 0) { @@ -348,6 +356,19 @@ page.status = "Recording..."; page.statusColor = "#e87171"; timerInterval = setInterval(updateTimer, 1000); + + // Preview overlay: if the user opted in, reset any leftover state + // from a prior run and open the window when the main window is not + // focused (i.e. the user is dictating into some other app). + if (settings.transcriptionPreview && tauriRuntimeAvailable) { + emit("preview-listening").catch(() => {}); + try { + const focused = await getCurrentWindow().isFocused(); + if (!focused) { + invoke("open_preview_window").catch(() => {}); + } + } catch { /* best effort */ } + } } catch (err) { error = typeof err === "string" ? err : err?.message || "Microphone error"; // Surface the failure as a sticky error toast so it does not get @@ -473,11 +494,17 @@ async function finaliseTranscription(audioPath = null) { if (transcript.trim()) { + if (settings.transcriptionPreview && settings.aiTier !== "off" && settings.formatMode !== "Raw") { + emit("preview-cleanup").catch(() => {}); + } const cleanedTranscript = await cleanupTranscriptIfEnabled(transcript); if (cleanedTranscript !== transcript) { transcript = cleanedTranscript; replaceSegmentsWithCleanedText(cleanedTranscript); } + if (settings.transcriptionPreview) { + emit("preview-final", { text: transcript }).catch(() => {}); + } if (settings.autoPaste) { try { @@ -535,6 +562,10 @@ saved = true; setTimeout(() => { saved = false; extractedCount = 0; }, 4000); + } else if (settings.transcriptionPreview) { + // No transcript to surface — dismiss the overlay rather than leaving + // it stuck in "listening". + emit("preview-hide").catch(() => {}); } page.status = "Ready"; page.statusColor = "#7ec89a"; diff --git a/src/lib/pages/SettingsPage.svelte b/src/lib/pages/SettingsPage.svelte index 951878c..17896a2 100644 --- a/src/lib/pages/SettingsPage.svelte +++ b/src/lib/pages/SettingsPage.svelte @@ -1518,6 +1518,11 @@ label="Auto-paste into focused window" description={pasteBackendsDescription} /> + + // @ts-nocheck + import "../../app.css"; + import { onDestroy, onMount } from "svelte"; + import { getCurrentWindow } from "@tauri-apps/api/window"; + import { listen } from "@tauri-apps/api/event"; + import { settings } from "$lib/stores/page.svelte.js"; + import { + getPreferences, + updatePreferences, + applyExternalPreferences, + PREFERENCES_CHANGED_EVENT, + } from "$lib/stores/preferences.svelte.js"; + + let { children } = $props(); + let unlistenPrefs = null; + + const prefs = getPreferences(); + + // Keep transcript-editor theme sync trick: legacy settings → preferences + $effect(() => { + const legacyTheme = settings.theme; + const mapped = legacyTheme === "Light" ? "light" : legacyTheme === "Dark" ? "dark" : "system"; + if (prefs.theme !== mapped) { + updatePreferences({ theme: mapped }); + } + }); + + if (typeof window !== "undefined") { + window.addEventListener("storage", (event) => { + if (event.key === "kon_settings" && event.newValue) { + try { Object.assign(settings, JSON.parse(event.newValue)); } catch {} + } + }); + } + + onMount(async () => { + try { + let ownLabel = null; + try { ownLabel = getCurrentWindow().label; } catch {} + unlistenPrefs = await listen(PREFERENCES_CHANGED_EVENT, (event) => { + const payload = event?.payload; + if (!payload || payload.source === ownLabel) return; + applyExternalPreferences(payload.prefs); + }); + } catch {} + }); + + onDestroy(() => { + if (unlistenPrefs) unlistenPrefs(); + }); + + // Escape closes the preview without destroying it — the next dictation + // reopens it instantly via open_preview_window. + function handleKeydown(event) { + if (event.key === "Escape") { + getCurrentWindow().hide().catch(() => {}); + } + } + + + + +
+
+ {@render children()} +
+
diff --git a/src/routes/preview/+page.svelte b/src/routes/preview/+page.svelte new file mode 100644 index 0000000..7b99720 --- /dev/null +++ b/src/routes/preview/+page.svelte @@ -0,0 +1,214 @@ + + +
+
+
+ {#if phase === "listening"} + + {:else if phase === "live"} + + + + + + {:else if phase === "cleanup"} + + + + + + {:else} + + {/if} + {phaseLabel} +
+ +
+ + +
+
+ +
+ {#if activeText.trim()} + {activeText} + {:else} + + {phase === "listening" ? "Waiting for speech…" : ""} + + {/if} +
+
+ +