| |
| """OpenAI-compatible shim in front of sglang-omni s2-pro (port 8001). |
| |
| Keeps the public contract: model s2pro-egy, named voices, 24 kHz output. |
| POST /v1/audio/speech {model, input, voice, response_format wav|pcm, stream, |
| sample_rate=24000, temperature, top_p} |
| GET /health /v1/models /v1/voices |
| """ |
|
|
| import argparse |
| import io |
| import json |
| import struct |
| import time |
| import urllib.request |
| from pathlib import Path |
|
|
| import numpy as np |
| import soundfile as sf |
| import uvicorn |
| from fastapi import FastAPI, HTTPException, Response |
| from fastapi.responses import StreamingResponse |
| from loguru import logger |
| from pydantic import BaseModel |
|
|
| MODEL_ID = "s2pro-egy" |
| VOICES_DIR = Path("/opt/work/voices") |
| UPSTREAM = "http://localhost:8001/v1/audio/speech" |
| UPSTREAM_MODEL = "/opt/work/checkpoints/s2pro-egy-merged" |
| SRC_RATE = 44100 |
|
|
| app = FastAPI() |
| VOICES = {} |
|
|
|
|
| def load_voices(): |
| VOICES.clear() |
| for wav in sorted(VOICES_DIR.glob("*.wav")): |
| txt = wav.with_suffix(".txt") |
| if txt.exists(): |
| VOICES[wav.stem] = { |
| "audio_path": str(wav), |
| "text": txt.read_text(encoding="utf-8").strip(), |
| } |
| logger.info(f"voices: {list(VOICES)}") |
|
|
|
|
| def resample(audio: np.ndarray, src: int, dst: int) -> np.ndarray: |
| if src == dst: |
| return audio |
| import torch |
| import torchaudio.functional as AF |
| t = torch.from_numpy(np.ascontiguousarray(audio, dtype=np.float32)) |
| return AF.resample(t, src, dst).numpy() |
|
|
|
|
| def wav_stream_header(sample_rate: int, channels: int = 1, bits: int = 16) -> bytes: |
| byte_rate = sample_rate * channels * bits // 8 |
| block_align = channels * bits // 8 |
| return b"".join([ |
| b"RIFF", struct.pack("<I", 0xFFFFFFFF), b"WAVE", |
| b"fmt ", struct.pack("<IHHIIHH", 16, 1, channels, sample_rate, |
| byte_rate, block_align, bits), |
| b"data", struct.pack("<I", 0xFFFFFFFF), |
| ]) |
|
|
|
|
| class SpeechRequest(BaseModel): |
| model: str = MODEL_ID |
| input: str |
| voice: str = "masry" |
| response_format: str = "wav" |
| stream: bool = False |
| sample_rate: int = 24000 |
| temperature: float = 0.8 |
| top_p: float = 0.8 |
| speed: float | None = None |
|
|
|
|
| @app.get("/health") |
| def health(): |
| return {"status": "ok", "model": MODEL_ID, "engine": "sglang-omni", |
| "voices": list(VOICES)} |
|
|
|
|
| @app.get("/v1/models") |
| def models(): |
| return {"object": "list", |
| "data": [{"id": MODEL_ID, "object": "model", "owned_by": "olimi"}]} |
|
|
|
|
| @app.get("/v1/voices") |
| def voices(): |
| return {"voices": list(VOICES)} |
|
|
|
|
| @app.post("/v1/audio/speech") |
| def speech(req: SpeechRequest): |
| if req.voice not in VOICES: |
| raise HTTPException(400, f"unknown voice '{req.voice}'; have {list(VOICES)}") |
| if req.response_format not in ("wav", "pcm"): |
| raise HTTPException(400, "response_format must be wav or pcm") |
|
|
| body = { |
| "model": UPSTREAM_MODEL, |
| "voice": "default", |
| "input": req.input, |
| "references": [VOICES[req.voice]], |
| "temperature": req.temperature, |
| "top_p": req.top_p, |
| "stream": req.stream, |
| } |
| if req.stream: |
| body["response_format"] = "pcm" |
| t0 = time.time() |
| up = urllib.request.Request( |
| UPSTREAM, data=json.dumps(body).encode(), |
| headers={"Content-Type": "application/json"}) |
|
|
| if not req.stream: |
| try: |
| with urllib.request.urlopen(up, timeout=300) as r: |
| raw = r.read() |
| except urllib.error.HTTPError as e: |
| raise HTTPException(e.code, e.read().decode()[:300]) |
| audio, sr = sf.read(io.BytesIO(raw), dtype="float32") |
| audio = resample(audio, sr, req.sample_rate) |
| logger.info(f"non-stream {len(audio)/req.sample_rate:.2f}s in {time.time()-t0:.2f}s") |
| if req.response_format == "pcm": |
| return Response((np.clip(audio, -1, 1) * 32767).astype("<i2").tobytes(), |
| media_type="audio/pcm") |
| buf = io.BytesIO() |
| sf.write(buf, audio, req.sample_rate, format="WAV", subtype="PCM_16") |
| return Response(buf.getvalue(), media_type="audio/wav") |
|
|
| def gen(): |
| if req.response_format == "wav": |
| yield wav_stream_header(req.sample_rate) |
| first = True |
| carry = b"" |
| try: |
| with urllib.request.urlopen(up, timeout=300) as r: |
| while True: |
| chunk = r.read(32768) |
| if not chunk: |
| break |
| data = carry + chunk |
| usable = len(data) - (len(data) % 2) |
| carry = data[usable:] |
| if usable == 0: |
| continue |
| audio = np.frombuffer(data[:usable], dtype="<i2").astype(np.float32) / 32768.0 |
| audio = resample(audio, SRC_RATE, req.sample_rate) |
| if first: |
| logger.info(f"TTFA {time.time()-t0:.2f}s") |
| first = False |
| yield (np.clip(audio, -1, 1) * 32767).astype("<i2").tobytes() |
| except urllib.error.HTTPError as e: |
| logger.error(f"upstream {e.code}: {e.read()[:200]}") |
|
|
| media = "audio/wav" if req.response_format == "wav" else "audio/pcm" |
| return StreamingResponse(gen(), media_type=media) |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--port", type=int, default=8000) |
| args = ap.parse_args() |
| load_voices() |
| logger.info(f"shim ready on :{args.port} -> {UPSTREAM}") |
| uvicorn.run(app, host="0.0.0.0", port=args.port, log_level="warning") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|