Files
voice-to-notes/src/routes/+page.svelte
Claude 27b705b5b6 File-based project save/load, AI chat formatting, text edit fix
Project files (.vtn):
- Save Project: serializes transcript, speakers, audio path to JSON file
- Open Project: loads .vtn file, restores audio/transcript/speakers
- User chooses filename and location via save dialog
- Replaces SQLite-based project persistence (DB commands remain for future use)
- Text edits update in-memory store immediately, persist on explicit save
- Fix Windows path separator in project name extraction

AI chat:
- Markdown rendering in assistant messages (headers, lists, bold, code)
- Better visual distinction with border-left accents
- Styled markdown elements for dark theme

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 22:17:35 -07:00

683 lines
19 KiB
Svelte

<script lang="ts">
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open, save } from '@tauri-apps/plugin-dialog';
import WaveformPlayer from '$lib/components/WaveformPlayer.svelte';
import TranscriptEditor from '$lib/components/TranscriptEditor.svelte';
import SpeakerManager from '$lib/components/SpeakerManager.svelte';
import AIChatPanel from '$lib/components/AIChatPanel.svelte';
import ProgressOverlay from '$lib/components/ProgressOverlay.svelte';
import SettingsModal from '$lib/components/SettingsModal.svelte';
import { segments, speakers } from '$lib/stores/transcript';
import { settings, loadSettings } from '$lib/stores/settings';
import type { Segment, Speaker } from '$lib/types/transcript';
import { onMount, tick } from 'svelte';
let appReady = $state(false);
let waveformPlayer: WaveformPlayer;
let audioUrl = $state('');
let showSettings = $state(false);
// Project management state
let currentProjectPath = $state<string | null>(null);
let currentProjectName = $state('');
let audioFilePath = $state('');
onMount(() => {
loadSettings();
// Global keyboard shortcuts
function handleKeyDown(e: KeyboardEvent) {
// Don't trigger shortcuts when typing in inputs
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (e.key === ' ' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
waveformPlayer?.togglePlayPause?.();
} else if (e.key === 'o' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleFileImport();
} else if (e.key === ',' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
showSettings = true;
} else if (e.key === 'Escape') {
showExportMenu = false;
showSettings = false;
}
}
// Close export dropdown on outside click
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (showExportMenu) {
if (!target.closest('.export-dropdown')) {
showExportMenu = false;
}
}
}
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('click', handleClickOutside);
appReady = true;
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('click', handleClickOutside);
};
});
let isTranscribing = $state(false);
let transcriptionProgress = $state(0);
let transcriptionStage = $state('');
let transcriptionMessage = $state('');
// Speaker color palette for auto-assignment
const speakerColors = ['#e94560', '#4ecdc4', '#ffe66d', '#a8e6cf', '#ff8b94', '#c7ceea', '#ffd93d', '#6bcb77'];
async function saveProject() {
const defaultName = currentProjectName || 'Untitled';
const outputPath = await save({
defaultPath: `${defaultName}.vtn`,
filters: [{ name: 'Voice to Notes Project', extensions: ['vtn'] }],
});
if (!outputPath) return;
const projectData = {
version: 1,
name: outputPath.split(/[\\/]/).pop()?.replace('.vtn', '') || defaultName,
audio_file: audioFilePath,
created_at: new Date().toISOString(),
segments: $segments.map(seg => {
const speaker = $speakers.find(s => s.id === seg.speaker_id);
return {
text: seg.text,
start_ms: seg.start_ms,
end_ms: seg.end_ms,
speaker: speaker?.label ?? null,
is_edited: seg.is_edited,
words: seg.words.map(w => ({
word: w.word,
start_ms: w.start_ms,
end_ms: w.end_ms,
confidence: w.confidence ?? 0,
})),
};
}),
speakers: $speakers.map(s => ({
label: s.label,
display_name: s.display_name,
color: s.color || '#e94560',
})),
};
try {
await invoke('save_project_file', { path: outputPath, project: projectData });
currentProjectPath = outputPath;
currentProjectName = projectData.name;
} catch (err) {
console.error('Failed to save project:', err);
alert(`Failed to save: ${err}`);
}
}
async function openProject() {
const filePath = await open({
filters: [{ name: 'Voice to Notes Project', extensions: ['vtn'] }],
multiple: false,
});
if (!filePath) return;
try {
const project = await invoke<{
version: number;
name: string;
audio_file: string;
segments: Array<{
text: string;
start_ms: number;
end_ms: number;
speaker: string | null;
is_edited: boolean;
words: Array<{ word: string; start_ms: number; end_ms: number; confidence: number }>;
}>;
speakers: Array<{ label: string; display_name: string | null; color: string }>;
}>('load_project_file', { path: filePath });
// Rebuild speakers
const newSpeakers: Speaker[] = project.speakers.map((s, idx) => ({
id: `speaker-${idx}`,
project_id: '',
label: s.label,
display_name: s.display_name,
color: s.color,
}));
speakers.set(newSpeakers);
const speakerLookup = new Map(newSpeakers.map(s => [s.label, s.id]));
// Rebuild segments
const newSegments: Segment[] = project.segments.map((seg, idx) => ({
id: `seg-${idx}`,
project_id: '',
media_file_id: '',
speaker_id: seg.speaker ? (speakerLookup.get(seg.speaker) ?? null) : null,
start_ms: seg.start_ms,
end_ms: seg.end_ms,
text: seg.text,
original_text: null,
confidence: null,
is_edited: seg.is_edited,
edited_at: null,
segment_index: idx,
words: seg.words.map((w, widx) => ({
id: `word-${idx}-${widx}`,
segment_id: `seg-${idx}`,
word: w.word,
start_ms: w.start_ms,
end_ms: w.end_ms,
confidence: w.confidence,
word_index: widx,
})),
}));
segments.set(newSegments);
// Load audio
audioFilePath = project.audio_file;
audioUrl = convertFileSrc(project.audio_file);
waveformPlayer?.loadAudio(audioUrl);
currentProjectPath = filePath as string;
currentProjectName = project.name;
} catch (err) {
console.error('Failed to load project:', err);
alert(`Failed to load project: ${err}`);
}
}
function handleWordClick(timeMs: number) {
console.log('[voice-to-notes] Word clicked, seeking to', timeMs, 'ms');
waveformPlayer?.seekTo(timeMs);
}
function handleTextEdit(segmentId: string, newText: string) {
// In-memory store is already updated by TranscriptEditor.
// Changes persist when user saves the project file.
}
async function handleFileImport() {
const filePath = await open({
multiple: false,
filters: [{
name: 'Audio/Video',
extensions: ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'wma',
'mp4', 'mkv', 'avi', 'mov', 'webm'],
}],
});
if (!filePath) return;
// Track the original file path and convert to asset URL for wavesurfer
audioFilePath = filePath;
audioUrl = convertFileSrc(filePath);
waveformPlayer?.loadAudio(audioUrl);
// Clear previous results
segments.set([]);
speakers.set([]);
// Start pipeline (transcription + diarization)
isTranscribing = true;
transcriptionProgress = 0;
transcriptionStage = 'Starting...';
transcriptionMessage = 'Initializing pipeline...';
// Flush DOM so the progress overlay renders before the blocking invoke
await tick();
// Listen for progress events from the sidecar
const unlisten = await listen<{
percent: number;
stage: string;
message: string;
}>('pipeline-progress', (event) => {
console.log('[voice-to-notes] Progress event:', event.payload);
const { percent, stage, message } = event.payload;
if (typeof percent === 'number') transcriptionProgress = percent;
if (typeof stage === 'string') transcriptionStage = stage;
if (typeof message === 'string') transcriptionMessage = message;
});
const unlistenSegment = await listen<{
index: number;
text: string;
start_ms: number;
end_ms: number;
words: Array<{ word: string; start_ms: number; end_ms: number; confidence: number }>;
}>('pipeline-segment', (event) => {
const seg = event.payload;
const newSeg: Segment = {
id: `seg-${seg.index}`,
project_id: '',
media_file_id: '',
speaker_id: null,
start_ms: seg.start_ms,
end_ms: seg.end_ms,
text: seg.text,
original_text: null,
confidence: null,
is_edited: false,
edited_at: null,
segment_index: seg.index,
words: seg.words.map((w, widx) => ({
id: `word-${seg.index}-${widx}`,
segment_id: `seg-${seg.index}`,
word: w.word,
start_ms: w.start_ms,
end_ms: w.end_ms,
confidence: w.confidence,
word_index: widx,
})),
};
segments.update(segs => [...segs, newSeg]);
});
const unlistenSpeaker = await listen<{
updates: Array<{ index: number; speaker: string }>;
}>('pipeline-speaker-update', (event) => {
const { updates } = event.payload;
// Build speakers from unique labels
const uniqueLabels = [...new Set(updates.map(u => u.speaker))].sort();
const newSpeakers: Speaker[] = uniqueLabels.map((label, idx) => ({
id: `speaker-${idx}`,
project_id: '',
label,
display_name: null,
color: speakerColors[idx % speakerColors.length],
}));
speakers.set(newSpeakers);
// Update existing segments with speaker assignments
const speakerLookup = new Map(newSpeakers.map(s => [s.label, s.id]));
segments.update(segs =>
segs.map((seg, i) => {
const update = updates.find(u => u.index === i);
if (update) {
return { ...seg, speaker_id: speakerLookup.get(update.speaker) ?? null };
}
return seg;
})
);
});
try {
const result = await invoke<{
segments: Array<{
text: string;
start_ms: number;
end_ms: number;
speaker: string | null;
words: Array<{
word: string;
start_ms: number;
end_ms: number;
confidence: number;
}>;
}>;
language: string;
duration_ms: number;
speakers: string[];
num_speakers: number;
}>('run_pipeline', {
filePath,
model: $settings.transcription_model || undefined,
device: $settings.transcription_device || undefined,
language: $settings.transcription_language || undefined,
skipDiarization: $settings.skip_diarization || undefined,
hfToken: $settings.hf_token || undefined,
numSpeakers: $settings.num_speakers && $settings.num_speakers > 0 ? $settings.num_speakers : undefined,
});
// Create speaker entries from pipeline result
const newSpeakers: Speaker[] = (result.speakers || []).map((label, idx) => ({
id: `speaker-${idx}`,
project_id: '',
label,
display_name: null,
color: speakerColors[idx % speakerColors.length],
}));
speakers.set(newSpeakers);
// Build speaker label → id lookup
const speakerLookup = new Map(newSpeakers.map(s => [s.label, s.id]));
// Convert result to our store format
const newSegments: Segment[] = result.segments.map((seg, idx) => ({
id: `seg-${idx}`,
project_id: '',
media_file_id: '',
speaker_id: seg.speaker ? (speakerLookup.get(seg.speaker) ?? null) : null,
start_ms: seg.start_ms,
end_ms: seg.end_ms,
text: seg.text,
original_text: null,
confidence: null,
is_edited: false,
edited_at: null,
segment_index: idx,
words: seg.words.map((w, widx) => ({
id: `word-${idx}-${widx}`,
segment_id: `seg-${idx}`,
word: w.word,
start_ms: w.start_ms,
end_ms: w.end_ms,
confidence: w.confidence,
word_index: widx,
})),
}));
segments.set(newSegments);
// Set project name from audio file name (user can save explicitly)
const fileName = filePath.split(/[\\/]/).pop() || 'Untitled';
currentProjectName = fileName.replace(/\.[^.]+$/, '');
currentProjectPath = null;
} catch (err) {
console.error('Pipeline failed:', err);
alert(`Pipeline failed: ${err}`);
} finally {
unlisten();
unlistenSegment();
unlistenSpeaker();
isTranscribing = false;
}
}
const exportFormats = [
{ name: 'SubRip Subtitle', ext: 'srt', format: 'srt' },
{ name: 'WebVTT', ext: 'vtt', format: 'vtt' },
{ name: 'Advanced SubStation Alpha', ext: 'ass', format: 'ass' },
{ name: 'Plain Text', ext: 'txt', format: 'txt' },
{ name: 'Markdown', ext: 'md', format: 'md' },
];
let showExportMenu = $state(false);
async function handleExport(format: string, ext: string, filterName: string) {
showExportMenu = false;
const outputPath = await save({
filters: [{ name: filterName, extensions: [ext] }],
});
if (!outputPath) return;
// Build speaker lookup: speaker_id → display_name
const speakerMap: Record<string, string> = {};
for (const s of $speakers) {
speakerMap[s.label] = s.display_name || s.label;
}
// Build export segments from store
const exportSegments = $segments.map(seg => {
const speaker = $speakers.find(s => s.id === seg.speaker_id);
return {
text: seg.text,
start_ms: seg.start_ms,
end_ms: seg.end_ms,
speaker: speaker?.label ?? null,
};
});
try {
await invoke('export_transcript', {
segments: exportSegments,
speakers: speakerMap,
format,
outputPath,
title: 'Voice to Notes Transcript',
});
alert(`Exported to ${outputPath}`);
} catch (err) {
console.error('Export failed:', err);
alert(`Export failed: ${err}`);
}
}
</script>
{#if !appReady}
<div class="splash-screen">
<h1 class="splash-title">Voice to Notes</h1>
<p class="splash-subtitle">Loading...</p>
<div class="splash-spinner"></div>
</div>
{:else}
<div class="app-shell">
<div class="app-header">
<h1>Voice to Notes</h1>
<div class="header-actions">
<button class="settings-btn" onclick={openProject} disabled={isTranscribing}>
Open Project
</button>
{#if $segments.length > 0}
<button class="settings-btn" onclick={saveProject}>
Save Project
</button>
{/if}
<button class="import-btn" onclick={handleFileImport} disabled={isTranscribing}>
{#if isTranscribing}
Processing...
{:else}
Import Audio/Video
{/if}
</button>
<button class="settings-btn" onclick={() => showSettings = true} title="Settings">
Settings
</button>
{#if $segments.length > 0}
<div class="export-dropdown">
<button class="export-btn" onclick={() => showExportMenu = !showExportMenu}>
Export
</button>
{#if showExportMenu}
<div class="export-menu">
{#each exportFormats as fmt}
<button class="export-option" onclick={() => handleExport(fmt.format, fmt.ext, fmt.name)}>
{fmt.name} (.{fmt.ext})
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</div>
<div class="workspace">
<div class="main-content">
<WaveformPlayer bind:this={waveformPlayer} {audioUrl} />
<TranscriptEditor onWordClick={handleWordClick} onTextEdit={handleTextEdit} />
</div>
<div class="sidebar-right">
<SpeakerManager />
<AIChatPanel />
</div>
</div>
</div>
<ProgressOverlay
visible={isTranscribing}
percent={transcriptionProgress}
stage={transcriptionStage}
message={transcriptionMessage}
/>
<SettingsModal
visible={showSettings}
onClose={() => showSettings = false}
/>
{/if}
<style>
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: #0f3460;
color: #e0e0e0;
}
h1 {
font-size: 1.25rem;
margin: 0;
}
.import-btn {
background: #e94560;
border: none;
color: white;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
.import-btn:hover:not(:disabled) {
background: #d63851;
}
.import-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
.header-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.settings-btn {
background: none;
border: 1px solid #4a5568;
color: #e0e0e0;
padding: 0.5rem 0.75rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
.settings-btn:hover:not(:disabled) {
background: rgba(255,255,255,0.05);
border-color: #e94560;
}
.settings-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.export-dropdown {
position: relative;
}
.export-btn {
background: #0f3460;
border: 1px solid #4a5568;
color: #e0e0e0;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
.export-btn:hover {
background: #1a4a7a;
}
.export-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.25rem;
background: #16213e;
border: 1px solid #4a5568;
border-radius: 6px;
overflow: hidden;
z-index: 10;
min-width: 220px;
}
.export-option {
display: block;
width: 100%;
background: none;
border: none;
color: #e0e0e0;
padding: 0.5rem 1rem;
text-align: left;
cursor: pointer;
font-size: 0.8rem;
}
.export-option:hover {
background: rgba(233, 69, 96, 0.2);
}
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.workspace {
display: flex;
gap: 1rem;
padding: 1rem;
flex: 1;
min-height: 0;
overflow: hidden;
background: #0a0a23;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
min-height: 0;
overflow-y: auto;
}
.sidebar-right {
width: 300px;
display: flex;
flex-direction: column;
gap: 1rem;
flex-shrink: 0;
min-height: 0;
overflow-y: auto;
}
.splash-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: #0a0a23;
color: #e0e0e0;
gap: 1rem;
}
.splash-title {
font-size: 2rem;
margin: 0;
color: #e94560;
}
.splash-subtitle {
font-size: 1rem;
color: #888;
margin: 0;
}
.splash-spinner {
width: 32px;
height: 32px;
border: 3px solid #2a3a5e;
border-top-color: #e94560;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>