Initial commit: Tonemark PWA
Build & Push Docker Image / build-and-push (push) Failing after 11s

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:
Giancarmine Salucci
2026-05-06 16:41:25 +02:00
co-authored by Copilot
commit 13a96b6efa
68 changed files with 9712 additions and 0 deletions
+38
View File
@@ -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)`);
});
+6
View File
@@ -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

+296
View File
@@ -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 &amp; 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>
+63
View File
@@ -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>
+93
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+153
View File
@@ -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);
}
}
+137
View File
@@ -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);
}
+38
View File
@@ -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');
}
+162
View File
@@ -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];
}
+98
View File
@@ -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;
}
+153
View File
@@ -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);
}
}
+108
View File
@@ -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;
}
+47
View File
@@ -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);
}
}
})
);
}
+91
View File
@@ -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;
}
}
+60
View File
@@ -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;
}