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