Initial commit: Tonemark PWA
Some checks failed
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
commit 13a96b6efa
68 changed files with 9712 additions and 0 deletions

153
src/lib/server/pipeline.ts Normal file
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);
}
}