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:
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user