File size: 8,622 Bytes
3862eca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
"""
NeMo STT Server - Arabic FastConformer
LiveKit-compatible HTTP API for speech-to-text.
Model: nvidia/stt_ar_fastconformer_hybrid_large_pcd_v1.0
Input: 16kHz mono PCM or WAV
"""
import logging
import os
import tempfile

import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse

MODEL_NAME = "nvidia/stt_ar_fastconformer_hybrid_large_pcd_v1.0"
_MODEL_FILENAME = "stt_ar_fastconformer_hybrid_large_pcd_v1.0.nemo"
# Prefer env; then local nemo_stt/models/ (no HF download); else Docker /app/
_server_dir = os.path.dirname(os.path.abspath(__file__))
_local_model = os.path.join(_server_dir, "models", _MODEL_FILENAME)
MODEL_PATH = os.getenv("NEMO_MODEL_PATH") or (
    _local_model if os.path.isfile(_local_model) else f"/app/{_MODEL_FILENAME}"
)
SAMPLE_RATE = 16000
CATT_CKPT = os.getenv("CATT_CKPT") or os.path.join(_server_dir, "models", "catt", "best_ed_mlm_ns_epoch_178.pt")

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

app = FastAPI(title="NeMo STT Server", version="0.1.0")
asr_model = None
diacritizer = None


def load_model():
    global asr_model
    if asr_model is not None:
        return
    try:
        import nemo.collections.asr as nemo_asr
        if os.path.isfile(MODEL_PATH):
            logger.info("Loading model from %s", MODEL_PATH)
            asr_model = nemo_asr.models.EncDecHybridRNNTCTCBPEModel.restore_from(MODEL_PATH)
        else:
            logger.info("Model file not found, loading from_pretrained %s", MODEL_NAME)
            asr_model = nemo_asr.models.EncDecHybridRNNTCTCBPEModel.from_pretrained(model_name=MODEL_NAME)
        asr_model.eval()
        # Disable CUDA graphs — two separate flags both need to be off.
        # use_cuda_graphs controls the greedy path; use_cuda_graph_decoder
        # controls the loop_labels path. Both hit the same broken cu_call()
        # that returns 5 values instead of 6 on this CUDA/PyTorch combo.
        try:
            from omegaconf import open_dict
            with open_dict(asr_model.cfg):
                asr_model.cfg.decoding.greedy.use_cuda_graphs = False
                asr_model.cfg.decoding.greedy.use_cuda_graph_decoder = False
            asr_model.change_decoding_strategy(asr_model.cfg.decoding)
            logger.info("CUDA graphs disabled for RNNT decoding")
        except Exception as _e:
            logger.warning("Could not disable CUDA graphs: %s", _e)
        logger.info("Model loaded successfully")
    except Exception as e:
        logger.exception("Failed to load model: %s", e)
        raise


def load_diacritizer():
    """Best-effort: forces transcripts to come out with tashkeel via vendored CATT.
    Never raises — /transcribe falls back to plain (undiacritized) text if this fails,
    exactly like Fasih-TTS's own diacritizer loading does."""
    global diacritizer
    if diacritizer is not None:
        return
    try:
        from diacritize import Diacritizer

        device = "cuda" if asr_model is not None and next(asr_model.parameters()).is_cuda else None
        diacritizer = Diacritizer(ckpt=CATT_CKPT, device=device)
        logger.info("CATT diacritizer loaded (device=%s)", diacritizer.device)
    except Exception as e:
        logger.warning("CATT diacritizer unavailable, transcripts will be plain text: %s", e)


def _diacritize(text: str) -> str:
    """Best-effort: '' on empty input, failure, or an unavailable diacritizer — the
    caller falls back to the plain transcript, /transcribe never breaks over this."""
    if not text or diacritizer is None:
        return ""
    try:
        return diacritizer.diacritize_texts([text])[0]
    except Exception:
        logger.warning("Diacritization failed for transcript, returning plain text", exc_info=True)
        return ""


@app.on_event("startup")
async def startup():
    load_model()
    load_diacritizer()


@app.get("/health")
async def health():
    """Health check for LiveKit / load balancers."""
    return {
        "status": "ok",
        "model": "stt_ar_fastconformer_hybrid_large_pcd_v1.0",
        "diacritizer": diacritizer is not None,
    }


@app.post("/transcribe")
async def transcribe(request: Request):
    """
    Transcribe audio to text.
    Accepts:
    - Raw PCM: 16kHz, mono, 16-bit signed (Content-Type: application/octet-stream)
    - WAV file: 16kHz mono (Content-Type: audio/wav or multipart/form-data)
    Returns: {"text": "...", "is_final": true}
    """
    if asr_model is None:
        load_model()

    content_type = request.headers.get("content-type", "")
    body = await request.body()

    if not body or len(body) < 1000:
        raise HTTPException(400, "Audio too short (min ~1s at 16kHz)")

    wav_path = None
    try:
        if "wav" in content_type or body[:4] == b"RIFF":
            wav_path = _to_16k_wav(body, ".wav")
        elif "mp3" in content_type or body[:3] == b"ID3" or body[:2] == b"\xff\xfb":
            wav_path = _to_16k_wav(body, ".mp3")
        else:
            wav_path = _pcm_to_wav_temp(body)

        wav_size = os.path.getsize(wav_path) if wav_path and os.path.exists(wav_path) else 0
        logger.info("WAV path=%s size=%d bytes", wav_path, wav_size)
        output = asr_model.transcribe([str(wav_path)])
        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)
        if not output:
            text = ""
        elif isinstance(output, tuple) and len(output) >= 1:
            # (best_hypotheses, all_hypotheses) when extract_nbest
            hyps = output[0]
            first = hyps[0] if hyps else None
            if hasattr(first, "text"):
                text = first.text or ""
            elif isinstance(first, str):
                text = first
            else:
                text = str(first) if first else ""
        elif hasattr(output[0], "text"):
            text = output[0].text or ""
        elif isinstance(output[0], str):
            text = output[0]
        else:
            text = str(output[0]) if output[0] else ""
            logger.info("Raw output type: %s, repr: %r", type(output[0]), output[0])

        text = text.strip()
        text_diacritized = _diacritize(text)
        return JSONResponse({
            "text": text_diacritized or text,
            "text_plain": text,
            "diacritized": bool(text_diacritized),
            "is_final": True,
        })
    except Exception as e:
        logger.exception("Transcription error: %s", e)
        raise HTTPException(500, str(e))
    finally:
        if wav_path and os.path.exists(wav_path):
            try:
                os.unlink(wav_path)
            except OSError:
                pass


def _pcm_to_wav_temp(pcm_bytes: bytes) -> str:
    """Convert raw PCM 16kHz mono 16-bit to WAV file."""
    import wave
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
        wav_path = f.name
    with wave.open(wav_path, "wb") as wav:
        wav.setnchannels(1)
        wav.setsampwidth(2)
        wav.setframerate(SAMPLE_RATE)
        wav.writeframes(pcm_bytes)
    return wav_path


def _bytes_to_wav_temp(data: bytes) -> str:
    """Write bytes to temp WAV file (if already WAV) or try to parse."""
    if data[:4] == b"RIFF":
        with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
            f.write(data)
            return f.name
    return _pcm_to_wav_temp(data)


def _to_16k_wav(audio_bytes: bytes, suffix: str) -> str:
    """Convert any audio to 16kHz mono WAV via ffmpeg."""
    import ffmpeg
    with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
        f.write(audio_bytes)
        tmp_path = f.name
    wav_path = tempfile.mktemp(suffix=".wav")
    try:
        stream = ffmpeg.input(tmp_path)
        stream = ffmpeg.output(
            stream, wav_path,
            acodec="pcm_s16le", ac=1, ar=SAMPLE_RATE,
            loglevel="error",
        )
        ffmpeg.run(stream, overwrite_output=True)
        return wav_path
    except ffmpeg.Error as e:
        err = (e.stderr or b"").decode(errors="replace")
        raise RuntimeError(f"FFmpeg conversion failed: {err}") from e
    finally:
        if os.path.exists(tmp_path):
            try:
                os.unlink(tmp_path)
            except OSError:
                pass


if __name__ == "__main__":
    port = int(os.getenv("NEMO_STT_PORT", "3005"))
    host = os.getenv("NEMO_STT_HOST", "0.0.0.0")
    logger.info("Starting NeMo STT server on %s:%d", host, port)
    uvicorn.run(app, host=host, port=port)