feat: 固化 SelfMedia 语音服务 YAML 控制面 (#2150)
* feat: 固化 SelfMedia 语音服务 YAML 控制面 * fix: 参数化 torchaudio ABI 身份 * feat: 切换 SelfMedia 语音服务到 CosyVoice3 --------- Co-authored-by: Codex <codex@local>
This commit is contained in:
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,9 @@
|
||||
bindAddr = "0.0.0.0"
|
||||
bindPort = {{CONTROL_PORT}}
|
||||
|
||||
auth.method = "token"
|
||||
auth.token = "{{ .Envs.FRP_TOKEN }}"
|
||||
|
||||
allowPorts = [
|
||||
{ single = {{REMOTE_PORT}} }
|
||||
]
|
||||
Executable
+15
@@ -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"
|
||||
Reference in New Issue
Block a user