From d34ebd147eeddee1f9af0fd10c1059fc1d97d875 Mon Sep 17 00:00:00 2001 From: titor Date: Sat, 9 May 2026 04:06:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E6=97=A0=E5=A3=B0=E9=9F=B3=E9=97=AE=E9=A2=98?= =?UTF-8?q?=EF=BC=88SSE=20=E8=A1=8C=E7=BC=93=E5=86=B2=20+=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=B1=82=E7=BA=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Cargo.lock | 2 +- Cargo.toml | 2 +- agents.md | 37 +++- changelog.md | 436 ++++++++++---------------------------------- project.config.toml | 2 +- src/api.rs | 159 ++++++++++++++-- src/cli.rs | 3 + src/client.rs | 11 +- src/config.rs | 2 + src/daemon.rs | 243 ++++++++++++++++-------- src/main.rs | 153 ++++++++++++++-- src/tone.rs | 11 +- src/ui.rs | 18 +- taolun.md | 57 +++++- 15 files changed, 679 insertions(+), 458 deletions(-) diff --git a/.gitignore b/.gitignore index aed7737..f72aa12 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ .code/ .fleet/ .cursor/ +mimo-tts diff --git a/Cargo.lock b/Cargo.lock index 89753b1..1bc2cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,7 +1225,7 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mimo-tts" -version = "0.3.0" +version = "0.3.2" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 510c589..d70e4a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mimo-tts" -version = "0.3.0" +version = "0.3.2" edition = "2021" [dependencies] diff --git a/agents.md b/agents.md index f53c125..2d6dee7 100644 --- a/agents.md +++ b/agents.md @@ -98,9 +98,29 @@ **解决方案**:`text.chars().take(50).collect::()` 按字符截取 **经验**:处理多字节字符(中文)时,必须使用字符索引而非字节索引 +### 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 命令��直接执行的方式 +**原因**:不熟悉 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 原始格式,能快速定位数据流断裂点 diff --git a/changelog.md b/changelog.md index fdda94d..25e7f0c 100644 --- a/changelog.md +++ b/changelog.md @@ -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,简洁易调试 +- 风格参数自动转换为 `` 标签(符合官方文档) + +--- + +## [0.2.0] - 2026-04-24 + +### 新增 +- 实现 `--stream` 流式输出功能 + - SSE(Server-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 -- 新增 play_audio() 函数:使用 rodio 从内存播放 WAV - -### 修改(第四轮) -- 修改 Cargo.toml:添加 rodio 依赖 -- 修改 cli.rs:添加 --play 参数(与 --output 互斥) -- 修改 main.rs: - - synthesize() 返回 Result> - - 添加 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]` 标签并存) - - 服务端自动转换为 `` 标签(符合官方文档) - - 支持如"开心"、"东北话"、"唱歌"等风格描述 - -### 修改(第十一轮) -- 修改 `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 调用) -- ✅ 使用 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 diff --git a/project.config.toml b/project.config.toml index 2e2d024..72febc7 100644 --- a/project.config.toml +++ b/project.config.toml @@ -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" diff --git a/src/api.rs b/src/api.rs index 0cc5489..eb7bb05 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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> { 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::(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>, + ) -> 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, - audio: Option, +struct SseChunk { + choices: Vec, +} + +#[derive(Debug, Deserialize)] +struct SseChoice { + delta: SseDelta, +} + +#[derive(Debug, Deserialize)] +struct SseDelta { + audio: Option, +} + +#[derive(Debug, Deserialize)] +struct SseAudio { + #[allow(dead_code)] + id: Option, + data: Option, +} + +/// 从 SSE event JSON 中提取 base64 音频数据,返回 None 如果无音频 +fn extract_audio_from_sse(data_str: &str) -> Option { + if let Ok(chunk) = serde_json::from_str::(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 diff --git a/src/cli.rs b/src/cli.rs index 25c5f47..4ff6839 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -99,6 +99,9 @@ pub enum Commands { /// 风格描述(可选,如:开心、东北话、唱歌等) #[arg(short, long)] style: Option, + /// 使用流式播放(边下载边播放) + #[arg(long)] + stream: bool, /// 守护进程端口(默认:9876) #[arg(short, long, default_value = "9876")] port: u16, diff --git a/src/client.rs b/src/client.rs index b9b80b3..f7c18c3 100644 --- a/src/client.rs +++ b/src/client.rs @@ -22,6 +22,9 @@ pub struct ClientRequest { /// 风格描述(可选) #[serde(skip_serializing_if = "Option::is_none")] pub style: Option, + /// 是否使用流式播放 + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, } /// 服务端响应结构(与 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, port: u16, ) -> Result { 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); diff --git a/src/config.rs b/src/config.rs index 73e4d78..785934f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, } diff --git a/src/daemon.rs b/src/daemon.rs index 15001c9..5d24925 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -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, + /// 是否使用流式播放(边下载边播放) + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, } /// 服务端响应结构 @@ -68,6 +72,9 @@ fn get_socket_path() -> Result { 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 = 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_local,handle_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 { 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 { - // 按官方文档,使用 标签 final_text = format!("{}", style_value, final_text); write_log(&format!("添加风格标签: {}", style_value))?; } @@ -347,49 +358,117 @@ async fn process_tts_request(request: DaemonRequest) -> Result { .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 " )); } - // 创建 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::>(); + + // spawn_local 不要求 Send,OutputStream 可以留在当前环境 + 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 = 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 = 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 = 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::(&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 { diff --git a/src/main.rs b/src/main.rs index 87aca20..4d4d57c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, + file: Option, + 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 " + )); + } + + 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::>(); + + // 接收任务:先缓冲 ~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 = 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 = 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 = 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 { let sample_rate: u32 = 24000; // Mimo-TTS PCM16 输出通常是 24kHz let bits_per_sample: u16 = 16; diff --git a/src/tone.rs b/src/tone.rs index e84c353..66be9aa 100644 --- a/src/tone.rs +++ b/src/tone.rs @@ -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 } diff --git a/src/ui.rs b/src/ui.rs index 415dac1..b593591 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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); } diff --git a/taolun.md b/taolun.md index f3c344b..862555b 100644 --- a/taolun.md +++ b/taolun.md @@ -555,4 +555,59 @@ mimo-tts daemon --port 9876 stop - 播放功能复用现有的 `play_audio()` 函数 - 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 数据成功进入 channel,sink 正常播放 ✅ + +### 技术细节 +- `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(流式播放修复) +