"""远端 TTS 适配器(HTTP 调 Qwen3-TTS 服务)。 **为什么是 HTTP 而不是进程内调用**:qwen-tts 和 vllm-omni/助手的依赖互斥 (transformers 4.57 vs 5.14、hf_hub 0.36 vs 1.31),装一个 venv 里会互相破坏, 实测把 vllm-omni 搞崩过。所以 TTS 跑在独立进程,用 HTTP 通信。 接口和 `LocalTTS` 保持一致(同步的 synthesize / synthesize_sentences), 这样引擎那边一行不用改——切换本地 VITS 和远端 Qwen3-TTS 只是换一个类。 实测延迟(RTX 3090,Serena 音色,非流式): 文本长度 音频 合成耗时 RTF 8 字 1.52s 1429ms 0.94 ← 短句最差 19 字 3.36s 1881ms 0.56 26 字 4.64s 2527ms 0.55 **注意短句 RTF 反而最差**:每次调用有约 1 秒的固定开销, 与文本长度无关(talker 模型自回归解码的启动成本 + 声码器解码)。 所以"让模型先说个短句"并不能降低首音延迟——反而比中长句更不划算。 流式可以绕开这个固定开销,但 qwen-tts 的封装把 codes→音频 的解码放在 全部 codes 生成完之后,拿不到增量。要走流式需要接管底层解码循环。 """ from __future__ import annotations import io import json import logging import time import urllib.error import urllib.request import wave from typing import Iterator import numpy as np from .tts import split_sentences # 复用分句逻辑 log = logging.getLogger(__name__) class RemoteTTSError(RuntimeError): pass class RemoteTTS: """通过 HTTP 调用远端 TTS 服务。接口与 LocalTTS 一致。""" def __init__(self, base_url: str, *, voice: str = "Serena", instructions: str = "用轻快活泼的语气说", language: str = "Chinese", timeout_s: float = 60.0): self.base_url = base_url.rstrip("/") self.voice = voice self.instructions = instructions self.language = language self.timeout_s = timeout_s self.sample_rate = 24000 # Qwen3-TTS 输出 24kHz,稍后按需重采样 self._checked = False # ------------------------------------------------------------ 健康检查 def health(self) -> tuple[bool, str]: try: with urllib.request.urlopen(f"{self.base_url}/health", timeout=10) as r: d = json.loads(r.read().decode()) if d.get("model_loaded"): return True, f"模型已加载,默认音色 {d.get('default_voice')}" return False, f"服务在但模型未加载:{d.get('status')}" except Exception as e: # noqa: BLE001 return False, f"{type(e).__name__}: {e}" def list_voices(self) -> dict: with urllib.request.urlopen(f"{self.base_url}/v1/voices", timeout=10) as r: return json.loads(r.read().decode()) # ------------------------------------------------------------ 合成 def synthesize(self, text: str, *, instructions: str | None = None) -> np.ndarray: """合成一段文本,返回 float32 单声道。""" text = (text or "").strip() if not text: return np.zeros(0, dtype=np.float32) payload = json.dumps({ "input": text, "voice": self.voice, "instructions": instructions if instructions is not None else self.instructions, "language": self.language, "response_format": "wav", }).encode("utf-8") req = urllib.request.Request( f"{self.base_url}/v1/audio/speech", data=payload, headers={"Content-Type": "application/json"}, method="POST", ) t0 = time.perf_counter() try: with urllib.request.urlopen(req, timeout=self.timeout_s) as r: wav_bytes = r.read() sr = int(r.headers.get("X-Sample-Rate", self.sample_rate)) gen_ms = r.headers.get("X-Gen-Ms", "?") rtf = r.headers.get("X-RTF", "?") except urllib.error.HTTPError as e: detail = e.read().decode("utf-8", "replace")[:200] raise RemoteTTSError(f"TTS 服务 HTTP {e.code}: {detail}") from e except Exception as e: # noqa: BLE001 raise RemoteTTSError(f"TTS 服务不可达:{type(e).__name__}: {e}") from e self.sample_rate = sr log.debug("合成 %d 字:服务端 %sms(RTF %s),往返 %.0fms", len(text), gen_ms, rtf, (time.perf_counter() - t0) * 1000) with wave.open(io.BytesIO(wav_bytes), "rb") as w: n = w.getnframes() ch = w.getnchannels() pcm = w.readframes(n) audio = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0 if ch > 1: # 服务端是单声道,防御一下 audio = audio.reshape(-1, ch).mean(axis=1) return audio def synthesize_sentences(self, text: str) -> Iterator[tuple[str, np.ndarray]]: """按句合成,逐句返回。调用方拿到第一句就能开播。""" for seg in split_sentences(text): yield seg, self.synthesize(seg) def synthesize_all(self, text: str) -> np.ndarray: chunks = [a for _, a in self.synthesize_sentences(text) if len(a)] if not chunks: return np.zeros(0, dtype=np.float32) return np.concatenate(chunks) __all__ = ["RemoteTTS", "RemoteTTSError"]