#!/usr/bin/env python3 """Streaming-ish web server for the from-scratch Zipformer-M CTC (Hindi/Hinglish). Same browser/websocket protocol as the old NeMo server (mic -> 16k PCM int16 over /ws; server returns {"type":"partial"/"final","text":...}; supports {cmd:reset|flush}). Server accumulates audio and re-decodes with lhotse-Fbank + causal encoder + CTC greedy (the same forward as eval_wer.py, so output matches reported WER). ▁ -> space. Env: EXP_DIR, LANG_DIR, EPOCH, AVG (checkpoint averaging), PORT. """ import os, json, argparse, asyncio, math import numpy as np import torch import sys sys.path.insert(0, "/root/icefall/egs/hindi/ASR/zipformer"); sys.path.insert(0, "/root/icefall") from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse from train import add_model_arguments, get_model, get_params from icefall.lexicon import Lexicon from icefall.checkpoint import average_checkpoints, load_checkpoint from icefall.decode import ctc_greedy_search from icefall.utils import make_pad_mask from lhotse import Fbank, FbankConfig EXP_DIR = os.environ.get("EXP_DIR", "/workspace/hindi_ft/asr_ctc/exp_p1") LANG_DIR = os.environ.get("LANG_DIR", "/workspace/hindi_ft/asr_ctc/data/lang_char") EPOCH = int(os.environ.get("EPOCH", "30")) AVG = int(os.environ.get("AVG", "5")) PORT = int(os.environ.get("PORT", "8080")) SR = 16000 LOG_EPS = math.log(1e-10) WB = "▁" DEVICE = torch.device("cuda", 0) if torch.cuda.is_available() else torch.device("cpu") print(f"[boot] loading zipformer CTC exp={EXP_DIR} epoch={EPOCH} avg={AVG}", flush=True) _ap = argparse.ArgumentParser(); add_model_arguments(_ap) params = get_params(); params.update(vars(_ap.parse_args([]))) params.causal = True; params.chunk_size = "16,32,64,-1"; params.left_context_frames = "64,128,256,-1" params.use_ctc = True; params.use_transducer = False lexicon = Lexicon(LANG_DIR) params.blank_id = lexicon.token_table[""]; params.vocab_size = max(lexicon.tokens) + 1 model = get_model(params) if AVG > 1: start = EPOCH - AVG + 1 fns = [f"{EXP_DIR}/epoch-{e}.pt" for e in range(start, EPOCH + 1) if os.path.exists(f"{EXP_DIR}/epoch-{e}.pt")] print(f"[boot] averaging {len(fns)} ckpts", flush=True) model.load_state_dict(average_checkpoints(fns, device=DEVICE), strict=False) else: load_checkpoint(f"{EXP_DIR}/epoch-{EPOCH}.pt", model) model.to(DEVICE).eval() fbank = Fbank(FbankConfig(num_mel_bins=80)) print(f"[boot] ready on {DEVICE}, vocab={params.vocab_size}", flush=True) @torch.no_grad() def transcribe(samples: np.ndarray) -> str: if samples.shape[0] < SR * 0.2: return "" feats = fbank.extract(torch.from_numpy(samples), SR) # (T,80) feat = torch.as_tensor(np.asarray(feats), dtype=torch.float32).unsqueeze(0).to(DEVICE) flens = torch.tensor([feat.shape[1]], device=DEVICE) + 30 feat = torch.nn.functional.pad(feat, (0, 0, 0, 30), value=LOG_EPS) x, xl = model.encoder_embed(feat, flens) mask = make_pad_mask(xl); x = x.permute(1, 0, 2) enc, el = model.encoder(x, xl, mask); enc = enc.permute(1, 0, 2) ctc = model.ctc_output(enc) ids = ctc_greedy_search(ctc, el)[0] return "".join(lexicon.token_table[i] for i in ids).replace(WB, " ").strip() app = FastAPI() class StreamState: def __init__(self): self.raw = np.zeros(0, dtype=np.float32) self.last = 0 self.transcript = "" def add(self, pcm): self.raw = np.concatenate([self.raw, pcm]) if self.raw.shape[0] > SR * 40: # cap 40s self.raw = self.raw[-SR * 40:] def process(self): self.transcript = transcribe(self.raw) return self.transcript @app.get("/health") async def health(): return {"status": "ok", "model": "zipformer-M-ctc-hindi", "epoch": EPOCH, "avg": AVG, "device": str(DEVICE)} @app.websocket("/ws") async def ws_endpoint(ws: WebSocket): await ws.accept() state = StreamState() step = SR // 2 # re-decode every ~0.5s of new audio try: while True: msg = await ws.receive() if "bytes" in msg and msg["bytes"] is not None: pcm = np.frombuffer(msg["bytes"], dtype=np.int16).astype(np.float32) / 32768.0 state.add(pcm) if state.raw.shape[0] - state.last >= step: state.last = state.raw.shape[0] text = await asyncio.to_thread(state.process) await ws.send_text(json.dumps({"type": "partial", "text": text})) elif "text" in msg and msg["text"] is not None: cmd = json.loads(msg["text"]) if cmd.get("cmd") == "reset": state = StreamState() await ws.send_text(json.dumps({"type": "reset"})) elif cmd.get("cmd") == "flush": text = await asyncio.to_thread(state.process) await ws.send_text(json.dumps({"type": "final", "text": text})) except WebSocketDisconnect: pass except Exception as e: try: await ws.send_text(json.dumps({"type": "error", "text": str(e)})) except Exception: pass @app.get("/") async def index(): return HTMLResponse(HTML_PAGE) HTML_PAGE = r""" Hindi ASR — Zipformer CTC

Streaming Speech-to-Text

Zipformer-M · char-CTC · from scratch (Hindi/Hinglish)
idle
Mic runs at your device rate, downsampled to 16 kHz and streamed as PCM. Speak naturally — text updates every chunk.
""" if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=PORT)