Phase-1: from-scratch Zipformer-M CTC streaming (Hindi/Hinglish) + full training scripts
e146811 verified | #!/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["<blk>"]; 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) | |
| 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 | |
| async def health(): | |
| return {"status": "ok", "model": "zipformer-M-ctc-hindi", "epoch": EPOCH, "avg": AVG, "device": str(DEVICE)} | |
| 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 | |
| async def index(): | |
| return HTMLResponse(HTML_PAGE) | |
| HTML_PAGE = r"""<!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"/> | |
| <title>Hindi ASR — Zipformer CTC</title> | |
| <style> | |
| :root { color-scheme: light dark; } | |
| * { box-sizing: border-box; } | |
| body { margin:0; font-family: -apple-system, system-ui, Segoe UI, Roboto, sans-serif; | |
| background:#0b0f14; color:#e6edf3; display:flex; min-height:100vh; } | |
| .wrap { margin:auto; width:min(820px, 92vw); padding:32px 0; } | |
| h1 { font-size:20px; font-weight:600; margin:0 0 4px; } | |
| .sub { color:#8b949e; font-size:13px; margin-bottom:24px; } | |
| .card { background:#121820; border:1px solid #222c38; border-radius:14px; padding:22px; } | |
| .controls { display:flex; gap:12px; align-items:center; margin-bottom:18px; } | |
| button { border:none; border-radius:10px; padding:11px 18px; font-size:14px; font-weight:600; | |
| cursor:pointer; transition:.15s; } | |
| #mic { background:#2ea043; color:#fff; } | |
| #mic.rec { background:#da3633; } | |
| #mic:disabled { opacity:.5; cursor:not-allowed; } | |
| #clear { background:#21262d; color:#e6edf3; } | |
| .dot { width:10px; height:10px; border-radius:50%; background:#3a3f45; display:inline-block; } | |
| .dot.on { background:#da3633; box-shadow:0 0 0 0 rgba(218,54,51,.7); animation:p 1.3s infinite; } | |
| @keyframes p { 0%{box-shadow:0 0 0 0 rgba(218,54,51,.6)} 70%{box-shadow:0 0 0 9px rgba(218,54,51,0)} } | |
| .status { font-size:12px; color:#8b949e; margin-left:auto; } | |
| #out { min-height:180px; font-size:20px; line-height:1.55; white-space:pre-wrap; | |
| padding:16px; background:#0b0f14; border-radius:10px; border:1px solid #222c38; } | |
| #out .cursor { color:#2ea043; animation:b 1s steps(1) infinite; } | |
| @keyframes b { 50%{opacity:0} } | |
| .foot { margin-top:14px; font-size:11px; color:#6e7681; } | |
| code { background:#21262d; padding:1px 6px; border-radius:5px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <h1>Streaming Speech-to-Text</h1> | |
| <div class="sub">Zipformer-M · char-CTC · from scratch (Hindi/Hinglish)</div> | |
| <div class="card"> | |
| <div class="controls"> | |
| <button id="mic">● Start talking</button> | |
| <button id="clear">Clear</button> | |
| <span class="dot" id="dot"></span> | |
| <span class="status" id="status">idle</span> | |
| </div> | |
| <div id="out"><span class="cursor">▍</span></div> | |
| <div class="foot">Mic runs at your device rate, downsampled to 16 kHz and streamed as PCM. | |
| Speak naturally — text updates every chunk.</div> | |
| </div> | |
| </div> | |
| <script> | |
| const micBtn = document.getElementById('mic'); | |
| const clearBtn = document.getElementById('clear'); | |
| const out = document.getElementById('out'); | |
| const dot = document.getElementById('dot'); | |
| const statusEl = document.getElementById('status'); | |
| let ws, audioCtx, source, processor, stream, recording = false; | |
| let text = ""; | |
| function render() { out.innerHTML = (text ? escapeHtml(text) + " " : "") + '<span class="cursor">▍</span>'; } | |
| function escapeHtml(s){ return s.replace(/[&<>]/g, c=>({'&':'&','<':'<','>':'>'}[c])); } | |
| function openWS() { | |
| return new Promise((resolve, reject) => { | |
| const proto = location.protocol === 'https:' ? 'wss' : 'ws'; | |
| ws = new WebSocket(`${proto}://${location.host}/ws`); | |
| ws.binaryType = 'arraybuffer'; | |
| ws.onopen = () => resolve(); | |
| ws.onerror = (e) => reject(e); | |
| ws.onmessage = (ev) => { | |
| const m = JSON.parse(ev.data); | |
| if (m.type === 'partial' || m.type === 'final') { text = m.text || text; render(); } | |
| else if (m.type === 'reset') { text = ""; render(); } | |
| else if (m.type === 'error') { statusEl.textContent = 'error: ' + m.text; } | |
| }; | |
| }); | |
| } | |
| function downsample(buffer, inRate, outRate) { | |
| if (outRate === inRate) return buffer; | |
| const ratio = inRate / outRate; | |
| const outLen = Math.floor(buffer.length / ratio); | |
| const result = new Float32Array(outLen); | |
| let pos = 0; | |
| for (let i = 0; i < outLen; i++) { | |
| const start = Math.floor(i * ratio), end = Math.floor((i + 1) * ratio); | |
| let sum = 0, n = 0; | |
| for (let j = start; j < end && j < buffer.length; j++) { sum += buffer[j]; n++; } | |
| result[i] = n ? sum / n : buffer[start] || 0; | |
| } | |
| return result; | |
| } | |
| async function start() { | |
| statusEl.textContent = 'connecting…'; | |
| await openWS(); | |
| stream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true } | |
| }); | |
| audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
| source = audioCtx.createMediaStreamSource(stream); | |
| processor = audioCtx.createScriptProcessor(4096, 1, 1); | |
| source.connect(processor); | |
| processor.connect(audioCtx.destination); | |
| const inRate = audioCtx.sampleRate; | |
| processor.onaudioprocess = (e) => { | |
| if (!recording || ws.readyState !== WebSocket.OPEN) return; | |
| const ds = downsample(e.inputBuffer.getChannelData(0), inRate, 16000); | |
| const pcm = new Int16Array(ds.length); | |
| for (let i = 0; i < ds.length; i++) { let s = Math.max(-1, Math.min(1, ds[i])); pcm[i] = s * 32767; } | |
| ws.send(pcm.buffer); | |
| }; | |
| recording = true; | |
| micBtn.classList.add('rec'); micBtn.textContent = '■ Stop'; | |
| dot.classList.add('on'); statusEl.textContent = 'listening…'; | |
| } | |
| function stop() { | |
| recording = false; | |
| if (processor) processor.disconnect(); | |
| if (source) source.disconnect(); | |
| if (stream) stream.getTracks().forEach(t => t.stop()); | |
| if (audioCtx) audioCtx.close(); | |
| if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({cmd:'flush'})); | |
| micBtn.classList.remove('rec'); micBtn.textContent = '● Start talking'; | |
| dot.classList.remove('on'); statusEl.textContent = 'stopped'; | |
| } | |
| micBtn.onclick = async () => { | |
| micBtn.disabled = true; | |
| try { if (!recording) await start(); else stop(); } | |
| catch (e) { statusEl.textContent = 'mic error: ' + e.message; } | |
| micBtn.disabled = false; | |
| }; | |
| clearBtn.onclick = () => { | |
| text = ""; render(); | |
| if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({cmd:'reset'})); | |
| }; | |
| render(); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=PORT) | |