馃 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}