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,615 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import type { Job, Segment } from '$lib/types.js';
import SourceIcon from '$lib/components/SourceIcon.svelte';
import Waveform from '$lib/components/Waveform.svelte';
const ACCENT = '#cdf24e';
const jobId = $derived($page.params.id);
let job = $state<Job | null>(null);
let segments = $state<Segment[]>([]);
let error = $state('');
let chunkInfo = $state({ chunk: 0, total: 0 });
let eventSource: EventSource | null = null;
const statusLabel: Record<string, string> = {
pending: 'Pending',
downloading: 'Downloading…',
preparing: 'Preparing audio…',
transcribing: 'Transcribing…',
processing: 'Post-processing…',
done: 'Done',
failed: 'Failed',
cancelled: 'Cancelled'
};
// Pipeline stages derived from job status
const pipelineStages = $derived.by(() => {
const status = job?.status ?? 'pending';
const stages = [
{ k: 'fetch', label: 'Fetch source' },
{ k: 'extract', label: 'Extract audio track' },
{ k: 'process', label: `Audio processing · ${job?.audioMode ?? 'auto'}` },
{ k: 'transcribe', label: 'Transcribing' },
{ k: 'finalize', label: 'Format &amp; save' }
];
const order = ['pending', 'downloading', 'preparing', 'transcribing', 'processing', 'done'];
const idx = order.indexOf(status);
return stages.map((s, i) => ({
...s,
done: i < idx - 1 || status === 'done',
active: i === idx - 1 && status !== 'done' && status !== 'failed',
pending: i > idx - 1 && status !== 'done'
}));
});
function jobKind(job: Job): 'youtube' | 'audio' | 'video' | 'file' {
const s = job.source ?? '';
if (s.includes('youtube') || s.includes('youtu.be')) return 'youtube';
if (/\.(mp3|m4a|wav|ogg|flac|aac)$/i.test(s)) return 'audio';
if (/\.(mp4|mov|mkv|webm|avi)$/i.test(s)) return 'video';
return 'file';
}
onMount(async () => {
await loadJob();
if (job && !['done', 'failed', 'cancelled'].includes(job.status)) {
openStream();
}
});
onDestroy(() => eventSource?.close());
async function loadJob() {
const res = await fetch(`/api/jobs/${jobId}`);
if (!res.ok) {
error = 'Job not found';
return;
}
job = await res.json();
if (job?.segmentsJson) {
try {
segments = JSON.parse(job.segmentsJson);
} catch { /* ignore */ }
}
}
function openStream() {
eventSource = new EventSource(`/api/jobs/${jobId}/stream`);
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'progress') {
chunkInfo = { chunk: data.chunk ?? 0, total: data.total ?? 0 };
if (job) job = { ...job, progress: data.progress ?? job.progress, status: 'transcribing' };
} else if (data.type === 'status') {
if (job) job = { ...job, status: data.status, progress: data.progress ?? job.progress };
} else if (data.type === 'done') {
eventSource?.close();
loadJob();
} else if (data.type === 'error') {
if (job) job = { ...job, status: 'failed', error: data.message };
eventSource?.close();
}
} catch { /* ignore */ }
};
}
function secToTimestamp(sec: number): string {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
return h > 0
? `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
: `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
const formats = ['srt', 'txt', 'md', 'json'] as const;
const isActive = $derived(!job || !['done', 'failed', 'cancelled'].includes(job.status));
</script>
<svelte:head>
<title>{job?.title ?? 'Job'} — Tonemark</title>
</svelte:head>
<div class="page">
{#if error}
<div class="error-banner" role="alert">{error}</div>
{:else if !job}
<div class="loading" aria-busy="true">
<svg width="20" height="20" viewBox="0 0 20 20" style="animation: spin 1s linear infinite">
<circle cx="10" cy="10" r="8" stroke="var(--text-muted)" stroke-width="2" fill="none" stroke-dasharray="30 14"/>
</svg>
Loading…
</div>
{:else}
<!-- ── Breadcrumb ─────────────────────────────────────── -->
<div class="breadcrumb mono">
<a href="/" class="crumb-link">Home</a>
<span></span>
<span style="color: #fff">{job.id.slice(0, 8)}</span>
</div>
<!-- ── Job header ────────────────────────────────────── -->
<div class="job-header">
<SourceIcon kind={jobKind(job)} size={52} accent={ACCENT} />
<div class="job-header-text">
<h1 class="job-title">{job.title || job.source}</h1>
<div class="job-meta mono">
{job.source?.includes('http') ? job.source : (job.source ?? '')}
{#if job.audioMode}· {job.audioMode}{/if}
{#if job.meanVolume != null}· {job.meanVolume.toFixed(1)} dBFS{/if}
</div>
</div>
{#if isActive}
<form method="POST" action="/api/jobs/{job.id}?_method=DELETE">
<button type="button" class="btn-cancel" aria-label="Cancel job">Cancel</button>
</form>
{/if}
</div>
<!-- ── Progress block ────────────────────────────────── -->
{#if isActive || job.status === 'done'}
<div class="progress-card glass">
<!-- Waveform coloured by progress -->
<div class="progress-wave">
<Waveform
bars={140}
progress={job.progress}
accent={ACCENT}
height={80}
pattern="default"
/>
</div>
<div class="progress-footer">
<div class="progress-left">
<span class="progress-pct mono">
{job.progress}<span style="color: var(--text-dim); font-weight: 400">%</span>
</span>
<span class="progress-status">{statusLabel[job.status] ?? job.status}</span>
</div>
{#if chunkInfo.total > 0}
<span class="progress-chunks mono">
chunk {chunkInfo.chunk} / {chunkInfo.total}
</span>
{/if}
</div>
<!-- Progress bar -->
<div class="progress-bar-track">
<div
class="progress-bar-fill"
style="width: {job.progress}%; background: {ACCENT}; box-shadow: 0 0 12px {ACCENT}80;"
></div>
</div>
</div>
{/if}
<!-- ── Error ─────────────────────────────────────────── -->
{#if job.error}
<div class="error-banner" role="alert">{job.error}</div>
{/if}
<!-- ── Two-column: pipeline + downloads/transcript ───── -->
<div class="two-col">
<!-- Pipeline stages -->
<div class="glass stage-card">
<div class="label" style="margin-bottom: 16px;">Pipeline</div>
<div class="stages">
{#each pipelineStages as stage}
<div class="stage-row">
<div
class="stage-dot"
style={stage.done
? `background: ${ACCENT};`
: stage.active
? `background: transparent; border: 2px solid ${ACCENT};`
: 'background: rgba(255,255,255,0.05);'}
>
{#if stage.done}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2 5l2 2 4-4" stroke="#0c0d10" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{:else if stage.active}
<div class="stage-dot-inner" style="background: {ACCENT}"></div>
{/if}
</div>
<span
class="stage-label"
style={stage.pending ? 'color: var(--text-dim)' : stage.active ? 'color: #fff; font-weight: 500' : ''}
>
{@html stage.label}
</span>
{#if stage.active}
<span class="mono" style="font-size: 11.5px; color: {ACCENT}">{job.progress}%</span>
{/if}
</div>
{/each}
</div>
</div>
<!-- Downloads or live preview -->
<div class="glass side-card">
{#if job.status === 'done'}
<div class="label" style="margin-bottom: 16px;">Download transcript</div>
<div class="dl-grid">
{#each formats as fmt, i}
<a
href="/api/jobs/{job.id}/download/{fmt}"
download
class="dl-btn mono"
style={i === 0
? `background: color-mix(in oklab, ${ACCENT} 12%, transparent); color: ${ACCENT}; border-color: color-mix(in oklab, ${ACCENT} 30%, transparent);`
: ''}
>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none">
<path d="M5.5 1v7M2 5l3.5 3.5L9 5M1.5 9.5h8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{fmt.toUpperCase()}
</a>
{/each}
</div>
{#if job.outputDir}
<div class="output-dir mono">{job.outputDir}</div>
{/if}
{:else if isActive}
<div class="live-header">
<div class="label">Live preview</div>
<div class="streaming-badge" style="color: {ACCENT}">
<div class="stream-dot" style="background: {ACCENT}; animation: pulse 1.4s infinite"></div>
Streaming
</div>
</div>
{#if segments.length > 0}
{@const last = segments[segments.length - 1]}
<div class="live-text">
<span class="mono" style="color: var(--text-dim); margin-right: 8px;">
{secToTimestamp(last.start)}
</span>
{last.text}<span style="color: {ACCENT}; animation: blink 1s infinite; margin-left: 3px;"></span>
</div>
{:else}
<div style="font-size: 13px; color: var(--text-muted); font-style: italic;">
Waiting for segments…
</div>
{/if}
{/if}
</div>
</div>
<!-- ── Transcript viewer ──────────────────────────────── -->
{#if segments.length > 0}
<section class="glass transcript-card">
<div class="transcript-header">
<div class="label">Transcript</div>
<span class="mono" style="font-size: 12px; color: var(--text-muted);">
{segments.length} segments
</span>
</div>
<div class="transcript-body">
{#each segments as seg}
<div class="seg-row">
<span class="seg-ts mono">{secToTimestamp(seg.start)}</span>
<p class="seg-text">{seg.text}</p>
</div>
{/each}
</div>
</section>
{/if}
{/if}
</div>
<style>
.page {
padding: 32px 40px;
display: flex;
flex-direction: column;
gap: 20px;
max-width: 1000px;
}
/* ── Loading / errors ──────────────────────────────────── */
.loading {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-muted);
font-size: 14px;
}
.error-banner {
padding: 12px 16px;
border-radius: 10px;
background: rgba(255, 90, 90, 0.08);
border: 1px solid rgba(255, 90, 90, 0.2);
color: #ff8a8a;
font-size: 13px;
}
/* ── Breadcrumb ─────────────────────────────────────────── */
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
}
.crumb-link {
color: var(--text-muted);
text-decoration: none;
}
.crumb-link:hover {
color: var(--text);
}
/* ── Job header ─────────────────────────────────────────── */
.job-header {
display: flex;
align-items: center;
gap: 18px;
}
.job-header-text {
flex: 1;
min-width: 0;
}
.job-title {
margin: 0 0 4px;
font-size: 26px;
font-weight: 600;
letter-spacing: -0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.job-meta {
font-size: 12.5px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.btn-cancel {
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgba(255, 90, 90, 0.3);
background: rgba(255, 90, 90, 0.08);
color: #ff8a8a;
font-size: 12.5px;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
}
.btn-cancel:hover {
background: rgba(255, 90, 90, 0.15);
}
/* ── Progress card ──────────────────────────────────────── */
.progress-card {
padding: 28px;
}
.progress-wave {
margin-bottom: 20px;
}
.progress-footer {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
}
.progress-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.progress-pct {
font-size: 36px;
font-weight: 600;
letter-spacing: -0.02em;
}
.progress-status {
font-size: 14px;
color: var(--text-muted);
}
.progress-chunks {
font-size: 12.5px;
color: var(--text-muted);
}
.progress-bar-track {
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
/* ── Two column ─────────────────────────────────────────── */
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.stage-card,
.side-card {
padding: 22px;
}
/* ── Pipeline stages ────────────────────────────────────── */
.stages {
display: flex;
flex-direction: column;
gap: 14px;
}
.stage-row {
display: flex;
align-items: center;
gap: 14px;
}
.stage-dot {
width: 18px;
height: 18px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stage-dot-inner {
width: 6px;
height: 6px;
border-radius: 3px;
}
.stage-label {
flex: 1;
font-size: 13.5px;
color: rgba(232, 233, 236, 0.85);
}
/* ── Live preview / downloads ───────────────────────────── */
.live-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.streaming-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-family: var(--font-mono);
}
.stream-dot {
width: 6px;
height: 6px;
border-radius: 3px;
}
.live-text {
font-size: 13.5px;
line-height: 1.7;
color: rgba(232, 233, 236, 0.85);
}
.dl-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.dl-btn {
padding: 11px;
border-radius: 9px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.025);
color: rgba(232, 233, 236, 0.85);
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.15s;
}
.dl-btn:hover {
background: rgba(255, 255, 255, 0.04);
}
.output-dir {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-dim);
word-break: break-all;
}
/* ── Transcript ─────────────────────────────────────────── */
.transcript-card {
padding: 22px;
}
.transcript-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 16px;
}
.transcript-body {
max-height: 480px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
padding-right: 4px;
}
.seg-row {
display: flex;
gap: 14px;
}
.seg-ts {
font-size: 11px;
color: var(--text-dim);
flex-shrink: 0;
margin-top: 3px;
width: 50px;
text-align: right;
}
.seg-text {
margin: 0;
font-size: 14px;
line-height: 1.65;
color: rgba(232, 233, 236, 0.85);
}
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.page {
padding: 20px 16px;
}
.job-title {
font-size: 20px;
}
.two-col {
grid-template-columns: 1fr;
}
.dl-grid {
grid-template-columns: repeat(4, 1fr);
}
}
</style>