fix: 修复流式播放无声音问题(SSE 行缓冲 + 解析层级)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@
|
||||
.code/
|
||||
.fleet/
|
||||
.cursor/
|
||||
mimo-tts
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1225,7 +1225,7 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mimo-tts"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mimo-tts"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
37
agents.md
37
agents.md
@@ -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 原始格式,能快速定位数据流断裂点
|
||||
|
||||
436
changelog.md
436
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,简洁易调试
|
||||
- 风格参数自动转换为 `<style>...</style>` 标签(符合官方文档)
|
||||
|
||||
---
|
||||
|
||||
## [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<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 调用)
|
||||
- ✅ 使用 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
159
src/api.rs
159
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<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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
243
src/daemon.rs
243
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<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_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<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 不要求 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<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 {
|
||||
|
||||
153
src/main.rs
153
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<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;
|
||||
|
||||
11
src/tone.rs
11
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
|
||||
}
|
||||
|
||||
|
||||
18
src/ui.rs
18
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);
|
||||
}
|
||||
|
||||
57
taolun.md
57
taolun.md
@@ -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 数据成功进入 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(流式播放修复)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user