feat: 固化 SelfMedia 语音服务 YAML 控制面 (#2150)
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Failed
Pipelines as Code CI / unidesk-host- Success

* feat: 固化 SelfMedia 语音服务 YAML 控制面

* fix: 参数化 torchaudio ABI 身份

* feat: 切换 SelfMedia 语音服务到 CosyVoice3

---------

Co-authored-by: Codex <codex@local>
This commit is contained in:
Lyon
2026-07-15 17:17:44 +08:00
committed by GitHub
parent 607a533625
commit 58b82fe009
12 changed files with 985 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
ARG SOURCE_REPOSITORY
ARG SOURCE_REF
ARG SOURCE_SUBMODULES
ARG REFERENCE_WAV_PATH
ARG REFERENCE_WAV_SHA256
ARG PIP_VERSION
ARG PIP_INDEX_URL
ARG SETUPTOOLS_INSTALL_SPEC
ARG TORCH_VERSION_IDENTITY
ARG TORCH_LIBRARY_PATH
ARG TORCHAUDIO_VERSION_IDENTITY
ARG OPENAI_WHISPER_INSTALL_SPEC
ARG ONNXRUNTIME_GPU_INSTALL_SPEC
ARG ONNXRUNTIME_GPU_INDEX_URL
ENV DEBIAN_FRONTEND=noninteractive \
PIP_INDEX_URL=${PIP_INDEX_URL} \
PYTHONPATH=/opt/CosyVoice:/opt/CosyVoice/third_party/Matcha-TTS \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl ffmpeg git git-lfs libsox-dev sox \
&& rm -rf /var/lib/apt/lists/* \
&& python -m pip install "pip==${PIP_VERSION}" "${SETUPTOOLS_INSTALL_SPEC}" wheel
RUN git clone --no-checkout "${SOURCE_REPOSITORY}" /opt/CosyVoice \
&& git -C /opt/CosyVoice fetch --depth 1 origin "${SOURCE_REF}" \
&& git -C /opt/CosyVoice checkout --detach "${SOURCE_REF}" \
&& test "$(git -C /opt/CosyVoice rev-parse HEAD)" = "${SOURCE_REF}" \
&& if [ "${SOURCE_SUBMODULES}" = recursive ]; then git -C /opt/CosyVoice submodule update --init --recursive --depth 1; fi \
&& printf '%s %s\n' "${REFERENCE_WAV_SHA256}" "/opt/CosyVoice/${REFERENCE_WAV_PATH}" | sha256sum -c -
RUN sed -e '/^torch==/d' \
-e '/^deepspeed==/d' \
-e '/^tensorrt-cu12/d' \
-e '/^openai-whisper==/d' \
-e '/^onnxruntime-gpu==/d' \
/opt/CosyVoice/requirements.txt > /tmp/runtime-requirements.txt \
&& python -m pip install "${OPENAI_WHISPER_INSTALL_SPEC}" --no-build-isolation
RUN python -m pip install --index-url "${ONNXRUNTIME_GPU_INDEX_URL}" "${ONNXRUNTIME_GPU_INSTALL_SPEC}"
RUN python -m pip install -r /tmp/runtime-requirements.txt \
&& test -d "${TORCH_LIBRARY_PATH}" \
&& python -c 'import sys, torch, torchaudio; expected_torch=sys.argv[1]; expected_audio=sys.argv[2]; assert torch.__version__.split("+")[0] == expected_torch, f"torch identity mismatch: expected={expected_torch} actual={torch.__version__}"; assert torchaudio.__version__.split("+")[0] == expected_audio, f"torchaudio identity mismatch: expected={expected_audio} actual={torchaudio.__version__}"; from cosyvoice.cli.cosyvoice import AutoModel' "${TORCH_VERSION_IDENTITY}" "${TORCHAUDIO_VERSION_IDENTITY}"
LABEL ai.unidesk.source.ref="${SOURCE_REF}"
LABEL ai.unidesk.torchaudio.version-identity="${TORCHAUDIO_VERSION_IDENTITY}"
WORKDIR /app
COPY api.py /app/api.py
COPY model-init.sh /app/model-init.sh
EXPOSE 18327
CMD ["python", "/app/api.py"]
+154
View File
@@ -0,0 +1,154 @@
import io
import os
import threading
import time
from contextlib import asynccontextmanager
import torch
import torchaudio
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.onnx_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 not device.startswith("cuda:"):
raise RuntimeError("VOICE_DEVICE must select a CUDA device")
torch.cuda.set_device(int(device.split(":", 1)[1]))
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"),
)
self.onnx_providers = self.model.frontend.speech_tokenizer_session.get_providers()
if "CUDAExecutionProvider" not in self.onnx_providers:
raise RuntimeError(f"CUDAExecutionProvider was not instantiated: {self.onnx_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)
@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,
"device": os.environ["VOICE_DEVICE"],
"fp16": 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"],
"onnxProviders": runtime.onnx_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 = 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)
+94
View File
@@ -0,0 +1,94 @@
services:
model-init:
image: ${VOICE_IMAGE}
build: &voice-build
context: .
dockerfile: Containerfile
args:
BASE_IMAGE: ${BASE_IMAGE}
SOURCE_REPOSITORY: ${SOURCE_REPOSITORY}
SOURCE_REF: ${SOURCE_REF}
SOURCE_SUBMODULES: ${SOURCE_SUBMODULES}
REFERENCE_WAV_PATH: ${REFERENCE_WAV_PATH}
REFERENCE_WAV_SHA256: ${REFERENCE_WAV_SHA256}
PIP_VERSION: ${PIP_VERSION}
PIP_INDEX_URL: ${PIP_INDEX_URL}
SETUPTOOLS_INSTALL_SPEC: ${SETUPTOOLS_INSTALL_SPEC}
TORCH_VERSION_IDENTITY: ${TORCH_VERSION_IDENTITY}
TORCH_LIBRARY_PATH: ${TORCH_LIBRARY_PATH}
TORCHAUDIO_VERSION_IDENTITY: ${TORCHAUDIO_VERSION_IDENTITY}
OPENAI_WHISPER_INSTALL_SPEC: ${OPENAI_WHISPER_INSTALL_SPEC}
ONNXRUNTIME_GPU_INSTALL_SPEC: ${ONNXRUNTIME_GPU_INSTALL_SPEC}
ONNXRUNTIME_GPU_INDEX_URL: ${ONNXRUNTIME_GPU_INDEX_URL}
restart: "no"
environment:
MODEL_REPOSITORY: ${MODEL_REPOSITORY}
MODEL_BRANCH: ${MODEL_BRANCH}
MODEL_REF: ${MODEL_REF}
MODEL_REQUIRE_BRANCH_HEAD_MATCH: ${MODEL_REQUIRE_BRANCH_HEAD_MATCH}
command: [/app/model-init.sh]
volumes:
- ${VOICE_CACHE_DIR}/model:/model
logging: &voice-logs
driver: json-file
options:
max-size: 20m
max-file: "3"
voice:
image: ${VOICE_IMAGE}
build: *voice-build
restart: unless-stopped
gpus: all
shm_size: 4gb
deploy:
replicas: ${VOICE_REPLICAS}
depends_on:
model-init:
condition: service_completed_successfully
ports:
- ${VOICE_BIND_ADDRESS}:${VOICE_PORT}:${VOICE_PORT}
environment:
MODEL_ID: ${MODEL_ID}
MODEL_DIR: /model
MODEL_REF: ${MODEL_REF}
SOURCE_REF: ${SOURCE_REF}
PROMPT_WAV: /opt/CosyVoice/${REFERENCE_WAV_PATH}
PROMPT_TEXT: ${REFERENCE_PROMPT_TEXT}
REFERENCE_VOICE_ID: ${REFERENCE_VOICE_ID}
VOICE_DEVICE: ${VOICE_DEVICE}
VOICE_FP16: ${VOICE_FP16}
VOICE_LOAD_TRT: ${VOICE_LOAD_TRT}
VOICE_LOAD_VLLM: ${VOICE_LOAD_VLLM}
VOICE_WORKERS: ${VOICE_WORKERS}
VOICE_CONCURRENCY: ${VOICE_CONCURRENCY}
VOICE_SPEED_MIN: ${VOICE_SPEED_MIN}
VOICE_SPEED_MAX: ${VOICE_SPEED_MAX}
VOICE_PORT: ${VOICE_PORT}
PYTORCH_CUDA_ALLOC_CONF: ${PYTORCH_CUDA_ALLOC_CONF}
CUDA_MODULE_LOADING: ${CUDA_MODULE_LOADING}
LD_LIBRARY_PATH: ${TORCH_LIBRARY_PATH}
volumes:
- ${VOICE_CACHE_DIR}/model:/model:ro
- ${VOICE_DATA_DIR}:/data
healthcheck:
test: [CMD, curl, --fail, --silent, http://127.0.0.1:${VOICE_PORT}/health]
interval: 30s
timeout: 10s
retries: 20
start_period: 10m
logging: *voice-logs
frpc:
image: ${FRPC_IMAGE}
restart: unless-stopped
depends_on:
voice:
condition: service_healthy
env_file:
- secret.env
command: [-c, /etc/frp/frpc.toml]
volumes:
- ./frpc.toml:/etc/frp/frpc.toml:ro
network_mode: host
logging: *voice-logs
+17
View File
@@ -0,0 +1,17 @@
services:
frps:
image: ${FRPS_IMAGE}
restart: unless-stopped
env_file:
- secret.env
command: [-c, /etc/frp/frps.toml]
ports:
- ${FRPS_CONTROL_PORT}:${FRPS_CONTROL_PORT}/tcp
- ${FRP_REMOTE_PORT}:${FRP_REMOTE_PORT}/tcp
volumes:
- ./frps.toml:/etc/frp/frps.toml:ro
logging:
driver: json-file
options:
max-size: 20m
max-file: "3"
+12
View File
@@ -0,0 +1,12 @@
serverAddr = "{{SERVER_ADDRESS}}"
serverPort = {{CONTROL_PORT}}
auth.method = "token"
auth.token = "{{ .Envs.FRP_TOKEN }}"
[[proxies]]
name = "{{PROXY_NAME}}"
type = "tcp"
localIP = "{{LOCAL_ADDRESS}}"
localPort = {{LOCAL_PORT}}
remotePort = {{REMOTE_PORT}}
+9
View File
@@ -0,0 +1,9 @@
bindAddr = "0.0.0.0"
bindPort = {{CONTROL_PORT}}
auth.method = "token"
auth.token = "{{ .Envs.FRP_TOKEN }}"
allowPorts = [
{ single = {{REMOTE_PORT}} }
]
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
if [ ! -d /model/.git ]; then
find /model -mindepth 1 -maxdepth 1 -exec rm -rf {} +
GIT_LFS_SKIP_SMUDGE=1 git clone --no-checkout "$MODEL_REPOSITORY" /model
fi
git -C /model fetch --depth 1 origin "$MODEL_BRANCH"
if [ "$MODEL_REQUIRE_BRANCH_HEAD_MATCH" = true ]; then
test "$(git -C /model rev-parse FETCH_HEAD)" = "$MODEL_REF"
fi
GIT_LFS_SKIP_SMUDGE=1 git -C /model checkout --detach "$MODEL_REF"
git -C /model lfs pull
test "$(git -C /model rev-parse HEAD)" = "$MODEL_REF"