feat(kon): scaffold hybrid modular workspace

- Cargo workspace with 6 domain crates: core, audio, transcription, ai-formatting, storage, cloud-providers
- Minimal Tauri shell (lib.rs + main.rs) with plugin registration
- Svelte 5 frontend copied from Ramble v0.2
- All crates compile as empty stubs
- App identifier: uk.co.corbel.kon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jake
2026-03-16 20:21:38 +00:00
commit 9926a42b7a
80 changed files with 13328 additions and 0 deletions

View File

@@ -0,0 +1,669 @@
<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, saveTasks } 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";
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);
let error = $state("");
let showExportMenu = $state(false);
let transcribing = $state(false);
let saved = $state(false);
let extractedCount = $state(0);
let aiProcessing = $state(false);
let aiStatus = $state("");
let unlisten = null;
let chunkTimeOffset = 0;
// Cursor-based insertion
let textareaEl = $state(null);
let insertPos = $state(-1); // -1 = append mode, >= 0 = insert at position
// Template
let activeTemplate = $state("");
let showTemplateMenu = $state(false);
// 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) => {
const result = typeof event.payload === "string"
? JSON.parse(event.payload)
: event.payload;
handleResult(result);
});
window.addEventListener("ramble:toggle-recording", hotkeyHandler);
await checkModelState();
});
onDestroy(() => {
if (unlisten) unlisten();
window.removeEventListener("ramble:toggle-recording", hotkeyHandler);
clearInterval(timerInterval);
clearInterval(chunkTimer);
if (page.recording) {
page.recording = false;
page.status = "Ready";
page.statusColor = "#7ec89a";
}
cleanup();
});
function handleResult(result) {
if (result.status === "transcription" && result.segments) {
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
if (transcript && result.chunk_id > 1) {
transcript += " " + text;
} else if (!transcript) {
transcript = text;
} else {
transcript += " " + text;
}
}
// 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;
}
}
if (!page.recording && result.chunk_id === chunkId) {
finaliseTranscription();
}
}
}
async function checkModelState() {
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; }
}
} catch (err) {
error = typeof err === "string" ? err : err.message || "Failed to check model";
}
}
async function loadModel() {
modelLoading = true;
page.status = "Loading model...";
page.statusColor = "#e8c86e";
error = "";
try {
if (settings.engine === "parakeet") {
await invoke("load_parakeet_model", { name: "ctc-int8" });
} else {
await invoke("load_model", { size: settings.modelSize.toLowerCase() });
}
modelReady = true;
modelLoading = false;
page.status = "Ready";
page.statusColor = "#7ec89a";
} catch (err) {
error = typeof err === "string" ? err : err.message || "Failed to load model";
modelLoading = false;
page.status = "Error";
page.statusColor = "#e87171";
}
}
function onModelDownloaded() {
needsDownload = false;
loadModel();
}
async function toggleRecording() {
if (page.recording) {
await stopRecording();
} else {
await startRecording();
}
}
async function startRecording() {
error = "";
saved = false;
if (!modelReady) {
if (needsDownload) return;
await loadModel();
if (!modelReady) return;
}
// Capture cursor position for insert mode
if (textareaEl && transcript.length > 0) {
insertPos = textareaEl.selectionStart ?? transcript.length;
} else {
insertPos = -1; // Append mode for empty textarea
}
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;
// Only clear transcript if not in insert mode (fresh recording)
if (insertPos === -1) {
transcript = "";
segments = [];
}
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);
}
}
};
source.connect(workletNode);
startTime = Date.now();
page.recording = true;
page.status = "Recording...";
page.statusColor = "#e87171";
timerInterval = setInterval(updateTimer, 1000);
chunkTimer = setInterval(sendChunk, 3000);
} catch (err) {
error = `Microphone access denied: ${err.message}`;
page.status = "Error";
page.statusColor = "#e87171";
cleanup();
}
}
async function stopRecording() {
clearInterval(timerInterval);
clearInterval(chunkTimer);
page.recording = false;
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 < 8000) return;
if (transcribing) return;
chunkId++;
const currentChunkId = chunkId;
const samples = [...pcmBuffer];
pcmBuffer = [];
if (samples.length > 4_800_000) {
samples.length = 4_800_000;
}
transcribing = true;
try {
let initialPrompt = "";
if (page.activeProfile && page.activeProfile !== "None") {
initialPrompt = page.activeProfile;
}
if (settings.engine === "parakeet") {
await invoke("transcribe_pcm_parakeet", {
samples,
chunkId: currentChunkId,
removeFillers: settings.removeFillers,
britishEnglish: settings.britishEnglish,
antiHallucination: settings.antiHallucination,
});
} else {
await invoke("transcribe_pcm", {
samples,
chunkId: currentChunkId,
language: settings.language,
initialPrompt,
removeFillers: settings.removeFillers,
britishEnglish: settings.britishEnglish,
antiHallucination: settings.antiHallucination,
formatMode: settings.formatMode,
});
}
} catch (err) {
console.error("transcribe_pcm failed:", err);
error = typeof err === "string" ? err : err.message || "Transcription failed";
if (!page.recording) {
page.status = "Error";
page.statusColor = "#e87171";
}
} finally {
transcribing = false;
}
}
async function finaliseTranscription() {
if (transcript.trim()) {
if (settings.autoCopy) {
navigator.clipboard.writeText(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,
date: new Date().toLocaleString("en-GB"),
source: "live",
preview: transcript.slice(0, 120),
text: transcript,
segments: segments,
duration: (Date.now() - startTime) / 1000,
language: settings.language,
template: activeTemplate || undefined,
audioPath,
});
// Extract tasks from transcript
const extracted = extractTasks(transcript);
extractedCount = extracted.length;
for (const item of extracted) {
addTask({
text: item.text,
bucket: "inbox",
sourceTranscriptId: historyId,
});
}
// Auto-open task sidebar if tasks were extracted
if (extracted.length > 0) {
page.taskSidebarOpen = true;
}
saved = true;
setTimeout(() => { saved = false; extractedCount = 0; }, 4000);
}
page.status = "Ready";
page.statusColor = "#7ec89a";
insertPos = -1;
}
function updateTimer() {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const mins = Math.floor(elapsed / 60).toString().padStart(2, "0");
const secs = (elapsed % 60).toString().padStart(2, "0");
page.timerText = `${mins}:${secs}`;
}
function copyAll() {
if (transcript) navigator.clipboard.writeText(transcript).catch(() => {});
}
function clearTranscript() {
transcript = "";
segments = [];
page.timerText = "00:00";
error = "";
saved = false;
insertPos = -1;
activeTemplate = "";
}
function handleExport(format) {
if (!transcript) return;
showExportMenu = false;
const content = exportTranscript(transcript, segments, format);
const ext = format === "vtt" ? "vtt" : format === "srt" ? "srt" : format === "md" ? "md" : "txt";
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `ramble-${new Date().toISOString().slice(0, 10)}.${ext}`;
a.click();
URL.revokeObjectURL(url);
}
function applyTemplate(template) {
activeTemplate = template.name;
showTemplateMenu = false;
transcript = template.sections.map((s) => `## ${s}\n\n`).join("\n");
segments = [];
// Position cursor at first section body
requestAnimationFrame(() => {
if (textareaEl) {
const firstNewline = transcript.indexOf("\n\n") + 2;
textareaEl.selectionStart = firstNewline;
textareaEl.selectionEnd = firstNewline;
textareaEl.focus();
}
});
}
function manualExtractTasks() {
if (!transcript.trim() || aiProcessing) return;
aiProcessing = true;
aiStatus = "Extracting tasks...";
try {
const extracted = extractTasks(transcript);
for (const item of extracted) {
addTask({ text: item.text, bucket: "inbox" });
}
aiStatus = `${extracted.length} task${extracted.length === 1 ? '' : 's'} extracted`;
} catch (err) {
aiStatus = typeof err === "string" ? err : "Extraction failed";
} finally {
aiProcessing = false;
setTimeout(() => { aiStatus = ""; }, 4000);
}
}
function saveTypedText() {
if (!transcript.trim()) return;
addToHistory({
id: crypto.randomUUID(),
date: new Date().toLocaleString("en-GB"),
source: "typed",
preview: transcript.slice(0, 120),
text: transcript,
segments: [],
duration: 0,
language: settings.language,
});
saved = true;
setTimeout(() => { saved = false; }, 3000);
}
let taskCount = $derived(tasks.filter((t) => !t.done).length);
let wordCount = $derived(
transcript.trim() ? transcript.trim().split(/\s+/).length : 0
);
</script>
<div class="flex flex-col h-full animate-fade-in">
{#if needsDownload}
<ModelDownloader modelSize={settings.modelSize.toLowerCase()} onComplete={onModelDownloaded} />
{:else}
<!-- Header -->
<div class="flex items-center gap-5 px-7 pt-6 pb-4">
<!-- Record button -->
<button
class="relative flex items-center justify-center w-[56px] h-[56px] min-w-[56px] flex-shrink-0 rounded-full text-white
active:scale-[0.93] transition-all duration-200
{page.recording
? '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)]'}"
onclick={toggleRecording}
disabled={modelLoading}
>
{#if page.recording}
<span class="w-[18px] h-[18px] rounded-[4px] bg-white"></span>
{:else if modelLoading}
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
</svg>
{:else}
<span class="w-[18px] h-[18px] rounded-full bg-white/90"></span>
{/if}
</button>
<!-- Timer + label -->
<div class="flex flex-col flex-shrink-0">
<span class="text-[28px] font-bold text-text tabular-nums tracking-tight leading-none" style="font-variant-numeric: tabular-nums;">
{page.timerText}
</span>
<span class="text-[11px] text-text-tertiary mt-1">
{#if page.recording}
<span class="inline-flex items-center gap-1.5">
<span class="w-[6px] h-[6px] rounded-full bg-danger animate-pulse-soft"></span>
<span class="font-medium text-danger tracking-wide">REC</span>
{#if insertPos >= 0}
<span class="text-text-tertiary ml-1">inserting at cursor</span>
{/if}
</span>
{:else if modelLoading}
<span class="text-warning">Loading Whisper model...</span>
{:else if saved}
<span class="text-success animate-fade-in">
Saved to history{#if extractedCount > 0} · {extractedCount} task{extractedCount === 1 ? '' : 's'} extracted{/if}
</span>
{:else}
Press record or <kbd class="px-1 py-0.5 rounded bg-bg-elevated text-[10px] text-text-tertiary border border-border-subtle">Ctrl+Shift+R</kbd>
{/if}
</span>
</div>
<div class="flex-1"></div>
<!-- Actions -->
<div class="flex gap-0.5 flex-shrink-0">
<!-- Template selector -->
{#if templates.length > 0}
<div class="relative">
<button
class="px-3 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
onclick={() => { showTemplateMenu = !showTemplateMenu; showExportMenu = false; }}
>Template</button>
{#if showTemplateMenu}
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[160px]">
{#each templates as template}
<button
class="w-full text-left px-3 py-1.5 text-[12px] text-text-secondary hover:bg-hover hover:text-text
{activeTemplate === template.name ? 'text-accent' : ''}"
onclick={() => applyTemplate(template)}
>{template.name}</button>
{/each}
</div>
{/if}
</div>
{/if}
<button
class="px-3 py-1.5 rounded-lg text-[12px] flex-shrink-0 whitespace-nowrap {aiProcessing ? 'text-warning' : 'text-accent hover:bg-accent/10 hover:text-accent font-medium'}"
onclick={manualExtractTasks}
disabled={aiProcessing || !transcript.trim()}
>{aiProcessing ? "Extracting..." : "Extract Tasks"}</button>
<span class="w-px h-4 bg-border-subtle flex-shrink-0"></span>
<button
class="px-3 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap
{!transcript.trim() ? 'opacity-40 cursor-default' : ''}"
onclick={saveTypedText}
disabled={!transcript.trim()}
>Save</button>
<button
class="px-3 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
onclick={copyAll}
>Copy</button>
<button
class="px-3 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text flex-shrink-0 whitespace-nowrap"
onclick={clearTranscript}
>Clear</button>
<div class="relative flex-shrink-0">
<button
class="px-3 py-1.5 rounded-lg text-[12px] text-text-secondary hover:bg-hover hover:text-text whitespace-nowrap"
onclick={() => { showExportMenu = !showExportMenu; showTemplateMenu = false; }}
>Export</button>
{#if showExportMenu}
<div class="absolute right-0 top-full mt-1 bg-bg-card border border-border rounded-lg shadow-lg py-1 z-10 animate-fade-in min-w-[120px]">
{#each [["txt", "Plain Text"], ["md", "Markdown"], ["csv", "CSV"], ["html", "HTML"], ["srt", "SRT Subtitles"], ["vtt", "WebVTT"]] as [fmt, label]}
<button
class="w-full text-left px-3 py-1.5 text-[12px] text-text-secondary hover:bg-hover hover:text-text"
onclick={() => handleExport(fmt)}
>{label}</button>
{/each}
</div>
{/if}
</div>
<span class="w-px h-4 bg-border-subtle flex-shrink-0"></span>
<!-- Task sidebar toggle -->
<button
class="relative px-2 py-1.5 rounded-lg flex-shrink-0
{page.taskSidebarOpen ? 'bg-accent/10' : 'hover:bg-hover'}"
onclick={() => page.taskSidebarOpen = !page.taskSidebarOpen}
aria-label="Toggle task sidebar"
>
<svg class="w-4 h-4 {page.taskSidebarOpen ? 'text-accent' : 'text-text-tertiary'}" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
{#if taskCount > 0}
<span class="absolute -top-0.5 -right-0.5 text-[9px] px-1 py-0 rounded-full bg-accent text-white font-medium min-w-[14px] text-center leading-[14px]">
{taskCount}
</span>
{/if}
</button>
</div>
</div>
<!-- Template indicator -->
{#if activeTemplate}
<div class="px-7 pb-2 animate-fade-in">
<div class="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-accent-subtle border border-accent/20">
<span class="text-[11px] text-accent font-medium">Template: {activeTemplate}</span>
<span class="text-[11px] text-text-tertiary">Click a section, then record to fill it</span>
<div class="flex-1"></div>
<button class="text-[11px] text-text-tertiary hover:text-text" onclick={() => activeTemplate = ""}>Remove</button>
</div>
</div>
{/if}
<!-- Error -->
{#if error}
<div class="px-7 pb-2 animate-fade-in">
<div class="px-4 py-2 rounded-lg bg-danger/10 border border-danger/20 text-[12px] text-danger">
{error}
</div>
</div>
{/if}
<!-- Transcript area -->
<div class="flex-1 px-7 pb-3 min-h-0">
<Card classes="h-full flex flex-col">
<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"
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
></textarea>
</Card>
</div>
<!-- Bottom bar -->
<div class="flex items-center px-8 pb-4">
<span class="text-[11px] text-text-tertiary">
{#if wordCount > 0}
{wordCount} {wordCount === 1 ? 'word' : 'words'}
{/if}
</span>
<div class="flex-1"></div>
{#if aiStatus}
<span class="text-[11px] text-accent animate-fade-in mr-3">{aiStatus}</span>
{/if}
<span class="text-[11px] text-text-tertiary">
{settings.formatMode} · {page.activeProfile === "None" ? "No profile" : page.activeProfile}
</span>
</div>
{/if}
</div>