Tonemark is a SvelteKit PWA for transcribing YouTube videos, audio and video files, and microphone recordings using a local Whisper backend. Features: - Dark glassmorphic UI with electric-lime accent (5 switchable themes) - Rail nav (desktop) / tab bar (mobile) layout - Drop zone, YouTube URL input, and live audio recording inputs - Audio mode waveform cards (none / standard / aggressive / auto) - Real-time transcription progress with animated waveform - Job queue with SSE streaming updates - Push notifications on job completion - PWA with native SvelteKit service worker - SRT / TXT / MD / JSON transcript downloads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type AccentColor = {
|
||||
value: string;
|
||||
label: string;
|
||||
/** RGB for glow/radial gradient — e.g. "205,242,78" */
|
||||
rgb: string;
|
||||
};
|
||||
|
||||
export const ACCENT_OPTIONS: AccentColor[] = [
|
||||
{ value: '#cdf24e', label: 'Lime', rgb: '205,242,78' },
|
||||
{ value: '#7cd4ff', label: 'Cyan', rgb: '124,212,255' },
|
||||
{ value: '#ff8a5c', label: 'Coral', rgb: '255,138,92' },
|
||||
{ value: '#c8a8ff', label: 'Lavender', rgb: '200,168,255' },
|
||||
{ value: '#ffd86b', label: 'Amber', rgb: '255,216,107' },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'tonemark-accent';
|
||||
const DEFAULT = ACCENT_OPTIONS[0];
|
||||
|
||||
function getInitial(): AccentColor {
|
||||
if (!browser) return DEFAULT;
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return ACCENT_OPTIONS.find((a) => a.value === stored) ?? DEFAULT;
|
||||
}
|
||||
|
||||
export const accent = writable<AccentColor>(getInitial());
|
||||
|
||||
accent.subscribe((a) => {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(STORAGE_KEY, a.value);
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--accent', a.value);
|
||||
root.style.setProperty('--accent-rgb', a.rgb);
|
||||
// Update radial glow on body
|
||||
root.style.setProperty('--accent-glow', `rgba(${a.rgb},0.06)`);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#cdf24e"/>
|
||||
<rect x="6" y="8" width="4" height="16" rx="2" fill="#0c0d10"/>
|
||||
<rect x="14" y="12" width="4" height="8" rx="2" fill="#0c0d10"/>
|
||||
<rect x="22" y="6" width="4" height="20" rx="2" fill="#0c0d10"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 345 B |
@@ -0,0 +1,296 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* RecordButton — microphone recorder with live waveform.
|
||||
* Captures audio via MediaRecorder, shows a live AnalyserNode waveform.
|
||||
* Emits `recorded` event with the audio Blob when stopped.
|
||||
*/
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
accent?: string;
|
||||
audioMode?: string;
|
||||
ondone?: (blob: Blob, filename: string) => void;
|
||||
}
|
||||
|
||||
let { accent = '#cdf24e', audioMode = 'auto', ondone }: Props = $props();
|
||||
|
||||
type RecordState = 'idle' | 'requesting' | 'recording' | 'stopping';
|
||||
|
||||
let state = $state<RecordState>('idle');
|
||||
let error = $state('');
|
||||
let elapsed = $state(0); // seconds
|
||||
let liveData = $state<Float32Array | null>(null);
|
||||
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let chunks: Blob[] = [];
|
||||
let stream: MediaStream | null = null;
|
||||
let audioCtx: AudioContext | null = null;
|
||||
let analyser: AnalyserNode | null = null;
|
||||
let animFrame: number | null = null;
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function formatTime(s: number): string {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function startAnalyser(src: MediaStream) {
|
||||
audioCtx = new AudioContext();
|
||||
analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 128;
|
||||
analyser.smoothingTimeConstant = 0.7;
|
||||
audioCtx.createMediaStreamSource(src).connect(analyser);
|
||||
|
||||
const buf = new Float32Array(analyser.frequencyBinCount);
|
||||
|
||||
function tick() {
|
||||
if (!analyser) return;
|
||||
analyser.getFloatFrequencyData(buf);
|
||||
// Normalise dB range [-100, 0] → [0, 1]
|
||||
const norm = new Float32Array(buf.length);
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
norm[i] = Math.max(0, Math.min(1, (buf[i] + 100) / 100));
|
||||
}
|
||||
liveData = norm;
|
||||
animFrame = requestAnimationFrame(tick);
|
||||
}
|
||||
tick();
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
error = '';
|
||||
state = 'requesting';
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch {
|
||||
error = 'Microphone access denied';
|
||||
state = 'idle';
|
||||
return;
|
||||
}
|
||||
|
||||
chunks = [];
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunks.push(e.data);
|
||||
};
|
||||
mediaRecorder.onstop = () => finalize();
|
||||
mediaRecorder.start(100);
|
||||
|
||||
startAnalyser(stream);
|
||||
|
||||
elapsed = 0;
|
||||
timerInterval = setInterval(() => elapsed++, 1000);
|
||||
state = 'recording';
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
state = 'stopping';
|
||||
mediaRecorder?.stop();
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
if (animFrame) cancelAnimationFrame(animFrame);
|
||||
stream?.getTracks().forEach((t) => t.stop());
|
||||
audioCtx?.close();
|
||||
liveData = null;
|
||||
}
|
||||
|
||||
function finalize() {
|
||||
const mime = mediaRecorder?.mimeType ?? 'audio/webm';
|
||||
const ext = mime.includes('ogg') ? 'ogg' : mime.includes('mp4') ? 'mp4' : 'webm';
|
||||
const blob = new Blob(chunks, { type: mime });
|
||||
const filename = `recording-${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.${ext}`;
|
||||
state = 'idle';
|
||||
ondone?.(blob, filename);
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
if (animFrame) cancelAnimationFrame(animFrame);
|
||||
stream?.getTracks().forEach((t) => t.stop());
|
||||
audioCtx?.close();
|
||||
});
|
||||
|
||||
// Static waveform heights for idle state display
|
||||
const IDLE_BARS = 48;
|
||||
const idleHeights = Array.from(
|
||||
{ length: IDLE_BARS },
|
||||
(_, i) => 3 + Math.abs(Math.sin(i * 0.7) + Math.cos(i * 0.31)) * 20
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="recorder">
|
||||
<!-- Waveform display -->
|
||||
<div class="waveform-area" aria-hidden="true">
|
||||
{#if state === 'recording' && liveData}
|
||||
<!-- Live waveform from AnalyserNode -->
|
||||
<svg viewBox="0 0 {IDLE_BARS * 5} 28" preserveAspectRatio="none" class="waveform-svg">
|
||||
{#each Array.from(liveData).slice(0, IDLE_BARS) as v, i}
|
||||
{@const h = 2 + v * 24}
|
||||
<rect
|
||||
x={i * 5}
|
||||
y={(28 - h) / 2}
|
||||
width="3"
|
||||
height={h}
|
||||
rx="1.5"
|
||||
fill={accent}
|
||||
opacity={0.5 + v * 0.5}
|
||||
/>
|
||||
{/each}
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Static idle waveform -->
|
||||
<svg viewBox="0 0 {IDLE_BARS * 5} 28" preserveAspectRatio="none" class="waveform-svg">
|
||||
{#each idleHeights as h, i}
|
||||
<rect
|
||||
x={i * 5}
|
||||
y={(28 - h) / 2}
|
||||
width="3"
|
||||
height={h}
|
||||
rx="1.5"
|
||||
fill={state === 'idle' ? 'rgba(255,255,255,0.15)' : accent}
|
||||
opacity={state === 'idle' ? 1 : 0.3}
|
||||
/>
|
||||
{/each}
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Timer (recording only) -->
|
||||
{#if state === 'recording'}
|
||||
<div class="timer" style="color: {accent}">
|
||||
<span class="rec-dot" style="background: {accent}"></span>
|
||||
{formatTime(elapsed)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error -->
|
||||
{#if error}
|
||||
<p class="rec-error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="btn-row">
|
||||
{#if state === 'idle' || state === 'requesting'}
|
||||
<button
|
||||
class="btn-record"
|
||||
style="background: {accent}; color: #0c0d10;"
|
||||
onclick={startRecording}
|
||||
disabled={state === 'requesting'}
|
||||
aria-label="Start recording"
|
||||
>
|
||||
{#if state === 'requesting'}
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" style="animation: spin 1s linear infinite">
|
||||
<circle cx="6.5" cy="6.5" r="5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-dasharray="20 12"/>
|
||||
</svg>
|
||||
Requesting…
|
||||
{:else}
|
||||
<svg width="13" height="13" viewBox="0 0 13 13">
|
||||
<circle cx="6.5" cy="6.5" r="3" fill="currentColor" />
|
||||
</svg>
|
||||
Record
|
||||
{/if}
|
||||
</button>
|
||||
{:else if state === 'recording'}
|
||||
<button
|
||||
class="btn-stop"
|
||||
onclick={stopRecording}
|
||||
aria-label="Stop recording and submit"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect x="2" y="2" width="8" height="8" rx="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
Stop & Transcribe
|
||||
</button>
|
||||
{:else}
|
||||
<div class="btn-record" style="background: rgba(255,255,255,0.06); color: var(--text-muted);">
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" style="animation: spin 1s linear infinite">
|
||||
<circle cx="6.5" cy="6.5" r="5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-dasharray="20 12"/>
|
||||
</svg>
|
||||
Saving…
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.recorder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.waveform-area {
|
||||
height: 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.waveform-svg {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.timer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.rec-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.2s infinite;
|
||||
}
|
||||
|
||||
.rec-error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #ff8a8a;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-record,
|
||||
.btn-stop {
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, filter 0.15s;
|
||||
}
|
||||
|
||||
.btn-record:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.btn-record:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-stop {
|
||||
background: rgba(255, 138, 138, 0.15);
|
||||
border: 1px solid rgba(255, 138, 138, 0.3);
|
||||
color: #ff8a8a;
|
||||
}
|
||||
.btn-stop:hover {
|
||||
background: rgba(255, 138, 138, 0.22);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
kind: 'youtube' | 'audio' | 'video' | 'mic' | 'file';
|
||||
size?: number;
|
||||
accent?: string;
|
||||
}
|
||||
|
||||
let { kind, size = 36, accent = '#cdf24e' }: Props = $props();
|
||||
|
||||
const tint: Record<string, string> = $derived({
|
||||
youtube: '#ff5e5e',
|
||||
audio: accent,
|
||||
video: '#7aa2ff',
|
||||
mic: accent,
|
||||
file: '#c5c8d3'
|
||||
});
|
||||
|
||||
const color = $derived(tint[kind] ?? '#c5c8d3');
|
||||
const r = $derived(size * 0.3);
|
||||
</script>
|
||||
|
||||
<div
|
||||
style="
|
||||
width: {size}px; height: {size}px;
|
||||
border-radius: {r}px;
|
||||
background: color-mix(in oklab, {color} 12%, transparent);
|
||||
border: 1px solid color-mix(in oklab, {color} 25%, transparent);
|
||||
color: {color};
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
"
|
||||
>
|
||||
{#if kind === 'youtube'}
|
||||
<svg width={size * 0.55} height={size * 0.55} viewBox="0 0 16 16" fill="none">
|
||||
<path d="M2 4.5C2 3.4 2.9 2.5 4 2.5H12C13.1 2.5 14 3.4 14 4.5V11.5C14 12.6 13.1 13.5 12 13.5H4C2.9 13.5 2 12.6 2 11.5V4.5Z"
|
||||
stroke="currentColor" stroke-width="1.3"/>
|
||||
<path d="M7 6L10.5 8L7 10V6Z" fill="currentColor"/>
|
||||
</svg>
|
||||
{:else if kind === 'audio'}
|
||||
<svg width={size * 0.55} height={size * 0.55} viewBox="0 0 16 16" fill="none">
|
||||
<rect x="3" y="6" width="1.5" height="4" rx="0.7" fill="currentColor"/>
|
||||
<rect x="5.5" y="4" width="1.5" height="8" rx="0.7" fill="currentColor"/>
|
||||
<rect x="8" y="2" width="1.5" height="12" rx="0.7" fill="currentColor"/>
|
||||
<rect x="10.5" y="5" width="1.5" height="6" rx="0.7" fill="currentColor"/>
|
||||
</svg>
|
||||
{:else if kind === 'video'}
|
||||
<svg width={size * 0.55} height={size * 0.55} viewBox="0 0 16 16" fill="none">
|
||||
<rect x="2" y="4" width="9" height="8" rx="1.5" stroke="currentColor" stroke-width="1.3"/>
|
||||
<path d="M11 7L14 5V11L11 9V7Z" fill="currentColor"/>
|
||||
</svg>
|
||||
{:else if kind === 'mic'}
|
||||
<svg width={size * 0.55} height={size * 0.55} viewBox="0 0 16 16" fill="none">
|
||||
<rect x="5.5" y="1.5" width="5" height="7" rx="2.5" stroke="currentColor" stroke-width="1.3"/>
|
||||
<path d="M3 7.5C3 10.5 5 12.5 8 12.5s5-2 5-5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
<line x1="8" y1="12.5" x2="8" y2="14.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width={size * 0.55} height={size * 0.55} viewBox="0 0 16 16" fill="none">
|
||||
<path d="M4 2H10L13 5V13C13 13.55 12.55 14 12 14H4C3.45 14 3 13.55 3 13V3C3 2.45 3.45 2 4 2Z"
|
||||
stroke="currentColor" stroke-width="1.3"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Waveform — decorative or progress-indicating bar chart.
|
||||
*
|
||||
* progress: 0–100. Bars up to that point are colored `accent`; the rest are dim.
|
||||
* pattern: controls bar height distribution per audio processing mode.
|
||||
* liveData: if provided, overrides the static pattern with real analyser data (Float32Array 0–1).
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
bars?: number;
|
||||
progress?: number;
|
||||
accent?: string;
|
||||
height?: number;
|
||||
pattern?: 'default' | 'flat' | 'medium' | 'aggressive' | 'auto';
|
||||
liveData?: Float32Array | null;
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
bars = 80,
|
||||
progress = 0,
|
||||
accent = '#cdf24e',
|
||||
height = 40,
|
||||
pattern = 'default',
|
||||
liveData = null,
|
||||
gap = 1.5
|
||||
}: Props = $props();
|
||||
|
||||
// Deterministic bar height from index — no Math.random() to avoid hydration mismatch
|
||||
function staticHeight(i: number): number {
|
||||
const t = i / bars;
|
||||
switch (pattern) {
|
||||
case 'flat':
|
||||
// None / Raw: very quiet, almost flat
|
||||
return 2 + Math.abs(Math.sin(i * 1.2)) * 3;
|
||||
case 'medium':
|
||||
// Standard: gentle variation
|
||||
return 3 + Math.abs(Math.sin(i * 0.6) + Math.cos(i * 0.25) * 0.5) * (height * 0.45);
|
||||
case 'aggressive':
|
||||
// Aggressive: loud peaks with lots of energy
|
||||
return 3 + Math.abs(Math.sin(i * 0.42) * Math.cos(i * 0.17)) * (height * 0.9);
|
||||
case 'auto':
|
||||
// Auto: mixed natural-sounding
|
||||
return 3 + Math.abs(Math.sin(i * 0.7) + Math.cos(i * 0.31)) * (height * 0.55);
|
||||
default:
|
||||
// Default / decorative waveform
|
||||
return 6 + Math.abs(Math.sin(i * 0.7) + Math.cos(i * 0.31)) * (height * 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
// Derived bar data — uses liveData if available, otherwise static
|
||||
const barData = $derived.by(() => {
|
||||
if (liveData && liveData.length > 0) {
|
||||
// Downsample / upsample liveData to `bars` length
|
||||
return Array.from({ length: bars }, (_, i) => {
|
||||
const srcIdx = Math.floor((i / bars) * liveData.length);
|
||||
return 2 + liveData[srcIdx] * (height - 2);
|
||||
});
|
||||
}
|
||||
return Array.from({ length: bars }, (_, i) => staticHeight(i));
|
||||
});
|
||||
|
||||
// Threshold index where accent color ends
|
||||
const threshold = $derived(Math.floor((progress / 100) * bars));
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 {bars * (100 / bars) * (1 + gap / 10)} {height}"
|
||||
preserveAspectRatio="none"
|
||||
style="width: 100%; height: {height}px; display: block;"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#each barData as h, i}
|
||||
{@const x = i * (100 / bars) * (1 + gap / 10)}
|
||||
{@const w = Math.max(1, (100 / bars) * 0.72)}
|
||||
{@const y = (height - h) / 2}
|
||||
{@const colored = i < threshold}
|
||||
<rect
|
||||
{x}
|
||||
{y}
|
||||
width={w}
|
||||
height={h}
|
||||
rx="1"
|
||||
fill={colored
|
||||
? accent
|
||||
: progress > 0
|
||||
? `rgba(255,255,255,0.08)`
|
||||
: `rgba(255,255,255,${0.05 + (i % 3) * 0.03})`}
|
||||
opacity={colored ? (0.3 + (i / bars) * 0.7) : 1}
|
||||
/>
|
||||
{/each}
|
||||
</svg>
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -0,0 +1,153 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { existsSync } from 'fs';
|
||||
import { mkdir, unlink, rename } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import type { AudioMode, AudioAnalysis } from '$lib/types.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const TMP_DIR = join(process.env.DATA_DIR ?? '/tmp/.whisper-pwa', 'audio');
|
||||
|
||||
export async function ensureTmpDir() {
|
||||
if (!existsSync(TMP_DIR)) await mkdir(TMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export function tmpPath(jobId: string, suffix: string) {
|
||||
return join(TMP_DIR, `${jobId}${suffix}`);
|
||||
}
|
||||
|
||||
export async function cleanup(...paths: string[]) {
|
||||
await Promise.allSettled(paths.map((p) => unlink(p).catch(() => {})));
|
||||
}
|
||||
|
||||
/** Run ffmpeg volumedetect and return mean/max dB. */
|
||||
export async function analyzeVolume(inputPath: string): Promise<AudioAnalysis> {
|
||||
const { stderr } = await execFileAsync('ffmpeg', [
|
||||
'-i', inputPath,
|
||||
'-af', 'volumedetect',
|
||||
'-vn', '-sn', '-dn',
|
||||
'-f', 'null', '-'
|
||||
]);
|
||||
const meanMatch = stderr.match(/mean_volume:\s*([-\d.]+)\s*dB/);
|
||||
const maxMatch = stderr.match(/max_volume:\s*([-\d.]+)\s*dB/);
|
||||
return {
|
||||
meanVolume: meanMatch ? parseFloat(meanMatch[1]) : -99,
|
||||
maxVolume: maxMatch ? parseFloat(maxMatch[1]) : -99
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect leading silence duration (ms).
|
||||
* Only trims if silence begins at/near time 0 (< 0.5s).
|
||||
* Capped at 30s to prevent accidental over-trimming.
|
||||
*/
|
||||
async function detectLeadingSilenceMs(inputPath: string): Promise<number> {
|
||||
try {
|
||||
const { stderr } = await execFileAsync('ffmpeg', [
|
||||
'-i', inputPath,
|
||||
'-af', 'silencedetect=n=-40dB:d=0.1',
|
||||
'-vn', '-sn', '-dn',
|
||||
'-f', 'null', '-'
|
||||
]);
|
||||
const startMatch = stderr.match(/silence_start:\s*([\d.]+)/);
|
||||
const endMatch = stderr.match(/silence_end:\s*([\d.]+)/);
|
||||
// Only trim if silence genuinely starts at the very beginning of the file
|
||||
if (startMatch && endMatch && parseFloat(startMatch[1]) < 0.5) {
|
||||
return Math.min(Math.floor(parseFloat(endMatch[1]) * 1000), 30_000);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Build ffmpeg -af filter chain for the given mode and mean volume. */
|
||||
export function buildFilterChain(mode: AudioMode, meanVolume: number): string | null {
|
||||
const isQuiet = meanVolume < -30;
|
||||
|
||||
switch (mode) {
|
||||
case 'none':
|
||||
return null;
|
||||
|
||||
case 'standard':
|
||||
return 'highpass=f=80,lowpass=f=8000,loudnorm=I=-16:LRA=11:TP=-1.5';
|
||||
|
||||
case 'aggressive':
|
||||
return [
|
||||
'highpass=f=80',
|
||||
isQuiet ? 'volume=24dB,dynaudnorm=f=500:g=15' : null,
|
||||
'lowpass=f=8000',
|
||||
'afftdn=nf=-30',
|
||||
'agate=threshold=0.01:attack=5:release=50',
|
||||
'loudnorm=I=-16:LRA=11:TP=-1.5'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
|
||||
case 'auto':
|
||||
default:
|
||||
if (isQuiet) {
|
||||
return [
|
||||
'highpass=f=80',
|
||||
'volume=24dB',
|
||||
'dynaudnorm=f=500:g=15',
|
||||
'lowpass=f=8000',
|
||||
'afftdn=nf=-25',
|
||||
'loudnorm=I=-16:LRA=11:TP=-1.5'
|
||||
].join(',');
|
||||
}
|
||||
return 'highpass=f=80,lowpass=f=8000,loudnorm=I=-16:LRA=11:TP=-1.5';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare audio for Whisper: convert to 16kHz mono WAV, trim leading silence,
|
||||
* apply the appropriate filter chain.
|
||||
* Returns path to the prepared WAV file.
|
||||
*/
|
||||
export async function prepareAudio(
|
||||
inputPath: string,
|
||||
jobId: string,
|
||||
mode: AudioMode
|
||||
): Promise<{ wavPath: string; analysis: AudioAnalysis }> {
|
||||
await ensureTmpDir();
|
||||
|
||||
// Step 1: analyse volume on the original file
|
||||
const analysis = await analyzeVolume(inputPath);
|
||||
|
||||
// Step 2: detect leading silence
|
||||
const silenceMs = await detectLeadingSilenceMs(inputPath);
|
||||
|
||||
const wavPath = tmpPath(jobId, '.wav');
|
||||
const filterChain = buildFilterChain(mode, analysis.meanVolume);
|
||||
|
||||
const args: string[] = ['-y'];
|
||||
|
||||
// Trim leading silence
|
||||
if (silenceMs > 0) {
|
||||
args.push('-ss', (silenceMs / 1000).toFixed(3));
|
||||
}
|
||||
|
||||
args.push('-i', inputPath, '-ar', '16000', '-ac', '1');
|
||||
|
||||
if (filterChain) {
|
||||
args.push('-af', filterChain);
|
||||
}
|
||||
|
||||
args.push('-c:a', 'pcm_s16le', wavPath);
|
||||
|
||||
await execFileAsync('ffmpeg', args);
|
||||
return { wavPath, analysis };
|
||||
}
|
||||
|
||||
/** Move a file to a new path (cross-device safe). */
|
||||
export async function moveFile(src: string, dest: string) {
|
||||
try {
|
||||
await rename(src, dest);
|
||||
} catch {
|
||||
const { copyFile } = await import('fs/promises');
|
||||
await copyFile(src, dest);
|
||||
await unlink(src);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { Job, JobStatus, AudioMode, PushSubscription } from '$lib/types.js';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR ?? join(process.env.HOME ?? '/tmp', '.whisper-pwa');
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const db = new Database(join(DATA_DIR, 'jobs.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
audio_mode TEXT NOT NULL DEFAULT 'auto',
|
||||
mean_volume REAL,
|
||||
whisper_job_id TEXT,
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
output_dir TEXT,
|
||||
segments_json TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
const stmts = {
|
||||
insertJob: db.prepare(`
|
||||
INSERT INTO jobs (id, status, title, source, audio_mode)
|
||||
VALUES (@id, 'pending', @title, @source, @audioMode)
|
||||
`),
|
||||
updateJob: db.prepare(`
|
||||
UPDATE jobs SET
|
||||
status = @status,
|
||||
title = @title,
|
||||
mean_volume = @meanVolume,
|
||||
whisper_job_id = @whisperJobId,
|
||||
progress = @progress,
|
||||
output_dir = @outputDir,
|
||||
segments_json = @segmentsJson,
|
||||
error = @error,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = @id
|
||||
`),
|
||||
setStatus: db.prepare(`
|
||||
UPDATE jobs SET status = @status, progress = @progress, updated_at = datetime('now')
|
||||
WHERE id = @id
|
||||
`),
|
||||
getJob: db.prepare('SELECT * FROM jobs WHERE id = ?'),
|
||||
listJobs: db.prepare('SELECT * FROM jobs ORDER BY created_at DESC, rowid DESC LIMIT 100'),
|
||||
upsertSub: db.prepare(`
|
||||
INSERT INTO push_subscriptions (endpoint, p256dh, auth)
|
||||
VALUES (@endpoint, @p256dh, @auth)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET p256dh = excluded.p256dh, auth = excluded.auth
|
||||
`),
|
||||
allSubs: db.prepare('SELECT * FROM push_subscriptions'),
|
||||
deleteSub: db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?')
|
||||
};
|
||||
|
||||
function rowToJob(row: Record<string, unknown>): Job {
|
||||
return {
|
||||
id: row.id as string,
|
||||
status: row.status as JobStatus,
|
||||
title: row.title as string,
|
||||
source: row.source as string,
|
||||
audioMode: row.audio_mode as AudioMode,
|
||||
meanVolume: row.mean_volume as number | null,
|
||||
whisperJobId: row.whisper_job_id as string | null,
|
||||
progress: row.progress as number,
|
||||
outputDir: row.output_dir as string | null,
|
||||
segmentsJson: row.segments_json as string | null,
|
||||
error: row.error as string | null,
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string
|
||||
};
|
||||
}
|
||||
|
||||
export function createJob(source: string, title: string, audioMode: AudioMode): Job {
|
||||
const id = randomUUID();
|
||||
stmts.insertJob.run({ id, title, source, audioMode });
|
||||
return getJob(id)!;
|
||||
}
|
||||
|
||||
export function getJob(id: string): Job | null {
|
||||
const row = stmts.getJob.get(id) as Record<string, unknown> | undefined;
|
||||
return row ? rowToJob(row) : null;
|
||||
}
|
||||
|
||||
export function listJobs(): Job[] {
|
||||
return (stmts.listJobs.all() as Record<string, unknown>[]).map(rowToJob);
|
||||
}
|
||||
|
||||
export function updateJob(job: Partial<Job> & { id: string }): void {
|
||||
const current = getJob(job.id);
|
||||
if (!current) return;
|
||||
const merged = { ...current, ...job };
|
||||
stmts.updateJob.run({
|
||||
id: merged.id,
|
||||
status: merged.status,
|
||||
title: merged.title,
|
||||
meanVolume: merged.meanVolume,
|
||||
whisperJobId: merged.whisperJobId,
|
||||
progress: merged.progress,
|
||||
outputDir: merged.outputDir,
|
||||
segmentsJson: merged.segmentsJson,
|
||||
error: merged.error
|
||||
});
|
||||
}
|
||||
|
||||
export function setJobStatus(id: string, status: JobStatus, progress = 0): void {
|
||||
stmts.setStatus.run({ id, status, progress });
|
||||
}
|
||||
|
||||
export function savePushSubscription(sub: { endpoint: string; p256dh: string; auth: string }): void {
|
||||
stmts.upsertSub.run(sub);
|
||||
}
|
||||
|
||||
export function getAllSubscriptions(): PushSubscription[] {
|
||||
return stmts.allSubs.all() as PushSubscription[];
|
||||
}
|
||||
|
||||
export function deletePushSubscription(endpoint: string): void {
|
||||
stmts.deleteSub.run(endpoint);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const COMPOSE_FILE = process.env.WHISPER_COMPOSE_FILE ?? '';
|
||||
const WHISPER_URL = process.env.WHISPER_URL ?? 'http://localhost:8080';
|
||||
|
||||
/** Poll /health until ready or timeout (ms). */
|
||||
export async function waitForHealth(timeoutMs = 60_000): Promise<boolean> {
|
||||
const { default: fetch } = await import('node-fetch');
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`${WHISPER_URL}/health`, { signal: AbortSignal.timeout(2000) });
|
||||
if (res.ok) return true;
|
||||
} catch { /* not ready yet */ }
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Start the whisper-rtx2080 container if a compose file is configured. */
|
||||
export async function ensureWhisperRunning(): Promise<void> {
|
||||
if (!COMPOSE_FILE || !existsSync(COMPOSE_FILE)) {
|
||||
// No compose file configured — assume server is already running
|
||||
return;
|
||||
}
|
||||
|
||||
const healthy = await waitForHealth(3000);
|
||||
if (healthy) return;
|
||||
|
||||
console.log('[docker] Starting whisper-rtx2080...');
|
||||
await execFileAsync('docker', ['compose', '-f', COMPOSE_FILE, 'up', '-d']);
|
||||
const ok = await waitForHealth(120_000);
|
||||
if (!ok) throw new Error('whisper-rtx2080 did not become healthy within 2 minutes');
|
||||
console.log('[docker] whisper-rtx2080 is ready');
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { existsSync } from 'fs';
|
||||
import { mkdir, unlink, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const TMP_DIR = join(process.env.DATA_DIR ?? '/tmp/.whisper-pwa', 'downloads');
|
||||
|
||||
export async function ensureTmpDir() {
|
||||
if (!existsSync(TMP_DIR)) await mkdir(TMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export interface CaptionResult {
|
||||
type: 'captions';
|
||||
segments: Array<{ index: number; start: number; end: number; text: string; words: [] }>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface AudioResult {
|
||||
type: 'audio';
|
||||
audioPath: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export type DownloadResult = CaptionResult | AudioResult;
|
||||
|
||||
/** Try to get auto-generated captions from YouTube. Returns null if unavailable. */
|
||||
async function tryGetCaptions(url: string, outDir: string): Promise<CaptionResult | null> {
|
||||
const jsonPath = join(outDir, 'info.json');
|
||||
try {
|
||||
await execFileAsync('yt-dlp', [
|
||||
'--write-auto-subs',
|
||||
'--sub-langs', 'en.*',
|
||||
'--skip-download',
|
||||
'--write-info-json',
|
||||
'--no-playlist',
|
||||
'-o', join(outDir, '%(title)s.%(ext)s'),
|
||||
url
|
||||
]);
|
||||
|
||||
// Find the VTT/SRT file
|
||||
const { readdirSync } = await import('fs');
|
||||
const files = readdirSync(outDir);
|
||||
const vttFile = files.find((f) => f.endsWith('.vtt') || f.endsWith('.srt'));
|
||||
if (!vttFile) return null;
|
||||
|
||||
let title = 'Untitled';
|
||||
if (existsSync(jsonPath)) {
|
||||
try {
|
||||
const info = JSON.parse((await import('fs')).readFileSync(jsonPath, 'utf8'));
|
||||
title = info.title ?? title;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const content = (await import('fs')).readFileSync(join(outDir, vttFile), 'utf8');
|
||||
const segments = parseVtt(content);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
return { type: 'captions', segments, title };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Download best audio from YouTube. Returns path to audio file. */
|
||||
async function downloadAudio(url: string, outDir: string): Promise<{ audioPath: string; title: string }> {
|
||||
await execFileAsync('yt-dlp', [
|
||||
'-f', 'bestaudio',
|
||||
'--write-info-json',
|
||||
'--no-playlist',
|
||||
'-o', join(outDir, 'audio.%(ext)s'),
|
||||
url
|
||||
]);
|
||||
|
||||
const { readdirSync, readFileSync } = await import('fs');
|
||||
const files = readdirSync(outDir);
|
||||
const audioFile = files.find((f) => f.startsWith('audio.') && !f.endsWith('.json'));
|
||||
if (!audioFile) throw new Error('yt-dlp did not produce an audio file');
|
||||
|
||||
let title = 'Untitled';
|
||||
const jsonFile = files.find((f) => f.endsWith('.info.json'));
|
||||
if (jsonFile) {
|
||||
try {
|
||||
title = JSON.parse(readFileSync(join(outDir, jsonFile), 'utf8')).title ?? title;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return { audioPath: join(outDir, audioFile), title };
|
||||
}
|
||||
|
||||
/** Download a YouTube URL: try captions first, fall back to audio. */
|
||||
export async function downloadYouTube(url: string, jobId: string): Promise<DownloadResult> {
|
||||
await ensureTmpDir();
|
||||
const outDir = join(TMP_DIR, jobId);
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const captions = await tryGetCaptions(url, outDir);
|
||||
if (captions) return captions;
|
||||
|
||||
const { audioPath, title } = await downloadAudio(url, outDir);
|
||||
return { type: 'audio', audioPath, title };
|
||||
}
|
||||
|
||||
/** Save an uploaded file to tmp dir and return its path. */
|
||||
export async function saveUploadedFile(
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
jobId: string
|
||||
): Promise<string> {
|
||||
await ensureTmpDir();
|
||||
const outDir = join(TMP_DIR, jobId);
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const dest = join(outDir, filename);
|
||||
await writeFile(dest, buffer);
|
||||
return dest;
|
||||
}
|
||||
|
||||
export async function cleanupJobTmp(jobId: string) {
|
||||
const outDir = join(TMP_DIR, jobId);
|
||||
try {
|
||||
const { rm } = await import('fs/promises');
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Parse a WebVTT string into segments. */
|
||||
function parseVtt(
|
||||
content: string
|
||||
): Array<{ index: number; start: number; end: number; text: string; words: [] }> {
|
||||
const segments: Array<{ index: number; start: number; end: number; text: string; words: [] }> = [];
|
||||
const blocks = content.split(/\n\n+/);
|
||||
let index = 0;
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.trim().split('\n');
|
||||
const timeLine = lines.find((l) => l.includes('-->'));
|
||||
if (!timeLine) continue;
|
||||
|
||||
const [startStr, endStr] = timeLine.split('-->').map((s) => s.trim().split(' ')[0]);
|
||||
const start = vttTimeToSec(startStr);
|
||||
const end = vttTimeToSec(endStr);
|
||||
const text = lines
|
||||
.filter((l) => !l.includes('-->') && !/^\d+$/.test(l.trim()) && l.trim())
|
||||
.join(' ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim();
|
||||
|
||||
if (text) {
|
||||
segments.push({ index: index++, start, end, text, words: [] });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function vttTimeToSec(t: string): number {
|
||||
const parts = t.split(':').map(Number);
|
||||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
||||
return parts[0];
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import type { Segment } from '$lib/types.js';
|
||||
|
||||
function secToSrtTime(sec: number): string {
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
const ms = Math.round((sec % 1) * 1000);
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(ms).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
function secToVttTime(sec: number): string {
|
||||
return secToSrtTime(sec).replace(',', '.');
|
||||
}
|
||||
|
||||
export function buildSrt(segments: Segment[]): string {
|
||||
return segments
|
||||
.map(
|
||||
(s, i) =>
|
||||
`${i + 1}\n${secToSrtTime(s.start)} --> ${secToSrtTime(s.end)}\n${s.text.trim()}`
|
||||
)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/** Pure text — no timestamps, clean paragraphs. */
|
||||
export function buildTxt(segments: Segment[]): string {
|
||||
const lines: string[] = [];
|
||||
let para = '';
|
||||
|
||||
for (const seg of segments) {
|
||||
const t = seg.text.trim();
|
||||
if (!t) continue;
|
||||
para += (para ? ' ' : '') + t;
|
||||
// Start a new paragraph on sentence-ending punctuation followed by enough text
|
||||
if (/[.!?]$/.test(t) && para.length > 200) {
|
||||
lines.push(para);
|
||||
para = '';
|
||||
}
|
||||
}
|
||||
if (para) lines.push(para);
|
||||
return lines.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildMd(segments: Segment[], title: string): string {
|
||||
const lines: string[] = [`# ${title}\n`];
|
||||
let lastHeadingSec = -Infinity;
|
||||
|
||||
for (const seg of segments) {
|
||||
// Add a timestamp heading roughly every 5 minutes
|
||||
if (seg.start - lastHeadingSec >= 300) {
|
||||
const t = secToVttTime(seg.start).split('.')[0];
|
||||
lines.push(`\n## ${t}\n`);
|
||||
lastHeadingSec = seg.start;
|
||||
}
|
||||
lines.push(seg.text.trim());
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildJson(segments: Segment[], title: string): string {
|
||||
return JSON.stringify({ title, segments }, null, 2);
|
||||
}
|
||||
|
||||
export interface OutputPaths {
|
||||
srt: string;
|
||||
txt: string;
|
||||
md: string;
|
||||
json: string;
|
||||
}
|
||||
|
||||
/** Write all 4 output formats to OUTPUT_DIR/{safeTitle}/. */
|
||||
export async function writeOutputs(
|
||||
segments: Segment[],
|
||||
title: string,
|
||||
jobId: string
|
||||
): Promise<OutputPaths> {
|
||||
const baseDir = process.env.OUTPUT_DIR ?? join(process.env.HOME ?? '/tmp', 'transcripts');
|
||||
const safeTitle = title.replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').slice(0, 80) || jobId;
|
||||
const outDir = join(baseDir, safeTitle);
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const paths: OutputPaths = {
|
||||
srt: join(outDir, `${safeTitle}.srt`),
|
||||
txt: join(outDir, `${safeTitle}.txt`),
|
||||
md: join(outDir, `${safeTitle}.md`),
|
||||
json: join(outDir, `${safeTitle}.json`)
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
writeFile(paths.srt, buildSrt(segments), 'utf8'),
|
||||
writeFile(paths.txt, buildTxt(segments), 'utf8'),
|
||||
writeFile(paths.md, buildMd(segments, title), 'utf8'),
|
||||
writeFile(paths.json, buildJson(segments, title), 'utf8')
|
||||
]);
|
||||
|
||||
return paths;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { createJob, updateJob, setJobStatus, getJob } from './db.js';
|
||||
import { downloadYouTube, saveUploadedFile, cleanupJobTmp } from './downloader.js';
|
||||
import { prepareAudio, cleanup as cleanupFiles } from './audio.js';
|
||||
import { submitJob, streamJob } from './whisper.js';
|
||||
import { ensureWhisperRunning } from './docker.js';
|
||||
import type { AudioMode, Segment } from '$lib/types.js';
|
||||
|
||||
const WEBHOOK_BASE_URL = process.env.WEBHOOK_BASE_URL ?? 'http://localhost:3000';
|
||||
|
||||
/** Progress listeners: jobId → set of callbacks */
|
||||
const progressListeners = new Map<string, Set<(data: string) => void>>();
|
||||
|
||||
export function subscribeProgress(jobId: string, cb: (data: string) => void): () => void {
|
||||
if (!progressListeners.has(jobId)) progressListeners.set(jobId, new Set());
|
||||
progressListeners.get(jobId)!.add(cb);
|
||||
return () => progressListeners.get(jobId)?.delete(cb);
|
||||
}
|
||||
|
||||
export function emitProgress(jobId: string, payload: object) {
|
||||
const listeners = progressListeners.get(jobId);
|
||||
if (!listeners) return;
|
||||
const data = JSON.stringify(payload);
|
||||
for (const cb of listeners) cb(data);
|
||||
}
|
||||
|
||||
/** Start a transcription job for a YouTube URL. Runs async — returns immediately. */
|
||||
export async function startYouTubeJob(
|
||||
url: string,
|
||||
audioMode: AudioMode = 'auto',
|
||||
language?: string
|
||||
): Promise<string> {
|
||||
const job = createJob(url, 'Downloading…', audioMode);
|
||||
runJob(job.id, { type: 'youtube', url }, audioMode, language).catch((err) => {
|
||||
console.error(`[pipeline] job ${job.id} failed:`, err);
|
||||
});
|
||||
return job.id;
|
||||
}
|
||||
|
||||
/** Start a transcription job for an uploaded file. Runs async — returns immediately. */
|
||||
export async function startUploadJob(
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
audioMode: AudioMode = 'auto',
|
||||
language?: string
|
||||
): Promise<string> {
|
||||
const job = createJob(filename, filename, audioMode);
|
||||
runJob(job.id, { type: 'upload', buffer, filename }, audioMode, language).catch((err) => {
|
||||
console.error(`[pipeline] job ${job.id} failed:`, err);
|
||||
});
|
||||
return job.id;
|
||||
}
|
||||
|
||||
async function runJob(
|
||||
jobId: string,
|
||||
input: { type: 'youtube'; url: string } | { type: 'upload'; buffer: Buffer; filename: string },
|
||||
audioMode: AudioMode,
|
||||
language?: string
|
||||
) {
|
||||
let rawAudioPath: string | null = null;
|
||||
let wavPath: string | null = null;
|
||||
|
||||
try {
|
||||
// ── 1. Download / save input ──────────────────────────────────────────
|
||||
setJobStatus(jobId, 'downloading', 0);
|
||||
emitProgress(jobId, { type: 'status', status: 'downloading' });
|
||||
|
||||
let title = 'Untitled';
|
||||
let captionSegments: Segment[] | null = null;
|
||||
|
||||
if (input.type === 'youtube') {
|
||||
const result = await downloadYouTube(input.url, jobId);
|
||||
if (result.type === 'captions') {
|
||||
// Fast path — use captions directly
|
||||
title = result.title;
|
||||
captionSegments = result.segments;
|
||||
} else {
|
||||
rawAudioPath = result.audioPath;
|
||||
title = result.title;
|
||||
}
|
||||
} else {
|
||||
rawAudioPath = await saveUploadedFile(input.buffer, input.filename, jobId);
|
||||
title = input.filename.replace(/\.[^.]+$/, '');
|
||||
}
|
||||
|
||||
updateJob({ id: jobId, title });
|
||||
|
||||
if (captionSegments) {
|
||||
// Caption fast path — skip whisper
|
||||
const { deduplicateSegments } = await import('./postprocess.js');
|
||||
const { writeOutputs } = await import('./formatter.js');
|
||||
const segments = deduplicateSegments(captionSegments);
|
||||
const paths = await writeOutputs(segments, title, jobId);
|
||||
updateJob({
|
||||
id: jobId,
|
||||
status: 'done',
|
||||
progress: 100,
|
||||
segmentsJson: JSON.stringify(segments),
|
||||
outputDir: paths.srt.replace(/\/[^/]+$/, '')
|
||||
});
|
||||
emitProgress(jobId, { type: 'done' });
|
||||
const { sendNotification } = await import('./push.js');
|
||||
await sendNotification(jobId, '✅ Transcript ready', title);
|
||||
await cleanupJobTmp(jobId);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 2. Prepare audio ─────────────────────────────────────────────────
|
||||
setJobStatus(jobId, 'preparing', 5);
|
||||
emitProgress(jobId, { type: 'status', status: 'preparing' });
|
||||
const { wavPath: wp, analysis } = await prepareAudio(rawAudioPath!, jobId, audioMode);
|
||||
wavPath = wp;
|
||||
updateJob({ id: jobId, meanVolume: analysis.meanVolume });
|
||||
|
||||
// ── 3. Ensure whisper is running ──────────────────────────────────────
|
||||
await ensureWhisperRunning();
|
||||
|
||||
// ── 4. Submit to whisper with webhook ────────────────────────────────
|
||||
setJobStatus(jobId, 'transcribing', 10);
|
||||
emitProgress(jobId, { type: 'status', status: 'transcribing' });
|
||||
|
||||
const webhookUrl = `${WEBHOOK_BASE_URL}/api/webhook/${jobId}`;
|
||||
const whisperJobId = await submitJob(wavPath, webhookUrl, language);
|
||||
updateJob({ id: jobId, whisperJobId });
|
||||
|
||||
// ── 5. Open SSE for live progress (non-blocking relay) ───────────────
|
||||
streamJob(
|
||||
whisperJobId,
|
||||
(percent, chunk, total) => {
|
||||
const progress = 10 + Math.round(percent * 0.8);
|
||||
setJobStatus(jobId, 'transcribing', progress);
|
||||
emitProgress(jobId, { type: 'progress', percent, chunk, total, progress });
|
||||
},
|
||||
() => { /* webhook will handle completion */ },
|
||||
(msg) => {
|
||||
setJobStatus(jobId, 'failed', 0);
|
||||
updateJob({ id: jobId, error: msg });
|
||||
emitProgress(jobId, { type: 'error', message: msg });
|
||||
}
|
||||
).catch((err) => console.warn('[pipeline] SSE relay error:', err));
|
||||
|
||||
// Clean up wav after submitting (webhook handles the rest)
|
||||
await cleanupFiles(wavPath);
|
||||
wavPath = null;
|
||||
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
updateJob({ id: jobId, status: 'failed', error: message });
|
||||
emitProgress(jobId, { type: 'error', message });
|
||||
if (rawAudioPath) await cleanupFiles(rawAudioPath).catch(() => {});
|
||||
if (wavPath) await cleanupFiles(wavPath).catch(() => {});
|
||||
await cleanupJobTmp(jobId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Segment } from '$lib/types.js';
|
||||
|
||||
// ── Collapse consecutive repeated phrases within a segment's text ────────────
|
||||
|
||||
function collapseRepeats(text: string): string {
|
||||
let prev = '';
|
||||
// Keep applying until stable
|
||||
while (true) {
|
||||
const next = collapseOnce(text);
|
||||
if (next === prev || next === text) return next;
|
||||
prev = text;
|
||||
text = next;
|
||||
}
|
||||
}
|
||||
|
||||
function collapseOnce(text: string): string {
|
||||
// Match any repeated phrase (2+ words) appearing consecutively
|
||||
return text.replace(/\b(.{10,}?)\s+\1\b/gi, '$1');
|
||||
}
|
||||
|
||||
// ── Merge consecutive segments with identical (or near-identical) text ───────
|
||||
|
||||
function normalise(s: string) {
|
||||
return s.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function mergeConsecutive(segments: Segment[]): Segment[] {
|
||||
const out: Segment[] = [];
|
||||
for (const seg of segments) {
|
||||
const last = out[out.length - 1];
|
||||
if (last && normalise(last.text) === normalise(seg.text)) {
|
||||
last.end = seg.end;
|
||||
} else {
|
||||
out.push({ ...seg });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── N-gram deduplication ─────────────────────────────────────────────────────
|
||||
|
||||
const NGRAM_N = 6;
|
||||
const LOOKBACK_CHARS = 500;
|
||||
const SIMILARITY_THRESHOLD = 0.6;
|
||||
|
||||
function ngrams(text: string, n: number): string[] {
|
||||
const words = text.toLowerCase().split(/\s+/);
|
||||
const grams: string[] = [];
|
||||
for (let i = 0; i <= words.length - n; i++) {
|
||||
grams.push(words.slice(i, i + n).join(' '));
|
||||
}
|
||||
return grams;
|
||||
}
|
||||
|
||||
function jaccardSimilarity(a: string, b: string): number {
|
||||
const ga = new Set(ngrams(a, NGRAM_N));
|
||||
const gb = new Set(ngrams(b, NGRAM_N));
|
||||
// If neither text is long enough to produce n-grams they cannot be compared;
|
||||
// treat as dissimilar so short segments are never incorrectly discarded.
|
||||
if (ga.size === 0 && gb.size === 0) return 0;
|
||||
const intersection = [...ga].filter((g) => gb.has(g)).length;
|
||||
const union = new Set([...ga, ...gb]).size;
|
||||
return union === 0 ? 0 : intersection / union;
|
||||
}
|
||||
|
||||
function ngramDedup(segments: Segment[]): Segment[] {
|
||||
const out: Segment[] = [];
|
||||
for (const seg of segments) {
|
||||
const windowText = out
|
||||
.slice(-20)
|
||||
.map((s) => s.text)
|
||||
.join(' ')
|
||||
.slice(-LOOKBACK_CHARS);
|
||||
|
||||
if (windowText.length > 0 && jaccardSimilarity(seg.text, windowText) >= SIMILARITY_THRESHOLD) {
|
||||
continue; // duplicate — skip
|
||||
}
|
||||
out.push(seg);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Full deduplication pipeline ──────────────────────────────────────────────
|
||||
|
||||
export function deduplicateSegments(segments: Segment[]): Segment[] {
|
||||
// 1. Collapse repeats within each segment's text
|
||||
let result = segments.map((s) => ({
|
||||
...s,
|
||||
text: collapseRepeats(s.text.trim())
|
||||
}));
|
||||
|
||||
// 2. Remove empty segments
|
||||
result = result.filter((s) => s.text.length > 0);
|
||||
|
||||
// 3. First merge pass
|
||||
result = mergeConsecutive(result);
|
||||
|
||||
// 4. N-gram dedup
|
||||
result = ngramDedup(result);
|
||||
|
||||
// 5. Second merge pass (catches new adjacencies after dedup)
|
||||
result = mergeConsecutive(result);
|
||||
|
||||
// 6. Re-index
|
||||
result.forEach((s, i) => (s.index = i));
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import webpush from 'web-push';
|
||||
import { getAllSubscriptions, deletePushSubscription } from './db.js';
|
||||
|
||||
let initialised = false;
|
||||
|
||||
function init() {
|
||||
if (initialised) return;
|
||||
const pub = process.env.VAPID_PUBLIC_KEY;
|
||||
const priv = process.env.VAPID_PRIVATE_KEY;
|
||||
const sub = process.env.VAPID_SUBJECT ?? 'mailto:admin@localhost';
|
||||
if (!pub || !priv) {
|
||||
console.warn('[push] VAPID keys not set — push notifications disabled');
|
||||
return;
|
||||
}
|
||||
webpush.setVapidDetails(sub, pub, priv);
|
||||
initialised = true;
|
||||
}
|
||||
|
||||
export function getVapidPublicKey(): string | null {
|
||||
return process.env.VAPID_PUBLIC_KEY || null;
|
||||
}
|
||||
|
||||
export async function sendNotification(jobId: string, title: string, body: string): Promise<void> {
|
||||
init();
|
||||
if (!initialised) return;
|
||||
|
||||
const subscriptions = getAllSubscriptions();
|
||||
const payload = JSON.stringify({ jobId, title, body });
|
||||
|
||||
await Promise.allSettled(
|
||||
subscriptions.map(async (sub) => {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
payload
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { statusCode?: number }).statusCode;
|
||||
if (status === 410 || status === 404) {
|
||||
deletePushSubscription(sub.endpoint);
|
||||
} else {
|
||||
console.error('[push] send failed:', err);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function whisperUrl() {
|
||||
return process.env.WHISPER_URL ?? 'http://localhost:8080';
|
||||
}
|
||||
|
||||
/** Submit an audio file to whisper-rtx2080. Returns the whisper job id. */
|
||||
export async function submitJob(
|
||||
wavPath: string,
|
||||
webhookUrl: string,
|
||||
language?: string
|
||||
): Promise<string> {
|
||||
const FormData = (await import('form-data')).default;
|
||||
const { createReadStream } = await import('fs');
|
||||
const { default: fetch } = await import('node-fetch');
|
||||
|
||||
const form = new FormData();
|
||||
form.append('audio', createReadStream(wavPath));
|
||||
form.append('task', 'transcribe');
|
||||
form.append('webhook_url', webhookUrl);
|
||||
if (language) form.append('language', language);
|
||||
|
||||
const res = await fetch(`${whisperUrl()}/jobs`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: form.getHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`whisper /jobs returned ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as { job_id: string };
|
||||
return json.job_id;
|
||||
}
|
||||
|
||||
/** Open an SSE stream from whisper and call onProgress/onDone callbacks. */
|
||||
export async function streamJob(
|
||||
whisperJobId: string,
|
||||
onProgress: (percent: number, chunk: number, total: number) => void,
|
||||
onDone: () => void,
|
||||
onError: (msg: string) => void
|
||||
): Promise<void> {
|
||||
const { default: fetch } = await import('node-fetch');
|
||||
const res = await fetch(`${whisperUrl()}/jobs/${whisperJobId}/stream`);
|
||||
if (!res.ok || !res.body) throw new Error(`SSE stream returned ${res.status}`);
|
||||
|
||||
let buf = '';
|
||||
for await (const chunk of res.body) {
|
||||
buf += chunk.toString();
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop() ?? '';
|
||||
|
||||
let eventType = '';
|
||||
let dataLine = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) eventType = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) dataLine = line.slice(5).trim();
|
||||
}
|
||||
|
||||
if (!dataLine) continue;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(dataLine);
|
||||
if (payload.type === 'progress') {
|
||||
onProgress(payload.percent ?? 0, payload.chunk ?? 0, payload.total ?? 0);
|
||||
} else if (payload.type === 'done') {
|
||||
onDone();
|
||||
return;
|
||||
} else if (payload.type === 'error') {
|
||||
onError(payload.message ?? 'unknown error');
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if the whisper server is healthy. */
|
||||
export async function checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const { default: fetch } = await import('node-fetch');
|
||||
const res = await fetch(`${whisperUrl()}/health`, { signal: AbortSignal.timeout(3000) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export type AudioMode = 'auto' | 'standard' | 'aggressive' | 'none';
|
||||
|
||||
export type JobStatus = 'pending' | 'downloading' | 'preparing' | 'transcribing' | 'processing' | 'done' | 'failed' | 'cancelled';
|
||||
|
||||
export interface Segment {
|
||||
index: number;
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
words: Word[];
|
||||
}
|
||||
|
||||
export interface Word {
|
||||
start: number;
|
||||
end: number;
|
||||
word: string;
|
||||
probability?: number;
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: string;
|
||||
status: JobStatus;
|
||||
title: string;
|
||||
source: string;
|
||||
audioMode: AudioMode;
|
||||
meanVolume: number | null;
|
||||
whisperJobId: string | null;
|
||||
progress: number;
|
||||
outputDir: string | null;
|
||||
segmentsJson: string | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WhisperJob {
|
||||
id: string;
|
||||
status: 'queued' | 'running' | 'done' | 'failed' | 'cancelled';
|
||||
language: string | null;
|
||||
task: string;
|
||||
duration_secs: number | null;
|
||||
progress: number;
|
||||
segments: Segment[];
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
export interface PushSubscription {
|
||||
id: number;
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AudioAnalysis {
|
||||
meanVolume: number;
|
||||
maxVolume: number;
|
||||
}
|
||||
Reference in New Issue
Block a user