import array
import subprocess
import sys
import tempfile
import time
import wave
from pathlib import Path
import gradio as gr
import spaces
from huggingface_hub import hf_hub_download
import transcribe_cpp
# --------------------------------------------------------------------------
# Language tables
# --------------------------------------------------------------------------
# Canary 1B v2 — 25 European (BCP-47). Language hint REQUIRED, no auto-detect.
CANARY_1B_V2_LANGS = [
("Bulgarian", "bg"), ("Croatian", "hr"), ("Czech", "cs"), ("Danish", "da"),
("Dutch", "nl"), ("English", "en"), ("Estonian", "et"), ("Finnish", "fi"),
("French", "fr"), ("German", "de"), ("Greek", "el"), ("Hungarian", "hu"),
("Italian", "it"), ("Latvian", "lv"), ("Lithuanian", "lt"), ("Maltese", "mt"),
("Polish", "pl"), ("Portuguese", "pt"), ("Romanian", "ro"), ("Slovak", "sk"),
("Slovenian", "sl"), ("Spanish", "es"), ("Swedish", "sv"), ("Russian", "ru"),
("Ukrainian", "uk"),
]
# Cohere Transcribe — 14 languages.
COHERE_LANGS = [
("English", "en"), ("French", "fr"), ("German", "de"), ("Spanish", "es"),
("Italian", "it"), ("Portuguese", "pt"), ("Dutch", "nl"), ("Polish", "pl"),
("Greek", "el"), ("Arabic", "ar"), ("Japanese", "ja"), ("Chinese", "zh"),
("Vietnamese", "vi"), ("Korean", "ko"),
]
# SenseVoice Small — auto-detect + 5 named languages.
SENSEVOICE_LANGS = [
("Auto-detect", "auto"), ("Chinese", "zh"), ("Cantonese", "yue"),
("English", "en"), ("Japanese", "ja"), ("Korean", "ko"),
]
# Nemotron 3.5 ASR Streaming — 40 locales (32 transcription-ready + 8 adaptation-ready).
NEMOTRON_35_LOCALES = [
("Auto-detect", "auto"),
("English (US)", "en-US"), ("English (UK)", "en-GB"), ("Spanish (US)", "es-US"),
("Spanish (ES)", "es-ES"), ("French (FR)", "fr-FR"), ("French (CA)", "fr-CA"),
("Italian", "it-IT"), ("Portuguese (BR)", "pt-BR"), ("Portuguese (PT)", "pt-PT"),
("Dutch", "nl-NL"), ("German", "de-DE"), ("Turkish", "tr-TR"), ("Russian", "ru-RU"),
("Arabic", "ar-AR"), ("Hindi", "hi-IN"), ("Japanese", "ja-JP"), ("Korean", "ko-KR"),
("Vietnamese", "vi-VN"), ("Ukrainian", "uk-UA"),
("Polish", "pl-PL"), ("Swedish", "sv-SE"), ("Czech", "cs-CZ"),
("Norwegian Bokmål", "nb-NO"), ("Danish", "da-DK"), ("Bulgarian", "bg-BG"),
("Finnish", "fi-FI"), ("Croatian", "hr-HR"), ("Slovak", "sk-SK"),
("Mandarin", "zh-CN"), ("Hungarian", "hu-HU"), ("Romanian", "ro-RO"),
("Estonian", "et-EE"),
("Greek", "el-GR"), ("Lithuanian", "lt-LT"), ("Latvian", "lv-LV"),
("Maltese", "mt-MT"), ("Slovenian", "sl-SI"), ("Hebrew", "he-IL"),
("Thai", "th-TH"), ("Norwegian Nynorsk", "nn-NO"),
]
# Whisper large-v3-turbo — 100 languages + auto-detect (canonical OpenAI set; v3 adds Cantonese).
WHISPER_LANGS = [
("Auto-detect", "auto"), ("Afrikaans", "af"), ("Albanian", "sq"), ("Amharic", "am"),
("Arabic", "ar"), ("Armenian", "hy"), ("Assamese", "as"), ("Azerbaijani", "az"),
("Bashkir", "ba"), ("Basque", "eu"), ("Belarusian", "be"), ("Bengali", "bn"),
("Bosnian", "bs"), ("Breton", "br"), ("Bulgarian", "bg"), ("Burmese", "my"),
("Cantonese", "yue"), ("Castilian", "es"), ("Catalan", "ca"), ("Chinese", "zh"),
("Croatian", "hr"), ("Czech", "cs"), ("Danish", "da"), ("Dutch", "nl"),
("English", "en"), ("Estonian", "et"), ("Faroese", "fo"), ("Finnish", "fi"),
("Flemish", "nl"), ("French", "fr"), ("Galician", "gl"), ("Georgian", "ka"),
("German", "de"), ("Greek", "el"), ("Gujarati", "gu"), ("Haitian Creole", "ht"),
("Hausa", "ha"), ("Hawaiian", "haw"), ("Hebrew", "he"), ("Hindi", "hi"),
("Hungarian", "hu"), ("Icelandic", "is"), ("Indonesian", "id"), ("Italian", "it"),
("Japanese", "ja"), ("Javanese", "jw"), ("Kannada", "kn"), ("Kazakh", "kk"),
("Khmer", "km"), ("Korean", "ko"), ("Lao", "lo"), ("Latin", "la"),
("Latvian", "lv"), ("Lingala", "ln"), ("Lithuanian", "lt"), ("Luxembourgish", "lb"),
("Macedonian", "mk"), ("Malagasy", "mg"), ("Malay", "ms"), ("Malayalam", "ml"),
("Maltese", "mt"), ("Maori", "mi"), ("Marathi", "mr"), ("Mongolian", "mn"),
("Nepali", "ne"), ("Norwegian", "no"), ("Nynorsk", "nn"), ("Occitan", "oc"),
("Pashto", "ps"), ("Persian", "fa"), ("Polish", "pl"), ("Portuguese", "pt"),
("Punjabi", "pa"), ("Romanian", "ro"), ("Russian", "ru"), ("Sanskrit", "sa"),
("Serbian", "sr"), ("Shona", "sn"), ("Sindhi", "sd"), ("Sinhala", "si"),
("Slovak", "sk"), ("Slovenian", "sl"), ("Somali", "so"), ("Spanish", "es"),
("Sundanese", "su"), ("Swahili", "sw"), ("Swedish", "sv"), ("Tagalog", "tl"),
("Tajik", "tg"), ("Tamil", "ta"), ("Tatar", "tt"), ("Telugu", "te"),
("Thai", "th"), ("Tibetan", "bo"), ("Turkish", "tr"), ("Turkmen", "tk"),
("Ukrainian", "uk"), ("Urdu", "ur"), ("Uzbek", "uz"), ("Vietnamese", "vi"),
("Welsh", "cy"), ("Yiddish", "yi"), ("Yoruba", "yo"),
]
# --------------------------------------------------------------------------
# Model registry
# --------------------------------------------------------------------------
DEFAULT_MODEL = "Parakeet — Unified EN 0.6B"
MODELS = {
"Parakeet — Unified EN 0.6B": {
"family": "Parakeet",
"repo_id": "handy-computer/parakeet-unified-en-0.6b-gguf",
"filename": "parakeet-unified-en-0.6b-Q8_0.gguf",
"language": None,
"language_choices": None,
"supports_timestamps": True,
"max_seconds": None,
"size": "731 MB",
"info": "NVIDIA FastConformer + RNN-T · English · 1.60% WER · offline + buffered streaming",
"tags": ["TIMESTAMPS", "STREAMING", "ENGLISH"],
},
"Nemotron — 3.5 ASR Streaming 0.6B": {
"family": "Nemotron",
"repo_id": "handy-computer/nemotron-3.5-asr-streaming-0.6b-gguf",
"filename": "nemotron-3.5-asr-streaming-0.6b-Q8_0.gguf",
"language": "en-US",
"language_choices": NEMOTRON_35_LOCALES,
"auto_value": "auto",
"supports_timestamps": True,
"max_seconds": None,
"size": "716 MB",
"info": "NVIDIA cache-aware FastConformer + RNN-T · 40 locales · streaming · native punctuation",
"tags": ["TIMESTAMPS", "STREAMING", "40 LOCALES", "AUTO-DETECT"],
},
"Whisper — Large v3 Turbo": {
"family": "Whisper",
"repo_id": "handy-computer/whisper-large-v3-turbo-gguf",
"filename": "whisper-large-v3-turbo-Q8_0.gguf",
"language": "auto",
"language_choices": WHISPER_LANGS,
"auto_value": None,
"supports_timestamps": True,
"max_seconds": None,
"size": "845 MB",
"info": "OpenAI encoder-decoder transformer · 100 languages + auto-detect · 2.01% WER · translation to English",
"tags": ["TIMESTAMPS", "100 LANGUAGES", "AUTO-DETECT", "TRANSLATION"],
},
"Canary — 1B v2": {
"family": "Canary",
"repo_id": "handy-computer/canary-1b-v2-gguf",
"filename": "canary-1b-v2-Q8_0.gguf",
"language": "en",
"language_choices": CANARY_1B_V2_LANGS,
"supports_timestamps": False,
"max_seconds": 400,
"size": "1.1 GB",
"info": "NVIDIA FastConformer + Transformer AED · 25 European languages · 1.91% WER · language hint required",
"tags": ["25 LANGUAGES", "TRANSLATION"],
},
"Canary — Qwen 2.5B": {
"family": "Canary",
"repo_id": "handy-computer/canary-qwen-2.5b-gguf",
"filename": "canary-qwen-2.5b-Q8_0.gguf",
"language": None,
"language_choices": None,
"supports_timestamps": False,
"max_seconds": None,
"size": "2.61 GB",
"info": "NVIDIA FastConformer encoder + Qwen3-1.7B LM · English · 1.63% WER · ~54 min context",
"tags": ["AUDIO-LLM", "ENGLISH", "LONG-FORM"],
},
"Cohere — Transcribe 03-2026": {
"family": "Cohere",
"repo_id": "handy-computer/cohere-transcribe-03-2026-gguf",
"filename": "cohere-transcribe-03-2026-Q8_0.gguf",
"language": "en",
"language_choices": COHERE_LANGS,
"supports_timestamps": False,
"max_seconds": 400,
"size": "2.41 GB",
"info": "Cohere Conformer encoder-decoder · 14 languages · 1.27% WER · tops the Open ASR leaderboard",
"tags": ["14 LANGUAGES", "TOP OPEN-ASR"],
},
"Qwen3-ASR — 0.6B": {
"family": "Qwen3-ASR",
"repo_id": "handy-computer/qwen3-asr-0.6b-gguf",
"filename": "Qwen3-ASR-0.6B-Q8_0.gguf",
"language": None,
"language_choices": None,
"supports_timestamps": False,
"max_seconds": None,
"size": "811 MB",
"info": "Alibaba audio encoder + Qwen3 LM · 30 languages, auto-detect only · 2.11% WER · ~87 min context",
"tags": ["AUDIO-LLM", "30 LANGUAGES", "AUTO-DETECT", "LONG-FORM"],
},
"SenseVoice — Small": {
"family": "SenseVoice",
"repo_id": "handy-computer/SenseVoiceSmall-gguf",
"filename": "SenseVoiceSmall-Q8_0.gguf",
"language": "auto",
"language_choices": SENSEVOICE_LANGS,
"auto_value": None,
"supports_timestamps": False,
"max_seconds": None,
"size": "241 MB",
"info": "FunAudioLLM SAN-M + CTC · zh/yue/en/ja/ko · 3.13% WER · strongest on Mandarin · emotion/event tags",
"tags": ["AUTO-DETECT", "ZH/EN/JA/KO", "COMPACT"],
},
"FunASR — MLT Nano 2512": {
"family": "FunASR",
"repo_id": "handy-computer/fun-asr-mlt-nano-2512-gguf",
"filename": "Fun-ASR-MLT-Nano-2512-Q8_0.gguf",
"language": None,
"language_choices": None,
"supports_timestamps": False,
"max_seconds": None,
"size": "850 MB",
"info": "Alibaba SenseVoice encoder + Qwen3-0.6B LM · 31 languages, auto · 1.74% WER · ~41 min context",
"tags": ["AUDIO-LLM", "31 LANGUAGES", "AUTO-DETECT"],
},
"Granite — Speech 4.1 2B": {
"family": "Granite",
"repo_id": "handy-computer/granite-speech-4.1-2b-gguf",
"filename": "granite-speech-4.1-2b-Q8_0.gguf",
"language": None,
"language_choices": None,
"supports_timestamps": False,
"max_seconds": None,
"size": "2.56 GB",
"info": "IBM Conformer + Granite-4.0-1b LM · en/fr/de/es/pt/ja · 1.32% WER · translation support",
"tags": ["AUDIO-LLM", "6 LANGUAGES", "TRANSLATION"],
},
"Moonshine — Base": {
"family": "Moonshine",
"repo_id": "handy-computer/moonshine-base-gguf",
"filename": "moonshine-base-Q8_0.gguf",
"language": None,
"language_choices": None,
"supports_timestamps": False,
"max_seconds": None,
"size": "74 MB",
"info": "Useful Sensors encoder-decoder · English · 3.26% WER · tiny 74 MB footprint",
"tags": ["ENGLISH", "COMPACT", "74 MB"],
},
"Moonshine — Streaming Medium": {
"family": "Moonshine",
"repo_id": "handy-computer/moonshine-streaming-medium-gguf",
"filename": "moonshine-streaming-medium-Q8_0.gguf",
"language": None,
"language_choices": None,
"supports_timestamps": False,
"max_seconds": None,
"size": "282 MB",
"info": "Useful Sensors streaming encoder-decoder · English · 2.16% WER · real-time streaming",
"tags": ["STREAMING", "ENGLISH"],
},
}
SAMPLE_CLIP = str(Path(__file__).parent / "sample_clip.wav")
# --------------------------------------------------------------------------
# Audio helpers
# --------------------------------------------------------------------------
def format_time(ms: int) -> str:
s = ms / 1000.0
h = int(s // 3600)
m = int((s % 3600) // 60)
sec = s % 60
if h > 0:
return f"{h:02d}:{m:02d}:{sec:05.2f}"
return f"{m:02d}:{sec:05.2f}"
def _srt_time(ms: int) -> str:
s = ms / 1000.0
h = int(s // 3600)
m = int((s % 3600) // 60)
sec = int(s % 60)
millis = int(ms % 1000)
return f"{h:02d}:{m:02d}:{sec:02d},{millis:03d}"
def _vtt_time(ms: int) -> str:
s = ms / 1000.0
h = int(s // 3600)
m = int((s % 3600) // 60)
sec = int(s % 60)
millis = int(ms % 1000)
return f"{h:02d}:{m:02d}:{sec:02d}.{millis:03d}"
MAX_CUE_CHARS = 42
MAX_CUE_WORDS = 10
MAX_CUE_MS = 6000
BREAK_CHARS = set(",.;:!?")
def _group_words_into_cues(words):
cues = []
current = []
current_text = ""
for w in words:
word_text = w.text.strip()
if not word_text:
continue
candidate = (current_text + " " + word_text).strip() if current_text else word_text
duration = (w.t1_ms - current[0].t0_ms) if current else 0
if current and (
len(candidate) > MAX_CUE_CHARS * 2
or len(current) >= MAX_CUE_WORDS
or duration > MAX_CUE_MS
):
cues.append((current[0].t0_ms, current[-1].t1_ms, current_text))
current = []
current_text = ""
candidate = word_text
current.append(w)
current_text = candidate
if word_text and word_text[-1] in BREAK_CHARS and len(current_text) >= MAX_CUE_CHARS:
cues.append((current[0].t0_ms, current[-1].t1_ms, current_text))
current = []
current_text = ""
if current:
cues.append((current[0].t0_ms, current[-1].t1_ms, current_text))
return cues
def _wrap_cue_text(text: str) -> str:
if len(text) <= MAX_CUE_CHARS:
return text
mid = len(text) // 2
best = -1
for i in range(mid - 10, mid + 10):
if 0 <= i < len(text) and text[i] == " ":
if best == -1 or abs(i - mid) < abs(best - mid):
best = i
if best == -1:
return text
return text[:best] + "\n" + text[best + 1:]
def build_srt(words) -> str:
cues = _group_words_into_cues(words)
lines = []
for i, (t0, t1, text) in enumerate(cues, 1):
lines.append(str(i))
lines.append(f"{_srt_time(t0)} --> {_srt_time(t1)}")
lines.append(_wrap_cue_text(text))
lines.append("")
return "\n".join(lines)
def build_vtt(words) -> str:
cues = _group_words_into_cues(words)
lines = ["WEBVTT", ""]
for t0, t1, text in cues:
lines.append(f"{_vtt_time(t0)} --> {_vtt_time(t1)}")
lines.append(_wrap_cue_text(text))
lines.append("")
return "\n".join(lines)
def build_timestamped_text(words) -> str:
cues = _group_words_into_cues(words)
return "\n".join(f"[{format_time(t0)} -> {format_time(t1)}] {text}" for t0, t1, text in cues)
def convert_to_wav16k(input_path: Path) -> Path:
"""Return a 16 kHz mono WAV path, converting via ffmpeg only if needed."""
if input_path.suffix.lower() == ".wav":
try:
with wave.open(str(input_path), "rb") as w:
if w.getnchannels() == 1 and w.getframerate() == 16000 and w.getsampwidth() == 2:
return input_path
except Exception:
pass
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp.close()
tmp_path = Path(tmp.name)
cmd = ["ffmpeg", "-y", "-i", str(input_path), "-ar", "16000", "-ac", "1", "-f", "wav", str(tmp_path)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
tmp_path.unlink(missing_ok=True)
raise gr.Error("Could not read that audio file. Details: " + result.stderr.strip()[-500:])
return tmp_path
def load_wav_mono16k(path: Path) -> array.array:
with wave.open(str(path), "rb") as w:
n_channels = w.getnchannels()
sample_width = w.getsampwidth()
framerate = w.getframerate()
frames = w.readframes(w.getnframes())
if sample_width != 2:
raise gr.Error(f"Expected 16-bit PCM audio, got {sample_width * 8}-bit.")
if framerate != 16000:
raise gr.Error(f"Expected 16 kHz audio after conversion, got {framerate} Hz.")
pcm16 = array.array("h")
pcm16.frombytes(frames)
if sys.byteorder == "big":
pcm16.byteswap()
if n_channels > 1:
mono = array.array("h", [0]) * (len(pcm16) // n_channels)
for i in range(len(mono)):
acc = sum(pcm16[i * n_channels + c] for c in range(n_channels))
mono[i] = int(acc / n_channels)
pcm16 = mono
return array.array("f", (s / 32768.0 for s in pcm16))
def _gpu_duration(model_path: str, pcm: array.array, language, want_timestamps: bool):
p = model_path.lower()
if any(k in p for k in ("granite", "canary-qwen", "cohere")):
return 240
return 90
@spaces.GPU(duration=_gpu_duration)
def run_transcription(model_path: str, pcm: array.array, language, want_timestamps: bool):
start = time.time()
with transcribe_cpp.Model(model_path, backend="auto") as model:
backend_used = model.backend
with model.session() as session:
if want_timestamps:
result = session.run(pcm, timestamps="word", language=language)
else:
result = session.run(pcm, language=language)
elapsed = time.time() - start
words = list(getattr(result, "words", None) or [])
text = (result.text or "").strip()
detected_language = getattr(result, "language", None)
return text, words, detected_language, backend_used, elapsed
# --------------------------------------------------------------------------
# Gradio callbacks
# --------------------------------------------------------------------------
def resolve_language(cfg: dict, code) -> object:
"""Map the UI selection onto the value the engine expects."""
if not cfg.get("language_choices"):
return cfg.get("language")
if code == "auto":
return cfg.get("auto_value")
return code
def model_card_md(cfg: dict) -> str:
badges = ""
for tag in cfg.get("tags", []):
cls = "badge badge--alert" if tag == "GATED" else "badge"
badges += f'{tag}'
slug = cfg["repo_id"].split("/")[-1].replace("-gguf", "").replace("-GGUF", "")
return (
'
'
'
'
f'
Family{cfg["family"]}
'
f'
Weights{cfg["size"]}
'
f'
Checkpoint{slug}
'
"
"
f'
{badges}
'
f'
{cfg["info"]}
'
"
"
)
FORMAT_CHOICES = ["SRT (.srt)", "WebVTT (.vtt)", "Timestamped text (.txt)"]
def on_model_change(model_label: str):
cfg = MODELS.get(model_label, MODELS[DEFAULT_MODEL])
card = model_card_md(cfg)
choices = cfg.get("language_choices")
if choices:
lang_update = gr.Dropdown(choices=choices, value=cfg["language"], visible=True)
else:
lang_update = gr.Dropdown(choices=[], value=None, visible=False)
if cfg.get("supports_timestamps", True):
ts_update = gr.Checkbox(interactive=True, value=True, label="Include timestamps")
fmt_update = gr.Dropdown(visible=True)
else:
ts_update = gr.Checkbox(
interactive=False, value=False, label="Include timestamps (not supported by this model)"
)
fmt_update = gr.Dropdown(visible=False)
return card, lang_update, ts_update, fmt_update
def on_timestamps_toggle(show_timestamps: bool):
return gr.Dropdown(visible=show_timestamps)
def do_transcribe(
audio_path, model_label, language_code, show_timestamps, output_format,
progress=gr.Progress(track_tqdm=True),
):
if not audio_path:
raise gr.Error("Please upload, record, or pick the sample clip first.")
cfg = MODELS.get(model_label, MODELS[DEFAULT_MODEL])
language = resolve_language(cfg, language_code)
want_timestamps = bool(show_timestamps) and cfg.get("supports_timestamps", True)
progress(0.05, desc="Reading audio…")
src = Path(audio_path)
wav_path = convert_to_wav16k(src)
try:
pcm = load_wav_mono16k(wav_path)
finally:
if wav_path != src:
wav_path.unlink(missing_ok=True)
if len(pcm) == 0:
raise gr.Error("That clip appears to be empty or silent.")
duration_s = len(pcm) / 16000.0
max_s = cfg.get("max_seconds")
if max_s and duration_s > max_s:
raise gr.Error(
f"{model_label} accepts up to {max_s / 60:.1f} minutes per clip; "
f"this clip is {duration_s / 60:.1f} minutes. Try a shorter clip or a different model."
)
progress(0.2, desc=f"Fetching {cfg['family']} weights ({cfg['size']}, cached after first run)…")
try:
model_path = hf_hub_download(repo_id=cfg["repo_id"], filename=cfg["filename"])
except Exception as e:
hint = " (this model is gated — accept its license on the Hub first)" if "GATED" in cfg.get("tags", []) else ""
raise gr.Error(f"Could not download model weights{hint}: {e}")
progress(0.5, desc="Transcribing…")
try:
text, words, detected_lang, backend_used, elapsed = run_transcription(
model_path, pcm, language, want_timestamps
)
except Exception as e:
raise gr.Error(f"Transcription failed: {e}")
progress(1.0, desc="Done")
if want_timestamps and words:
if output_format and output_format.startswith("SRT"):
content = build_srt(words)
ext = "srt"
elif output_format and output_format.startswith("WebVTT"):
content = build_vtt(words)
ext = "vtt"
else:
content = build_timestamped_text(words)
ext = "txt"
preview = content
else:
content = text if text else "(no speech detected)"
ext = "txt"
preview = (
f"Model: {model_label}\n"
f"Clip duration: {format_time(int(duration_s * 1000))}\n"
f"Processing time: {elapsed:.2f}s (backend: {backend_used})\n"
f"Language: {detected_lang or language or 'n/a'}\n\n"
f"{content}"
)
out_dir = Path(tempfile.mkdtemp(prefix="transcript_"))
out_path = out_dir / f"{src.stem}_transcript.{ext}"
out_path.write_text(content, encoding="utf-8")
return preview, gr.DownloadButton(value=str(out_path), visible=True, label=f"Download .{ext}")
def reset_all():
return None, "", gr.DownloadButton(value=None, visible=False)
# --------------------------------------------------------------------------
# UI
# --------------------------------------------------------------------------
CUSTOM_CSS = """
@import url('https://fonts.googleapis.com/css2?family=Anton&family=Archivo:wght@500;600;700;800;900&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
/* ---- Light palette ---- */
:root {
--paper: #ECE6D8;
--panel: #F8F5EC;
--card: #FFFFFF;
--ink: #1C1709;
--ink-soft: #544E3C;
--accent: #fba2c9;
--accent-2: #1F6F5C; /* pine green */
--accent-3: #E9B44C; /* amber */
--grid: rgba(28,23,9,0.05);
--shadow-ink: #1C1709;
--badge-ink: #1C1709; /* badge text stays dark on amber in both modes */
--hover-wash: rgba(28,23,9,0.06);
}
/* ---- Dark palette (Gradio toggles a .dark class) ---- */
.dark {
--paper: #14110A;
--panel: #1E1A11;
--card: #282316;
--ink: #F4EFE0;
--ink-soft: #C7BEA5;
--accent: #fba2c9;
--accent-2: #43B493;
--accent-3: #F2C879;
--grid: rgba(244,239,224,0.045);
--shadow-ink: #000000;
--badge-ink: #1C1709;
--hover-wash: rgba(244,239,224,0.08);
}
.gradio-container {
max-width: 1180px !important;
margin: auto !important;
background:
repeating-linear-gradient(0deg, transparent, transparent 31px, var(--grid) 32px),
var(--paper) !important;
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif !important;
color: var(--ink) !important;
}
footer { display: none !important; }
/* ---------- Hero (compact strip) ---------- */
#hero {
display: flex;
align-items: center;
gap: 26px;
flex-wrap: wrap;
border: 2px solid var(--ink);
background: var(--panel);
box-shadow: 6px 6px 0 0 var(--shadow-ink);
padding: 14px 20px;
margin: 14px 0 12px 0;
}
#hero .hero-left { flex: 0 0 auto; }
#hero .hero-mid { flex: 1 1 300px; min-width: 240px; }
#hero .hero-right { flex: 0 0 auto; margin-left: auto; }
#hero .eyebrow {
display: flex;
align-items: center;
gap: 8px;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-soft);
margin: 0 0 7px 0;
}
#hero .eyebrow .dot {
width: 8px; height: 8px;
background: var(--accent);
border: 1.5px solid var(--ink);
flex: none;
}
#hero h1 {
font-family: 'Anton', ui-sans-serif, sans-serif;
font-size: clamp(1.7rem, 3vw, 2.4rem);
line-height: 0.96;
font-weight: 400;
letter-spacing: 0.005em;
text-transform: uppercase;
margin: 0;
color: var(--ink);
}
#hero h1 .accent { color: var(--accent); }
#hero .lede {
color: var(--ink-soft);
font-size: 0.85rem;
line-height: 1.45;
max-width: 56ch;
margin: 0 0 9px 0;
}
#hero .lede b { color: var(--ink); font-weight: 600; }
#hero .spec-plate {
display: flex;
flex-wrap: wrap;
border: 1.5px solid var(--ink);
background: var(--card);
width: fit-content;
}
#hero .spec-plate span {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.58rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-soft);
padding: 4px 10px;
border-right: 1.5px solid var(--ink);
white-space: nowrap;
}
#hero .spec-plate span:last-child { border-right: none; }
#hero .spec-plate b { color: var(--ink); font-weight: 600; }
#hero .vu svg { display: block; width: 150px; height: 64px; }
#hero .vu rect {
transform-box: fill-box;
transform-origin: bottom;
animation-name: rise;
animation-duration: 0.55s;
animation-timing-function: cubic-bezier(.22,.9,.3,1);
animation-fill-mode: both;
}
#hero .vu .b1 { fill: var(--accent); }
#hero .vu .b2 { fill: var(--accent-2); }
#hero .vu .b3 { fill: var(--accent-3); }
#hero .vu .b4 { fill: var(--ink); }
@keyframes rise {
from { transform: scaleY(0.05); opacity: 0.35; }
to { transform: scaleY(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
#hero .vu rect { animation: none; }
}
/* ---------- Panels (tight, one-viewport friendly) ---------- */
.panel {
border: 2px solid var(--ink) !important;
border-radius: 0 !important;
background: var(--panel) !important;
padding: 14px 16px 16px 16px !important;
box-shadow: 6px 6px 0 0 var(--shadow-ink);
}
#console-panel { border-top: 10px solid var(--accent) !important; }
#output-panel { border-top: 10px solid var(--accent-2) !important; }
.panel-eyebrow, .panel-eyebrow p {
font-family: 'IBM Plex Mono', ui-monospace, monospace !important;
font-size: 0.66rem !important;
font-weight: 600 !important;
letter-spacing: 0.2em !important;
text-transform: uppercase !important;
color: var(--ink) !important;
margin: 0 0 10px 0 !important;
}
/* shrink the vertical rhythm inside panels */
.panel .block, .panel .form, .panel .wrap { margin-bottom: 0 !important; }
.panel > .form, .panel .col-wrap { gap: 8px !important; }
/* ---------- Component frames ---------- */
#model-select, #language-select, #audio-input, #timestamps-toggle {
border-radius: 0 !important;
}
#model-select .wrap, #language-select .wrap,
#model-select select, #language-select select,
#audio-input .wrap {
border-radius: 0 !important;
}
#audio-input { min-height: 0 !important; }
#audio-input .wrap { min-height: 0 !important; padding: 8px !important; }
label span, .label-wrap span {
font-family: 'IBM Plex Mono', ui-monospace, monospace !important;
font-size: 0.64rem !important;
font-weight: 600 !important;
letter-spacing: 0.12em !important;
text-transform: uppercase !important;
color: var(--ink) !important;
}
/* checkbox label (e.g. "Include timestamps") — keep it legible */
#timestamps-toggle label, #timestamps-toggle span {
color: var(--ink) !important;
}
/* ---------- Spec card (model info) ---------- */
#model-info { margin: -2px 0 4px 0; }
#model-info .spec-card {
border: 1.5px solid var(--ink);
background: var(--card);
padding: 10px 12px;
}
#model-info .spec-grid {
display: flex;
flex-wrap: wrap;
gap: 18px;
margin-bottom: 8px;
}
#model-info .spec-grid .k {
display: block;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.55rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-soft);
margin-bottom: 1px;
}
#model-info .spec-grid .v {
font-family: 'Archivo', ui-sans-serif, sans-serif;
font-weight: 800;
font-size: 0.95rem;
color: var(--ink);
}
#model-info .spec-grid .v-small {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-weight: 500;
font-size: 0.7rem;
word-break: break-all;
}
#model-info .badges {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin: 2px 0 8px 0;
}
#model-info .badge {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1.5px solid var(--ink);
background: var(--accent-3);
color: var(--badge-ink);
padding: 2px 6px;
}
#model-info .badge--alert {
background: var(--accent);
color: #fff;
}
#model-info .desc {
font-size: 0.8rem;
line-height: 1.45;
color: var(--ink-soft);
margin: 0;
}
/* ---------- Buttons ---------- */
#transcribe-btn {
background: var(--accent) !important;
border: 2px solid var(--ink) !important;
border-radius: 0 !important;
color: #fff !important;
font-family: 'Archivo', ui-sans-serif, sans-serif !important;
font-weight: 800 !important;
text-transform: uppercase !important;
letter-spacing: 0.07em !important;
box-shadow: 4px 4px 0 0 var(--shadow-ink) !important;
transition: transform .08s ease, box-shadow .08s ease !important;
}
#transcribe-btn:hover {
transform: translate(-2px, -2px);
box-shadow: 6px 6px 0 0 var(--shadow-ink) !important;
}
#transcribe-btn:active {
transform: translate(2px, 2px);
box-shadow: 1px 1px 0 0 var(--shadow-ink) !important;
}
#clear-btn {
border-radius: 0 !important;
background: transparent !important;
border: 2px solid var(--ink) !important;
color: var(--ink) !important;
font-family: 'Archivo', ui-sans-serif, sans-serif !important;
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: 0.07em !important;
}
#clear-btn:hover { background: var(--hover-wash) !important; }
#download-btn {
border-radius: 0 !important;
border: 2px solid var(--accent-2) !important;
background: transparent !important;
color: var(--accent-2) !important;
font-family: 'Archivo', ui-sans-serif, sans-serif !important;
font-weight: 700 !important;
text-transform: uppercase !important;
letter-spacing: 0.07em !important;
}
#download-btn:hover { background: var(--hover-wash) !important; }
/* ---------- Transcript ---------- */
#transcript-box textarea {
font-family: 'IBM Plex Mono', ui-monospace, Menlo, Consolas, monospace !important;
font-size: 0.85rem !important;
line-height: 1.5 !important;
color: var(--ink) !important;
border-radius: 0 !important;
}
/* ---------- Examples ---------- */
.examples-table, .examples-table tr, .examples-table td, .examples-table th {
border-radius: 0 !important;
}
#credits {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
text-align: center;
font-size: 0.7rem;
line-height: 1.6;
color: var(--ink-soft);
border-top: 1.5px solid var(--ink);
margin-top: 16px;
padding-top: 12px;
}
#credits a { color: var(--accent) !important; font-weight: 600; }
/* ---------- Responsive ---------- */
/* phones / narrow: stack naturally, drop the VU meter to save height */
@media (max-width: 900px) {
#hero { flex-direction: column; align-items: flex-start; gap: 12px; }
#hero .hero-right { display: none; }
#hero .vu { display: none; }
}
/* very short desktop windows: squeeze a touch more */
@media (min-width: 901px) and (max-height: 760px) {
#hero { padding: 10px 16px; margin: 10px 0 10px 0; }
#hero .vu { display: none; }
.panel { padding: 10px 12px 12px 12px !important; }
}
"""
THEME = gr.themes.Soft(
primary_hue=gr.themes.colors.pink,
secondary_hue=gr.themes.colors.emerald,
neutral_hue=gr.themes.colors.stone,
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace"],
)
with gr.Blocks(title="Local STT Studio · transcribe.cpp") as demo:
gr.HTML(
"""
Audio transcription powered by transcribe.cpp
Try it here.
Run it locally.
Test any model on this Space,
then drop the engine into your own app — it's built for on-device, offline transcription.
Engine transcribe.cpp
Runtime ggml
Families 12
License MIT
"""
)
with gr.Row():
with gr.Column(scale=5, elem_classes="panel", elem_id="console-panel"):
gr.Markdown("Input / Console", elem_classes="panel-eyebrow")
audio_input = gr.Audio(
sources=["upload", "microphone"],
type="filepath",
label="Audio Input",
elem_id="audio-input",
)
gr.Examples(
examples=[[SAMPLE_CLIP]],
inputs=[audio_input],
label="Or try the sample clip (JFK, 11s)",
)
model_dropdown = gr.Dropdown(
choices=list(MODELS.keys()),
value=DEFAULT_MODEL,
label="Model — 12 Families",
filterable=False,
elem_id="model-select",
)
model_info = gr.HTML(model_card_md(MODELS[DEFAULT_MODEL]), elem_id="model-info")
with gr.Row():
language_dropdown = gr.Dropdown(
choices=MODELS[DEFAULT_MODEL]["language_choices"] or [],
value=MODELS[DEFAULT_MODEL]["language"],
label="Language",
filterable=False,
visible=MODELS[DEFAULT_MODEL]["language_choices"] is not None,
elem_id="language-select",
)
timestamps_toggle = gr.Checkbox(value=True, label="Include timestamps", elem_id="timestamps-toggle")
with gr.Row():
format_dropdown = gr.Dropdown(
choices=FORMAT_CHOICES,
value=FORMAT_CHOICES[0],
label="Transcript Format",
filterable=False,
visible=MODELS[DEFAULT_MODEL].get("supports_timestamps", True),
elem_id="format-select",
)
with gr.Row():
clear_btn = gr.ClearButton(value="Clear", size="lg", elem_id="clear-btn")
transcribe_btn = gr.Button("Transcribe", variant="primary", size="lg", elem_id="transcribe-btn")
with gr.Column(scale=6, elem_classes="panel", elem_id="output-panel"):
gr.Markdown("Transcript / Out", elem_classes="panel-eyebrow")
transcript_box = gr.Textbox(
label="Transcript",
lines=12,
max_lines=12,
buttons=["copy"],
interactive=False,
elem_id="transcript-box",
placeholder="Your transcript will appear here…",
)
download_btn = gr.DownloadButton("Download .txt", visible=False, elem_id="download-btn")
gr.Markdown(
"12 model families via [transcribe.cpp](https://github.com/handy-computer/transcribe.cpp) — "
"Parakeet · Nemotron · Whisper · Canary · Canary-Qwen · Cohere · Qwen3-ASR · SenseVoice · "
"FunASR · Granite · Moonshine. "
"GGUF ports verified & hosted by [handy-computer](https://huggingface.co/handy-computer) on the Hub.",
elem_id="credits",
)
model_dropdown.change(
on_model_change,
inputs=model_dropdown,
outputs=[model_info, language_dropdown, timestamps_toggle, format_dropdown],
)
timestamps_toggle.change(
on_timestamps_toggle, inputs=timestamps_toggle, outputs=format_dropdown
)
transcribe_btn.click(
do_transcribe,
inputs=[audio_input, model_dropdown, language_dropdown, timestamps_toggle, format_dropdown],
outputs=[transcript_box, download_btn],
)
clear_btn.click(reset_all, outputs=[audio_input, transcript_box, download_btn])
if __name__ == "__main__":
demo.queue().launch(theme=THEME, css=CUSTOM_CSS)