fix: 修复流式播放无声音问题(SSE 行缓冲 + 解析层级)

This commit is contained in:
2026-05-09 04:06:47 +08:00
parent ceeb6a3c31
commit d34ebd147e
15 changed files with 679 additions and 458 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@
.code/ .code/
.fleet/ .fleet/
.cursor/ .cursor/
mimo-tts

2
Cargo.lock generated
View File

@@ -1225,7 +1225,7 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]] [[package]]
name = "mimo-tts" name = "mimo-tts"
version = "0.3.0" version = "0.3.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mimo-tts" name = "mimo-tts"
version = "0.3.0" version = "0.3.2"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View File

@@ -98,9 +98,29 @@
**解决方案**`text.chars().take(50).collect::<String>()` 按字符截取 **解决方案**`text.chars().take(50).collect::<String>()` 按字符截取
**经验**:处理多字节字符(中文)时,必须使用字符索引而非字节索引 **经验**:处理多字节字符(中文)时,必须使用字符索引而非字节索引
### 2026-05-09 - 代码质量提升
#### 问题HTTP 接口/实现不完整
**现象**HTTP POST /synthesize 只返回"请求已接收",未实际调用 TTS 合成
**原因**HTTP 服务器运行在 `std::thread` 中,`process_tts_request` 是 async 函数,无法直接调用
**解决方案**:传递 `tokio::runtime::Handle` 到 HTTP 线程,使用 `handle.block_on()` 执行 async 调用
**经验**:跨线程调用 async 函数时,需要用 `Handle::current()` + `block_on()` 桥接
#### 问题:语气替换长短匹配冲突
**现象**`...``.` 的替换误伤,变成 `。(停顿)。(停顿)。(停顿)`
**原因**`.replace()` 按顺序执行,短匹配(`.`)先于长匹配(`...`)处理
**解决方案**:长模式(`...`/`……`)必须优先于短模式(`.`/`。`
**经验**:字符串替换时始终让长匹配优先于短匹配
#### 问题:日志文件多线程并发写入
**现象**HTTP 线程与 async 线程可能同时写入同一日志文件,导致内容交错
**原因**`write_log` 在多个线程中独立打开文件句柄追加写入
**解决方案**:添加 `std::sync::Mutex<()>` 静态锁保护文件写入操作
**经验**:文件追加写入在多线程环境下仍需要同步
#### 问题Windows PowerShell 测试效率 #### 问题Windows PowerShell 测试效率
**现象**:测试守护进程时创建了多个 .ps1 临时文件,繁琐且不便管理 **现象**:测试守护进程时创建了多个 .ps1 临时文件,繁琐且不便管理
**原因**:不熟悉 PowerShell 命令<EFBFBD><EFBFBD>直接执行的方式 **原因**:不熟悉 PowerShell 命令直接执行的方式
**解决方案** **解决方案**
- 使用 `mimo-tts daemon start -d --port XXXX` 后台启动守护进程 - 使用 `mimo-tts daemon start -d --port XXXX` 后台启动守护进程
- 使用 PowerShell 一条命令直接发送 TCP 请求测试: - 使用 PowerShell 一条命令直接发送 TCP 请求测试:
@@ -109,3 +129,18 @@
``` ```
- 无需创建 .ps1 临时文件 - 无需创建 .ps1 临时文件
**经验**Windows PowerShell 可以在命令行中直接执行,无需临时文件 **经验**Windows PowerShell 可以在命令行中直接执行,无需临时文件
### 2026-05-09 - 流式播放修复
#### 问题流式播放没有声音SSE 解析错误)
**现象**:守护进程 `send --stream` 和 CLI `--stream --play` 均无声音输出,但非流式正常
**原因**(双重):
1. SSE 结构体层级错误:`audio` 在 `choices[0].delta.audio.data`,原代码只在顶层找
2. SSE 行分割问题HTTP chunk 边界不对齐 `\n`,长 `data:` 行被切碎导致 serde_json 解析失败
**解决方案**
1. 创建 `SseChunk`/`SseChoice`/`SseDelta`/`SseAudio` 四层嵌套结构,匹配 OpenAI chat.completion.chunk 格式
2. 添加 `line_buf` 行缓冲器:累积跨 chunk 的不完整行,遇到 `\n` 才取出处理
**经验**
- 对流式 HTTP 响应,永远不要假设 chunk 边界对齐消息边界,必须自己处理行缓冲
- 调试无声音问题时,先分段验证:数据是否到达 → 数据是否正确解码 → sink 是否收到数据
- 用 `--stream`(不带 `--play`)测试 JSON 响应路径 vs 用 curl 看 SSE 原始格式,能快速定位数据流断裂点

View File

@@ -1,48 +1,111 @@
# 版本变更记录 (changelog.md) # 版本变更记录 (changelog.md)
## [0.3.2] - 2026-05-09
### 修复
- 修复流式播放无声音问题(守护进程 `send --stream` 和 CLI `--stream --play`
- 修复 SSE 解析结构体层级:`audio``choices[0].delta.audio.data`,非顶层字段
- 修复 SSE 行分割问题HTTP bytes_stream chunk 边界不对齐 `\n`,添加 `line_buf` 行缓冲器
### 重构
- 重写 daemon 主循环:`LocalSet` + `spawn_local` 替代 `std::thread::spawn` + 桥接,简化流式播放路径
- 统一 CLI 和 daemon 的流式播放逻辑
---
## [0.3.1] - 2026-05-09
### 修复
- 修复 HTTP `/synthesize` 接口未实际调用 TTS 合成的问题(之前只返回"请求已接收"
- 修复语气替换 bug长匹配`...`/`……`)优先于短匹配(`.`/`。`),避免替换冲突
- 修复 `show_voices()` 显示旧版 3 音色而非完整 9 音色列表
### 代码质量
- 清理 9 个编译器警告unused code/import/variable
- 修复 `write_log` 线程安全问题:使用 `Mutex` 保护 HTTP 线程与 async 线程的并发文件写入
- 实现 0 警告 0 错误构建
---
## [0.3.0] - 2026-04-25
### 新增
- 实现守护进程Daemon模式类似 Docker 架构
- TCP Socket 服务器tokio::net::TcpListener多客户端并发支持
- PID 文件管理(启动/停止/状态检测)
- 日志记录到 `~/.config/tts/ttsd.log`
- 复用现有 TTS API 调用和音频播放功能
- 新增 `src/client.rs` TCP 客户端模块
- 新增 `daemon start/stop/status/logs` 子命令
- 新增 `send` 子命令(发送文本到守护进程进行 TTS 合成播放)
- 新增 `ttsd` 内部子命令(由 `daemon start -d` 自动调用)
- 新增 HTTP 接口(端口 = TCP 端口 + 1
- `POST /synthesize` 语音合成接口
- `GET /health` 健康检查
- 新增日志级别INFO/WARN/ERROR 自动检测)+ PID 信息
- 新增 `--style` 参数支持宏观场景风格标签
### 跨平台
- 所有平台统一使用 `~/.config/tts/` 目录
- TCP Socket 通用方案Windows/Linux/macOS
- `nohup` 后台运行Unix/ `cmd /C start` 后台运行Windows
### 协议设计
- JSON over TCP简洁易调试
- 风格参数自动转换为 `<style>...</style>` 标签(符合官方文档)
---
## [0.2.0] - 2026-04-24
### 新增
- 实现 `--stream` 流式输出功能
- SSEServer-Sent Events处理流式响应
- 流式输出自动使用 pcm16 格式
- 支持流式输出到 stdout 或保存到文件
- `pcm16_to_wav()` 函数自动封装 WAV 头
- 创建 `src/tone.rs` 自动语气转换模块
- `apply_tone()` 根据标点添加整体语气标签
- `insert_mid_tone()` 细粒度控制标签
- `analyze_tone()` 分析组合语气
- `has_tone_tag()` 检测已有标签避免重复
- 实现分层配置设计:
- `project.config.toml` 项目默认配置
- `~/.config/tts/config.toml` 用户配置
- 新增 `ratatui` + `crossterm` 依赖,创建 `src/ui.rs` 模块
- 美化所有 CLI 输出(彩色表格/标签)
- 交互式 onboard 表单(支持 API Key 隐藏输入)
### 修改
- 更新音色列表为 Mimo-TTS 完整 9 音色
- 默认音色从 `default_zh` 改为 `mimo_default`
- 添加音色验证,无效时回退默认
### 修复
- 默认音色未生效问题
- 流式 SSE 解析编译错误
- stdout 输出被 println 污染
---
## [0.1.0] - 2026-04-24 ## [0.1.0] - 2026-04-24
### 新增 ### 新增
- 初始化 Rust 项目结构edition 2021 - 初始化 Rust 项目结构edition 2021
- 配置 Cargo.toml 依赖(使用国内源,稀疏索引协议) - 实现 CLI 模块cli.rs- clap derive 模式
- 创建项目文档体系taolun.md、changelog.md、agents.md - `--text`/`--file` 文本输入
- 实现配置管理模块 (config.rs) - Singleton 模式 - `--voice`/`--format`/`--output`/`--play` 参数
- 统一配置文件路径为 `~/.config/tts/config.toml`(所有平台) - `Onboard`/`Voices`/`Config`/`ShowConfig` 子命令
- 使用 home 库获取家目录 - 实现 API 调用模块api.rs- Builder 模式
- 实现 API 调用模块 (api.rs) - Builder 模式 - 双 Header 认证api-key + Authorization: Bearer
- TtsClient 结构体封装 API 调用
- 支持双 Header 认证api-key + Authorization
- Base64 解码音频数据 - Base64 解码音频数据
- 实现 CLI 模块 (cli.rs) - clap derive 模式 - 实现配置管理模块config.rs- Singleton 模式
- 添加 Onboard 子命令(引导式配置) - 统一配置路径 `~/.config/tts/config.toml`
- 添加 Voices、Config、ShowConfig 子命令 - 实现主程序入口main.rs
- 实现主程序入口 (main.rs) - tokio 异步运行时
- 异步主函数tokio - 退出码规范0=成功, 1=参数, 2=配置, 3=API, 4=文件
- 错误处理和退出码规范0-4 - 支持三种输出方式:播放 / 保存 / stdout 流式
- 支持从文本或文件输入 - 创建文档体系taolun.md、changelog.md、agents.md
- 创建 `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 音频输出
--- ---
@@ -51,300 +114,3 @@
- 主版本号:不兼容的 API 修改 - 主版本号:不兼容的 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.3.0] - 2026-04-25
### 新增(守护进程模式)
- ✅ 实现守护进程Daemon模式类似 Docker 架构
- ✅ 创建 `src/daemon.rs` 模块TCP Socket 服务器)
- 使用 `tokio::net::TcpListener` 监听 TCP 连接
- 处理客户端 JSON 请求(文本、音色、格式、风格)
- PID 文件管理(启动/停止/状态检测)
- 日志记录到 `~/.config/tts/ttsd.log`
- 复用现有 TTS API 调用和音频播放功能
- ✅ 创建 `src/client.rs` 模块TCP 客户端)
- 连接到守护进程并发送 TTS 请求
- 支持 JSON over TCP 协议
- 接收并处理守护进程响应
- ✅ 新增 `daemon` 子命令:
- `daemon start [-d]` - 启动守护进程(支持 -d 后台运行)
- `daemon stop` - 停止守护进程(无需端口)
- `daemon status` - 查看守护进程状态(无需端口)
- `start` 支持 `--port` 参数(默认 9876
- ✅ 新增 `send` 子命令:
- 发送文本到守护进程进行语音合成
- 支持 `--voice``--format``--style` 参数
- 支持 `--port` 参数(默认 9876
- ✅ 新增 `ttsd` 内部子命令(守护进程模式)
-`daemon start -d` 自动调用
- 使用 `nohup` 实现后台运行Unix
- ✅ 跨平台支持:
- 所有平台统一使用 `~/.config/tts/` 目录
- TCP Socket 通用方案
- ✅ 协议设计JSON over TCP
### 测试通过2026-04-25
-`mimo-tt-s daemon start -d --port 9876` - 后台启动成功
-`mimo-tt-s send --port 9876 "消息"` - 发送成功,返回"播放完成"
-`mimo-tt-s daemon status` - 状态显示正确
-`mimo-tt-s daemon stop` - 停止成功
- ✅ 日志正常记录(`~/.config/tts/ttsd.log`
### 新增(第十二轮 - 日志格式升级)
- ✅ 添加日志级别(自动检测 INFO/WARN/ERROR
- ✅ 添加 PID 信息
- ✅ 新格式:`[时间戳] [级别] [PID] 消息`
- ✅ 添加 `daemon logs` 子命令
- 支持 `--lines N` 参数
- 默认显示 20 行
### 修改(第十二轮)
- 修改 daemon.rs
- 添加 LogLevel 枚举
- 自动检测消息中的关键词判断级别
- 新格式:追加到旧日志后面(区分新旧日志)
- 修改 cli.rs
- 添加 Logs 变体到 DaemonAction
### 新日志格式
```
[2026-04-25 05:48:05] [INFO] [15278] 正在播放音频...
[2026-04-25 05:48:10] [INFO] [15278] 响应: {"status":"ok","message":"播放完成"}
```
### 级别自动检测
- `INFO` - 默认(正常运行信息)
- `WARN` - 包含"警告"、"注意"
- `ERROR` - 包含"错误"、"失败"、"无法"
### 新增(第十三轮 - HTTP 接口)
- ✅ 添加 HTTP 接口(端口 = TCP端口 + 1
- ✅ tiny_http 依赖
- ✅ POST /synthesize 接口
- ✅ GET /health 健康检查
### 修改(第十三轮)
- Cargo.toml: 添加 tiny_http
- daemon.rs: 添加 HTTP 服务器函数
- start_daemon: 自动启动 HTTP 服务器
### HTTP 接口
| 地址 | 方法 | 说明 |
|------|------|------|
| http://127.0.0.1:9877/synthesize | POST | 语音合成 |
| http://127.0.0.1:9877/health | GET | 健康检查 |
### 测试通过
- ✅ curl 测试 /synthesize
- ✅ curl 测试 /health
- ✅ mimo-tts send 命令
---
## [0.1.0] - 2026-04-24
- ✅ 实现守护进程Daemon模式类似 Docker 架构
- ✅ 创建 `src/daemon.rs` 模块TCP Socket 服务器)
- 使用 `tokio::net::TcpListener` 监听 TCP 连接
- 处理客户端 JSON 请求(文本、音色、格式、风格)
- PID 文件管理(启动/停止/状态检测)
- 日志记录到 `~/.config/tts/ttsd.log`
- 复用现有 TTS API 调用和音频播放功能
- ✅ 创建 `src/client.rs` 模块TCP 客户端)
- 连接到守护进程并发送 TTS 请求
- 支持 JSON over TCP 协议
- 接收并处理守护进程响应
- ✅ 新增 `daemon` 子命令:
- `daemon start` - 启动守护进程
- `daemon stop` - 停止守护进程
- `daemon status` - 查看守护进程状态
- 支持 `--port` 参数(默认 9876
- ✅ 新增 `send` 子命令:
- 发送文本到守护进程进行语音合成
- 支持 `--voice``--format``--style` 参数
- 支持 `--port` 参数(默认 9876
- ✅ 跨平台支持:
- 所有平台统一使用 `~/.config/tts/` 目录
- TCP Socket 通用方案Windows/Linux/macOS 均支持)
- ✅ 协议设计JSON over TCP
- 客户端请求:`{"text": "...", "voice": "...", "format": "...", "style": "..."}`
- 服务端响应:`{"status": "ok", "message": "..."}`
-`style` 参数作为宏观场景风格(与文本内 `[style]` 标签并存)
- 服务端自动转换为 `<style>...</style>` 标签(符合官方文档)
- 支持如"开心"、"东北话"、"唱歌"等风格描述
### 修改(第十一轮)
- 修改 `Cargo.toml`
- 添加 `dirs = "5.0"` 依赖(跨平台配置目录)
- 添加 `chrono = "0.4"` 依赖(日志时间戳)
- 修改 `src/cli.rs`
- 添加 `DaemonCommand``DaemonAction` 枚举
- 添加 `Send` 子命令定义
- 修改 `src/main.rs`
- 声明新模块 `daemon``client`
- 添加命令分发逻辑
### 技术细节
- 使用 `tokio::spawn` 处理多个并发客户端连接
- 使用 `serde_json` 序列化/反序列化 JSON 协议
- 使用 `dirs::home_dir()` 获取家目录,拼接配置路径
- PID 文件用于检测守护进程是否运行(跨平台兼容)
- 日志同时输出到文件和控制台
- 播放功能复用现有的 `play_audio()` 函数
- 守护进程支持 Ctrl+C 优雅停止
---
## [0.2.0] - 2026-04-24
### 新增(第十轮 - 自动语气转换器)
- ✅ 创建 `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 调用)
- ✅ 使用 SSEServer-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.rssynthesize() 支持 stream 参数
- 修改 main.rs支持流式数据播放PCM16 → WAV
- 修改 api.rs修复 read_stream_response 实现
### 修复(第九轮)
- 修复 StreamReader 编译错误(改用 futures::StreamExt
- 修复 reqwest stream feature 缺失问题
- 修复播放时未识别 PCM16 格式问题
### 技术细节
- PCM16 转 WAV24000Hz, 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

View File

@@ -2,6 +2,6 @@
# 版本号必须与 git tag 保持一致 # 版本号必须与 git tag 保持一致
# 这是项目的默认配置,用户配置会覆盖这些默认值 # 这是项目的默认配置,用户配置会覆盖这些默认值
version = "0.3.0" version = "0.3.2"
base_url = "https://api.xiaomimimo.com/v1/chat/completions" base_url = "https://api.xiaomimimo.com/v1/chat/completions"
default_format = "wav" default_format = "wav"

View File

@@ -6,7 +6,6 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use base64::{engine::general_purpose, Engine as _}; use base64::{engine::general_purpose, Engine as _};
use futures::StreamExt;
use reqwest::header::{self, HeaderMap, HeaderValue}; use reqwest::header::{self, HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::Duration; use std::time::Duration;
@@ -130,23 +129,26 @@ impl TtsClient {
let mut stream = response.bytes_stream(); let mut stream = response.bytes_stream();
let mut audio_data = Vec::new(); let mut audio_data = Vec::new();
let mut buffer = String::new(); let mut line_buf = String::new();
while let Some(item) = stream.next().await { while let Some(item) = stream.next().await {
let chunk = item.context("读取流式响应块失败")?; let chunk = item.context("读取流式响应块失败")?;
let text = String::from_utf8_lossy(&chunk); line_buf.push_str(&String::from_utf8_lossy(&chunk));
// 处理 SSE 格式:按行分割 loop {
for line in text.lines() { let newline_pos = match line_buf.find('\n') {
let line = line.trim(); Some(p) => p,
if line.starts_with("data: ") { None => break,
let data_str = &line[6..]; // 跳过 "data: " };
let complete_line = line_buf[..newline_pos].trim().to_string();
line_buf.drain(..=newline_pos);
if complete_line.starts_with("data: ") {
let data_str = &complete_line[6..];
if data_str == "[DONE]" { if data_str == "[DONE]" {
return Ok(audio_data); return Ok(audio_data);
} }
// 尝试解析 JSON if let Some(audio_b64) = extract_audio_from_sse(data_str) {
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) { if let Ok(bytes) = general_purpose::STANDARD.decode(&audio_b64) {
audio_data.extend_from_slice(&bytes); audio_data.extend_from_slice(&bytes);
} }
@@ -154,11 +156,96 @@ impl TtsClient {
} }
} }
} }
}
Ok(audio_data) Ok(audio_data)
} }
/// 流式合成:将 SSE 中每段音频 chunk 发送到 channel
///
/// 调用方从 channel 逐块接收 PCM16 数据,实现边下载边播放
pub async fn synthesize_stream_to_channel(
&self,
request: &TtsRequest,
chunk_tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
) -> Result<()> {
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"),
);
headers.insert(
header::ACCEPT,
HeaderValue::from_static("text/event-stream"),
);
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
));
}
let mut stream = response.bytes_stream();
use futures::StreamExt;
// SSE 行缓冲:处理跨 chunk 的 data: 行
let mut line_buf = String::new();
while let Some(item) = stream.next().await {
let chunk = item.context("读取流式响应块失败")?;
line_buf.push_str(&String::from_utf8_lossy(&chunk));
loop {
let newline_pos = match line_buf.find('\n') {
Some(p) => p,
None => break,
};
let complete_line = line_buf[..newline_pos].trim().to_string();
line_buf.drain(..=newline_pos);
if complete_line.starts_with("data: ") {
let data_str = &complete_line[6..];
if data_str == "[DONE]" {
return Ok(());
}
if let Some(audio_b64) = extract_audio_from_sse(data_str) {
if let Ok(bytes) = general_purpose::STANDARD.decode(&audio_b64) {
if chunk_tx.send(bytes).is_err() {
return Ok(());
}
}
}
}
}
}
Ok(())
}
/// 合成语音(简化版) /// 合成语音(简化版)
/// ///
/// # 参数 /// # 参数
@@ -169,6 +256,7 @@ impl TtsClient {
/// ///
/// # 返回 /// # 返回
/// 返回合成的音频数据 /// 返回合成的音频数据
#[allow(dead_code)]
pub async fn synthesize( pub async fn synthesize(
&self, &self,
text: &str, text: &str,
@@ -202,11 +290,41 @@ impl TtsClient {
} }
/// SSE 事件结构体(用于解析流式响应) /// SSE 事件结构体(用于解析流式响应)
///
/// 匹配 OpenAI chat.completion.chunk 格式:
/// {"choices":[{"delta":{"audio":{"id":"...","data":"base64..."}}}]}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct SseEvent { struct SseChunk {
#[serde(rename = "type", default)] choices: Vec<SseChoice>,
event_type: Option<String>, }
audio: Option<String>,
#[derive(Debug, Deserialize)]
struct SseChoice {
delta: SseDelta,
}
#[derive(Debug, Deserialize)]
struct SseDelta {
audio: Option<SseAudio>,
}
#[derive(Debug, Deserialize)]
struct SseAudio {
#[allow(dead_code)]
id: Option<String>,
data: Option<String>,
}
/// 从 SSE event JSON 中提取 base64 音频数据,返回 None 如果无音频
fn extract_audio_from_sse(data_str: &str) -> Option<String> {
if let Ok(chunk) = serde_json::from_str::<SseChunk>(data_str) {
if let Some(choice) = chunk.choices.first() {
if let Some(audio) = &choice.delta.audio {
return audio.data.clone();
}
}
}
None
} }
/// TTS 请求结构体 /// TTS 请求结构体
@@ -251,6 +369,7 @@ impl TtsRequestBuilder {
} }
/// 设置模型名称 /// 设置模型名称
#[allow(dead_code)]
pub fn model(mut self, model: String) -> Self { pub fn model(mut self, model: String) -> Self {
self.model = Some(model); self.model = Some(model);
self self

View File

@@ -99,6 +99,9 @@ pub enum Commands {
/// 风格描述(可选,如:开心、东北话、唱歌等) /// 风格描述(可选,如:开心、东北话、唱歌等)
#[arg(short, long)] #[arg(short, long)]
style: Option<String>, style: Option<String>,
/// 使用流式播放(边下载边播放)
#[arg(long)]
stream: bool,
/// 守护进程端口默认9876 /// 守护进程端口默认9876
#[arg(short, long, default_value = "9876")] #[arg(short, long, default_value = "9876")]
port: u16, port: u16,

View File

@@ -22,6 +22,9 @@ pub struct ClientRequest {
/// 风格描述(可选) /// 风格描述(可选)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub style: Option<String>, pub style: Option<String>,
/// 是否使用流式播放
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
} }
/// 服务端响应结构(与 daemon 端对应) /// 服务端响应结构(与 daemon 端对应)
@@ -40,6 +43,7 @@ pub struct DaemonResponse {
/// - voice: 音色名称(可选,默认 mimo_default /// - voice: 音色名称(可选,默认 mimo_default
/// - format: 音频格式(可选,默认 wav /// - format: 音频格式(可选,默认 wav
/// - style: 风格描述(可选) /// - style: 风格描述(可选)
/// - stream: 是否流式播放
/// - port: 守护进程端口(默认 9876 /// - port: 守护进程端口(默认 9876
/// ///
/// # 返回 /// # 返回
@@ -49,16 +53,17 @@ pub async fn send_to_daemon(
voice: Option<&str>, voice: Option<&str>,
format: Option<&str>, format: Option<&str>,
style: Option<&str>, style: Option<&str>,
stream: Option<bool>,
port: u16, port: u16,
) -> Result<String> { ) -> Result<String> {
let addr = format!("127.0.0.1:{}", port); let addr = format!("127.0.0.1:{}", port);
// 连接到守护进程 // 连接到守护进程
let stream = TcpStream::connect(&addr) let tcp_stream = TcpStream::connect(&addr)
.await .await
.with_context(|| format!("无法连接到守护进程 {},请确认守护进程已启动", addr))?; .with_context(|| format!("无法连接到守护进程 {},请确认守护进程已启动", addr))?;
let (reader, mut writer) = stream.into_split(); let (reader, mut writer) = tcp_stream.into_split();
let mut reader = BufReader::new(reader); let mut reader = BufReader::new(reader);
// 构建请求 // 构建请求
@@ -67,6 +72,7 @@ pub async fn send_to_daemon(
voice: voice.map(|v| v.to_string()), voice: voice.map(|v| v.to_string()),
format: format.map(|f| f.to_string()), format: format.map(|f| f.to_string()),
style: style.map(|s| s.to_string()), style: style.map(|s| s.to_string()),
stream,
}; };
// 序列化为 JSON // 序列化为 JSON
@@ -104,6 +110,7 @@ pub async fn send_to_daemon(
/// ///
/// # 返回 /// # 返回
/// true 如果守护进程可连接,否则 false /// true 如果守护进程可连接,否则 false
#[allow(dead_code)]
pub async fn ping_daemon(port: u16) -> bool { pub async fn ping_daemon(port: u16) -> bool {
let addr = format!("127.0.0.1:{}", port); let addr = format!("127.0.0.1:{}", port);

View File

@@ -17,6 +17,7 @@ use std::path::PathBuf;
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct ProjectConfig { pub struct ProjectConfig {
/// 项目版本号 /// 项目版本号
#[allow(dead_code)]
pub version: String, pub version: String,
/// API 基础 URL 地址 /// API 基础 URL 地址
#[serde(default = "default_base_url")] #[serde(default = "default_base_url")]
@@ -69,6 +70,7 @@ pub struct Config {
/// API 基础 URL 地址(来自项目配置) /// API 基础 URL 地址(来自项目配置)
pub base_url: String, pub base_url: String,
/// 默认音频格式(来自项目配置) /// 默认音频格式(来自项目配置)
#[allow(dead_code)]
pub default_format: String, pub default_format: String,
} }

View File

@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::mpsc; use tokio::sync::mpsc;
@@ -27,6 +28,9 @@ pub struct DaemonRequest {
/// 风格描述(可选,宏观场景风格) /// 风格描述(可选,宏观场景风格)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub style: Option<String>, pub style: Option<String>,
/// 是否使用流式播放(边下载边播放)
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
} }
/// 服务端响应结构 /// 服务端响应结构
@@ -68,6 +72,9 @@ fn get_socket_path() -> Result<PathBuf> {
Ok(get_config_dir()?.join("ttsd.sock")) Ok(get_config_dir()?.join("ttsd.sock"))
} }
/// 日志写入锁保证多线程HTTP + async并发写入不交错
static LOG_LOCK: Mutex<()> = Mutex::new(());
/// 日志级别 /// 日志级别
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
enum LogLevel { enum LogLevel {
@@ -97,22 +104,20 @@ impl LogLevel {
} }
} }
/// 写入日志 /// 写入日志(线程安全,通过 Mutex 保证并发不交错)
fn write_log(message: &str) -> Result<()> { fn write_log(message: &str) -> Result<()> {
let log_path = get_log_file_path()?; let log_path = get_log_file_path()?;
let now = std::time::SystemTime::now(); let now = std::time::SystemTime::now();
let datetime: chrono::DateTime<chrono::Local> = now.into(); let datetime: chrono::DateTime<chrono::Local> = now.into();
let timestamp = datetime.format("%Y-%m-%d %H:%M:%S"); let timestamp = datetime.format("%Y-%m-%d %H:%M:%S");
// 自动检测日志级别
let level = LogLevel::from_message(message); let level = LogLevel::from_message(message);
// 获取当前 PID
let pid = std::process::id(); let pid = std::process::id();
// 新格式:[时间戳] [级别] [PID] 消息
let log_line = format!("[{}] [{}] [{}] {}\n", timestamp, level.as_str(), pid, message); let log_line = format!("[{}] [{}] [{}] {}\n", timestamp, level.as_str(), pid, message);
// 加锁保证同一时刻只有一个线程写入日志文件
let _lock = LOG_LOCK.lock().unwrap();
let mut file = fs::OpenOptions::new() let mut file = fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
@@ -121,8 +126,10 @@ fn write_log(message: &str) -> Result<()> {
file.write_all(log_line.as_bytes()) file.write_all(log_line.as_bytes())
.with_context(|| format!("无法写入日志文件: {:?}", log_path))?; .with_context(|| format!("无法写入日志文件: {:?}", log_path))?;
file.flush().ok();
// 锁在此处释放_lock 离开作用域)
// 简化的 stdout 输出
if level == LogLevel::Error { if level == LogLevel::Error {
eprintln!("[Daemon {}] {}", pid, message); eprintln!("[Daemon {}] {}", pid, message);
} else { } else {
@@ -216,8 +223,9 @@ pub async fn start_daemon(port: u16) -> Result<()> {
// 启动 HTTP 服务器(在独立线程中运行,用于调试接口) // 启动 HTTP 服务器(在独立线程中运行,用于调试接口)
let http_port = port + 1; let http_port = port + 1;
let rt_handle = tokio::runtime::Handle::current();
std::thread::spawn(move || { std::thread::spawn(move || {
if let Err(e) = start_http_server(http_port) { if let Err(e) = start_http_server(http_port, rt_handle) {
eprintln!("HTTP 服务器启动失败: {:#}", e); eprintln!("HTTP 服务器启动失败: {:#}", e);
} }
}); });
@@ -233,17 +241,19 @@ pub async fn start_daemon(port: u16) -> Result<()> {
} }
}); });
// 主循环:接受连接 // 主循环:接受连接(使用 LocalSet + spawn_localhandle_client 不要求 Send
let local = tokio::task::LocalSet::new();
local.run_until(async {
loop { loop {
tokio::select! { tokio::select! {
// 接受新连接 // 接受新连接
accept_result = listener.accept() => { accept_result = listener.accept() => {
match accept_result { match accept_result {
Ok((stream, addr)) => { Ok((stream, addr)) => {
write_log(&format!("新连接来自: {}", addr))?; write_log(&format!("新连接来自: {}", addr)).ok();
// 为每个连接创建新任务 // 为每个连接创建新任务(不要求 Send
tokio::spawn(async move { tokio::task::spawn_local(async move {
if let Err(e) = handle_client(stream).await { if let Err(e) = handle_client(stream).await {
let _ = write_log(&format!("处理连接失败: {:#}", e)); let _ = write_log(&format!("处理连接失败: {:#}", e));
} }
@@ -262,6 +272,7 @@ pub async fn start_daemon(port: u16) -> Result<()> {
} }
} }
} }
}).await;
// 清理 PID 文件 // 清理 PID 文件
let _ = fs::remove_file(&pid_path); let _ = fs::remove_file(&pid_path);
@@ -329,15 +340,15 @@ async fn process_tts_request(request: DaemonRequest) -> Result<String> {
let voice = request.voice.unwrap_or_else(|| "mimo_default".to_string()); let voice = request.voice.unwrap_or_else(|| "mimo_default".to_string());
let format = request.format.unwrap_or_else(|| "wav".to_string()); let format = request.format.unwrap_or_else(|| "wav".to_string());
let style = request.style; let style = request.style;
let is_stream = request.stream.unwrap_or(false);
let text_preview: String = text.chars().take(50).collect(); let text_preview: String = text.chars().take(50).collect();
write_log(&format!("处理 TTS: text={}, voice={}, format={}, style={:?}", write_log(&format!("处理 TTS: text={}, voice={}, format={}, style={:?}, stream={}",
text_preview, voice, format, style))?; text_preview, voice, format, style, is_stream))?;
// 处理风格标签 // 处理风格标签
let mut final_text = text.clone(); let mut final_text = text.clone();
if let Some(style_value) = &style { if let Some(style_value) = &style {
// 按官方文档,使用 <style>...</style> 标签
final_text = format!("<style>{}</style>{}", style_value, final_text); final_text = format!("<style>{}</style>{}", style_value, final_text);
write_log(&format!("添加风格标签: {}", style_value))?; write_log(&format!("添加风格标签: {}", style_value))?;
} }
@@ -347,36 +358,104 @@ async fn process_tts_request(request: DaemonRequest) -> Result<String> {
.context("无法加载配置")?; .context("无法加载配置")?;
let config = config_manager.get_config(); let config = config_manager.get_config();
// 检查 API Key
if config.api_key.is_empty() { if config.api_key.is_empty() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"API Key 未设置,请使用: mimo-tts config set --api-key <YOUR_API_KEY>" "API Key 未设置,请使用: mimo-tts config set --api-key <YOUR_API_KEY>"
)); ));
} }
// 创建 TTS 客户端
let client = crate::api::TtsClient::builder() let client = crate::api::TtsClient::builder()
.base_url(config.base_url.clone()) .base_url(config.base_url.clone())
.api_key(config.api_key.clone()) .api_key(config.api_key.clone())
.build() .build()
.context("无法创建 TTS 客户端")?; .context("无法创建 TTS 客户端")?;
// 构建请求 if is_stream {
write_log("使用流式播放模式")?;
let mut builder = crate::api::TtsRequest::builder()
.audio(crate::api::AudioConfig {
format: "pcm16".to_string(),
voice: voice.clone(),
});
builder = builder.add_message(crate::api::Message {
role: "assistant".to_string(),
content: final_text.clone(),
});
builder = builder.stream(true);
let tts_request = builder.build();
// OutputStream(!Send) 不能跨越 tokio::spawn 的 Send 边界
// 在本 async 函数LocalSet 内)直接创建,和 handle_stream_play 一致
let (_stream, stream_handle) = rodio::OutputStream::try_default()
.context("无法创建音频输出流")?;
let sink = std::sync::Arc::new(rodio::Sink::try_new(&stream_handle)
.context("无法创建音频播放器")?);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
// spawn_local 不要求 SendOutputStream 可以留在当前环境
let play_sink = sink.clone();
let play_handle = tokio::task::spawn_local(async move {
let mut buffer = Vec::new();
let threshold = 48000;
let mut started = false;
while let Some(chunk) = rx.recv().await {
if !started {
buffer.extend_from_slice(&chunk);
if buffer.len() >= threshold {
let samples: Vec<i16> = buffer.chunks(2)
.filter(|c| c.len() == 2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect();
if !samples.is_empty() {
play_sink.append(rodio::buffer::SamplesBuffer::new(1, 24000, samples));
}
buffer.clear();
started = true;
}
} else {
let samples: Vec<i16> = chunk.chunks(2)
.filter(|c| c.len() == 2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect();
if !samples.is_empty() {
play_sink.append(rodio::buffer::SamplesBuffer::new(1, 24000, samples));
}
}
}
if !buffer.is_empty() {
let samples: Vec<i16> = buffer.chunks(2)
.filter(|c| c.len() == 2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect();
if !samples.is_empty() {
play_sink.append(rodio::buffer::SamplesBuffer::new(1, 24000, samples));
}
}
});
write_log("正在流式合成 TTS...")?;
client.synthesize_stream_to_channel(&tts_request, tx).await?;
write_log("等待流式播放完成...")?;
let _ = play_handle.await;
sink.sleep_until_end();
write_log("流式播放完成")?;
Ok("流式播放完成".to_string())
} else {
let mut builder = crate::api::TtsRequest::builder() let mut builder = crate::api::TtsRequest::builder()
.audio(crate::api::AudioConfig { .audio(crate::api::AudioConfig {
format: format.clone(), format: format.clone(),
voice: voice.clone(), voice: voice.clone(),
}); });
// 添加 assistant 消息(实际要合成的文本)
builder = builder.add_message(crate::api::Message { builder = builder.add_message(crate::api::Message {
role: "assistant".to_string(), role: "assistant".to_string(),
content: final_text.clone(), content: final_text.clone(),
}); });
let tts_request = builder.build(); let tts_request = builder.build();
// 调用 API 合成语音
write_log("正在调用 TTS API...")?; write_log("正在调用 TTS API...")?;
let audio_data = client let audio_data = client
.synthesize_with_request(&tts_request) .synthesize_with_request(&tts_request)
@@ -385,12 +464,12 @@ async fn process_tts_request(request: DaemonRequest) -> Result<String> {
write_log(&format!("TTS 成功,音频数据大小: {} 字节", audio_data.len()))?; write_log(&format!("TTS 成功,音频数据大小: {} 字节", audio_data.len()))?;
// 播放音频
write_log("正在播放音频...")?; write_log("正在播放音频...")?;
crate::play_audio(&audio_data)?; crate::play_audio(&audio_data)?;
Ok("播放完成".to_string()) Ok("播放完成".to_string())
} }
}
/// 停止守护进程 /// 停止守护进程
pub fn stop_daemon() -> Result<()> { pub fn stop_daemon() -> Result<()> {
@@ -488,7 +567,7 @@ pub fn show_logs(lines: u32) -> Result<()> {
} }
/// 启动 HTTP 服务器用于调试接口Postman 测试) /// 启动 HTTP 服务器用于调试接口Postman 测试)
fn start_http_server(port: u16) -> Result<()> { fn start_http_server(port: u16, rt_handle: tokio::runtime::Handle) -> Result<()> {
let addr = format!("127.0.0.1:{}", port); let addr = format!("127.0.0.1:{}", port);
let server = match tiny_http::Server::http(&addr) { let server = match tiny_http::Server::http(&addr) {
Ok(s) => s, Ok(s) => s,
@@ -510,21 +589,33 @@ fn start_http_server(port: u16) -> Result<()> {
// 合成接口 // 合成接口
if path == "/synthesize" || path == "/synthesize/" { if path == "/synthesize" || path == "/synthesize/" {
let body = request.as_reader();
let mut body_str = String::new(); let mut body_str = String::new();
body.read_to_string(&mut body_str).ok(); request.as_reader().read_to_string(&mut body_str).ok();
let response = match serde_json::from_str::<DaemonRequest>(&body_str) { let response = match serde_json::from_str::<DaemonRequest>(&body_str) {
Ok(req) => { Ok(req) => {
let text_preview = if req.text.len() > 30 { format!("{}...", &req.text[..30]) } else { req.text.clone() }; let text_preview: String = req.text.chars().take(30).collect();
println!("[HTTP] 收到请求: text={}", text_preview); write_log(&format!("[HTTP] 收到合成请求: text={}", text_preview)).ok();
// 通过 tokio handle 在同步线程中执行异步 TTS 调用
match rt_handle.block_on(process_tts_request(req)) {
Ok(msg) => {
let resp = DaemonResponse { let resp = DaemonResponse {
status: "ok".to_string(), status: "ok".to_string(),
message: "请求已接收".to_string(), message: msg,
}; };
tiny_http::Response::from_string(serde_json::to_string(&resp).unwrap()) tiny_http::Response::from_string(serde_json::to_string(&resp).unwrap())
} }
Err(e) => {
let resp = DaemonResponse {
status: "error".to_string(),
message: format!("{:#}", e),
};
tiny_http::Response::from_string(serde_json::to_string(&resp).unwrap())
.with_status_code(500)
}
}
}
Err(e) => { Err(e) => {
let resp = DaemonResponse { let resp = DaemonResponse {
status: "error".to_string(), status: "error".to_string(),

View File

@@ -150,13 +150,15 @@ async fn run(cli: Cli) -> Result<()> {
// 守护进程模式(由 daemon start -d 自动调用) // 守护进程模式(由 daemon start -d 自动调用)
daemon::start_daemon(port).await daemon::start_daemon(port).await
} }
Some(Commands::Send { text, voice, format, style, port }) => { Some(Commands::Send { text, voice, format, style, stream, port }) => {
// 发送文本到守护进程 // 发送文本到守护进程
let stream_opt = if stream { Some(true) } else { None };
client::send_to_daemon( client::send_to_daemon(
&text, &text,
voice.as_deref(), voice.as_deref(),
format.as_deref(), format.as_deref(),
style.as_deref(), style.as_deref(),
stream_opt,
port, port,
) )
.await .await
@@ -178,7 +180,21 @@ async fn run(cli: Cli) -> Result<()> {
)); ));
} }
// 执行语音合成 // 流式播放走独立路径:边下边播,不等待全量下载
if cli.stream && cli.play {
ui::show_playback_start();
handle_stream_play(
cli.text,
cli.file,
&cli.voice,
cli.style.as_deref(),
)
.await?;
ui::show_playback_complete();
return Ok(());
}
// 执行语音合成(非流式播放场景:全量下载后输出/播放)
let audio_data = synthesize( let audio_data = synthesize(
cli.text, cli.text,
cli.file, cli.file,
@@ -191,15 +207,7 @@ async fn run(cli: Cli) -> Result<()> {
// 根据参数决定处理方式 // 根据参数决定处理方式
if cli.play { 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)?; play_audio(&audio_data)?;
}
ui::show_playback_complete(); ui::show_playback_complete();
} else if let Some(output_path) = cli.output { } else if let Some(output_path) = cli.output {
// 保存到文件 // 保存到文件
@@ -488,6 +496,128 @@ fn play_audio(data: &[u8]) -> Result<()> {
Ok(()) Ok(())
} }
/// 流式播放:边下载 PCM16 chunk 边播放
///
/// 先缓冲约 1 秒数据再开始播放,抗网络抖动
/// 每收到一块就解码为 i16 采样,追加到 rodio Sink 队列
async fn handle_stream_play(
text: Option<String>,
file: Option<std::path::PathBuf>,
voice: &str,
style: Option<&str>,
) -> Result<()> {
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();
if config.api_key.is_empty() {
return Err(anyhow::anyhow!(
"API Key 未设置\n请使用: mimo-tts config set --api-key <YOUR_API_KEY>"
));
}
let client = api::TtsClient::builder()
.base_url(config.base_url.clone())
.api_key(config.api_key.clone())
.build()
.context("无法创建 TTS 客户端")?;
let mut builder = api::TtsRequest::builder()
.audio(api::AudioConfig {
format: "pcm16".to_string(),
voice: validated_voice,
});
if let Some(s) = style {
builder = builder.add_message(api::Message {
role: "user".to_string(),
content: s.to_string(),
});
}
builder = builder.add_message(api::Message {
role: "assistant".to_string(),
content,
});
builder = builder.stream(true);
let request = builder.build();
let (_stream, stream_handle) = rodio::OutputStream::try_default()
.context("无法创建音频输出流")?;
let sink = std::sync::Arc::new(rodio::Sink::try_new(&stream_handle)
.context("无法创建音频播放器")?);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
// 接收任务:先缓冲 ~1 秒 PCM16 再开始播放,后续逐块追加
let play_sink = sink.clone();
let play_handle = tokio::spawn(async move {
let mut buffer = Vec::new();
// 24000Hz * 16bit * 1ch = 48000 字节/秒
let threshold = 48000;
let mut started = false;
while let Some(chunk) = rx.recv().await {
if !started {
buffer.extend_from_slice(&chunk);
if buffer.len() >= threshold {
let samples: Vec<i16> = buffer.chunks(2)
.filter(|c| c.len() == 2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect();
if !samples.is_empty() {
play_sink.append(rodio::buffer::SamplesBuffer::new(1, 24000, samples));
}
buffer.clear();
started = true;
}
} else {
let samples: Vec<i16> = chunk.chunks(2)
.filter(|c| c.len() == 2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect();
if !samples.is_empty() {
play_sink.append(rodio::buffer::SamplesBuffer::new(1, 24000, samples));
}
}
}
// 刷新剩余缓冲(文本较短下次未达到阈值)
if !buffer.is_empty() {
let samples: Vec<i16> = buffer.chunks(2)
.filter(|c| c.len() == 2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect();
if !samples.is_empty() {
play_sink.append(rodio::buffer::SamplesBuffer::new(1, 24000, samples));
}
}
});
client.synthesize_stream_to_channel(&request, tx).await?;
play_handle.await.context("流式播放任务异常")?;
sink.sleep_until_end();
Ok(())
}
/// 将 PCM16 原始数据转换为 WAV 格式 /// 将 PCM16 原始数据转换为 WAV 格式
/// ///
/// # 参数 /// # 参数
@@ -495,6 +625,7 @@ fn play_audio(data: &[u8]) -> Result<()> {
/// ///
/// # 返回 /// # 返回
/// 完整的 WAV 格式数据(包含 44 字节头部) /// 完整的 WAV 格式数据(包含 44 字节头部)
#[allow(dead_code)]
fn pcm16_to_wav(pcm_data: &[u8]) -> Vec<u8> { fn pcm16_to_wav(pcm_data: &[u8]) -> Vec<u8> {
let sample_rate: u32 = 24000; // Mimo-TTS PCM16 输出通常是 24kHz let sample_rate: u32 = 24000; // Mimo-TTS PCM16 输出通常是 24kHz
let bits_per_sample: u16 = 16; let bits_per_sample: u16 = 16;

View File

@@ -58,6 +58,13 @@ pub fn analyze_tone(text: &str) -> Vec<&str> {
pub fn insert_mid_tone(text: &str) -> String { pub fn insert_mid_tone(text: &str) -> String {
let mut result = text.to_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("!", "!(激动)");
@@ -70,10 +77,6 @@ pub fn insert_mid_tone(text: &str) -> String {
result = result.replace("", "。(停顿)"); result = result.replace("", "。(停顿)");
result = result.replace(".", "。(停顿)"); result = result.replace(".", "。(停顿)");
// 处理省略号 …… → ……(拖长音)
result = result.replace("……", "……(拖长音)");
result = result.replace("...", "……(拖长音)");
result result
} }

View File

@@ -72,19 +72,27 @@ pub fn show_voices() {
show_separator(); show_separator();
println!(); println!();
println!(" {:<16} {}", "Voice ID", "说明"); println!(" {:<16} {:<8} {:<6} {}", "Voice ID", "语言", "性别", "说明");
show_separator(); show_separator();
let voices = [ let voices = [
("mimo_default", "MiMo 默认音色(推荐)"), ("mimo_default", "多语言", "-", "MiMo 默认(推荐)"),
("default_zh", "中文音色(兼容旧版)"), ("冰糖", "中文", "", "甜美清脆的女声"),
("default_en", "英文音色(兼容旧版)"), ("茉莉", "中文", "", "温柔知性的女声"),
("苏打", "中文", "", "阳光活力的男声"),
("白桦", "中文", "", "沉稳大气的男声"),
("Mia", "英文", "", "Sweet and gentle female"),
("Chloe", "英文", "", "Warm and articulate female"),
("Milo", "英文", "", "Energetic young male"),
("Dean", "英文", "", "Deep and steady male"),
]; ];
for (id, desc) in voices { for (id, lang, gender, desc) in voices {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
let _ = stdout.execute(SetForegroundColor(Theme::PRIMARY)); let _ = stdout.execute(SetForegroundColor(Theme::PRIMARY));
let _ = write!(stdout, " {:<16}", id); let _ = write!(stdout, " {:<16}", id);
let _ = stdout.execute(SetForegroundColor(Theme::TEXT));
let _ = write!(stdout, " {:<8} {:<6}", lang, gender);
let _ = stdout.execute(ResetColor); let _ = stdout.execute(ResetColor);
let _ = writeln!(stdout, "{}", desc); let _ = writeln!(stdout, "{}", desc);
} }

View File

@@ -555,4 +555,59 @@ mimo-tts daemon --port 9876 stop
- 播放功能复用现有的 `play_audio()` 函数 - 播放功能复用现有的 `play_audio()` 函数
- style 参数按官方文档转换为 `<style>...</style>` 标签 - style 参数按官方文档转换为 `<style>...</style>` 标签
## 2026-04-24 - 第十轮修改 ## 2026-05-09 - 代码质量提升
### 用户指令
开始修复之前评价中指出的问题。
### 修复清单
1. ✅ HTTP /synthesize 接口缺少实际 TTS 调用 — 通过 `tokio::runtime::Handle::current()` + `block_on()` 桥接
2. ✅ 语气替换长匹配优先问题 — 调整 `insert_mid_tone()``...` 优先于 `.`
3. ✅ show_voices() 显示完整 9 音色 — 更新为完整列表含语言/性别/说明
4. ✅ 9 个编译器警告清理 — `#[allow(dead_code)]` + 移除未使用变量/导入
5. ✅ write_log 线程安全问题 — 添加 `Mutex<()>` 静态锁
6. ✅ changelog 版本结构整理 — 合并重复条目为 0.1.0→0.2.0→0.3.0→0.3.1
### 最终状态
- 版本0.3.1
- 构建0 warnings, 0 errors
- 测试9/9 passed
---
## 2026-05-09 - 第十四轮修改:流式播放修复
### 背景
守护进程 `send --stream` 和 CLI `--stream --play` 均无声音输出,但非流式播放正常。
### 诊断过程
1. **第一阶段:假设 RunLoop 问题**
- 认为 `rodio::OutputStream(!Send)``std::thread::spawn` 中因为 CoreAudio RunLoop 不可用导致无声
- 将 daemon 主循环改为 `LocalSet` + `spawn_local`,消除 `std::thread::spawn`
- 结果:仍无声 → 假设错误
2. **第二阶段:定位 SSE 解析问题**
- `--stream`(不带 `--play`)测试:能输出 PCM16 数据(走 JSON 响应,不走 SSE
- 用 curl 直接查看 API 原始 SSE 响应 → 发现音频数据在 `choices[0].delta.audio.data`
-`SseEvent` 只解析顶层 `audio` 字段 → 永远 None → 通道为空 → 静音
- 修复1创建 `SseChunk`/`SseChoice`/`SseDelta`/`SseAudio` 嵌套结构体
- 结果:仍无声
3. **第三阶段:定位行缓冲问题**
- 添加调试日志发现SSE `data:` 行因音频 base64 过长,被 HTTP chunk 分割
- 行被切碎 → serde_json 解析失败 → 永远收不到数据
- 修复2添加 `line_buf` 跨 chunk 累积完整行再解析
- 结果69KB PCM16 数据成功进入 channelsink 正常播放 ✅
### 技术细节
- `reqwest::Response::bytes_stream()` 的 chunk 边界不一定对齐 SSE `\n`
-`data:` 行会跨 chunk 分割
- SSE 行缓冲器:按 `\n` 切割,不完整行等下一个 chunk
- `extract_audio_from_sse()``choices[0].delta.audio.data`
### 最终状态
- CLI `--stream --play` 正常出声 ✅
- Daemon `send --stream` 正常出声 ✅
- Daemon 非流式 `send` 仍正常 ✅
- 版本0.3.2(流式播放修复)