"""Rani demo: transcribe Nepali speech. Ampixa Labs.""" import json import os import subprocess import tempfile import gradio as gr import numpy as np import soundfile as sf import torch # Preload at startup: the checkpoint is 485 MB — downloading it lazily made the first # user's request hang for minutes and look broken. from huggingface_hub import hf_hub_download from huggingface_hub.errors import GatedRepoError from nemo.collections.asr.models import EncDecHybridRNNTCTCBPEModel from omegaconf import OmegaConf, open_dict MODEL_REPO = "ampixa/rani-asr" MAX_SECONDS = 60 SAMPLE_RATE = 16000 # A mic recording that starts with this much exact digital silence lost its first words; one # that ends with this much was blanked by the browser before upload. LEAD_SILENCE_NOTE_S = 0.5 TRAIL_SILENCE_NOTE_S = 1.0 # Gradio 5.44's recorder (wavesurfer RecordPlugin) opens the microphone when Record is clicked # and starts MediaRecorder at once. A microphone delivers exact digital zeros while it starts # up (measured 1.8 s on Chromium; Bluetooth headsets are typically 1-2 s), so whatever the # visitor said first was recorded as blanks. Hold getUserMedia until the stream carries real # samples: the timer and Stop button only appear once recording starts, so they now mean # "live". Gradio also ignores its own input-device dropdown, so honour it here. Every failure # path falls back to the unpatched behaviour. MIC_HEAD = """ """ try: _path = hf_hub_download(MODEL_REPO, "rani/rani.nemo") except GatedRepoError as e: # The model is gated (manual review), so anonymous downloads get a 401. The Space # reads its token from the HF_TOKEN secret; without it startup dies in a long # traceback that never says what to fix. raise RuntimeError( f"{MODEL_REPO} is gated and this Space could not authenticate. Set an HF_TOKEN " f"secret with read access to {MODEL_REPO} in the Space settings." ) from e torch.set_num_threads(2) MODEL = EncDecHybridRNNTCTCBPEModel.restore_from(_path, map_location="cpu", strict=False) MODEL.eval() # The released checkpoint carries no dataset config; transcribe() needs this field. with open_dict(MODEL.cfg): MODEL.cfg.validation_ds = OmegaConf.create({"use_start_end_token": False}) MODEL.change_decoding_strategy(MODEL.cfg.decoding, decoder_type="rnnt") print("model preloaded", flush=True) DESCRIPTION = ( "Nepali speech-to-text from Ampixa Labs, previously called NepaliConformer. " "**33.5 mistakes per 100 words on [NepTel 0.2](https://huggingface.co/datasets/ampixa/neptel)**, " "real Nepali phone audio with human-corrected references. " "[Results](https://ampixa.com/rani/) · [Code](https://github.com/Ampixa/rani-asr) · " "[Weights, with approval](https://huggingface.co/ampixa/rani-asr). " "This is a CPU demo, so a 25 s clip takes several seconds." ) ARTICLE = ( "**Microphone:** press ⏺ Record, start speaking once the timer runs, press ⏹ Stop. It " f"transcribes automatically. Recordings longer than {MAX_SECONDS} s are cut to the first " f"{MAX_SECONDS} s. Mic blocked? " "[Open the demo full-screen](https://voidash-rani-asr.hf.space).\n\n" "**Examples:** phone-call clips are from the " "[NepTel](https://huggingface.co/datasets/ampixa/neptel) benchmark (audio © InfoBayAI, " "CC-BY-4.0). Read-speech clips are from Google FLEURS ne_np test (CC-BY-4.0). " "This demo runs Rani without a language model." ) def get_model(): return MODEL def _prepare(path): """Normalize any input to mono 16 kHz PCM capped at MAX_SECONDS. Returns (path, original_duration_seconds, temp_files). Browser microphone recordings arrive as stereo (and sometimes as webm/mp4 that libsndfile cannot open at all). The model takes a mono signal only: feeding it a two-channel file raises "Output shape expected = (batch, time)" inside NeMo. """ temps = [] try: info = sf.info(path) needs_convert = info.channels != 1 or info.samplerate != SAMPLE_RATE except RuntimeError as e: # soundfile.LibsndfileError: container libsndfile can't read print(f"soundfile cannot open {path!r} ({e}); converting with ffmpeg", flush=True) needs_convert = True if needs_convert: out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name temps.append(out) proc = subprocess.run( ["ffmpeg", "-y", "-loglevel", "error", "-i", path, "-ar", str(SAMPLE_RATE), "-ac", "1", "-c:a", "pcm_s16le", out], capture_output=True, text=True, ) if proc.returncode != 0: raise gr.Error( "Could not read that audio (ffmpeg: %s). Try a WAV or MP3 file." % (proc.stderr.strip().splitlines() or ["unknown error"])[-1] ) path = out duration = sf.info(path).duration if duration > MAX_SECONDS: # Cut instead of refusing: a visitor who talked for 70 s should still get a # transcript, not an error after the fact. data, sr = sf.read(path, frames=MAX_SECONDS * SAMPLE_RATE, dtype="int16") out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name temps.append(out) sf.write(out, data, sr, subtype="PCM_16") path = out return path, duration, temps def _digital_silence(path): """Return (leading_zero_s, trailing_zero_s, nonzero_fraction) of a mono 16 kHz PCM file. Exact zeros never come from a live microphone (even a quiet room has noise). Leading zeros mean the browser recorded before the device delivered audio; trailing zeros mean the browser blanked the audio before upload (Brave's fingerprinting protection did this to Gradio's WAV encoder); all zeros mean a dead input. """ data, sr = sf.read(path, dtype="int16") if len(data) == 0: return 0.0, 0.0, 0.0 nonzero = np.flatnonzero(data) if nonzero.size == 0: return len(data) / sr, len(data) / sr, 0.0 return nonzero[0] / sr, (len(data) - 1 - nonzero[-1]) / sr, nonzero.size / len(data) def transcribe(audio_path, from_mic=False): if not audio_path: # Most often: the user pressed Transcribe while the mic was still recording, # so no file exists yet. return "Press ⏹ stop to finish the recording — it transcribes automatically." audio_path, duration, temps = _prepare(audio_path) note = "" try: if from_mic: lead, trail, live_fraction = _digital_silence(audio_path) print(f"mic recording: {duration:.1f}s, leading digital silence {lead:.2f}s, " f"trailing {trail:.2f}s, non-zero {live_fraction:.0%}", flush=True) if live_fraction == 0.0: return ("The recording is completely silent (digital zeros): the browser captured " "no sound from the microphone. Check that the browser may use the " "microphone and that the right input is your system default.") if lead >= LEAD_SILENCE_NOTE_S: note += (f"\n\n(the first {lead:.1f} s of the recording were silent while the " "microphone started; anything said then was not captured)") if trail >= TRAIL_SILENCE_NOTE_S: note += (f"\n\n(the last {trail:.1f} s of the recording reached the server as " "digital silence: the browser blanked it before upload)") model = get_model() out = model.transcribe([audio_path], batch_size=1, verbose=False)[0] finally: for t in temps: try: os.remove(t) except OSError as e: print(f"could not remove temp file {t}: {e}", flush=True) text = out.text if hasattr(out, "text") else str(out) text = " ".join(t for t in text.split() if t != "") or "(no speech detected)" if duration > MAX_SECONDS: text += f"\n\n(only the first {MAX_SECONDS} s of {duration:.0f} s were transcribed)" return text + note def transcribe_mic(audio_path): return transcribe(audio_path, from_mic=True) def transcribe_live(audio_path): return transcribe(audio_path) def transcribe_example(audio_path): return transcribe(audio_path) def _load_samples(): manifest = os.path.join("examples", "samples.json") if not os.path.isfile(manifest): print(f"no {manifest}; starting without examples", flush=True) return [] with open(manifest, encoding="utf-8") as f: samples = json.load(f) for s in samples: path = os.path.join("examples", s["file"]) if not os.path.isfile(path): raise FileNotFoundError(f"{manifest} lists {s['file']} but {path} is missing") s["path"] = path return samples SAMPLES = _load_samples() with gr.Blocks(title="Rani: Nepali speech-to-text", head=MIC_HEAD) as demo: gr.Markdown("# Rani (रानी): Nepali speech-to-text") gr.Markdown(DESCRIPTION) with gr.Row(): with gr.Column(): audio_in = gr.Audio( sources=["microphone", "upload"], type="filepath", label=f"Nepali speech (mic or file, ≤{MAX_SECONDS} s)", ) with gr.Row(): clear_btn = gr.Button("Clear") submit_btn = gr.Button("Transcribe", variant="primary") with gr.Column(): text_out = gr.Textbox(label="Transcript (Devanagari)", lines=6) # Transcribe as soon as the recording stops or a file lands: waiting for an explicit # Transcribe click made the demo look broken for anyone who never pressed stop. audio_in.stop_recording(transcribe_mic, audio_in, text_out) audio_in.upload(transcribe_live, audio_in, text_out) submit_btn.click(transcribe, audio_in, text_out) clear_btn.click(lambda: (None, ""), None, [audio_in, text_out]) if SAMPLES: gr.Examples( examples=[[s["path"]] for s in SAMPLES], example_labels=[s["label"] for s in SAMPLES], inputs=[audio_in], outputs=text_out, fn=transcribe_example, cache_examples=False, run_on_click=True, label="Examples — click one to transcribe it", ) gr.Markdown(ARTICLE) if __name__ == "__main__": demo.launch(ssr_mode=False, show_error=True)