馃 distributed transcription service
thistle.dunkirk.sh
1import { css, html, LitElement } from "lit";
2import { customElement, state } from "lit/decorators.js";
3import "./vtt-viewer.ts";
4
5interface TranscriptionJob {
6 id: string;
7 filename: string;
8 status: "uploading" | "processing" | "transcribing" | "completed" | "failed";
9 progress: number;
10 transcript?: string;
11 created_at: number;
12 audioUrl?: string;
13 vttSegments?: VTTSegment[];
14 vttContent?: string;
15}
16
17interface VTTSegment {
18 start: number;
19 end: number;
20 text: string;
21 index?: string;
22}
23
24
25
26
27
28class WordStreamer {
29 private queue: string[] = [];
30 private isProcessing = false;
31 private wordDelay: number;
32 private onWord: (word: string) => void;
33
34 constructor(wordDelay: number = 50, onWord: (word: string) => void) {
35 this.wordDelay = wordDelay;
36 this.onWord = onWord;
37 }
38
39 addChunk(text: string) {
40 // Split on whitespace and filter out empty strings
41 const words = text.split(/(\s+)/).filter((w) => w.length > 0);
42 this.queue.push(...words);
43
44 // Start processing if not already running
45 if (!this.isProcessing) {
46 this.processQueue();
47 }
48 }
49
50 private async processQueue() {
51 this.isProcessing = true;
52
53 while (this.queue.length > 0) {
54 const word = this.queue.shift()!;
55 this.onWord(word);
56 await new Promise((resolve) => setTimeout(resolve, this.wordDelay));
57 }
58
59 this.isProcessing = false;
60 }
61
62 showAll() {
63 // Drain entire queue immediately
64 while (this.queue.length > 0) {
65 const word = this.queue.shift()!;
66 this.onWord(word);
67 }
68 this.isProcessing = false;
69 }
70
71 clear() {
72 this.queue = [];
73 this.isProcessing = false;
74 }
75}
76
77@customElement("transcription-component")
78export class TranscriptionComponent extends LitElement {
79 @state() jobs: TranscriptionJob[] = [];
80 @state() isUploading = false;
81 @state() dragOver = false;
82 @state() serviceAvailable = true;
83 // Word streamers for each job
84 private wordStreamers = new Map<string, WordStreamer>();
85 // Displayed transcripts
86 private displayedTranscripts = new Map<string, string>();
87 // Track last full transcript to compare
88 private lastTranscripts = new Map<string, string>();
89
90 static override styles = css`
91 :host {
92 display: block;
93 }
94
95 .upload-area {
96 border: 2px dashed var(--secondary);
97 border-radius: 8px;
98 padding: 3rem 2rem;
99 text-align: center;
100 transition: all 0.2s;
101 cursor: pointer;
102 background: var(--background);
103 }
104
105 .upload-area:hover,
106 .upload-area.drag-over {
107 border-color: var(--primary);
108 background: color-mix(in srgb, var(--primary) 5%, transparent);
109 }
110
111 .upload-area.disabled {
112 border-color: var(--secondary);
113 opacity: 0.6;
114 cursor: not-allowed;
115 }
116
117 .upload-area.disabled:hover {
118 border-color: var(--secondary);
119 background: transparent;
120 }
121
122 .upload-icon {
123 font-size: 3rem;
124 color: var(--secondary);
125 margin-bottom: 1rem;
126 }
127
128 .upload-text {
129 color: var(--text);
130 font-size: 1.125rem;
131 font-weight: 500;
132 margin-bottom: 0.5rem;
133 }
134
135 .upload-hint {
136 color: var(--text);
137 opacity: 0.7;
138 font-size: 0.875rem;
139 }
140
141 .jobs-section {
142 margin-top: 2rem;
143 }
144
145 .jobs-title {
146 font-size: 1.25rem;
147 font-weight: 600;
148 color: var(--text);
149 margin-bottom: 1rem;
150 }
151
152 .job-card {
153 background: var(--background);
154 border: 1px solid var(--secondary);
155 border-radius: 8px;
156 padding: 1.5rem;
157 margin-bottom: 1rem;
158 }
159
160 .job-header {
161 display: flex;
162 align-items: center;
163 justify-content: space-between;
164 margin-bottom: 1rem;
165 }
166
167 .job-filename {
168 font-weight: 500;
169 color: var(--text);
170 }
171
172 .job-status {
173 padding: 0.25rem 0.75rem;
174 border-radius: 4px;
175 font-size: 0.75rem;
176 font-weight: 600;
177 text-transform: uppercase;
178 }
179
180 .status-uploading {
181 background: color-mix(in srgb, var(--primary) 10%, transparent);
182 color: var(--primary);
183 }
184
185 .status-processing {
186 background: color-mix(in srgb, var(--primary) 10%, transparent);
187 color: var(--primary);
188 }
189
190 .status-transcribing {
191 background: color-mix(in srgb, var(--accent) 10%, transparent);
192 color: var(--accent);
193 }
194
195 .status-completed {
196 background: color-mix(in srgb, var(--success) 10%, transparent);
197 color: var(--success);
198 }
199
200 .status-failed {
201 background: color-mix(in srgb, var(--text) 10%, transparent);
202 color: var(--text);
203 }
204
205 .progress-bar {
206 width: 100%;
207 height: 4px;
208 background: var(--secondary);
209 border-radius: 2px;
210 margin-bottom: 1rem;
211 overflow: hidden;
212 position: relative;
213 }
214
215 .progress-fill {
216 height: 100%;
217 background: var(--primary);
218 border-radius: 2px;
219 transition: width 0.3s;
220 }
221
222 .progress-fill.indeterminate {
223 width: 30%;
224 background: var(--primary);
225 animation: progress-slide 1.5s ease-in-out infinite;
226 }
227
228 @keyframes progress-slide {
229 0% {
230 transform: translateX(-100%);
231 }
232 100% {
233 transform: translateX(333%);
234 }
235 }
236
237 .job-transcript {
238 background: color-mix(in srgb, var(--primary) 5%, transparent);
239 border-radius: 6px;
240 padding: 1rem;
241 margin-top: 1rem;
242 font-family: monospace;
243 font-size: 0.875rem;
244 color: var(--text);
245 line-height: 1.6;
246 word-wrap: break-word;
247 }
248
249 .segment {
250 cursor: pointer;
251 transition: background 0.1s;
252 display: inline;
253 }
254
255 .segment:hover {
256 background: color-mix(in srgb, var(--primary) 15%, transparent);
257 border-radius: 2px;
258 }
259
260 .current-segment {
261 background: color-mix(in srgb, var(--accent) 30%, transparent);
262 border-radius: 2px;
263 }
264
265 .paragraph {
266 display: block;
267 margin: 0 0 1rem 0;
268 line-height: 1.6;
269 }
270
271 .audio-player {
272 margin-top: 1rem;
273 width: 100%;
274 }
275
276 .audio-player audio {
277 width: 100%;
278 height: 2.5rem;
279 }
280
281 .hidden {
282 display: none;
283 }
284
285 .file-input {
286 display: none;
287 }
288 `;
289
290 private eventSources: Map<string, EventSource> = new Map();
291 private handleAuthChange = async () => {
292 await this.checkHealth();
293 await this.loadJobs();
294 this.connectToJobStreams();
295 };
296
297 override async connectedCallback() {
298 super.connectedCallback();
299 await this.checkHealth();
300 await this.loadJobs();
301 this.connectToJobStreams();
302
303 // Listen for auth changes to reload jobs
304 window.addEventListener("auth-changed", this.handleAuthChange);
305 }
306
307 override disconnectedCallback() {
308 super.disconnectedCallback();
309 // Clean up all event sources and word streamers
310 for (const es of this.eventSources.values()) {
311 es.close();
312 }
313 this.eventSources.clear();
314
315 for (const streamer of this.wordStreamers.values()) {
316 streamer.clear();
317 }
318 this.wordStreamers.clear();
319 this.displayedTranscripts.clear();
320 this.lastTranscripts.clear();
321
322 window.removeEventListener("auth-changed", this.handleAuthChange);
323 }
324
325 private connectToJobStreams() {
326 // Connect to SSE streams for active jobs
327 for (const job of this.jobs) {
328 if (
329 job.status === "processing" ||
330 job.status === "transcribing" ||
331 job.status === "uploading"
332 ) {
333 this.connectToJobStream(job.id);
334 }
335 }
336 }
337
338 private connectToJobStream(jobId: string, retryCount = 0) {
339 if (this.eventSources.has(jobId)) {
340 return; // Already connected
341 }
342
343 const eventSource = new EventSource(`/api/transcriptions/${jobId}/stream`);
344
345 // Handle named "update" events from SSE stream
346 eventSource.addEventListener("update", async (event) => {
347 const update = JSON.parse(event.data);
348
349 // Update the job in our list efficiently (mutate in place for Lit)
350 const job = this.jobs.find((j) => j.id === jobId);
351 if (job) {
352 // Update properties directly
353 if (update.status !== undefined) job.status = update.status;
354 if (update.progress !== undefined) job.progress = update.progress;
355 if (update.transcript !== undefined) {
356 job.transcript = update.transcript;
357
358 // Get or create word streamer for this job
359 if (!this.wordStreamers.has(jobId)) {
360 const streamer = new WordStreamer(50, (word) => {
361 const current = this.displayedTranscripts.get(jobId) || "";
362 this.displayedTranscripts.set(jobId, current + word);
363 this.requestUpdate();
364 });
365 this.wordStreamers.set(jobId, streamer);
366 }
367
368 const streamer = this.wordStreamers.get(jobId)!;
369 const lastTranscript = this.lastTranscripts.get(jobId) || "";
370 const newTranscript = update.transcript;
371
372 // Check if this is new content we haven't seen
373 if (newTranscript !== lastTranscript) {
374 // If new transcript starts with last transcript, it's cumulative - add diff
375 if (newTranscript.startsWith(lastTranscript)) {
376 const newPortion = newTranscript.slice(lastTranscript.length);
377 if (newPortion.trim()) {
378 streamer.addChunk(newPortion);
379 }
380 } else {
381 // Completely different segment, add space separator then new content
382 if (lastTranscript) {
383 streamer.addChunk(" ");
384 }
385 streamer.addChunk(newTranscript);
386 }
387 this.lastTranscripts.set(jobId, newTranscript);
388 }
389
390 // On completion, show everything immediately
391 if (update.status === "completed") {
392 streamer.showAll();
393 this.wordStreamers.delete(jobId);
394 this.lastTranscripts.delete(jobId);
395 }
396 }
397
398 // Trigger Lit re-render by creating new array reference
399 this.jobs = [...this.jobs];
400
401 // Close connection if job is complete or failed
402 if (update.status === "completed" || update.status === "failed") {
403 eventSource.close();
404 this.eventSources.delete(jobId);
405
406 // Clean up streamer
407 const streamer = this.wordStreamers.get(jobId);
408 if (streamer) {
409 streamer.clear();
410 this.wordStreamers.delete(jobId);
411 }
412 this.lastTranscripts.delete(jobId);
413
414 // Load VTT for completed jobs
415 if (update.status === "completed") {
416 await this.loadVTTForJob(jobId);
417 }
418 }
419 }
420 });
421
422 eventSource.onerror = (error) => {
423 console.warn(`SSE connection error for job ${jobId}:`, error);
424 eventSource.close();
425 this.eventSources.delete(jobId);
426
427 // Check if the job still exists before retrying
428 const job = this.jobs.find((j) => j.id === jobId);
429 if (!job) {
430 console.log(`Job ${jobId} no longer exists, skipping retry`);
431 return;
432 }
433
434 // Don't retry if job is already in a terminal state
435 if (job.status === "completed" || job.status === "failed") {
436 console.log(`Job ${jobId} is ${job.status}, skipping retry`);
437 return;
438 }
439
440 // Retry connection up to 3 times with exponential backoff
441 if (retryCount < 3) {
442 const backoff = 2 ** retryCount * 1000; // 1s, 2s, 4s
443 console.log(
444 `Retrying connection in ${backoff}ms (attempt ${retryCount + 1}/3)`,
445 );
446 setTimeout(() => {
447 this.connectToJobStream(jobId, retryCount + 1);
448 }, backoff);
449 } else {
450 console.error(`Failed to connect to job ${jobId} after 3 attempts`);
451 }
452 };
453
454 this.eventSources.set(jobId, eventSource);
455 }
456
457 async checkHealth() {
458 try {
459 const response = await fetch("/api/transcriptions/health");
460 if (response.ok) {
461 const data = await response.json();
462 this.serviceAvailable = data.available;
463 } else {
464 this.serviceAvailable = false;
465 }
466 } catch {
467 this.serviceAvailable = false;
468 }
469 }
470
471 async loadJobs() {
472 try {
473 const response = await fetch("/api/transcriptions");
474 if (response.ok) {
475 const data = await response.json();
476 this.jobs = data.jobs;
477
478 // Initialize displayedTranscripts for completed/failed jobs
479 for (const job of this.jobs) {
480 if ((job.status === "completed" || job.status === "failed") && job.transcript) {
481 this.displayedTranscripts.set(job.id, job.transcript);
482 }
483
484 // Fetch VTT for completed jobs
485 if (job.status === "completed") {
486 await this.loadVTTForJob(job.id);
487 }
488 }
489 // Don't override serviceAvailable - it's set by checkHealth()
490 } else if (response.status === 404) {
491 // Transcription service not available - show empty state
492 this.jobs = [];
493 } else {
494 console.error("Failed to load jobs:", response.status);
495 }
496 } catch (error) {
497 // Network error or service unavailable - don't break the page
498 console.warn("Transcription service unavailable:", error);
499 this.jobs = [];
500 }
501 }
502
503 private async loadVTTForJob(jobId: string) {
504 try {
505 const response = await fetch(`/api/transcriptions/${jobId}?format=vtt`);
506 if (response.ok) {
507 const vttContent = await response.text();
508
509 // Update job with VTT content
510 const job = this.jobs.find((j) => j.id === jobId);
511 if (job) {
512 job.vttContent = vttContent;
513 job.audioUrl = `/api/transcriptions/${jobId}/audio`;
514 this.jobs = [...this.jobs];
515 }
516 }
517 } catch (error) {
518 console.warn(`Failed to load VTT for job ${jobId}:`, error);
519 }
520 }
521
522
523
524 private handleDragOver(e: DragEvent) {
525 e.preventDefault();
526 this.dragOver = true;
527 }
528
529 private handleDragLeave(e: DragEvent) {
530 e.preventDefault();
531 this.dragOver = false;
532 }
533
534 private async handleDrop(e: DragEvent) {
535 e.preventDefault();
536 this.dragOver = false;
537
538 const files = e.dataTransfer?.files;
539 const file = files?.[0];
540 if (file) {
541 await this.uploadFile(file);
542 }
543 }
544
545 private async handleFileSelect(e: Event) {
546 const input = e.target as HTMLInputElement;
547 const file = input.files?.[0];
548 if (file) {
549 await this.uploadFile(file);
550 }
551 }
552
553 private async uploadFile(file: File) {
554 const allowedTypes = [
555 "audio/mpeg", // MP3
556 "audio/wav", // WAV
557 "audio/x-wav", // WAV (alternative)
558 "audio/m4a", // M4A
559 "audio/x-m4a", // M4A (alternative)
560 "audio/mp4", // MP4 audio
561 "audio/aac", // AAC
562 "audio/ogg", // OGG
563 "audio/webm", // WebM audio
564 "audio/flac", // FLAC
565 ];
566
567 // Also check file extension for M4A files (sometimes MIME type isn't set correctly)
568 const isM4A = file.name.toLowerCase().endsWith(".m4a");
569 const isAllowedType =
570 allowedTypes.includes(file.type) || (isM4A && file.type === "");
571
572 if (!isAllowedType) {
573 alert(
574 "Please select a supported audio file (MP3, WAV, M4A, AAC, OGG, WebM, or FLAC)",
575 );
576 return;
577 }
578
579 if (file.size > 100 * 1024 * 1024) {
580 // 100MB limit
581 alert("File size must be less than 100MB");
582 return;
583 }
584
585 this.isUploading = true;
586
587 try {
588 const formData = new FormData();
589 formData.append("audio", file);
590
591 const response = await fetch("/api/transcriptions", {
592 method: "POST",
593 body: formData,
594 });
595
596 if (!response.ok) {
597 const data = await response.json();
598 alert(
599 data.error ||
600 "Upload failed - transcription service may be unavailable",
601 );
602 } else {
603 const result = await response.json();
604 await this.loadJobs();
605 // Connect to SSE stream for this new job
606 this.connectToJobStream(result.id);
607 }
608 } catch {
609 alert("Upload failed - transcription service may be unavailable");
610 } finally {
611 this.isUploading = false;
612 }
613 }
614
615 private getStatusClass(status: string) {
616 return `status-${status}`;
617 }
618
619 private renderTranscript(job: TranscriptionJob) {
620 if (!job.vttContent) {
621 const displayed = this.displayedTranscripts.get(job.id) || "";
622 return displayed;
623 }
624
625 // Delegate VTT rendering and highlighting to the vtt-viewer component
626 return html`<vtt-viewer .vttContent=${job.vttContent ?? ""} .audioId=${`audio-${job.id}`}></vtt-viewer>`;
627 }
628
629
630
631 override render() {
632 return html`
633 <div class="upload-area ${this.dragOver ? "drag-over" : ""} ${!this.serviceAvailable ? "disabled" : ""}"
634 @dragover=${this.serviceAvailable ? this.handleDragOver : null}
635 @dragleave=${this.serviceAvailable ? this.handleDragLeave : null}
636 @drop=${this.serviceAvailable ? this.handleDrop : null}
637 @click=${this.serviceAvailable ? () => (this.shadowRoot?.querySelector(".file-input") as HTMLInputElement)?.click() : null}>
638 <div class="upload-icon">馃幍</div>
639 <div class="upload-text">
640 ${
641 !this.serviceAvailable
642 ? "Transcription service unavailable"
643 : this.isUploading
644 ? "Uploading..."
645 : "Drop audio file here or click to browse"
646 }
647 </div>
648 <div class="upload-hint">
649 ${this.serviceAvailable ? "Supports MP3, WAV, M4A, AAC, OGG, WebM, FLAC up to 100MB" : "Transcription is currently unavailable"}
650 </div>
651 <input type="file" class="file-input" accept="audio/mpeg,audio/wav,audio/m4a,audio/mp4,audio/aac,audio/ogg,audio/webm,audio/flac,.m4a" @change=${this.handleFileSelect} ${!this.serviceAvailable ? "disabled" : ""} />
652 </div>
653
654 <div class="jobs-section ${this.jobs.length === 0 ? "hidden" : ""}">
655 <h3 class="jobs-title">Your Transcriptions</h3>
656 ${this.jobs.map(
657 (job) => html`
658 <div class="job-card">
659 <div class="job-header">
660 <span class="job-filename">${job.filename}</span>
661 <span class="job-status ${this.getStatusClass(job.status)}">${job.status}</span>
662 </div>
663
664 ${
665 job.status === "uploading" ||
666 job.status === "processing" ||
667 job.status === "transcribing"
668 ? html`
669 <div class="progress-bar">
670 <div class="progress-fill ${job.status === "processing" ? "indeterminate" : ""}" style="${job.status === "processing" ? "" : `width: ${job.progress}%`}"></div>
671 </div>
672 `
673 : ""
674 }
675
676 ${
677 job.status === "completed" && job.audioUrl && job.vttContent
678 ? html`
679 <div class="audio-player">
680 <audio id="audio-${job.id}" preload="metadata" controls src="${job.audioUrl}"></audio>
681 </div>
682 ${this.renderTranscript(job)}
683 `
684 : this.displayedTranscripts.has(job.id) && this.displayedTranscripts.get(job.id)
685 ? html`
686 <div class="job-transcript">${this.renderTranscript(job)}</div>
687 `
688 : ""
689 }
690 </div>
691 `,
692 )}
693 </div>
694 `;
695 }
696}