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