Spaces:
Sleeping
Sleeping
| import base64 | |
| import mimetypes | |
| import os | |
| import secrets | |
| import time | |
| from concurrent.futures import ThreadPoolExecutor | |
| from html import escape | |
| from functools import lru_cache | |
| import gradio as gr | |
| import torch | |
| from qwen_asr import Qwen3ASRModel | |
| try: | |
| import spaces | |
| except Exception: # pragma: no cover - local CPU development fallback | |
| class _SpacesFallback: | |
| def GPU(duration=120): | |
| def decorator(func): | |
| return func | |
| return decorator | |
| spaces = _SpacesFallback() | |
| FINETUNED_MODEL_KEY = "heritagelab_finetuned" | |
| OFF_THE_SHELF_MODEL_KEY = "off_the_shelf" | |
| DEFAULT_MODEL_CHOICE = FINETUNED_MODEL_KEY | |
| MODEL_CONFIGS = { | |
| FINETUNED_MODEL_KEY: { | |
| "id": os.getenv( | |
| "FINETUNED_MODEL_ID", | |
| os.getenv("MODEL_ID", "timcuhk/NRC-HeritageLab-Qwen3-ASR-0p6B-Inuktitut"), | |
| ), | |
| "label": "HeritageLab fine-tuned Qwen3-ASR 0.6B (recommended)", | |
| "display": "Qwen3-ASR 0.6B fine-tuned on HeritageLab", | |
| "short": "Fine-tuned on HeritageLab", | |
| "description": "Recommended for Inuktitut ASR in this demo; this checkpoint was fine-tuned on the HeritageLab dataset.", | |
| }, | |
| OFF_THE_SHELF_MODEL_KEY: { | |
| "id": os.getenv("OFF_THE_SHELF_MODEL_ID", "Qwen/Qwen3-ASR-0.6B"), | |
| "label": "Off-the-shelf Qwen3-ASR 0.6B (not fine-tuned)", | |
| "display": "Off-the-shelf Qwen3-ASR 0.6B", | |
| "short": "Off-the-shelf baseline", | |
| "description": "Original public Qwen3-ASR model without HeritageLab fine-tuning; included to show why fine-tuning matters.", | |
| }, | |
| } | |
| MODEL_CHOICES = [(config["label"], key) for key, config in MODEL_CONFIGS.items()] | |
| DEFAULT_CONTEXT = os.getenv( | |
| "DEFAULT_CONTEXT", | |
| "The audio is Inuktitut in roman orthography. Transcribe using lowercase roman letters when possible.", | |
| ) | |
| HF_ACCESS_TOKEN = os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN") | |
| DEMO_USERNAME = os.getenv("DEMO_USERNAME", "nrc") | |
| DEMO_PASSWORD = os.getenv("DEMO_PASSWORD") | |
| NO_FORCED_LANGUAGE = "Auto / no forced language" | |
| LANGUAGE_CHOICES = [ | |
| NO_FORCED_LANGUAGE, | |
| "English", | |
| "French", | |
| ] | |
| EXAMPLE_AUDIO = { | |
| "demo1": "examples/siqiniq.wav", | |
| "demo2": "examples/tusaalanga_weekend.wav", | |
| } | |
| DEMO_MAX_NEW_TOKENS = 64 | |
| DEFAULT_DEVICE = "CPU" | |
| CUSTOM_CSS = """ | |
| body { | |
| background: #f8fafc !important; | |
| } | |
| .gradio-container { | |
| max-width: 1280px !important; | |
| margin: 0 auto !important; | |
| padding: 24px 34px 44px 34px !important; | |
| } | |
| @media (max-width: 900px) { | |
| .gradio-container { | |
| padding-left: 14px !important; | |
| padding-right: 14px !important; | |
| } | |
| } | |
| .dark body, | |
| body.dark, | |
| html.dark body, | |
| [data-theme="dark"] body { | |
| background: #020617 !important; | |
| } | |
| .generating { | |
| border-color: inherit !important; | |
| box-shadow: none !important; | |
| } | |
| button.show-api { | |
| display: none !important; | |
| } | |
| .nrc-model-notice { | |
| border: 1px solid #cbd5e1 !important; | |
| border-left: 4px solid #2563eb !important; | |
| background: linear-gradient(180deg, #ffffff, #f8fafc) !important; | |
| color: #1f2937 !important; | |
| border-radius: 14px !important; | |
| padding: 11px 13px !important; | |
| font-size: 0.92rem !important; | |
| line-height: 1.35 !important; | |
| margin-top: 8px !important; | |
| } | |
| .nrc-model-notice.warning { | |
| border-left-color: #d97706 !important; | |
| } | |
| .nrc-model-notice strong { | |
| color: #1d4ed8 !important; | |
| } | |
| .nrc-model-notice.warning strong { | |
| color: #b45309 !important; | |
| } | |
| .dark .nrc-model-notice, | |
| body.dark .nrc-model-notice, | |
| html.dark .nrc-model-notice, | |
| [data-theme="dark"] .nrc-model-notice, | |
| .gradio-container.dark .nrc-model-notice { | |
| border-color: #475569 !important; | |
| border-left-color: #60a5fa !important; | |
| background: linear-gradient(180deg, #1f2937, #111827) !important; | |
| color: #e5e7eb !important; | |
| } | |
| .dark .nrc-model-notice strong, | |
| body.dark .nrc-model-notice strong, | |
| html.dark .nrc-model-notice strong, | |
| [data-theme="dark"] .nrc-model-notice strong, | |
| .gradio-container.dark .nrc-model-notice strong { | |
| color: #93c5fd !important; | |
| } | |
| .dark .nrc-model-notice.warning, | |
| body.dark .nrc-model-notice.warning, | |
| html.dark .nrc-model-notice.warning, | |
| [data-theme="dark"] .nrc-model-notice.warning, | |
| .gradio-container.dark .nrc-model-notice.warning { | |
| border-left-color: #f59e0b !important; | |
| } | |
| .dark .nrc-model-notice.warning strong, | |
| body.dark .nrc-model-notice.warning strong, | |
| html.dark .nrc-model-notice.warning strong, | |
| [data-theme="dark"] .nrc-model-notice.warning strong, | |
| .gradio-container.dark .nrc-model-notice.warning strong { | |
| color: #fbbf24 !important; | |
| } | |
| #settings-section > .styler, | |
| #audio-section > .styler, | |
| #result-section > .styler { | |
| border-radius: 18px !important; | |
| padding: 22px !important; | |
| } | |
| #settings-section, | |
| #audio-section, | |
| #result-section { | |
| margin-top: 18px !important; | |
| } | |
| #settings-section .block, | |
| #audio-section .block, | |
| #result-section .block { | |
| border-radius: 14px !important; | |
| } | |
| #settings-main-row { | |
| gap: 26px !important; | |
| align-items: stretch !important; | |
| } | |
| #model-settings-column, | |
| #runtime-settings-column { | |
| gap: 20px !important; | |
| } | |
| #device-language-row { | |
| gap: 18px !important; | |
| margin-bottom: 20px !important; | |
| } | |
| #settings-section .nrc-model-notice { | |
| margin-top: 20px !important; | |
| } | |
| #settings-section .block { | |
| margin-bottom: 0 !important; | |
| } | |
| #audio-section { | |
| padding-top: 12px !important; | |
| padding-bottom: 12px !important; | |
| } | |
| #audio-control-row { | |
| align-items: center !important; | |
| gap: 12px !important; | |
| margin: 2px 0 16px 0 !important; | |
| } | |
| #audio-control-row .compact-help { | |
| flex: 1 1 360px !important; | |
| min-width: 280px !important; | |
| } | |
| #audio-control-row .compact-help .prose, | |
| #audio-control-row .compact-help p { | |
| margin: 0 !important; | |
| font-size: 0.92rem !important; | |
| line-height: 1.25 !important; | |
| } | |
| #audio-control-row button, | |
| #transcribe-button { | |
| min-height: 40px !important; | |
| padding: 8px 18px !important; | |
| border-radius: 999px !important; | |
| } | |
| #transcribe-button { | |
| min-width: 190px !important; | |
| font-weight: 700 !important; | |
| } | |
| #audio-input { | |
| height: 205px !important; | |
| min-height: 205px !important; | |
| max-height: 205px !important; | |
| margin-bottom: 0 !important; | |
| } | |
| #audio-input > .wrap { | |
| height: 205px !important; | |
| min-height: 205px !important; | |
| max-height: 205px !important; | |
| } | |
| #audio-input .audio-container { | |
| height: 178px !important; | |
| min-height: 178px !important; | |
| max-height: 178px !important; | |
| } | |
| #audio-input button.center.boundedheight.flex { | |
| height: 148px !important; | |
| min-height: 148px !important; | |
| max-height: 148px !important; | |
| padding: 10px !important; | |
| } | |
| #audio-input .source-selection { | |
| padding: 2px 0 !important; | |
| } | |
| #status-panel .html-container { | |
| max-height: 240px !important; | |
| overflow-y: auto !important; | |
| } | |
| .nrc-ready-status { | |
| border: 1px solid #e2e8f0 !important; | |
| background: linear-gradient(180deg, #ffffff, #f8fafc) !important; | |
| color: #0f172a !important; | |
| border-radius: 18px !important; | |
| padding: 14px 16px !important; | |
| box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05) !important; | |
| } | |
| .nrc-ready-title { | |
| color: #475569 !important; | |
| font-size: 0.78rem !important; | |
| font-weight: 700 !important; | |
| letter-spacing: 0.08em !important; | |
| text-transform: uppercase !important; | |
| margin-bottom: 4px !important; | |
| } | |
| .nrc-ready-main { | |
| color: #0f172a !important; | |
| font-size: 1.02rem !important; | |
| font-weight: 800 !important; | |
| line-height: 1.25 !important; | |
| } | |
| .nrc-ready-note { | |
| color: #475569 !important; | |
| margin-top: 8px !important; | |
| font-size: 0.92rem !important; | |
| line-height: 1.35 !important; | |
| } | |
| .nrc-ready-meta { | |
| display: flex !important; | |
| flex-wrap: wrap !important; | |
| gap: 8px !important; | |
| margin-top: 10px !important; | |
| } | |
| .nrc-ready-pill { | |
| border: 1px solid #dbeafe !important; | |
| background: #eff6ff !important; | |
| color: #1d4ed8 !important; | |
| border-radius: 999px !important; | |
| padding: 4px 9px !important; | |
| font-size: 0.82rem !important; | |
| font-weight: 700 !important; | |
| } | |
| .dark .nrc-ready-status, | |
| body.dark .nrc-ready-status, | |
| html.dark .nrc-ready-status, | |
| [data-theme="dark"] .nrc-ready-status, | |
| .gradio-container.dark .nrc-ready-status { | |
| border-color: #334155 !important; | |
| background: linear-gradient(180deg, #1f2937, #111827) !important; | |
| color: #f8fafc !important; | |
| } | |
| .dark .nrc-ready-title, | |
| body.dark .nrc-ready-title, | |
| html.dark .nrc-ready-title, | |
| [data-theme="dark"] .nrc-ready-title, | |
| .gradio-container.dark .nrc-ready-title, | |
| .dark .nrc-ready-note, | |
| body.dark .nrc-ready-note, | |
| html.dark .nrc-ready-note, | |
| [data-theme="dark"] .nrc-ready-note, | |
| .gradio-container.dark .nrc-ready-note { | |
| color: #cbd5e1 !important; | |
| } | |
| .dark .nrc-ready-main, | |
| body.dark .nrc-ready-main, | |
| html.dark .nrc-ready-main, | |
| [data-theme="dark"] .nrc-ready-main, | |
| .gradio-container.dark .nrc-ready-main { | |
| color: #f8fafc !important; | |
| } | |
| .dark .nrc-ready-pill, | |
| body.dark .nrc-ready-pill, | |
| html.dark .nrc-ready-pill, | |
| [data-theme="dark"] .nrc-ready-pill, | |
| .gradio-container.dark .nrc-ready-pill { | |
| border-color: #1e40af !important; | |
| background: #172554 !important; | |
| color: #bfdbfe !important; | |
| } | |
| .nrc-working-status { | |
| border: 2px solid #93c5fd !important; | |
| border-left: 7px solid #2563eb !important; | |
| background: linear-gradient(180deg, #ffffff, #dbeafe) !important; | |
| color: #1e3a8a !important; | |
| border-radius: 18px !important; | |
| padding: 18px 20px !important; | |
| box-shadow: 0 14px 32px rgba(37, 99, 235, 0.16) !important; | |
| } | |
| .nrc-working-head { | |
| display: flex !important; | |
| align-items: flex-start !important; | |
| gap: 12px !important; | |
| } | |
| .nrc-working-spinner { | |
| width: 18px !important; | |
| height: 18px !important; | |
| border: 3px solid #bfdbfe !important; | |
| border-top-color: #2563eb !important; | |
| border-radius: 999px !important; | |
| flex: 0 0 auto !important; | |
| margin-top: 2px !important; | |
| animation: nrc-spin 0.9s linear infinite !important; | |
| } | |
| @keyframes nrc-spin { | |
| to { transform: rotate(360deg); } | |
| } | |
| .nrc-working-status .nrc-working-title { | |
| color: #1d4ed8 !important; | |
| font-size: 0.8rem !important; | |
| font-weight: 800 !important; | |
| letter-spacing: 0.08em !important; | |
| text-transform: uppercase !important; | |
| margin-bottom: 4px !important; | |
| } | |
| .nrc-working-status .nrc-working-main { | |
| color: #0f172a !important; | |
| font-size: 1.12rem !important; | |
| font-weight: 800 !important; | |
| line-height: 1.25 !important; | |
| } | |
| .nrc-working-status .nrc-working-note { | |
| color: #334155 !important; | |
| margin-top: 10px !important; | |
| font-size: 0.95rem !important; | |
| font-weight: 650 !important; | |
| line-height: 1.35 !important; | |
| } | |
| .nrc-working-steps { | |
| display: flex !important; | |
| flex-wrap: wrap !important; | |
| gap: 8px !important; | |
| margin-top: 13px !important; | |
| } | |
| .nrc-working-step, | |
| .nrc-working-time { | |
| border: 1px solid #bfdbfe !important; | |
| background: rgba(255, 255, 255, 0.82) !important; | |
| color: #1e40af !important; | |
| border-radius: 999px !important; | |
| padding: 6px 10px !important; | |
| font-size: 0.84rem !important; | |
| font-weight: 800 !important; | |
| } | |
| .dark .nrc-working-status, | |
| body.dark .nrc-working-status, | |
| html.dark .nrc-working-status, | |
| [data-theme="dark"] .nrc-working-status, | |
| .gradio-container.dark .nrc-working-status { | |
| border-color: #1d4ed8 !important; | |
| border-left-color: #60a5fa !important; | |
| background: linear-gradient(180deg, #1e293b, #0f172a) !important; | |
| color: #e5e7eb !important; | |
| } | |
| .dark .nrc-working-spinner, | |
| body.dark .nrc-working-spinner, | |
| html.dark .nrc-working-spinner, | |
| [data-theme="dark"] .nrc-working-spinner, | |
| .gradio-container.dark .nrc-working-spinner { | |
| border-color: #1e40af !important; | |
| border-top-color: #93c5fd !important; | |
| } | |
| .dark .nrc-working-status .nrc-working-title, | |
| body.dark .nrc-working-status .nrc-working-title, | |
| html.dark .nrc-working-status .nrc-working-title, | |
| [data-theme="dark"] .nrc-working-status .nrc-working-title, | |
| .gradio-container.dark .nrc-working-status .nrc-working-title, | |
| .dark .nrc-working-status .nrc-working-note, | |
| body.dark .nrc-working-status .nrc-working-note, | |
| html.dark .nrc-working-status .nrc-working-note, | |
| [data-theme="dark"] .nrc-working-status .nrc-working-note, | |
| .gradio-container.dark .nrc-working-status .nrc-working-note { | |
| color: #cbd5e1 !important; | |
| } | |
| .dark .nrc-working-status .nrc-working-main, | |
| body.dark .nrc-working-status .nrc-working-main, | |
| html.dark .nrc-working-status .nrc-working-main, | |
| [data-theme="dark"] .nrc-working-status .nrc-working-main, | |
| .gradio-container.dark .nrc-working-status .nrc-working-main { | |
| color: #f8fafc !important; | |
| } | |
| .dark .nrc-working-step, | |
| body.dark .nrc-working-step, | |
| html.dark .nrc-working-step, | |
| [data-theme="dark"] .nrc-working-step, | |
| .gradio-container.dark .nrc-working-step, | |
| .dark .nrc-working-time, | |
| body.dark .nrc-working-time, | |
| html.dark .nrc-working-time, | |
| [data-theme="dark"] .nrc-working-time, | |
| .gradio-container.dark .nrc-working-time { | |
| border-color: #1e40af !important; | |
| background: rgba(30, 41, 59, 0.92) !important; | |
| color: #bfdbfe !important; | |
| } | |
| """ | |
| APP_JS = r""" | |
| () => { | |
| const playbackState = { | |
| armed: false, | |
| playStarted: false, | |
| }; | |
| let activeAudio = null; | |
| const syncAudioElement = () => document.querySelector("#nrc-sync-audio"); | |
| const allAudioElements = () => Array.from(document.querySelectorAll("audio")); | |
| const visibleAudioElements = () => allAudioElements().filter((audio) => audio.id !== "nrc-sync-audio"); | |
| const audioElement = () => activeAudio || syncAudioElement() || visibleAudioElements()[0] || document.querySelector("audio"); | |
| const transcriptPanel = () => document.querySelector("#transcript-panel"); | |
| const transcriptWords = () => Array.from(document.querySelectorAll("#transcript-panel span[data-word-index]")); | |
| const transcriptText = () => { | |
| const panel = transcriptPanel(); | |
| return panel ? panel.innerText.replace(/\s+/g, " ").trim() : ""; | |
| }; | |
| const statusContentElement = () => ( | |
| document.querySelector("#status-panel .prose") || | |
| document.querySelector("#status-panel .html-container > div") || | |
| document.querySelector("#status-panel .html-container") || | |
| document.querySelector("#status-panel") | |
| ); | |
| const statusRootElement = () => document.querySelector("#status-panel"); | |
| const hasTranscript = () => transcriptWords().length > 0 && !transcriptText().includes("Transcript will appear here."); | |
| const updateTranscriptHighlight = (sourceAudio = null) => { | |
| const audio = sourceAudio || audioElement(); | |
| const words = transcriptWords(); | |
| if (!audio || !words.length) return; | |
| const duration = Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : Number(audio.dataset.duration || 0); | |
| const ratio = duration > 0 ? Math.min(1, Math.max(0, audio.currentTime / duration)) : 0; | |
| const activeCount = Math.min(words.length, Math.max(0, Math.ceil(ratio * words.length))); | |
| words.forEach((word, index) => { | |
| const isActive = index < activeCount; | |
| word.style.color = isActive ? "#0f172a" : "#94a3b8"; | |
| word.style.fontWeight = isActive ? "800" : "500"; | |
| word.style.background = index === activeCount - 1 ? "#dbeafe" : "transparent"; | |
| word.style.borderRadius = index === activeCount - 1 ? "4px" : "0"; | |
| word.style.padding = index === activeCount - 1 ? "0 2px" : "0"; | |
| }); | |
| }; | |
| const attachAudioTimeSync = (audio) => { | |
| if (!audio || audio.dataset.nrcSyncAttached === "1") return; | |
| audio.dataset.nrcSyncAttached = "1"; | |
| const setActiveAndUpdate = () => { | |
| activeAudio = audio; | |
| updateTranscriptHighlight(audio); | |
| }; | |
| audio.addEventListener("play", () => { | |
| activeAudio = audio; | |
| if (hasTranscript()) { | |
| const duration = Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : Number(audio.dataset.duration || 0); | |
| if (duration > 0 && audio.currentTime >= duration - 0.05) { | |
| try { audio.currentTime = 0; } catch (_) {} | |
| } | |
| } | |
| updateTranscriptHighlight(audio); | |
| }); | |
| audio.addEventListener("timeupdate", setActiveAndUpdate); | |
| audio.addEventListener("seeking", setActiveAndUpdate); | |
| audio.addEventListener("seeked", setActiveAndUpdate); | |
| audio.addEventListener("pause", setActiveAndUpdate); | |
| audio.addEventListener("ended", setActiveAndUpdate); | |
| audio.addEventListener("loadedmetadata", setActiveAndUpdate); | |
| }; | |
| const attachAllAudioTimeSync = () => allAudioElements().forEach((audio) => attachAudioTimeSync(audio)); | |
| const primeAudio = () => { | |
| const audio = syncAudioElement(); | |
| attachAudioTimeSync(audio); | |
| if (!audio || audio.dataset.primed === "1") return; | |
| try { | |
| audio.dataset.primed = "1"; | |
| audio.loop = true; | |
| audio.muted = true; | |
| audio.volume = 0; | |
| audio.currentTime = 0; | |
| const primed = audio.play(); | |
| if (primed && typeof primed.catch === "function") primed.catch(() => {}); | |
| } catch (_) {} | |
| }; | |
| window.nrcDisarmTranscribePlayback = () => { | |
| playbackState.armed = false; | |
| playbackState.playStarted = false; | |
| activeAudio = null; | |
| allAudioElements().forEach((audio) => { | |
| try { | |
| audio.pause(); | |
| audio.loop = false; | |
| audio.muted = false; | |
| audio.volume = 1; | |
| audio.currentTime = 0; | |
| } catch (_) {} | |
| }); | |
| updateTranscriptHighlight(); | |
| return []; | |
| }; | |
| window.nrcArmTranscribePlayback = () => { | |
| playbackState.armed = true; | |
| playbackState.playStarted = false; | |
| primeAudio(); | |
| return []; | |
| }; | |
| window.nrcShowImmediateWorkingStatus = () => { | |
| window.nrcArmTranscribePlayback(); | |
| const statusPanel = statusContentElement(); | |
| if (!statusPanel) return []; | |
| const startedAt = Date.now(); | |
| window.nrcTranscribeStartedAt = startedAt; | |
| const render = () => { | |
| const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); | |
| statusPanel.innerHTML = ` | |
| <div class="nrc-working-status" data-nrc-working="1" data-nrc-immediate-working="1"> | |
| <div class="nrc-working-head"> | |
| <div class="nrc-working-spinner" aria-hidden="true"></div> | |
| <div> | |
| <div class="nrc-working-title">Transcribing now</div> | |
| <div class="nrc-working-main">Please wait — the page is actively processing.</div> | |
| <div class="nrc-working-note">The app is not frozen. The model is loading or running, and the transcript will appear automatically when finished.</div> | |
| </div> | |
| </div> | |
| <div class="nrc-working-steps"> | |
| <span class="nrc-working-step">Request sent</span> | |
| <span class="nrc-working-step">Model starting/running</span> | |
| <span class="nrc-working-time">Elapsed: <span class="nrc-elapsed-timer" data-nrc-started-at="${startedAt}">${elapsed}s</span></span> | |
| </div> | |
| </div> | |
| `; | |
| }; | |
| render(); | |
| const timer = window.setInterval(() => { | |
| if (!document.querySelector("[data-nrc-working]")) { | |
| window.clearInterval(timer); | |
| return; | |
| } | |
| window.nrcRefreshElapsedTimers(); | |
| }, 1000); | |
| return []; | |
| }; | |
| window.nrcRefreshElapsedTimers = () => { | |
| window.nrcCleanupCompletedStatus(); | |
| window.nrcCleanupStaleImmediateWorking(); | |
| const startedAt = window.nrcTranscribeStartedAt || Date.now(); | |
| document.querySelectorAll("[data-nrc-working]").forEach((panel) => { | |
| panel.querySelectorAll(".nrc-elapsed-timer").forEach((timer) => { | |
| const timerStartedAt = Number(timer.dataset.nrcStartedAt || startedAt); | |
| if (!timer.dataset.nrcStartedAt) timer.dataset.nrcStartedAt = String(timerStartedAt); | |
| const elapsed = Math.max(0, Math.round((Date.now() - timerStartedAt) / 1000)); | |
| timer.textContent = `${elapsed}s`; | |
| }); | |
| }); | |
| }; | |
| window.nrcCleanupStaleImmediateWorking = () => { | |
| const root = statusRootElement(); | |
| if (!root) return []; | |
| const serverWorking = Array.from(root.querySelectorAll("[data-nrc-working]")).filter( | |
| (panel) => !panel.hasAttribute("data-nrc-immediate-working") | |
| ); | |
| if (!serverWorking.length) return []; | |
| root.querySelectorAll("[data-nrc-immediate-working]").forEach((panel) => panel.remove()); | |
| return []; | |
| }; | |
| window.nrcCleanupCompletedStatus = () => { | |
| const root = statusRootElement(); | |
| if (!root) return []; | |
| const hasCompletedReady = root.querySelector(".nrc-ready-status") && root.innerText.includes("Transcription complete"); | |
| if (!hasCompletedReady) return []; | |
| root.querySelectorAll("[data-nrc-working]").forEach((panel) => panel.remove()); | |
| return []; | |
| }; | |
| window.nrcStartTranscribePlayback = () => { | |
| if (!playbackState.armed && playbackState.playStarted) return []; | |
| if (!hasTranscript()) return []; | |
| const audio = syncAudioElement() || audioElement(); | |
| attachAudioTimeSync(audio); | |
| playbackState.playStarted = true; | |
| playbackState.armed = false; | |
| if (audio) { | |
| try { | |
| audio.loop = false; | |
| audio.currentTime = 0; | |
| audio.volume = 1; | |
| audio.muted = false; | |
| const started = audio.play(); | |
| if (started && typeof started.catch === "function") started.catch(() => {}); | |
| } catch (_) {} | |
| } | |
| updateTranscriptHighlight(audio); | |
| return []; | |
| }; | |
| window.nrcMirrorVisibleAudioButton = (button, intent = "") => { | |
| const audio = syncAudioElement(); | |
| if (!button || !audio || !hasTranscript()) return []; | |
| attachAudioTimeSync(audio); | |
| const aria = button.getAttribute("aria-label") || ""; | |
| const duration = Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : Number(audio.dataset.duration || 0); | |
| if (intent === "play" || aria === "Pause") { | |
| activeAudio = audio; | |
| try { | |
| audio.loop = false; | |
| audio.muted = true; | |
| audio.volume = 0; | |
| if (duration > 0 && audio.currentTime >= duration - 0.05) audio.currentTime = 0; | |
| const started = audio.play(); | |
| if (started && typeof started.catch === "function") started.catch(() => {}); | |
| } catch (_) {} | |
| updateTranscriptHighlight(audio); | |
| } else if (intent === "pause" || aria === "Play") { | |
| try { audio.pause(); } catch (_) {} | |
| updateTranscriptHighlight(audio); | |
| } | |
| return []; | |
| }; | |
| window.nrcResetSyncedAudio = () => { | |
| const audio = syncAudioElement(); | |
| if (!audio) return []; | |
| activeAudio = audio; | |
| try { | |
| audio.pause(); | |
| audio.currentTime = 0; | |
| } catch (_) {} | |
| updateTranscriptHighlight(audio); | |
| return []; | |
| }; | |
| const playWhenTranscriptStarts = () => { | |
| if (!playbackState.armed || playbackState.playStarted) return; | |
| if (!hasTranscript()) return; | |
| window.nrcStartTranscribePlayback(); | |
| }; | |
| const observeTranscript = () => { | |
| const panel = transcriptPanel(); | |
| if (!panel) { | |
| setTimeout(observeTranscript, 500); | |
| return; | |
| } | |
| new MutationObserver(() => { | |
| if (playbackState.armed) primeAudio(); | |
| updateTranscriptHighlight(); | |
| playWhenTranscriptStarts(); | |
| }).observe(panel, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true, | |
| attributes: true, | |
| }); | |
| }; | |
| const attachButtonGuards = () => { | |
| document.querySelectorAll("button").forEach((button) => { | |
| const label = button.innerText.trim(); | |
| if (label === "Transcribe" && !button.dataset.nrcPlaybackArm) { | |
| button.dataset.nrcPlaybackArm = "1"; | |
| button.addEventListener("click", () => window.nrcShowImmediateWorkingStatus(), true); | |
| } | |
| if ((label === "Demo 1" || label === "Demo 2") && !button.dataset.nrcPlaybackDisarm) { | |
| button.dataset.nrcPlaybackDisarm = "1"; | |
| button.addEventListener("click", () => window.nrcDisarmTranscribePlayback(), true); | |
| } | |
| const aria = button.getAttribute("aria-label") || ""; | |
| if (button.classList.contains("play-pause-button") && !button.dataset.nrcVisibleAudioBridge) { | |
| button.dataset.nrcVisibleAudioBridge = "1"; | |
| button.addEventListener("click", () => { | |
| const intent = (button.getAttribute("aria-label") || "") === "Play" ? "play" : "pause"; | |
| setTimeout(() => window.nrcMirrorVisibleAudioButton(button, intent), 80); | |
| }, false); | |
| } | |
| if (aria === "Reset audio" && !button.dataset.nrcVisibleAudioReset) { | |
| button.dataset.nrcVisibleAudioReset = "1"; | |
| button.addEventListener("click", () => setTimeout(() => window.nrcResetSyncedAudio(), 80), false); | |
| } | |
| }); | |
| if (playbackState.armed) primeAudio(); | |
| attachAllAudioTimeSync(); | |
| }; | |
| observeTranscript(); | |
| attachButtonGuards(); | |
| setInterval(() => { | |
| attachButtonGuards(); | |
| window.nrcCleanupCompletedStatus(); | |
| window.nrcCleanupStaleImmediateWorking(); | |
| window.nrcRefreshElapsedTimers(); | |
| attachAllAudioTimeSync(); | |
| updateTranscriptHighlight(); | |
| playWhenTranscriptStarts(); | |
| }, 250); | |
| } | |
| """ | |
| def _torch_dtype(device: str): | |
| if device == "cuda" and torch.cuda.is_available(): | |
| return torch.float16 | |
| return torch.float32 | |
| def _model_config(model_choice: str): | |
| return MODEL_CONFIGS.get(model_choice, MODEL_CONFIGS[DEFAULT_MODEL_CHOICE]) | |
| def _model_display_name(model_choice: str): | |
| return _model_config(model_choice)["display"] | |
| def _format_model_notice(model_choice: str = DEFAULT_MODEL_CHOICE): | |
| config = _model_config(model_choice) | |
| notice_class = "nrc-model-notice" if model_choice == FINETUNED_MODEL_KEY else "nrc-model-notice warning" | |
| return f""" | |
| <div class="{notice_class}"> | |
| <strong>Selected model:</strong> {escape(config["short"])}. {escape(config["description"])} | |
| </div> | |
| """ | |
| def _handle_model_choice(model_choice: str): | |
| return _format_model_notice(model_choice), _format_transcript_html(), _format_ready_details(model_choice), {} | |
| def _load_model(model_choice: str, device: str): | |
| model_id = _model_config(model_choice)["id"] | |
| kwargs = { | |
| "trust_remote_code": True, | |
| "max_inference_batch_size": 1, | |
| "max_new_tokens": 192, | |
| "torch_dtype": _torch_dtype(device), | |
| } | |
| if HF_ACCESS_TOKEN: | |
| kwargs["token"] = HF_ACCESS_TOKEN | |
| if device == "cuda" and torch.cuda.is_available(): | |
| kwargs["device_map"] = "cuda" | |
| else: | |
| kwargs["device_map"] = "cpu" | |
| return Qwen3ASRModel.from_pretrained(model_id, **kwargs) | |
| def _normalize_language(language: str): | |
| language = (language or "").strip() | |
| if not language or language == NO_FORCED_LANGUAGE: | |
| return None | |
| return language or None | |
| def _context_for_language(language: str): | |
| forced_language = _normalize_language(language) | |
| if forced_language: | |
| return f"Transcribe the audio in {forced_language}. Return only the transcript." | |
| return DEFAULT_CONTEXT | |
| def _best_available_device(): | |
| return "GPU" if torch.cuda.is_available() else "CPU" | |
| def _format_run_details(model_choice: str, requested_device: str, actual_device: str, detected_language: str, elapsed: float): | |
| actual_display = "GPU / CUDA" if actual_device == "cuda" else "CPU" | |
| requested_display = f"Requested {requested_device}" | |
| language_display = detected_language if detected_language != "not reported" else "Auto language" | |
| device_color = "#166534" if actual_device == "cuda" else "#854d0e" | |
| device_background = "#dcfce7" if actual_device == "cuda" else "#fef3c7" | |
| return f""" | |
| <div class="nrc-ready-status"> | |
| <div style="display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap;"> | |
| <div> | |
| <div class="nrc-ready-title">Ready</div> | |
| <div class="nrc-ready-main">Transcription complete</div> | |
| <div class="nrc-ready-note">{escape(_model_display_name(model_choice))}</div> | |
| </div> | |
| <div style="background:{device_background}; color:{device_color}; border-radius:999px; padding:7px 12px; font-size:0.9rem; font-weight:800; white-space:nowrap;">{escape(actual_display)}</div> | |
| </div> | |
| <div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(145px,1fr)); gap:10px; margin-top:14px;"> | |
| <div style="border:1px solid #e2e8f0; background:#ffffff; border-radius:12px; padding:10px 12px;"> | |
| <div style="color:#64748b; font-size:0.78rem; font-weight:700; margin-bottom:3px;">Device request</div> | |
| <div style="color:#0f172a; font-weight:700;">{escape(requested_display)}</div> | |
| </div> | |
| <div style="border:1px solid #e2e8f0; background:#ffffff; border-radius:12px; padding:10px 12px;"> | |
| <div style="color:#64748b; font-size:0.78rem; font-weight:700; margin-bottom:3px;">Language</div> | |
| <div style="color:#0f172a; font-weight:700;">{escape(language_display)}</div> | |
| </div> | |
| <div style="border:1px solid #e2e8f0; background:#ffffff; border-radius:12px; padding:10px 12px;"> | |
| <div style="color:#64748b; font-size:0.78rem; font-weight:700; margin-bottom:3px;">Runtime</div> | |
| <div style="color:#0f172a; font-weight:700;">{elapsed:.1f} seconds</div> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| def _format_pending_details(device: str = "GPU", model_choice: str = DEFAULT_MODEL_CHOICE): | |
| device_note = "Requesting GPU inference" if device == "GPU" else "Running on CPU" | |
| return f""" | |
| <div class="nrc-working-status" data-nrc-working="1"> | |
| <div class="nrc-working-head"> | |
| <div class="nrc-working-spinner" aria-hidden="true"></div> | |
| <div> | |
| <div class="nrc-working-title">Transcribing now</div> | |
| <div class="nrc-working-main">Please wait — the page is actively processing.</div> | |
| <div class="nrc-working-note">The app is not frozen. {escape(device_note)} for {escape(_model_display_name(model_choice))}; the transcript will appear automatically when finished.</div> | |
| </div> | |
| </div> | |
| <div class="nrc-working-steps"> | |
| <span class="nrc-working-step">Audio received</span> | |
| <span class="nrc-working-step">Model starting</span> | |
| <span class="nrc-working-time">Elapsed: <span class="nrc-elapsed-timer">0s</span></span> | |
| </div> | |
| </div> | |
| """ | |
| def _format_inference_progress_details(device: str, elapsed: float, model_choice: str = DEFAULT_MODEL_CHOICE): | |
| device_note = "GPU inference is running" if device == "GPU" else "CPU inference is running" | |
| return f""" | |
| <div class="nrc-working-status" data-nrc-working="1"> | |
| <div class="nrc-working-head"> | |
| <div class="nrc-working-spinner" aria-hidden="true"></div> | |
| <div> | |
| <div class="nrc-working-title">Transcribing now</div> | |
| <div class="nrc-working-main">Please wait — the page is actively processing.</div> | |
| <div class="nrc-working-note">The app is not frozen. {escape(device_note)} for {escape(_model_display_name(model_choice))}; the transcript and audio highlighting will start automatically after inference finishes.</div> | |
| </div> | |
| </div> | |
| <div class="nrc-working-steps"> | |
| <span class="nrc-working-step">Model running</span> | |
| <span class="nrc-working-step">Transcript pending</span> | |
| <span class="nrc-working-time">Elapsed: <span class="nrc-elapsed-timer">{elapsed:.0f}s</span></span> | |
| </div> | |
| </div> | |
| """ | |
| def _format_ready_details(model_choice: str = DEFAULT_MODEL_CHOICE, message: str | None = None, elapsed: float | None = None, actual_device: str | None = None): | |
| note = message or "Choose or upload audio, then click Transcribe. Results will appear below." | |
| meta_items = [] | |
| if elapsed is not None: | |
| meta_items.append(f"<span class='nrc-ready-pill'>Runtime: {elapsed:.1f}s</span>") | |
| if actual_device: | |
| device_label = "GPU / CUDA" if actual_device == "cuda" else "CPU" | |
| meta_items.append(f"<span class='nrc-ready-pill'>{escape(device_label)}</span>") | |
| meta = f"<div class='nrc-ready-meta'>{''.join(meta_items)}</div>" if meta_items else "" | |
| return f""" | |
| <div class="nrc-ready-status"> | |
| <div class="nrc-ready-title">Ready</div> | |
| <div class="nrc-ready-main">{escape(_model_display_name(model_choice))}</div> | |
| <div class="nrc-ready-note">{escape(note)}</div> | |
| {meta} | |
| </div> | |
| """ | |
| def _format_demo_loading_details(demo_label: str): | |
| return f""" | |
| <div style="border:1px solid #dbeafe; background:linear-gradient(180deg,#ffffff,#f8fbff); border-radius:18px; padding:16px 18px; box-shadow:0 10px 24px rgba(15,23,42,0.06);"> | |
| <div style="color:#475569; font-size:0.78rem; font-weight:700; letter-spacing:0.08em; text-transform:uppercase; margin-bottom:4px;">Demo status</div> | |
| <div style="color:#0f172a; font-size:1.02rem; font-weight:800; line-height:1.25;">Loading {escape(demo_label)}</div> | |
| <div style="color:#475569; margin-top:10px; font-size:0.95rem;">Preparing the demo audio. Click play to listen, then click Transcribe to run ASR.</div> | |
| </div> | |
| """ | |
| def _format_demo_ready_details(demo_label: str): | |
| return f""" | |
| <div style="border:1px solid #bbf7d0; background:linear-gradient(180deg,#ffffff,#f0fdf4); border-radius:18px; padding:16px 18px; box-shadow:0 10px 24px rgba(15,23,42,0.06);"> | |
| <div style="color:#166534; font-size:0.78rem; font-weight:700; letter-spacing:0.08em; text-transform:uppercase; margin-bottom:4px;">Demo ready</div> | |
| <div style="color:#0f172a; font-size:1.02rem; font-weight:800; line-height:1.25;">{escape(demo_label)} is loaded</div> | |
| <div style="color:#475569; margin-top:10px; font-size:0.95rem;">Click play to listen. Click Transcribe to run ASR; repeated Transcribe clicks replay the transcript highlighting.</div> | |
| </div> | |
| """ | |
| def _format_audio_loading_notice(demo_label: str): | |
| return f""" | |
| <div style="border:1px solid #bfdbfe; background:#eff6ff; color:#1e3a8a; border-radius:12px; padding:10px 12px; font-size:0.92rem; margin-top:8px;"> | |
| Loading {escape(demo_label)} into the audio input... | |
| </div> | |
| """ | |
| def _format_demo_ready_notice(demo_label: str): | |
| return f""" | |
| <div style="border:1px solid #bbf7d0; background:#f0fdf4; color:#166534; border-radius:12px; padding:10px 12px; font-size:0.92rem; margin-top:8px;"> | |
| {escape(demo_label)} is loaded. Click play to listen, then click Transcribe. | |
| </div> | |
| """ | |
| def _format_upload_reminder(audio_path: str): | |
| if not audio_path: | |
| return "" | |
| return """ | |
| <div style="border:1px solid #fde68a; background:#fffbeb; color:#854d0e; border-radius:12px; padding:10px 12px; font-size:0.92rem; margin-top:8px;"> | |
| If GPU is unavailable or the queue fails, select CPU and use a short clip. | |
| </div> | |
| """ | |
| def _reset_demo_outputs(): | |
| return "", _format_playback_html(), _format_transcript_html(), _format_ready_details(), {} | |
| def _handle_audio_change(audio_path: str): | |
| if audio_path: | |
| return _format_upload_reminder(audio_path), _format_playback_html(audio_path), _format_transcript_html(), _format_ready_details(), {} | |
| return _reset_demo_outputs() | |
| def _demo_selection(example_key: str, demo_label: str): | |
| selected_device = DEFAULT_DEVICE | |
| audio_path = EXAMPLE_AUDIO[example_key] | |
| yield gr.update(label=f"Audio input — loading {demo_label}...", autoplay=False), selected_device, NO_FORCED_LANGUAGE, DEMO_MAX_NEW_TOKENS, _format_playback_html(), _format_transcript_html(), _format_demo_loading_details(demo_label), {}, _format_audio_loading_notice(demo_label) | |
| yield gr.update(value=audio_path, label=f"Audio input — {demo_label} loaded", autoplay=False), selected_device, NO_FORCED_LANGUAGE, DEMO_MAX_NEW_TOKENS, _format_playback_html(audio_path), _format_transcript_html(), _format_demo_ready_details(demo_label), {}, _format_demo_ready_notice(demo_label) | |
| def _audio_duration_seconds(audio_path: str): | |
| try: | |
| import soundfile as sf | |
| audio_info = sf.info(audio_path) | |
| if audio_info.frames and audio_info.samplerate: | |
| return max(0.5, audio_info.frames / audio_info.samplerate) | |
| except Exception: | |
| pass | |
| try: | |
| import librosa | |
| return max(0.5, float(librosa.get_duration(path=audio_path))) | |
| except Exception: | |
| return 2.0 | |
| def _audio_data_uri(audio_path: str): | |
| if not audio_path or not os.path.exists(audio_path): | |
| return "" | |
| mime_type = mimetypes.guess_type(audio_path)[0] or "audio/wav" | |
| with open(audio_path, "rb") as audio_file: | |
| encoded_audio = base64.b64encode(audio_file.read()).decode("ascii") | |
| return f"data:{mime_type};base64,{encoded_audio}" | |
| def _format_playback_html(audio_path: str = "", autoplay_muted: bool = False): | |
| if not audio_path: | |
| return "" | |
| data_uri = _audio_data_uri(audio_path) | |
| if not data_uri: | |
| return "" | |
| duration_seconds = _audio_duration_seconds(audio_path) | |
| priming_attrs = " autoplay muted loop data-priming='1'" if autoplay_muted else "" | |
| return f""" | |
| <audio id="nrc-sync-audio" preload="auto" playsinline data-duration="{duration_seconds:.6f}" style="display:none;"{priming_attrs} src="{data_uri}"></audio> | |
| """ | |
| def _format_transcript_html(text: str = "", visible_words: int = 0, show_all_words: bool = False): | |
| words = (text or "").split() | |
| visible = words[:visible_words] | |
| if show_all_words and words: | |
| body = " ".join( | |
| f"<span data-word-index='{index}' style='color:{'#0f172a' if index < visible_words else '#94a3b8'}; font-weight:{'800' if index < visible_words else '500'};'>{escape(word)}</span>" | |
| for index, word in enumerate(words) | |
| ) | |
| elif not visible: | |
| body = "<span style='color:#94a3b8;'>Transcript will appear here.</span>" | |
| else: | |
| body = " ".join( | |
| f"<span data-word-index='{index}' style='color:#0f172a; font-weight:800;'>{escape(word)}</span>" | |
| for index, word in enumerate(visible) | |
| ) | |
| return f""" | |
| <div style="border:1px solid #e2e8f0; background:#ffffff; border-radius:16px; padding:16px 18px; min-height:180px; line-height:1.8; font-size:1.2rem;"> | |
| <div style="color:#64748b; font-size:0.78rem; font-weight:800; letter-spacing:0.08em; text-transform:uppercase; margin-bottom:10px;">Transcript</div> | |
| <div>{body}</div> | |
| </div> | |
| """ | |
| def _highlight_transcript(text: str, duration_seconds: float): | |
| words = (text or "").split() | |
| yield _format_transcript_html(text, 0) | |
| if not words: | |
| return | |
| pause = min(0.8, max(0.08, duration_seconds / len(words))) | |
| for index in range(1, len(words) + 1): | |
| time.sleep(pause) | |
| yield _format_transcript_html(text, index) | |
| def _highlight_replayed_transcript(audio_path: str, transcript_text: str): | |
| transcript_text = (transcript_text or "").strip() | |
| if not audio_path or not transcript_text: | |
| yield _format_transcript_html() | |
| return | |
| words = transcript_text.split() | |
| duration_seconds = _audio_duration_seconds(audio_path) | |
| yield _format_transcript_html(transcript_text, 0, show_all_words=True) | |
| if not words: | |
| return | |
| pause = min(0.8, max(0.08, duration_seconds / len(words))) | |
| for index in range(1, len(words) + 1): | |
| time.sleep(pause) | |
| yield _format_transcript_html(transcript_text, index, show_all_words=True) | |
| def _stream_fresh_transcript_with_audio(audio_path: str, transcript: str, duration_seconds: float): | |
| words = (transcript or "").split() | |
| if not words: | |
| yield gr.update(), _format_transcript_html(transcript, 0) | |
| return | |
| pause = min(0.8, max(0.08, duration_seconds / len(words))) | |
| yield gr.update(), _format_transcript_html(transcript, 0, show_all_words=True) | |
| def _stream_replayed_transcript_with_audio(audio_path: str, transcript_text: str): | |
| transcript_text = (transcript_text or "").strip() | |
| if not audio_path or not transcript_text: | |
| yield gr.update(), _format_transcript_html() | |
| return | |
| words = transcript_text.split() | |
| duration_seconds = _audio_duration_seconds(audio_path) | |
| pause = min(0.8, max(0.08, duration_seconds / len(words))) if words else 0.08 | |
| yield gr.update(), _format_transcript_html(transcript_text, 0, show_all_words=True) | |
| for index in range(1, len(words) + 1): | |
| time.sleep(pause) | |
| yield gr.update(), _format_transcript_html(transcript_text, index, show_all_words=True) | |
| def _reset_audio_to_start(audio_path: str): | |
| yield gr.update() | |
| time.sleep(0.25) | |
| def _transcript_cache_matches(cache: dict, audio_path: str, model_choice: str, language: str, max_new_tokens: int): | |
| if not isinstance(cache, dict) or not cache.get("text"): | |
| return False | |
| return ( | |
| cache.get("audio_path") == audio_path | |
| and cache.get("model_choice") == model_choice | |
| and cache.get("language") == language | |
| and int(cache.get("max_new_tokens", 0)) == int(max_new_tokens) | |
| ) | |
| def _make_transcript_cache(audio_path: str, model_choice: str, language: str, max_new_tokens: int, transcript: str, status: str): | |
| return { | |
| "audio_path": audio_path, | |
| "model_choice": model_choice, | |
| "language": language, | |
| "max_new_tokens": int(max_new_tokens), | |
| "text": transcript, | |
| "status": status, | |
| } | |
| def _run_asr(audio_path: str, device: str, model_choice: str, language: str, max_new_tokens: int): | |
| if not audio_path: | |
| raise gr.Error("Please upload or record an audio file first.") | |
| resolved_device = "cuda" if device == "GPU" and torch.cuda.is_available() else "cpu" | |
| started = time.time() | |
| model = _load_model(model_choice, resolved_device) | |
| model.max_new_tokens = int(max_new_tokens) | |
| outputs = model.transcribe( | |
| [audio_path], | |
| context=[_context_for_language(language)], | |
| language=[_normalize_language(language)] if _normalize_language(language) else None, | |
| ) | |
| result = outputs[0] | |
| detected = getattr(result, "language", None) or "not reported" | |
| text = getattr(result, "text", "").strip() | |
| elapsed = time.time() - started | |
| status = _format_run_details(model_choice, device, resolved_device, detected, elapsed) | |
| return text, status | |
| def _stream_transcript(audio_path, device, model_choice, language, max_new_tokens): | |
| started = time.time() | |
| with ThreadPoolExecutor(max_workers=1) as executor: | |
| future = executor.submit(_run_asr, audio_path, device, model_choice, language, max_new_tokens) | |
| while not future.done(): | |
| yield gr.update(), gr.update(), _format_transcript_html(), _format_inference_progress_details(device, time.time() - started, model_choice), {} | |
| time.sleep(0.5) | |
| transcript, status = future.result() | |
| duration_seconds = _audio_duration_seconds(audio_path) | |
| cache = _make_transcript_cache(audio_path, model_choice, language, max_new_tokens, transcript, status) | |
| for audio_update, transcript_html in _stream_fresh_transcript_with_audio(audio_path, transcript, duration_seconds): | |
| yield audio_update, gr.update(), transcript_html, status, cache | |
| def transcribe_cpu(audio_path, model_choice, language, max_new_tokens): | |
| yield from _stream_transcript(audio_path, "CPU", model_choice, language, max_new_tokens) | |
| def transcribe_gpu(audio_path, model_choice, language, max_new_tokens): | |
| yield from _stream_transcript(audio_path, "GPU", model_choice, language, max_new_tokens) | |
| def transcribe(audio_path, device, model_choice, language, max_new_tokens, transcript_cache): | |
| if not audio_path: | |
| raise gr.Error("Please upload, record, or select an audio file first.") | |
| if _transcript_cache_matches(transcript_cache, audio_path, model_choice, language, max_new_tokens): | |
| status = transcript_cache.get("status", "") | |
| text = transcript_cache.get("text", "") | |
| for audio_update in _reset_audio_to_start(audio_path): | |
| yield audio_update, _format_playback_html(audio_path, autoplay_muted=True), _format_transcript_html(text, 0, show_all_words=True), status, transcript_cache | |
| for audio_update, transcript_html in _stream_replayed_transcript_with_audio(audio_path, text): | |
| yield audio_update, gr.update(), transcript_html, status, transcript_cache | |
| return | |
| for audio_update in _reset_audio_to_start(audio_path): | |
| yield audio_update, _format_playback_html(audio_path, autoplay_muted=True), _format_transcript_html(), _format_pending_details(device, model_choice), {} | |
| if device == "GPU": | |
| yield from transcribe_gpu(audio_path, model_choice, language, max_new_tokens) | |
| else: | |
| yield from transcribe_cpu(audio_path, model_choice, language, max_new_tokens) | |
| def _format_login_status(message: str = "", success: bool = False): | |
| if not message: | |
| return "" | |
| border = "#bbf7d0" if success else "#fecaca" | |
| background = "#f0fdf4" if success else "#fef2f2" | |
| color = "#166534" if success else "#991b1b" | |
| return f""" | |
| <div style="border:1px solid {border}; background:{background}; color:{color}; border-radius:12px; padding:10px 12px; font-size:0.92rem; margin-top:8px;"> | |
| {escape(message)} | |
| </div> | |
| """ | |
| def _validate_access(username: str, password: str): | |
| if not DEMO_PASSWORD: | |
| raise gr.Error("DEMO_PASSWORD Space secret is not configured. Please contact the demo owner.") | |
| username_ok = secrets.compare_digest((username or "").strip(), DEMO_USERNAME) | |
| password_ok = secrets.compare_digest(password or "", DEMO_PASSWORD) | |
| if username_ok and password_ok: | |
| return gr.update(visible=False), gr.update(visible=True), True, _format_login_status("Access granted.", True) | |
| return gr.update(visible=True), gr.update(visible=False), False, _format_login_status("Incorrect username or password.") | |
| def _require_access(access_granted: bool): | |
| if access_granted is not True: | |
| raise gr.Error("Please enter the demo password first.") | |
| def _handle_audio_change_unlocked(audio_path: str, access_granted: bool): | |
| _require_access(access_granted) | |
| return _handle_audio_change(audio_path) | |
| def demo_one_unlocked(access_granted: bool): | |
| _require_access(access_granted) | |
| yield from demo_one() | |
| def demo_two_unlocked(access_granted: bool): | |
| _require_access(access_granted) | |
| yield from demo_two() | |
| def transcribe_unlocked(audio_path, device, model_choice, language, max_new_tokens, transcript_cache, access_granted): | |
| _require_access(access_granted) | |
| yield from transcribe(audio_path, device, model_choice, language, max_new_tokens, transcript_cache) | |
| def demo_one(): | |
| yield from _demo_selection("demo1", "Demo 1") | |
| def demo_two(): | |
| yield from _demo_selection("demo2", "Demo 2") | |
| with gr.Blocks(title="NRC-HeritageLab ASR Demo", css=CUSTOM_CSS, js=APP_JS) as demo: | |
| gr.HTML( | |
| """ | |
| <div style="background: linear-gradient(135deg, #102a43, #315c7c); color: #ffffff; padding: 28px; border-radius: 18px; margin-bottom: 12px; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);"> | |
| <h1 style="color: #ffffff; margin: 0 0 10px 0; font-size: 2.25rem; line-height: 1.15; font-weight: 800;">NRC-HeritageLab ASR Demo</h1> | |
| <p style="color: #e0f2fe; margin: 0; font-size: 1.05rem; line-height: 1.45;">Inuktitut automatic speech recognition with a recommended HeritageLab fine-tuned Qwen3-ASR 0.6B checkpoint and an off-the-shelf Qwen3-ASR comparison model.</p> | |
| </div> | |
| """ | |
| ) | |
| access_state = gr.State(False) | |
| with gr.Column(visible=True) as login_panel: | |
| gr.Markdown( | |
| "**Password required**\n\n" | |
| "This public demo is shared with restricted access. Enter the demo username and password to continue." | |
| ) | |
| with gr.Row(): | |
| login_username = gr.Textbox(value=DEMO_USERNAME, label="Username") | |
| login_password = gr.Textbox(label="Password", type="password") | |
| login_button = gr.Button("Unlock demo", variant="primary") | |
| login_status = gr.HTML() | |
| with gr.Column(visible=False) as app_panel: | |
| gr.Markdown( | |
| "Follow the steps below: choose the model, provide audio, then run transcription. " | |
| "The off-the-shelf model is included only for comparison with the HeritageLab fine-tuned checkpoint." | |
| ) | |
| with gr.Group(elem_id="settings-section"): | |
| gr.Markdown("**1. Choose model and recognition settings**") | |
| with gr.Row(equal_height=True, elem_id="settings-main-row"): | |
| with gr.Column(scale=5, min_width=360, elem_id="model-settings-column"): | |
| model_choice = gr.Dropdown( | |
| choices=MODEL_CHOICES, | |
| value=DEFAULT_MODEL_CHOICE, | |
| label="ASR model", | |
| info="Use the fine-tuned model for normal demo use; choose off-the-shelf only to compare before fine-tuning.", | |
| ) | |
| model_notice = gr.HTML(value=_format_model_notice(DEFAULT_MODEL_CHOICE)) | |
| with gr.Column(scale=4, min_width=320, elem_id="runtime-settings-column"): | |
| with gr.Row(elem_id="device-language-row"): | |
| device = gr.Radio( | |
| choices=["CPU", "GPU"], | |
| value=DEFAULT_DEVICE, | |
| label="Inference device", | |
| info="CPU is the default; GPU is faster when available.", | |
| ) | |
| language = gr.Dropdown( | |
| choices=LANGUAGE_CHOICES, | |
| value=NO_FORCED_LANGUAGE, | |
| label="Language constraint", | |
| info="For Inuktitut, keep Auto.", | |
| ) | |
| max_new_tokens = gr.Slider( | |
| minimum=32, | |
| maximum=384, | |
| value=192, | |
| step=16, | |
| label="Maximum transcript length (tokens)", | |
| info="Default is enough for short clips.", | |
| ) | |
| with gr.Group(elem_id="audio-section"): | |
| gr.Markdown("**2. Provide audio**") | |
| with gr.Row(elem_id="audio-control-row", equal_height=False): | |
| gr.Markdown( | |
| "Use a demo, upload audio, or record with the microphone.", | |
| elem_classes=["compact-help"], | |
| ) | |
| demo_one_button = gr.Button("Demo 1", variant="secondary") | |
| demo_two_button = gr.Button("Demo 2", variant="secondary") | |
| run = gr.Button("Transcribe", variant="primary", elem_id="transcribe-button") | |
| audio = gr.Audio( | |
| sources=["upload", "microphone"], | |
| type="filepath", | |
| label="Audio input", | |
| elem_id="audio-input", | |
| ) | |
| upload_notice = gr.HTML() | |
| playback = gr.HTML(value=_format_playback_html(), elem_id="playback-panel") | |
| with gr.Group(elem_id="result-section"): | |
| gr.Markdown("**3. Transcript and run status**") | |
| status = gr.HTML(value=_format_ready_details(DEFAULT_MODEL_CHOICE), elem_id="status-panel") | |
| transcript = gr.HTML(value=_format_transcript_html(), elem_id="transcript-panel") | |
| transcript_cache_state = gr.State({}) | |
| with gr.Accordion("Notes", open=False): | |
| gr.Markdown( | |
| """ | |
| - This demo runs locally inside the Hugging Face Space; it does not call an external ASR API. | |
| - The default language setting does not force English, which is important for Inuktitut speech. | |
| - Model choice matters: the fine-tuned checkpoint is recommended; the off-the-shelf model is included as a comparison baseline. | |
| - CPU inference is included for portability, but GPU inference is recommended for longer clips. | |
| """ | |
| ) | |
| model_choice.change( | |
| _handle_model_choice, | |
| inputs=[model_choice], | |
| outputs=[model_notice, transcript, status, transcript_cache_state], | |
| show_progress="hidden", | |
| api_name=False, | |
| ) | |
| audio.change( | |
| _handle_audio_change_unlocked, | |
| inputs=[audio, access_state], | |
| outputs=[upload_notice, playback, transcript, status, transcript_cache_state], | |
| show_progress="hidden", | |
| api_name=False, | |
| ) | |
| audio.clear( | |
| _reset_demo_outputs, | |
| outputs=[upload_notice, playback, transcript, status, transcript_cache_state], | |
| show_progress="hidden", | |
| api_name=False, | |
| ) | |
| demo_one_button.click( | |
| demo_one_unlocked, | |
| inputs=[access_state], | |
| outputs=[audio, device, language, max_new_tokens, playback, transcript, status, transcript_cache_state, upload_notice], | |
| show_progress="hidden", | |
| api_name=False, | |
| ) | |
| demo_two_button.click( | |
| demo_two_unlocked, | |
| inputs=[access_state], | |
| outputs=[audio, device, language, max_new_tokens, playback, transcript, status, transcript_cache_state, upload_notice], | |
| show_progress="hidden", | |
| api_name=False, | |
| ) | |
| run.click( | |
| transcribe_unlocked, | |
| inputs=[audio, device, model_choice, language, max_new_tokens, transcript_cache_state, access_state], | |
| outputs=[audio, playback, transcript, status, transcript_cache_state], | |
| show_progress="hidden", | |
| api_name=False, | |
| ) | |
| login_button.click( | |
| _validate_access, | |
| inputs=[login_username, login_password], | |
| outputs=[login_panel, app_panel, access_state, login_status], | |
| show_progress="hidden", | |
| api_name="unlock_demo", | |
| ) | |
| if __name__ == "__main__": | |
| if not DEMO_PASSWORD: | |
| raise RuntimeError("DEMO_PASSWORD Space secret must be configured before launch.") | |
| demo.queue(default_concurrency_limit=1).launch() | |