nnserve 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. nnserve-0.0.1/.githooks/pre-commit +13 -0
  2. nnserve-0.0.1/.github/FUNDING.yml +1 -0
  3. nnserve-0.0.1/.github/workflows/main.yml +51 -0
  4. nnserve-0.0.1/.gitignore +14 -0
  5. nnserve-0.0.1/PKG-INFO +20 -0
  6. nnserve-0.0.1/README.md +42 -0
  7. nnserve-0.0.1/nnserve/__init__.py +6 -0
  8. nnserve-0.0.1/nnserve/api.py +389 -0
  9. nnserve-0.0.1/nnserve/app.py +70 -0
  10. nnserve-0.0.1/nnserve/asr/__init__.py +5 -0
  11. nnserve-0.0.1/nnserve/asr/audio.py +41 -0
  12. nnserve-0.0.1/nnserve/asr/format.py +50 -0
  13. nnserve-0.0.1/nnserve/asr/whistle.py +29 -0
  14. nnserve-0.0.1/nnserve/cli.py +27 -0
  15. nnserve-0.0.1/nnserve/config.py +29 -0
  16. nnserve-0.0.1/nnserve/documents.py +101 -0
  17. nnserve-0.0.1/nnserve/handlers.py +75 -0
  18. nnserve-0.0.1/nnserve/ocr/__init__.py +4 -0
  19. nnserve-0.0.1/nnserve/ocr/geometry.py +215 -0
  20. nnserve-0.0.1/nnserve/ocr/indra.py +158 -0
  21. nnserve-0.0.1/nnserve/ocr/layout.py +340 -0
  22. nnserve-0.0.1/nnserve/ocr/order.py +230 -0
  23. nnserve-0.0.1/nnserve/ocr/pipeline.py +536 -0
  24. nnserve-0.0.1/nnserve/registry.py +51 -0
  25. nnserve-0.0.1/nnserve/schemas.py +294 -0
  26. nnserve-0.0.1/nnserve/streaming.py +15 -0
  27. nnserve-0.0.1/nnserve/tts/__init__.py +5 -0
  28. nnserve-0.0.1/nnserve/tts/audio.py +57 -0
  29. nnserve-0.0.1/nnserve/tts/kitten.py +44 -0
  30. nnserve-0.0.1/nnserve/tts/qwen3.py +72 -0
  31. nnserve-0.0.1/nnserve/version.py +1 -0
  32. nnserve-0.0.1/pyproject.toml +52 -0
  33. nnserve-0.0.1/tests/__init__.py +0 -0
  34. nnserve-0.0.1/tests/conftest.py +146 -0
  35. nnserve-0.0.1/tests/handlers.py +3 -0
  36. nnserve-0.0.1/tests/helpers.py +31 -0
  37. nnserve-0.0.1/tests/test_api.py +342 -0
  38. nnserve-0.0.1/tests/test_asr.py +81 -0
  39. nnserve-0.0.1/tests/test_documents.py +42 -0
  40. nnserve-0.0.1/tests/test_ocr_pipeline.py +225 -0
  41. nnserve-0.0.1/tests/test_registry.py +56 -0
  42. nnserve-0.0.1/tests/test_tts.py +91 -0
@@ -0,0 +1,13 @@
1
+ #!/bin/sh
2
+ # Auto-format staged python files; re-stage anything the formatter changed.
3
+ files=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.py$')
4
+ [ -z "$files" ] && exit 0
5
+ ruff format $files >/dev/null 2>&1 || {
6
+ echo "pre-commit: ruff format failed; is ruff on PATH?" >&2
7
+ exit 1
8
+ }
9
+ changed=$(git diff --name-only -- $files)
10
+ if [ -n "$changed" ]; then
11
+ git add $changed
12
+ echo "pre-commit: reformatted and re-staged: $changed"
13
+ fi
@@ -0,0 +1 @@
1
+ github: [JiauZhang]
@@ -0,0 +1,51 @@
1
+ name: Publish Python 🐍 distributions 📦 to PyPI
2
+
3
+ # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
4
+ on:
5
+ push:
6
+ # Sequence of patterns matched against refs/heads
7
+ branches:
8
+ - main
9
+ # Sequence of patterns matched against refs/tags
10
+ tags:
11
+ - v*
12
+ paths-ignore:
13
+ - '**.md'
14
+ - 'docs/**'
15
+ - '**/.gitignore'
16
+
17
+ jobs:
18
+ build-n-publish:
19
+ name: Build and publish Python 🐍 distributions 📦 to PyPI
20
+ runs-on: ubuntu-latest
21
+ # Specifying a GitHub environment is optional, but strongly encouraged
22
+ environment: nnserve_pypi_api
23
+ permissions:
24
+ # IMPORTANT: this permission is mandatory for Trusted Publishing
25
+ id-token: write
26
+
27
+ steps:
28
+ - uses: actions/checkout@v3
29
+ - name: Set up Python
30
+ uses: actions/setup-python@v4
31
+ with:
32
+ python-version: "3.11"
33
+
34
+ - name: Install pypa/build
35
+ run: >-
36
+ python -m
37
+ pip install
38
+ build
39
+ --user
40
+ - name: Build a binary wheel and a source tarball
41
+ run: >-
42
+ python -m
43
+ build
44
+ --sdist
45
+ --wheel
46
+ --outdir dist/
47
+ .
48
+
49
+ - name: Publish distribution 📦 to PyPI
50
+ if: startsWith(github.ref, 'refs/tags')
51
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .DS_Store
4
+ .venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ runs/
11
+ *.safetensors
12
+ *.pth
13
+ *.onnx
14
+ *.log
nnserve-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.5
2
+ Name: nnserve
3
+ Version: 0.0.1
4
+ Summary: Inference serving for neural network models
5
+ Author-email: Jiau Zhang <jiauzhang@163.com>
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: fastapi>=0.115
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: nnm
10
+ Requires-Dist: numpy>=2.0
11
+ Requires-Dist: opencv-python-headless>=4.11
12
+ Requires-Dist: pillow>=11.0
13
+ Requires-Dist: python-multipart>=0.0.20
14
+ Requires-Dist: tokenizers>=0.22
15
+ Requires-Dist: uvicorn>=0.30
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=8.3.4; extra == 'dev'
18
+ Requires-Dist: ruff>=0.17.0; extra == 'dev'
19
+ Provides-Extra: pdf
20
+ Requires-Dist: pypdfium2>=4.30; extra == 'pdf'
@@ -0,0 +1,42 @@
1
+ Inference serving for neural network models: one FastAPI service that exposes
2
+ OCR, speech synthesis, transcription and an OpenAI-compatible Responses path,
3
+ each backed by a registered model.
4
+
5
+ ## Install
6
+
7
+ ```shell
8
+ pip install nnserve
9
+ ```
10
+
11
+ The `pdf` extra adds page rendering for PDF inputs:
12
+
13
+ ```shell
14
+ pip install "nnserve[pdf]"
15
+ ```
16
+
17
+ ## Sponsor
18
+
19
+ <table align="center">
20
+ <thead>
21
+ <tr>
22
+ <th colspan="2">公众号</th>
23
+ </tr>
24
+ </thead>
25
+ <tbody align="center" valign="center">
26
+ <tr>
27
+ <td colspan="2"><img src="https://jiauzhang.github.io/ghstatic/images/ofa_m.png" style="height: 196px" alt="AliPay.png"></td>
28
+ </tr>
29
+ </tbody>
30
+ <thead>
31
+ <tr>
32
+ <th>AliPay</th>
33
+ <th>WeChatPay</th>
34
+ </tr>
35
+ </thead>
36
+ <tbody align="center" valign="center">
37
+ <tr>
38
+ <td><img src="https://jiauzhang.github.io/AliPay.png" style="width: 196px; height: 196px" alt="AliPay.png"></td>
39
+ <td><img src="https://jiauzhang.github.io/WeChatPay.png" style="width: 196px; height: 196px" alt="WeChatPay.png"></td>
40
+ </tr>
41
+ </tbody>
42
+ </table>
@@ -0,0 +1,6 @@
1
+ from nnserve.app import create_app
2
+ from nnserve.config import ModelConfig, ServeConfig
3
+ from nnserve.registry import ModelEntry, ModelRegistry
4
+ from nnserve.version import __version__
5
+
6
+ __all__ = ["ModelConfig", "ModelEntry", "ModelRegistry", "ServeConfig", "__version__", "create_app"]
@@ -0,0 +1,389 @@
1
+ # ruff: noqa: B008
2
+ import time
3
+ import uuid
4
+
5
+ import httpx
6
+ from fastapi import APIRouter, File, Form, Request, Response, UploadFile
7
+ from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
8
+
9
+ from nnserve.asr.audio import decode_audio
10
+ from nnserve.asr.format import render_transcription
11
+ from nnserve.documents import load_document, parse_page_selection, render_pages
12
+ from nnserve.handlers import ResponsesOutput, UsageStats
13
+ from nnserve.schemas import (
14
+ TRANSCRIPTION_FORMATS,
15
+ InputTokensDetails,
16
+ MessageItem,
17
+ ModelInfo,
18
+ ModelList,
19
+ OcrRequest,
20
+ OcrResponse,
21
+ OcrUsageInfo,
22
+ OutputText,
23
+ OutputTokensDetails,
24
+ ResponsesError,
25
+ ResponsesRequest,
26
+ ResponsesResponse,
27
+ ResponsesUsage,
28
+ SpeechRequest,
29
+ TextConfig,
30
+ TextFormat,
31
+ TranscriptionRequest,
32
+ )
33
+ from nnserve.streaming import EventSequence, encode_event
34
+ from nnserve.tts.audio import encode_audio
35
+
36
+ router = APIRouter()
37
+
38
+
39
+ class ApiError(Exception):
40
+ def __init__(self, message, status_code=400, *, type="invalid_request_error", param=None, code=None):
41
+ super().__init__(message)
42
+ self.message = message
43
+ self.status_code = status_code
44
+ self.type = type
45
+ self.param = param
46
+ self.code = code
47
+
48
+ def body(self):
49
+ return {"error": {"message": self.message, "type": self.type, "param": self.param, "code": self.code}}
50
+
51
+
52
+ def authorize(request: Request):
53
+ api_key = request.app.state.config.api_key
54
+ if api_key and request.headers.get("authorization") != f"Bearer {api_key}":
55
+ raise ApiError("Invalid API key", 401, code="invalid_api_key")
56
+
57
+
58
+ def model_entry(request: Request, model_id):
59
+ try:
60
+ return request.app.state.registry.get(model_id)
61
+ except KeyError:
62
+ raise ApiError(f"model {model_id!r} is not registered", 404, param="model", code="model_not_found") from None
63
+
64
+
65
+ def model_handler(request: Request, entry):
66
+ handler = request.app.state.handlers.get(entry.id)
67
+ if handler is None:
68
+ raise ApiError(f"model {entry.id!r} is not loaded", 503, type="server_error", code="model_not_loaded")
69
+ return handler
70
+
71
+
72
+ def has_image(value):
73
+ if isinstance(value, dict):
74
+ return value.get("type") == "input_image" or any(has_image(item) for item in value.values())
75
+ if isinstance(value, list):
76
+ return any(has_image(item) for item in value)
77
+ return False
78
+
79
+
80
+ def validate_responses(request: ResponsesRequest, entry):
81
+ if request.tools:
82
+ raise ApiError("tools are not supported", param="tools", code="unsupported_parameter")
83
+ if request.tool_choice not in (None, "none"):
84
+ raise ApiError("tool_choice is not supported", param="tool_choice", code="unsupported_parameter")
85
+ format_type = request.text.format.type if request.text and request.text.format else "text"
86
+ if format_type in ("json_object", "json_schema"):
87
+ raise ApiError("structured text output is not supported", param="text.format", code="unsupported_parameter")
88
+ if not request.input and not request.instructions:
89
+ raise ApiError("input or instructions is required", param="input", code="missing_required_parameter")
90
+ if has_image(request.input) and "vision" not in entry.capabilities:
91
+ raise ApiError(f"model {entry.id!r} has no vision capability", param="input", code="unsupported_capability")
92
+
93
+
94
+ def text_config(request: ResponsesRequest):
95
+ if request.text is None:
96
+ return TextConfig(format=TextFormat())
97
+ if request.text.format is None:
98
+ return TextConfig(format=TextFormat(), verbosity=request.text.verbosity)
99
+ return request.text
100
+
101
+
102
+ def build_usage(stats: UsageStats):
103
+ return ResponsesUsage(
104
+ input_tokens=stats.input_tokens,
105
+ input_tokens_details=InputTokensDetails(cached_tokens=stats.cached_tokens),
106
+ output_tokens=stats.output_tokens,
107
+ output_tokens_details=OutputTokensDetails(reasoning_tokens=stats.reasoning_tokens),
108
+ total_tokens=stats.input_tokens + stats.output_tokens,
109
+ )
110
+
111
+
112
+ def build_response(
113
+ request: ResponsesRequest,
114
+ output: ResponsesOutput,
115
+ *,
116
+ response_id=None,
117
+ item_id=None,
118
+ created_at=None,
119
+ status="completed",
120
+ error=None,
121
+ ):
122
+ now = created_at or int(time.time())
123
+ item = MessageItem(
124
+ id=item_id or f"msg_{uuid.uuid4().hex}",
125
+ status="in_progress" if status == "in_progress" else "completed",
126
+ content=[OutputText(text=output.text)],
127
+ )
128
+ return ResponsesResponse(
129
+ id=response_id or f"resp_{uuid.uuid4().hex}",
130
+ created_at=now,
131
+ completed_at=None if status == "in_progress" else now,
132
+ status=status,
133
+ error=error,
134
+ model=request.model,
135
+ instructions=request.instructions,
136
+ output=[item],
137
+ output_text=output.text,
138
+ text=text_config(request),
139
+ temperature=request.temperature if request.temperature is not None else 1.0,
140
+ top_p=request.top_p if request.top_p is not None else 1.0,
141
+ truncation=request.truncation or "disabled",
142
+ parallel_tool_calls=request.parallel_tool_calls if request.parallel_tool_calls is not None else True,
143
+ usage=build_usage(output.usage),
144
+ max_output_tokens=request.max_output_tokens,
145
+ user=request.user,
146
+ metadata=request.metadata or {},
147
+ )
148
+
149
+
150
+ @router.get("/health")
151
+ def health():
152
+ return {"status": "ok"}
153
+
154
+
155
+ @router.get("/metrics")
156
+ def metrics(request: Request):
157
+ entries = request.app.state.registry.list()
158
+ lines = [
159
+ "# HELP nnserve_models Registered models",
160
+ "# TYPE nnserve_models gauge",
161
+ f"nnserve_models {len(entries)}",
162
+ ]
163
+ return PlainTextResponse("\n".join(lines) + "\n")
164
+
165
+
166
+ @router.get("/v1/models")
167
+ def list_models(request: Request):
168
+ authorize(request)
169
+ entries = request.app.state.registry.list()
170
+ return ModelList(data=[ModelInfo(id=entry.id, task=entry.task, capabilities=list(entry.capabilities)) for entry in entries])
171
+
172
+
173
+ @router.get("/v1/models/{model_id}")
174
+ def get_model(model_id: str, request: Request):
175
+ authorize(request)
176
+ entry = model_entry(request, model_id)
177
+ return ModelInfo(id=entry.id, task=entry.task, capabilities=list(entry.capabilities))
178
+
179
+
180
+ @router.post("/v1/responses")
181
+ def create_response(payload: ResponsesRequest, request: Request):
182
+ authorize(request)
183
+ entry = model_entry(request, payload.model)
184
+ if entry.task not in ("chat", "vlm"):
185
+ raise ApiError(
186
+ f"model {entry.id!r} has task {entry.task!r}; use a task-specific endpoint",
187
+ param="model",
188
+ code="model_not_supported",
189
+ )
190
+ validate_responses(payload, entry)
191
+ handler = model_handler(request, entry)
192
+
193
+ if not payload.stream:
194
+ return build_response(payload, handler.respond(payload, entry))
195
+
196
+ response_id = f"resp_{uuid.uuid4().hex}"
197
+ item_id = f"msg_{uuid.uuid4().hex}"
198
+ created_at = int(time.time())
199
+ return StreamingResponse(
200
+ _response_stream(payload, entry, handler, response_id, item_id, created_at),
201
+ media_type="text/event-stream",
202
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
203
+ )
204
+
205
+
206
+ def _response_stream(payload, entry, handler, response_id, item_id, created_at):
207
+ sequence = EventSequence()
208
+ partial = build_response(
209
+ payload, ResponsesOutput(""), response_id=response_id, item_id=item_id, created_at=created_at, status="in_progress"
210
+ )
211
+ yield encode_event(sequence.next("response.created", response=partial.model_dump(mode="json")))
212
+ yield encode_event(sequence.next("response.in_progress", response=partial.model_dump(mode="json")))
213
+ yield encode_event(
214
+ sequence.next(
215
+ "response.output_item.added",
216
+ output_index=0,
217
+ item={"type": "message", "id": item_id, "status": "in_progress", "role": "assistant", "content": []},
218
+ )
219
+ )
220
+ yield encode_event(
221
+ sequence.next(
222
+ "response.content_part.added",
223
+ output_index=0,
224
+ content_index=0,
225
+ item_id=item_id,
226
+ part={"type": "output_text", "text": "", "annotations": [], "logprobs": []},
227
+ )
228
+ )
229
+
230
+ text = []
231
+ usage = UsageStats()
232
+ try:
233
+ for chunk in handler.stream(payload, entry):
234
+ if chunk.text:
235
+ text.append(chunk.text)
236
+ yield encode_event(
237
+ sequence.next(
238
+ "response.output_text.delta",
239
+ output_index=0,
240
+ content_index=0,
241
+ item_id=item_id,
242
+ delta=chunk.text,
243
+ )
244
+ )
245
+ if chunk.usage is not None:
246
+ usage = chunk.usage
247
+ except Exception as exc: # noqa: BLE001
248
+ failed = build_response(
249
+ payload,
250
+ ResponsesOutput("".join(text), usage),
251
+ response_id=response_id,
252
+ item_id=item_id,
253
+ created_at=created_at,
254
+ status="failed",
255
+ error=ResponsesError(code="server_error", message=str(exc), type="server_error"),
256
+ )
257
+ yield encode_event(sequence.next("response.failed", response=failed.model_dump(mode="json")))
258
+ return
259
+
260
+ output = "".join(text)
261
+ yield encode_event(
262
+ sequence.next("response.output_text.done", output_index=0, content_index=0, item_id=item_id, text=output)
263
+ )
264
+ yield encode_event(
265
+ sequence.next(
266
+ "response.content_part.done",
267
+ output_index=0,
268
+ content_index=0,
269
+ item_id=item_id,
270
+ part={"type": "output_text", "text": output, "annotations": [], "logprobs": []},
271
+ )
272
+ )
273
+ completed = build_response(
274
+ payload, ResponsesOutput(output, usage), response_id=response_id, item_id=item_id, created_at=created_at
275
+ )
276
+ yield encode_event(
277
+ sequence.next(
278
+ "response.output_item.done",
279
+ output_index=0,
280
+ item=completed.output[0].model_dump(mode="json"),
281
+ )
282
+ )
283
+ yield encode_event(sequence.next("response.completed", response=completed.model_dump(mode="json")))
284
+
285
+
286
+ @router.post("/v1/ocr")
287
+ def create_ocr(payload: OcrRequest, request: Request):
288
+ authorize(request)
289
+ entry = model_entry(request, payload.model)
290
+ if entry.task != "ocr":
291
+ raise ApiError(f"model {entry.id!r} has task {entry.task!r}", param="model", code="model_not_supported")
292
+ if payload.bbox_annotation_format or payload.document_annotation_format or payload.document_annotation_prompt:
293
+ raise ApiError("annotation formats are not supported", code="unsupported_parameter")
294
+ handler = model_handler(request, entry)
295
+
296
+ config = request.app.state.config
297
+ try:
298
+ source = load_document(payload.document, timeout=config.request_timeout)
299
+ selection = parse_page_selection(payload.pages)
300
+ pages = render_pages(source, selection, dpi=config.pdf_dpi)
301
+ except ValueError as exc:
302
+ raise ApiError(str(exc), code="invalid_document") from exc
303
+ except httpx.HTTPError as exc:
304
+ raise ApiError(f"could not fetch the document: {exc}", code="invalid_document") from exc
305
+
306
+ if not pages:
307
+ raise ApiError("no pages to process", code="invalid_document")
308
+
309
+ result = handler.process(payload, entry, pages)
310
+ return OcrResponse(
311
+ pages=result.pages,
312
+ model=payload.model,
313
+ document_annotation=result.document_annotation,
314
+ usage_info=OcrUsageInfo(pages_processed=len(result.pages), doc_size_bytes=source.size_bytes),
315
+ )
316
+
317
+
318
+ @router.post("/v1/audio/speech")
319
+ def create_speech(payload: SpeechRequest, request: Request):
320
+ authorize(request)
321
+ entry = model_entry(request, payload.model)
322
+ if entry.task != "tts":
323
+ raise ApiError(f"model {entry.id!r} has task {entry.task!r}", param="model", code="model_not_supported")
324
+ if payload.stream_format == "sse":
325
+ raise ApiError("stream_format sse is not supported", param="stream_format", code="unsupported_parameter")
326
+ handler = model_handler(request, entry)
327
+
328
+ try:
329
+ audio = handler.synthesize(payload, entry)
330
+ except KeyError as exc:
331
+ raise ApiError(str(exc), param="voice", code="voice_not_found") from exc
332
+ except ValueError as exc:
333
+ raise ApiError(str(exc), code="invalid_request") from exc
334
+
335
+ content, media_type = encode_audio(audio.samples, audio.sample_rate, payload.response_format)
336
+ return Response(content=content, media_type=media_type)
337
+
338
+
339
+ @router.post("/v1/audio/transcriptions")
340
+ async def create_transcription(
341
+ request: Request,
342
+ file: UploadFile = File(...),
343
+ model: str = Form(...),
344
+ language: str | None = Form(None),
345
+ prompt: str | None = Form(None),
346
+ response_format: str = Form("json"),
347
+ temperature: float | None = Form(None),
348
+ timestamp_granularities: list[str] | None = Form(None, alias="timestamp_granularities[]"),
349
+ granularity_alias: list[str] | None = Form(None, alias="timestamp_granularities"),
350
+ ):
351
+ authorize(request)
352
+ entry = model_entry(request, model)
353
+ if entry.task != "asr":
354
+ raise ApiError(f"model {entry.id!r} has task {entry.task!r}", param="model", code="model_not_supported")
355
+ if response_format not in TRANSCRIPTION_FORMATS:
356
+ raise ApiError(
357
+ f"response_format must be one of {TRANSCRIPTION_FORMATS}",
358
+ param="response_format",
359
+ code="invalid_request",
360
+ )
361
+ granularities = timestamp_granularities or granularity_alias or []
362
+ if any(value not in ("word", "segment") for value in granularities):
363
+ raise ApiError("timestamp_granularities must be word or segment", code="invalid_request")
364
+ handler = model_handler(request, entry)
365
+
366
+ try:
367
+ audio = decode_audio(await file.read())
368
+ except ValueError as exc:
369
+ raise ApiError(str(exc), code="invalid_audio") from exc
370
+ except RuntimeError as exc:
371
+ raise ApiError(str(exc), status_code=500, type="server_error", code="audio_decode_unavailable") from exc
372
+
373
+ payload = TranscriptionRequest(
374
+ model=model,
375
+ language=language,
376
+ prompt=prompt,
377
+ response_format=response_format,
378
+ temperature=temperature,
379
+ timestamp_granularities=granularities,
380
+ )
381
+ try:
382
+ result = handler.transcribe(payload, entry, audio)
383
+ except ValueError as exc:
384
+ raise ApiError(str(exc), code="invalid_request") from exc
385
+
386
+ body, media_type = render_transcription(result, payload, len(audio) / 16000)
387
+ if isinstance(body, dict):
388
+ return JSONResponse(content=body)
389
+ return PlainTextResponse(content=body, media_type=media_type)
@@ -0,0 +1,70 @@
1
+ import importlib
2
+ import logging
3
+
4
+ from fastapi import FastAPI, Request
5
+ from fastapi.exceptions import RequestValidationError
6
+ from fastapi.responses import JSONResponse
7
+
8
+ from nnserve.api import ApiError, router
9
+ from nnserve.config import ServeConfig
10
+ from nnserve.registry import ModelRegistry
11
+ from nnserve.version import __version__
12
+
13
+ logger = logging.getLogger("nnserve")
14
+
15
+
16
+ async def api_error_handler(request: Request, exc: ApiError):
17
+ return JSONResponse(status_code=exc.status_code, content=exc.body())
18
+
19
+
20
+ async def validation_error_handler(request: Request, exc: RequestValidationError):
21
+ errors = exc.errors()
22
+ first = errors[0] if errors else {}
23
+ location = [str(part) for part in first.get("loc", ())]
24
+ return JSONResponse(
25
+ status_code=400,
26
+ content={
27
+ "error": {
28
+ "message": str(first.get("msg", "Invalid request")),
29
+ "type": "invalid_request_error",
30
+ "param": location[-1] if location else None,
31
+ "code": "invalid_request",
32
+ }
33
+ },
34
+ )
35
+
36
+
37
+ async def unexpected_error_handler(request: Request, exc: Exception):
38
+ logger.exception("unhandled service error")
39
+ return JSONResponse(
40
+ status_code=500,
41
+ content={"error": {"message": str(exc), "type": "server_error", "param": None, "code": "server_error"}},
42
+ )
43
+
44
+
45
+ def load_handler(spec, entry):
46
+ module_name, separator, class_name = spec.partition(":")
47
+ if not separator or not module_name or not class_name:
48
+ raise ValueError(f'handler must be "module:ClassName", got {spec!r}')
49
+ return getattr(importlib.import_module(module_name), class_name)(entry)
50
+
51
+
52
+ def build_handlers(config, registry):
53
+ handlers = {}
54
+ for model in config.models:
55
+ if model.handler:
56
+ handlers[model.id] = load_handler(model.handler, registry.get(model.id))
57
+ return handlers
58
+
59
+
60
+ def create_app(config: ServeConfig | None = None, handlers=None) -> FastAPI:
61
+ config = config or ServeConfig()
62
+ app = FastAPI(title="nnserve", version=__version__)
63
+ app.state.config = config
64
+ app.state.registry = ModelRegistry.from_models(config.models)
65
+ app.state.handlers = dict(handlers) if handlers is not None else build_handlers(config, app.state.registry)
66
+ app.include_router(router)
67
+ app.add_exception_handler(ApiError, api_error_handler)
68
+ app.add_exception_handler(RequestValidationError, validation_error_handler)
69
+ app.add_exception_handler(Exception, unexpected_error_handler)
70
+ return app
@@ -0,0 +1,5 @@
1
+ from nnserve.asr.audio import decode_audio
2
+ from nnserve.asr.format import render_transcription
3
+ from nnserve.asr.whistle import WhistleAsrHandler
4
+
5
+ __all__ = ["WhistleAsrHandler", "decode_audio", "render_transcription"]
@@ -0,0 +1,41 @@
1
+ import shutil
2
+ import subprocess
3
+
4
+ import numpy as np
5
+
6
+ TARGET_RATE = 16000
7
+
8
+
9
+ def decode_audio(data, sample_rate=TARGET_RATE):
10
+ if not data:
11
+ raise ValueError("empty audio upload")
12
+ if shutil.which("ffmpeg") is None:
13
+ raise RuntimeError("audio decoding requires ffmpeg")
14
+
15
+ result = subprocess.run(
16
+ [
17
+ "ffmpeg",
18
+ "-hide_banner",
19
+ "-loglevel",
20
+ "error",
21
+ "-i",
22
+ "pipe:0",
23
+ "-vn",
24
+ "-f",
25
+ "f32le",
26
+ "-ac",
27
+ "1",
28
+ "-ar",
29
+ str(sample_rate),
30
+ "pipe:1",
31
+ ],
32
+ input=data,
33
+ capture_output=True,
34
+ check=False,
35
+ )
36
+ if result.returncode != 0:
37
+ raise ValueError(result.stderr.decode("utf-8", "ignore").strip() or "could not decode audio")
38
+ samples = np.frombuffer(result.stdout, dtype="<f4").copy()
39
+ if samples.size == 0:
40
+ raise ValueError("no audio samples decoded")
41
+ return samples