Download index.html from jebin2/STT: direct link, hf CLI and curl.
- Browser
- Download file 57.9 kB
-
https://huggingface.co/spaces/jebin2/STT/resolve/main/index.html
- Command line
-
hf download hf://spaces/jebin2/STT/index.html
-
curl -L -o index.html https://huggingface.co/spaces/jebin2/STT/resolve/main/index.html
57.9 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta content="width=device-width, initial-scale=1.0" name="viewport" /> | |
| <title>STT - Speech to Text</title> | |
| <!-- External Assets --> | |
| <script src="/static/js/tailwind.min.js"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@300..700&family=Caveat:wght@400..700&display=swap" | |
| rel="stylesheet" /> | |
| <link | |
| href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" | |
| rel="stylesheet" /> | |
| <!-- Tailwind Configuration --> | |
| <script id="tailwind-config"> | |
| tailwind.config = { | |
| darkMode: "class", | |
| theme: { | |
| extend: { | |
| colors: { | |
| surface: "#e6f0fd", | |
| "crayon-blue": "#2563eb", | |
| "crayon-red": "#dc2626", | |
| "crayon-green": "#16a34a", | |
| "crayon-yellow": "#ca8a04", | |
| "crayon-orange": "#ea580c", | |
| "crayon-purple": "#7c3aed", | |
| "crayon-dark": "#1A1A1A" | |
| }, | |
| fontFamily: { | |
| "fredoka": ["Fredoka", "sans-serif"], | |
| "caveat": ["Caveat", "cursive"] | |
| }, | |
| fontSize: { | |
| "headline-lg": ["42px", { lineHeight: "1.1", fontWeight: "700" }], | |
| "headline-md": ["28px", { lineHeight: "1.2", fontWeight: "600" }], | |
| "body-lg": ["24px", { lineHeight: "1.5", fontWeight: "500" }], | |
| "label-sm": ["18px", { lineHeight: "1.2", letterSpacing: "0.01em", fontWeight: "500" }], | |
| "body-md": ["20px", { lineHeight: "1.5", fontWeight: "400" }] | |
| } | |
| }, | |
| }, | |
| } | |
| </script> | |
| <svg height="0" style="position: absolute;" width="0"> | |
| <filter height="120%" id="crayon-texture" width="120%" x="-10%" y="-10%"> | |
| <feTurbulence baseFrequency="0.4" numOctaves="3" result="noise" type="fractalNoise"></feTurbulence> | |
| <feDisplacementMap in="SourceGraphic" in2="noise" scale="2.5" xChannelSelector="R" yChannelSelector="G"> | |
| </feDisplacementMap> | |
| </filter> | |
| <filter height="120%" id="crayon-heavy" width="120%" x="-10%" y="-10%"> | |
| <feTurbulence baseFrequency="0.5" numOctaves="4" result="noise" type="fractalNoise"></feTurbulence> | |
| <feDisplacementMap in="SourceGraphic" in2="noise" scale="4" xChannelSelector="R" yChannelSelector="G"> | |
| </feDisplacementMap> | |
| </filter> | |
| </svg> | |
| <style> | |
| /* Sketchbook Styles */ | |
| body { | |
| background-color: #e6f0fd; | |
| background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.08'/%3E%3C/svg%3E"); | |
| color: #1A1A1A; | |
| font-family: 'Fredoka', sans-serif; | |
| } | |
| .bg-surface { | |
| background-color: rgb(255 255 255 / 0%) ; | |
| backdrop-filter: blur(2px); | |
| } | |
| .crayon-filter { | |
| filter: url('#crayon-texture'); | |
| } | |
| .crayon-heavy { | |
| filter: url('#crayon-heavy'); | |
| } | |
| .crayon-border-green { | |
| border: 4px solid #16a34a; | |
| border-radius: 12px 8px 15px 10px / 8px 14px 10px 12px; | |
| filter: url('#crayon-texture'); | |
| } | |
| .task-card { | |
| border: 3px solid rgba(124, 58, 237, 0.4); | |
| border-radius: 20px 15px 25px 18px / 18px 25px 15px 20px; | |
| filter: url('#crayon-texture'); | |
| } | |
| .crayon-border-blue { | |
| border: 4px dashed #2563eb; | |
| border-radius: 15px 10px 12px 18px / 12px 18px 15px 10px; | |
| filter: url('#crayon-texture'); | |
| } | |
| .crayon-border-purple { | |
| border: 4px solid #7c3aed; | |
| border-radius: 10px 16px 12px 14px / 16px 12px 14px 10px; | |
| filter: url('#crayon-texture'); | |
| } | |
| .crayon-button { | |
| border: 4px solid #2563eb; | |
| border-radius: 12px 8px 14px 10px / 8px 14px 10px 12px; | |
| transition: all 0.2s ease; | |
| filter: url('#crayon-texture'); | |
| cursor: pointer; | |
| } | |
| .crayon-button:hover { | |
| transform: scale(1.05) rotate(1deg); | |
| box-shadow: 6px 6px 0px 0px rgba(0, 0, 0, 0.1); | |
| } | |
| .crayon-button:hover .material-symbols-outlined.spin-on-hover { | |
| animation: spin 2s linear infinite; | |
| } | |
| @keyframes spin { | |
| from { | |
| transform: rotate(0deg); | |
| } | |
| to { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| @keyframes drift { | |
| 0% { | |
| transform: translateX(0); | |
| } | |
| 50% { | |
| transform: translateX(20px); | |
| } | |
| 100% { | |
| transform: translateX(0); | |
| } | |
| } | |
| .drift-slow { | |
| animation: drift 8s ease-in-out infinite; | |
| } | |
| .drift-medium { | |
| animation: drift 5s ease-in-out infinite; | |
| } | |
| .organic-shape { | |
| border-radius: 255px 15px 225px 15px/15px 225px 15px 255px; | |
| filter: url('#crayon-texture'); | |
| } | |
| .scribble-fill-green { | |
| background: repeating-linear-gradient(60deg, #16a34a, #16a34a 2px, #15803d 3px, #16a34a 4px); | |
| } | |
| .progress-fill { | |
| background: repeating-linear-gradient(80deg, #2563eb, #2563eb 2px, #1d4ed8 3px, #2563eb 5px); | |
| } | |
| .material-symbols-outlined { | |
| font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48; | |
| filter: url('#crayon-texture'); | |
| } | |
| /* --- Modals --- */ | |
| .modal { | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(15, 23, 42, 0.6); | |
| display: none; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 100; | |
| padding: 2rem; | |
| } | |
| .modal.active { | |
| display: flex; | |
| } | |
| .modal-content { | |
| border: 4px solid #2563eb; | |
| width: 100%; | |
| max-width: 900px; | |
| border-radius: 24px; | |
| display: flex; | |
| flex-direction: column; | |
| max-height: 85vh; | |
| box-shadow: 12px 12px 0px 0px rgba(0, 0, 0, 0.1); | |
| position: relative; | |
| } | |
| .modal-sketch-bg { | |
| position: absolute; | |
| inset: -8px; | |
| border: 6px solid #2563eb; | |
| backdrop-filter: blur(10px); | |
| border-radius: 255px 15px 225px 15px/15px 225px 15px 255px; | |
| z-index: -1; | |
| filter: url('#crayon-texture'); | |
| pointer-events: none; | |
| } | |
| .modal-header { | |
| padding: 1.5rem 2rem; | |
| border-bottom: 3px dashed #adc6ff; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| position: relative; | |
| z-index: 10; | |
| } | |
| .modal-body { | |
| padding: 2rem; | |
| overflow-y: auto; | |
| position: relative; | |
| z-index: 10; | |
| } | |
| #resultText, | |
| pre { | |
| border: 3px dashed #adc6ff ; | |
| padding: 2rem ; | |
| border-radius: 20px ; | |
| font-family: 'Fredoka', sans-serif ; | |
| font-size: 1.5rem ; | |
| font-weight: 600 ; | |
| color: #fff ; | |
| white-space: pre-wrap ; | |
| word-break: break-all ; | |
| line-height: 1.6 ; | |
| filter: url('#crayon-texture'); | |
| } | |
| .close-modal { | |
| background: transparent; | |
| border: none; | |
| color: #dc2626; | |
| font-size: 3rem; | |
| cursor: pointer; | |
| font-weight: 700; | |
| } | |
| .text-headline-lg { | |
| filter: url('#crayon-texture'); | |
| } | |
| .copy-btn { | |
| background: #16a34a; | |
| color: white; | |
| padding: 0.5rem 1.5rem; | |
| border-radius: 12px; | |
| font-weight: 700; | |
| box-shadow: 4px 4px 0px 0px #15803d; | |
| transition: all 0.2s; | |
| filter: url('#crayon-texture'); | |
| } | |
| .copy-btn:hover { | |
| transform: translate(-2px, -2px); | |
| box-shadow: 6px 6px 0px 0px #15803d; | |
| } | |
| .live-btn { | |
| border: 4px solid #dc2626; | |
| border-radius: 12px 8px 14px 10px / 8px 14px 10px 12px; | |
| transition: all 0.2s ease; | |
| filter: url('#crayon-texture'); | |
| cursor: pointer; | |
| animation: livePulse 2s ease-in-out infinite; | |
| } | |
| .live-btn:hover { | |
| transform: scale(1.05) rotate(-1deg); | |
| box-shadow: 6px 6px 0px 0px rgba(0, 0, 0, 0.1); | |
| } | |
| @keyframes livePulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.6; } | |
| } | |
| #liveModal .live-transcript { | |
| border: 3px dashed #fca5a5 ; | |
| padding: 1.5rem ; | |
| border-radius: 20px ; | |
| font-family: 'Fredoka', sans-serif ; | |
| font-size: 1.3rem ; | |
| font-weight: 500 ; | |
| color: #fff ; | |
| white-space: pre-wrap ; | |
| word-break: break-all ; | |
| line-height: 1.6 ; | |
| filter: url('#crayon-texture'); | |
| min-height: 200px; | |
| max-height: 400px; | |
| overflow-y: auto; | |
| } | |
| #liveModal select { | |
| border: 3px solid #7c3aed; | |
| border-radius: 12px; | |
| padding: 0.5rem 1rem; | |
| font-family: 'Fredoka', sans-serif; | |
| font-size: 1.1rem; | |
| font-weight: 600; | |
| background: white; | |
| filter: url('#crayon-texture'); | |
| outline: none; | |
| } | |
| #liveModal select:focus { | |
| border-color: #2563eb; | |
| } | |
| /* --- Specific UI Elements --- */ | |
| .status-modal-content { | |
| max-width: 450px; | |
| text-align: center; | |
| padding: 3rem 2rem; | |
| } | |
| .status-modal-bg { | |
| position: absolute; | |
| inset: -12px; | |
| border: 8px solid #2563eb; | |
| background: #e6f0fd; | |
| border-radius: 20px 40px 15px 35px / 35px 15px 40px 20px; | |
| z-index: -1; | |
| filter: url('#crayon-texture'); | |
| pointer-events: none; | |
| } | |
| .status-icon-container { | |
| width: 100px; | |
| height: 100px; | |
| margin: 0 auto 1.5rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| border-radius: 20px 15px 25px 18px / 18px 25px 15px 20px; | |
| border: 4px solid currentColor; | |
| filter: url('#crayon-texture'); | |
| position: relative; | |
| z-index: 20; | |
| } | |
| .status-icon-bg { | |
| position: absolute; | |
| inset: 0; | |
| background: currentColor; | |
| opacity: 0.15; | |
| z-index: -1; | |
| border-radius: inherit; | |
| } | |
| .modal-decoration { | |
| position: absolute; | |
| pointer-events: none; | |
| opacity: 0.3; | |
| z-index: 5; | |
| filter: url('#crayon-texture'); | |
| } | |
| .table-container::-webkit-scrollbar { | |
| width: 10px; | |
| } | |
| .table-container::-webkit-scrollbar-track { | |
| background: #f1f5f9; | |
| border-radius: 10px; | |
| } | |
| .table-container::-webkit-scrollbar-thumb { | |
| background: #cbd5e1; | |
| border-radius: 10px; | |
| border: 2px solid #f1f5f9; | |
| } | |
| .dragging { | |
| border-color: #16a34a ; | |
| background-color: #f0fdf4 ; | |
| } | |
| </style> | |
| </head> | |
| <body class="h-screen flex flex-col overflow-hidden relative"> | |
| <!-- Main Background Decorations --> | |
| <div class="fixed inset-0 pointer-events-none overflow-hidden -z-10 opacity-40"> | |
| <!-- Stars --> | |
| <span | |
| class="material-symbols-outlined absolute text-5xl text-crayon-yellow top-20 left-[10%] rotate-12 crayon-heavy animate-pulse">star</span> | |
| <span | |
| class="material-symbols-outlined absolute text-3xl text-crayon-orange top-[40%] left-[5%] -rotate-12 crayon-filter">star</span> | |
| <span | |
| class="material-symbols-outlined absolute text-4xl text-crayon-yellow bottom-[20%] left-[15%] rotate-45 animate-pulse">star</span> | |
| <span | |
| class="material-symbols-outlined absolute text-6xl text-crayon-orange top-[15%] right-[15%] rotate-[-15deg] crayon-heavy">star</span> | |
| <span | |
| class="material-symbols-outlined absolute text-3xl text-crayon-yellow bottom-[30%] right-[10%] rotate-12 animate-pulse">star</span> | |
| <!-- Clouds --> | |
| <span | |
| class="material-symbols-outlined absolute text-[120px] text-crayon-blue top-[10%] left-[25%] opacity-20 drift-slow">cloud</span> | |
| <span | |
| class="material-symbols-outlined absolute text-[80px] text-crayon-purple bottom-[15%] left-[40%] opacity-10 drift-medium">cloud</span> | |
| <span | |
| class="material-symbols-outlined absolute text-[100px] text-crayon-blue top-[60%] right-[25%] opacity-15 drift-slow">cloud</span> | |
| <span | |
| class="material-symbols-outlined absolute text-[150px] text-crayon-purple top-[30%] right-[5%] opacity-10 drift-medium">cloud</span> | |
| <!-- Hearts --> | |
| <span | |
| class="material-symbols-outlined absolute text-4xl text-crayon-red top-[25%] left-[18%] rotate-[-15deg] crayon-filter animate-pulse">favorite</span> | |
| <span | |
| class="material-symbols-outlined absolute text-2xl text-crayon-red bottom-[10%] right-[20%] rotate-12 crayon-filter">favorite</span> | |
| <span | |
| class="material-symbols-outlined absolute text-5xl text-crayon-red top-[70%] left-[8%] rotate-[10deg] animate-pulse">favorite</span> | |
| </div> | |
| <!-- SVG Filters --> | |
| <svg height="0" width="0" style="position: absolute;"> | |
| <filter id="crayon-texture" x="-10%" y="-10%" width="120%" height="120%"> | |
| <feTurbulence type="fractalNoise" baseFrequency="0.4" numOctaves="3" result="noise" /> | |
| <feDisplacementMap in="SourceGraphic" in2="noise" scale="2.5" xChannelSelector="R" yChannelSelector="G" /> | |
| </filter> | |
| <filter id="crayon-heavy" x="-10%" y="-10%" width="120%" height="120%"> | |
| <feTurbulence type="fractalNoise" baseFrequency="0.5" numOctaves="4" result="noise" /> | |
| <feDisplacementMap in="SourceGraphic" in2="noise" scale="4" xChannelSelector="R" yChannelSelector="G" /> | |
| </filter> | |
| </svg> | |
| <!-- Header --> | |
| <header | |
| class="bg-surface flex justify-between items-center w-[calc(100%-48px)] mx-6 mt-6 px-8 py-5 crayon-border-green z-10 shrink-0 organic-shape shadow-sm"> | |
| <div class="flex items-center gap-5"> | |
| <div | |
| class="bg-crayon-green text-white w-14 h-14 rounded-2xl flex items-center justify-center border-[3px] border-crayon-green rotate-[-4deg] crayon-filter scribble-fill-green shadow-md"> | |
| <span class="material-symbols-outlined text-4xl">mic</span> | |
| </div> | |
| <div class="flex flex-col -rotate-1"> | |
| <h1 class="text-headline-lg text-[#4c1d95] leading-none mb-1">STT</h1> | |
| <span class="text-label-sm text-[#4b5563] font-bold">Speech to Text</span> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-10 relative"> | |
| <div class="absolute -left-48 top-2 rotate-12 crayon-heavy"> | |
| <span class="material-symbols-outlined text-4xl text-crayon-yellow">star</span> | |
| </div> | |
| <div class="absolute -left-24 top-0 -rotate-6 crayon-heavy opacity-80"> | |
| <span class="material-symbols-outlined text-5xl text-crayon-blue">cloud</span> | |
| </div> | |
| <button id="apiDocBtn" | |
| class="flex items-center gap-2 text-headline-md text-crayon-purple px-8 py-3 bg-surface crayon-button rotate-1 shadow-md"> | |
| <span class="material-symbols-outlined text-3xl">menu_book</span> | |
| API DOC | |
| </button> | |
| <div | |
| class="flex items-center gap-4 bg-surface px-6 py-3 rounded-full border-[4px] border-crayon-green organic-shape shadow-md"> | |
| <div id="healthDot" class="w-4 h-4 rounded-full bg-crayon-green shadow-[0_0_12px_rgba(22,163,74,0.5)]"> | |
| </div> | |
| <span id="healthText" class="text-headline-md text-crayon-green text-2xl">Service Online</span> | |
| <button id="liveBtn" | |
| class="hidden items-center gap-2 px-6 py-2 bg-crayon-red text-white text-headline-md font-bold live-btn shadow-md"> | |
| <span class="material-symbols-outlined text-3xl">mic</span> | |
| LIVE | |
| </button> | |
| <div class="text-crayon-orange flex items-center justify-center rotate-[15deg]"> | |
| <span class="material-symbols-outlined text-4xl">light_mode</span> | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- Main Content --> | |
| <main class="flex-1 flex overflow-hidden p-8 gap-8 mx-auto w-full relative"> | |
| <!-- Input Section --> | |
| <section class="w-[450px] flex flex-col gap-8"> | |
| <div id="uploadZone" | |
| class="flex flex-col gap-6 p-8 bg-surface crayon-border-blue flex-1 relative organic-shape cursor-pointer group shadow-sm hover:shadow-md transition-shadow"> | |
| <div class="flex items-center gap-4 mb-2 rotate-1"> | |
| <div | |
| class="bg-crayon-blue text-white rounded-full w-14 h-14 flex items-center justify-center border-2 border-crayon-blue crayon-filter shadow-md"> | |
| <span class="material-symbols-outlined text-4xl">upload</span> | |
| </div> | |
| <div> | |
| <h2 class="text-headline-md text-crayon-blue leading-none mb-1 flex items-center gap-2"> | |
| INPUT | |
| <span class="material-symbols-outlined text-crayon-red text-2xl rotate-12">favorite</span> | |
| </h2> | |
| <p class="text-label-sm text-[#6b7280]">Upload your file</p> | |
| </div> | |
| </div> | |
| <div | |
| class="flex-1 flex flex-col items-center justify-center relative border-[4px] border-dashed border-[#adc6ff] rounded-[32px] bg-surface p-6 group-hover:bg-blue-50 transition-colors"> | |
| <div class="relative w-48 h-48 mb-8 flex items-center justify-center"> | |
| <div | |
| class="absolute w-36 h-44 bg-blue-100 border-[3px] border-crayon-blue rounded-xl rotate-[12deg] right-4 bottom-4 organic-shape opacity-60"> | |
| </div> | |
| <div | |
| class="absolute w-36 h-44 bg-surface border-[3px] border-crayon-blue rounded-xl z-10 flex flex-col items-center p-4 organic-shape rotate-[-4deg] shadow-sm"> | |
| <div | |
| class="w-8 h-8 rounded-full bg-crayon-yellow self-start mb-4 border-[2px] border-[#1A1A1A]"> | |
| </div> | |
| <div class="w-full h-2 bg-[#adc6ff] rounded-full mb-3"></div> | |
| <div class="w-2/3 h-2 bg-[#adc6ff] rounded-full self-start"></div> | |
| </div> | |
| <div | |
| class="absolute -bottom-4 -right-4 bg-crayon-blue text-white rounded-full w-16 h-16 flex items-center justify-center border-[4px] border-white z-20 shadow-xl group-hover:scale-110 transition-transform rotate-12 crayon-filter"> | |
| <span class="material-symbols-outlined text-4xl">arrow_upward</span> | |
| </div> | |
| </div> | |
| <p class="text-headline-md text-[#1A1A1A] mb-1 font-bold">Drag & drop here</p> | |
| <p class="text-label-sm text-crayon-purple mb-8 font-bold">or click to browse</p> | |
| <div class="flex gap-2 mt-auto w-full justify-center flex-wrap"> | |
| <span | |
| class="px-4 py-1.5 bg-surface border-[2px] border-crayon-blue text-crayon-blue text-lg font-bold rounded-xl organic-shape -rotate-2">WAV</span> | |
| <span | |
| class="px-4 py-1.5 bg-surface border-[2px] border-crayon-green text-crayon-green text-lg font-bold rounded-xl organic-shape rotate-1">MP3</span> | |
| <span | |
| class="px-4 py-1.5 bg-surface border-[2px] border-crayon-red text-crayon-red text-lg font-bold rounded-xl organic-shape -rotate-1">FLAC</span> | |
| </div> | |
| </div> | |
| <!-- stopPropagation so using these controls doesn't open the file picker --> | |
| <div class="flex items-center justify-center gap-4 mt-6 flex-wrap" | |
| onclick="event.stopPropagation()"> | |
| <label class="text-label-sm text-[#4b5563] font-bold">Model:</label> | |
| <select id="uploadEngineSelect"></select> | |
| <label class="text-label-sm text-[#4b5563] font-bold">Spoken:</label> | |
| <select id="uploadLanguageSelect"></select> | |
| <label class="text-label-sm text-[#4b5563] font-bold">Output:</label> | |
| <select id="uploadTaskSelect"></select> | |
| </div> | |
| <p id="uploadEngineNote" class="text-label-sm text-[#6b7280] text-center mt-2" | |
| onclick="event.stopPropagation()"></p> | |
| <input type="file" id="fileInput" hidden accept="audio/*"> | |
| </div> | |
| </section> | |
| <!-- Activity Section --> | |
| <section | |
| class="flex-1 flex flex-col bg-surface crayon-border-purple p-8 relative organic-shape overflow-hidden shadow-sm"> | |
| <div class="flex items-center justify-between mb-8"> | |
| <div class="flex items-center gap-4 rotate-1"> | |
| <div | |
| class="bg-crayon-purple text-white rounded-full w-14 h-14 flex items-center justify-center border-[3px] border-crayon-purple shadow-md"> | |
| <span class="material-symbols-outlined text-4xl">schedule</span> | |
| </div> | |
| <div> | |
| <h2 class="text-headline-lg text-[#4c1d95] leading-none mb-1">ACTIVITY</h2> | |
| <p class="text-label-sm text-[#6b7280] font-bold">Recent tasks</p> | |
| </div> | |
| </div> | |
| <div class="relative w-40 h-20 flex items-center"> | |
| <svg class="absolute left-[-30px] top-4 w-32 h-16 crayon-filter text-crayon-yellow" fill="none" | |
| stroke="currentColor" stroke-dasharray="6 6" stroke-linecap="round" stroke-width="3" | |
| viewBox="0 0 100 50"> | |
| <path d="M10,40 Q40,50 60,30 T90,10" /> | |
| </svg> | |
| <span | |
| class="material-symbols-outlined text-5xl text-crayon-orange absolute right-0 top-0 rotate-12">send</span> | |
| </div> | |
| <button onclick="loadTasks()" | |
| class="flex items-center gap-2 text-headline-md text-crayon-blue px-8 py-3 bg-surface crayon-button border-[4px] -rotate-1 shadow-md"> | |
| <span class="material-symbols-outlined text-3xl spin-on-hover">sync</span> | |
| Refresh | |
| </button> | |
| </div> | |
| <!-- List Header --> | |
| <div class="flex w-full px-6 pb-4 border-b-[5px] text-[#4c1d95] font-bold text-xl uppercase tracking-wider" | |
| style="border-color: rgba(124, 58, 237, 0.4); border-radius: 20px 15px 25px 18px / 18px 25px 15px 20px; filter: url(#crayon-texture);"> | |
| <div class="w-1/2">CONTENT</div> | |
| <div class="w-1/6 text-center">STATUS</div> | |
| <div class="w-1/4 text-center">PROGRESS</div> | |
| <div class="w-[10%] text-center">ACTION</div> | |
| </div> | |
| <!-- Task List Body --> | |
| <div id="queueBody" class="flex flex-col gap-5 mt-6 overflow-y-auto flex-1 table-container pr-4"> | |
| <div class="text-center py-32 text-headline-md text-[#94a3b8] font-bold opacity-60">No tasks found | |
| yet...</div> | |
| </div> | |
| </section> | |
| </main> | |
| <!-- Modals --> | |
| <!-- Result & API Modal --> | |
| <div id="resultModal" class="modal"> | |
| <div class="modal-content"> | |
| <div class="modal-sketch-bg"></div> | |
| <!-- Decorations --> | |
| <span | |
| class="material-symbols-outlined modal-decoration text-6xl text-crayon-yellow top-4 left-4 -rotate-12">star</span> | |
| <span | |
| class="material-symbols-outlined modal-decoration text-8xl text-crayon-blue top-12 right-20 opacity-20">cloud</span> | |
| <span | |
| class="material-symbols-outlined modal-decoration text-4xl text-crayon-orange bottom-10 left-10 rotate-45">star</span> | |
| <span | |
| class="material-symbols-outlined modal-decoration text-7xl text-crayon-purple bottom-4 right-4 -rotate-6 opacity-40">cloud</span> | |
| <div class="modal-header"> | |
| <div class="flex items-center gap-6"> | |
| <span id="modalTitle" class="text-headline-lg text-[#1e1b4b]">Transcription Result</span> | |
| <button id="copyBtn" onclick="copyResult()" class="copy-btn">📋 Copy Text</button> | |
| </div> | |
| <button class="close-modal" onclick="closeModal()">×</button> | |
| </div> | |
| <div class="modal-body"> | |
| <pre id="resultText"></pre> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Status Modal --> | |
| <div id="statusModal" class="modal"> | |
| <div class="modal-content status-modal-content"> | |
| <div id="statusBg" class="status-modal-bg"></div> | |
| <span | |
| class="material-symbols-outlined modal-decoration text-4xl text-crayon-yellow top-6 right-6 rotate-12">star</span> | |
| <span | |
| class="material-symbols-outlined modal-decoration text-6xl text-crayon-blue top-10 left-4 opacity-30">cloud</span> | |
| <span | |
| class="material-symbols-outlined modal-decoration text-3xl text-crayon-orange bottom-8 right-10 -rotate-12">star</span> | |
| <span | |
| class="material-symbols-outlined modal-decoration text-5xl text-crayon-purple bottom-10 left-8 rotate-6 opacity-30">cloud</span> | |
| <div id="statusIconContainer" class="status-icon-container text-crayon-blue"> | |
| <div class="status-icon-bg"></div> | |
| <span id="statusIcon" class="material-symbols-outlined text-6xl animate-bounce">upload</span> | |
| </div> | |
| <h2 id="statusMessage" class="text-headline-lg text-crayon-blue mb-2">Uploading...</h2> | |
| <p id="statusSubMessage" class="text-body-lg text-[#4b5563]">Processing your request, please wait.</p> | |
| </div> | |
| </div> | |
| <!-- Live STT Modal --> | |
| <div id="liveModal" class="modal"> | |
| <div class="modal-content"> | |
| <div class="modal-sketch-bg"></div> | |
| <span class="material-symbols-outlined modal-decoration text-6xl text-crayon-red top-4 left-4 -rotate-12">mic</span> | |
| <span class="material-symbols-outlined modal-decoration text-8xl text-crayon-blue top-12 right-20 opacity-20">cloud</span> | |
| <span class="material-symbols-outlined modal-decoration text-4xl text-crayon-orange bottom-10 left-10 rotate-45">star</span> | |
| <span class="material-symbols-outlined modal-decoration text-7xl text-crayon-purple bottom-4 right-4 -rotate-6 opacity-40">cloud</span> | |
| <div class="modal-header"> | |
| <div class="flex items-center gap-6"> | |
| <span class="text-headline-lg text-[#1e1b4b]">🔴 Live STT</span> | |
| </div> | |
| <button class="close-modal" onclick="closeLiveModal()">×</button> | |
| </div> | |
| <div class="modal-body"> | |
| <div class="flex items-center gap-6 mb-6 flex-wrap"> | |
| <label class="text-label-sm text-[#4b5563] font-bold">Engine:</label> | |
| <select id="liveEngineSelect"></select> | |
| <label id="liveModelLabel" class="text-label-sm text-[#4b5563] font-bold">Size:</label> | |
| <select id="liveModelSelect"></select> | |
| <label class="text-label-sm text-[#4b5563] font-bold">Speaking:</label> | |
| <select id="liveLanguageSelect"></select> | |
| <label class="text-label-sm text-[#4b5563] font-bold">Output:</label> | |
| <select id="liveTaskSelect"></select> | |
| <button id="startLiveBtn" | |
| class="flex items-center gap-2 px-8 py-3 bg-crayon-green text-white text-headline-md font-bold crayon-button border-crayon-green shadow-md"> | |
| <span class="material-symbols-outlined text-3xl">play_arrow</span> | |
| Start | |
| </button> | |
| <button id="stopLiveBtn" | |
| class="hidden items-center gap-2 px-8 py-3 bg-crayon-red text-white text-headline-md font-bold crayon-button border-crayon-red shadow-md"> | |
| <span class="material-symbols-outlined text-3xl">stop</span> | |
| Stop | |
| </button> | |
| <div id="liveStatusBadge" class="hidden items-center gap-2 px-4 py-2 bg-crayon-red text-white text-label-sm font-bold rounded-full animate-pulse"> | |
| <span class="material-symbols-outlined text-2xl">mic</span> | |
| RECORDING | |
| </div> | |
| </div> | |
| <div id="liveTranscript" class="live-transcript">Waiting to start...</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Application Logic --> | |
| <script> | |
| // --- Configuration --- | |
| const API_BASE = '/api'; | |
| // --- DOM Elements --- | |
| const UI = { | |
| uploadZone: document.getElementById('uploadZone'), | |
| fileInput: document.getElementById('fileInput'), | |
| queueBody: document.getElementById('queueBody'), | |
| resultModal: document.getElementById('resultModal'), | |
| statusModal: document.getElementById('statusModal'), | |
| resultText: document.getElementById('resultText'), | |
| modalTitle: document.getElementById('modalTitle'), | |
| statusMessage: document.getElementById('statusMessage'), | |
| statusSubMessage: document.getElementById('statusSubMessage'), | |
| statusIcon: document.getElementById('statusIcon'), | |
| statusIconContainer: document.getElementById('statusIconContainer'), | |
| statusBg: document.getElementById('statusBg'), | |
| healthDot: document.getElementById('healthDot'), | |
| healthText: document.getElementById('healthText'), | |
| apiDocBtn: document.getElementById('apiDocBtn') | |
| }; | |
| // --- UI Helpers --- | |
| function updateStatusModal(type, msg, subMsg) { | |
| UI.statusMessage.innerText = msg; | |
| UI.statusSubMessage.innerText = subMsg || "Processing your request, please wait."; | |
| UI.statusIconContainer.className = "status-icon-container"; | |
| UI.statusIcon.className = "material-symbols-outlined text-6xl"; | |
| UI.statusBg.style.borderColor = ""; | |
| if (type === 'uploading') { | |
| UI.statusIconContainer.classList.add('text-crayon-blue'); | |
| UI.statusIcon.innerText = "upload"; | |
| UI.statusIcon.classList.add('animate-bounce'); | |
| UI.statusBg.style.borderColor = "#2563eb"; | |
| } else if (type === 'success') { | |
| UI.statusIconContainer.classList.add('text-crayon-green'); | |
| UI.statusIcon.innerText = "check_circle"; | |
| UI.statusBg.style.borderColor = "#16a34a"; | |
| } else if (type === 'error') { | |
| UI.statusIconContainer.classList.add('text-crayon-red'); | |
| UI.statusIcon.innerText = "error"; | |
| UI.statusBg.style.borderColor = "#dc2626"; | |
| } | |
| } | |
| function closeModal() { | |
| UI.resultModal.classList.remove('active'); | |
| } | |
| function copyResult() { | |
| const text = UI.resultText.innerText; | |
| const btn = document.getElementById('copyBtn'); | |
| navigator.clipboard.writeText(text).then(() => { | |
| const orig = btn.innerText; | |
| btn.innerText = '✓ Copied!'; | |
| setTimeout(() => { btn.innerText = orig; }, 2000); | |
| }); | |
| } | |
| // --- API Functions --- | |
| async function loadTasks() { | |
| try { | |
| const res = await fetch(`${API_BASE}/tasks`); | |
| const data = await res.json(); | |
| renderQueue(data); | |
| } catch (err) { | |
| console.error("Load tasks error:", err); | |
| } | |
| } | |
| async function handleFile(file) { | |
| if (!file) return; | |
| UI.statusModal.classList.add('active'); | |
| updateStatusModal('uploading', "Uploading...", "Sending file to server..."); | |
| const formData = new FormData(); | |
| formData.append('audio', file); | |
| formData.append('engine', uploadEngineSelect.value); | |
| formData.append('language', uploadLanguageSelect.value); | |
| formData.append('task', uploadTaskSelect.value); | |
| try { | |
| const res = await fetch(`${API_BASE}/tasks/upload`, { method: 'POST', body: formData }); | |
| if (res.ok) { | |
| updateStatusModal('success', "Success! ✨", "File uploaded and task created."); | |
| setTimeout(() => { | |
| UI.statusModal.classList.remove('active'); | |
| loadTasks(); | |
| }, 1200); | |
| } else { | |
| updateStatusModal('error', "Upload Failed ❌", "Something went wrong on our end."); | |
| setTimeout(() => UI.statusModal.classList.remove('active'), 2000); | |
| } | |
| } catch (err) { | |
| console.error("Upload error:", err); | |
| updateStatusModal('error', "Connection Error ⚠️", "Could not reach the server."); | |
| setTimeout(() => UI.statusModal.classList.remove('active'), 2000); | |
| } | |
| } | |
| async function showResult(id) { | |
| try { | |
| const res = await fetch(`${API_BASE}/tasks/${id}`); | |
| const data = await res.json(); | |
| const text = data.result; | |
| UI.modalTitle.innerText = "Transcription Result"; | |
| let formatted = text; | |
| try { | |
| const parsed = JSON.parse(text); | |
| formatted = JSON.stringify(parsed, null, 2); | |
| } catch (e) { /* Not JSON, use raw text */ } | |
| UI.resultText.innerText = formatted; | |
| UI.resultModal.classList.add('active'); | |
| } catch (err) { | |
| console.error("Show result error:", err); | |
| } | |
| } | |
| async function checkHealth() { | |
| try { | |
| const res = await fetch('/health'); | |
| const data = await res.json(); | |
| const healthy = data.status === 'healthy'; | |
| UI.healthDot.className = `w-4 h-4 rounded-full ${healthy ? 'bg-crayon-green' : 'bg-crayon-red'} shadow-md`; | |
| UI.healthText.innerText = healthy ? 'Service Online' : 'Service Down'; | |
| UI.healthText.className = `text-headline-md font-bold ${healthy ? 'text-crayon-green' : 'text-crayon-red'}`; | |
| } catch (e) { | |
| UI.healthDot.className = 'w-4 h-4 rounded-full bg-crayon-red shadow-md'; | |
| UI.healthText.innerText = 'Connection Error'; | |
| UI.healthText.className = 'text-headline-md font-bold text-crayon-red'; | |
| } | |
| } | |
| // --- Renderers --- | |
| function renderQueue(tasks) { | |
| if (tasks.length === 0) { | |
| UI.queueBody.innerHTML = '<div class="text-center py-32 text-headline-md text-[#94a3b8] font-bold opacity-60">No tasks found yet...</div>'; | |
| return; | |
| } | |
| UI.queueBody.innerHTML = tasks.map((t, i) => { | |
| const rotate = i % 2 === 0 ? 'rotate-[0.3deg]' : '-rotate-[0.3deg]'; | |
| const status = t.status.toLowerCase(); | |
| const colors = { | |
| completed: { text: 'crayon-green', bg: 'bg-[#f0fdf4]' }, | |
| failed: { text: 'crayon-red', bg: 'bg-[#fef2f2]' }, | |
| processing: { text: 'crayon-blue', bg: 'bg-[#eff6ff]' }, | |
| pending: { text: 'crayon-purple', bg: 'bg-[#f5f3ff]' } | |
| }; | |
| const theme = colors[status] || colors.pending; | |
| return ` | |
| <div class="task-card flex items-center p-6 bg-surface hover:border-crayon-purple transition-colors shadow-sm ${rotate}"> | |
| <div class="flex items-center gap-5 w-1/2"> | |
| <div class="w-16 h-20 border-[3px] border-crayon-blue rounded-xl p-2 bg-surface flex shadow-sm organic-shape rotate-[-3deg]"> | |
| <div class="w-1/2 h-full border-r-[2px] border-[#adc6ff] flex flex-col gap-1.5 p-0.5"> | |
| <div class="w-full h-2 bg-[#adc6ff] rounded-sm"></div> | |
| <div class="w-full h-2 bg-[#adc6ff] rounded-sm"></div> | |
| <div class="w-full h-2 bg-[#adc6ff] rounded-sm"></div> | |
| </div> | |
| <div class="w-1/2 h-full flex flex-col gap-1.5 p-0.5 relative"> | |
| <div class="w-full h-2 bg-[#adc6ff] rounded-sm"></div> | |
| <div class="mt-auto flex gap-1.5 bottom-1 absolute"> | |
| <div class="w-2.5 h-2.5 bg-crayon-yellow rounded-sm"></div> | |
| <div class="w-2.5 h-2.5 bg-crayon-green rounded-sm"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex flex-col"> | |
| <span class="text-headline-md text-[#1A1A1A] leading-tight font-bold mb-1">${t.filename}</span> | |
| <div class="text-label-sm text-[#94a3b8] font-bold">${t.id.substring(0, 12)}</div> | |
| </div> | |
| </div> | |
| <div class="w-1/6 flex justify-center"> | |
| <div class="px-4 py-2 ${theme.bg} border-[3px] border-${theme.text} text-${theme.text} font-bold rounded-2xl uppercase tracking-tight crayon-filter"> | |
| ${status.replace('_', ' ')} | |
| </div> | |
| </div> | |
| <div class="w-1/4 flex items-center justify-center gap-4"> | |
| <div class="flex-1 h-6 border-[3px] border-[#adc6ff] rounded-full overflow-hidden bg-surface p-[2px] crayon-filter"> | |
| <div class="h-full rounded-full progress-fill shadow-sm" style="width:${t.progress}%"></div> | |
| </div> | |
| <span class="text-headline-md text-2xl text-[#1A1A1A] font-bold w-12 text-right">${status === 'completed' ? '' : t.progress + '%'}</span> | |
| </div> | |
| <div class="w-[10%] flex justify-center"> | |
| ${status === 'completed' ? ` | |
| <button onclick="showResult('${t.id}')" class="flex items-center gap-2 px-5 py-2.5 bg-white border-[3px] border-crayon-blue text-crayon-blue font-bold rounded-2xl hover:bg-crayon-blue hover:text-white transition-all shadow-sm crayon-filter"> | |
| <span class="material-symbols-outlined text-2xl">visibility</span> | |
| VIEW | |
| </button> | |
| ` : '—'} | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| // --- Event Listeners --- | |
| UI.uploadZone.onclick = () => UI.fileInput.click(); | |
| UI.uploadZone.ondragover = (e) => { e.preventDefault(); UI.uploadZone.classList.add('dragging'); }; | |
| UI.uploadZone.ondragleave = () => UI.uploadZone.classList.remove('dragging'); | |
| UI.uploadZone.ondrop = (e) => { | |
| e.preventDefault(); | |
| UI.uploadZone.classList.remove('dragging'); | |
| handleFile(e.dataTransfer.files[0]); | |
| }; | |
| UI.fileInput.onchange = (e) => handleFile(e.target.files[0]); | |
| UI.apiDocBtn.onclick = () => { | |
| const doc = { | |
| base_url: window.location.origin, | |
| endpoints: [ | |
| { method: "POST", path: "/api/tasks/upload", desc: "Upload audio for STT" }, | |
| { method: "GET", path: "/api/tasks", desc: "List all tasks" }, | |
| { method: "GET", path: "/api/tasks/{task_id}", desc: "Get STT result" }, | |
| { method: "GET", path: "/health", desc: "Service health" } | |
| ], | |
| example_usage: `curl -X POST -F 'audio=@file.wav' ${window.location.origin}/api/tasks/upload` | |
| }; | |
| UI.resultText.innerText = JSON.stringify(doc, null, 2); | |
| UI.modalTitle.innerText = "API Documentation"; | |
| UI.resultModal.classList.add('active'); | |
| }; | |
| // --- Live STT (WebSocket) --- | |
| const liveBtn = document.getElementById('liveBtn'); | |
| const liveModal = document.getElementById('liveModal'); | |
| const liveModelSelect = document.getElementById('liveModelSelect'); | |
| const liveModelLabel = document.getElementById('liveModelLabel'); | |
| const liveEngineSelect = document.getElementById('liveEngineSelect'); | |
| const liveLanguageSelect = document.getElementById('liveLanguageSelect'); | |
| const liveTaskSelect = document.getElementById('liveTaskSelect'); | |
| const uploadEngineSelect = document.getElementById('uploadEngineSelect'); | |
| const uploadLanguageSelect = document.getElementById('uploadLanguageSelect'); | |
| const uploadTaskSelect = document.getElementById('uploadTaskSelect'); | |
| const uploadEngineNote = document.getElementById('uploadEngineNote'); | |
| // --- Engine registry (server is the single source of truth) --- | |
| // Every dropdown below is built from /api/engines, so adding an engine | |
| // server-side makes it selectable here with no changes to this file. | |
| let REGISTRY = null; | |
| const TASK_LABELS = { transcribe: 'Same language', translate: 'English' }; | |
| function fillSelect(select, items, selected) { | |
| select.innerHTML = items.map(i => | |
| `<option value="${i.value}"${i.value === selected ? ' selected' : ''}>${i.label}</option>` | |
| ).join(''); | |
| } | |
| // Repopulate language/task/size to match the chosen engine, keeping the | |
| // user's current picks whenever the new engine still supports them. | |
| function syncEngineOptions(engineSelect, languageSelect, taskSelect, sizeSelect, note) { | |
| if (!REGISTRY) return; | |
| const spec = REGISTRY.engines[engineSelect.value]; | |
| if (!spec) return; | |
| const prevLang = languageSelect.value; | |
| const prevTask = taskSelect.value; | |
| const langs = spec.languages.map(l => ({ value: l.code, label: l.name })); | |
| fillSelect(languageSelect, langs, | |
| langs.some(l => l.value === prevLang) ? prevLang : langs[0].value); | |
| const tasks = spec.tasks.map(t => ({ value: t, label: TASK_LABELS[t] || t })); | |
| fillSelect(taskSelect, tasks, | |
| tasks.some(t => t.value === prevTask) ? prevTask : tasks[0].value); | |
| if (sizeSelect) { | |
| const hasSizes = spec.sizes && spec.sizes.length > 0; | |
| const sizes = (spec.sizes || []).map(s => ({ value: s, label: s })); | |
| if (hasSizes) fillSelect(sizeSelect, sizes, spec.default_size || sizes[0].value); | |
| sizeSelect.style.display = hasSizes ? '' : 'none'; | |
| if (liveModelLabel && sizeSelect === liveModelSelect) { | |
| liveModelLabel.style.display = hasSizes ? '' : 'none'; | |
| } | |
| } | |
| if (note) note.textContent = spec.notes || ''; | |
| } | |
| async function loadRegistry() { | |
| try { | |
| const res = await fetch(`${API_BASE}/engines`); | |
| REGISTRY = await res.json(); | |
| } catch (err) { | |
| console.error('Could not load engine registry:', err); | |
| return; | |
| } | |
| const all = Object.entries(REGISTRY.engines) | |
| .map(([value, spec]) => ({ value, label: spec.label })); | |
| fillSelect(uploadEngineSelect, all, REGISTRY.default_engine); | |
| syncEngineOptions(uploadEngineSelect, uploadLanguageSelect, uploadTaskSelect, | |
| null, uploadEngineNote); | |
| uploadEngineSelect.onchange = () => syncEngineOptions( | |
| uploadEngineSelect, uploadLanguageSelect, uploadTaskSelect, null, uploadEngineNote); | |
| // Live only offers engines that actually implement streaming. | |
| const streaming = all.filter(e => REGISTRY.streaming_engines.includes(e.value)); | |
| fillSelect(liveEngineSelect, streaming, streaming[0] && streaming[0].value); | |
| syncEngineOptions(liveEngineSelect, liveLanguageSelect, liveTaskSelect, | |
| liveModelSelect, null); | |
| liveEngineSelect.onchange = () => syncEngineOptions( | |
| liveEngineSelect, liveLanguageSelect, liveTaskSelect, liveModelSelect, null); | |
| } | |
| const startLiveBtn = document.getElementById('startLiveBtn'); | |
| const stopLiveBtn = document.getElementById('stopLiveBtn'); | |
| const liveTranscript = document.getElementById('liveTranscript'); | |
| const liveStatusBadge = document.getElementById('liveStatusBadge'); | |
| const urlParams = new URLSearchParams(window.location.search); | |
| if (urlParams.get('live') === 'true') { | |
| liveBtn.classList.remove('hidden'); | |
| } | |
| let liveWs = null; | |
| let liveMicStream = null; | |
| let liveAudioCtx = null; | |
| let liveProcessor = null; | |
| let isLiveStreaming = false; | |
| let liveResampleBuf = []; | |
| let livePreBuffer = []; | |
| let wsReady = false; | |
| let liveFinalizeTimer = null; | |
| let liveCommitted = ''; | |
| let liveTentative = ''; | |
| function escapeHtml(s) { | |
| return s.replace(/[&<>"']/g, c => ({ | |
| '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' | |
| }[c])); | |
| } | |
| function renderLiveTranscript() { | |
| const committed = liveCommitted ? escapeHtml(liveCommitted) : ''; | |
| const tentative = liveTentative | |
| ? `<span style="opacity:0.45">${escapeHtml(liveTentative)}</span>` : ''; | |
| if (!committed && !tentative) { | |
| liveTranscript.innerText = '🎤 Listening...'; | |
| } else { | |
| liveTranscript.innerHTML = (committed + (committed && tentative ? ' ' : '') + tentative); | |
| } | |
| liveTranscript.scrollTop = liveTranscript.scrollHeight; | |
| } | |
| const WS_SAMPLE_RATE = 16000; | |
| function openLiveModal() { | |
| liveModal.classList.add('active'); | |
| liveTranscript.innerText = 'Select a model and click Start.'; | |
| startLiveBtn.classList.remove('hidden'); | |
| stopLiveBtn.classList.add('hidden'); | |
| liveStatusBadge.classList.add('hidden'); | |
| } | |
| function closeLiveModal() { | |
| stopLiveStreaming(); | |
| liveModal.classList.remove('active'); | |
| } | |
| function float32ToInt16(float32) { | |
| const int16 = new Int16Array(float32.length); | |
| for (let i = 0; i < float32.length; i++) { | |
| const s = Math.max(-1, Math.min(1, float32[i])); | |
| int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; | |
| } | |
| return int16; | |
| } | |
| function resampleAndSend(floatData, nativeRate) { | |
| const ratio = WS_SAMPLE_RATE / nativeRate; | |
| const outLen = Math.floor(floatData.length * ratio); | |
| for (let outPos = 0; outPos < outLen; outPos++) { | |
| const srcPos = outPos / ratio; | |
| const idx = Math.floor(srcPos); | |
| const frac = srcPos - idx; | |
| const next = Math.min(idx + 1, floatData.length - 1); | |
| liveResampleBuf.push(floatData[idx] * (1 - frac) + floatData[next] * frac); | |
| } | |
| const targetSize = Math.floor(WS_SAMPLE_RATE * 0.4); | |
| while (liveResampleBuf.length >= targetSize) { | |
| const chunk = liveResampleBuf.splice(0, targetSize); | |
| const int16 = float32ToInt16(new Float32Array(chunk)); | |
| if (liveWs && liveWs.readyState === WebSocket.OPEN) { | |
| liveWs.send(int16.buffer); | |
| } | |
| } | |
| } | |
| async function startLiveStreaming() { | |
| try { | |
| // Set up mic + AudioContext in the click handler (user gesture) | |
| liveAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| liveMicStream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| const source = liveAudioCtx.createMediaStreamSource(liveMicStream); | |
| liveProcessor = liveAudioCtx.createScriptProcessor(4096, 1, 1); | |
| liveProcessor.onaudioprocess = (e) => { | |
| if (!isLiveStreaming) return; | |
| const data = e.inputBuffer.getChannelData(0); | |
| if (wsReady) { | |
| resampleAndSend(data, liveAudioCtx.sampleRate); | |
| } else { | |
| livePreBuffer.push(...data); | |
| } | |
| }; | |
| source.connect(liveProcessor); | |
| liveProcessor.connect(liveAudioCtx.destination); | |
| isLiveStreaming = true; | |
| // Connect WebSocket | |
| const engine = liveEngineSelect.value; | |
| const model = liveModelSelect.value; | |
| const language = liveLanguageSelect.value; | |
| const task = liveTaskSelect.value; | |
| const wsUrl = `${window.location.origin.replace(/^http/, 'ws')}/ws/transcribe`; | |
| liveWs = new WebSocket(wsUrl); | |
| liveWs.binaryType = 'arraybuffer'; | |
| liveWs.onopen = () => { | |
| liveWs.send(JSON.stringify({ engine, model, language, task })); | |
| }; | |
| liveWs.onmessage = (e) => { | |
| try { | |
| const msg = JSON.parse(e.data); | |
| if (msg.type === 'ready') { | |
| wsReady = true; | |
| // Flush pre-buffered audio | |
| if (livePreBuffer.length > 0) { | |
| resampleAndSend( | |
| new Float32Array(livePreBuffer.splice(0)), | |
| liveAudioCtx.sampleRate | |
| ); | |
| } | |
| liveCommitted = ''; | |
| liveTentative = ''; | |
| renderLiveTranscript(); | |
| } else if (msg.type === 'commit') { | |
| liveCommitted += (liveCommitted ? ' ' : '') + msg.text; | |
| liveTentative = ''; | |
| renderLiveTranscript(); | |
| } else if (msg.type === 'tentative') { | |
| liveTentative = msg.text || ''; | |
| renderLiveTranscript(); | |
| } else if (msg.type === 'done') { | |
| teardownLiveStreaming(); | |
| } else if (msg.type === 'warning') { | |
| console.warn('[live-stt]', msg.message); | |
| liveTranscript.innerText = '⚠️ ' + msg.message; | |
| } else if (msg.type === 'error') { | |
| liveTranscript.innerText = '❌ Error: ' + msg.message; | |
| } | |
| } catch (e) { /* ignore non-JSON */ } | |
| }; | |
| liveWs.onerror = () => { | |
| liveTranscript.innerText = '❌ WebSocket error. Check if the server is running.'; | |
| teardownLiveStreaming(); | |
| }; | |
| liveWs.onclose = () => { | |
| teardownLiveStreaming(); | |
| }; | |
| liveTranscript.innerText = 'Connecting...'; | |
| startLiveBtn.classList.add('hidden'); | |
| stopLiveBtn.classList.remove('hidden'); | |
| liveStatusBadge.classList.remove('hidden'); | |
| liveModelSelect.disabled = true; | |
| liveEngineSelect.disabled = true; | |
| liveLanguageSelect.disabled = true; | |
| liveTaskSelect.disabled = true; | |
| } catch (err) { | |
| liveTranscript.innerText = '❌ Error: ' + err.message; | |
| stopLiveStreaming(); | |
| } | |
| } | |
| function stopMicCapture() { | |
| isLiveStreaming = false; | |
| wsReady = false; | |
| livePreBuffer = []; | |
| liveResampleBuf = []; | |
| if (liveProcessor) { liveProcessor.disconnect(); liveProcessor = null; } | |
| if (liveMicStream) { liveMicStream.getTracks().forEach(t => t.stop()); liveMicStream = null; } | |
| if (liveAudioCtx) { liveAudioCtx.close().catch(() => {}); liveAudioCtx = null; } | |
| } | |
| function resetLiveUI() { | |
| startLiveBtn.classList.remove('hidden'); | |
| stopLiveBtn.classList.add('hidden'); | |
| stopLiveBtn.disabled = false; | |
| liveStatusBadge.classList.add('hidden'); | |
| liveModelSelect.disabled = false; | |
| liveEngineSelect.disabled = false; | |
| liveLanguageSelect.disabled = false; | |
| liveTaskSelect.disabled = false; | |
| } | |
| // Immediate teardown: closes the socket, stops the mic, resets the UI. | |
| // Idempotent so it's safe to call from onclose/onerror/done/timeout. | |
| function teardownLiveStreaming() { | |
| if (liveFinalizeTimer) { clearTimeout(liveFinalizeTimer); liveFinalizeTimer = null; } | |
| if (liveWs) { | |
| try { liveWs.close(); } catch (e) {} | |
| liveWs = null; | |
| } | |
| stopMicCapture(); | |
| resetLiveUI(); | |
| } | |
| // Graceful stop: stop sending audio but keep the socket open so the | |
| // server can flush the trailing segment before we tear down. | |
| function stopLiveStreaming() { | |
| // Flush the partial resample buffer before stopMicCapture clears it, | |
| // so the tail isn't dropped. | |
| if (liveWs && liveWs.readyState === WebSocket.OPEN && liveResampleBuf.length > 0) { | |
| try { liveWs.send(float32ToInt16(new Float32Array(liveResampleBuf.splice(0))).buffer); } catch (e) {} | |
| } | |
| stopMicCapture(); | |
| if (liveWs && liveWs.readyState === WebSocket.OPEN) { | |
| try { liveWs.send(JSON.stringify({ type: 'stop' })); } catch (e) {} | |
| liveStatusBadge.classList.add('hidden'); | |
| stopLiveBtn.disabled = true; | |
| // Fallback in case the server never replies with 'done'. | |
| liveFinalizeTimer = setTimeout(teardownLiveStreaming, 5000); | |
| } else { | |
| teardownLiveStreaming(); | |
| } | |
| } | |
| liveBtn.onclick = openLiveModal; | |
| liveModal.onclick = (e) => { if (e.target === liveModal) closeLiveModal(); }; | |
| startLiveBtn.onclick = () => { isLiveStreaming = true; startLiveStreaming(); }; | |
| stopLiveBtn.onclick = stopLiveStreaming; | |
| // --- Lifecycle --- | |
| loadRegistry(); | |
| loadTasks(); | |
| setInterval(loadTasks, 5000); | |
| setInterval(checkHealth, 10000); | |
| </script> | |
| </body> | |
| </html> |