NightPrince commited on
Commit
3862eca
·
verified ·
1 Parent(s): 226fffa

Add reference server

Browse files
Files changed (1) hide show
  1. server.py +234 -0
server.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NeMo STT Server - Arabic FastConformer
3
+ LiveKit-compatible HTTP API for speech-to-text.
4
+ Model: nvidia/stt_ar_fastconformer_hybrid_large_pcd_v1.0
5
+ Input: 16kHz mono PCM or WAV
6
+ """
7
+ import logging
8
+ import os
9
+ import tempfile
10
+
11
+ import uvicorn
12
+ from fastapi import FastAPI, HTTPException, Request
13
+ from fastapi.responses import JSONResponse
14
+
15
+ MODEL_NAME = "nvidia/stt_ar_fastconformer_hybrid_large_pcd_v1.0"
16
+ _MODEL_FILENAME = "stt_ar_fastconformer_hybrid_large_pcd_v1.0.nemo"
17
+ # Prefer env; then local nemo_stt/models/ (no HF download); else Docker /app/
18
+ _server_dir = os.path.dirname(os.path.abspath(__file__))
19
+ _local_model = os.path.join(_server_dir, "models", _MODEL_FILENAME)
20
+ MODEL_PATH = os.getenv("NEMO_MODEL_PATH") or (
21
+ _local_model if os.path.isfile(_local_model) else f"/app/{_MODEL_FILENAME}"
22
+ )
23
+ SAMPLE_RATE = 16000
24
+ CATT_CKPT = os.getenv("CATT_CKPT") or os.path.join(_server_dir, "models", "catt", "best_ed_mlm_ns_epoch_178.pt")
25
+
26
+ logging.basicConfig(level=logging.INFO)
27
+ logger = logging.getLogger(__name__)
28
+
29
+ app = FastAPI(title="NeMo STT Server", version="0.1.0")
30
+ asr_model = None
31
+ diacritizer = None
32
+
33
+
34
+ def load_model():
35
+ global asr_model
36
+ if asr_model is not None:
37
+ return
38
+ try:
39
+ import nemo.collections.asr as nemo_asr
40
+ if os.path.isfile(MODEL_PATH):
41
+ logger.info("Loading model from %s", MODEL_PATH)
42
+ asr_model = nemo_asr.models.EncDecHybridRNNTCTCBPEModel.restore_from(MODEL_PATH)
43
+ else:
44
+ logger.info("Model file not found, loading from_pretrained %s", MODEL_NAME)
45
+ asr_model = nemo_asr.models.EncDecHybridRNNTCTCBPEModel.from_pretrained(model_name=MODEL_NAME)
46
+ asr_model.eval()
47
+ # Disable CUDA graphs — two separate flags both need to be off.
48
+ # use_cuda_graphs controls the greedy path; use_cuda_graph_decoder
49
+ # controls the loop_labels path. Both hit the same broken cu_call()
50
+ # that returns 5 values instead of 6 on this CUDA/PyTorch combo.
51
+ try:
52
+ from omegaconf import open_dict
53
+ with open_dict(asr_model.cfg):
54
+ asr_model.cfg.decoding.greedy.use_cuda_graphs = False
55
+ asr_model.cfg.decoding.greedy.use_cuda_graph_decoder = False
56
+ asr_model.change_decoding_strategy(asr_model.cfg.decoding)
57
+ logger.info("CUDA graphs disabled for RNNT decoding")
58
+ except Exception as _e:
59
+ logger.warning("Could not disable CUDA graphs: %s", _e)
60
+ logger.info("Model loaded successfully")
61
+ except Exception as e:
62
+ logger.exception("Failed to load model: %s", e)
63
+ raise
64
+
65
+
66
+ def load_diacritizer():
67
+ """Best-effort: forces transcripts to come out with tashkeel via vendored CATT.
68
+ Never raises — /transcribe falls back to plain (undiacritized) text if this fails,
69
+ exactly like Fasih-TTS's own diacritizer loading does."""
70
+ global diacritizer
71
+ if diacritizer is not None:
72
+ return
73
+ try:
74
+ from diacritize import Diacritizer
75
+
76
+ device = "cuda" if asr_model is not None and next(asr_model.parameters()).is_cuda else None
77
+ diacritizer = Diacritizer(ckpt=CATT_CKPT, device=device)
78
+ logger.info("CATT diacritizer loaded (device=%s)", diacritizer.device)
79
+ except Exception as e:
80
+ logger.warning("CATT diacritizer unavailable, transcripts will be plain text: %s", e)
81
+
82
+
83
+ def _diacritize(text: str) -> str:
84
+ """Best-effort: '' on empty input, failure, or an unavailable diacritizer — the
85
+ caller falls back to the plain transcript, /transcribe never breaks over this."""
86
+ if not text or diacritizer is None:
87
+ return ""
88
+ try:
89
+ return diacritizer.diacritize_texts([text])[0]
90
+ except Exception:
91
+ logger.warning("Diacritization failed for transcript, returning plain text", exc_info=True)
92
+ return ""
93
+
94
+
95
+ @app.on_event("startup")
96
+ async def startup():
97
+ load_model()
98
+ load_diacritizer()
99
+
100
+
101
+ @app.get("/health")
102
+ async def health():
103
+ """Health check for LiveKit / load balancers."""
104
+ return {
105
+ "status": "ok",
106
+ "model": "stt_ar_fastconformer_hybrid_large_pcd_v1.0",
107
+ "diacritizer": diacritizer is not None,
108
+ }
109
+
110
+
111
+ @app.post("/transcribe")
112
+ async def transcribe(request: Request):
113
+ """
114
+ Transcribe audio to text.
115
+ Accepts:
116
+ - Raw PCM: 16kHz, mono, 16-bit signed (Content-Type: application/octet-stream)
117
+ - WAV file: 16kHz mono (Content-Type: audio/wav or multipart/form-data)
118
+ Returns: {"text": "...", "is_final": true}
119
+ """
120
+ if asr_model is None:
121
+ load_model()
122
+
123
+ content_type = request.headers.get("content-type", "")
124
+ body = await request.body()
125
+
126
+ if not body or len(body) < 1000:
127
+ raise HTTPException(400, "Audio too short (min ~1s at 16kHz)")
128
+
129
+ wav_path = None
130
+ try:
131
+ if "wav" in content_type or body[:4] == b"RIFF":
132
+ wav_path = _to_16k_wav(body, ".wav")
133
+ elif "mp3" in content_type or body[:3] == b"ID3" or body[:2] == b"\xff\xfb":
134
+ wav_path = _to_16k_wav(body, ".mp3")
135
+ else:
136
+ wav_path = _pcm_to_wav_temp(body)
137
+
138
+ wav_size = os.path.getsize(wav_path) if wav_path and os.path.exists(wav_path) else 0
139
+ logger.info("WAV path=%s size=%d bytes", wav_path, wav_size)
140
+ output = asr_model.transcribe([str(wav_path)])
141
+ logger.info("Transcribe output type=%s len=%s first=%r", type(output).__name__, len(output) if output else 0, output[0] if output else None)
142
+ if not output:
143
+ text = ""
144
+ elif isinstance(output, tuple) and len(output) >= 1:
145
+ # (best_hypotheses, all_hypotheses) when extract_nbest
146
+ hyps = output[0]
147
+ first = hyps[0] if hyps else None
148
+ if hasattr(first, "text"):
149
+ text = first.text or ""
150
+ elif isinstance(first, str):
151
+ text = first
152
+ else:
153
+ text = str(first) if first else ""
154
+ elif hasattr(output[0], "text"):
155
+ text = output[0].text or ""
156
+ elif isinstance(output[0], str):
157
+ text = output[0]
158
+ else:
159
+ text = str(output[0]) if output[0] else ""
160
+ logger.info("Raw output type: %s, repr: %r", type(output[0]), output[0])
161
+
162
+ text = text.strip()
163
+ text_diacritized = _diacritize(text)
164
+ return JSONResponse({
165
+ "text": text_diacritized or text,
166
+ "text_plain": text,
167
+ "diacritized": bool(text_diacritized),
168
+ "is_final": True,
169
+ })
170
+ except Exception as e:
171
+ logger.exception("Transcription error: %s", e)
172
+ raise HTTPException(500, str(e))
173
+ finally:
174
+ if wav_path and os.path.exists(wav_path):
175
+ try:
176
+ os.unlink(wav_path)
177
+ except OSError:
178
+ pass
179
+
180
+
181
+ def _pcm_to_wav_temp(pcm_bytes: bytes) -> str:
182
+ """Convert raw PCM 16kHz mono 16-bit to WAV file."""
183
+ import wave
184
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
185
+ wav_path = f.name
186
+ with wave.open(wav_path, "wb") as wav:
187
+ wav.setnchannels(1)
188
+ wav.setsampwidth(2)
189
+ wav.setframerate(SAMPLE_RATE)
190
+ wav.writeframes(pcm_bytes)
191
+ return wav_path
192
+
193
+
194
+ def _bytes_to_wav_temp(data: bytes) -> str:
195
+ """Write bytes to temp WAV file (if already WAV) or try to parse."""
196
+ if data[:4] == b"RIFF":
197
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
198
+ f.write(data)
199
+ return f.name
200
+ return _pcm_to_wav_temp(data)
201
+
202
+
203
+ def _to_16k_wav(audio_bytes: bytes, suffix: str) -> str:
204
+ """Convert any audio to 16kHz mono WAV via ffmpeg."""
205
+ import ffmpeg
206
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
207
+ f.write(audio_bytes)
208
+ tmp_path = f.name
209
+ wav_path = tempfile.mktemp(suffix=".wav")
210
+ try:
211
+ stream = ffmpeg.input(tmp_path)
212
+ stream = ffmpeg.output(
213
+ stream, wav_path,
214
+ acodec="pcm_s16le", ac=1, ar=SAMPLE_RATE,
215
+ loglevel="error",
216
+ )
217
+ ffmpeg.run(stream, overwrite_output=True)
218
+ return wav_path
219
+ except ffmpeg.Error as e:
220
+ err = (e.stderr or b"").decode(errors="replace")
221
+ raise RuntimeError(f"FFmpeg conversion failed: {err}") from e
222
+ finally:
223
+ if os.path.exists(tmp_path):
224
+ try:
225
+ os.unlink(tmp_path)
226
+ except OSError:
227
+ pass
228
+
229
+
230
+ if __name__ == "__main__":
231
+ port = int(os.getenv("NEMO_STT_PORT", "3005"))
232
+ host = os.getenv("NEMO_STT_HOST", "0.0.0.0")
233
+ logger.info("Starting NeMo STT server on %s:%d", host, port)
234
+ uvicorn.run(app, host=host, port=port)