feat: 初始化 Mimo-TTS CLI 工具
- 实现文本转语音功能(支持多种音色) - 支持流式输出(--stream)和直接播放(--play) - 实现自动语气转换器(根据标点自动添加语气标签) - 使用 crossterm 美化 CLI 输出 - 配置分层设计(项目配置 + 用户配置) - 独立模块划分:api.rs, cli.rs, config.rs, tone.rs, ui.rs v0.1.0
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/target
|
||||
.wav
|
||||
|
||||
.ini
|
||||
.DS_Store
|
||||
|
||||
.vs/
|
||||
.code/
|
||||
.fleet/
|
||||
.cursor/
|
||||
2998
Cargo.lock
generated
Normal file
2998
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
Cargo.toml
Normal file
33
Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "mimo-tts"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# CLI 参数解析库,使用 derive 模式简化命令行参数定义
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
# HTTP 客户端,用于调用 Mimo-TTS API
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
# 异步运行时,支持异步 API 调用
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
# 序列化/反序列化库,用于 JSON 和 TOML 处理
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
# Base64 解码,用于解码 API 返回的音频数据
|
||||
base64 = "0.22"
|
||||
# TOML 格式解析,用于配置文件
|
||||
toml = "0.8"
|
||||
# 获取用户家目录(跨平台)
|
||||
home = "0.5"
|
||||
# 错误处理库,提供便捷的 Result 类型
|
||||
anyhow = "1.0"
|
||||
# 音频播放库,支持从内存直接播放音频
|
||||
rodio = "0.19"
|
||||
# TUI 框架,用于美化 CLI 输出和交互式界面
|
||||
ratatui = "0.26"
|
||||
# 终端控制库,用于读取键盘输入和控制终端
|
||||
crossterm = "0.27"
|
||||
# 流式处理库,用于 SSE 流式响应
|
||||
futures = "0.3"
|
||||
# Tokio 工具库,提供 StreamReader
|
||||
tokio-util = "0.7"
|
||||
68
agents.md
Normal file
68
agents.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 项目规范与 AI 规范 (agents.md)
|
||||
|
||||
## 项目规范
|
||||
|
||||
### 代码风格
|
||||
- 使用 Rust 官方代码风格(rustfmt)
|
||||
- 所有公开 API 必须包含中文文档注释(///)
|
||||
- 复杂逻辑必须包含行内中文注释
|
||||
- 结构体、枚举、trait 使用 PascalCase 命名
|
||||
- 变量、函数使用 snake_case 命名
|
||||
|
||||
### 架构设计
|
||||
本项目采用 **面向对象 (OOP) + 设计模式** 架构:
|
||||
|
||||
1. **Builder 模式**:用于构建复杂的 API 请求对象
|
||||
2. **Strategy 模式**:不同音色和音频格式的处理策略
|
||||
3. **Singleton 模式**:全局配置管理器(确保配置只加载一次)
|
||||
4. **封装**:每个模块负责单一职责,通过 pub 控制可见性
|
||||
|
||||
### 错误处理
|
||||
- 使用 `anyhow::Result<T>` 作为统一返回类型
|
||||
- 自定义错误类型(如需更精细控制)
|
||||
- 退出码规范:
|
||||
- 0: 成功
|
||||
- 1: 参数错误
|
||||
- 2: 配置错误(如缺少 API Key)
|
||||
- 3: API 调用失败
|
||||
- 4: 文件操作失败
|
||||
|
||||
### 文档要求
|
||||
- 每次操作前必须更新对应文档
|
||||
- 代码变更同步更新 changelog.md
|
||||
- 踩坑记录及时写入【认知修正】章节
|
||||
|
||||
---
|
||||
|
||||
## AI 规范
|
||||
|
||||
### 沟通语言
|
||||
- 全程使用中文与用户沟通
|
||||
- 代码注释使用中文
|
||||
- 文档使用中文编写
|
||||
|
||||
### 执行顺序
|
||||
每次执行任何操作前,按以下顺序更新文档:
|
||||
1. 更新 `taolun.md` - 记录本次对话要点
|
||||
2. 更新 `agents.md` 的【认知修正】- 如有踩坑
|
||||
3. 更新 `changelog.md` - 如有版本变更
|
||||
4. 执行实际操作
|
||||
|
||||
---
|
||||
|
||||
## 认知修正(踩坑记录)
|
||||
|
||||
### 2026-04-24 - 项目初始化
|
||||
|
||||
#### 问题:Mimo-TTS API 文档无法直接访问
|
||||
**现象**:使用 webfetch 工具访问 https://platform.xiaomimimo.com/docs/ 返回的内容不完整
|
||||
**原因**:网站可能有反爬虫机制或需要 JavaScript 渲染
|
||||
**解决方案**:通过 websearch 搜索相关内容,从第三方文档(如 DMXAPI、GitHub 项目)获取 API 详细信息
|
||||
**经验**:对于无法直接抓取的文档,可以通过搜索引擎找到镜像或第三方整理的资料
|
||||
|
||||
#### 问题:API 认证需要双 Header
|
||||
**现象**:标准的 OpenAI SDK 方式(仅使用 Authorization Bearer)可能无法正常工作
|
||||
**原因**:Mimo-TTS API 要求同时提供 `api-key` 和 `Authorization: Bearer` 两个 Header
|
||||
**解决方案**:在代码中同时设置两个 Header
|
||||
**经验**:阅读 API 文档时要注意认证方式的特殊性,不能假设与标准 OpenAI API 完全一致
|
||||
|
||||
208
changelog.md
Normal file
208
changelog.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# 版本变更记录 (changelog.md)
|
||||
|
||||
## [0.1.0] - 2026-04-24
|
||||
|
||||
### 新增
|
||||
- 初始化 Rust 项目结构(edition 2021)
|
||||
- 配置 Cargo.toml 依赖(使用国内源,稀疏索引协议)
|
||||
- 创建项目文档体系(taolun.md、changelog.md、agents.md)
|
||||
- 实现配置管理模块 (config.rs) - Singleton 模式
|
||||
- 统一配置文件路径为 `~/.config/tts/config.toml`(所有平台)
|
||||
- 使用 home 库获取家目录
|
||||
- 实现 API 调用模块 (api.rs) - Builder 模式
|
||||
- TtsClient 结构体封装 API 调用
|
||||
- 支持双 Header 认证(api-key + Authorization)
|
||||
- Base64 解码音频数据
|
||||
- 实现 CLI 模块 (cli.rs) - clap derive 模式
|
||||
- 添加 Onboard 子命令(引导式配置)
|
||||
- 添加 Voices、Config、ShowConfig 子命令
|
||||
- 实现主程序入口 (main.rs)
|
||||
- 异步主函数(tokio)
|
||||
- 错误处理和退出码规范(0-4)
|
||||
- 支持从文本或文件输入
|
||||
- 创建 `project.config.toml` 管理项目版本
|
||||
- Release 版本构建成功(无警告)
|
||||
- 语音合成功能测试成功
|
||||
|
||||
### 技术栈
|
||||
- Rust (edition 2021)
|
||||
- clap 4.6 (CLI 参数解析)
|
||||
- reqwest 0.12 (HTTP 客户端)
|
||||
- tokio 1.52 (异步运行时)
|
||||
- serde + serde_json (序列化)
|
||||
- toml (配置文件)
|
||||
- base64 0.22 (音频解码)
|
||||
- home 0.5 (跨平台家目录获取)
|
||||
- anyhow 1.0 (错误处理)
|
||||
|
||||
### 功能列表
|
||||
- ✅ 文本转语音(TTS)
|
||||
- ✅ 支持多种音色(default_zh, default_en, mimo_default)
|
||||
- ✅ 配置文件管理(~/.config/tts/config.toml)
|
||||
- ✅ CLI 引导式配置(onboard 命令)
|
||||
- ✅ 列出可用音色(voices 命令)
|
||||
- ✅ 配置管理(config set/show 命令)
|
||||
- ✅ WAV 音频输出
|
||||
|
||||
---
|
||||
|
||||
## 版本规范
|
||||
遵循 [语义化版本 2.0.0](https://semver.org/lang/zh-CN/):
|
||||
- 主版本号:不兼容的 API 修改
|
||||
- 次版本号:向下兼容的功能性新增
|
||||
- 修订号:向下兼容的问题修正
|
||||
|
||||
### 修改(第三轮 - 流式输出)
|
||||
- 修改 `synthesize()` 返回音频数据而非保存文件
|
||||
- 支持输出到 stdout(二进制流)
|
||||
- 保留 `--output` 参数用于保存到文件
|
||||
- 输出到 stdout 时自动抑制提示信息
|
||||
- 便于作为 claw skill 集成
|
||||
|
||||
|
||||
### 修改(第四轮 - 音频播放)
|
||||
- 添加 rodio 0.19 依赖(音频播放库)
|
||||
- 添加 --play 参数(直接播放音频)
|
||||
- 修改 synthesize() 返回音频数据
|
||||
- 新增 play_audio() 函数(使用 rodio 播放)
|
||||
- 支持三种输出方式:播放/保存/流式输出
|
||||
- --play 和 --output 互斥
|
||||
|
||||
## [0.1.0] 最终状态
|
||||
|
||||
### 新增功能(第四轮)
|
||||
- 添加 rodio 0.19 音频播放库
|
||||
- 新增 --play 参数:直接播放音频(单次播放,不循环)
|
||||
- synthesize() 函数改为返回音频数据 Vec<u8>
|
||||
- 新增 play_audio() 函数:使用 rodio 从内存播放 WAV
|
||||
|
||||
### 修改(第四轮)
|
||||
- 修改 Cargo.toml:添加 rodio 依赖
|
||||
- 修改 cli.rs:添加 --play 参数(与 --output 互斥)
|
||||
- 修改 main.rs:
|
||||
- synthesize() 返回 Result<Vec<u8>>
|
||||
- 添加 play_audio() 函数
|
||||
- run() 支持三种输出方式
|
||||
|
||||
### 修复(第四轮)
|
||||
- 修复 synthesize() 重复代码问题
|
||||
- 修复 stdout 输出被 println 污染的问题
|
||||
- 移除未使用的导入警告
|
||||
|
||||
### 功能列表(最终)
|
||||
- ✅ 文本转语音(TTS)
|
||||
- ✅ 支持多种音色(default_zh, default_en, mimo_default)
|
||||
- ✅ 配置文件管理(~/.config/tts/config.toml)
|
||||
- ✅ CLI 引导式配置(onboard 命令)
|
||||
- ✅ 列出可用音色(voices 命令)
|
||||
- ✅ 配置管理(config set/show 命令)
|
||||
- ✅ 直接播放音频(--play 参数)
|
||||
- ✅ 保存文件(--output 参数)
|
||||
- ✅ 流式输出(stdout 二进制流)
|
||||
- ✅ WAV 音频输出
|
||||
|
||||
### 修改(第五轮 - 音色扩展)
|
||||
- 更新音色列表为 Mimo-TTS 完整列表(8个音色)
|
||||
- 默认音色从 default_zh 改为 mimo_default
|
||||
- 添加音色验证(无效时使用 mimo_default)
|
||||
- 更新 list_voices() 显示详细音色信息
|
||||
- 废弃 default_zh 和 default_en
|
||||
|
||||
### 修改(第六轮 - UI 主题化)
|
||||
- 新增 ratatui 0.26 + crossterm 0.27 依赖
|
||||
- 新建 src/ui.rs 模块(主题、组件)
|
||||
- 美化所有 CLI 输出(voices、config、onboard 等)
|
||||
- 重写 onboard() 为交互式表单页面
|
||||
- 支持 API Key 隐藏输入(显示 *)
|
||||
- 所有命令输出统一主题风格
|
||||
- 使用 crossterm 实现彩色输出(未使用完整 ratatui Terminal)
|
||||
|
||||
### 实施完成(第六轮)
|
||||
1. ✅ 创建 src/ui.rs 模块(使用 crossterm 美化输出)
|
||||
2. ✅ 修改 main.rs 引入 ui 模块
|
||||
3. ✅ 美化 list_voices() 输出(彩色表格)
|
||||
4. ✅ 美化 show_config() 输出(彩色标签)
|
||||
5. ✅ 重写 onboard() 为交互式表单(支持密码隐藏输入)
|
||||
6. ✅ 美化播放和保存完成消息
|
||||
7. ✅ Release 构建成功(仅有未使用代码警告)
|
||||
8. ✅ 功能测试通过(voices、show-config)
|
||||
|
||||
## [0.2.0] - 开发中
|
||||
|
||||
### 新增(第十轮 - 自动语气转换器)
|
||||
- ✅ 创建 `src/tone.rs` 独立模块(高内聚低耦合)
|
||||
- ✅ 实现 `apply_tone()` 函数(自动语气转换)
|
||||
- ✅ 实现 `insert_mid_tone()` 函数(细粒度控制)
|
||||
- ✅ 实现 `analyze_tone()` 函数(分析语气)
|
||||
- ✅ 实现 `has_tone_tag()` 函数(检测已有标签)
|
||||
- ✅ 默认启用,无需额外参数
|
||||
- ✅ 支持整体风格标签 `[语气]`
|
||||
- ✅ 支持细粒度控制标签 `(描述)`
|
||||
|
||||
### 修改(第十轮)
|
||||
- 修改 main.rs:引入 tone 模块
|
||||
- 修改 main.rs:在 synthesize() 中调用语气转换
|
||||
|
||||
### 语气映射规则
|
||||
- `!` → [激动] + !(激动)
|
||||
- `?` → [疑惑] + ?(疑惑)
|
||||
- `。` → [平静] + 。(停顿)
|
||||
- `……` → ……(拖长音)
|
||||
- 多种标点 → 组合标签如 [激动 疑惑]
|
||||
|
||||
### 技术细节
|
||||
- 使用 `[语气]` 格式添加整体风格
|
||||
- 使用 `(描述)` 格式添加细粒度控制
|
||||
- 符合 Mimo-TTS 音频标签控制规范
|
||||
- 检测已有标签,避免重复添加
|
||||
- 4 个单元测试全部通过
|
||||
|
||||
## [0.2.0] - 开发中
|
||||
|
||||
### 新增(第九轮 - 流式输出完成)
|
||||
- ✅ 实现 `--stream` 参数功能(流式 API 调用)
|
||||
- ✅ 使用 SSE(Server-Sent Events)处理流式响应
|
||||
- ✅ 流式输出时自动使用 pcm16 格式
|
||||
- ✅ 支持流式输出到 stdout 或保存到文件
|
||||
- ✅ 修改 api.rs 添加流式请求方法
|
||||
- ✅ 使用 futures::StreamExt 处理字节流
|
||||
- ✅ 流式 API 返回原始 PCM16 数据(无 WAV 头)
|
||||
- ✅ 支持 `--stream` 和 `--play` 同时使用
|
||||
- ✅ 新增 pcm16_to_wav() 函数(自动封装 WAV 头)
|
||||
|
||||
### 修改(第九轮)
|
||||
- 修改 Cargo.toml:添加 futures、tokio-util 依赖
|
||||
- 为 reqwest 添加 stream feature
|
||||
- 修改 main.rs:synthesize() 支持 stream 参数
|
||||
- 修改 main.rs:支持流式数据播放(PCM16 → WAV)
|
||||
- 修改 api.rs:修复 read_stream_response 实现
|
||||
|
||||
### 修复(第九轮)
|
||||
- 修复 StreamReader 编译错误(改用 futures::StreamExt)
|
||||
- 修复 reqwest stream feature 缺失问题
|
||||
- 修复播放时未识别 PCM16 格式问题
|
||||
|
||||
### 技术细节
|
||||
- PCM16 转 WAV:24000Hz, 16bit, 单声道
|
||||
- WAV 头 44 字节,包含必要的格式信息
|
||||
- 流式播放时自动添加 WAV 头再播放
|
||||
|
||||
### 新增(第七轮 - 配置分层设计)
|
||||
- 实现分层配置设计:
|
||||
- `project.config.toml` - 项目默认配置(base_url、default_format)
|
||||
- `~/.config/tts/config.toml` - 用户配置(api_key、default_voice)
|
||||
- 用户配置覆盖项目默认配置中的对应项
|
||||
- 临时文件生成在当前目录(不允许离开当前目录)
|
||||
|
||||
### 修改(第七轮)
|
||||
- 重写 `config.rs` 实现分层配置加载
|
||||
- 修改 `cli.rs` 移除 base_url 参数
|
||||
- 修改 `ui.rs` 简化显示和表单(只处理 api_key 和 default_voice)
|
||||
- 修改 `main.rs` 使用新的配置结构
|
||||
- 更新 `project.config.toml` 添加 base_url 和 default_format
|
||||
|
||||
### 修复(第七轮)
|
||||
- 修复默认音色未生效问题(config.rs 默认值改为 mimo_default)
|
||||
- 修复配置文件只保存用户配置项
|
||||
|
||||
## [0.1.0] - 2026-04-24
|
||||
7
project.config.toml
Normal file
7
project.config.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
# 项目配置文件
|
||||
# 版本号必须与 git tag 保持一致
|
||||
# 这是项目的默认配置,用户配置会覆盖这些默认值
|
||||
|
||||
version = "0.1.0"
|
||||
base_url = "https://api.xiaomimimo.com/v1/chat/completions"
|
||||
default_format = "wav"
|
||||
439
src/api.rs
Normal file
439
src/api.rs
Normal file
@@ -0,0 +1,439 @@
|
||||
/// API 调用模块
|
||||
///
|
||||
/// 负责与 Mimo-TTS API 进行通信
|
||||
/// 使用 Builder 模式构建复杂的 API 请求
|
||||
/// 使用 reqwest 作为 HTTP 客户端
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use futures::StreamExt;
|
||||
use reqwest::header::{self, HeaderMap, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// TTS 客户端结构体
|
||||
///
|
||||
/// 封装 API 调用所需的配置和 HTTP 客户端
|
||||
pub struct TtsClient {
|
||||
/// API 基础 URL
|
||||
base_url: String,
|
||||
/// API 密钥
|
||||
api_key: String,
|
||||
/// HTTP 客户端实例
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl TtsClient {
|
||||
/// 创建新的 TTS 客户端构建器
|
||||
///
|
||||
/// 使用 Builder 模式构建客户端实例
|
||||
pub fn builder() -> TtsClientBuilder {
|
||||
TtsClientBuilder::new()
|
||||
}
|
||||
|
||||
/// 合成语音(使用预构建的请求对象)
|
||||
///
|
||||
/// # 参数
|
||||
/// - request: 预构建的 TtsRequest 对象
|
||||
///
|
||||
/// # 返回
|
||||
/// 返回音频数据(字节数组)
|
||||
pub async fn synthesize_with_request(
|
||||
&self,
|
||||
request: &TtsRequest,
|
||||
) -> Result<Vec<u8>> {
|
||||
// 构建请求 Header
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"api-key",
|
||||
HeaderValue::from_str(&self.api_key)
|
||||
.context("无效的 API Key 格式")?,
|
||||
);
|
||||
headers.insert(
|
||||
header::AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {}", self.api_key))
|
||||
.context("无效的 Authorization 格式")?,
|
||||
);
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json"),
|
||||
);
|
||||
|
||||
// 如果是流式请求,添加 Accept 头
|
||||
if request.stream.unwrap_or(false) {
|
||||
headers.insert(
|
||||
header::ACCEPT,
|
||||
HeaderValue::from_static("text/event-stream"),
|
||||
);
|
||||
}
|
||||
|
||||
// 发送 POST 请求
|
||||
let response = self
|
||||
.client
|
||||
.post(&self.base_url)
|
||||
.headers(headers)
|
||||
.json(request)
|
||||
.send()
|
||||
.await
|
||||
.context("API 请求失败")?;
|
||||
|
||||
// 检查响应状态
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "无法读取错误信息".to_string());
|
||||
return Err(anyhow::anyhow!(
|
||||
"API 返回错误: {} - {}",
|
||||
status,
|
||||
error_text
|
||||
));
|
||||
}
|
||||
|
||||
// 如果是流式响应,读取流
|
||||
if request.stream.unwrap_or(false) {
|
||||
Self::read_stream_response(response).await
|
||||
} else {
|
||||
// 非流式响应,解析 JSON
|
||||
let response_body: TtsResponse = response
|
||||
.json()
|
||||
.await
|
||||
.context("无法解析 API 响应")?;
|
||||
|
||||
// 提取 Base64 编码的音频数据
|
||||
let audio_data = response_body
|
||||
.choices
|
||||
.first()
|
||||
.ok_or_else(|| anyhow::anyhow!("响应中没有选择项"))?
|
||||
.message
|
||||
.audio
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("响应中没有音频数据"))?
|
||||
.data
|
||||
.clone();
|
||||
|
||||
// Base64 解码
|
||||
let audio_bytes = general_purpose::STANDARD
|
||||
.decode(&audio_data)
|
||||
.context("Base64 解码失败")?;
|
||||
|
||||
Ok(audio_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取流式响应
|
||||
///
|
||||
/// 假设响应是 SSE 格式,每个事件包含 Base64 编码的音频块
|
||||
async fn read_stream_response(response: reqwest::Response) -> Result<Vec<u8>> {
|
||||
use futures::StreamExt;
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut audio_data = Vec::new();
|
||||
let mut buffer = String::new();
|
||||
|
||||
while let Some(item) = stream.next().await {
|
||||
let chunk = item.context("读取流式响应块失败")?;
|
||||
let text = String::from_utf8_lossy(&chunk);
|
||||
|
||||
// 处理 SSE 格式:按行分割
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("data: ") {
|
||||
let data_str = &line[6..]; // 跳过 "data: "
|
||||
if data_str == "[DONE]" {
|
||||
return Ok(audio_data);
|
||||
}
|
||||
// 尝试解析 JSON
|
||||
if let Ok(event) = serde_json::from_str::<SseEvent>(data_str) {
|
||||
if let Some(audio_b64) = event.audio {
|
||||
if let Ok(bytes) = general_purpose::STANDARD.decode(&audio_b64) {
|
||||
audio_data.extend_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(audio_data)
|
||||
}
|
||||
|
||||
/// 合成语音(简化版)
|
||||
///
|
||||
/// # 参数
|
||||
/// - text: 要合成的文本
|
||||
/// - voice: 音色名称
|
||||
/// - format: 音频格式
|
||||
/// - stream: 是否流式
|
||||
///
|
||||
/// # 返回
|
||||
/// 返回合成的音频数据
|
||||
pub async fn synthesize(
|
||||
&self,
|
||||
text: &str,
|
||||
voice: &str,
|
||||
format: &str,
|
||||
stream: bool,
|
||||
) -> Result<Vec<u8>> {
|
||||
// 构建请求体
|
||||
let mut builder = TtsRequest::builder()
|
||||
.model("mimo-v2.5-tts".to_string())
|
||||
.add_message(Message {
|
||||
role: "user".to_string(),
|
||||
content: "请合成下面的文本".to_string(),
|
||||
})
|
||||
.add_message(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: text.to_string(),
|
||||
})
|
||||
.audio(AudioConfig {
|
||||
format: if stream { "pcm16".to_string() } else { format.to_string() },
|
||||
voice: voice.to_string(),
|
||||
});
|
||||
|
||||
if stream {
|
||||
builder = builder.stream(true);
|
||||
}
|
||||
|
||||
let request = builder.build();
|
||||
self.synthesize_with_request(&request).await
|
||||
}
|
||||
}
|
||||
|
||||
/// SSE 事件结构体(用于解析流式响应)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SseEvent {
|
||||
#[serde(rename = "type", default)]
|
||||
event_type: Option<String>,
|
||||
audio: Option<String>,
|
||||
}
|
||||
|
||||
/// TTS 请求结构体
|
||||
///
|
||||
/// 遵循 OpenAI Chat Completions API 格式
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TtsRequest {
|
||||
/// 模型名称
|
||||
model: String,
|
||||
/// 消息列表
|
||||
messages: Vec<Message>,
|
||||
/// 音频配置
|
||||
audio: AudioConfig,
|
||||
/// 是否流式输出
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream: Option<bool>,
|
||||
}
|
||||
|
||||
impl TtsRequest {
|
||||
/// 创建请求构建器
|
||||
pub fn builder() -> TtsRequestBuilder {
|
||||
TtsRequestBuilder::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// TTS 请求构建器(Builder 模式)
|
||||
pub struct TtsRequestBuilder {
|
||||
model: Option<String>,
|
||||
messages: Vec<Message>,
|
||||
audio: Option<AudioConfig>,
|
||||
stream: Option<bool>,
|
||||
}
|
||||
|
||||
impl TtsRequestBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
model: None,
|
||||
messages: Vec::new(),
|
||||
audio: None,
|
||||
stream: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置模型名称
|
||||
pub fn model(mut self, model: String) -> Self {
|
||||
self.model = Some(model);
|
||||
self
|
||||
}
|
||||
|
||||
/// 添加消息
|
||||
pub fn add_message(mut self, message: Message) -> Self {
|
||||
self.messages.push(message);
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置音频配置
|
||||
pub fn audio(mut self, audio: AudioConfig) -> Self {
|
||||
self.audio = Some(audio);
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置流式输出
|
||||
pub fn stream(mut self, stream: bool) -> Self {
|
||||
self.stream = Some(stream);
|
||||
self
|
||||
}
|
||||
|
||||
/// 构建最终请求对象
|
||||
pub fn build(self) -> TtsRequest {
|
||||
TtsRequest {
|
||||
model: self.model.unwrap_or_else(|| "mimo-v2.5-tts".to_string()),
|
||||
messages: self.messages,
|
||||
audio: self.audio.unwrap_or_else(|| AudioConfig {
|
||||
format: "wav".to_string(),
|
||||
voice: "mimo_default".to_string(),
|
||||
}),
|
||||
stream: self.stream,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息结构体
|
||||
///
|
||||
/// 表示对话中的一条消息
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct Message {
|
||||
/// 角色:user 或 assistant
|
||||
pub role: String,
|
||||
/// 消息内容
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// 音频配置结构体
|
||||
///
|
||||
/// 指定合成语音的格式和音色
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct AudioConfig {
|
||||
/// 音频格式(当前支持 wav、mp3、pcm16)
|
||||
pub format: String,
|
||||
/// 音色名称(如 mimo_default、茉莉等)
|
||||
pub voice: String,
|
||||
}
|
||||
|
||||
/// TTS API 响应结构体
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TtsResponse {
|
||||
/// 选择项列表
|
||||
pub choices: Vec<Choice>,
|
||||
}
|
||||
|
||||
/// 选择项结构体
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Choice {
|
||||
/// 消息
|
||||
pub message: ResponseMessage,
|
||||
}
|
||||
|
||||
/// 响应消息结构体
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ResponseMessage {
|
||||
/// 音频数据(可选)
|
||||
pub audio: Option<AudioData>,
|
||||
}
|
||||
|
||||
/// 音频数据结构体
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AudioData {
|
||||
/// Base64 编码的音频数据
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// TTS 客户端构建器(Builder 模式)
|
||||
///
|
||||
/// 用于逐步构建 TtsClient 实例
|
||||
pub struct TtsClientBuilder {
|
||||
base_url: Option<String>,
|
||||
api_key: Option<String>,
|
||||
timeout: Option<Duration>,
|
||||
}
|
||||
|
||||
impl TtsClientBuilder {
|
||||
/// 创建新的构建器实例
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_url: None,
|
||||
api_key: None,
|
||||
timeout: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置 API 基础 URL
|
||||
pub fn base_url(mut self, url: String) -> Self {
|
||||
self.base_url = Some(url);
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置 API 密钥
|
||||
pub fn api_key(mut self, key: String) -> Self {
|
||||
self.api_key = Some(key);
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置请求超时时间
|
||||
#[allow(dead_code)]
|
||||
pub fn timeout(mut self, duration: Duration) -> Self {
|
||||
self.timeout = Some(duration);
|
||||
self
|
||||
}
|
||||
|
||||
/// 构建 TtsClient 实例
|
||||
///
|
||||
/// 必须提供 base_url 和 api_key,否则返回错误
|
||||
pub fn build(self) -> Result<TtsClient> {
|
||||
let base_url = self
|
||||
.base_url
|
||||
.ok_or_else(|| anyhow::anyhow!("未设置 base_url"))?;
|
||||
let api_key = self
|
||||
.api_key
|
||||
.ok_or_else(|| anyhow::anyhow!("未设置 api_key"))?;
|
||||
|
||||
let mut client_builder = reqwest::Client::builder();
|
||||
if let Some(timeout) = self.timeout {
|
||||
client_builder = client_builder.timeout(timeout);
|
||||
}
|
||||
|
||||
let client = client_builder
|
||||
.build()
|
||||
.context("无法创建 HTTP 客户端")?;
|
||||
|
||||
Ok(TtsClient {
|
||||
base_url,
|
||||
api_key,
|
||||
client,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_request_builder() {
|
||||
let request = TtsRequest::builder()
|
||||
.model("mimo-v2.5-tts".to_string())
|
||||
.add_message(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: "你好".to_string(),
|
||||
})
|
||||
.audio(AudioConfig {
|
||||
format: "wav".to_string(),
|
||||
voice: "mimo_default".to_string(),
|
||||
})
|
||||
.build();
|
||||
|
||||
assert_eq!(request.model, "mimo-v2.5-tts");
|
||||
assert_eq!(request.messages.len(), 1);
|
||||
assert_eq!(request.audio.voice, "mimo_default");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client_builder() {
|
||||
let result = TtsClient::builder()
|
||||
.base_url("https://api.example.com".to_string())
|
||||
.api_key("test-key".to_string())
|
||||
.build();
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
96
src/cli.rs
Normal file
96
src/cli.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
/// CLI 参数定义模块
|
||||
///
|
||||
/// 使用 clap derive 模式定义命令行参数
|
||||
/// 支持子命令和全局选项
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
/// MiMo TTS - 基于 Mimo-TTS API 的文本转语音工具
|
||||
///
|
||||
/// 支持多种音色和格式的语音合成,配置文件位于 ~/.config/tts/config.toml
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "mimo-tts",
|
||||
version,
|
||||
about = "MiMo TTS - 基于 Mimo-TTS API 的文本转语音工具",
|
||||
long_about = None
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// 子命令(可选)
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Commands>,
|
||||
|
||||
/// 要合成的文本(与 --file 互斥)
|
||||
#[arg(short, long, group = "input")]
|
||||
pub text: Option<String>,
|
||||
|
||||
/// 从文件读取要合成的文本(与 --text 互斥)
|
||||
#[arg(short, long, group = "input", value_parser)]
|
||||
pub file: Option<std::path::PathBuf>,
|
||||
|
||||
/// 音色名称(默认:mimo_default)
|
||||
#[arg(short, long, default_value = "mimo_default")]
|
||||
pub voice: String,
|
||||
|
||||
/// 风格描述(如:开心、东北话、孙悟空、唱歌等)
|
||||
#[arg(long)]
|
||||
pub style: Option<String>,
|
||||
|
||||
/// 输出文件路径(与 --play 互斥)
|
||||
#[arg(short, long)]
|
||||
pub output: Option<std::path::PathBuf>,
|
||||
|
||||
/// 音频格式(当前固定为 wav)
|
||||
#[arg(long, default_value = "wav")]
|
||||
pub format: String,
|
||||
|
||||
/// 直接播放音频(不保存文件,不输出到 stdout)
|
||||
#[arg(long)]
|
||||
pub play: bool,
|
||||
|
||||
/// 使用流式输出(格式自动改为 pcm16)
|
||||
#[arg(long)]
|
||||
pub stream: bool,
|
||||
}
|
||||
|
||||
/// 子命令枚举
|
||||
///
|
||||
/// 定义程序支持的子命令
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// 列出所有可用的音色
|
||||
Voices,
|
||||
|
||||
/// 配置管理
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
action: ConfigAction,
|
||||
},
|
||||
|
||||
/// 显示当前配置
|
||||
ShowConfig,
|
||||
|
||||
/// 初始化配置(引导式设置)
|
||||
Onboard,
|
||||
}
|
||||
|
||||
/// 配置管理子命令
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ConfigAction {
|
||||
/// 设置配置项
|
||||
Set {
|
||||
/// 设置 API Key
|
||||
#[arg(long)]
|
||||
api_key: Option<String>,
|
||||
|
||||
/// 设置默认音色
|
||||
#[arg(long)]
|
||||
voice: Option<String>,
|
||||
},
|
||||
|
||||
/// 显示当前配置
|
||||
Show,
|
||||
|
||||
/// 初始化配置文件(交互式)
|
||||
Init,
|
||||
}
|
||||
224
src/config.rs
Normal file
224
src/config.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
/// 配置管理模块
|
||||
///
|
||||
/// 采用分层配置设计:
|
||||
/// 1. project.config.toml - 项目默认配置(base_url、default_format)
|
||||
/// 2. ~/.config/tts/config.toml - 用户配置(api_key、default_voice)
|
||||
/// 用户配置会覆盖项目默认配置中的对应项
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use home::home_dir;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 项目配置(从项目根目录的 project.config.toml 读取)
|
||||
///
|
||||
/// 这是项目的默认配置,包含 API 地址和默认格式
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ProjectConfig {
|
||||
/// 项目版本号
|
||||
pub version: String,
|
||||
/// API 基础 URL 地址
|
||||
#[serde(default = "default_base_url")]
|
||||
pub base_url: String,
|
||||
/// 默认音频格式
|
||||
#[serde(default = "default_format")]
|
||||
pub default_format: String,
|
||||
}
|
||||
|
||||
impl Default for ProjectConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: "0.1.0".to_string(),
|
||||
base_url: default_base_url(),
|
||||
default_format: default_format(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户配置(从 ~/.config/tts/config.toml 读取)
|
||||
///
|
||||
/// 只包含用户需要设置的项,会覆盖项目默认配置中的对应项
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserConfig {
|
||||
/// Mimo-TTS API 密钥
|
||||
pub api_key: String,
|
||||
/// 默认音色
|
||||
#[serde(default = "default_voice")]
|
||||
pub default_voice: String,
|
||||
}
|
||||
|
||||
impl Default for UserConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
api_key: String::new(),
|
||||
default_voice: default_voice(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 合并后的应用配置
|
||||
///
|
||||
/// 结合了项目默认配置和用户配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Mimo-TTS API 密钥(来自用户配置)
|
||||
pub api_key: String,
|
||||
/// 默认音色(来自用户配置,覆盖项目默认值)
|
||||
pub default_voice: String,
|
||||
/// API 基础 URL 地址(来自项目配置)
|
||||
pub base_url: String,
|
||||
/// 默认音频格式(来自项目配置)
|
||||
pub default_format: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// 从项目配置和用户配置创建合并配置
|
||||
pub fn from_configs(project: ProjectConfig, user: UserConfig) -> Self {
|
||||
Self {
|
||||
api_key: user.api_key,
|
||||
default_voice: user.default_voice,
|
||||
base_url: project.base_url,
|
||||
default_format: project.default_format,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回默认的 API 基础 URL
|
||||
fn default_base_url() -> String {
|
||||
"https://api.xiaomimimo.com/v1/chat/completions".to_string()
|
||||
}
|
||||
|
||||
/// 返回默认的语音音色
|
||||
fn default_voice() -> String {
|
||||
"mimo_default".to_string()
|
||||
}
|
||||
|
||||
/// 返回默认的音频格式
|
||||
fn default_format() -> String {
|
||||
"wav".to_string()
|
||||
}
|
||||
|
||||
/// 配置管理器(Singleton 模式)
|
||||
///
|
||||
/// 负责加载项目配置和用户配置,并合并为应用配置
|
||||
pub struct ConfigManager {
|
||||
/// 合并后的配置
|
||||
config: Config,
|
||||
/// 用户配置文件路径
|
||||
user_config_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ConfigManager {
|
||||
/// 创建配置管理器并加载配置
|
||||
pub fn new() -> Result<Self> {
|
||||
// 1. 加载项目配置(从项目根目录的 project.config.toml)
|
||||
let project_config = Self::load_project_config()?;
|
||||
|
||||
// 2. 加载用户配置(从 ~/.config/tts/config.toml)
|
||||
let (user_config, user_config_path) = Self::load_user_config()?;
|
||||
|
||||
// 3. 合并配置
|
||||
let config = Config::from_configs(project_config, user_config);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
user_config_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// 加载项目配置
|
||||
fn load_project_config() -> Result<ProjectConfig> {
|
||||
let project_config_path = std::env::current_dir()
|
||||
.context("无法获取当前目录")?
|
||||
.join("project.config.toml");
|
||||
|
||||
if project_config_path.exists() {
|
||||
let content = fs::read_to_string(&project_config_path)
|
||||
.with_context(|| format!("无法读取项目配置文件: {:?}", project_config_path))?;
|
||||
let config: ProjectConfig = toml::from_str(&content)
|
||||
.with_context(|| format!("无法解析项目配置文件: {:?}", project_config_path))?;
|
||||
Ok(config)
|
||||
} else {
|
||||
// 如果项目配置文件不存在,使用默认配置
|
||||
Ok(ProjectConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载用户配置
|
||||
fn load_user_config() -> Result<(UserConfig, PathBuf)> {
|
||||
// 所有平台统一使用 ~/.config/tts/config.toml
|
||||
let home = home_dir().ok_or_else(|| anyhow::anyhow!("无法获取用户家目录"))?;
|
||||
let config_dir = home.join(".config").join("tts");
|
||||
let config_path = config_dir.join("config.toml");
|
||||
|
||||
// 确保配置目录存在
|
||||
fs::create_dir_all(&config_dir)
|
||||
.with_context(|| format!("无法创建配置目录: {:?}", config_dir))?;
|
||||
|
||||
let config = if config_path.exists() {
|
||||
let content = fs::read_to_string(&config_path)
|
||||
.with_context(|| format!("无法读取用户配置文件: {:?}", config_path))?;
|
||||
let config: UserConfig = toml::from_str(&content)
|
||||
.with_context(|| format!("无法解析用户配置文件: {:?}", config_path))?;
|
||||
config
|
||||
} else {
|
||||
UserConfig::default()
|
||||
};
|
||||
|
||||
Ok((config, config_path))
|
||||
}
|
||||
|
||||
/// 获取合并后的配置引用
|
||||
pub fn get_config(&self) -> &Config {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 更新 API Key
|
||||
pub fn set_api_key(&mut self, api_key: String) {
|
||||
self.config.api_key = api_key;
|
||||
}
|
||||
|
||||
/// 更新默认音色
|
||||
pub fn set_default_voice(&mut self, voice: String) {
|
||||
self.config.default_voice = voice;
|
||||
}
|
||||
|
||||
/// 获取用户配置文件路径
|
||||
pub fn get_config_path(&self) -> &PathBuf {
|
||||
&self.user_config_path
|
||||
}
|
||||
|
||||
/// 保存用户配置到文件
|
||||
///
|
||||
/// 只保存用户配置项(api_key、default_voice)
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let user_config = UserConfig {
|
||||
api_key: self.config.api_key.clone(),
|
||||
default_voice: self.config.default_voice.clone(),
|
||||
};
|
||||
|
||||
let content = toml::to_string_pretty(&user_config)
|
||||
.context("无法序列化用户配置")?;
|
||||
|
||||
fs::write(&self.user_config_path, content)
|
||||
.with_context(|| format!("无法写入用户配置文件: {:?}", self.user_config_path))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_voice() {
|
||||
assert_eq!(default_voice(), "mimo_default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_base_url() {
|
||||
assert_eq!(default_base_url(), "https://api.xiaomimimo.com/v1/chat/completions");
|
||||
}
|
||||
}
|
||||
447
src/main.rs
Normal file
447
src/main.rs
Normal file
@@ -0,0 +1,447 @@
|
||||
mod cli;
|
||||
mod config;
|
||||
mod api;
|
||||
mod ui;
|
||||
mod tone;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Commands, ConfigAction};
|
||||
use config::ConfigManager;
|
||||
use rodio;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::process;
|
||||
|
||||
/// 程序退出码定义
|
||||
///
|
||||
/// 遵循 agents.md 中定义的退出码规范
|
||||
#[derive(Debug)]
|
||||
enum ExitCode {
|
||||
Success = 0,
|
||||
ArgumentError = 1,
|
||||
ConfigError = 2,
|
||||
ApiError = 3,
|
||||
FileError = 4,
|
||||
}
|
||||
|
||||
impl From<ExitCode> for i32 {
|
||||
fn from(code: ExitCode) -> Self {
|
||||
code as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// 主函数
|
||||
///
|
||||
/// 使用 tokio 运行时处理异步 API 调用
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// 解析命令行参数
|
||||
let cli = Cli::parse();
|
||||
|
||||
// 执行程序逻辑,如果出错则处理错误并返回对应退出码
|
||||
let exit_code = match run(cli).await {
|
||||
Ok(_) => ExitCode::Success,
|
||||
Err(e) => {
|
||||
// 根据错误类型返回对应的退出码
|
||||
eprintln!("错误: {:#}", e);
|
||||
|
||||
// 简化错误处理,根据错误信息判断类型
|
||||
let error_msg = e.to_string();
|
||||
if error_msg.contains("API") || error_msg.contains("请求") {
|
||||
ExitCode::ApiError
|
||||
} else if error_msg.contains("配置") {
|
||||
ExitCode::ConfigError
|
||||
} else if error_msg.contains("文件") || error_msg.contains("读取") || error_msg.contains("写入") {
|
||||
ExitCode::FileError
|
||||
} else {
|
||||
ExitCode::ArgumentError
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
process::exit(exit_code.into());
|
||||
}
|
||||
|
||||
/// 程序主逻辑
|
||||
async fn run(cli: Cli) -> Result<()> {
|
||||
match cli.command {
|
||||
// 处理子命令
|
||||
Some(Commands::Voices) => {
|
||||
list_voices();
|
||||
Ok(())
|
||||
}
|
||||
Some(Commands::ShowConfig) => {
|
||||
show_config()
|
||||
}
|
||||
Some(Commands::Config { action }) => {
|
||||
handle_config_command(action)
|
||||
}
|
||||
Some(Commands::Onboard) => {
|
||||
// 引导式配置初始化
|
||||
onboard().await
|
||||
}
|
||||
// 没有子命令时,执行语音合成
|
||||
None => {
|
||||
// 检查参数组合
|
||||
if cli.play && cli.output.is_some() {
|
||||
return Err(anyhow::anyhow!("--play 和 --output 不能同时使用"));
|
||||
}
|
||||
|
||||
// 检查是否有输入(text 或 file)
|
||||
if cli.text.is_none() && cli.file.is_none() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"必须提供 --text 或 --file 参数\n使用 --help 查看帮助信息"
|
||||
));
|
||||
}
|
||||
|
||||
// 执行语音合成
|
||||
let audio_data = synthesize(
|
||||
cli.text,
|
||||
cli.file,
|
||||
&cli.voice,
|
||||
&cli.format,
|
||||
cli.style.as_deref(),
|
||||
cli.stream,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 根据参数决定处理方式
|
||||
if cli.play {
|
||||
// 播放音频(流式数据需要封装成 WAV 格式)
|
||||
ui::show_playback_start();
|
||||
if cli.stream {
|
||||
// 流式返回的是 PCM16 原始数据,需要添加 WAV 头
|
||||
let wav_data = pcm16_to_wav(&audio_data);
|
||||
play_audio(&wav_data)?;
|
||||
} else {
|
||||
play_audio(&audio_data)?;
|
||||
}
|
||||
ui::show_playback_complete();
|
||||
} else if let Some(output_path) = cli.output {
|
||||
// 保存到文件
|
||||
fs::write(&output_path, &audio_data)
|
||||
.with_context(|| format!("无法写入文件: {:?}", output_path))?;
|
||||
ui::show_save_complete(&output_path.to_string_lossy());
|
||||
} else {
|
||||
// 输出到 stdout(二进制流)
|
||||
let stdout = std::io::stdout();
|
||||
let mut handle = stdout.lock();
|
||||
use std::io::Write;
|
||||
handle.write_all(&audio_data)
|
||||
.context("无法写入标准输出")?;
|
||||
handle.flush()
|
||||
.context("无法刷新标准输出")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出所有可用的音色
|
||||
///
|
||||
/// 显示详细的音色信息,包括 Voice ID、语言、性别
|
||||
fn list_voices() {
|
||||
ui::show_voices();
|
||||
}
|
||||
|
||||
/// 合法的音色列表(mimo-v2.5-tts 支持)
|
||||
const VALID_VOICES: &[&str] = &[
|
||||
"mimo_default",
|
||||
"冰糖",
|
||||
"茉莉",
|
||||
"苏打",
|
||||
"白桦",
|
||||
"Mia",
|
||||
"Chloe",
|
||||
"Milo",
|
||||
"Dean",
|
||||
];
|
||||
|
||||
/// 验证音色是否合法
|
||||
///
|
||||
/// 如果音色不在合法列表中,输出警告并使用默认音色 mimo_default
|
||||
fn validate_voice(voice: &str) -> String {
|
||||
if VALID_VOICES.contains(&voice) {
|
||||
voice.to_string()
|
||||
} else {
|
||||
eprintln!("警告:无效音色 '{}',使用默认音色 'mimo_default'", voice);
|
||||
"mimo_default".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示当前配置
|
||||
fn show_config() -> Result<()> {
|
||||
let config_manager = ConfigManager::new()
|
||||
.context("无法加载配置")?;
|
||||
let config = config_manager.get_config();
|
||||
|
||||
ui::show_config(
|
||||
&config.api_key,
|
||||
&config.default_voice,
|
||||
&config_manager.get_config_path().to_string_lossy(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理配置相关子命令
|
||||
fn handle_config_command(action: ConfigAction) -> Result<()> {
|
||||
match action {
|
||||
ConfigAction::Set { api_key, voice, .. } => {
|
||||
let mut config_manager = ConfigManager::new()
|
||||
.context("无法加载配置")?;
|
||||
|
||||
if let Some(key) = api_key {
|
||||
config_manager.set_api_key(key);
|
||||
ui::show_success("API Key 已更新");
|
||||
}
|
||||
|
||||
if let Some(v) = voice {
|
||||
config_manager.set_default_voice(v);
|
||||
ui::show_success("默认音色已更新");
|
||||
}
|
||||
|
||||
config_manager.save()
|
||||
.context("无法保存配置")?;
|
||||
|
||||
ui::show_info("📁 配置已保存到:", &config_manager.get_config_path().to_string_lossy());
|
||||
}
|
||||
ConfigAction::Show => {
|
||||
show_config()?;
|
||||
}
|
||||
ConfigAction::Init => {
|
||||
// 交互式初始化
|
||||
ui::show_info("初始化配置...", "");
|
||||
let config_manager = ConfigManager::new()
|
||||
.context("无法创建配置")?;
|
||||
|
||||
ui::show_info("请使用以下命令设置 API Key:", "");
|
||||
println!(" mimo-tts config set --api-key <YOUR_API_KEY>");
|
||||
ui::show_info("配置文件将保存在:", &config_manager.get_config_path().to_string_lossy());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 引导式配置初始化
|
||||
///
|
||||
/// 交互式引导用户完成配置设置
|
||||
async fn onboard() -> Result<()> {
|
||||
let config_manager = ConfigManager::new()
|
||||
.context("无法创建配置管理器")?;
|
||||
|
||||
let current_config = config_manager.get_config();
|
||||
|
||||
// 使用 UI 模块显示交互式表单
|
||||
let result = ui::show_onboard_form(
|
||||
¤t_config.api_key,
|
||||
¤t_config.default_voice,
|
||||
);
|
||||
|
||||
let (api_key, default_voice) = result
|
||||
.map_err(|e| anyhow::anyhow!("表单输入错误: {}", e))?;
|
||||
|
||||
// 保存配置
|
||||
let mut config_manager = ConfigManager::new()
|
||||
.context("无法创建配置管理器")?;
|
||||
|
||||
if !api_key.is_empty() {
|
||||
config_manager.set_api_key(api_key);
|
||||
}
|
||||
|
||||
if !default_voice.is_empty() {
|
||||
config_manager.set_default_voice(default_voice);
|
||||
}
|
||||
|
||||
config_manager.save()
|
||||
.context("无法保存配置")?;
|
||||
|
||||
ui::show_info("📁 配置已保存到:", &config_manager.get_config_path().to_string_lossy());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 执行语音合成
|
||||
///
|
||||
/// # 参数
|
||||
/// - text: 直接提供的文本(可选)
|
||||
/// - file: 文本文件路径(可选)
|
||||
/// - voice: 音色名称
|
||||
/// - format: 音频格式
|
||||
/// - style: 风格描述(可选,会放在 user 消息中)
|
||||
/// - stream: 是否使用流式输出
|
||||
///
|
||||
/// # 返回
|
||||
/// 返回合成的音频数据(WAV 或 PCM16 格式)
|
||||
async fn synthesize(
|
||||
text: Option<String>,
|
||||
file: Option<std::path::PathBuf>,
|
||||
voice: &str,
|
||||
format: &str,
|
||||
style: Option<&str>,
|
||||
stream: bool,
|
||||
) -> Result<Vec<u8>> {
|
||||
// 获取要合成的文本
|
||||
let content = if let Some(t) = text {
|
||||
tone::apply_tone(&t)
|
||||
} else if let Some(f) = file {
|
||||
// 从文件读取文本
|
||||
let mut file = fs::File::open(&f)
|
||||
.with_context(|| format!("无法打开文件: {:?}", f))?;
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content)
|
||||
.with_context(|| format!("无法读取文件: {:?}", f))?;
|
||||
tone::apply_tone(&content)
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("没有提供文本内容"));
|
||||
};
|
||||
|
||||
// 验证音色是否合法,不合法则使用默认值
|
||||
let validated_voice = validate_voice(voice);
|
||||
|
||||
// 加载配置
|
||||
let config_manager = ConfigManager::new()
|
||||
.context("无法加载配置")?;
|
||||
let config = config_manager.get_config();
|
||||
|
||||
// 检查 API Key 是否设置
|
||||
if config.api_key.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"API Key 未设置\n请使用: mimo-tts config set --api-key <YOUR_API_KEY>"
|
||||
));
|
||||
}
|
||||
|
||||
// 创建 TTS 客户端
|
||||
let client = api::TtsClient::builder()
|
||||
.base_url(config.base_url.clone())
|
||||
.api_key(config.api_key.clone())
|
||||
.build()
|
||||
.context("无法创建 TTS 客户端")?;
|
||||
|
||||
// 流式输出时自动使用 pcm16 格式
|
||||
let actual_format = if stream { "pcm16" } else { format };
|
||||
|
||||
// 构建请求(如果指定了风格,添加到 user 消息)
|
||||
let mut builder = api::TtsRequest::builder()
|
||||
.audio(api::AudioConfig {
|
||||
format: actual_format.to_string(),
|
||||
voice: validated_voice,
|
||||
});
|
||||
|
||||
// 添加消息:如果指定了风格,先添加 user 消息描述风格
|
||||
if let Some(s) = style {
|
||||
builder = builder.add_message(api::Message {
|
||||
role: "user".to_string(),
|
||||
content: s.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 添加 assistant 消息(实际要合成的文本)
|
||||
builder = builder.add_message(api::Message {
|
||||
role: "assistant".to_string(),
|
||||
content: content.clone(),
|
||||
});
|
||||
|
||||
let request = builder.build();
|
||||
|
||||
// 调用 API 合成语音
|
||||
let audio_data = if stream {
|
||||
// 流式请求已在 api.rs 中处理
|
||||
client
|
||||
.synthesize_with_request(&request)
|
||||
.await
|
||||
.context("流式语音合成失败")?
|
||||
} else {
|
||||
client
|
||||
.synthesize_with_request(&request)
|
||||
.await
|
||||
.context("语音合成失败")?
|
||||
};
|
||||
|
||||
Ok(audio_data)
|
||||
}
|
||||
|
||||
/// 单元测试模块
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exit_code() {
|
||||
assert_eq!(i32::from(ExitCode::Success), 0);
|
||||
assert_eq!(i32::from(ExitCode::ArgumentError), 1);
|
||||
assert_eq!(i32::from(ExitCode::ConfigError), 2);
|
||||
assert_eq!(i32::from(ExitCode::ApiError), 3);
|
||||
assert_eq!(i32::from(ExitCode::FileError), 4);
|
||||
}
|
||||
}
|
||||
|
||||
/// 播放音频数据
|
||||
///
|
||||
/// 使用 rodio 直接从内存播放 WAV 音频
|
||||
/// # 参数
|
||||
/// - data: WAV 格式的音频数据
|
||||
fn play_audio(data: &[u8]) -> Result<()> {
|
||||
// 创建 rodio 音频输出流
|
||||
let (_stream, stream_handle) = rodio::OutputStream::try_default()
|
||||
.context("无法创建音频输出流")?;
|
||||
|
||||
// 从内存数据创建音频源
|
||||
let cursor = std::io::Cursor::new(data.to_vec());
|
||||
let source = rodio::Decoder::new(cursor)
|
||||
.context("无法解码音频数据")?;
|
||||
|
||||
// 创建播放器并播放(单次播放,不循环)
|
||||
let sink = rodio::Sink::try_new(&stream_handle)
|
||||
.context("无法创建音频播放器")?;
|
||||
sink.append(source);
|
||||
|
||||
// 等待播放完成
|
||||
sink.sleep_until_end();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 PCM16 原始数据转换为 WAV 格式
|
||||
///
|
||||
/// # 参数
|
||||
/// - pcm_data: PCM16 原始音频数据(16bit, 单声道, 24000Hz)
|
||||
///
|
||||
/// # 返回
|
||||
/// 完整的 WAV 格式数据(包含 44 字节头部)
|
||||
fn pcm16_to_wav(pcm_data: &[u8]) -> Vec<u8> {
|
||||
let sample_rate: u32 = 24000; // Mimo-TTS PCM16 输出通常是 24kHz
|
||||
let bits_per_sample: u16 = 16;
|
||||
let channels: u16 = 1;
|
||||
let byte_rate = sample_rate * channels as u32 * bits_per_sample as u32 / 8;
|
||||
let block_align = channels * bits_per_sample / 8;
|
||||
let data_size = pcm_data.len() as u32;
|
||||
let file_size = 36 + data_size;
|
||||
|
||||
let mut wav = Vec::with_capacity(44 + pcm_data.len());
|
||||
|
||||
// RIFF 头
|
||||
wav.extend_from_slice(b"RIFF");
|
||||
wav.extend_from_slice(&file_size.to_le_bytes());
|
||||
wav.extend_from_slice(b"WAVE");
|
||||
|
||||
// fmt 子块
|
||||
wav.extend_from_slice(b"fmt ");
|
||||
wav.extend_from_slice(&16u32.to_le_bytes()); // PCM 格式大小
|
||||
wav.extend_from_slice(&1u16.to_le_bytes()); // PCM 格式
|
||||
wav.extend_from_slice(&channels.to_le_bytes());
|
||||
wav.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
wav.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
wav.extend_from_slice(&block_align.to_le_bytes());
|
||||
wav.extend_from_slice(&bits_per_sample.to_le_bytes());
|
||||
|
||||
// data 子块
|
||||
wav.extend_from_slice(b"data");
|
||||
wav.extend_from_slice(&data_size.to_le_bytes());
|
||||
wav.extend_from_slice(pcm_data);
|
||||
|
||||
wav
|
||||
}
|
||||
147
src/tone.rs
Normal file
147
src/tone.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
/// 自动语气转换模块
|
||||
///
|
||||
/// 根据文本中的标点符号自动添加语气标签
|
||||
/// 符合 Mimo-TTS 音频标签控制规范
|
||||
/// 高内聚低耦合,可独立使用
|
||||
|
||||
/// 检测文本是否已包含风格标签
|
||||
///
|
||||
/// 支持的格式:[xxx]、(xxx)、(xxx)
|
||||
pub fn has_tone_tag(text: &str) -> bool {
|
||||
let trimmed = text.trim();
|
||||
trimmed.starts_with('[')
|
||||
|| trimmed.starts_with('(')
|
||||
|| trimmed.starts_with('(')
|
||||
}
|
||||
|
||||
/// 分析文本中的语气标签列表
|
||||
///
|
||||
/// # 参数
|
||||
/// - text: 要分析的文本
|
||||
///
|
||||
/// # 返回
|
||||
/// 返回检测到的语气标签列表
|
||||
pub fn analyze_tone(text: &str) -> Vec<&str> {
|
||||
let mut tones = Vec::new();
|
||||
|
||||
let has_exclamation = text.contains('!') || text.contains('!');
|
||||
let has_question = text.contains('?') || text.contains('?');
|
||||
let has_period = text.contains('。') || text.contains('.');
|
||||
let has_ellipsis = text.contains("……") || text.contains("...");
|
||||
|
||||
if has_exclamation {
|
||||
tones.push("激动");
|
||||
}
|
||||
if has_question {
|
||||
tones.push("疑惑");
|
||||
}
|
||||
if has_ellipsis {
|
||||
tones.push("拖长音");
|
||||
}
|
||||
if tones.is_empty() && has_period {
|
||||
tones.push("平静");
|
||||
}
|
||||
if tones.is_empty() {
|
||||
tones.push("平静");
|
||||
}
|
||||
|
||||
tones
|
||||
}
|
||||
|
||||
/// 插入细粒度控制标签(在标点符号附近)
|
||||
///
|
||||
/// # 参数
|
||||
/// - text: 原文本
|
||||
///
|
||||
/// # 返回
|
||||
/// 插入细粒度标签后的文本
|
||||
pub fn insert_mid_tone(text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
|
||||
// 处理感叹号 ! → !(激动)
|
||||
result = result.replace("!", "!(激动)");
|
||||
result = result.replace("!", "!(激动)");
|
||||
|
||||
// 处理问号 ? → ?(疑惑)
|
||||
result = result.replace("?", "?(疑惑)");
|
||||
result = result.replace("?", "?(疑惑)");
|
||||
|
||||
// 处理句号 。 → 。(停顿)
|
||||
result = result.replace("。", "。(停顿)");
|
||||
result = result.replace(".", "。(停顿)");
|
||||
|
||||
// 处理省略号 …… → ……(拖长音)
|
||||
result = result.replace("……", "……(拖长音)");
|
||||
result = result.replace("...", "……(拖长音)");
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 应用自动语气转换(主入口函数)
|
||||
///
|
||||
/// 在文本开头添加整体风格标签,并在标点附近插入细粒度控制标签
|
||||
/// 如果文本已有标签,则不重复添加
|
||||
///
|
||||
/// # 参数
|
||||
/// - text: 要转换的文本
|
||||
///
|
||||
/// # 返回
|
||||
/// 带语气标签的文本
|
||||
pub fn apply_tone(text: &str) -> String {
|
||||
// 检测是否已有标签,避免重复添加
|
||||
if has_tone_tag(text) {
|
||||
// 已有标签,只添加细粒度控制
|
||||
return insert_mid_tone(text);
|
||||
}
|
||||
|
||||
// 分析整体语气
|
||||
let tones = analyze_tone(text);
|
||||
let tone_label = tones.join(" ");
|
||||
|
||||
// 先插入细粒度标签
|
||||
let mut result = insert_mid_tone(text);
|
||||
|
||||
// 在文本开头添加整体风格标签
|
||||
result = format!("[{}]{}", tone_label, result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_has_tone_tag() {
|
||||
assert!(has_tone_tag("[开心]你好"));
|
||||
assert!(has_tone_tag("(开心)你好"));
|
||||
assert!(has_tone_tag("(开心)你好"));
|
||||
assert!(!has_tone_tag("你好"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_analyze_tone() {
|
||||
assert_eq!(analyze_tone("你好!"), vec!["激动"]);
|
||||
assert_eq!(analyze_tone("你好?"), vec!["疑惑"]);
|
||||
assert_eq!(analyze_tone("你好。"), vec!["平静"]);
|
||||
assert_eq!(analyze_tone("你好?!"), vec!["激动", "疑惑"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_mid_tone() {
|
||||
assert!(insert_mid_tone("你好!").contains("!(激动)"));
|
||||
assert!(insert_mid_tone("你好?").contains("?(疑惑)"));
|
||||
assert!(insert_mid_tone("你好。").contains("。(停顿)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_tone() {
|
||||
let result = apply_tone("你好!");
|
||||
assert!(result.starts_with("[激动]"));
|
||||
assert!(result.contains("!(激动)"));
|
||||
|
||||
let result2 = apply_tone("[开心]你好!");
|
||||
assert!(result2.starts_with("[开心]"));
|
||||
assert!(result2.contains("!(激动)"));
|
||||
}
|
||||
}
|
||||
225
src/ui.rs
Normal file
225
src/ui.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
/// UI 主题化模块
|
||||
///
|
||||
/// 使用 crossterm 提供美化的 CLI 输出和交互式界面
|
||||
|
||||
use crossterm::{
|
||||
style::{Color, ResetColor, SetForegroundColor},
|
||||
ExecutableCommand,
|
||||
};
|
||||
use std::io::{self, Write};
|
||||
|
||||
/// 应用主题颜色
|
||||
pub struct Theme;
|
||||
|
||||
impl Theme {
|
||||
pub const PRIMARY: Color = Color::Cyan;
|
||||
pub const SECONDARY: Color = Color::Blue;
|
||||
pub const SUCCESS: Color = Color::Green;
|
||||
pub const TEXT: Color = Color::White;
|
||||
pub const DIM: Color = Color::DarkGrey;
|
||||
}
|
||||
|
||||
/// 打印带颜色的文本
|
||||
fn print_colored(color: Color, text: &str) -> io::Result<()> {
|
||||
let mut stdout = io::stdout();
|
||||
stdout.execute(SetForegroundColor(color))?;
|
||||
write!(stdout, "{}", text)?;
|
||||
stdout.execute(ResetColor)?;
|
||||
stdout.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 打印带颜色的行(自动换行)
|
||||
fn println_colored(color: Color, text: &str) -> io::Result<()> {
|
||||
print_colored(color, &format!("{}\n", text))
|
||||
}
|
||||
|
||||
/// 显示标题
|
||||
pub fn show_title(title: &str) {
|
||||
let mut stdout = io::stdout();
|
||||
let _ = stdout.execute(SetForegroundColor(Theme::PRIMARY));
|
||||
let _ = stdout.write_all(format!("\n {}\n", title).as_bytes());
|
||||
let _ = stdout.execute(ResetColor);
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
|
||||
/// 显示成功消息
|
||||
pub fn show_success(message: &str) {
|
||||
let mut stdout = io::stdout();
|
||||
let _ = stdout.execute(SetForegroundColor(Theme::SUCCESS));
|
||||
let _ = write!(stdout, " ✓ {}", message);
|
||||
let _ = stdout.execute(ResetColor);
|
||||
let _ = writeln!(stdout);
|
||||
}
|
||||
|
||||
/// 显示信息(带缩进)
|
||||
pub fn show_info(label: &str, value: &str) {
|
||||
let mut stdout = io::stdout();
|
||||
let _ = stdout.execute(SetForegroundColor(Theme::SECONDARY));
|
||||
let _ = write!(stdout, " {} ", label);
|
||||
let _ = stdout.execute(ResetColor);
|
||||
let _ = writeln!(stdout, "{}", value);
|
||||
}
|
||||
|
||||
/// 显示分隔线
|
||||
pub fn show_separator() {
|
||||
let _ = println_colored(Theme::DIM, " ────────────────────────────────────────────────");
|
||||
}
|
||||
|
||||
/// 显示音色列表(美化版)
|
||||
pub fn show_voices() {
|
||||
show_title("可用音色列表");
|
||||
show_separator();
|
||||
|
||||
println!();
|
||||
println!(" {:<16} {}", "Voice ID", "说明");
|
||||
show_separator();
|
||||
|
||||
let voices = [
|
||||
("mimo_default", "MiMo 默认音色(推荐)"),
|
||||
("default_zh", "中文音色(兼容旧版)"),
|
||||
("default_en", "英文音色(兼容旧版)"),
|
||||
];
|
||||
|
||||
for (id, desc) in voices {
|
||||
let mut stdout = io::stdout();
|
||||
let _ = stdout.execute(SetForegroundColor(Theme::PRIMARY));
|
||||
let _ = write!(stdout, " {:<16}", id);
|
||||
let _ = stdout.execute(ResetColor);
|
||||
let _ = writeln!(stdout, "{}", desc);
|
||||
}
|
||||
|
||||
println!();
|
||||
show_separator();
|
||||
println!();
|
||||
println!(" 使用方式: mimo-tts --voice <Voice ID> --text \"要合成的文本\"");
|
||||
println!(" 提示: Voice ID 区分大小写");
|
||||
println!();
|
||||
}
|
||||
|
||||
/// 显示配置信息(美化版)
|
||||
pub fn show_config(api_key: &str, default_voice: &str, config_path: &str) {
|
||||
show_title("当前配置");
|
||||
show_separator();
|
||||
println!();
|
||||
|
||||
show_info("📁 配置文件:", config_path);
|
||||
show_info("🔑 API Key:", if api_key.is_empty() { "(未设置)" } else { "***" });
|
||||
show_info("🎙 默认音色:", default_voice);
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
/// 读取密码输入(隐藏字符)
|
||||
pub fn read_password(prompt: &str) -> io::Result<String> {
|
||||
use crossterm::{
|
||||
event::{read, Event, KeyCode, KeyEvent},
|
||||
terminal::{disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
|
||||
let mut stdout = io::stdout();
|
||||
let _ = write!(stdout, " {} ", prompt);
|
||||
let _ = stdout.flush();
|
||||
|
||||
enable_raw_mode()?;
|
||||
|
||||
let mut password = String::new();
|
||||
|
||||
loop {
|
||||
match read()? {
|
||||
Event::Key(KeyEvent { code, .. }) => match code {
|
||||
KeyCode::Enter => {
|
||||
disable_raw_mode()?;
|
||||
println!();
|
||||
break Ok(password);
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
password.push(c);
|
||||
let _ = write!(stdout, "*");
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if !password.is_empty() {
|
||||
password.pop();
|
||||
let _ = write!(stdout, "\x08 \x08");
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
disable_raw_mode()?;
|
||||
println!();
|
||||
break Ok(String::new());
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取普通输入
|
||||
pub fn read_input(prompt: &str) -> io::Result<String> {
|
||||
let mut stdout = io::stdout();
|
||||
let _ = write!(stdout, " {} ", prompt);
|
||||
let _ = stdout.flush();
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
Ok(input.trim().to_string())
|
||||
}
|
||||
|
||||
/// 显示 onboard 表单(交互式配置)
|
||||
pub fn show_onboard_form(
|
||||
current_api_key: &str,
|
||||
current_voice: &str,
|
||||
) -> io::Result<(String, String)> {
|
||||
show_title("MiMo TTS 配置向导");
|
||||
show_separator();
|
||||
println!();
|
||||
let _ = println_colored(Theme::TEXT, " 欢迎使用 MiMo TTS 配置向导!");
|
||||
let _ = println_colored(Theme::DIM, " 请按照以下步骤完成配置,按 Enter 跳过保持当前值");
|
||||
println!();
|
||||
|
||||
// API Key
|
||||
show_info("当前 API Key:", if current_api_key.is_empty() { "(未设置)" } else { "***" });
|
||||
let api_key = read_password("🔑 输入新 API Key (留空保持不变):")?;
|
||||
let api_key = if api_key.is_empty() {
|
||||
current_api_key.to_string()
|
||||
} else {
|
||||
api_key
|
||||
};
|
||||
|
||||
println!();
|
||||
|
||||
// 默认音色
|
||||
show_info("当前默认音色:", current_voice);
|
||||
let _ = println_colored(Theme::DIM, " 可选音色: mimo_default, 冰糖, 茉莉, 苏打, 白桦, Mia, Chloe, Milo, Dean");
|
||||
let voice_input = read_input("🎙 输入新默认音色 (留空保持当前):")?;
|
||||
let voice = if voice_input.is_empty() {
|
||||
current_voice.to_string()
|
||||
} else {
|
||||
voice_input
|
||||
};
|
||||
|
||||
println!();
|
||||
show_separator();
|
||||
show_success("配置向导完成!");
|
||||
println!();
|
||||
|
||||
Ok((api_key, voice))
|
||||
}
|
||||
|
||||
/// 显示播放状态
|
||||
pub fn show_playback_start() {
|
||||
show_info("▶", "正在播放音频...");
|
||||
}
|
||||
|
||||
/// 显示播放完成
|
||||
pub fn show_playback_complete() {
|
||||
show_success("播放完成");
|
||||
}
|
||||
|
||||
/// 显示保存完成
|
||||
pub fn show_save_complete(path: &str) {
|
||||
show_success(&format!("语音合成完成,已保存到: {}", path));
|
||||
}
|
||||
347
taolun.md
Normal file
347
taolun.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# 讨论记录 (taolun.md)
|
||||
|
||||
## 2026-04-24 - 项目启动
|
||||
|
||||
### 用户需求
|
||||
基于 Mimo-TTS API 开发 Rust CLI 工具,要求:
|
||||
- Rust 编写,单一二进制可执行文件
|
||||
- 后期通过 skill 给其他 claw 工具使用
|
||||
- CLI 参数调用方式
|
||||
- OOP + 设计模式架构
|
||||
- 全程中文沟通
|
||||
- 详细中文注释
|
||||
- 每次操作前先更新文档
|
||||
|
||||
### API 信息
|
||||
- Endpoint: `POST https://api.xiaomimimo.com/v1/chat/completions`
|
||||
- 认证:双 Header (`api-key` + `Authorization: Bearer`)
|
||||
- 音频格式:WAV(Base64 编码)
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-24 - 实施记录
|
||||
|
||||
### 第一轮完成
|
||||
1. ✅ 创建三个文档(taolun.md、changelog.md、agents.md)
|
||||
2. ✅ 配置 Rust 国内源(稀疏索引协议)
|
||||
3. ✅ 实现项目代码(config.rs、api.rs、cli.rs、main.rs)
|
||||
4. ✅ Debug + Release 构建成功
|
||||
5. ✅ 基本功能测试通过
|
||||
|
||||
### 第二轮修改
|
||||
**用户新增需求:**
|
||||
1. **project.config.toml**: 项目根目录,含 version,与 git version 一致
|
||||
2. **统一配置路径**:所有平台使用 `~/.config/tts/config.toml`
|
||||
3. **API Key**: sk-csa6s3bvpwqw21zfs3urbmqgrw720eoa1qdz6o42abk5xoj8
|
||||
4. **onboard 命令**:CLI 引导式配置初始化
|
||||
|
||||
**执行内容:**
|
||||
1. ✅ 创建 `project.config.toml` (version = "0.1.0")
|
||||
2. ✅ 修改 `Cargo.toml`(移除 dirs,添加 home 依赖)
|
||||
3. ✅ 修改 `config.rs`(统一路径为 `~/.config/tts/config.toml`)
|
||||
4. ✅ 修改 `cli.rs`(添加 Onboard 子命令)
|
||||
5. ✅ 修改 `main.rs`(处理 Onboard,恢复 main() 函数)
|
||||
6. ✅ 配置 API Key 成功
|
||||
7. ✅ 语音合成测试成功(test.wav、final_test.wav)
|
||||
8. ✅ Release 构建成功(无警告)
|
||||
|
||||
### 踩坑记录
|
||||
1. **Cargo 国内源超时**
|
||||
- 解决:改用稀疏索引协议(sparse+https://)
|
||||
|
||||
2. **clap derive 模式缺少 Parser trait 导入**
|
||||
- 解决:添加 `use clap::Parser;`
|
||||
|
||||
3. **main() 函数丢失**
|
||||
- 原因:编辑时不小心删除
|
||||
- 解决:恢复 main() 函数和 ExitCode 枚举
|
||||
|
||||
4. **配置文件路径统一**
|
||||
- 需求:所有平台使用 `~/.config/tts/config.toml`
|
||||
- 解决:使用 home 库获取家目录,手动拼接路径
|
||||
|
||||
### 项目最终状态
|
||||
- **位置**: /Users/titor/tts
|
||||
- **二进制**: target/release/mimo-tts (4.5MB)
|
||||
- **配置**: ~/.config/tts/config.toml(已配置 API Key)
|
||||
- **项目配置**: project.config.toml (version = "0.1.0")
|
||||
- **功能**:
|
||||
- ✅ TTS 语音合成
|
||||
- ✅ 配置管理(config set/show)
|
||||
- ✅ 引导式配置(onboard)
|
||||
- ✅ 列出音色(voices)
|
||||
- **架构**: Builder 模式 + Singleton 模式 + OOP
|
||||
|
||||
### 退出码规范
|
||||
- 0: 成功
|
||||
- 1: 参数错误
|
||||
- 2: 配置错误
|
||||
- 3: API 调用失败
|
||||
- 4: 文件操作失败
|
||||
|
||||
## 2026-04-24 - 第三轮修改
|
||||
|
||||
### 用户新需求
|
||||
**不要保存语音到本地,使用流式数据输出**
|
||||
- 语音数据直接输出到 stdout(二进制流)
|
||||
- 便于其他程序通过管道读取
|
||||
- 更适合作为 claw skill 集成
|
||||
|
||||
### 修改计划
|
||||
1. 修改 `synthesize()` 函数,返回音频数据而不是保存文件
|
||||
2. 修改 `main.rs`,支持输出到 stdout(二进制模式)
|
||||
3. 保留可选的 `--output` 参数用于保存到文件(向后兼容)
|
||||
4. 当输出到 stdout 时,不输出其他文本信息(避免污染二进制流)
|
||||
5. 流式调用可能需要使用 pcm16 格式(根据 API 文档)
|
||||
|
||||
|
||||
## 2026-04-24 - 第四轮修改
|
||||
|
||||
### 用户新需求
|
||||
**添加音频播放功能**
|
||||
- 使用 rodio 库直接播放音频
|
||||
- 添加 --play 参数触发播放模式
|
||||
- 默认单次播放,不循环
|
||||
- 保留 --output 和 stdout 输出功能
|
||||
|
||||
### 技术选型
|
||||
- HTTP 客户端:reqwest(已使用)
|
||||
- 异步运行时:tokio(已使用)
|
||||
- 音频播放:rodio 0.19
|
||||
- WAV 处理:无需额外库(rodio 直接支持)
|
||||
|
||||
## 2026-04-24 - 第四轮实施完成
|
||||
|
||||
### 已完成
|
||||
1. ✅ 修改 Cargo.toml 添加 rodio 0.19 依赖
|
||||
2. ✅ 修改 cli.rs 添加 --play 参数
|
||||
3. ✅ 修改 main.rs:
|
||||
- synthesize() 返回 Vec<u8>(音频数据)
|
||||
- 添加 play_audio() 函数(使用 rodio 播放)
|
||||
- 修改 run() 支持三种输出方式(播放/保存/stdout)
|
||||
4. ✅ 修复编译错误(synthesize() 重复代码、未使用导入)
|
||||
5. ✅ 修复 stdout 输出污染问题(移除 synthesize() 中的 println)
|
||||
6. ✅ Release 构建成功(无警告)
|
||||
7. ✅ 功能测试通过:
|
||||
- --play 播放音频 ✅
|
||||
- --output 保存文件 ✅
|
||||
- stdout 二进制流输出 ✅
|
||||
|
||||
### 最终项目状态
|
||||
- 二进制文件:target/release/mimo-tts (约 6MB,包含 rodio)
|
||||
- 支持三种输出方式:播放、保存、流式输出
|
||||
- 所有功能测试通过
|
||||
|
||||
## 2026-04-24 - 第五轮修改
|
||||
|
||||
### 用户确认需求
|
||||
**扩展音色支持**
|
||||
1. ✅ 更新音色列表为 Mimo-TTS 完整列表(8个音色)
|
||||
2. ✅ 默认音色从 default_zh 改为 mimo_default
|
||||
3. ✅ 添加音色验证,无效时使用默认音色
|
||||
4. ✅ 废弃 default_zh 和 default_en
|
||||
|
||||
### 完整音色列表
|
||||
| Voice ID | 语言 | 性别 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| mimo_default | 多语言 | - | MiMo默认(中国集群=冰糖)|
|
||||
| 冰糖 | 中文 | 女性 | 甜美清脆的女声 |
|
||||
| 茉莉 | 中文 | 女性 | 温柔知性的女声 |
|
||||
| 苏打 | 中文 | 男性 | 阳光活力的男声 |
|
||||
| 白桦 | 中文 | 男性 | 沉稳大气的男声 |
|
||||
| Mia | 英文 | 女性 | Sweet and gentle female |
|
||||
| Chloe | 英文 | 女性 | Warm and articulate female |
|
||||
| Milo | 英文 | 男性 | Energetic young male |
|
||||
| Dean | 英文 | 男性 | Deep and steady male |
|
||||
|
||||
## 2026-04-24 - 第五轮修改(修复)
|
||||
|
||||
### 问题修复
|
||||
- **默认音色未生效**:config.rs 中 `default_voice()` 函数返回值仍是 `default_zh`
|
||||
- 修复:将 `config.rs:39` 默认值改为 `mimo_default`
|
||||
- 更新配置文件 `~/.config/tts/config.toml` 中的 `default_voice`
|
||||
- 验证:show-config 现在正确显示 `mimo_default`
|
||||
|
||||
## 2026-04-24 - 第六轮修改
|
||||
|
||||
### 用户需求
|
||||
**UI 主题化所有 CLI 输出**
|
||||
1. 使用 ratatui + crossterm 美化 CLI 输出
|
||||
2. onboard 改为完整表单页面(同时显示所有配置项)
|
||||
3. API Key 输入支持隐藏(显示 * 代替)
|
||||
4. 所有命令输出都使用主题美化
|
||||
5. 不需要简洁模式
|
||||
|
||||
### 实施方案
|
||||
- 新增 src/ui.rs 模块(主题、组件)
|
||||
- 修改 Cargo.toml 添加 ratatui + crossterm
|
||||
- 美化 list_voices()、show_config() 输出
|
||||
- 重写 onboard() 为完整交互式表单
|
||||
- 美化所有命令输出(播放、保存等)
|
||||
|
||||
### 第六轮实施完成
|
||||
1. ✅ 创建 src/ui.rs 模块(使用 crossterm 美化输出)
|
||||
2. ✅ 修改 main.rs 引入 ui 模块
|
||||
3. ✅ 美化 list_voices() 输出(彩色表格)
|
||||
4. ✅ 美化 show_config() 输出(彩色标签)
|
||||
5. ✅ 重写 onboard() 为交互式表单(支持密码隐藏输入)
|
||||
6. ✅ 美化播放和保存完成消息
|
||||
7. ✅ Release 构建成功(仅有未使用代码警告)
|
||||
8. ✅ 功能测试通过(voices、show-config)
|
||||
|
||||
### 技术细节
|
||||
- 使用 crossterm 而非完整的 ratatui Terminal(简化实现)
|
||||
- 密码输入使用 enable_raw_mode 实现字符隐藏
|
||||
- 输出使用颜色主题(PRIMARY、SUCCESS、ERROR 等)
|
||||
- show_onboard_form 返回 Result 类型,由调用者处理错误
|
||||
|
||||
### 第七轮修改
|
||||
|
||||
### 用户需求
|
||||
**配置分层设计**
|
||||
1. `project.config.toml` - 项目默认配置(全量配置)
|
||||
- version
|
||||
- base_url
|
||||
- default_format
|
||||
2. `~/.config/tts/config.toml` - 用户配置(仅覆盖项)
|
||||
- api_key
|
||||
- default_voice
|
||||
3. 临时文件只允许存放在当前目录下
|
||||
|
||||
### 实施方案
|
||||
- 修改 `project.config.toml` 添加 base_url 和 default_format
|
||||
- 重写 `config.rs` 实现分层配置加载
|
||||
- 修改 `cli.rs` 移除 base_url 参数
|
||||
- 修改 `ui.rs` 简化显示和表单(只处理 api_key 和 default_voice)
|
||||
- 修改 `main.rs` 使用新的配置结构
|
||||
- 测试时临时文件输出到当前目录
|
||||
|
||||
### 第七轮实施完成
|
||||
1. ✅ 创建分层配置结构(ProjectConfig + UserConfig + Config)
|
||||
2. ✅ `project.config.toml` 包含默认配置(base_url、default_format)
|
||||
3. ✅ 用户配置只保存 api_key 和 default_voice
|
||||
4. ✅ 修改 cli.rs 移除 base_url 参数
|
||||
5. ✅ 修改 ui.rs 简化显示和表单
|
||||
6. ✅ Release 构建成功(仅有未使用字段警告)
|
||||
7. ✅ 功能测试通过(show-config、voices、语音合成)
|
||||
8. ✅ 临时文件生成在当前目录
|
||||
|
||||
### 技术细节
|
||||
- ConfigManager 先加载项目配置,再加载用户配置,最后合并
|
||||
- save() 只保存 UserConfig(api_key、default_voice)
|
||||
- project.config.toml 从当前项目目录读取
|
||||
- 用户配置路径:`~/.config/tts/config.toml`
|
||||
|
||||
## 2026-04-24 - 第八轮修改
|
||||
|
||||
### 用户反馈
|
||||
**使用 --voice 提示失败**
|
||||
- 问题:使用 `--voice '茉莉'` 时报错:Unknown voice
|
||||
- 原因:之前使用了错误的 API 文档,实际 API 只支持 3 个预置音色
|
||||
- 最新发现:mimo-v2.5-tts 模型支持更多音色(9 个)
|
||||
- 模型名应为 `mimo-v2.5-tts`(不是 `mimo-v2-tts`)
|
||||
|
||||
### 音色列表(mimo-v2.5-tts)
|
||||
| Voice ID | 语言 | 性别 | 说明 |
|
||||
|----------|------|------|------|
|
||||
| mimo_default | 多语言 | - | MiMo默认(中国集群=冰糖)|
|
||||
| 冰糖 | 中文 | 女性 | 甜美清脆的女声 |
|
||||
| 茉莉 | 中文 | 女性 | 温柔知性的女声 |
|
||||
| 苏打 | 中文 | 男性 | 阳光活力的男声 |
|
||||
| 白桦 | 中文 | 男性 | 沉稳大气的男声 |
|
||||
| Mia | 英文 | 女性 | Sweet and gentle female |
|
||||
| Chloe | 英文 | 女性 | Warm and articulate female |
|
||||
| Milo | 英文 | 男性 | Energetic young male |
|
||||
| Dean | 英文 | 男性 | Deep and steady male |
|
||||
|
||||
### 新增功能
|
||||
- `--style` 参数:风格描述(如:开心、东北话、孙悟空、唱歌等)
|
||||
- 风格通过 `user` 消息传递(不是 `<style>` 标签)
|
||||
- 模型名更新为 `mimo-v2.5-tts`
|
||||
|
||||
### 第八轮实施完成
|
||||
1. ✅ 更新模型名为 `mimo-v2.5-tts`
|
||||
2. ✅ 恢复 9 个音色列表
|
||||
3. ✅ 添加 `--style` 参数支持
|
||||
4. ✅ 修改风格传递方式(用 user 消息而非 `<style>` 标签)
|
||||
5. ✅ 修复编译错误(删除多余代码块)
|
||||
6. ✅ 测试通过(默认音色、茉莉音色均正常)
|
||||
7. ✅ 临时文件生成在当前目录
|
||||
|
||||
## 2026-04-24 - 第九轮修改
|
||||
|
||||
### 用户需求
|
||||
**实现流式输出功能**
|
||||
- `--stream` 参数已在 cli.rs 定义,需要实现
|
||||
- 流式输出时格式自动改为 pcm16
|
||||
- 使用流式 API 调用(Server-Sent Events)
|
||||
- 输出到 stdout(二进制流)
|
||||
|
||||
### 实施方案
|
||||
- 修改 api.rs 添加流式请求方法
|
||||
- 使用 reqwest 的流式响应处理
|
||||
- 处理 SSE(Server-Sent Events)格式
|
||||
- 拼接音频数据后输出到 stdout
|
||||
- 如果指定了 --output,则保存到文件
|
||||
|
||||
### 第九轮实施完成
|
||||
|
||||
1. ✅ 修改 Cargo.toml 添加依赖(futures、tokio-util、reqwest stream feature)
|
||||
2. ✅ 修改 main.rs:
|
||||
- synthesize() 函数添加 stream 参数
|
||||
- 流式输出时自动使用 pcm16 格式
|
||||
- 传递 stream 参数给 API 调用
|
||||
- 支持 --stream 和 --play 同时使用(PCM16 转 WAV)
|
||||
3. ✅ 修改 api.rs:
|
||||
- 修复 read_stream_response 函数(使用 futures::StreamExt)
|
||||
- 正确处理 SSE 格式流式响应
|
||||
4. ✅ 新增 pcm16_to_wav() 函数(封装 WAV 头)
|
||||
5. ✅ Release 构建成功(仅有未使用代码警告)
|
||||
6. ✅ 流式输出测试通过(PCM16 数据正常)
|
||||
7. ✅ 流式播放测试通过(--stream --play 正常工作)
|
||||
|
||||
### 技术细节
|
||||
- 流式 API 返回原始 PCM16 数据(无 WAV 头)
|
||||
- 使用 futures::StreamExt 处理 reqwest 的 bytes_stream()
|
||||
- SSE 格式解析:`data: {...}` 和 `data: [DONE]`
|
||||
- 每个 chunk 包含 Base64 编码的音频数据
|
||||
- PCM16 转 WAV:添加 44 字节 WAV 头(24000Hz, 16bit, mono)
|
||||
- --stream 和 --play 同时使用时自动封装 WAV 格式后播放
|
||||
|
||||
## 2026-04-24 - 第十轮修改
|
||||
|
||||
### 用户需求
|
||||
**实现自动语气转换器(独立模块)**
|
||||
- 创建 `src/tone.rs` 独立模块(高内聚低耦合)
|
||||
- 默认启用,无需额外参数
|
||||
- 根据标点符号自动添加语气标签
|
||||
- 支持整体风格标签和细粒度控制标签
|
||||
|
||||
### 语气映射规则
|
||||
|
||||
#### 整体风格标签(文本开头)
|
||||
- 包含 `!` → `[激动]`
|
||||
- 包含 `?` → `[疑惑]`
|
||||
- 包含 `。` → `[平静]`(默认)
|
||||
- 多种标点组合 → `[激动 疑惑]` 等
|
||||
|
||||
#### 细粒度控制标签(文本中间)
|
||||
- `!` → `!(激动)`
|
||||
- `?` → `?(疑惑)`
|
||||
- `。` → `。(停顿)`
|
||||
- `……` → `……(拖长音)`
|
||||
|
||||
### 第十轮实施完成
|
||||
|
||||
1. ✅ 创建 src/tone.rs 模块
|
||||
2. ✅ 实现 apply_tone() 函数(整体语气)
|
||||
3. ✅ 实现 insert_mid_tone() 函数(细粒度控制)
|
||||
4. ✅ 实现 analyze_tone() 函数(分析语气)
|
||||
5. ✅ 实现 has_tone_tag() 函数(检测已有标签)
|
||||
6. ✅ 修改 main.rs 引入 tone 模块
|
||||
7. ✅ 在 synthesize() 中调用语气转换
|
||||
8. ✅ Release 构建成功
|
||||
9. ✅ 单元测试通过(4 个测试)
|
||||
10. ✅ 功能测试通过(激动语气、疑惑语气)
|
||||
|
||||
## 2026-04-24 - 第十轮修改
|
||||
Reference in New Issue
Block a user