| import gradio as gr |
| import numpy as np |
| import struct |
| import time |
| import os |
| import json |
| import threading |
| from typing import Union |
| from faster_whisper import WhisperModel |
|
|
| print("Loading whisper-small...") |
| model = WhisperModel("small", device="cpu", compute_type="int8") |
| print("β
Ready") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| MAX_CONCURRENT_JOBS = 3 |
|
|
| _active_jobs_lock = threading.Lock() |
| _active_jobs = 0 |
| _total_jobs_served = 0 |
|
|
|
|
| def _job_started(): |
| global _active_jobs, _total_jobs_served |
| with _active_jobs_lock: |
| _active_jobs += 1 |
| _total_jobs_served += 1 |
|
|
|
|
| def _job_finished(): |
| global _active_jobs |
| with _active_jobs_lock: |
| _active_jobs = max(0, _active_jobs - 1) |
|
|
|
|
| def _get_capacity_snapshot() -> dict: |
| with _active_jobs_lock: |
| active = _active_jobs |
| total_served = _total_jobs_served |
| return { |
| "max_concurrent_jobs": MAX_CONCURRENT_JOBS, |
| "active_jobs": active, |
| "available_slots": max(0, MAX_CONCURRENT_JOBS - active), |
| "total_jobs_served_since_startup": total_served, |
| } |
|
|
|
|
| LANGUAGES = { |
| "Auto Detect": None, |
| "Arabic (ar)": "ar", "Hindi (hi)": "hi", "English (en)": "en", |
| "French (fr)": "fr", "German (de)": "de", "Spanish (es)": "es", |
| "Chinese (zh)": "zh", "Japanese (ja)": "ja", "Russian (ru)": "ru", |
| "Urdu (ur)": "ur", "Turkish (tr)": "tr", "Korean (ko)": "ko", |
| "Italian (it)": "it", "Portuguese (pt)": "pt", |
| } |
| VALID_CODES = {v for v in LANGUAGES.values() if v is not None} |
|
|
| |
| |
| |
| |
| |
| MAX_AUDIO_SECONDS = 3 * 60 * 60 |
| MAX_FILE_BYTES = 900 * 1024 * 1024 |
|
|
|
|
| def fmt_time(s: float) -> str: |
| h = int(s) // 3600 |
| m = (int(s) % 3600) // 60 |
| sec = s - h * 3600 - m * 60 |
| if h > 0: |
| return f"{h:02d}:{m:02d}:{sec:05.2f}" |
| return f"{m:02d}:{sec:05.2f}" |
|
|
|
|
| def resolve_language(language_code: str): |
| """Accept '', 'auto', None, a raw ISO code ('en'), or a UI label ('English (en)').""" |
| if not language_code or language_code.lower() == "auto": |
| return None |
| if language_code in VALID_CODES: |
| return language_code |
| if language_code in LANGUAGES: |
| return LANGUAGES[language_code] |
| raise ValueError(f"Unknown language code: {language_code!r}") |
|
|
|
|
| def parse_pcm_header(path_or_filelike, file_size: int): |
| """ |
| Reads only the 8-byte header and validates against actual file size. |
| Self-correcting: handles endian flips and fully-corrupt headers by |
| falling back to deriving n_samples from the real body size. |
| |
| Returns (n_samples, audio_duration). |
| """ |
| if file_size < 8: |
| raise ValueError("File too small to contain a valid pcm.bin header") |
|
|
| if hasattr(path_or_filelike, "read"): |
| header = path_or_filelike.read(8) |
| else: |
| with open(path_or_filelike, "rb") as f: |
| header = f.read(8) |
|
|
| n_samples = struct.unpack_from("<i", header, 0)[0] |
| audio_duration = struct.unpack_from("<f", header, 4)[0] |
|
|
| expected_body = n_samples * 4 |
| actual_body = file_size - 8 |
|
|
| if actual_body != expected_body: |
| |
| n_samples_be = struct.unpack_from(">i", header, 0)[0] |
| if n_samples_be * 4 == actual_body: |
| n_samples = n_samples_be |
| audio_duration = struct.unpack_from(">f", header, 4)[0] |
| print(f"β οΈ Header was big-endian β corrected. n_samples={n_samples}") |
| else: |
| |
| if actual_body % 4 != 0: |
| raise ValueError( |
| f"Body size {actual_body} bytes is not a multiple of 4 " |
| f"(not valid float32 PCM data)" |
| ) |
| n_samples = actual_body // 4 |
| audio_duration = n_samples / 16000.0 |
| print(f"β οΈ Header unreliable β derived from file size instead. " |
| f"n_samples={n_samples}, duration={audio_duration:.1f}s") |
|
|
| if audio_duration > MAX_AUDIO_SECONDS: |
| raise ValueError( |
| f"Audio too long: {audio_duration/60:.1f} min " |
| f"(limit {MAX_AUDIO_SECONDS/60:.0f} min)" |
| ) |
|
|
| return n_samples, audio_duration |
|
|
|
|
| def transcribe_pcm_stream(pcm, audio_duration: float, language): |
| """ |
| Generator version of the transcription core. Yields a series of |
| progress dicts like: |
| {"status": "progress", "fraction": 0.42, "desc": "Transcribed 00:08.10 / 00:19.30"} |
| and finally yields ONE result dict (the same shape transcribe_pcm used |
| to return directly): |
| {"status": "done", "language": ..., "words": [...], ...} |
| |
| Used by BOTH gradio_decode (consumes the generator directly instead of |
| a progress_cb) and api_transcribe (yields straight through β gr.api() |
| auto-streams generator yields as SSE events, which is how Android's |
| poll loop can show a real percentage instead of a guessed one). |
| |
| pcm may be a real np.ndarray OR a np.memmap β both work transparently |
| with faster_whisper/ctranslate2. |
| |
| Includes automatic VAD fallback: if VAD filters out all speech |
| (a common cause of empty results on quiet/compressed/phone audio), |
| automatically retries once with VAD disabled. |
| """ |
| t0 = time.time() |
|
|
| pcm_max = float(np.max(np.abs(pcm))) if pcm.size > 0 else 0.0 |
| pcm_rms = float(np.sqrt(np.mean(np.asarray(pcm, dtype=np.float64) ** 2))) if pcm.size > 0 else 0.0 |
|
|
| def _run(vad_filter: bool, vad_params): |
| segments_iter, info = model.transcribe( |
| audio=pcm, |
| language=language, |
| beam_size=1, |
| word_timestamps=True, |
| vad_filter=vad_filter, |
| vad_parameters=vad_params, |
| condition_on_previous_text=False, |
| ) |
| segments, words = [], [] |
| seg_count = 0 |
| for seg in segments_iter: |
| seg_count += 1 |
| segments.append({ |
| "start": round(float(seg.start), 3), |
| "end": round(float(seg.end), 3), |
| "text": seg.text.strip(), |
| }) |
| if seg.words: |
| for w in seg.words: |
| words.append({ |
| "start": round(float(w.start), 3), |
| "end": round(float(w.end), 3), |
| "word": w.word.strip(), |
| }) |
| if audio_duration > 0: |
| frac = min(0.95, 0.1 + 0.85 * (seg.end / audio_duration)) |
| yield { |
| "status": "progress", |
| "fraction": round(frac, 4), |
| "desc": f"Transcribed {fmt_time(seg.end)} / {fmt_time(audio_duration)}", |
| } |
| return_value["segments"] = segments |
| return_value["words"] = words |
| return_value["info"] = info |
| return_value["seg_count"] = seg_count |
|
|
| yield {"status": "progress", "fraction": 0.1, "desc": "Transcribing..."} |
|
|
| return_value = {} |
| yield from _run(vad_filter=True, vad_params=dict(min_silence_duration_ms=500)) |
| segments, words = return_value["segments"], return_value["words"] |
| info, seg_count = return_value["info"], return_value["seg_count"] |
|
|
| vad_fallback_used = False |
| if not segments: |
| vad_fallback_used = True |
| yield {"status": "progress", "fraction": 0.5, |
| "desc": "No speech detected with VAD β retrying without VAD..."} |
| return_value = {} |
| yield from _run(vad_filter=False, vad_params=None) |
| segments, words = return_value["segments"], return_value["words"] |
| info, seg_count = return_value["info"], return_value["seg_count"] |
|
|
| elapsed_s = time.time() - t0 |
| rtf = elapsed_s / max(audio_duration, 0.01) |
|
|
| yield {"status": "progress", "fraction": 1.0, "desc": "Done"} |
|
|
| note = None |
| if not words and pcm_max < 0.01: |
| note = "Audio may be silent or near-silent (peak amplitude is very low)." |
| elif vad_fallback_used and words: |
| note = "VAD initially filtered everything out; retried with VAD disabled." |
|
|
| yield { |
| "status": "done", |
| "language": info.language, |
| "language_prob": round(info.language_probability, 3), |
| "duration_seconds": round(audio_duration, 2), |
| "decode_seconds": round(elapsed_s, 2), |
| "real_time_factor": round(rtf, 3), |
| "segment_count": seg_count, |
| "segments": segments, |
| "words": words, |
| "diagnostics": { |
| "pcm_peak_amplitude": round(pcm_max, 4), |
| "pcm_rms_amplitude": round(pcm_rms, 4), |
| "vad_fallback_used": vad_fallback_used, |
| "note": note, |
| }, |
| } |
|
|
|
|
| |
| |
| |
|
|
| def gradio_decode(pcm_file, language_label, progress=gr.Progress()): |
| if pcm_file is None: |
| return "β Please upload pcm.bin" |
|
|
| _job_started() |
| try: |
| file_size = os.path.getsize(pcm_file) |
| if file_size > MAX_FILE_BYTES: |
| return (f"β File too large: {file_size/1e6:.0f} MB " |
| f"(limit {MAX_FILE_BYTES/1e6:.0f} MB)") |
|
|
| progress(0, desc="Reading header...") |
| n_samples, audio_duration = parse_pcm_header(pcm_file, file_size) |
|
|
| |
| |
| progress(0.05, desc=f"Loading {audio_duration:.0f}s of audio...") |
| pcm = np.memmap(pcm_file, dtype=np.float32, mode="r", |
| offset=8, shape=(n_samples,)) |
|
|
| language = LANGUAGES.get(language_label, None) |
|
|
| result = None |
| for event in transcribe_pcm_stream(pcm, audio_duration, language): |
| if event["status"] == "progress": |
| progress(event["fraction"], desc=event["desc"]) |
| elif event["status"] == "done": |
| result = event |
|
|
| |
| MAX_DISPLAY_WORDS = 20000 |
| words = result["words"] |
| truncated = len(words) > MAX_DISPLAY_WORDS |
| display_words = words[:MAX_DISPLAY_WORDS] |
|
|
| lines = "\n".join( |
| f"{fmt_time(w['start'])} β {fmt_time(w['end'])} {w['word']}" |
| for w in display_words |
| ) |
| if truncated: |
| lines += f"\n\nβ¦ truncated, {len(words) - MAX_DISPLAY_WORDS} more words not shown β¦" |
|
|
| diag = result["diagnostics"] |
| warning = "" |
| if not words: |
| warning = ( |
| f"\nβ οΈ No words detected.\n" |
| f" peak amplitude = {diag['pcm_peak_amplitude']} (near 0 = silent/empty audio)\n" |
| f" rms amplitude = {diag['pcm_rms_amplitude']}\n" |
| f" vad fallback used = {diag['vad_fallback_used']}\n" |
| f" {diag['note'] or ''}\n" |
| ) |
|
|
| return ( |
| f"π {result['language']} ({result['language_prob']:.0%}) " |
| f"β± {result['duration_seconds']/60:.1f} min " |
| f"π¦ {file_size/1e6:.0f} MB " |
| f"π§© {result['segment_count']} segments " |
| f"π {result['decode_seconds']:.1f}s (RTF={result['real_time_factor']:.2f}x)\n" |
| f"{warning}\n" |
| f"{lines}" |
| ) |
|
|
| except MemoryError: |
| return "β Out of memory β file too large for this Space's RAM. Try a smaller/shorter file." |
| except ValueError as e: |
| return f"β {e}" |
| except Exception as e: |
| return f"β Error: {e}" |
| finally: |
| _job_finished() |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| def api_health() -> dict: |
| """Health check. Returns service status.""" |
| return {"status": "ok", "model": "whisper-small", "device": "cpu", "compute_type": "int8"} |
|
|
|
|
| def _resolve_file_path(pcm_file: Union[str, dict]) -> str: |
| """ |
| gr.api() functions with a plain `str` type hint (no bound gr.File() |
| component) do NOT get Gradio's automatic FileDataβpath preprocessing. |
| When called via gradio_client's handle_file(), the client sends a |
| FileData-shaped dict instead of a plain path string: |
| {"path": "...", "url": ..., "orig_name": ..., "meta": {...}} |
| This unwraps that dict if present; passes through a plain string |
| unchanged (e.g. when called directly from Python or the Gradio UI). |
| """ |
| if isinstance(pcm_file, dict): |
| path = pcm_file.get("path") |
| if not path: |
| raise ValueError(f"File dict missing 'path' key: {pcm_file!r}") |
| return path |
| return pcm_file |
|
|
|
|
| def api_transcribe(pcm_file: Union[str, dict], language: str = "") -> dict: |
| """ |
| Upload pcm.bin + language code, get back JSON with segments + word timestamps. |
| |
| THIS IS A GENERATOR FUNCTION. gr.api() automatically streams generator |
| yields as Server-Sent Events β each yield becomes one SSE `data:` event |
| the client (Android, gradio_client, curl, etc.) can read while polling. |
| This is what lets Android show a REAL transcription percentage instead |
| of guessing one from elapsed poll attempts. |
| |
| Event shapes yielded, in order: |
| {"status": "progress", "fraction": 0.1, "desc": "Transcribing..."} |
| {"status": "progress", "fraction": 0.42, "desc": "Transcribed 00:08.1 / 00:19.3"} |
| ... (one progress event per completed Whisper segment) ... |
| {"status": "progress", "fraction": 1.0, "desc": "Done"} |
| {"status": "done", "language": "en", "words": [...], ...} β FINAL event |
| |
| On error, the FIRST and ONLY event yielded is: |
| {"status": "error", "error": "<message>"} |
| |
| Python client example (reading every streamed event): |
| from gradio_client import Client, handle_file |
| client = Client("don0726/Whis") |
| job = client.submit( |
| pcm_file=handle_file("pcm.bin"), |
| language="en", |
| api_name="/api_transcribe", |
| ) |
| for event in job: |
| print(event) # each intermediate + the final result |
| |
| Args: |
| pcm_file: path to an uploaded pcm.bin file (Gradio provides this |
| automatically β pass handle_file("local/path.bin") from |
| the client). |
| language: ISO code like 'en', or '' / 'auto' for auto-detect. |
| """ |
| snapshot = _get_capacity_snapshot() |
| if snapshot["available_slots"] <= 0: |
| |
| |
| |
| |
| |
| yield { |
| "status": "error", |
| "error": ( |
| f"Server busy: all {snapshot['max_concurrent_jobs']} " |
| f"transcription slots are in use. Please try again shortly." |
| ), |
| } |
| return |
|
|
| _job_started() |
| try: |
| resolved_path = _resolve_file_path(pcm_file) |
| file_size = os.path.getsize(resolved_path) |
| if file_size > MAX_FILE_BYTES: |
| yield {"status": "error", |
| "error": f"File too large: {file_size/1e6:.0f} MB (limit {MAX_FILE_BYTES/1e6:.0f} MB)"} |
| return |
|
|
| try: |
| lang = resolve_language(language) |
| except ValueError as e: |
| yield {"status": "error", "error": str(e)} |
| return |
|
|
| try: |
| n_samples, audio_duration = parse_pcm_header(resolved_path, file_size) |
| except ValueError as e: |
| yield {"status": "error", "error": f"Invalid pcm.bin: {e}"} |
| return |
|
|
| |
| pcm = np.memmap(resolved_path, dtype=np.float32, mode="r", |
| offset=8, shape=(n_samples,)) |
|
|
| yield from transcribe_pcm_stream(pcm, audio_duration, lang) |
|
|
| except Exception as e: |
| yield {"status": "error", "error": f"Internal error: {e}"} |
| finally: |
| _job_finished() |
|
|
|
|
| def api_status() -> dict: |
| """ |
| Check server capacity β how many transcription "channels"/slots are |
| available right now. Useful for clients to decide whether to submit a |
| job immediately or show a "server busy, try again" message. |
| |
| Returns: |
| { |
| "max_concurrent_jobs": 3, |
| "active_jobs": 1, |
| "available_slots": 2, |
| "total_jobs_served_since_startup": 47 |
| } |
| """ |
| return _get_capacity_snapshot() |
|
|
|
|
| with gr.Blocks(title="Whisper Word Timestamps") as demo: |
| gr.Markdown(f""" |
| # ποΈ Word-Level Timestamps |
| Upload `pcm.bin` β get word + start/end time. |
| Supports large files (memory-mapped, streamed progress). |
| Handles up to **{MAX_CONCURRENT_JOBS} simultaneous transcriptions**. |
| |
| **API users:** call via `gradio_client.Client("don0726/Whis")`, then |
| `job = client.submit(pcm_file=handle_file("pcm.bin"), language="en", |
| api_name="/api_transcribe")` and iterate `for event in job:` to see |
| live progress + the final result. Check `/api_status` first to see |
| current server capacity. See the "View API" link in this page's |
| footer for full request/response details. |
| """) |
| with gr.Row(): |
| with gr.Column(): |
| pcm_input = gr.File(label="π pcm.bin", file_types=[".bin"]) |
| lang_input = gr.Dropdown(label="π Language", choices=list(LANGUAGES.keys()), value="Auto Detect") |
| btn = gr.Button("π Transcribe", variant="primary") |
| with gr.Column(): |
| out = gr.Textbox(label="Word timestamps", lines=30) |
|
|
| btn.click(fn=gradio_decode, inputs=[pcm_input, lang_input], outputs=out, |
| concurrency_limit=MAX_CONCURRENT_JOBS) |
|
|
| |
| |
| |
| |
| gr.api(api_health, api_name="api_health") |
| gr.api(api_status, api_name="api_status") |
|
|
| |
| |
| |
| |
| |
| |
| gr.api(api_transcribe, api_name="api_transcribe", concurrency_limit=MAX_CONCURRENT_JOBS) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|