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/
.fleet/
.cursor/
mimo-tts

2
Cargo.lock generated
View File

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

View File

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

View File

@@ -98,9 +98,29 @@
**解决方案**`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 测试效率
**现象**:测试守护进程时创建了多个 .ps1 临时文件,繁琐且不便管理
**原因**:不熟悉 PowerShell 命令<EFBFBD><EFBFBD>直接执行的方式
**原因**:不熟悉 PowerShell 命令直接执行的方式
**解决方案**
- 使用 `mimo-tts daemon start -d --port XXXX` 后台启动守护进程
- 使用 PowerShell 一条命令直接发送 TCP 请求测试:
@@ -109,3 +129,18 @@
```
- 无需创建 .ps1 临时文件
**经验**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)
## [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
### 新增
- 初始化 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
- 实现 CLI 模块cli.rs- clap derive 模式
- `--text`/`--file` 文本输入
- `--voice`/`--format`/`--output`/`--play` 参数
- `Onboard`/`Voices`/`Config`/`ShowConfig` 子命令
- 实现 API 调用模块api.rs- Builder 模式
- 双 Header 认证api-key + Authorization: Bearer
- 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 音频输出
- 实现配置管理模块config.rs- Singleton 模式
- 统一配置路径 `~/.config/tts/config.toml`
- 实现主程序入口main.rs
- tokio 异步运行时
- 退出码规范0=成功, 1=参数, 2=配置, 3=API, 4=文件
- 支持三种输出方式:播放 / 保存 / stdout 流式
- 创建文档体系taolun.md、changelog.md、agents.md
---
@@ -51,300 +114,3 @@
- 主版本号:不兼容的 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 保持一致
# 这是项目的默认配置,用户配置会覆盖这些默认值
version = "0.3.0"
version = "0.3.2"
base_url = "https://api.xiaomimimo.com/v1/chat/completions"
default_format = "wav"

View File

@@ -6,7 +6,6 @@
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;
@@ -127,29 +126,31 @@ impl TtsClient {
/// 假设响应是 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();
let mut line_buf = 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: "
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(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);
}
if let Some(audio_b64) = extract_audio_from_sse(data_str) {
if let Ok(bytes) = general_purpose::STANDARD.decode(&audio_b64) {
audio_data.extend_from_slice(&bytes);
}
}
}
@@ -159,6 +160,92 @@ impl TtsClient {
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(
&self,
text: &str,
@@ -202,11 +290,41 @@ impl TtsClient {
}
/// SSE 事件结构体(用于解析流式响应)
///
/// 匹配 OpenAI chat.completion.chunk 格式:
/// {"choices":[{"delta":{"audio":{"id":"...","data":"base64..."}}}]}
#[derive(Debug, Deserialize)]
struct SseEvent {
#[serde(rename = "type", default)]
event_type: Option<String>,
audio: Option<String>,
struct SseChunk {
choices: Vec<SseChoice>,
}
#[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 请求结构体
@@ -251,6 +369,7 @@ impl TtsRequestBuilder {
}
/// 设置模型名称
#[allow(dead_code)]
pub fn model(mut self, model: String) -> Self {
self.model = Some(model);
self

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
@@ -27,6 +28,9 @@ pub struct DaemonRequest {
/// 风格描述(可选,宏观场景风格)
#[serde(skip_serializing_if = "Option::is_none")]
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"))
}
/// 日志写入锁保证多线程HTTP + async并发写入不交错
static LOG_LOCK: Mutex<()> = Mutex::new(());
/// 日志级别
#[derive(Debug, PartialEq)]
enum LogLevel {
@@ -97,22 +104,20 @@ impl LogLevel {
}
}
/// 写入日志
/// 写入日志(线程安全,通过 Mutex 保证并发不交错)
fn write_log(message: &str) -> Result<()> {
let log_path = get_log_file_path()?;
let now = std::time::SystemTime::now();
let datetime: chrono::DateTime<chrono::Local> = now.into();
let timestamp = datetime.format("%Y-%m-%d %H:%M:%S");
// 自动检测日志级别
let level = LogLevel::from_message(message);
// 获取当前 PID
let pid = std::process::id();
// 新格式:[时间戳] [级别] [PID] 消息
let log_line = format!("[{}] [{}] [{}] {}\n", timestamp, level.as_str(), pid, message);
// 加锁保证同一时刻只有一个线程写入日志文件
let _lock = LOG_LOCK.lock().unwrap();
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
@@ -121,8 +126,10 @@ fn write_log(message: &str) -> Result<()> {
file.write_all(log_line.as_bytes())
.with_context(|| format!("无法写入日志文件: {:?}", log_path))?;
file.flush().ok();
// 锁在此处释放_lock 离开作用域)
// 简化的 stdout 输出
if level == LogLevel::Error {
eprintln!("[Daemon {}] {}", pid, message);
} else {
@@ -216,8 +223,9 @@ pub async fn start_daemon(port: u16) -> Result<()> {
// 启动 HTTP 服务器(在独立线程中运行,用于调试接口)
let http_port = port + 1;
let rt_handle = tokio::runtime::Handle::current();
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);
}
});
@@ -233,35 +241,38 @@ pub async fn start_daemon(port: u16) -> Result<()> {
}
});
// 主循环:接受连接
loop {
tokio::select! {
// 接受新连接
accept_result = listener.accept() => {
match accept_result {
Ok((stream, addr)) => {
write_log(&format!("新连接来自: {}", addr))?;
// 为每个连接创建新任务
tokio::spawn(async move {
if let Err(e) = handle_client(stream).await {
let _ = write_log(&format!("处理连接失败: {:#}", e));
}
});
}
Err(e) => {
write_log(&format!("接受连接失败: {}", e)).ok();
// 主循环:接受连接(使用 LocalSet + spawn_localhandle_client 不要求 Send
let local = tokio::task::LocalSet::new();
local.run_until(async {
loop {
tokio::select! {
// 接受新连接
accept_result = listener.accept() => {
match accept_result {
Ok((stream, addr)) => {
write_log(&format!("新连接来自: {}", addr)).ok();
// 为每个连接创建新任务(不要求 Send
tokio::task::spawn_local(async move {
if let Err(e) = handle_client(stream).await {
let _ = write_log(&format!("处理连接失败: {:#}", e));
}
});
}
Err(e) => {
write_log(&format!("接受连接失败: {}", e)).ok();
}
}
}
}
// 收到停止信号
_ = rx.recv() => {
write_log("正在停止守护进程...").ok();
break;
// 收到停止信号
_ = rx.recv() => {
write_log("正在停止守护进程...").ok();
break;
}
}
}
}
}).await;
// 清理 PID 文件
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 format = request.format.unwrap_or_else(|| "wav".to_string());
let style = request.style;
let is_stream = request.stream.unwrap_or(false);
let text_preview: String = text.chars().take(50).collect();
write_log(&format!("处理 TTS: text={}, voice={}, format={}, style={:?}",
text_preview, voice, format, style))?;
write_log(&format!("处理 TTS: text={}, voice={}, format={}, style={:?}, stream={}",
text_preview, voice, format, style, is_stream))?;
// 处理风格标签
let mut final_text = text.clone();
if let Some(style_value) = &style {
// 按官方文档,使用 <style>...</style> 标签
final_text = format!("<style>{}</style>{}", style_value, final_text);
write_log(&format!("添加风格标签: {}", style_value))?;
}
@@ -347,49 +358,117 @@ async fn process_tts_request(request: DaemonRequest) -> Result<String> {
.context("无法加载配置")?;
let config = config_manager.get_config();
// 检查 API Key
if config.api_key.is_empty() {
return Err(anyhow::anyhow!(
"API Key 未设置,请使用: mimo-tts config set --api-key <YOUR_API_KEY>"
));
}
// 创建 TTS 客户端
let client = crate::api::TtsClient::builder()
.base_url(config.base_url.clone())
.api_key(config.api_key.clone())
.build()
.context("无法创建 TTS 客户端")?;
// 构建请求
let mut builder = crate::api::TtsRequest::builder()
.audio(crate::api::AudioConfig {
format: format.clone(),
voice: voice.clone(),
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(),
});
// 添加 assistant 消息(实际要合成的文本)
builder = builder.add_message(crate::api::Message {
role: "assistant".to_string(),
content: final_text.clone(),
});
let tts_request = builder.build();
// 调用 API 合成语音
write_log("正在调用 TTS API...")?;
let audio_data = client
.synthesize_with_request(&tts_request)
.await
.context("语音合成失败")?;
write_log(&format!("TTS 成功,音频数据大小: {} 字节", audio_data.len()))?;
// 播放音频
write_log("正在播放音频...")?;
crate::play_audio(&audio_data)?;
Ok("播放完成".to_string())
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()
.audio(crate::api::AudioConfig {
format: format.clone(),
voice: voice.clone(),
});
builder = builder.add_message(crate::api::Message {
role: "assistant".to_string(),
content: final_text.clone(),
});
let tts_request = builder.build();
write_log("正在调用 TTS API...")?;
let audio_data = client
.synthesize_with_request(&tts_request)
.await
.context("语音合成失败")?;
write_log(&format!("TTS 成功,音频数据大小: {} 字节", audio_data.len()))?;
write_log("正在播放音频...")?;
crate::play_audio(&audio_data)?;
Ok("播放完成".to_string())
}
}
/// 停止守护进程
@@ -488,7 +567,7 @@ pub fn show_logs(lines: u32) -> Result<()> {
}
/// 启动 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 server = match tiny_http::Server::http(&addr) {
Ok(s) => s,
@@ -510,20 +589,32 @@ fn start_http_server(port: u16) -> Result<()> {
// 合成接口
if path == "/synthesize" || path == "/synthesize/" {
let body = request.as_reader();
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) {
Ok(req) => {
let text_preview = if req.text.len() > 30 { format!("{}...", &req.text[..30]) } else { req.text.clone() };
println!("[HTTP] 收到请求: text={}", text_preview);
let text_preview: String = req.text.chars().take(30).collect();
write_log(&format!("[HTTP] 收到合成请求: text={}", text_preview)).ok();
let resp = DaemonResponse {
status: "ok".to_string(),
message: "请求已接收".to_string(),
};
tiny_http::Response::from_string(serde_json::to_string(&resp).unwrap())
// 通过 tokio handle 在同步线程中执行异步 TTS 调用
match rt_handle.block_on(process_tts_request(req)) {
Ok(msg) => {
let resp = DaemonResponse {
status: "ok".to_string(),
message: msg,
};
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) => {
let resp = DaemonResponse {

View File

@@ -150,13 +150,15 @@ async fn run(cli: Cli) -> Result<()> {
// 守护进程模式(由 daemon start -d 自动调用)
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(
&text,
voice.as_deref(),
format.as_deref(),
style.as_deref(),
stream_opt,
port,
)
.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(
cli.text,
cli.file,
@@ -191,15 +207,7 @@ async fn run(cli: Cli) -> Result<()> {
// 根据参数决定处理方式
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();
} else if let Some(output_path) = cli.output {
// 保存到文件
@@ -488,6 +496,128 @@ fn play_audio(data: &[u8]) -> Result<()> {
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 格式
///
/// # 参数
@@ -495,6 +625,7 @@ fn play_audio(data: &[u8]) -> Result<()> {
///
/// # 返回
/// 完整的 WAV 格式数据(包含 44 字节头部)
#[allow(dead_code)]
fn pcm16_to_wav(pcm_data: &[u8]) -> Vec<u8> {
let sample_rate: u32 = 24000; // Mimo-TTS PCM16 输出通常是 24kHz
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 {
let mut result = text.to_string();
// 注意:长匹配必须优先于短匹配,避免互相冲突
// 例如 ... 必须在 . 之前处理
// 处理省略号 …… → ……(拖长音)
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
}

View File

@@ -72,19 +72,27 @@ pub fn show_voices() {
show_separator();
println!();
println!(" {:<16} {}", "Voice ID", "说明");
println!(" {:<16} {:<8} {:<6} {}", "Voice ID", "语言", "性别", "说明");
show_separator();
let voices = [
("mimo_default", "MiMo 默认音色(推荐)"),
("default_zh", "中文音色(兼容旧版)"),
("default_en", "英文音色(兼容旧版)"),
("mimo_default", "多语言", "-", "MiMo 默认(推荐)"),
("冰糖", "中文", "", "甜美清脆的女声"),
("茉莉", "中文", "", "温柔知性的女声"),
("苏打", "中文", "", "阳光活力的男声"),
("白桦", "中文", "", "沉稳大气的男声"),
("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 _ = stdout.execute(SetForegroundColor(Theme::PRIMARY));
let _ = write!(stdout, " {:<16}", id);
let _ = stdout.execute(SetForegroundColor(Theme::TEXT));
let _ = write!(stdout, " {:<8} {:<6}", lang, gender);
let _ = stdout.execute(ResetColor);
let _ = writeln!(stdout, "{}", desc);
}

View File

@@ -555,4 +555,59 @@ mimo-tts daemon --port 9876 stop
- 播放功能复用现有的 `play_audio()` 函数
- 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(流式播放修复)