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:
2026-04-21 08:49:29 +01:00
parent 42b32a4f1a
commit eb60a8bfd3
10 changed files with 403 additions and 0 deletions

View File

@@ -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";

View File

@@ -1518,6 +1518,11 @@
label="Auto-paste into focused window"
description={pasteBackendsDescription}
/>
<Toggle
bind:checked={settings.transcriptionPreview}
label="Floating preview when Kon is unfocused"
description="Shows a small always-on-top window with the raw transcription as you dictate, then the final formatted text. Only opens when the main window is unfocused or hidden."
/>
<Toggle
bind:checked={settings.meetingAutoCapture}
label="Remind me when a meeting starts"