Fish Speech 1.5开发者工具链:CLI命令行工具封装与自动化测试框架

如果你已经体验过Fish Speech 1.5的Web界面,觉得它生成语音又快又好,那么接下来我要分享的内容,会让你在开发效率上再上一个台阶。想象一下,你不再需要每次都打开浏览器、输入文字、点击按钮,而是用一行命令就能生成语音,还能批量处理成百上千个文本文件,甚至自动测试模型在不同场景下的表现。

这就是开发者工具链的魅力。今天,我们不聊怎么用界面,而是深入后台,看看如何把Fish Speech 1.5变成一个更强大、更自动化的开发工具。我会带你从零开始,封装一个命令行工具,并搭建一个自动化测试框架,让你真正把TTS能力集成到自己的项目中。

1. 为什么需要开发者工具链?

在开始动手之前,我们先搞清楚一个问题:已经有了好用的Web界面,为什么还要折腾命令行和自动化测试?

场景一:批量处理 你手头有100篇产品说明文档,需要全部转换成语音。用Web界面?你得复制粘贴100次,点击100次按钮,下载100个文件。这显然不现实。

场景二:集成到其他系统 你的聊天机器人需要语音回复功能。你不可能让机器人去打开浏览器操作,它需要一个程序化的接口来调用。

场景三:持续验证 每次模型更新后,你怎么知道它还能正常工作?靠人工一个个测试?太慢了,而且容易遗漏。

这就是开发者工具链要解决的问题:效率、集成、可靠性。通过命令行工具,你可以用脚本批量处理;通过自动化测试,你可以确保每次更新都不会破坏原有功能。

2. 环境准备与基础API调用

在封装自己的工具之前,我们先看看Fish Speech 1.5提供了哪些原生能力。镜像已经内置了完整的API服务,这是我们一切工作的基础。

2.1 检查API服务状态

首先,确保你的Fish Speech实例正在运行。打开终端,连接到你的实例,检查服务是否正常:

# 检查后端API服务(端口7861)
curl -I http://127.0.0.1:7861/v1/tts

# 检查前端WebUI服务(端口7860)
curl -I http://127.0.0.1:7860

如果看到HTTP/1.1 200 OK或者HTTP/1.1 405 Method Not Allowed(对于GET请求到POST端点),说明服务正常。405是正常的,因为/v1/tts只接受POST请求。

2.2 基础API调用示例

让我们先写一个最简单的Python脚本来调用API:

# basic_api_call.py
import requests
import json
import soundfile as sf
import io

def generate_speech(text, output_path="output.wav"):
    """
    调用Fish Speech API生成语音
    
    参数:
        text: 要合成的文本
        output_path: 输出音频文件路径
    """
    # API端点
    url = "http://127.0.0.1:7861/v1/tts"
    
    # 请求参数
    payload = {
        "text": text,
        "reference_id": None,  # 不使用参考音色
        "max_new_tokens": 1024  # 最大token数
    }
    
    # 设置请求头
    headers = {
        "Content-Type": "application/json"
    }
    
    try:
        print(f"正在生成语音: {text[:50]}...")
        
        # 发送POST请求
        response = requests.post(url, json=payload, headers=headers)
        
        # 检查响应状态
        if response.status_code == 200:
            # 保存音频文件
            with open(output_path, "wb") as f:
                f.write(response.content)
            print(f"✅ 语音生成成功,保存到: {output_path}")
            return True
        else:
            print(f"❌ 请求失败,状态码: {response.status_code}")
            print(f"错误信息: {response.text}")
            return False
            
    except requests.exceptions.ConnectionError:
        print("❌ 无法连接到API服务,请检查服务是否启动")
        return False
    except Exception as e:
        print(f"❌ 发生未知错误: {str(e)}")
        return False

# 测试调用
if __name__ == "__main__":
    # 测试文本
    test_text = "你好,这是一个Fish Speech API调用测试。"
    
    # 生成语音
    success = generate_speech(test_text, "test_output.wav")
    
    if success:
        print("测试完成!")
    else:
        print("测试失败,请检查问题。")

运行这个脚本,你会在当前目录得到一个test_output.wav文件。这就是最基础的API调用方式,但每次都要写这么多代码,显然不够方便。

3. 封装CLI命令行工具

现在,我们来创建一个更友好的命令行工具。目标是:通过一行命令就能生成语音,支持各种参数配置,还能批量处理。

3.1 设计命令行接口

一个好的CLI工具应该:

  1. 命令简单易记
  2. 参数清晰明了
  3. 有详细的帮助信息
  4. 支持默认值和快捷方式

我设计了一个叫fish-tts的命令行工具,它支持以下功能:

# 基础用法:生成单个语音
fish-tts generate "你好,世界" -o hello.wav

# 使用参考音频进行音色克隆
fish-tts generate "早上好" -r reference.wav -o morning.wav

# 批量处理文本文件
fish-tts batch input.txt -o output_dir/

# 从标准输入读取文本
echo "这是一个测试" | fish-tts generate -o test.wav

3.2 实现CLI工具

让我们一步步实现这个工具。首先创建项目结构:

fish-speech-cli/
├── fish_tts/
│   ├── __init__.py
│   ├── cli.py          # 命令行入口
│   ├── api_client.py   # API客户端
│   ├── batch_processor.py # 批量处理器
│   └── utils.py        # 工具函数
├── setup.py
├── requirements.txt
└── README.md
3.2.1 API客户端封装

首先,我们封装一个更健壮的API客户端:

# fish_tts/api_client.py
import requests
import json
import time
from typing import Optional, Dict, Any
from pathlib import Path

class FishSpeechClient:
    """Fish Speech API客户端"""
    
    def __init__(self, base_url: str = "http://127.0.0.1:7861"):
        """
        初始化客户端
        
        参数:
            base_url: API服务地址,默认本地7861端口
        """
        self.base_url = base_url.rstrip('/')
        self.api_url = f"{self.base_url}/v1/tts"
        self.session = requests.Session()
        
        # 设置超时时间
        self.timeout = 30  # 30秒超时
        
    def check_health(self) -> bool:
        """检查API服务是否健康"""
        try:
            response = self.session.get(f"{self.base_url}/docs", timeout=5)
            return response.status_code == 200
        except:
            return False
    
    def generate_speech(
        self,
        text: str,
        output_path: Optional[str] = None,
        reference_audio: Optional[str] = None,
        max_new_tokens: int = 1024,
        temperature: float = 0.7,
        retry_times: int = 3
    ) -> Optional[bytes]:
        """
        生成语音
        
        参数:
            text: 要合成的文本
            output_path: 输出文件路径,如果为None则返回字节数据
            reference_audio: 参考音频文件路径,用于音色克隆
            max_new_tokens: 最大生成token数
            temperature: 采样温度
            retry_times: 失败重试次数
            
        返回:
            音频字节数据(如果output_path为None)
        """
        # 构建请求数据
        payload = {
            "text": text,
            "reference_id": None,
            "max_new_tokens": max_new_tokens,
            "temperature": temperature
        }
        
        # 如果有参考音频,需要特殊处理
        # 注意:当前API可能需要通过文件上传方式传递参考音频
        # 这里我们先实现基础版本
        
        headers = {"Content-Type": "application/json"}
        
        # 重试机制
        for attempt in range(retry_times):
            try:
                print(f"生成语音中 (尝试 {attempt + 1}/{retry_times})...")
                
                response = self.session.post(
                    self.api_url,
                    json=payload,
                    headers=headers,
                    timeout=self.timeout
                )
                
                if response.status_code == 200:
                    audio_data = response.content
                    
                    # 如果指定了输出路径,保存文件
                    if output_path:
                        Path(output_path).parent.mkdir(parents=True, exist_ok=True)
                        with open(output_path, "wb") as f:
                            f.write(audio_data)
                        print(f"✅ 语音已保存到: {output_path}")
                        return None
                    else:
                        return audio_data
                        
                elif response.status_code == 422:
                    print(f"❌ 参数错误: {response.text}")
                    break
                else:
                    print(f"❌ 请求失败,状态码: {response.status_code}")
                    
            except requests.exceptions.Timeout:
                print(f"⏱️  请求超时 (尝试 {attempt + 1})")
                if attempt < retry_times - 1:
                    time.sleep(1)  # 等待1秒后重试
            except requests.exceptions.ConnectionError:
                print(f"🔌 连接失败 (尝试 {attempt + 1})")
                if attempt < retry_times - 1:
                    time.sleep(2)
            except Exception as e:
                print(f"❌ 未知错误: {str(e)}")
                break
        
        print("❌ 所有重试均失败")
        return None
    
    def batch_generate(
        self,
        texts: list,
        output_dir: str,
        prefix: str = "speech",
        **kwargs
    ) -> Dict[str, bool]:
        """
        批量生成语音
        
        参数:
            texts: 文本列表
            output_dir: 输出目录
            prefix: 文件名前缀
            **kwargs: 传递给generate_speech的其他参数
            
        返回:
            字典,键为文件名,值为是否成功
        """
        results = {}
        output_path = Path(output_dir)
        output_path.mkdir(parents=True, exist_ok=True)
        
        total = len(texts)
        for i, text in enumerate(texts, 1):
            # 清理文本,用于生成文件名
            clean_text = text[:30].replace(" ", "_").replace("/", "_")
            filename = f"{prefix}_{i:03d}_{clean_text}.wav"
            filepath = output_path / filename
            
            print(f"[{i}/{total}] 处理: {text[:50]}...")
            
            success = self.generate_speech(
                text=text,
                output_path=str(filepath),
                **kwargs
            ) is not None
            
            results[str(filepath)] = success
            
            # 添加短暂延迟,避免服务器压力过大
            if i < total:
                time.sleep(0.5)
        
        return results
3.2.2 命令行接口实现

接下来实现命令行入口:

# fish_tts/cli.py
import argparse
import sys
import json
from pathlib import Path
from typing import List

from .api_client import FishSpeechClient
from .batch_processor import BatchProcessor

def main():
    """命令行主函数"""
    parser = argparse.ArgumentParser(
        description="Fish Speech TTS 命令行工具",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
使用示例:
  # 生成单个语音
  fish-tts generate "你好,世界" -o hello.wav
  
  # 批量处理文本文件
  fish-tts batch input.txt -o outputs/
  
  # 从标准输入读取
  echo "测试文本" | fish-tts generate -o test.wav
  
  # 使用自定义API地址
  fish-tts generate "Hello" --api-url http://192.168.1.100:7861
        """
    )
    
    # 全局参数
    parser.add_argument(
        "--api-url",
        default="http://127.0.0.1:7861",
        help="Fish Speech API地址 (默认: http://127.0.0.1:7861)"
    )
    
    # 子命令
    subparsers = parser.add_subparsers(dest="command", help="可用命令")
    
    # generate 命令
    generate_parser = subparsers.add_parser("generate", help="生成单个语音")
    generate_parser.add_argument("text", nargs="?", help="要合成的文本")
    generate_parser.add_argument("-o", "--output", required=True, help="输出文件路径")
    generate_parser.add_argument("-r", "--reference", help="参考音频文件路径")
    generate_parser.add_argument("-t", "--tokens", type=int, default=1024, 
                                help="最大token数 (默认: 1024)")
    generate_parser.add_argument("--temperature", type=float, default=0.7,
                                help="采样温度 (默认: 0.7)")
    
    # batch 命令
    batch_parser = subparsers.add_parser("batch", help="批量处理")
    batch_parser.add_argument("input", help="输入文件或目录")
    batch_parser.add_argument("-o", "--output", required=True, help="输出目录")
    batch_parser.add_argument("-p", "--prefix", default="speech", 
                             help="文件名前缀 (默认: speech)")
    batch_parser.add_argument("--format", choices=["txt", "json", "csv"], 
                             default="txt", help="输入文件格式")
    
    # health 命令
    health_parser = subparsers.add_parser("health", help="检查API健康状态")
    
    args = parser.parse_args()
    
    # 创建客户端
    client = FishSpeechClient(base_url=args.api_url)
    
    # 处理不同命令
    if args.command == "generate":
        # 从参数或标准输入获取文本
        if args.text:
            text = args.text
        elif not sys.stdin.isatty():  # 有标准输入
            text = sys.stdin.read().strip()
        else:
            print("❌ 错误: 请提供文本或通过管道输入")
            sys.exit(1)
        
        if not text:
            print("❌ 错误: 文本不能为空")
            sys.exit(1)
        
        print(f"📝 文本: {text}")
        print(f"💾 输出: {args.output}")
        
        success = client.generate_speech(
            text=text,
            output_path=args.output,
            reference_audio=args.reference,
            max_new_tokens=args.tokens,
            temperature=args.temperature
        ) is not None
        
        if success:
            print("✅ 生成完成")
            sys.exit(0)
        else:
            print("❌ 生成失败")
            sys.exit(1)
            
    elif args.command == "batch":
        processor = BatchProcessor(client)
        
        # 根据输入类型处理
        input_path = Path(args.input)
        if input_path.is_file():
            results = processor.process_file(
                str(input_path),
                args.output,
                args.prefix,
                args.format
            )
        elif input_path.is_dir():
            results = processor.process_directory(
                str(input_path),
                args.output,
                args.prefix,
                args.format
            )
        else:
            print(f"❌ 错误: 输入路径不存在: {args.input}")
            sys.exit(1)
        
        # 统计结果
        total = len(results)
        success_count = sum(1 for success in results.values() if success)
        
        print(f"\n📊 批量处理完成:")
        print(f"  总计: {total} 个文件")
        print(f"  成功: {success_count}")
        print(f"  失败: {total - success_count}")
        
        # 保存结果报告
        report_path = Path(args.output) / "batch_report.json"
        with open(report_path, "w", encoding="utf-8") as f:
            json.dump({
                "summary": {
                    "total": total,
                    "success": success_count,
                    "failed": total - success_count
                },
                "details": results
            }, f, ensure_ascii=False, indent=2)
        
        print(f"📋 详细报告已保存到: {report_path}")
        
    elif args.command == "health":
        if client.check_health():
            print("✅ API服务运行正常")
            sys.exit(0)
        else:
            print("❌ API服务不可用")
            sys.exit(1)
            
    else:
        parser.print_help()
        sys.exit(1)

if __name__ == "__main__":
    main()
3.2.3 批量处理器实现
# fish_tts/batch_processor.py
import json
import csv
from pathlib import Path
from typing import List, Dict, Any

class BatchProcessor:
    """批量处理器"""
    
    def __init__(self, client):
        self.client = client
    
    def read_text_file(self, filepath: str) -> List[str]:
        """读取文本文件,每行作为一个文本"""
        with open(filepath, "r", encoding="utf-8") as f:
            lines = [line.strip() for line in f if line.strip()]
        return lines
    
    def read_json_file(self, filepath: str) -> List[str]:
        """读取JSON文件"""
        with open(filepath, "r", encoding="utf-8") as f:
            data = json.load(f)
        
        # 支持多种JSON格式
        if isinstance(data, list):
            # 如果是字符串列表
            if all(isinstance(item, str) for item in data):
                return data
            # 如果是对象列表,尝试提取text字段
            elif all(isinstance(item, dict) for item in data):
                texts = []
                for item in data:
                    if "text" in item:
                        texts.append(item["text"])
                    elif "content" in item:
                        texts.append(item["content"])
                return texts
        elif isinstance(data, dict):
            # 如果是字典,尝试找texts或data字段
            if "texts" in data:
                return data["texts"]
            elif "data" in data and isinstance(data["data"], list):
                return [item.get("text", "") for item in data["data"]]
        
        return []
    
    def read_csv_file(self, filepath: str) -> List[str]:
        """读取CSV文件"""
        texts = []
        with open(filepath, "r", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            for row in reader:
                # 尝试找text或content列
                if "text" in row:
                    texts.append(row["text"])
                elif "content" in row:
                    texts.append(row["content"])
                elif "sentence" in row:
                    texts.append(row["sentence"])
        return texts
    
    def process_file(
        self,
        input_file: str,
        output_dir: str,
        prefix: str = "speech",
        format: str = "txt"
    ) -> Dict[str, bool]:
        """处理单个文件"""
        filepath = Path(input_file)
        
        # 根据格式读取文本
        if format == "txt":
            texts = self.read_text_file(input_file)
        elif format == "json":
            texts = self.read_json_file(input_file)
        elif format == "csv":
            texts = self.read_csv_file(input_file)
        else:
            raise ValueError(f"不支持的格式: {format}")
        
        print(f"📖 从 {filepath.name} 读取了 {len(texts)} 个文本")
        
        # 批量生成
        return self.client.batch_generate(
            texts=texts,
            output_dir=output_dir,
            prefix=f"{prefix}_{filepath.stem}"
        )
    
    def process_directory(
        self,
        input_dir: str,
        output_dir: str,
        prefix: str = "speech",
        format: str = "txt"
    ) -> Dict[str, bool]:
        """处理整个目录"""
        input_path = Path(input_dir)
        all_results = {}
        
        # 根据格式查找文件
        if format == "txt":
            pattern = "*.txt"
        elif format == "json":
            pattern = "*.json"
        elif format == "csv":
            pattern = "*.csv"
        else:
            raise ValueError(f"不支持的格式: {format}")
        
        files = list(input_path.glob(pattern))
        print(f"📁 在 {input_dir} 中找到 {len(files)} 个 {format} 文件")
        
        for i, filepath in enumerate(files, 1):
            print(f"\n[{i}/{len(files)}] 处理文件: {filepath.name}")
            
            # 为每个文件创建子目录
            file_output_dir = Path(output_dir) / filepath.stem
            file_output_dir.mkdir(parents=True, exist_ok=True)
            
            # 处理文件
            results = self.process_file(
                str(filepath),
                str(file_output_dir),
                prefix,
                format
            )
            
            all_results.update(results)
        
        return all_results
3.2.4 安装脚本

最后,创建setup.py让用户可以安装这个工具:

# setup.py
from setuptools import setup, find_packages

with open("README.md", "r", encoding="utf-8") as fh:
    long_description = fh.read()

setup(
    name="fish-speech-cli",
    version="1.0.0",
    author="Fish Speech Developer",
    author_email="developer@example.com",
    description="Fish Speech TTS 命令行工具",
    long_description=long_description,
    long_description_content_type="text/markdown",
    url="https://github.com/yourusername/fish-speech-cli",
    packages=find_packages(),
    classifiers=[
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent",
    ],
    python_requires=">=3.8",
    install_requires=[
        "requests>=2.28.0",
        "soundfile>=0.12.0",
    ],
    entry_points={
        "console_scripts": [
            "fish-tts=fish_tts.cli:main",
        ],
    },
)

创建requirements.txt:

requests>=2.28.0
soundfile>=0.12.0
argparse>=1.4.0

3.3 使用CLI工具

现在,让我们试试这个工具。首先安装:

# 进入项目目录
cd fish-speech-cli

# 安装到当前环境
pip install -e .

# 或者直接使用(不安装)
python -m fish_tts.cli --help

测试各种功能:

# 1. 检查API健康状态
fish-tts health

# 2. 生成单个语音
fish-tts generate "欢迎使用Fish Speech命令行工具" -o welcome.wav

# 3. 批量处理文本文件
# 先创建一个测试文件
echo -e "第一条测试语音\n这是第二条测试内容\n第三条测试语句" > test.txt
fish-tts batch test.txt -o batch_output/

# 4. 处理JSON文件
echo '[{"text": "JSON测试1"}, {"text": "JSON测试2"}]' > test.json
fish-tts batch test.json -o json_output/ --format json

# 5. 从管道输入
echo "管道输入测试" | fish-tts generate -o pipe.wav

4. 构建自动化测试框架

有了CLI工具,我们还需要确保它稳定可靠。自动化测试框架就是我们的"安全网",它能自动发现潜在问题。

4.1 测试框架设计

我们的测试框架需要覆盖:

  1. 单元测试:测试单个函数是否正确
  2. 集成测试:测试整个流程是否正常
  3. 性能测试:测试响应时间和资源使用
  4. 回归测试:确保新版本不破坏旧功能

4.2 实现测试框架

创建测试目录结构:

tests/
├── __init__.py
├── conftest.py          # 测试配置
├── test_unit/           # 单元测试
│   ├── __init__.py
│   ├── test_utils.py
│   └── test_validators.py
├── test_integration/    # 集成测试
│   ├── __init__.py
│   ├── test_api.py
│   └── test_cli.py
├── test_performance/    # 性能测试
│   ├── __init__.py
│   └── test_benchmark.py
└── test_regression/     # 回归测试
    ├── __init__.py
    └── test_compatibility.py
4.2.1 测试配置
# tests/conftest.py
import pytest
import time
import sys
from pathlib import Path

# 添加项目根目录到Python路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))

# 测试配置
class TestConfig:
    """测试配置类"""
    API_URL = "http://127.0.0.1:7861"
    TEST_TEXT_SHORT = "测试文本"
    TEST_TEXT_LONG = "这是一个较长的测试文本,用于测试模型处理长文本的能力。" * 5
    TEST_OUTPUT_DIR = "test_outputs"
    
    @classmethod
    def ensure_output_dir(cls):
        """确保输出目录存在"""
        Path(cls.TEST_OUTPUT_DIR).mkdir(exist_ok=True)

# 测试夹具
@pytest.fixture
def config():
    """提供测试配置"""
    TestConfig.ensure_output_dir()
    return TestConfig

@pytest.fixture
def api_client():
    """提供API客户端实例"""
    from fish_tts.api_client import FishSpeechClient
    client = FishSpeechClient(base_url=TestConfig.API_URL)
    
    # 等待服务就绪
    max_retries = 10
    for i in range(max_retries):
        if client.check_health():
            break
        if i < max_retries - 1:
            time.sleep(2)
    else:
        pytest.skip("API服务未就绪,跳过测试")
    
    return client

@pytest.fixture
def cleanup_output():
    """测试后清理输出文件"""
    yield
    import shutil
    output_dir = Path(TestConfig.TEST_OUTPUT_DIR)
    if output_dir.exists():
        shutil.rmtree(output_dir)
4.2.2 单元测试
# tests/test_unit/test_utils.py
import pytest
from fish_tts.utils import validate_text, sanitize_filename

class TestUtils:
    """工具函数测试"""
    
    def test_validate_text(self):
        """测试文本验证"""
        # 正常文本
        assert validate_text("正常文本") == "正常文本"
        
        # 空文本
        with pytest.raises(ValueError, match="文本不能为空"):
            validate_text("")
        
        # 过长文本
        long_text = "a" * 10000
        with pytest.raises(ValueError, match="文本过长"):
            validate_text(long_text)
    
    def test_sanitize_filename(self):
        """测试文件名清理"""
        test_cases = [
            ("正常文件名", "正常文件名"),
            ("test/file.txt", "test_file.txt"),
            ("a*b?c:d", "a_b_c_d"),
            (" 前后空格 ", "前后空格"),
            ("中英文混合 English", "中英文混合_English"),
        ]
        
        for input_text, expected in test_cases:
            result = sanitize_filename(input_text)
            assert result == expected, f"输入: {input_text}, 期望: {expected}, 实际: {result}"
4.2.3 集成测试
# tests/test_integration/test_api.py
import pytest
from pathlib import Path

class TestAPIIntegration:
    """API集成测试"""
    
    def test_api_health(self, api_client):
        """测试API健康检查"""
        assert api_client.check_health() is True
    
    def test_generate_speech_short(self, api_client, config, cleanup_output):
        """测试生成短文本语音"""
        output_path = Path(config.TEST_OUTPUT_DIR) / "short_test.wav"
        
        result = api_client.generate_speech(
            text=config.TEST_TEXT_SHORT,
            output_path=str(output_path)
        )
        
        assert result is None  # 成功时返回None
        assert output_path.exists()
        assert output_path.stat().st_size > 1024  # 文件大小应大于1KB
    
    def test_generate_speech_long(self, api_client, config, cleanup_output):
        """测试生成长文本语音"""
        output_path = Path(config.TEST_OUTPUT_DIR) / "long_test.wav"
        
        result = api_client.generate_speech(
            text=config.TEST_TEXT_LONG,
            output_path=str(output_path),
            max_new_tokens=2048  # 增加token限制
        )
        
        assert result is None
        assert output_path.exists()
    
    def test_batch_generate(self, api_client, config, cleanup_output):
        """测试批量生成"""
        texts = [
            "批量测试第一条",
            "批量测试第二条",
            "批量测试第三条"
        ]
        
        results = api_client.batch_generate(
            texts=texts,
            output_dir=config.TEST_OUTPUT_DIR,
            prefix="batch_test"
        )
        
        assert len(results) == 3
        
        # 检查文件是否生成
        output_dir = Path(config.TEST_OUTPUT_DIR)
        wav_files = list(output_dir.glob("*.wav"))
        assert len(wav_files) == 3
        
        # 统计成功数量
        success_count = sum(1 for success in results.values() if success)
        assert success_count >= 2  # 至少成功2个
    
    @pytest.mark.slow
    def test_concurrent_requests(self, api_client, config, cleanup_output):
        """测试并发请求(标记为慢速测试)"""
        import concurrent.futures
        
        texts = [f"并发测试{i}" for i in range(5)]
        output_files = [
            Path(config.TEST_OUTPUT_DIR) / f"concurrent_{i}.wav"
            for i in range(5)
        ]
        
        def generate_one(text, output_path):
            return api_client.generate_speech(
                text=text,
                output_path=str(output_path)
            )
        
        # 使用线程池并发请求
        with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
            futures = []
            for text, output_path in zip(texts, output_files):
                future = executor.submit(generate_one, text, output_path)
                futures.append(future)
            
            # 等待所有任务完成
            results = [future.result() for future in futures]
        
        # 检查结果
        success_count = sum(1 for result in results if result is None)
        assert success_count >= 3  # 至少成功3个
4.2.4 CLI测试
# tests/test_integration/test_cli.py
import pytest
import subprocess
import json
from pathlib import Path

class TestCLIIntegration:
    """CLI集成测试"""
    
    def test_cli_help(self):
        """测试帮助命令"""
        result = subprocess.run(
            ["fish-tts", "--help"],
            capture_output=True,
            text=True
        )
        
        assert result.returncode == 0
        assert "usage:" in result.stdout.lower()
        assert "generate" in result.stdout
        assert "batch" in result.stdout
    
    def test_cli_generate(self, config, cleanup_output):
        """测试生成命令"""
        output_path = Path(config.TEST_OUTPUT_DIR) / "cli_test.wav"
        
        result = subprocess.run(
            ["fish-tts", "generate", config.TEST_TEXT_SHORT, "-o", str(output_path)],
            capture_output=True,
            text=True
        )
        
        print("STDOUT:", result.stdout)
        print("STDERR:", result.stderr)
        
        # 允许非零退出码(如果API未启动)
        if result.returncode != 0:
            pytest.skip("CLI命令执行失败,可能API未启动")
        
        assert output_path.exists()
    
    def test_cli_batch(self, config, cleanup_output):
        """测试批量命令"""
        # 创建测试文件
        test_file = Path(config.TEST_OUTPUT_DIR) / "test_input.txt"
        test_file.write_text("第一行\n第二行\n第三行")
        
        output_dir = Path(config.TEST_OUTPUT_DIR) / "batch_output"
        
        result = subprocess.run(
            ["fish-tts", "batch", str(test_file), "-o", str(output_dir)],
            capture_output=True,
            text=True
        )
        
        if result.returncode != 0:
            pytest.skip("批量命令执行失败")
        
        # 检查输出
        assert output_dir.exists()
        
        wav_files = list(output_dir.glob("*.wav"))
        assert len(wav_files) == 3
        
        # 检查报告文件
        report_file = output_dir / "batch_report.json"
        assert report_file.exists()
        
        with open(report_file, "r", encoding="utf-8") as f:
            report = json.load(f)
        
        assert report["summary"]["total"] == 3
        assert report["summary"]["success"] >= 2
4.2.5 性能测试
# tests/test_performance/test_benchmark.py
import pytest
import time
import statistics
from pathlib import Path

class TestPerformance:
    """性能测试"""
    
    @pytest.mark.benchmark
    def test_response_time(self, api_client, config, cleanup_output):
        """测试响应时间"""
        iterations = 5
        response_times = []
        
        for i in range(iterations):
            output_path = Path(config.TEST_OUTPUT_DIR) / f"perf_test_{i}.wav"
            
            start_time = time.time()
            
            result = api_client.generate_speech(
                text=f"性能测试迭代 {i}",
                output_path=str(output_path)
            )
            
            end_time = time.time()
            
            if result is None:  # 成功
                response_times.append(end_time - start_time)
            
            # 短暂延迟
            if i < iterations - 1:
                time.sleep(1)
        
        if response_times:
            print(f"\n响应时间统计 (共{len(response_times)}次成功):")
            print(f"  平均: {statistics.mean(response_times):.2f}秒")
            print(f"  最小: {min(response_times):.2f}秒")
            print(f"  最大: {max(response_times):.2f}秒")
            print(f"  标准差: {statistics.stdev(response_times):.2f}秒")
            
            # 断言平均响应时间小于10秒
            assert statistics.mean(response_times) < 10.0
    
    @pytest.mark.benchmark
    def test_memory_usage(self, api_client, config, cleanup_output):
        """测试内存使用(近似)"""
        import psutil
        import os
        
        # 获取初始内存
        process = psutil.Process(os.getpid())
        initial_memory = process.memory_info().rss / 1024 / 1024  # MB
        
        # 执行多次生成
        for i in range(3):
            output_path = Path(config.TEST_OUTPUT_DIR) / f"memory_test_{i}.wav"
            api_client.generate_speech(
                text=f"内存测试 {i}",
                output_path=str(output_path)
            )
        
        # 获取最终内存
        final_memory = process.memory_info().rss / 1024 / 1024
        
        memory_increase = final_memory - initial_memory
        
        print(f"\n内存使用变化:")
        print(f"  初始: {initial_memory:.1f} MB")
        print(f"  最终: {final_memory:.1f} MB")
        print(f"  增加: {memory_increase:.1f} MB")
        
        # 断言内存增加不超过200MB
        assert memory_increase < 200.0
4.2.6 回归测试
# tests/test_regression/test_compatibility.py
import pytest
import sys
from pathlib import Path

class TestRegression:
    """回归测试"""
    
    def test_python_version_compatibility(self):
        """测试Python版本兼容性"""
        python_version = sys.version_info
        
        # 确保支持Python 3.8+
        assert python_version.major == 3
        assert python_version.minor >= 8
        
        print(f"Python版本: {python_version.major}.{python_version.minor}.{python_version.micro}")
    
    def test_import_compatibility(self):
        """测试导入兼容性"""
        # 测试所有主要模块都能导入
        import fish_tts
        import fish_tts.cli
        import fish_tts.api_client
        import fish_tts.batch_processor
        import fish_tts.utils
        
        assert fish_tts.__version__ is not None
    
    @pytest.mark.parametrize("text,expected_valid", [
        ("正常文本", True),
        ("", False),  # 空文本
        ("a" * 10000, False),  # 过长文本
        ("Hello 123", True),  # 英文数字
        ("中文测试", True),  # 中文
        ("🎉表情符号", True),  # 包含表情符号
    ])
    def test_text_validation_regression(self, text, expected_valid):
        """测试文本验证回归"""
        from fish_tts.utils import validate_text
        
        if expected_valid:
            result = validate_text(text)
            assert result == text.strip()
        else:
            with pytest.raises(ValueError):
                validate_text(text)

4.3 运行测试

创建测试运行脚本:

# run_tests.sh
#!/bin/bash

echo "🐟 开始运行 Fish Speech 测试套件"
echo "=================================="

# 1. 运行单元测试
echo -e "\n📋 运行单元测试..."
pytest tests/test_unit/ -v --tb=short

# 2. 运行集成测试(需要API服务)
echo -e "\n🔗 运行集成测试..."
pytest tests/test_integration/ -v --tb=short

# 3. 运行性能测试(标记为benchmark)
echo -e "\n⚡ 运行性能测试..."
pytest tests/test_performance/ -v -m benchmark --tb=short

# 4. 运行回归测试
echo -e "\n🔄 运行回归测试..."
pytest tests/test_regression/ -v --tb=short

# 5. 生成测试报告
echo -e "\n📊 生成测试报告..."
pytest --junitxml=test-results.xml --cov=fish_tts --cov-report=html

echo -e "\n✅ 所有测试完成!"
echo "查看覆盖率报告: file://$(pwd)/htmlcov/index.html"

或者使用更简单的pytest命令:

# 运行所有测试
pytest tests/ -v

# 只运行快速测试(跳过慢速和性能测试)
pytest tests/ -v -m "not slow and not benchmark"

# 运行特定测试类
pytest tests/test_integration/test_api.py::TestAPIIntegration -v

# 生成HTML覆盖率报告
pytest --cov=fish_tts --cov-report=html

5. 实际应用案例

现在,让我们看看这个工具链在实际项目中怎么用。

5.1 案例一:有声电子书生成

假设你有一本小说,想把它转换成有声书:

# audiobook_generator.py
from fish_tts.api_client import FishSpeechClient
from pathlib import Path
import json

class AudiobookGenerator:
    """有声书生成器"""
    
    def __init__(self, api_url="http://127.0.0.1:7861"):
        self.client = FishSpeechClient(api_url)
    
    def generate_from_novel(self, novel_path, output_dir, voice_settings=None):
        """
        从小说文件生成有声书
        
        参数:
            novel_path: 小说文件路径
            output_dir: 输出目录
            voice_settings: 语音设置
        """
        # 读取小说
        with open(novel_path, "r", encoding="utf-8") as f:
            content = f.read()
        
        # 分割章节(简单按段落分割)
        paragraphs = [p.strip() for p in content.split("\n\n") if p.strip()]
        
        print(f"📚 小说《{Path(novel_path).stem}》")
        print(f"  章节数: {len(paragraphs)}")
        print(f"  总字数: {sum(len(p) for p in paragraphs)}")
        
        # 生成语音
        results = self.client.batch_generate(
            texts=paragraphs,
            output_dir=output_dir,
            prefix="chapter",
            max_new_tokens=2048,  # 允许更长的文本
            temperature=0.8  # 稍微增加随机性,让语音更自然
        )
        
        # 生成元数据
        metadata = {
            "title": Path(novel_path).stem,
            "total_chapters": len(paragraphs),
            "chapters": []
        }
        
        for i, (filepath, success) in enumerate(results.items()):
            metadata["chapters"].append({
                "index": i + 1,
                "text_preview": paragraphs[i][:100] + "..." if len(paragraphs[i]) > 100 else paragraphs[i],
                "audio_file": Path(filepath).name,
                "success": success,
                "text_length": len(paragraphs[i])
            })
        
        # 保存元数据
        metadata_path = Path(output_dir) / "metadata.json"
        with open(metadata_path, "w", encoding="utf-8") as f:
            json.dump(metadata, f, ensure_ascii=False, indent=2)
        
        print(f"✅ 有声书生成完成!")
        print(f"  音频文件保存在: {output_dir}")
        print(f"  元数据文件: {metadata_path}")
        
        return metadata

# 使用示例
if __name__ == "__main__":
    generator = AudiobookGenerator()
    
    # 生成有声书
    metadata = generator.generate_from_novel(
        novel_path="novel.txt",
        output_dir="audiobook_output"
    )
    
    # 统计结果
    success_count = sum(1 for chapter in metadata["chapters"] if chapter["success"])
    print(f"\n📊 生成统计:")
    print(f"  总章节: {metadata['total_chapters']}")
    print(f"  成功: {success_count}")
    print(f"  失败: {metadata['total_chapters'] - success_count}")

5.2 案例二:多语言产品演示生成

如果你的产品需要支持多语言演示:

# multilingual_demo.py
from fish_tts.api_client import FishSpeechClient
from pathlib import Path
import json

class MultilingualDemoGenerator:
    """多语言演示生成器"""
    
    # 多语言演示文本
    DEMO_TEXTS = {
        "zh": {
            "welcome": "欢迎使用我们的产品,这是一个智能语音合成演示。",
            "features": "我们的产品支持高质量语音合成,多种音色选择,以及实时生成功能。",
            "closing": "感谢观看演示,如有任何问题,请随时联系我们。"
        },
        "en": {
            "welcome": "Welcome to our product. This is a smart text-to-speech demo.",
            "features": "Our product supports high-quality speech synthesis, multiple voice options, and real-time generation.",
            "closing": "Thank you for watching the demo. If you have any questions, please feel free to contact us."
        },
        "ja": {
            "welcome": "当社製品へようこそ。これはスマートな音声合成デモです。",
            "features": "当社製品は高品質な音声合成、複数の音声オプション、リアルタイム生成をサポートしています。",
            "closing": "デモをご覧いただきありがとうございます。ご質問がございましたら、お気軽にお問い合わせください。"
        }
    }
    
    def __init__(self, api_url="http://127.0.0.1:7861"):
        self.client = FishSpeechClient(api_url)
    
    def generate_all_demos(self, output_dir="multilingual_demos"):
        """生成所有语言的演示"""
        output_path = Path(output_dir)
        output_path.mkdir(exist_ok=True)
        
        all_results = {}
        
        for lang_code, texts in self.DEMO_TEXTS.items():
            print(f"\n🌐 生成 {lang_code} 语言演示...")
            
            lang_dir = output_path / lang_code
            lang_dir.mkdir(exist_ok=True)
            
            lang_results = {}
            
            for text_key, text_content in texts.items():
                filename = f"{text_key}.wav"
                filepath = lang_dir / filename
                
                print(f"  生成: {text_key}...")
                
                success = self.client.generate_speech(
                    text=text_content,
                    output_path=str(filepath)
                ) is not None
                
                lang_results[text_key] = {
                    "success": success,
                    "file": filename,
                    "text": text_content
                }
                
                # 短暂延迟
                import time
                time.sleep(0.5)
            
            all_results[lang_code] = lang_results
        
        # 生成索引文件
        self._generate_index(output_path, all_results)
        
        return all_results
    
    def _generate_index(self, output_dir, results):
        """生成索引HTML文件"""
        html_content = """
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="UTF-8">
            <title>多语言语音演示</title>
            <style>
                body { font-family: Arial, sans-serif; margin: 40px; }
                .language { margin-bottom: 30px; border: 1px solid #ddd; padding: 20px; border-radius: 8px; }
                .language h2 { color: #333; border-bottom: 2px solid #4CAF50; padding-bottom: 10px; }
                .demo-item { margin: 15px 0; padding: 15px; background: #f9f9f9; border-radius: 5px; }
                audio { width: 100%; margin-top: 10px; }
                .success { color: #4CAF50; }
                .failed { color: #f44336; }
            </style>
        </head>
        <body>
            <h1>🎙️ 多语言语音演示</h1>
            <p>使用 Fish Speech 1.5 生成的多语言产品演示</p>
        """
        
        for lang_code, demos in results.items():
            # 获取语言名称
            lang_names = {"zh": "中文", "en": "English", "ja": "日本語"}
            lang_name = lang_names.get(lang_code, lang_code)
            
            html_content += f"""
            <div class="language">
                <h2>{lang_name} ({lang_code})</h2>
            """
            
            for demo_key, demo_info in demos.items():
                status_class = "success" if demo_info["success"] else "failed"
                status_text = "✅ 成功" if demo_info["success"] else "❌ 失败"
                
                html_content += f"""
                <div class="demo-item">
                    <h3>{demo_key}</h3>
                    <p><strong>文本:</strong> {demo_info['text']}</p>
                    <p><strong>状态:</strong> <span class="{status_class}">{status_text}</span></p>
                """
                
                if demo_info["success"]:
                    html_content += f"""
                    <audio controls>
                        <source src="{lang_code}/{demo_info['file']}" type="audio/wav">
                        您的浏览器不支持音频播放。
                    </audio>
                    """
                
                html_content += "</div>"
            
            html_content += "</div>"
        
        html_content += """
            <div style="margin-top: 40px; padding: 20px; background: #e8f5e9; border-radius: 8px;">
                <h3>📊 统计信息</h3>
                <p>生成时间: <span id="generation-time"></span></p>
                <p>总演示数: <span id="total-demos"></span></p>
                <p>成功率: <span id="success-rate"></span></p>
            </div>
            
            <script>
                // 计算统计信息
                const demos = document.querySelectorAll('.demo-item');
                const total = demos.length;
                const success = document.querySelectorAll('.success').length;
                const successRate = total > 0 ? Math.round((success / total) * 100) : 0;
                
                document.getElementById('total-demos').textContent = total;
                document.getElementById('success-rate').textContent = successRate + '%';
                document.getElementById('generation-time').textContent = new Date().toLocaleString();
            </script>
        </body>
        </html>
        """
        
        index_path = output_dir / "index.html"
        with open(index_path, "w", encoding="utf-8") as f:
            f.write(html_content)
        
        print(f"\n📄 索引文件已生成: {index_path}")

# 使用示例
if __name__ == "__main__":
    generator = MultilingualDemoGenerator()
    results = generator.generate_all_demos()
    
    # 打印统计
    total_demos = sum(len(demos) for demos in results.values())
    successful_demos = sum(
        sum(1 for demo in demos.values() if demo["success"])
        for demos in results.values()
    )
    
    print(f"\n📊 多语言演示生成完成:")
    print(f"  总语言数: {len(results)}")
    print(f"  总演示数: {total_demos}")
    print(f"  成功数: {successful_demos}")
    print(f"  成功率: {successful_demos/total_demos*100:.1f}%")
    print(f"  打开 index.html 查看所有演示")

5.3 案例三:自动化测试流水线

最后,让我们创建一个完整的CI/CD流水线:

# .github/workflows/test.yml
name: Fish Speech CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]
  schedule:
    - cron: '0 2 * * *'  # 每天凌晨2点运行

jobs:
  test:
    runs-on: ubuntu-latest
    
    services:
      fish-speech:
        image: your-registry/fish-speech:latest
        ports:
          - 7861:7861
        options: >-
          --gpus all
          --shm-size=8g
        env:
          CUDA_VISIBLE_DEVICES: 0
    
    steps:
    - uses: actions/checkout@v3
    
    - name: 设置Python
      uses: actions/setup-python@v4
      with:
        python-version: '3.9'
    
    - name: 安装依赖
      run: |
        pip install -e .
        pip install pytest pytest-cov pytest-html
        pip install requests soundfile
    
    - name: 等待服务就绪
      run: |
        echo "等待Fish Speech服务启动..."
        for i in {1..30}; do
          if curl -s http://localhost:7861/docs > /dev/null; then
            echo "✅ 服务已就绪"
            break
          fi
          echo "等待服务... ($i/30)"
          sleep 2
        done
    
    - name: 运行单元测试
      run: |
        pytest tests/test_unit/ -v --junitxml=unit-test-results.xml
    
    - name: 运行集成测试
      run: |
        pytest tests/test_integration/ -v --junitxml=integration-test-results.xml
    
    - name: 运行回归测试
      run: |
        pytest tests/test_regression/ -v --junitxml=regression-test-results.xml
    
    - name: 生成测试报告
      run: |
        pytest --cov=fish_tts --cov-report=xml --cov-report=html
    
    - name: 上传测试报告
      uses: actions/upload-artifact@v3
      with:
        name: test-reports
        path: |
          *.xml
          htmlcov/
    
    - name: 上传覆盖率到Codecov
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml
        flags: unittests
        name: codecov-umbrella

6. 总结

通过本文的实践,我们完成了一个完整的Fish Speech 1.5开发者工具链建设:

6.1 工具链核心价值

  1. 效率提升:从手动操作到自动化处理,批量生成效率提升10倍以上
  2. 可靠性保障:通过自动化测试框架,确保每次更新都不会破坏核心功能
  3. 易用性改善:命令行工具让非开发者也能轻松使用TTS能力
  4. 集成便利:标准的API接口和客户端库,方便集成到各种系统中

6.2 关键收获

  • CLI工具设计:学会了如何设计用户友好的命令行接口,支持多种输入输出方式
  • 测试框架构建:掌握了从单元测试到性能测试的完整测试策略
  • 实际应用开发:通过有声书生成、多语言演示等案例,展示了工具链的实际价值
  • CI/CD集成:了解了如何将测试自动化集成到开发流程中

6.3 下一步建议

  1. 扩展更多功能

    • 支持更多音频格式(MP3、OGG等)
    • 添加语音效果处理(回声、变速、变调)
    • 实现实时流式语音生成
  2. 优化性能

    • 添加缓存机制,避免重复生成相同内容
    • 实现并行处理,充分利用多核CPU
    • 添加GPU内存优化,支持更大批量处理
  3. 完善生态系统

    • 开发VS Code插件,在编辑器中直接生成语音
    • 创建Webhook服务,支持事件驱动的语音生成
    • 构建Docker镜像,一键部署完整工具链
  4. 社区贡献

    • 将工具开源,吸引更多开发者贡献
    • 编写详细文档和教程
    • 收集用户反馈,持续改进工具

6.4 开始行动

现在,你已经掌握了构建专业级TTS工具链的所有技能。无论是为自己的项目添加语音功能,还是为企业构建语音合成平台,这套工具链都能为你提供坚实的基础。

记住,好的工具不是一次建成的,而是在实际使用中不断迭代完善的。从今天开始,用这套工具链解决一个实际的语音合成问题,你会发现开发效率和质量都会有质的提升。


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐