Ctrl K
ComunidadGuías…

cosyvoice_worker.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""CosyVoice 3 sidecar — runs in the isolated `cosyvoice` conda env and exposes
a small HTTP API on 127.0.0.1:8101 so PolyTTS's CosyvoiceEngine can drive it
without importing CosyVoice's conflicting deps (CosyVoice pins torch 2.3.1; the
main PolyTTS venv runs torch 2.12 for qwen/voxcpm).

Run:  conda run -n cosyvoice python cosyvoice_worker.py
PolyTTS orchestrates VRAM: POST /load loads the model, /unload frees it.
"""
import os
import sys
import io
import gc
import threading

COSYVOICE_REPO = os.environ.get("COSYVOICE_REPO", "/home/ubuntu/CosyVoice")
MODEL_DIR = os.environ.get("COSYVOICE_MODEL_DIR",
                           f"{COSYVOICE_REPO}/pretrained_models/Fun-CosyVoice3-0.5B")
PORT = int(os.environ.get("COSYVOICE_PORT", "8101"))

# CosyVoice package + its Matcha-TTS submodule must be importable
for p in (COSYVOICE_REPO, f"{COSYVOICE_REPO}/third_party/Matcha-TTS"):
    if p not in sys.path:
        sys.path.insert(0, p)

import numpy as np
import soundfile as sf
import torch
import uvicorn
from fastapi import FastAPI
from fastapi.responses import Response, JSONResponse
from pydantic import BaseModel

from cosyvoice.cli.cosyvoice import AutoModel

SYS_PREFIX = "You are a helpful assistant"
app = FastAPI(title="CosyVoice sidecar")
_model = None
_lock = threading.Lock()


def _ensure_loaded():
    global _model
    if _model is None:
        with _lock:
            if _model is None:
                print(f"[cosyvoice] loading model from {MODEL_DIR} ...", flush=True)
                _model = AutoModel(model_dir=MODEL_DIR)
                print(f"[cosyvoice] loaded. sr={_model.sample_rate}", flush=True)
    return _model


class TTSReq(BaseModel):
    text: str
    voice_wav_path: str
    ref_text: str = ""
    instruct: str | None = None        # emotion/style instruction, e.g. "请用温暖的语气"
    speed: float | None = None          # 0.5..2.0 (passed as part of instruct if set)
    # Group N sentences per generation so prosody flows within a chunk, then join
    # chunks with a controlled pause. 1 = per-sentence (choppy); large = whole-text
    # (CosyVoice decides boundaries, can cut mid-sentence / under-pause).
    sentences_per_chunk: int | None = None
    interchunk_silence: float | None = None
    # Insert a CosyVoice control token (e.g. "[breath]" / "[quick_breath]") at
    # every within-chunk sentence boundary so the model breathes there naturally
    # (fixes under-paused sentence ends inside multi-sentence chunks).
    breath_token: str | None = None


# Generate sentence-by-sentence and join with a guaranteed inter-sentence
# silence. CosyVoice's whole-text mode groups multiple sentences per chunk and
# hard-concat ran some sentence ends into the next with insufficient pause.
INTERSENTENCE_SILENCE = float(os.environ.get("COSYVOICE_INTERSENTENCE_SILENCE", "0.35"))
_SENT_ENDERS = "。!?!?\n"


def _split_sentences(text: str):
    out, buf = [], ""
    for ch in text:
        buf += ch
        if ch in _SENT_ENDERS:
            s = buf.strip()
            if s:
                out.append(s)
            buf = ""
    if buf.strip():
        out.append(buf.strip())
    return out or [text]


def _gen_one(m, text, inst, ref_text, voice_wav_path):
    if inst:
        prompt = f"{SYS_PREFIX}. {inst}<|endofprompt|>"
        return m.inference_instruct2(text, prompt, voice_wav_path, stream=False)
    prompt = f"{SYS_PREFIX}<|endofprompt|>{ref_text}"
    return m.inference_zero_shot(text, prompt, voice_wav_path, stream=False)


def _synth(req: TTSReq):
    m = _ensure_loaded()
    inst = req.instruct or ""
    if req.speed:
        inst = (inst + " " if inst else "") + f"语速设为{req.speed}"
    sents = _split_sentences(req.text)
    spc = req.sentences_per_chunk or int(os.environ.get("COSYVOICE_SENTENCES_PER_CHUNK", "2"))
    gap_s = (req.interchunk_silence if req.interchunk_silence is not None
             else float(os.environ.get("COSYVOICE_INTERCHUNK_SILENCE", "0.5")))
    breath = req.breath_token or os.environ.get("COSYVOICE_BREATH_TOKEN", "")
    # Pack sentences into sentence-aligned chunks (no mid-sentence cuts); insert
    # a breath token between sentences within a chunk so every boundary breathes.
    joiner = f"{breath}" if breath else ""
    chunks_text = [joiner.join(sents[i:i + spc]) for i in range(0, len(sents), spc)]
    gap = torch.zeros(int(m.sample_rate * gap_s)) if len(chunks_text) > 1 else None
    with _lock:  # serialize GPU access across the whole multi-chunk job
        pieces = []
        for ct in chunks_text:
            seg = []
            for j in _gen_one(m, ct, inst, req.ref_text, req.voice_wav_path):
                t = j["tts_speech"]
                if t.dim() > 1:
                    t = t.squeeze(0)
                seg.append(t.cpu().float())
            if seg:
                pieces.append(torch.cat(seg))
    print(f"[cosyvoice] {len(sents)} sentences -> {len(chunks_text)} chunks "
          f"(<= {spc}/chunk), gap={gap_s}s, breath={breath or 'none'}", flush=True)
    if not pieces:
        return np.zeros(0, dtype=np.float32), m.sample_rate
    audio = pieces[0]
    for p in pieces[1:]:
        audio = torch.cat([audio, gap, p])
    return np.asarray(audio.numpy(), dtype=np.float32), m.sample_rate


@app.post("/load")
def load():
    _ensure_loaded()
    return {"loaded": True, "sample_rate": _model.sample_rate}


@app.post("/unload")
def unload():
    global _model
    with _lock:
        _model = None
    gc.collect()
    torch.cuda.empty_cache()
    return {"loaded": False}


@app.get("/health")
def health():
    return {"loaded": _model is not None, "model_dir": MODEL_DIR}


@app.post("/tts")
def tts(req: TTSReq):
    try:
        audio, sr = _synth(req)
    except Exception as e:
        return JSONResponse(status_code=500, content={"detail": str(e)})
    buf = io.BytesIO()
    sf.write(buf, audio, sr, subtype="PCM_16", format="WAV")
    return Response(content=buf.getvalue(), media_type="audio/wav",
                    headers={"X-Sample-Rate": str(sr)})


if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=PORT, log_level="info")