audio: wire user's microphone choice through start_native_capture + live session
Day 1 follow-up to 96980c7. The device-picker UI in Settings now
actually takes effect: settings.microphoneDevice flows from the Svelte
store, through the Tauri invoke, into MicrophoneCapture::start_with_device
on the Rust side.
Touched paths (back-to-front):
- src-tauri/src/commands/audio.rs:start_native_capture — new optional
`device_name: Option<String>` parameter; routes to start_with_device
when set, falls back to auto-select start() when None or empty.
- src-tauri/src/commands/live.rs:StartLiveTranscriptionConfig — new
optional `microphone_device: Option<String>` field with same
semantics (rename_all = "camelCase" maps it to microphoneDevice on
the wire).
- src-tauri/src/commands/live.rs:run_live_session — picks
start_with_device when an explicit name is provided.
- src/lib/pages/DictationPage.svelte — passes
microphoneDevice: settings.microphoneDevice || null in the invoke.
Behaviour:
- "Auto" in the picker (empty string) -> backend auto-selects, skipping
monitor sources and validating by RMS energy.
- Specific device -> backend opens that device by exact name; if it has
been disconnected the user gets a clear error pointing them back at
Settings.
cargo check -p kon-audio passes clean. Tauri-crate cargo check requires
cmake (pre-existing infra dependency for whisper-rs-sys); install via
`sudo dnf install cmake clang-devel`.
This commit is contained in:
@@ -1,25 +1,28 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { page, settings, templates, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
|
||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
import { page, settings, templates, profiles, addToHistory, addTask, tasks } from "$lib/stores/page.svelte.js";
|
||||
import Card from "$lib/components/Card.svelte";
|
||||
import ModelDownloader from "$lib/components/ModelDownloader.svelte";
|
||||
import { exportTranscript } from "$lib/utils/export.js";
|
||||
import { extractTasks } from "$lib/utils/taskExtractor.js";
|
||||
import { pad } from "$lib/utils/time.js";
|
||||
import { MAX_PCM_SAMPLES, MIN_CHUNK_SAMPLES, CHUNK_INTERVAL_MS, FEEDBACK_TIMEOUT_MS } from "$lib/utils/constants.js";
|
||||
import { FEEDBACK_TIMEOUT_MS } from "$lib/utils/constants.js";
|
||||
import { Mic, Loader2, SquareCheck, AlertTriangle } from 'lucide-svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||
import { bionicReading } from '$lib/actions/bionicReading.js';
|
||||
import { measurePreWrap } from '$lib/utils/textMeasure.js';
|
||||
import { transcriptPretextFont, transcriptPretextLineHeight } from '$lib/utils/accessibilityTypography.js';
|
||||
import { hasTauriRuntime } from '$lib/utils/runtime.js';
|
||||
const prefs = getPreferences();
|
||||
const tauriRuntimeAvailable = hasTauriRuntime();
|
||||
const browserPreviewMessage = "You're viewing Kon in a normal browser. Local transcription only works in the Tauri desktop app window.";
|
||||
|
||||
let transcript = $state("");
|
||||
let segments = $state([]);
|
||||
let timerInterval = $state(null);
|
||||
let startTime = $state(0);
|
||||
let chunkId = $state(0);
|
||||
let modelReady = $state(false);
|
||||
let modelLoading = $state(false);
|
||||
let needsDownload = $state(false);
|
||||
@@ -30,8 +33,14 @@
|
||||
let extractedCount = $state(0);
|
||||
let aiProcessing = $state(false);
|
||||
let aiStatus = $state("");
|
||||
let unlisten = null;
|
||||
let chunkTimeOffset = 0;
|
||||
let runtimeCapabilities = $state(null);
|
||||
let sessionId = $state(null);
|
||||
let drainingSessionId = $state(null);
|
||||
let liveWarning = $state("");
|
||||
let lastResultAt = $state(0);
|
||||
let lastLiveActivityAt = $state(0);
|
||||
let resultChannel = null;
|
||||
let statusChannel = null;
|
||||
|
||||
// Cursor-based insertion
|
||||
let textareaEl = $state(null);
|
||||
@@ -44,41 +53,23 @@
|
||||
// Deduplication: track which chunk IDs have been processed
|
||||
let processedChunks = new Set();
|
||||
|
||||
// AudioWorklet state
|
||||
let audioContext = null;
|
||||
let workletNode = null;
|
||||
let mediaStream = null;
|
||||
let pcmBuffer = [];
|
||||
let chunkTimer = null;
|
||||
let allSamples = []; // Accumulate all PCM for audio saving
|
||||
|
||||
// Global hotkey listener
|
||||
let hotkeyHandler = () => toggleRecording();
|
||||
|
||||
onMount(async () => {
|
||||
unlisten = await listen("transcription-result", (event) => {
|
||||
let result;
|
||||
try {
|
||||
result = typeof event.payload === "string"
|
||||
? JSON.parse(event.payload)
|
||||
: event.payload;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse transcription result:", e);
|
||||
return;
|
||||
}
|
||||
handleResult(result);
|
||||
});
|
||||
|
||||
window.addEventListener("kon:toggle-recording", hotkeyHandler);
|
||||
|
||||
if (!tauriRuntimeAvailable) {
|
||||
error = browserPreviewMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
await checkModelState();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unlisten) unlisten();
|
||||
window.removeEventListener("kon:toggle-recording", hotkeyHandler);
|
||||
clearInterval(timerInterval);
|
||||
clearInterval(chunkTimer);
|
||||
if (page.recording) {
|
||||
page.recording = false;
|
||||
page.status = "Ready";
|
||||
@@ -87,74 +78,154 @@
|
||||
cleanup();
|
||||
});
|
||||
|
||||
function handleResult(result) {
|
||||
if (result.status === "transcription" && result.segments) {
|
||||
// Deduplication guard: skip if this chunk_id was already processed
|
||||
if (result.chunk_id != null && processedChunks.has(result.chunk_id)) {
|
||||
return;
|
||||
$effect(() => {
|
||||
settings.engine;
|
||||
settings.modelSize;
|
||||
if (!tauriRuntimeAvailable) return;
|
||||
queueMicrotask(() => {
|
||||
if (!page.recording) {
|
||||
void checkModelState();
|
||||
}
|
||||
if (result.chunk_id != null) processedChunks.add(result.chunk_id);
|
||||
const text = result.segments.map((s) => s.text).join(" ").trim();
|
||||
if (text) {
|
||||
if (insertPos >= 0) {
|
||||
// Insert at cursor position
|
||||
const before = transcript.slice(0, insertPos);
|
||||
const after = transcript.slice(insertPos);
|
||||
const spaceBefore = before && !before.endsWith(" ") && !before.endsWith("\n") ? " " : "";
|
||||
const spaceAfter = after && !after.startsWith(" ") && !after.startsWith("\n") ? " " : "";
|
||||
transcript = before + spaceBefore + text + spaceAfter + after;
|
||||
insertPos += spaceBefore.length + text.length + spaceAfter.length;
|
||||
// Move cursor to end of inserted text
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaEl) {
|
||||
textareaEl.selectionStart = insertPos;
|
||||
textareaEl.selectionEnd = insertPos;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Append mode
|
||||
// First chunk (id=1) starts the transcript; subsequent chunks append to it.
|
||||
// When transcript is empty, assign directly; otherwise prepend a space separator.
|
||||
if (!transcript) {
|
||||
transcript = text;
|
||||
} else {
|
||||
transcript += " " + text;
|
||||
});
|
||||
});
|
||||
|
||||
function whisperModelId(size) {
|
||||
const map = {
|
||||
Tiny: "whisper-tiny-en",
|
||||
Base: "whisper-base-en",
|
||||
Small: "whisper-small-en",
|
||||
Medium: "whisper-medium-en",
|
||||
};
|
||||
return map[size] || "whisper-base-en";
|
||||
}
|
||||
|
||||
function selectedModelId() {
|
||||
return settings.engine === "parakeet"
|
||||
? "parakeet-ctc-0.6b-int8"
|
||||
: whisperModelId(settings.modelSize);
|
||||
}
|
||||
|
||||
function currentEngineCapabilities() {
|
||||
return runtimeCapabilities?.engines?.find((engine) => engine.id === settings.engine) || null;
|
||||
}
|
||||
|
||||
function currentModelCapabilities() {
|
||||
return currentEngineCapabilities()?.models?.find((model) => model.id === selectedModelId()) || null;
|
||||
}
|
||||
|
||||
function currentModelIsEnglishOnly() {
|
||||
return currentModelCapabilities()?.languageSupport?.kind === "english-only";
|
||||
}
|
||||
|
||||
function effectiveLanguage() {
|
||||
return currentModelIsEnglishOnly() ? "en" : settings.language;
|
||||
}
|
||||
|
||||
function buildInitialPrompt() {
|
||||
if (!page.activeProfile || page.activeProfile === "None") return "";
|
||||
const profile = profiles.find((entry) => entry.name === page.activeProfile);
|
||||
if (!profile?.words) return "";
|
||||
const words = profile.words
|
||||
.split("\n")
|
||||
.map((word) => word.trim())
|
||||
.filter(Boolean);
|
||||
if (words.length === 0) return "";
|
||||
return `Use these terms when they match the audio: ${words.join(", ")}`;
|
||||
}
|
||||
|
||||
async function refreshRuntimeCapabilities() {
|
||||
runtimeCapabilities = await invoke("get_runtime_capabilities");
|
||||
}
|
||||
|
||||
function matchesLiveSession(candidateSessionId) {
|
||||
return candidateSessionId != null
|
||||
&& (candidateSessionId === sessionId || candidateSessionId === drainingSessionId);
|
||||
}
|
||||
|
||||
function handleLiveResult(result) {
|
||||
if (!result || !matchesLiveSession(result.sessionId) || !result.segments) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastResultAt = Date.now();
|
||||
lastLiveActivityAt = lastResultAt;
|
||||
if (textareaEl) {
|
||||
if (page.recording && userScrolledUp) {
|
||||
shouldPreserveScrollAnchor = true;
|
||||
} else if (page.recording) {
|
||||
shouldStickToBottom = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.chunkId != null && processedChunks.has(result.chunkId)) {
|
||||
return;
|
||||
}
|
||||
if (result.chunkId != null) processedChunks.add(result.chunkId);
|
||||
|
||||
const text = result.segments.map((segment) => segment.text).join(" ").trim();
|
||||
if (text) {
|
||||
if (insertPos >= 0) {
|
||||
const before = transcript.slice(0, insertPos);
|
||||
const after = transcript.slice(insertPos);
|
||||
const spaceBefore = before && !before.endsWith(" ") && !before.endsWith("\n") ? " " : "";
|
||||
const spaceAfter = after && !after.startsWith(" ") && !after.startsWith("\n") ? " " : "";
|
||||
transcript = before + spaceBefore + text + spaceAfter + after;
|
||||
insertPos += spaceBefore.length + text.length + spaceAfter.length;
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaEl) {
|
||||
textareaEl.selectionStart = insertPos;
|
||||
textareaEl.selectionEnd = insertPos;
|
||||
}
|
||||
}
|
||||
|
||||
// Offset segment timestamps to be absolute
|
||||
const offset = chunkTimeOffset;
|
||||
const adjusted = result.segments.map((s) => ({
|
||||
...s,
|
||||
start: s.start + offset,
|
||||
end: s.end + offset,
|
||||
}));
|
||||
segments = [...segments, ...adjusted];
|
||||
if (result.duration) {
|
||||
chunkTimeOffset += result.duration;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
transcript = transcript ? `${transcript} ${text}` : text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!page.recording && result.chunk_id === chunkId) {
|
||||
finaliseTranscription();
|
||||
}
|
||||
const absoluteSegments = result.segments.map((segment) => ({
|
||||
...segment,
|
||||
start: segment.start + result.chunkStartSecs,
|
||||
end: segment.end + result.chunkStartSecs,
|
||||
}));
|
||||
segments = [...segments, ...absoluteSegments];
|
||||
}
|
||||
|
||||
function handleLiveStatus(status) {
|
||||
if (!status || !matchesLiveSession(status.sessionId)) return;
|
||||
|
||||
lastLiveActivityAt = Date.now();
|
||||
|
||||
if (status.type === "overload" || status.type === "warning") {
|
||||
liveWarning = status.message || "Kon is dropping older audio to stay responsive.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.type === "error") {
|
||||
error = status.message || "Live transcription failed";
|
||||
transcriptionFailed = true;
|
||||
page.status = "Error";
|
||||
page.statusColor = "#e87171";
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.type === "finished" && status.droppedAudioMs > 0) {
|
||||
liveWarning = `Dropped ${Math.round(status.droppedAudioMs / 1000)}s of older audio to keep up in real time.`;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkModelState() {
|
||||
if (!tauriRuntimeAvailable) {
|
||||
modelReady = false;
|
||||
needsDownload = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (settings.engine === "parakeet") {
|
||||
const loaded = await invoke("check_parakeet_engine");
|
||||
if (loaded) { modelReady = true; return; }
|
||||
const downloaded = await invoke("check_parakeet_model", { name: "ctc-int8" });
|
||||
if (downloaded) { needsDownload = false; await loadModel(); }
|
||||
else { needsDownload = true; }
|
||||
} else {
|
||||
const loaded = await invoke("check_engine");
|
||||
if (loaded) { modelReady = true; return; }
|
||||
const downloaded = await invoke("check_model", { size: settings.modelSize.toLowerCase() });
|
||||
if (downloaded) { needsDownload = false; await loadModel(); }
|
||||
else { needsDownload = true; }
|
||||
await refreshRuntimeCapabilities();
|
||||
const currentModel = currentModelCapabilities();
|
||||
modelReady = !!currentModel?.loaded;
|
||||
needsDownload = currentModel ? !currentModel.downloaded : true;
|
||||
if (currentModelIsEnglishOnly() && settings.language !== "en") {
|
||||
settings.language = "en";
|
||||
}
|
||||
} catch (err) {
|
||||
error = typeof err === "string" ? err : err.message || "Failed to check model";
|
||||
@@ -162,6 +233,10 @@
|
||||
}
|
||||
|
||||
async function loadModel() {
|
||||
if (!tauriRuntimeAvailable) {
|
||||
error = browserPreviewMessage;
|
||||
return;
|
||||
}
|
||||
modelLoading = true;
|
||||
page.status = "Loading model...";
|
||||
page.statusColor = "#e8c86e";
|
||||
@@ -173,6 +248,7 @@
|
||||
} else {
|
||||
await invoke("load_model", { size: settings.modelSize.toLowerCase() });
|
||||
}
|
||||
await refreshRuntimeCapabilities();
|
||||
modelReady = true;
|
||||
modelLoading = false;
|
||||
page.status = "Ready";
|
||||
@@ -187,7 +263,7 @@
|
||||
|
||||
function onModelDownloaded() {
|
||||
needsDownload = false;
|
||||
loadModel();
|
||||
void loadModel();
|
||||
}
|
||||
|
||||
async function toggleRecording() {
|
||||
@@ -200,8 +276,15 @@
|
||||
|
||||
async function startRecording() {
|
||||
error = "";
|
||||
liveWarning = "";
|
||||
saved = false;
|
||||
transcriptionFailed = false;
|
||||
if (!tauriRuntimeAvailable) {
|
||||
error = browserPreviewMessage;
|
||||
page.status = "Desktop app required";
|
||||
page.statusColor = "#e8c86e";
|
||||
return;
|
||||
}
|
||||
if (!modelReady) {
|
||||
if (needsDownload) return;
|
||||
await loadModel();
|
||||
@@ -216,48 +299,49 @@
|
||||
}
|
||||
|
||||
try {
|
||||
audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
|
||||
await audioContext.audioWorklet.addModule("/pcm-processor.js");
|
||||
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
||||
});
|
||||
|
||||
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||
workletNode = new AudioWorkletNode(audioContext, "pcm-processor");
|
||||
|
||||
pcmBuffer = [];
|
||||
chunkId = 0;
|
||||
chunkTimeOffset = 0;
|
||||
sessionId = null;
|
||||
drainingSessionId = null;
|
||||
lastResultAt = 0;
|
||||
lastLiveActivityAt = 0;
|
||||
processedChunks.clear();
|
||||
|
||||
// Only clear transcript if not in insert mode (fresh recording)
|
||||
if (insertPos === -1) {
|
||||
transcript = "";
|
||||
segments = [];
|
||||
previousMeasuredContentHeight = 0;
|
||||
}
|
||||
|
||||
allSamples = [];
|
||||
workletNode.port.onmessage = (e) => {
|
||||
if (e.data.type === "pcm") {
|
||||
pcmBuffer = pcmBuffer.concat(e.data.samples);
|
||||
if (settings.saveAudio) {
|
||||
allSamples = allSamples.concat(e.data.samples);
|
||||
}
|
||||
}
|
||||
};
|
||||
resultChannel = new Channel((message) => handleLiveResult(message));
|
||||
statusChannel = new Channel((message) => handleLiveStatus(message));
|
||||
|
||||
source.connect(workletNode);
|
||||
const response = await invoke("start_live_transcription_session", {
|
||||
config: {
|
||||
engine: settings.engine,
|
||||
modelId: selectedModelId(),
|
||||
language: effectiveLanguage(),
|
||||
initialPrompt: buildInitialPrompt(),
|
||||
saveAudio: settings.saveAudio,
|
||||
outputFolder: settings.outputFolder || null,
|
||||
removeFillers: settings.removeFillers,
|
||||
britishEnglish: settings.britishEnglish,
|
||||
antiHallucination: settings.antiHallucination,
|
||||
formatMode: settings.formatMode,
|
||||
// Honour Settings → Audio → Microphone choice. Empty = auto-select.
|
||||
microphoneDevice: settings.microphoneDevice || null,
|
||||
},
|
||||
resultChannel,
|
||||
statusChannel,
|
||||
});
|
||||
sessionId = response.sessionId;
|
||||
|
||||
startTime = Date.now();
|
||||
page.recording = true;
|
||||
page.status = "Recording...";
|
||||
page.statusColor = "#e87171";
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
chunkTimer = setInterval(sendChunk, CHUNK_INTERVAL_MS);
|
||||
} catch (err) {
|
||||
error = `Microphone access denied: ${err.message}`;
|
||||
error = typeof err === "string" ? err : err?.message || "Microphone error";
|
||||
page.status = "Error";
|
||||
page.statusColor = "#e87171";
|
||||
cleanup();
|
||||
@@ -266,115 +350,71 @@
|
||||
|
||||
async function stopRecording() {
|
||||
clearInterval(timerInterval);
|
||||
clearInterval(chunkTimer);
|
||||
page.recording = false;
|
||||
transcribing = true;
|
||||
page.status = "Finalising...";
|
||||
page.statusColor = "#e8c86e";
|
||||
|
||||
const waitForTranscription = () => new Promise((resolve) => {
|
||||
const check = () => transcribing ? setTimeout(check, 100) : resolve();
|
||||
check();
|
||||
});
|
||||
await waitForTranscription();
|
||||
await sendChunk();
|
||||
|
||||
cleanup();
|
||||
|
||||
if (chunkId === 0) {
|
||||
page.status = "Ready";
|
||||
page.statusColor = "#7ec89a";
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (workletNode) {
|
||||
workletNode.disconnect();
|
||||
workletNode = null;
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach((t) => t.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
if (audioContext) {
|
||||
audioContext.close();
|
||||
audioContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendChunk() {
|
||||
if (pcmBuffer.length < MIN_CHUNK_SAMPLES) return;
|
||||
if (transcribing) return;
|
||||
|
||||
chunkId++;
|
||||
const currentChunkId = chunkId;
|
||||
const samples = [...pcmBuffer];
|
||||
pcmBuffer = [];
|
||||
if (samples.length > MAX_PCM_SAMPLES) {
|
||||
console.warn(`PCM buffer truncated from ${samples.length} to ${MAX_PCM_SAMPLES} samples (~5 min at 16 kHz)`);
|
||||
samples.length = MAX_PCM_SAMPLES;
|
||||
}
|
||||
transcribing = true;
|
||||
|
||||
try {
|
||||
let initialPrompt = "";
|
||||
if (page.activeProfile && page.activeProfile !== "None") {
|
||||
initialPrompt = page.activeProfile;
|
||||
}
|
||||
const activityBeforeStop = lastLiveActivityAt;
|
||||
drainingSessionId = sessionId;
|
||||
const response = sessionId
|
||||
? await invoke("stop_live_transcription_session", { sessionId })
|
||||
: null;
|
||||
await waitForResultDrain(activityBeforeStop);
|
||||
cleanup();
|
||||
|
||||
if (settings.engine === "parakeet") {
|
||||
await invoke("transcribe_pcm_parakeet", {
|
||||
samples,
|
||||
chunkId: currentChunkId,
|
||||
removeFillers: settings.removeFillers,
|
||||
britishEnglish: settings.britishEnglish,
|
||||
antiHallucination: settings.antiHallucination,
|
||||
formatMode: settings.formatMode,
|
||||
});
|
||||
if (processedChunks.size === 0 && !transcript.trim()) {
|
||||
page.status = "Ready";
|
||||
page.statusColor = "#7ec89a";
|
||||
} else {
|
||||
await invoke("transcribe_pcm", {
|
||||
samples,
|
||||
chunkId: currentChunkId,
|
||||
language: settings.language,
|
||||
initialPrompt,
|
||||
removeFillers: settings.removeFillers,
|
||||
britishEnglish: settings.britishEnglish,
|
||||
antiHallucination: settings.antiHallucination,
|
||||
formatMode: settings.formatMode,
|
||||
});
|
||||
await finaliseTranscription(response?.audioPath || null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("transcribe_pcm failed:", err);
|
||||
error = typeof err === "string" ? err : err.message || "Transcription failed";
|
||||
transcriptionFailed = true;
|
||||
if (!page.recording) {
|
||||
page.status = "Error";
|
||||
page.statusColor = "#e87171";
|
||||
}
|
||||
error = typeof err === "string" ? err : err?.message || "Failed to stop live transcription";
|
||||
page.status = "Error";
|
||||
page.statusColor = "#e87171";
|
||||
cleanup();
|
||||
} finally {
|
||||
transcribing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function finaliseTranscription() {
|
||||
function cleanup() {
|
||||
sessionId = null;
|
||||
drainingSessionId = null;
|
||||
resultChannel = null;
|
||||
statusChannel = null;
|
||||
}
|
||||
|
||||
async function waitForResultDrain(previousActivityAt = 0) {
|
||||
const firstMessageDeadline = Date.now() + 400;
|
||||
while (Date.now() < firstMessageDeadline) {
|
||||
if (lastLiveActivityAt > previousActivityAt) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
if (lastLiveActivityAt <= previousActivityAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idleDeadline = Date.now() + 1500;
|
||||
while (Date.now() < idleDeadline) {
|
||||
if (Date.now() - lastLiveActivityAt >= 200) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
|
||||
async function finaliseTranscription(audioPath = null) {
|
||||
if (transcript.trim()) {
|
||||
if (settings.autoCopy) {
|
||||
invoke("copy_to_clipboard", { text: transcript }).catch(() => {});
|
||||
}
|
||||
|
||||
// Save audio if enabled — capture path for history replay
|
||||
let audioPath = null;
|
||||
if (settings.saveAudio && allSamples.length > 0) {
|
||||
try {
|
||||
audioPath = await invoke("save_audio", {
|
||||
samples: allSamples,
|
||||
outputFolder: settings.outputFolder || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("save_audio failed:", err);
|
||||
}
|
||||
allSamples = [];
|
||||
}
|
||||
|
||||
const historyId = crypto.randomUUID();
|
||||
addToHistory({
|
||||
id: historyId,
|
||||
@@ -384,7 +424,7 @@
|
||||
text: transcript,
|
||||
segments: segments,
|
||||
duration: (Date.now() - startTime) / 1000,
|
||||
language: settings.language,
|
||||
language: effectiveLanguage(),
|
||||
template: activeTemplate || undefined,
|
||||
audioPath,
|
||||
});
|
||||
@@ -425,8 +465,12 @@
|
||||
function clearTranscript() {
|
||||
transcript = "";
|
||||
segments = [];
|
||||
previousMeasuredContentHeight = 0;
|
||||
shouldPreserveScrollAnchor = false;
|
||||
shouldStickToBottom = false;
|
||||
page.timerText = "00:00";
|
||||
error = "";
|
||||
liveWarning = "";
|
||||
saved = false;
|
||||
insertPos = -1;
|
||||
activeTemplate = "";
|
||||
@@ -452,6 +496,7 @@
|
||||
showTemplateMenu = false;
|
||||
transcript = template.sections.map((s) => `## ${s}\n\n`).join("\n");
|
||||
segments = [];
|
||||
previousMeasuredContentHeight = 0;
|
||||
// Position cursor at first section body
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaEl) {
|
||||
@@ -504,6 +549,80 @@
|
||||
return trimmed ? trimmed.split(/\s+/).length : 0;
|
||||
});
|
||||
|
||||
// Pretext content height measurement (DOM-free).
|
||||
// Used for scroll-to-bottom affordance during live transcription.
|
||||
let textareaWidth = $state(0);
|
||||
let userScrolledUp = $state(false);
|
||||
let previousMeasuredContentHeight = $state(0);
|
||||
let shouldPreserveScrollAnchor = $state(false);
|
||||
let shouldStickToBottom = $state(false);
|
||||
|
||||
// Build font string matching .font-transcript CSS
|
||||
let transcriptFont = $derived(transcriptPretextFont(prefs.accessibility));
|
||||
let transcriptLineHeight = $derived(transcriptPretextLineHeight(prefs.accessibility));
|
||||
|
||||
let contentHeight = $derived.by(() => {
|
||||
if (!transcript.trim() || textareaWidth <= 0) return 0;
|
||||
// Subtract padding (p-6 = 24px each side)
|
||||
const innerWidth = textareaWidth - 48;
|
||||
if (innerWidth <= 0) return 0;
|
||||
return measurePreWrap(transcript, transcriptFont, innerWidth, transcriptLineHeight).height;
|
||||
});
|
||||
|
||||
function onTextareaScroll(e) {
|
||||
const el = e.target;
|
||||
// "scrolled up" = more than 1 line from the bottom
|
||||
userScrolledUp = (el.scrollHeight - el.scrollTop - el.clientHeight) > transcriptLineHeight;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (textareaEl) {
|
||||
textareaEl.scrollTop = textareaEl.scrollHeight;
|
||||
userScrolledUp = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Track textarea width for Pretext layout calculations
|
||||
$effect(() => {
|
||||
if (!textareaEl) return;
|
||||
const ro = new ResizeObserver(entries => {
|
||||
for (const entry of entries) textareaWidth = entry.contentRect.width;
|
||||
});
|
||||
ro.observe(textareaEl);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const measuredHeight = contentHeight;
|
||||
const previousHeight = previousMeasuredContentHeight;
|
||||
|
||||
if (!textareaEl) {
|
||||
previousMeasuredContentHeight = measuredHeight;
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldPreserveScrollAnchor && measuredHeight > previousHeight) {
|
||||
const delta = measuredHeight - previousHeight;
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaEl) {
|
||||
textareaEl.scrollTop += delta;
|
||||
}
|
||||
});
|
||||
shouldPreserveScrollAnchor = false;
|
||||
} else if (shouldStickToBottom) {
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaEl) {
|
||||
textareaEl.scrollTop = textareaEl.scrollHeight;
|
||||
}
|
||||
});
|
||||
shouldStickToBottom = false;
|
||||
}
|
||||
|
||||
previousMeasuredContentHeight = measuredHeight;
|
||||
});
|
||||
|
||||
let showScrollHint = $derived(page.recording && userScrolledUp && contentHeight > 0);
|
||||
|
||||
let reduceMotion = $derived(
|
||||
prefs.accessibility.reduceMotion === 'on'
|
||||
|| (prefs.accessibility.reduceMotion === 'system'
|
||||
@@ -519,7 +638,11 @@
|
||||
{#if page.recording}Recording started{/if}
|
||||
</div>
|
||||
{#if needsDownload}
|
||||
<ModelDownloader modelSize={settings.modelSize.toLowerCase()} onComplete={onModelDownloaded} />
|
||||
<ModelDownloader
|
||||
engine={settings.engine}
|
||||
modelSize={settings.modelSize.toLowerCase()}
|
||||
onComplete={onModelDownloaded}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Control strip -->
|
||||
<div class="flex items-center gap-3 px-5 h-[56px] border-b border-border-subtle flex-shrink-0">
|
||||
@@ -531,9 +654,11 @@
|
||||
? 'bg-danger animate-pulse-warm'
|
||||
: modelLoading
|
||||
? 'bg-warning opacity-60 cursor-wait'
|
||||
: 'bg-accent hover:bg-accent-hover shadow-[0_4px_20px_rgba(232,168,124,0.3)]'}"
|
||||
: !tauriRuntimeAvailable
|
||||
? 'bg-bg-elevated text-text-tertiary cursor-not-allowed'
|
||||
: 'bg-accent hover:bg-accent-hover shadow-[0_4px_20px_rgba(232,168,124,0.3)]'}"
|
||||
onclick={toggleRecording}
|
||||
disabled={modelLoading}
|
||||
disabled={modelLoading || !tauriRuntimeAvailable}
|
||||
aria-label={page.recording ? "Stop recording" : "Start recording"}
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
>
|
||||
@@ -571,6 +696,8 @@
|
||||
<span class="text-[11px] text-text-tertiary">
|
||||
{#if modelLoading}
|
||||
Loading model...
|
||||
{:else if !tauriRuntimeAvailable}
|
||||
Desktop app required for local transcription
|
||||
{:else if saved}
|
||||
<span class="text-success animate-fade-in">
|
||||
Saved{#if extractedCount > 0} · {extractedCount} task{extractedCount === 1 ? '' : 's'} extracted{/if}
|
||||
@@ -612,7 +739,7 @@
|
||||
aria-label="Toggle task sidebar"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<SquareCheck size={16} class="{page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'}" aria-hidden="true" />
|
||||
<SquareCheck size={16} class={page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'} aria-hidden="true" />
|
||||
<span class="text-[11px] {page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'}">Tasks</span>
|
||||
</span>
|
||||
{#if taskCount > 0}
|
||||
@@ -704,6 +831,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if liveWarning && !error}
|
||||
<div class="px-5 pt-2 animate-fade-in flex-shrink-0">
|
||||
<div class="px-4 py-2 rounded-lg bg-warning/10 border border-warning/20 text-[12px] text-warning">
|
||||
{liveWarning}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Transcript area -->
|
||||
<div class="flex-1 px-5 pt-3 pb-3 min-h-0" style="--text-transcript: {prefs.accessibility.transcriptSize}px; font-size: var(--text-transcript)">
|
||||
<Card classes="h-full flex flex-col">
|
||||
@@ -727,17 +862,30 @@
|
||||
aria-live="polite"
|
||||
></textarea>
|
||||
{:else}
|
||||
<textarea
|
||||
bind:this={textareaEl}
|
||||
class="font-transcript flex-1 w-full bg-transparent text-text p-6
|
||||
resize-none focus:outline-none placeholder:text-text-tertiary min-h-0"
|
||||
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
|
||||
bind:value={transcript}
|
||||
onclick={() => { showExportMenu = false; showTemplateMenu = false; }}
|
||||
data-no-transition
|
||||
aria-label="Transcript"
|
||||
aria-live="polite"
|
||||
></textarea>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<textarea
|
||||
bind:this={textareaEl}
|
||||
class="font-transcript h-full w-full bg-transparent text-text p-6
|
||||
resize-none focus:outline-none placeholder:text-text-tertiary"
|
||||
placeholder={activeTemplate ? "Click a section above, then press record..." : "Your words will appear here..."}
|
||||
bind:value={transcript}
|
||||
onscroll={onTextareaScroll}
|
||||
onclick={() => { showExportMenu = false; showTemplateMenu = false; }}
|
||||
data-no-transition
|
||||
aria-label="Transcript"
|
||||
aria-live="polite"
|
||||
></textarea>
|
||||
{#if showScrollHint}
|
||||
<button
|
||||
class="absolute bottom-3 right-6 px-3 py-1.5 rounded-full bg-accent text-white text-[11px] font-medium shadow-md hover:bg-accent-hover animate-fade-in"
|
||||
onclick={scrollToBottom}
|
||||
aria-label="Scroll to latest"
|
||||
style="transition-duration: var(--duration-ui)"
|
||||
>
|
||||
↓ New text
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Status footer (inside transcript card) -->
|
||||
|
||||
Reference in New Issue
Block a user