Files
2026-07-15 19:32:21 +02:00

183 lines
6.6 KiB
Python

import io
import os
import threading
import time
from contextlib import asynccontextmanager
import torch
import torchaudio
import onnxruntime
import uvicorn
from cosyvoice.cli.cosyvoice import AutoModel
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel, Field
class SpeechRequest(BaseModel):
model: str | None = None
input: str = Field(min_length=1)
voice: str | None = None
response_format: str = "wav"
speed: float = 1.0
instructions: str | None = None
instruct: str | None = None
class Runtime:
def __init__(self) -> None:
self.model = None
self.loaded_at = None
self.speech_tokenizer_providers = None
self.error = None
self.lock = threading.Lock()
def load(self) -> None:
try:
if not torch.cuda.is_available():
raise RuntimeError("CUDA is unavailable")
device = os.environ["VOICE_DEVICE"]
if device != "cuda:0":
raise RuntimeError("VOICE_DEVICE must be cuda:0")
if not env_bool("VOICE_FP16"):
raise RuntimeError("VOICE_FP16 must remain enabled for core CUDA inference")
torch.cuda.set_device(int(device.split(":", 1)[1]))
original_inference_session = onnxruntime.InferenceSession
speech_tokenizer_provider = os.environ["VOICE_SPEECH_TOKENIZER_PROVIDER"]
if speech_tokenizer_provider != "CPUExecutionProvider":
raise RuntimeError("VOICE_SPEECH_TOKENIZER_PROVIDER must be CPUExecutionProvider")
def inference_session(*args, **kwargs):
if kwargs.get("providers") == ["CUDAExecutionProvider"]:
kwargs["providers"] = [speech_tokenizer_provider]
return original_inference_session(*args, **kwargs)
onnxruntime.InferenceSession = inference_session
try:
self.model = AutoModel(
model_dir=os.environ["MODEL_DIR"],
load_trt=env_bool("VOICE_LOAD_TRT"),
load_vllm=env_bool("VOICE_LOAD_VLLM"),
fp16=env_bool("VOICE_FP16"),
)
finally:
onnxruntime.InferenceSession = original_inference_session
self.speech_tokenizer_providers = self.model.frontend.speech_tokenizer_session.get_providers()
if self.speech_tokenizer_providers != [speech_tokenizer_provider]:
raise RuntimeError(f"speech tokenizer provider mismatch: {self.speech_tokenizer_providers}")
self.loaded_at = time.time()
except Exception as error:
self.error = f"{type(error).__name__}: {error}"
raise
def env_bool(name: str) -> bool:
value = os.environ[name].lower()
if value not in {"true", "false"}:
raise RuntimeError(f"{name} must be true or false")
return value == "true"
runtime = Runtime()
@asynccontextmanager
async def lifespan(_: FastAPI):
runtime.load()
yield
app = FastAPI(title="SelfMedia Voice", version="1", lifespan=lifespan)
INSTRUCTION_END_TOKEN = "<|endofprompt|>"
def normalize_instruction(value: str | None) -> str | None:
if value is None or not value.strip():
return None
normalized = value.strip()
if INSTRUCTION_END_TOKEN in normalized:
return normalized
return f"You are a helpful assistant. {normalized}{INSTRUCTION_END_TOKEN}"
@app.get("/health")
def health() -> dict:
return {
"ok": runtime.model is not None and runtime.error is None,
"model": os.environ["MODEL_ID"],
"modelRef": os.environ["MODEL_REF"],
"sourceRef": os.environ["SOURCE_REF"],
"torch": torch.__version__,
"torchaudio": torchaudio.__version__,
"cuda": torch.version.cuda,
"gpu": torch.cuda.get_device_name(torch.cuda.current_device()) if torch.cuda.is_available() else None,
"coreInferenceDevice": os.environ["VOICE_DEVICE"],
"coreInferenceFp16": env_bool("VOICE_FP16"),
"loadTrt": env_bool("VOICE_LOAD_TRT"),
"loadVllm": env_bool("VOICE_LOAD_VLLM"),
"workers": int(os.environ["VOICE_WORKERS"]),
"concurrency": os.environ["VOICE_CONCURRENCY"],
"speechTokenizerProviders": runtime.speech_tokenizer_providers,
"loadedAt": runtime.loaded_at,
"error": runtime.error,
}
@app.post("/v1/audio/speech")
def speech(request: SpeechRequest) -> Response:
if request.response_format.lower() != "wav":
raise HTTPException(status_code=400, detail="response_format must be wav")
if not float(os.environ["VOICE_SPEED_MIN"]) <= request.speed <= float(os.environ["VOICE_SPEED_MAX"]):
raise HTTPException(status_code=400, detail="speed is outside the configured range")
if runtime.model is None:
raise HTTPException(status_code=503, detail=runtime.error or "model is not ready")
if request.voice not in {None, os.environ["REFERENCE_VOICE_ID"]}:
raise HTTPException(status_code=400, detail="voice must select the configured reference voice")
started = time.perf_counter()
instruction = normalize_instruction(request.instructions or request.instruct)
with runtime.lock:
inference = (
runtime.model.inference_instruct2(
request.input,
instruction,
os.environ["PROMPT_WAV"],
stream=False,
speed=request.speed,
)
if instruction
else runtime.model.inference_zero_shot(
request.input,
os.environ["PROMPT_TEXT"],
os.environ["PROMPT_WAV"],
stream=False,
speed=request.speed,
)
)
chunks = [
output["tts_speech"].cpu()
for output in inference
]
if not chunks:
raise HTTPException(status_code=500, detail="empty inference result")
waveform = torch.cat(chunks, dim=1)
output = io.BytesIO()
torchaudio.save(output, waveform, runtime.model.sample_rate, format="wav")
return Response(
content=output.getvalue(),
media_type="audio/wav",
headers={
"X-Inference-Seconds": f"{time.perf_counter() - started:.6f}",
"X-Sample-Rate": str(runtime.model.sample_rate),
"X-Inference-Mode": "instruct2" if instruction else "zero-shot",
},
)
if __name__ == "__main__":
workers = int(os.environ["VOICE_WORKERS"])
if workers != 1:
raise RuntimeError("VOICE_WORKERS must be 1 for the single-GPU model runtime")
uvicorn.run(app, host="0.0.0.0", port=int(os.environ["VOICE_PORT"]), workers=workers)