@mcoda/mswarm 0.1.101 → 0.1.103
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.
- package/dist/codali-executor.d.ts +1 -1
- package/dist/codali-executor.d.ts.map +1 -1
- package/dist/codali-executor.js.map +1 -1
- package/dist/invocation-token.d.ts +2 -0
- package/dist/invocation-token.d.ts.map +1 -1
- package/dist/invocation-token.js +10 -0
- package/dist/invocation-token.js.map +1 -1
- package/dist/runtime.d.ts +59 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +754 -28
- package/dist/runtime.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +8 -2
- package/dist/server.js.map +1 -1
- package/package.json +6 -3
- package/scripts/stable-audio-3-openai-server.py +738 -0
- package/scripts/wan22-openai-server.py +901 -0
|
@@ -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())
|