#!/usr/bin/env python3 """ VoxCPM 流式 TTS 测试脚本。 用法: python test_tts.py # 默认测试 python test_tts.py --text "你好世界" # 自定义文本 python test_tts.py --url http://x.x.x.x:8902 # 远程服务器 python test_tts.py --save test_output.wav # 保存为 WAV 文件 """ import argparse import base64 import json import struct import sys import time import requests def main(): parser = argparse.ArgumentParser(description="VoxCPM 流式 TTS 测试") parser.add_argument("--url", default="http://0.0.0.0:8902", help="TTS 服务地址") parser.add_argument("--text", default="你好,这是一个流式语音合成测试。今天天气不错,适合出去散步。", help="测试文本") parser.add_argument("--save", default="", help="保存 WAV 文件路径(不指定则不保存)") parser.add_argument("--play", action="store_true", help="本地播放(需要 pyaudio)") args = parser.parse_args() base_url = args.url.rstrip("/") request_id = f"test-{int(time.time() * 1000)}" # 1. 健康检查 print(f"→ 健康检查 {base_url}/health ...") try: r = requests.get(f"{base_url}/health", timeout=5) r.raise_for_status() print(f" 状态: {r.json()}") except Exception as e: print(f" ❌ 无法连接 {base_url}: {e}") sys.exit(1) # 2. 流式 TTS 请求 print(f"→ 流式 TTS 请求: \"{args.text}\"") t_start = time.perf_counter() sample_rate = 0 all_pcm = bytearray() total_samples = 0 total_chunks = 0 try: resp = requests.post( f"{base_url}/stream_tts", json={"text": args.text, "request_id": request_id}, stream=True, timeout=300, ) resp.raise_for_status() for line in resp.iter_lines(decode_unicode=True): if not line or not line.startswith("data: "): continue payload = json.loads(line[len("data: "):]) event_type = payload.get("type", "") if event_type == "audio.start": sample_rate = payload.get("sample_rate", 0) num_segments = payload.get("num_segments", 0) print(f" audio.start | 采样率={sample_rate}Hz 句数={num_segments}") elif event_type == "audio.chunk": b64_data = payload.get("data", "") idx = payload.get("index", 0) num_s = payload.get("num_samples", 0) if b64_data: pcm = base64.b64decode(b64_data) all_pcm.extend(pcm) total_samples += num_s total_chunks += 1 # 进度提示 if idx % 5 == 0 and b64_data: dur = total_samples / sample_rate if sample_rate else 0 print(f" audio.chunk | #{idx} 累计={dur:.1f}s") elif event_type == "audio.done": total = payload.get("total_segments", 0) print(f" audio.done | 共{total}句 {total_samples}样本 {total_chunks}块") elif event_type == "audio.error": print(f" ❌ audio.error: {payload.get('message', '')}") sys.exit(1) elif event_type == "audio.cancelled": print(f" ⚠ audio.cancelled") break elif event_type == "done": pass # 流结束 except requests.exceptions.ConnectionError as e: print(f" ❌ 连接失败: {e}") sys.exit(1) except Exception as e: print(f" ❌ 错误: {e}") sys.exit(1) t_elapsed = time.perf_counter() - t_start duration = total_samples / sample_rate if sample_rate else 0 rtf = t_elapsed / duration if duration > 0 else 0 print(f"\n{'='*50}") print(f" 总耗时: {t_elapsed:.2f}s") print(f" 音频时长: {duration:.2f}s") print(f" RTF: {rtf:.2f} (越小越好,<1 表示比实时快)") print(f" 采样率: {sample_rate} Hz") print(f" 总样本数: {total_samples}") print(f" PCM 大小: {len(all_pcm)} bytes") print(f"{'='*50}") if not all_pcm: print(" ⚠ 未收到任何音频数据") sys.exit(1) # 3. 保存 WAV if args.save: _save_wav(args.save, all_pcm, sample_rate) print(f"→ 已保存: {args.save}") # 4. 本地播放 if args.play: _play_pcm(all_pcm, sample_rate) def _save_wav(filepath: str, pcm_f32: bytearray, sample_rate: int): """将 PCM f32le 转为 16-bit WAV 保存。""" import io import wave import numpy as np arr = np.frombuffer(pcm_f32, dtype=np.float32) # 转为 int16 arr_i16 = (arr * 32767).clip(-32768, 32767).astype(np.int16) with wave.open(filepath, "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(sample_rate) wf.writeframes(arr_i16.tobytes()) def _play_pcm(pcm_f32: bytearray, sample_rate: int): """通过 pyaudio 播放 PCM 音频。""" try: import pyaudio import numpy as np except ImportError: print(" ⚠ 需要安装 pyaudio: pip install pyaudio") return arr = np.frombuffer(pcm_f32, dtype=np.float32) arr_i16 = (arr * 32767).clip(-32768, 32767).astype(np.int16) p = pyaudio.PyAudio() stream = p.open( format=pyaudio.paInt16, channels=1, rate=sample_rate, output=True, ) print("→ 播放中...") stream.write(arr_i16.tobytes()) stream.stop_stream() stream.close() p.terminate() print("→ 播放完毕") if __name__ == "__main__": main()