File size: 13,877 Bytes
bc8e2a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
from __future__ import annotations

import argparse
import gc
import json
import os
from pathlib import Path
import threading
from typing import Any

from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
import psutil

from asr_onnx_runtime import OnnxAsrEngine, OnnxCacheAsrEngine, collect_metrics


APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static"
ENGINE: OnnxAsrEngine | None = None
ENGINE_INFO: dict[str, Any] = {}
ENGINE_ARGS: argparse.Namespace | None = None
ENGINE_LOCK = threading.RLock()
PROCESS = psutil.Process()
SERVICE_PEAK_RSS = PROCESS.memory_info().rss
LAST_REQUEST_PEAK_RSS = SERVICE_PEAK_RSS


app = FastAPI(title="Audio8 ASR Local")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")


def release_engine() -> None:
    global ENGINE
    ENGINE = None
    gc.collect()


class RequestMemorySampler:
    def __init__(self, sample_interval_s: float = 0.05) -> None:
        self.sample_interval_s = float(sample_interval_s)
        self.peak_rss_bytes = PROCESS.memory_info().rss
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None

    def _run(self) -> None:
        while not self._stop.wait(self.sample_interval_s):
            self.peak_rss_bytes = max(self.peak_rss_bytes, PROCESS.memory_info().rss)

    def start(self) -> None:
        self.peak_rss_bytes = PROCESS.memory_info().rss
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()

    def stop(self) -> int:
        self.peak_rss_bytes = max(self.peak_rss_bytes, PROCESS.memory_info().rss)
        self._stop.set()
        if self._thread is not None:
            self._thread.join(timeout=1.0)
        return self.peak_rss_bytes


def update_service_peak(candidate: int | None = None) -> None:
    global SERVICE_PEAK_RSS
    current = PROCESS.memory_info().rss
    values = [SERVICE_PEAK_RSS, current]
    if candidate is not None:
        values.append(int(candidate))
    SERVICE_PEAK_RSS = max(values)


def available_cache_precisions(bundle_dir: str | Path) -> list[str]:
    bundle_path = Path(bundle_dir).expanduser().resolve()
    metadata_path = bundle_path / "metadata.json"
    if not metadata_path.exists():
        return []
    metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
    prefill_graph = metadata.get("graphs", {}).get("lm_cache_prefill")
    graph = metadata.get("graphs", {}).get("lm_cache_decode")
    if not graph or not prefill_graph:
        return []
    prefill_graph_path = bundle_path / prefill_graph["path"]
    graph_path = bundle_path / graph["path"]
    values = []
    if prefill_graph_path.exists() and graph_path.exists():
        values.append("fp32")
    prefill_int8_path = prefill_graph_path.with_name(f"{prefill_graph_path.stem}_int8{prefill_graph_path.suffix}")
    int8_path = graph_path.with_name(f"{graph_path.stem}_int8{graph_path.suffix}")
    if prefill_int8_path.exists() and int8_path.exists():
        values.append("int8")
    prefill_int4_path = prefill_graph_path.with_name(f"{prefill_graph_path.stem}_int4{prefill_graph_path.suffix}")
    int4_path = graph_path.with_name(f"{graph_path.stem}_int4{graph_path.suffix}")
    if prefill_int4_path.exists() and int4_path.exists():
        values.append("int4")
    return values


def available_audio_precisions(bundle_dir: str | Path) -> list[str]:
    bundle_path = Path(bundle_dir).expanduser().resolve()
    metadata_path = bundle_path / "metadata.json"
    if not metadata_path.exists():
        return []
    metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
    graphs = metadata.get("graphs", {})
    values = []
    fp32_graph = graphs.get("audio_hidden")
    if fp32_graph and (bundle_path / fp32_graph["path"]).exists():
        values.append("fp32")
    int8_graph = graphs.get("audio_hidden_int8")
    if int8_graph and (bundle_path / int8_graph["path"]).exists():
        values.append("int8")
    return values


def runtime_snapshot() -> dict[str, Any]:
    update_service_peak()
    bundle_dir = ENGINE_INFO.get("bundle_dir")
    return {
        **ENGINE_INFO,
        "runtime_loaded": ENGINE is not None,
        "available_cache_precisions": available_cache_precisions(bundle_dir) if bundle_dir else [],
        "available_audio_precisions": available_audio_precisions(bundle_dir) if bundle_dir else [],
        "service_peak_rss_bytes": int(SERVICE_PEAK_RSS),
        "last_request_peak_rss_bytes": int(LAST_REQUEST_PEAK_RSS),
    }


def load_engine(
    args: argparse.Namespace,
    *,
    backend: str | None = None,
    cache_precision: str | None = None,
    audio_precision: str | None = None,
    release_existing: bool = False,
) -> None:
    global ENGINE, ENGINE_INFO
    if release_existing:
        release_engine()
    selected_backend = backend or args.backend
    selected_precision = cache_precision or args.cache_precision
    selected_audio_precision = audio_precision if audio_precision is not None else getattr(args, "audio_precision", None)
    threads = args.threads if args.threads > 0 else None

    if selected_backend in {"auto", "onnx_cache"}:
        try:
            engine = OnnxCacheAsrEngine(
                args.bundle_dir,
                provider=args.provider,
                intra_op_num_threads=threads,
                cache_precision=selected_precision,
                audio_precision=selected_audio_precision,
            )
            ENGINE = engine
            ENGINE_INFO = {
                "bundle_dir": str(engine.bundle_dir),
                "backend": "onnx_cache",
                "cache_precision": engine.cache_precision,
                "cache_prefill_graph": str(engine.prefill_graph_path),
                "cache_graph": str(engine.cache_graph_path),
                "audio_precision": engine.audio_precision,
                "audio_graph": str(engine.audio_graph_path),
                "providers": engine.providers,
            }
            update_service_peak()
            return
        except Exception:
            if selected_backend == "onnx_cache":
                raise

    engine = OnnxAsrEngine(
        args.bundle_dir,
        provider=args.provider,
        intra_op_num_threads=threads,
        audio_precision=selected_audio_precision or "fp32",
    )
    ENGINE = engine
    ENGINE_INFO = {
        "bundle_dir": str(engine.bundle_dir),
        "backend": "onnx",
        "cache_precision": None,
        "cache_graph": None,
        "audio_precision": engine.audio_precision,
        "audio_graph": str(engine.audio_graph_path),
        "providers": engine.providers,
    }
    update_service_peak()


@app.get("/")
async def index() -> FileResponse:
    return FileResponse(STATIC_DIR / "index.html")


@app.get("/health")
async def health() -> dict[str, Any]:
    if ENGINE is None:
        return {"ok": False, "error": "engine not loaded"}
    return {"ok": True, **runtime_snapshot()}


@app.get("/api/runtime")
async def runtime() -> dict[str, Any]:
    return {"ok": ENGINE is not None, "runtime": runtime_snapshot()}


@app.post("/api/reload")
async def reload_runtime(
    backend: str = Form("onnx_cache"),
    cache_precision: str | None = Form(None),
    audio_precision: str | None = Form(None),
) -> dict[str, Any]:
    if ENGINE_ARGS is None:
        raise HTTPException(status_code=503, detail="Server arguments are not initialized")
    if backend not in {"onnx_cache", "onnx", "auto"}:
        raise HTTPException(status_code=400, detail=f"Unsupported backend: {backend}")
    if cache_precision:
        cache_precision = cache_precision.lower().strip()
        if cache_precision not in {"fp32", "int8", "int4", "auto"}:
            raise HTTPException(status_code=400, detail=f"Unsupported cache precision: {cache_precision}")
    else:
        cache_precision = str(ENGINE_INFO.get("cache_precision") or "").lower().strip() or ENGINE_ARGS.cache_precision
    if audio_precision:
        audio_precision = audio_precision.lower().strip()
        if audio_precision not in {"fp32", "int8", "auto"}:
            raise HTTPException(status_code=400, detail=f"Unsupported audio precision: {audio_precision}")
    else:
        audio_precision = str(ENGINE_INFO.get("audio_precision") or "").lower().strip() or None
    with ENGINE_LOCK:
        try:
            load_engine(
                ENGINE_ARGS,
                backend=backend,
                cache_precision=cache_precision,
                audio_precision=audio_precision,
                release_existing=True,
            )
        except Exception as exc:
            raise HTTPException(status_code=500, detail=str(exc)) from exc
    return {"ok": True, "runtime": runtime_snapshot()}


@app.get("/metrics")
async def metrics() -> dict[str, Any]:
    data = collect_metrics()
    data["runtime"] = runtime_snapshot()
    return data


@app.post("/asr")
async def asr(
    audio: UploadFile = File(...),
    language: str | None = Form(None),
    max_new_tokens: int = Form(128),
    cache_precision: str | None = Form(None),
    audio_precision: str | None = Form(None),
    hotwords: str | None = Form(None),
    hotword_topk: int = Form(50),
    hotword_start_boost: float = Form(6.0),
    hotword_continuation_boost: float = Form(8.0),
) -> dict[str, Any]:
    global LAST_REQUEST_PEAK_RSS
    if ENGINE is None:
        raise HTTPException(status_code=503, detail="ASR engine is not loaded")
    audio_bytes = await audio.read()
    if not audio_bytes:
        raise HTTPException(status_code=400, detail="Empty audio upload")
    if ENGINE_ARGS is not None:
        requested_cache = cache_precision.lower().strip() if cache_precision else None
        requested_audio = audio_precision.lower().strip() if audio_precision else None
        if requested_cache and requested_cache not in {"fp32", "int8", "int4", "auto"}:
            raise HTTPException(status_code=400, detail=f"Unsupported cache precision: {requested_cache}")
        if requested_audio and requested_audio not in {"fp32", "int8", "auto"}:
            raise HTTPException(status_code=400, detail=f"Unsupported audio precision: {requested_audio}")
        current_cache = str(ENGINE_INFO.get("cache_precision") or "").lower().strip()
        current_audio = str(ENGINE_INFO.get("audio_precision") or "").lower().strip()
        target_cache = requested_cache or current_cache or ENGINE_ARGS.cache_precision
        target_audio = requested_audio or current_audio or getattr(ENGINE_ARGS, "audio_precision", None)
        should_reload = (
            ENGINE_INFO.get("backend") == "onnx_cache"
            and (
                (requested_cache is not None and target_cache != current_cache)
                or (requested_audio is not None and target_audio != current_audio)
            )
        )
        if should_reload:
            with ENGINE_LOCK:
                try:
                    load_engine(
                        ENGINE_ARGS,
                        backend="onnx_cache",
                        cache_precision=target_cache,
                        audio_precision=target_audio,
                        release_existing=True,
                    )
                except Exception as exc:
                    raise HTTPException(status_code=500, detail=str(exc)) from exc
    sampler = RequestMemorySampler()
    sampler.start()
    try:
        with ENGINE_LOCK:
            if ENGINE is None:
                raise HTTPException(status_code=503, detail="ASR engine is not loaded")
            result = ENGINE.transcribe(
                audio_bytes,
                language=language or None,
                max_new_tokens=max_new_tokens,
                hotwords=hotwords or None,
                hotword_topk=int(hotword_topk),
                hotword_start_boost=float(hotword_start_boost),
                hotword_continuation_boost=float(hotword_continuation_boost),
            )
        LAST_REQUEST_PEAK_RSS = max(int(sampler.stop()), PROCESS.memory_info().rss)
        update_service_peak(LAST_REQUEST_PEAK_RSS)
        result["request_peak_rss_bytes"] = int(LAST_REQUEST_PEAK_RSS)
        result["runtime"] = runtime_snapshot()
        return result
    except Exception as exc:
        LAST_REQUEST_PEAK_RSS = max(int(sampler.stop()), PROCESS.memory_info().rss)
        update_service_peak(LAST_REQUEST_PEAK_RSS)
        raise HTTPException(status_code=500, detail=str(exc)) from exc


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Run the local Audio8 ASR ONNX web UI.")
    parser.add_argument(
        "--bundle_dir",
        default=os.environ.get("AUDIO8_ASR_BUNDLE", "model_bundle"),
    )
    parser.add_argument(
        "--backend",
        choices=["auto", "onnx", "onnx_cache"],
        default=os.environ.get("ASR_BACKEND", "auto"),
    )
    parser.add_argument("--host", default=os.environ.get("HOST", "127.0.0.1"))
    parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "7860")))
    parser.add_argument("--provider", default=os.environ.get("ORT_PROVIDER", "CPUExecutionProvider"))
    parser.add_argument("--cache_precision", default=os.environ.get("ASR_CACHE_PRECISION", "auto"))
    parser.add_argument("--audio_precision", choices=["fp32", "int8", "auto"], default=os.environ.get("ASR_AUDIO_PRECISION", "auto"))
    parser.add_argument("--threads", type=int, default=int(os.environ.get("ORT_THREADS", "0")))
    return parser.parse_args()


def main() -> None:
    global ENGINE_ARGS
    args = parse_args()
    ENGINE_ARGS = args
    load_engine(args)
    _run_uvicorn(args)


def _run_uvicorn(args: argparse.Namespace) -> None:
    import uvicorn

    uvicorn.run(app, host=args.host, port=args.port)


if __name__ == "__main__":
    main()