- PyInstaller frozen sidecar: spec file, build script, and ffmpeg path resolver for self-contained distribution without Python prerequisites - Dual-mode sidecar launcher: frozen binary (production) with dev mode fallback - Parallel transcription + diarization pipeline (~30-40% faster) - GPU auto-detection for diarization (CUDA when available) - Async run_pipeline command for real-time progress event delivery - Web Audio API backend for instant playback and seeking - OpenAI-compatible provider replacing LiteLLM client-side routing - Cross-platform RAM detection (Linux/macOS/Windows) - Settings: speaker count hint, token reveal toggles, dark dropdown styling - Loading splash screen, flexbox layout fix for viewport overflow - Gitea Actions CI/CD pipeline (Linux, Windows, macOS ARM) - Updated README and CLAUDE.md documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
167 lines
4.1 KiB
Svelte
167 lines
4.1 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import WaveSurfer from 'wavesurfer.js';
|
|
import { isPlaying, currentTimeMs, durationMs } from '$lib/stores/playback';
|
|
|
|
interface Props {
|
|
audioUrl?: string;
|
|
onSeek?: (timeMs: number) => void;
|
|
}
|
|
|
|
let { audioUrl = '', onSeek }: Props = $props();
|
|
|
|
let container: HTMLDivElement;
|
|
let wavesurfer: WaveSurfer | null = $state(null);
|
|
let isReady = $state(false);
|
|
let isLoading = $state(false);
|
|
let currentTime = $state('0:00');
|
|
let totalTime = $state('0:00');
|
|
|
|
function formatTime(seconds: number): string {
|
|
const m = Math.floor(seconds / 60);
|
|
const s = Math.floor(seconds % 60);
|
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
onMount(() => {
|
|
wavesurfer = WaveSurfer.create({
|
|
container,
|
|
waveColor: '#4a5568',
|
|
progressColor: '#e94560',
|
|
cursorColor: '#e94560',
|
|
height: 80,
|
|
barWidth: 2,
|
|
barGap: 1,
|
|
barRadius: 2,
|
|
backend: 'WebAudio',
|
|
});
|
|
|
|
wavesurfer.on('timeupdate', (time: number) => {
|
|
currentTimeMs.set(Math.round(time * 1000));
|
|
currentTime = formatTime(time);
|
|
});
|
|
|
|
wavesurfer.on('ready', () => {
|
|
isReady = true;
|
|
isLoading = false;
|
|
const dur = wavesurfer!.getDuration();
|
|
durationMs.set(Math.round(dur * 1000));
|
|
totalTime = formatTime(dur);
|
|
});
|
|
|
|
wavesurfer.on('play', () => isPlaying.set(true));
|
|
wavesurfer.on('pause', () => isPlaying.set(false));
|
|
wavesurfer.on('finish', () => isPlaying.set(false));
|
|
|
|
wavesurfer.on('loading', () => {
|
|
isReady = false;
|
|
});
|
|
|
|
if (audioUrl) {
|
|
loadAudio(audioUrl);
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
wavesurfer?.destroy();
|
|
});
|
|
|
|
/** Toggle play/pause from current position. Exposed for keyboard shortcuts. */
|
|
export function togglePlayPause() {
|
|
if (!wavesurfer || !isReady) return;
|
|
wavesurfer.playPause();
|
|
}
|
|
|
|
function skipBack() {
|
|
if (wavesurfer && isReady) {
|
|
const time = Math.max(0, wavesurfer.getCurrentTime() - 5);
|
|
wavesurfer.setTime(time);
|
|
}
|
|
}
|
|
|
|
function skipForward() {
|
|
if (wavesurfer && isReady) {
|
|
const time = Math.min(wavesurfer.getDuration(), wavesurfer.getCurrentTime() + 5);
|
|
wavesurfer.setTime(time);
|
|
}
|
|
}
|
|
|
|
/** Seek to a specific time in milliseconds. Called from transcript click-to-seek. */
|
|
export function seekTo(timeMs: number) {
|
|
if (!wavesurfer || !isReady) {
|
|
console.warn('[voice-to-notes] seekTo ignored — audio not ready yet');
|
|
return;
|
|
}
|
|
wavesurfer.setTime(timeMs / 1000);
|
|
}
|
|
|
|
/** Load a new audio file. */
|
|
export function loadAudio(url: string) {
|
|
isReady = false;
|
|
isLoading = true;
|
|
wavesurfer?.load(url);
|
|
}
|
|
</script>
|
|
|
|
<div class="waveform-player">
|
|
<div class="waveform-container" bind:this={container}></div>
|
|
<div class="controls">
|
|
<button class="control-btn" onclick={skipBack} title="Back 5s" disabled={!isReady}>⏪</button>
|
|
<button class="control-btn play-btn" onclick={togglePlayPause} title="Play/Pause" disabled={!isReady}>
|
|
{#if !isReady}
|
|
⏳
|
|
{:else if $isPlaying}
|
|
⏸
|
|
{:else}
|
|
▶
|
|
{/if}
|
|
</button>
|
|
<button class="control-btn" onclick={skipForward} title="Forward 5s" disabled={!isReady}>⏩</button>
|
|
<span class="time">{currentTime} / {totalTime}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.waveform-player {
|
|
background: #1a1a2e;
|
|
border-radius: 8px;
|
|
padding: 0.75rem;
|
|
}
|
|
.waveform-container {
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
.controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
.control-btn {
|
|
background: #0f3460;
|
|
border: none;
|
|
color: #e0e0e0;
|
|
padding: 0.4rem 0.8rem;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
}
|
|
.control-btn:hover:not(:disabled) {
|
|
background: #1a4a7a;
|
|
}
|
|
.control-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
.play-btn {
|
|
padding: 0.4rem 1rem;
|
|
font-size: 1.2rem;
|
|
}
|
|
.time {
|
|
color: #999;
|
|
font-size: 0.875rem;
|
|
margin-left: auto;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
</style>
|