"""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)