@mcoda/mswarm 0.1.101 → 0.1.102

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.
@@ -0,0 +1,738 @@
1
+ #!/usr/bin/env python3
2
+ """Loopback-only OpenAI-shaped HTTP wrapper for Stable Audio 3 TensorRT."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import base64
8
+ import io
9
+ import ipaddress
10
+ import json
11
+ import logging
12
+ import math
13
+ import os
14
+ import secrets
15
+ import signal
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import threading
20
+ import time
21
+ import wave
22
+ from dataclasses import dataclass, field
23
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
24
+ from pathlib import Path
25
+ from socketserver import TCPServer
26
+ from typing import Any, Protocol
27
+
28
+
29
+ DEFAULT_HOST = "127.0.0.1"
30
+ DEFAULT_PORT = 11446
31
+ DEFAULT_MODEL_ID = "stable-audio-3-local"
32
+ DEFAULT_RUNTIME_ROOT = (
33
+ "/mnt/githubActions/piriatlas/tools/stable-audio-3/optimized/tensorRT"
34
+ )
35
+ SAMPLE_RATE = 44_100
36
+ CHANNELS = 2
37
+ SAMPLE_WIDTH_BYTES = 2
38
+ ALLOWED_FIELDS = {
39
+ "model",
40
+ "prompt",
41
+ "duration_seconds",
42
+ "response_format",
43
+ "n",
44
+ "seed",
45
+ "steps",
46
+ }
47
+
48
+
49
+ class AudioBackend(Protocol):
50
+ def generate(
51
+ self,
52
+ prompt: str,
53
+ *,
54
+ seconds: float,
55
+ steps: int,
56
+ seed: int | None,
57
+ ) -> tuple[bytes, dict[str, Any]]: ...
58
+
59
+
60
+ class ApiError(Exception):
61
+ def __init__(
62
+ self,
63
+ status: int,
64
+ message: str,
65
+ *,
66
+ error_type: str = "invalid_request_error",
67
+ param: str | None = None,
68
+ code: str | None = None,
69
+ ) -> None:
70
+ super().__init__(message)
71
+ self.status = status
72
+ self.message = message
73
+ self.error_type = error_type
74
+ self.param = param
75
+ self.code = code
76
+
77
+
78
+ class MockBackend:
79
+ """Dependency-free backend used only by the HTTP contract test."""
80
+
81
+ def generate(
82
+ self,
83
+ prompt: str,
84
+ *,
85
+ seconds: float,
86
+ steps: int,
87
+ seed: int | None,
88
+ ) -> tuple[bytes, dict[str, Any]]:
89
+ del prompt, steps
90
+ sample_count = int(round(seconds * SAMPLE_RATE))
91
+ output = io.BytesIO()
92
+ with wave.open(output, "wb") as wav_file:
93
+ wav_file.setnchannels(CHANNELS)
94
+ wav_file.setsampwidth(SAMPLE_WIDTH_BYTES)
95
+ wav_file.setframerate(SAMPLE_RATE)
96
+ wav_file.writeframes(b"\x00" * (sample_count * CHANNELS * SAMPLE_WIDTH_BYTES))
97
+ return output.getvalue(), {"seed": 0 if seed is None else seed}
98
+
99
+
100
+ class SubprocessBackend:
101
+ """Runs one canonical Stable Audio CLI process per request.
102
+
103
+ Upstream documents that the TensorRT decoder workspace evolves after the
104
+ first inference in a process. A fresh process per request preserves the
105
+ canonical CLI's deterministic behavior while the HTTP listener remains
106
+ resident and bounded.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ runtime_root: Path,
112
+ *,
113
+ dit: str,
114
+ decoder: str,
115
+ precision: str | None,
116
+ models_dir: Path | None,
117
+ engine_arch: str,
118
+ max_output_bytes: int,
119
+ timeout_seconds: float,
120
+ ) -> None:
121
+ self.runtime_root = runtime_root
122
+ self.python = runtime_root / ".venv" / "bin" / "python"
123
+ self.entrypoint = runtime_root / "scripts" / "sa3_trt.py"
124
+ if not self.python.is_file() or not os.access(self.python, os.X_OK):
125
+ raise RuntimeError(f"Stable Audio Python is missing or not executable: {self.python}")
126
+ if not self.entrypoint.is_file():
127
+ raise RuntimeError(f"Stable Audio TensorRT entrypoint not found at {self.entrypoint}")
128
+ self.dit = dit
129
+ self.decoder = decoder
130
+ self.precision = precision
131
+ self.models_dir = models_dir
132
+ self.max_output_bytes = max_output_bytes
133
+ self.timeout_seconds = timeout_seconds
134
+ precision_name = precision or "fp16mixed"
135
+ dit_directory = {
136
+ "sm-music": "sa3-sm-music",
137
+ "sm-sfx": "sa3-sm-sfx",
138
+ "medium": "sa3-m",
139
+ }[dit]
140
+ decoder_filename = (
141
+ "dec_dynamic_fp32.trt"
142
+ if precision_name == "fp32"
143
+ else {
144
+ "same-s": "dec_dynamic_bf16.trt",
145
+ "same-l": "dec_dynamic_triton_swa.trt",
146
+ }[decoder]
147
+ )
148
+ models_root = models_dir or runtime_root / "models"
149
+ required_files = (
150
+ runtime_root / "scripts" / "tokenizer.json",
151
+ models_root / engine_arch / "t5gemma" / "t5gemma_fp16mixed.trt",
152
+ models_root / engine_arch / dit_directory / f"dit_{precision_name}.trt",
153
+ models_root / engine_arch / decoder / decoder_filename,
154
+ )
155
+ missing = [path for path in required_files if not path.is_file() or path.stat().st_size == 0]
156
+ if missing:
157
+ raise RuntimeError(f"Stable Audio runtime file is missing or empty: {missing[0]}")
158
+
159
+ def generate(
160
+ self,
161
+ prompt: str,
162
+ *,
163
+ seconds: float,
164
+ steps: int,
165
+ seed: int | None,
166
+ ) -> tuple[bytes, dict[str, Any]]:
167
+ effective_seed = seed if seed is not None else secrets.randbelow(2**31)
168
+ with tempfile.TemporaryDirectory(prefix="stable-audio-3-") as temp_dir:
169
+ output_path = Path(temp_dir) / "generation.wav"
170
+ command = [
171
+ str(self.python),
172
+ str(self.entrypoint),
173
+ f"--prompt={prompt}",
174
+ "--dit",
175
+ self.dit,
176
+ "--decoder",
177
+ self.decoder,
178
+ "--seconds",
179
+ str(seconds),
180
+ "--steps",
181
+ str(steps),
182
+ "--seed",
183
+ str(effective_seed),
184
+ "--quiet",
185
+ "--out",
186
+ str(output_path),
187
+ ]
188
+ if self.precision:
189
+ command.extend(("--precision", self.precision))
190
+ if self.models_dir:
191
+ command.extend(("--models-dir", str(self.models_dir)))
192
+ process = subprocess.Popen(
193
+ command,
194
+ cwd=self.runtime_root,
195
+ stdin=subprocess.DEVNULL,
196
+ stdout=subprocess.DEVNULL,
197
+ stderr=subprocess.DEVNULL,
198
+ start_new_session=True,
199
+ )
200
+ try:
201
+ return_code = process.wait(timeout=self.timeout_seconds)
202
+ except subprocess.TimeoutExpired as error:
203
+ try:
204
+ os.killpg(process.pid, signal.SIGTERM)
205
+ process.wait(timeout=5)
206
+ except subprocess.TimeoutExpired:
207
+ os.killpg(process.pid, signal.SIGKILL)
208
+ process.wait()
209
+ except ProcessLookupError:
210
+ pass
211
+ raise RuntimeError("Stable Audio CLI timed out") from error
212
+ if return_code != 0:
213
+ raise RuntimeError(
214
+ f"Stable Audio CLI exited with status {return_code}"
215
+ )
216
+ try:
217
+ if output_path.stat().st_size > self.max_output_bytes:
218
+ raise RuntimeError("Stable Audio CLI output exceeds the configured limit")
219
+ wav_data = output_path.read_bytes()
220
+ except OSError as error:
221
+ raise RuntimeError("Stable Audio CLI did not produce a WAV file") from error
222
+ _validate_wav(wav_data, seconds)
223
+ return wav_data, {"seed": effective_seed}
224
+
225
+
226
+ @dataclass
227
+ class ServerState:
228
+ backend: AudioBackend
229
+ backend_name: str
230
+ model_id: str
231
+ dit: str
232
+ decoder: str
233
+ max_request_bytes: int
234
+ max_output_bytes: int
235
+ max_prompt_chars: int
236
+ min_duration_seconds: float
237
+ max_duration_seconds: float
238
+ max_steps: int
239
+ default_steps: int
240
+ fatal_error: threading.Event = field(default_factory=threading.Event)
241
+ inference_slots: threading.BoundedSemaphore = field(
242
+ default_factory=lambda: threading.BoundedSemaphore(1)
243
+ )
244
+
245
+
246
+ class AudioHttpServer(ThreadingHTTPServer):
247
+ daemon_threads = True
248
+ request_queue_size = 8
249
+
250
+ def server_bind(self) -> None:
251
+ # HTTPServer.server_bind performs a reverse-DNS lookup via getfqdn().
252
+ # That lookup is unnecessary for this loopback-only service and can
253
+ # stall startup on hosts whose resolver does not map loopback quickly.
254
+ TCPServer.server_bind(self)
255
+ host, port = self.server_address[:2]
256
+ self.server_name = str(host)
257
+ self.server_port = int(port)
258
+
259
+
260
+ def _is_int(value: Any) -> bool:
261
+ return isinstance(value, int) and not isinstance(value, bool)
262
+
263
+
264
+ def _parse_generation_request(payload: Any, state: ServerState) -> dict[str, Any]:
265
+ if not isinstance(payload, dict):
266
+ raise ApiError(400, "request body must be a JSON object", code="invalid_json")
267
+
268
+ unknown = sorted(set(payload) - ALLOWED_FIELDS)
269
+ if unknown:
270
+ raise ApiError(
271
+ 400,
272
+ f"unsupported request parameter: {unknown[0]}",
273
+ param=unknown[0],
274
+ code="unsupported_parameter",
275
+ )
276
+
277
+ model = payload.get("model")
278
+ if model != state.model_id:
279
+ raise ApiError(
280
+ 400,
281
+ f"model must be {state.model_id}",
282
+ param="model",
283
+ code="model_not_found",
284
+ )
285
+
286
+ prompt = payload.get("prompt")
287
+ if not isinstance(prompt, str) or not prompt.strip():
288
+ raise ApiError(
289
+ 400,
290
+ "prompt must be a non-empty string",
291
+ param="prompt",
292
+ code="invalid_prompt",
293
+ )
294
+ if len(prompt) > state.max_prompt_chars:
295
+ raise ApiError(
296
+ 400,
297
+ "prompt exceeds the configured limit",
298
+ param="prompt",
299
+ code="prompt_limit_exceeded",
300
+ )
301
+ if "\x00" in prompt:
302
+ raise ApiError(
303
+ 400,
304
+ "prompt must not contain a null character",
305
+ param="prompt",
306
+ code="invalid_prompt",
307
+ )
308
+ try:
309
+ prompt.encode("utf-8", errors="strict")
310
+ except UnicodeEncodeError as error:
311
+ raise ApiError(
312
+ 400,
313
+ "prompt must be valid UTF-8 text",
314
+ param="prompt",
315
+ code="invalid_prompt",
316
+ ) from error
317
+
318
+ duration = payload.get("duration_seconds")
319
+ if isinstance(duration, bool) or not isinstance(duration, (int, float)):
320
+ raise ApiError(
321
+ 400,
322
+ "duration_seconds must be a number",
323
+ param="duration_seconds",
324
+ code="invalid_duration",
325
+ )
326
+ duration = float(duration)
327
+ if not math.isfinite(duration):
328
+ raise ApiError(
329
+ 400,
330
+ "duration_seconds must be finite",
331
+ param="duration_seconds",
332
+ code="invalid_duration",
333
+ )
334
+ if not state.min_duration_seconds <= duration <= state.max_duration_seconds:
335
+ raise ApiError(
336
+ 400,
337
+ "duration_seconds is outside the configured range",
338
+ param="duration_seconds",
339
+ code="duration_limit_exceeded",
340
+ )
341
+
342
+ response_format = payload.get("response_format", "wav")
343
+ if response_format != "wav":
344
+ raise ApiError(
345
+ 400,
346
+ "response_format must be wav",
347
+ param="response_format",
348
+ code="unsupported_response_format",
349
+ )
350
+
351
+ n = payload.get("n", 1)
352
+ if not _is_int(n) or n != 1:
353
+ raise ApiError(400, "n must be 1", param="n", code="invalid_n")
354
+
355
+ seed = payload.get("seed")
356
+ if seed is not None and (not _is_int(seed) or not 0 <= seed <= 2**31 - 1):
357
+ raise ApiError(
358
+ 400,
359
+ "seed must be an integer between 0 and 2147483647",
360
+ param="seed",
361
+ code="invalid_seed",
362
+ )
363
+
364
+ steps = payload.get("steps", state.default_steps)
365
+ if not _is_int(steps) or not 1 <= steps <= state.max_steps:
366
+ raise ApiError(
367
+ 400,
368
+ f"steps must be an integer between 1 and {state.max_steps}",
369
+ param="steps",
370
+ code="invalid_steps",
371
+ )
372
+
373
+ return {
374
+ "prompt": prompt,
375
+ "duration_seconds": duration,
376
+ "steps": steps,
377
+ "seed": seed,
378
+ }
379
+
380
+
381
+ def _validate_wav(wav_data: bytes, duration_seconds: float) -> None:
382
+ expected_samples = int(round(duration_seconds * SAMPLE_RATE))
383
+ expected_pcm_bytes = expected_samples * CHANNELS * SAMPLE_WIDTH_BYTES
384
+ try:
385
+ with wave.open(io.BytesIO(wav_data), "rb") as wav_file:
386
+ if wav_file.getnchannels() != CHANNELS:
387
+ raise RuntimeError("backend WAV must be stereo")
388
+ if wav_file.getsampwidth() != SAMPLE_WIDTH_BYTES:
389
+ raise RuntimeError("backend WAV must use signed 16-bit samples")
390
+ if wav_file.getframerate() != SAMPLE_RATE:
391
+ raise RuntimeError(f"backend WAV must use {SAMPLE_RATE} Hz")
392
+ if wav_file.getnframes() != expected_samples:
393
+ raise RuntimeError(
394
+ f"backend WAV has {wav_file.getnframes()} frames; expected {expected_samples}"
395
+ )
396
+ pcm_data = wav_file.readframes(expected_samples + 1)
397
+ if len(pcm_data) != expected_pcm_bytes:
398
+ raise RuntimeError(
399
+ f"backend WAV has {len(pcm_data)} PCM bytes; expected {expected_pcm_bytes}"
400
+ )
401
+ except (EOFError, wave.Error) as error:
402
+ raise RuntimeError("backend returned an invalid WAV file") from error
403
+ if len(wav_data) != 44 + expected_pcm_bytes:
404
+ raise RuntimeError("backend WAV contains truncated or trailing data")
405
+
406
+
407
+ def _handler_for(state: ServerState) -> type[BaseHTTPRequestHandler]:
408
+ class Handler(BaseHTTPRequestHandler):
409
+ server_version = "stable-audio-3-openai/1"
410
+
411
+ def log_message(self, format_string: str, *args: Any) -> None:
412
+ del format_string, args
413
+
414
+ def _send_json(self, status: int, payload: dict[str, Any]) -> None:
415
+ encoded = json.dumps(payload, separators=(",", ":")).encode("utf-8")
416
+ self.send_response(status)
417
+ self.send_header("content-type", "application/json")
418
+ self.send_header("content-length", str(len(encoded)))
419
+ self.send_header("cache-control", "no-store")
420
+ self.end_headers()
421
+ try:
422
+ self.wfile.write(encoded)
423
+ except BrokenPipeError:
424
+ pass
425
+
426
+ def _send_error(self, error: ApiError) -> None:
427
+ self._send_json(
428
+ error.status,
429
+ {
430
+ "error": {
431
+ "message": error.message,
432
+ "type": error.error_type,
433
+ "param": error.param,
434
+ "code": error.code,
435
+ }
436
+ },
437
+ )
438
+
439
+ def do_GET(self) -> None:
440
+ if self.path == "/healthz":
441
+ if state.fatal_error.is_set():
442
+ self._send_json(503, {"status": "unhealthy", "model": state.model_id})
443
+ return
444
+ self._send_json(
445
+ 200,
446
+ {
447
+ "status": "ok",
448
+ "model": state.model_id,
449
+ "backend": state.backend_name,
450
+ "dit": state.dit,
451
+ "decoder": state.decoder,
452
+ },
453
+ )
454
+ return
455
+ if self.path == "/v1/models":
456
+ self._send_json(
457
+ 200,
458
+ {
459
+ "object": "list",
460
+ "data": [
461
+ {
462
+ "id": state.model_id,
463
+ "object": "model",
464
+ "owned_by": "local",
465
+ }
466
+ ],
467
+ },
468
+ )
469
+ return
470
+ self._send_error(ApiError(404, "not found", error_type="not_found_error"))
471
+
472
+ def do_POST(self) -> None:
473
+ if self.path != "/v1/audio/generations":
474
+ self._send_error(ApiError(404, "not found", error_type="not_found_error"))
475
+ return
476
+
477
+ try:
478
+ if self.headers.get("transfer-encoding"):
479
+ raise ApiError(
480
+ 400,
481
+ "chunked request bodies are not supported",
482
+ code="unsupported_transfer_encoding",
483
+ )
484
+ content_type = self.headers.get("content-type", "")
485
+ if content_type.split(";", 1)[0].strip().lower() != "application/json":
486
+ raise ApiError(
487
+ 400,
488
+ "content-type must be application/json",
489
+ code="invalid_content_type",
490
+ )
491
+ content_length_header = self.headers.get("content-length")
492
+ if content_length_header is None:
493
+ raise ApiError(411, "content-length is required", code="length_required")
494
+ try:
495
+ content_length = int(content_length_header)
496
+ except ValueError as error:
497
+ raise ApiError(
498
+ 400, "content-length is invalid", code="invalid_content_length"
499
+ ) from error
500
+ if content_length < 1:
501
+ raise ApiError(400, "request body is empty", code="invalid_json")
502
+ if content_length > state.max_request_bytes:
503
+ raise ApiError(
504
+ 413,
505
+ "request body exceeds the configured limit",
506
+ code="request_too_large",
507
+ )
508
+ raw_body = self.rfile.read(content_length)
509
+ if len(raw_body) != content_length:
510
+ raise ApiError(400, "request body is incomplete", code="invalid_json")
511
+ try:
512
+ payload = json.loads(raw_body)
513
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
514
+ raise ApiError(400, "request body is invalid JSON", code="invalid_json") from error
515
+ request = _parse_generation_request(payload, state)
516
+ except ApiError as error:
517
+ self._send_error(error)
518
+ return
519
+
520
+ if not state.inference_slots.acquire(blocking=False):
521
+ self._send_error(
522
+ ApiError(
523
+ 429,
524
+ "the local audio generator is busy",
525
+ error_type="rate_limit_error",
526
+ code="local_concurrency_saturated",
527
+ )
528
+ )
529
+ return
530
+
531
+ started_at = time.monotonic()
532
+ try:
533
+ wav_data, timing = state.backend.generate(
534
+ request["prompt"],
535
+ seconds=request["duration_seconds"],
536
+ steps=request["steps"],
537
+ seed=request["seed"],
538
+ )
539
+ _validate_wav(wav_data, request["duration_seconds"])
540
+ if len(wav_data) > state.max_output_bytes:
541
+ raise ApiError(
542
+ 413,
543
+ "generated audio exceeds the configured output limit",
544
+ code="output_too_large",
545
+ )
546
+ effective_seed = timing.get("seed") if isinstance(timing, dict) else None
547
+ if not _is_int(effective_seed):
548
+ raise RuntimeError("backend did not report an effective seed")
549
+ response = {
550
+ "created": int(time.time()),
551
+ "model": state.model_id,
552
+ "data": [
553
+ {
554
+ "b64_audio": base64.b64encode(wav_data).decode("ascii"),
555
+ "mime_type": "audio/wav",
556
+ "duration_seconds": request["duration_seconds"],
557
+ "sample_rate": SAMPLE_RATE,
558
+ "seed": effective_seed,
559
+ }
560
+ ],
561
+ }
562
+ self._send_json(200, response)
563
+ logging.info(
564
+ "generation completed duration_seconds=%.3f steps=%d elapsed_ms=%d",
565
+ request["duration_seconds"],
566
+ request["steps"],
567
+ int((time.monotonic() - started_at) * 1000),
568
+ )
569
+ except ApiError as error:
570
+ self._send_error(error)
571
+ except Exception as error:
572
+ logging.error("generation failed error_type=%s", type(error).__name__)
573
+ self._send_error(
574
+ ApiError(
575
+ 500,
576
+ "audio generation failed",
577
+ error_type="server_error",
578
+ code="generation_failed",
579
+ )
580
+ )
581
+ state.fatal_error.set()
582
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
583
+ finally:
584
+ state.inference_slots.release()
585
+
586
+ return Handler
587
+
588
+
589
+ def _load_subprocess_backend(args: argparse.Namespace) -> AudioBackend:
590
+ runtime_root = Path(args.runtime_root).expanduser().resolve()
591
+ models_dir = (
592
+ Path(args.models_dir).expanduser().resolve() if args.models_dir else None
593
+ )
594
+ return SubprocessBackend(
595
+ runtime_root,
596
+ dit=args.dit,
597
+ decoder=args.decoder,
598
+ precision=args.precision,
599
+ models_dir=models_dir,
600
+ engine_arch=args.engine_arch,
601
+ max_output_bytes=args.max_output_bytes,
602
+ timeout_seconds=args.inference_timeout_seconds,
603
+ )
604
+
605
+
606
+ def _parse_args() -> argparse.Namespace:
607
+ parser = argparse.ArgumentParser(description=__doc__)
608
+ parser.add_argument("--host", default=DEFAULT_HOST)
609
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT)
610
+ parser.add_argument("--backend", choices=("subprocess", "mock"), default="subprocess")
611
+ parser.add_argument("--runtime-root", default=os.environ.get("STABLE_AUDIO_3_TRT_ROOT", DEFAULT_RUNTIME_ROOT))
612
+ parser.add_argument("--models-dir")
613
+ parser.add_argument(
614
+ "--engine-arch",
615
+ default=os.environ.get("STABLE_AUDIO_3_ENGINE_ARCH", "sm_86"),
616
+ )
617
+ parser.add_argument("--dit", choices=("sm-music", "sm-sfx", "medium"), default="sm-music")
618
+ parser.add_argument("--decoder", choices=("same-s", "same-l"), default="same-s")
619
+ parser.add_argument("--precision", choices=("fp16mixed", "bf16", "fp8", "fp32"))
620
+ parser.add_argument("--model-id", default=DEFAULT_MODEL_ID)
621
+ parser.add_argument("--max-request-bytes", type=int, default=131_072)
622
+ parser.add_argument("--max-output-bytes", type=int, default=67_108_864)
623
+ parser.add_argument("--max-prompt-chars", type=int, default=8_192)
624
+ parser.add_argument("--min-duration-seconds", type=float, default=3.0)
625
+ parser.add_argument("--max-duration-seconds", type=float, default=120.0)
626
+ parser.add_argument("--default-steps", type=int, default=8)
627
+ parser.add_argument("--max-steps", type=int, default=32)
628
+ parser.add_argument("--inference-timeout-seconds", type=float, default=900.0)
629
+ parser.add_argument(
630
+ "--startup-self-test",
631
+ action=argparse.BooleanOptionalAction,
632
+ default=True,
633
+ )
634
+ return parser.parse_args()
635
+
636
+
637
+ def main() -> int:
638
+ args = _parse_args()
639
+ try:
640
+ address = ipaddress.ip_address(args.host)
641
+ except ValueError:
642
+ if args.host != "localhost":
643
+ raise SystemExit("--host must be a loopback IP address or localhost")
644
+ else:
645
+ if not address.is_loopback:
646
+ raise SystemExit("--host must resolve to a loopback address")
647
+ if not 1 <= args.port <= 65_535 and args.port != 0:
648
+ raise SystemExit("--port must be 0 or between 1 and 65535")
649
+ if args.decoder != ("same-l" if args.dit == "medium" else "same-s"):
650
+ raise SystemExit("medium requires same-l; sm-music and sm-sfx require same-s")
651
+ if args.precision in ("bf16", "fp8") and args.dit != "medium":
652
+ raise SystemExit("bf16 and fp8 precision are available only for medium")
653
+ if not 1 <= args.default_steps <= args.max_steps:
654
+ raise SystemExit("--default-steps must be within the configured steps range")
655
+ if (
656
+ not math.isfinite(args.min_duration_seconds)
657
+ or not math.isfinite(args.max_duration_seconds)
658
+ or args.min_duration_seconds < 3
659
+ or args.max_duration_seconds < args.min_duration_seconds
660
+ ):
661
+ raise SystemExit("duration limits are invalid")
662
+ if not math.isfinite(args.inference_timeout_seconds) or args.inference_timeout_seconds <= 0:
663
+ raise SystemExit("--inference-timeout-seconds must be positive")
664
+ if args.max_request_bytes <= 0 or args.max_output_bytes <= 0 or args.max_prompt_chars <= 0:
665
+ raise SystemExit("request, output, and prompt limits must be positive")
666
+ if not args.model_id.strip():
667
+ raise SystemExit("--model-id must be non-empty")
668
+ if not args.startup_self_test and args.backend != "mock":
669
+ raise SystemExit("--no-startup-self-test is permitted only with --backend mock")
670
+
671
+ logging.basicConfig(
672
+ level=logging.INFO,
673
+ format="%(asctime)s %(levelname)s %(message)s",
674
+ )
675
+ logging.info("loading backend=%s dit=%s decoder=%s", args.backend, args.dit, args.decoder)
676
+ backend: AudioBackend = (
677
+ MockBackend() if args.backend == "mock" else _load_subprocess_backend(args)
678
+ )
679
+ if args.startup_self_test:
680
+ logging.info("running startup inference self-test")
681
+ probe_seconds = max(3.0, args.min_duration_seconds)
682
+ probe_wav, probe_timing = backend.generate(
683
+ "readiness check: a clean warm analog synthesizer tone",
684
+ seconds=probe_seconds,
685
+ steps=args.default_steps,
686
+ seed=1_001,
687
+ )
688
+ _validate_wav(probe_wav, probe_seconds)
689
+ if len(probe_wav) > args.max_output_bytes:
690
+ raise RuntimeError("startup self-test output exceeds the configured limit")
691
+ if not _is_int(probe_timing.get("seed") if isinstance(probe_timing, dict) else None):
692
+ raise RuntimeError("startup self-test did not report an effective seed")
693
+ logging.info("startup inference self-test passed")
694
+ state = ServerState(
695
+ backend=backend,
696
+ backend_name=args.backend,
697
+ model_id=args.model_id,
698
+ dit=args.dit,
699
+ decoder=args.decoder,
700
+ max_request_bytes=args.max_request_bytes,
701
+ max_output_bytes=args.max_output_bytes,
702
+ max_prompt_chars=args.max_prompt_chars,
703
+ min_duration_seconds=args.min_duration_seconds,
704
+ max_duration_seconds=args.max_duration_seconds,
705
+ max_steps=args.max_steps,
706
+ default_steps=args.default_steps,
707
+ )
708
+ server = AudioHttpServer((args.host, args.port), _handler_for(state))
709
+
710
+ def request_shutdown(signum: int, frame: Any) -> None:
711
+ del signum, frame
712
+ threading.Thread(target=server.shutdown, daemon=True).start()
713
+
714
+ signal.signal(signal.SIGTERM, request_shutdown)
715
+ signal.signal(signal.SIGINT, request_shutdown)
716
+ host, port = server.server_address[:2]
717
+ print(
718
+ json.dumps(
719
+ {
720
+ "event": "listening",
721
+ "host": host,
722
+ "port": port,
723
+ "model": state.model_id,
724
+ "backend": state.backend_name,
725
+ },
726
+ separators=(",", ":"),
727
+ ),
728
+ flush=True,
729
+ )
730
+ try:
731
+ server.serve_forever(poll_interval=0.5)
732
+ finally:
733
+ server.server_close()
734
+ return 1 if state.fatal_error.is_set() else 0
735
+
736
+
737
+ if __name__ == "__main__":
738
+ raise SystemExit(main())