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

View File

@@ -0,0 +1,45 @@
import { json, error } from '@sveltejs/kit';
import { createJob, listJobs } from '$lib/server/db.js';
import { startYouTubeJob, startUploadJob } from '$lib/server/pipeline.js';
import type { AudioMode } from '$lib/types.js';
export async function GET() {
return json(listJobs());
}
export async function POST({ request }) {
const contentType = request.headers.get('content-type') ?? '';
let url: string | null = null;
let audioMode: AudioMode = 'auto';
let language: string | undefined;
let fileBuffer: Buffer | null = null;
let filename = 'upload';
if (contentType.includes('application/json')) {
const body = await request.json();
url = body.url ?? null;
audioMode = body.audioMode ?? 'auto';
language = body.language;
} else if (contentType.includes('multipart/form-data')) {
const form = await request.formData();
url = form.get('url')?.toString() ?? null;
audioMode = (form.get('audioMode')?.toString() as AudioMode) ?? 'auto';
language = form.get('language')?.toString();
const file = form.get('file');
if (file instanceof File) {
fileBuffer = Buffer.from(await file.arrayBuffer());
filename = file.name;
}
} else {
throw error(415, 'Unsupported content type');
}
if (!url && !fileBuffer) throw error(400, 'Provide url or file');
const jobId = url
? await startYouTubeJob(url, audioMode, language)
: await startUploadJob(fileBuffer!, filename, audioMode, language);
return json({ id: jobId }, { status: 201 });
}

View File

@@ -0,0 +1,18 @@
import { json, error } from '@sveltejs/kit';
import { getJob, setJobStatus } from '$lib/server/db.js';
export async function GET({ params }) {
const job = getJob(params.id);
if (!job) throw error(404, 'Job not found');
return json(job);
}
export async function DELETE({ params }) {
const job = getJob(params.id);
if (!job) throw error(404, 'Job not found');
if (job.status === 'done' || job.status === 'failed') {
throw error(409, 'Job already completed');
}
setJobStatus(params.id, 'cancelled', 0);
return new Response(null, { status: 204 });
}

View File

@@ -0,0 +1,37 @@
import { error } from '@sveltejs/kit';
import { getJob } from '$lib/server/db.js';
import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import { join } from 'path';
const MIME: Record<string, string> = {
srt: 'text/plain',
txt: 'text/plain',
md: 'text/markdown',
json: 'application/json'
};
export async function GET({ params }) {
const { id, format } = params;
if (!MIME[format]) throw error(400, `Unknown format: ${format}`);
const job = getJob(id);
if (!job) throw error(404, 'Job not found');
if (job.status !== 'done') throw error(409, 'Transcript not ready yet');
if (!job.outputDir) throw error(500, 'Output directory not set');
const safeTitle =
(job.title ?? id).replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').slice(0, 80) || id;
const filePath = join(job.outputDir, `${safeTitle}.${format}`);
if (!existsSync(filePath)) throw error(404, `${format} file not found`);
const content = await readFile(filePath);
return new Response(content.buffer as ArrayBuffer, {
headers: {
'Content-Type': MIME[format],
'Content-Disposition': `attachment; filename="${safeTitle}.${format}"`
}
});
}

View File

@@ -0,0 +1,34 @@
import { json, error } from '@sveltejs/kit';
import { getJob, updateJob } from '$lib/server/db.js';
import { deduplicateSegments } from '$lib/server/postprocess.js';
import { writeOutputs } from '$lib/server/formatter.js';
import type { Segment } from '$lib/types.js';
/** POST /api/jobs/[id]/reprocess — re-run post-processing and regenerate all output files. */
export async function POST({ params }) {
const job = getJob(params.id);
if (!job) throw error(404, 'Job not found');
if (!job.segmentsJson) {
throw error(422, 'No segments stored for this job — cannot reprocess');
}
try {
const rawSegments = JSON.parse(job.segmentsJson) as Segment[];
const segments = deduplicateSegments(rawSegments);
const paths = await writeOutputs(segments, job.title, job.id);
const outputDir = paths.srt.replace(/\/[^/]+$/, '');
updateJob({
id: job.id,
segmentsJson: JSON.stringify(segments),
outputDir
});
return json({ ok: true, paths });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
throw error(500, `Reprocess failed: ${message}`);
}
}

View File

@@ -0,0 +1,51 @@
import { error } from '@sveltejs/kit';
import { getJob } from '$lib/server/db.js';
import { subscribeProgress } from '$lib/server/pipeline.js';
export async function GET({ params, request }) {
const job = getJob(params.id);
if (!job) throw error(404, 'Job not found');
const stream = new ReadableStream({
start(controller) {
const enc = new TextEncoder();
function send(data: string) {
controller.enqueue(enc.encode(`data: ${data}\n\n`));
}
// Send current status immediately
send(JSON.stringify({ type: 'status', status: job.status, progress: job.progress }));
if (job.status === 'done' || job.status === 'failed' || job.status === 'cancelled') {
send(JSON.stringify({ type: 'done', status: job.status }));
controller.close();
return;
}
const unsub = subscribeProgress(params.id, (data) => {
send(data);
try {
const parsed = JSON.parse(data);
if (parsed.type === 'done' || parsed.type === 'error') {
controller.close();
unsub();
}
} catch { /* ignore */ }
});
request.signal.addEventListener('abort', () => {
unsub();
controller.close();
});
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
}
});
}