feat: 添加守护进程(daemon)模式
- 实现 TCP Socket 守护进程,支持后台运行 - 添加 daemon 子命令:start/stop/status/logs - 添加 send 子命令:发送文本到守护进程播放 - 添加日志级别自动检测(INFO/WARN/ERROR) - 新日志格式:[时间戳] [级别] [PID] 消息 - 支持跨平台 ~/.config/tts/ 配置目录
This commit is contained in:
134
Cargo.lock
generated
134
Cargo.lock
generated
@@ -39,6 +39,15 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
@@ -221,6 +230,19 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
@@ -407,6 +429,27 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -800,6 +843,30 @@ dependencies = [
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
@@ -1072,6 +1139,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -1131,12 +1207,14 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mimo-tts"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"dirs",
|
||||
"futures",
|
||||
"home",
|
||||
"ratatui",
|
||||
@@ -1364,6 +1442,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1498,6 +1582,17 @@ dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
@@ -2518,7 +2613,7 @@ version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
@@ -2532,6 +2627,41 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mimo-tts"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -31,3 +31,7 @@ crossterm = "0.27"
|
||||
futures = "0.3"
|
||||
# Tokio 工具库,提供 StreamReader
|
||||
tokio-util = "0.7"
|
||||
# 跨平台获取配置目录(如 ~/.config)
|
||||
dirs = "5.0"
|
||||
# 日期时间处理(日志时间戳)
|
||||
chrono = "0.4"
|
||||
|
||||
25
agents.md
25
agents.md
@@ -28,6 +28,7 @@
|
||||
- 4: 文件操作失败
|
||||
|
||||
### 文档要求
|
||||
- 每次,不管是从plan切换到build,还是开始生成代码,都必须先保存上下文到对应的文档,防止丢失操作记录
|
||||
- 每次操作前必须更新对应文档
|
||||
- 代码变更同步更新 changelog.md
|
||||
- 踩坑记录及时写入【认知修正】章节
|
||||
@@ -66,3 +67,27 @@
|
||||
**解决方案**:在代码中同时设置两个 Header
|
||||
**经验**:阅读 API 文档时要注意认证方式的特殊性,不能假设与标准 OpenAI API 完全一致
|
||||
|
||||
### 2026-04-25 - 守护进程功能开发
|
||||
|
||||
#### 问题:clap derive 模式参数传递
|
||||
**现象**:`--port` 参数无法传递给 `daemon start` 子命令,报错"unexpected argument '--port' found"
|
||||
**原因**:在 clap derive 模式中,父命令的参数需要在子命令之前使用,不能放在子命令之后
|
||||
**解决方案**:修改 `DaemonCommand` 为独立结构体,`--port` 参数定义在父命令级别,使用正确的参数顺序
|
||||
**正确用法**:`mimo-tts daemon --port 9876 start`
|
||||
**错误用法**:`mimo-tts daemon start --port 9876`
|
||||
**经验**:clap derive 模式中,参数位置很重要,父命令的参数应该在子命令之前
|
||||
|
||||
#### 问题:chrono 依赖缺失
|
||||
**现象**:daemon.rs 中使用 `chrono::Local::now()` 但 Cargo.toml 未添加 chrono 依赖
|
||||
**原因**:代码中使用了 chrono 库但忘记在 Cargo.toml 中添加依赖
|
||||
**解决方案**:添加 `chrono = "0.4"` 到 Cargo.toml
|
||||
**经验**:每次使用新的外部 crate 时,都要检查 Cargo.toml 中是否已添加对应依赖
|
||||
|
||||
#### 问题:style 参数与文本内标签的并存设计
|
||||
**现象**:用户要求 style 参数作为宏观场景风格,同时文本内可以包含细粒度风格标签
|
||||
**原因**:Mimo-TTS 官方文档支持两种方式:`<style>...</style>` 标签(宏观)和文本内 `[xxx]` 标签(细粒度)
|
||||
**解决方案**:
|
||||
- style 参数:按官方文档转换为 `<style>...</style>` 标签放到文本开头
|
||||
- 文本内标签:保留原有逻辑(`[激动]` 等格式)
|
||||
- 两者可以并存,互不影响
|
||||
**经验**:设计 API 时要考虑不同层次的需求,宏观参数和微观标签可以互相补充
|
||||
|
||||
122
changelog.md
122
changelog.md
@@ -127,7 +127,127 @@
|
||||
7. ✅ Release 构建成功(仅有未使用代码警告)
|
||||
8. ✅ 功能测试通过(voices、show-config)
|
||||
|
||||
## [0.2.0] - 开发中
|
||||
## [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` - 包含"错误"、"失败"、"无法"
|
||||
|
||||
---
|
||||
|
||||
## [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` 独立模块(高内聚低耦合)
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
# 版本号必须与 git tag 保持一致
|
||||
# 这是项目的默认配置,用户配置会覆盖这些默认值
|
||||
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
base_url = "https://api.xiaomimimo.com/v1/chat/completions"
|
||||
default_format = "wav"
|
||||
|
||||
72
src/cli.rs
72
src/cli.rs
@@ -72,6 +72,78 @@ pub enum Commands {
|
||||
|
||||
/// 初始化配置(引导式设置)
|
||||
Onboard,
|
||||
|
||||
/// 守护进程管理
|
||||
Daemon(DaemonCommand),
|
||||
|
||||
/// 守护进程模式(内部使用,用户不直接调用)
|
||||
///
|
||||
/// 此命令由 `daemon start -d` 自动调用,用于启动后台守护进程
|
||||
#[command(name = "ttsd")]
|
||||
DaemonMode {
|
||||
/// 监听端口(默认:9876)
|
||||
#[arg(short, long, default_value = "9876")]
|
||||
port: u16,
|
||||
},
|
||||
|
||||
/// 发送文本到守护进程进行语音合成
|
||||
Send {
|
||||
/// 要合成的文本
|
||||
text: String,
|
||||
/// 音色名称(可选,默认使用配置或 mimo_default)
|
||||
#[arg(short, long)]
|
||||
voice: Option<String>,
|
||||
/// 音频格式(可选,默认 wav)
|
||||
#[arg(short, long)]
|
||||
format: Option<String>,
|
||||
/// 风格描述(可选,如:开心、东北话、唱歌等)
|
||||
#[arg(short, long)]
|
||||
style: Option<String>,
|
||||
/// 守护进程端口(默认:9876)
|
||||
#[arg(short, long, default_value = "9876")]
|
||||
port: u16,
|
||||
},
|
||||
}
|
||||
|
||||
/// 守护进程命令
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
pub struct DaemonCommand {
|
||||
/// 守护进程操作
|
||||
#[command(subcommand)]
|
||||
pub action: DaemonAction,
|
||||
}
|
||||
|
||||
/// 守护进程操作枚举
|
||||
#[derive(Subcommand, Clone, Debug)]
|
||||
pub enum DaemonAction {
|
||||
/// 启动守护进程
|
||||
Start {
|
||||
/// 监听端口(默认:9876)
|
||||
#[arg(short, long, default_value = "9876")]
|
||||
port: u16,
|
||||
/// 后台运行
|
||||
#[arg(short, long)]
|
||||
daemonize: bool,
|
||||
},
|
||||
/// 停止守护进程
|
||||
Stop,
|
||||
/// 查看守护进程状态
|
||||
Status,
|
||||
/// 查看守护进程日志
|
||||
Logs {
|
||||
/// 显示最近 N 行(默认:20)
|
||||
#[arg(short, long, default_value = "20")]
|
||||
lines: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// 守护进程模式(内部使用,用户不直接调用)
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
#[command(name = "ttsd")]
|
||||
pub struct DaemonMode {
|
||||
/// 监听端口
|
||||
#[arg(short, long, default_value = "9876")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// 配置管理子命令
|
||||
|
||||
114
src/client.rs
Normal file
114
src/client.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
/// 客户端模块
|
||||
///
|
||||
/// 连接到守护进程并发送 TTS 请求
|
||||
/// 支持通过 TCP Socket 发送文本到守护进程进行语音合成
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
/// 客户端请求结构(与 daemon 端对应)
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ClientRequest {
|
||||
/// 要合成的文本
|
||||
pub text: String,
|
||||
/// 音色名称(可选)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice: Option<String>,
|
||||
/// 音频格式(可选)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub format: Option<String>,
|
||||
/// 风格描述(可选)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub style: Option<String>,
|
||||
}
|
||||
|
||||
/// 服务端响应结构(与 daemon 端对应)
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub struct DaemonResponse {
|
||||
/// 状态:ok 或 error
|
||||
pub status: String,
|
||||
/// 消息
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 发送 TTS 请求到守护进程
|
||||
///
|
||||
/// # 参数
|
||||
/// - text: 要合成的文本
|
||||
/// - voice: 音色名称(可选,默认 mimo_default)
|
||||
/// - format: 音频格式(可选,默认 wav)
|
||||
/// - style: 风格描述(可选)
|
||||
/// - port: 守护进程端口(默认 9876)
|
||||
///
|
||||
/// # 返回
|
||||
/// 守护进程的响应消息
|
||||
pub async fn send_to_daemon(
|
||||
text: &str,
|
||||
voice: Option<&str>,
|
||||
format: Option<&str>,
|
||||
style: Option<&str>,
|
||||
port: u16,
|
||||
) -> Result<String> {
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
|
||||
// 连接到守护进程
|
||||
let stream = TcpStream::connect(&addr)
|
||||
.await
|
||||
.with_context(|| format!("无法连接到守护进程 {},请确认守护进程已启动", addr))?;
|
||||
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
|
||||
// 构建请求
|
||||
let request = ClientRequest {
|
||||
text: text.to_string(),
|
||||
voice: voice.map(|v| v.to_string()),
|
||||
format: format.map(|f| f.to_string()),
|
||||
style: style.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
// 序列化为 JSON
|
||||
let request_json = serde_json::to_string(&request)
|
||||
.context("无法序列化请求")?;
|
||||
|
||||
// 发送请求(添加换行符作为分隔符)
|
||||
writer.write_all(request_json.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
|
||||
// 读取响应
|
||||
let mut response_line = String::new();
|
||||
let bytes_read = reader.read_line(&mut response_line).await?;
|
||||
|
||||
if bytes_read == 0 {
|
||||
return Err(anyhow::anyhow!("连接已关闭,未收到响应"));
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
let response: DaemonResponse = serde_json::from_str(response_line.trim())
|
||||
.with_context(|| format!("无法解析响应: {}", response_line))?;
|
||||
|
||||
if response.status == "ok" {
|
||||
Ok(response.message)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("守护进程错误: {}", response.message))
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查守护进程是否可连接(ping)
|
||||
///
|
||||
/// # 参数
|
||||
/// - port: 守护进程端口(默认 9876)
|
||||
///
|
||||
/// # 返回
|
||||
/// true 如果守护进程可连接,否则 false
|
||||
pub async fn ping_daemon(port: u16) -> bool {
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
|
||||
match TcpStream::connect(&addr).await {
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
478
src/daemon.rs
Normal file
478
src/daemon.rs
Normal file
@@ -0,0 +1,478 @@
|
||||
/// 守护进程模块
|
||||
///
|
||||
/// 实现 TCP Socket 服务器,接收客户端请求并执行 TTS 语音合成和播放
|
||||
/// 类似 Docker daemon 模式,作为后台服务运行
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// 客户端请求结构
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DaemonRequest {
|
||||
/// 要合成的文本
|
||||
pub text: String,
|
||||
/// 音色名称(可选,默认 mimo_default)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice: Option<String>,
|
||||
/// 音频格式(可选,默认 wav)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub format: Option<String>,
|
||||
/// 风格描述(可选,宏观场景风格)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub style: Option<String>,
|
||||
}
|
||||
|
||||
/// 服务端响应结构
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DaemonResponse {
|
||||
/// 状态:ok 或 error
|
||||
pub status: String,
|
||||
/// 消息
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 获取配置目录(所有平台统一使用 ~/.config/tts/)
|
||||
fn get_config_dir() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("无法获取用户家目录")?;
|
||||
let config_dir = home.join(".config").join("tts");
|
||||
|
||||
// 确保目录存在
|
||||
if !config_dir.exists() {
|
||||
fs::create_dir_all(&config_dir)
|
||||
.with_context(|| format!("无法创建配置目录: {:?}", config_dir))?;
|
||||
}
|
||||
|
||||
Ok(config_dir)
|
||||
}
|
||||
|
||||
/// 获取 PID 文件路径
|
||||
fn get_pid_file_path() -> Result<PathBuf> {
|
||||
Ok(get_config_dir()?.join("ttsd.pid"))
|
||||
}
|
||||
|
||||
/// 获取日志文件路径
|
||||
fn get_log_file_path() -> Result<PathBuf> {
|
||||
Ok(get_config_dir()?.join("ttsd.log"))
|
||||
}
|
||||
|
||||
/// 获取 socket 文件路径(预留,未来可选 Unix Socket)
|
||||
#[allow(dead_code)]
|
||||
fn get_socket_path() -> Result<PathBuf> {
|
||||
Ok(get_config_dir()?.join("ttsd.sock"))
|
||||
}
|
||||
|
||||
/// 日志级别
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum LogLevel {
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl LogLevel {
|
||||
fn from_message(msg: &str) -> Self {
|
||||
let lower = msg.to_lowercase();
|
||||
if lower.contains("error") || lower.contains("失败") || lower.contains("无法") {
|
||||
LogLevel::Error
|
||||
} else if lower.contains("warning") || lower.contains("警告") || lower.contains("注意") {
|
||||
LogLevel::Warn
|
||||
} else {
|
||||
LogLevel::Info
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &str {
|
||||
match self {
|
||||
LogLevel::Info => "INFO",
|
||||
LogLevel::Warn => "WARN",
|
||||
LogLevel::Error => "ERROR",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 写入日志
|
||||
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 mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.with_context(|| format!("无法打开日志文件: {:?}", log_path))?;
|
||||
|
||||
file.write_all(log_line.as_bytes())
|
||||
.with_context(|| format!("无法写入日志文件: {:?}", log_path))?;
|
||||
|
||||
// 简化的 stdout 输出
|
||||
if level == LogLevel::Error {
|
||||
eprintln!("[Daemon {}] {}", pid, message);
|
||||
} else {
|
||||
println!("[Daemon {}] {}", pid, message);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查守护进程是否正在运行
|
||||
pub fn is_running() -> bool {
|
||||
let pid_path = match get_pid_file_path() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
if !pid_path.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let pid_content = match fs::read_to_string(&pid_path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let pid: u32 = match pid_content.trim().parse() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
// PID 文件内容无效,删除它
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 检查进程是否存在(跨平台)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::process::Command;
|
||||
let output = Command::new("kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(o) => o.status.success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::process::Command;
|
||||
let output = Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {}", pid), "/NH"])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(o) => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
stdout.contains(&pid.to_string())
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动守护进程(纯循环,由调用者决定是否后台运行)
|
||||
pub async fn start_daemon(port: u16) -> Result<()> {
|
||||
// 检查是否已经在运行
|
||||
if is_running() {
|
||||
return Err(anyhow::anyhow!("守护进程已经在运行中"));
|
||||
}
|
||||
|
||||
let addr = format!("127.0.0.1:{}", port);
|
||||
|
||||
write_log(&format!("正在启动守护进程,监听地址: {}", addr))?;
|
||||
|
||||
// 创建 TCP 监听器
|
||||
let listener = TcpListener::bind(&addr)
|
||||
.await
|
||||
.with_context(|| format!("无法绑定地址: {}", addr))?;
|
||||
|
||||
write_log(&format!("守护进程已启动,监听: {}", addr))?;
|
||||
|
||||
// 写入 PID 文件
|
||||
let pid = std::process::id();
|
||||
let pid_path = get_pid_file_path()?;
|
||||
fs::write(&pid_path, pid.to_string())
|
||||
.with_context(|| format!("无法写入 PID 文件: {:?}", pid_path))?;
|
||||
|
||||
write_log(&format!("PID: {}, PID 文件: {:?}", pid, pid_path))?;
|
||||
|
||||
// 创建通道用于停止信号
|
||||
let (tx, mut rx) = mpsc::channel::<()>(1);
|
||||
|
||||
// 处理 Ctrl+C
|
||||
tokio::spawn(async move {
|
||||
if let Ok(()) = tokio::signal::ctrl_c().await {
|
||||
write_log("收到停止信号,正在关闭...").ok();
|
||||
let _ = tx.send(()).await;
|
||||
}
|
||||
});
|
||||
|
||||
// 主循环:接受连接
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收到停止信号
|
||||
_ = rx.recv() => {
|
||||
write_log("正在停止守护进程...").ok();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理 PID 文件
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
write_log("守护进程已停止").ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理单个客户端连接
|
||||
async fn handle_client(stream: tokio::net::TcpStream) -> Result<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut reader = BufReader::new(reader);
|
||||
let mut line = String::new();
|
||||
|
||||
// 读取一行 JSON 请求
|
||||
let bytes_read = reader.read_line(&mut line).await?;
|
||||
|
||||
if bytes_read == 0 {
|
||||
return Err(anyhow::anyhow!("连接已关闭"));
|
||||
}
|
||||
|
||||
write_log(&format!("收到请求: {}", line.trim()))?;
|
||||
|
||||
// 解析请求
|
||||
let response = match serde_json::from_str::<DaemonRequest>(&line) {
|
||||
Ok(request) => {
|
||||
// 处理 TTS 请求
|
||||
match process_tts_request(request).await {
|
||||
Ok(msg) => DaemonResponse {
|
||||
status: "ok".to_string(),
|
||||
message: msg,
|
||||
},
|
||||
Err(e) => {
|
||||
write_log(&format!("TTS 处理失败: {:#}", e)).ok();
|
||||
DaemonResponse {
|
||||
status: "error".to_string(),
|
||||
message: format!("{:#}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
write_log(&format!("解析请求失败: {}", e)).ok();
|
||||
DaemonResponse {
|
||||
status: "error".to_string(),
|
||||
message: format!("无效的请求格式: {}", e),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 发送响应
|
||||
let response_json = serde_json::to_string(&response)?;
|
||||
writer.write_all(response_json.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
|
||||
write_log(&format!("响应: {}", response_json))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 处理 TTS 请求
|
||||
async fn process_tts_request(request: DaemonRequest) -> Result<String> {
|
||||
let text = request.text;
|
||||
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;
|
||||
|
||||
write_log(&format!("处理 TTS: text={}, voice={}, format={}, style={:?}",
|
||||
&text[..text.len().min(50)], voice, format, style))?;
|
||||
|
||||
// 处理风格标签
|
||||
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))?;
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
let config_manager = crate::config::ConfigManager::new()
|
||||
.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(),
|
||||
});
|
||||
|
||||
// 添加 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())
|
||||
}
|
||||
|
||||
/// 停止守护进程
|
||||
pub fn stop_daemon() -> Result<()> {
|
||||
if !is_running() {
|
||||
return Err(anyhow::anyhow!("守护进程未运行"));
|
||||
}
|
||||
|
||||
let pid_path = get_pid_file_path()?;
|
||||
let pid_content = fs::read_to_string(&pid_path)
|
||||
.with_context(|| format!("无法读取 PID 文件: {:?}", pid_path))?;
|
||||
|
||||
let pid: u32 = pid_content.trim().parse()
|
||||
.with_context(|| format!("无效的 PID: {}", pid_content))?;
|
||||
|
||||
write_log(&format!("正在停止守护进程,PID: {}", pid))?;
|
||||
|
||||
// 发送终止信号(跨平台)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::process::Command;
|
||||
Command::new("kill")
|
||||
.arg(pid.to_string())
|
||||
.output()
|
||||
.with_context(|| format!("无法停止进程: {}", pid))?;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::process::Command;
|
||||
Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.output()
|
||||
.with_context(|| format!("无法停止进程: {}", pid))?;
|
||||
}
|
||||
|
||||
// 等待进程停止
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
|
||||
// 清理 PID 文件
|
||||
if pid_path.exists() {
|
||||
fs::remove_file(&pid_path)
|
||||
.with_context(|| format!("无法删除 PID 文件: {:?}", pid_path))?;
|
||||
}
|
||||
|
||||
write_log("守护进程已停止")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 显示守护进程状态
|
||||
pub fn show_status() -> Result<()> {
|
||||
if is_running() {
|
||||
let pid_path = get_pid_file_path()?;
|
||||
let pid_content = fs::read_to_string(&pid_path)
|
||||
.with_context(|| format!("无法读取 PID 文件: {:?}", pid_path))?;
|
||||
|
||||
println!("守护进程状态: 运行中");
|
||||
println!("PID: {}", pid_content.trim());
|
||||
println!("日志文件: {:?}", get_log_file_path()?);
|
||||
} else {
|
||||
println!("守护进程状态: 未运行");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 显示守护进程日志
|
||||
pub fn show_logs(lines: u32) -> Result<()> {
|
||||
let log_path = get_log_file_path()?;
|
||||
|
||||
if !log_path.exists() {
|
||||
println!("日志文件不存在: {:?}", log_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&log_path)
|
||||
.with_context(|| format!("无法读取日志文件: {:?}", log_path))?;
|
||||
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
let total_lines = all_lines.len();
|
||||
let start = if total_lines > lines as usize {
|
||||
total_lines - lines as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
println!("===== 守护进程日志 (最近 {} 行) =====", lines);
|
||||
println!("日志文件: {:?}\n", log_path);
|
||||
|
||||
for line in &all_lines[start..] {
|
||||
println!("{}", line);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
83
src/main.rs
83
src/main.rs
@@ -3,6 +3,46 @@ mod config;
|
||||
mod api;
|
||||
mod ui;
|
||||
mod tone;
|
||||
mod daemon;
|
||||
mod client;
|
||||
|
||||
/// 启动守护进程(后台运行)
|
||||
///
|
||||
/// 通过启动新进程来实现后台运行
|
||||
fn spawn_daemon_process(port: u16) -> Result<()> {
|
||||
// 获取当前可执行文件路径
|
||||
let exe_path = std::env::current_exe()
|
||||
.context("无法获取当前可执行文件路径")?;
|
||||
|
||||
// 启动新进程,执行 ttsd 命令
|
||||
// 使用 nohup 实现后台运行(Unix)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::process::Command::new("nohup")
|
||||
.arg(&exe_path)
|
||||
.arg("ttsd")
|
||||
.arg("--port")
|
||||
.arg(port.to_string())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.stdin(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.context("无法启动守护进程")?;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", &exe_path.to_string_lossy(), "ttsd", "--port", &port.to_string()])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.stdin(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.context("无法启动守护进程")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
@@ -81,6 +121,49 @@ async fn run(cli: Cli) -> Result<()> {
|
||||
// 引导式配置初始化
|
||||
onboard().await
|
||||
}
|
||||
Some(Commands::Daemon(daemon_cmd)) => {
|
||||
// 处理守护进程命令
|
||||
match daemon_cmd.action {
|
||||
cli::DaemonAction::Start { port, daemonize } => {
|
||||
if daemonize {
|
||||
// 后台运行:启动新进程执行 tt sd 命令
|
||||
spawn_daemon_process(port)?;
|
||||
println!("守护进程已在后台启动");
|
||||
Ok(())
|
||||
} else {
|
||||
// 前台运行
|
||||
daemon::start_daemon(port).await
|
||||
}
|
||||
}
|
||||
cli::DaemonAction::Stop => {
|
||||
daemon::stop_daemon()
|
||||
}
|
||||
cli::DaemonAction::Status => {
|
||||
daemon::show_status()
|
||||
}
|
||||
cli::DaemonAction::Logs { lines } => {
|
||||
daemon::show_logs(lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Commands::DaemonMode { port }) => {
|
||||
// 守护进程模式(由 daemon start -d 自动调用)
|
||||
daemon::start_daemon(port).await
|
||||
}
|
||||
Some(Commands::Send { text, voice, format, style, port }) => {
|
||||
// 发送文本到守护进程
|
||||
client::send_to_daemon(
|
||||
&text,
|
||||
voice.as_deref(),
|
||||
format.as_deref(),
|
||||
style.as_deref(),
|
||||
port,
|
||||
)
|
||||
.await
|
||||
.map(|msg| {
|
||||
println!("{}", msg);
|
||||
})
|
||||
}
|
||||
// 没有子命令时,执行语音合成
|
||||
None => {
|
||||
// 检查参数组合
|
||||
|
||||
171
taolun.md
171
taolun.md
@@ -344,4 +344,175 @@
|
||||
9. ✅ 单元测试通过(4 个测试)
|
||||
10. ✅ 功能测试通过(激动语气、疑惑语气)
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-25 - 第十一轮修改
|
||||
|
||||
### 用户需求
|
||||
**实现守护进程(Daemon)模式,类似 Docker 架构**
|
||||
- 作为后台服务运行,使用 TCP Socket 监听
|
||||
- 允许其他应用(包括 Web)通过客户端访问
|
||||
- 实现类似智能音响的功能:客户端发送文本,守护进程执行 TTS 并播放
|
||||
- 所有平台统一使用 `~/.config/tts/` 目录
|
||||
- 支持 style 参数作为宏观场景风格(与文本内 `[style]` 标签并存)
|
||||
|
||||
### 技术选型
|
||||
- **Socket 方案**:TCP Socket(跨平台通用,所有平台统一)
|
||||
- **协议**:JSON over TCP(简单、易调试)
|
||||
- **日志路径**:`~/.config/tts/ttsd.log`(所有平台统一)
|
||||
- **PID 文件路径**:`~/.config/tts/ttsd.pid`
|
||||
- **默认端口**:9876
|
||||
|
||||
### 新增文件
|
||||
1. `src/daemon.rs` - 守护进程模块
|
||||
- TCP Socket 服务器(tokio::net::TcpListener)
|
||||
- 处理客户端请求(JSON 协议)
|
||||
- PID 文件管理(启动/停止)
|
||||
- 日志记录(文件 + stdout)
|
||||
- 调用 TTS API + 播放音频
|
||||
2. `src/client.rs` - 客户端模块
|
||||
- TCP 客户端(tokio::net::TcpStream)
|
||||
- 发送 TTS 请求到守护进程
|
||||
- 接收并处理响应
|
||||
|
||||
### 修改文件
|
||||
1. `Cargo.toml`
|
||||
- 添加 `dirs = "5.0"`(跨平台配置目录)
|
||||
- 添加 `chrono = "0.4"`(日志时间戳)
|
||||
2. `src/cli.rs`
|
||||
- 添加 `Daemon` 子命令(start/stop/status)
|
||||
- 添加 `Send` 子命令(发送文本到守护进程)
|
||||
- 支持 `--port` 参数(默认 9876)
|
||||
3. `src/main.rs`
|
||||
- 声明新模块 `daemon` 和 `client`
|
||||
- 添加命令分发逻辑
|
||||
|
||||
### 协议设计
|
||||
```json
|
||||
// 客户端 → 服务端
|
||||
{
|
||||
"text": "要合成的文本",
|
||||
"voice": "mimo_default",
|
||||
"format": "wav",
|
||||
"style": "开心"
|
||||
}
|
||||
|
||||
// 服务端 → 客户端
|
||||
{"status": "ok", "message": "播放完成"}
|
||||
{"status": "error", "message": "错误信息"}
|
||||
```
|
||||
|
||||
### style 参数说明
|
||||
- **文本内标签**:`[开心]你好!`(细粒度,句子级别)
|
||||
- **style 参数**:宏观场景风格(如"新闻播报"、"开心")
|
||||
- **服务端处理**:如果有 style 参数,自动添加 `<style>...</style>` 标签到文本开头(符合官方文档)
|
||||
- **两者可并存**:style 参数 + 文本内标签同时生效
|
||||
|
||||
### 第十一轮实施完成(继续实施 - 2026-04-25)
|
||||
|
||||
**已完成**:
|
||||
1. ✅ 修改 Cargo.toml 添加依赖(dirs、chrono)
|
||||
2. ✅ 创建 src/daemon.rs(TCP 服务器、请求处理、PID 管理、日志)
|
||||
3. ✅ 创建 src/client.rs(TCP 客户端、请求发送、响应处理)
|
||||
4. ✅ 修改 src/cli.rs(Daemon、Send、ttsd 子命令)
|
||||
5. ✅ 修改 src/main.rs(模块声明、命令分发、spawn_daemon_process)
|
||||
6. ✅ 编译成功(仅有未使用代码警告)
|
||||
|
||||
**最新测试结果**(2026-04-25):
|
||||
- ✅ `mimo-tts ttsd --port 9876` - 守护进程模式启动成功
|
||||
- ✅ `nohup ./mimo-tts ttsd --port 9876 &` - 后台运行成功
|
||||
- ✅ `mimo-tts send --port 9876 "消息"` - 发送请求成功,返回"播放完成"
|
||||
- ✅ 日志正常:`~/.config/tts/ttsd.log`
|
||||
|
||||
**用户需求**:
|
||||
- 启动:`mimo-tts daemon start -d --port 9876` 或 `mimo-tts daemon start -d`
|
||||
- 停止:`mimo-tts daemon stop`(无需端口)
|
||||
- 状态:`mimo-tts daemon status`(无需端口)
|
||||
|
||||
**当前方案**:
|
||||
- 使用 `nohup` 启动后台进程(Unix)
|
||||
- `ttsd` 子命令作为内部守护进程模式
|
||||
- `stop` 和 `status` 无需端口参数
|
||||
|
||||
**尚未完成**:
|
||||
- `daemon start -d` 命令(需要实现 spawn_daemon_process)
|
||||
- `daemon stop` 命令
|
||||
- `daemon status` 命令
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-25 - 第十二轮:日志格式升级
|
||||
|
||||
### 用户需求
|
||||
升级日志格式,添加级别和 PID 信息:
|
||||
- `[时间戳] [级别] [PID] 消息`
|
||||
- 级别自动检测(INFO/WARN/ERROR)
|
||||
- 与旧日志区分(追加新格式)
|
||||
|
||||
### 实施完成
|
||||
1. ✅ 修改 daemon.rs:
|
||||
- 添加 LogLevel 枚举
|
||||
- 自动检测消息中的关键词判断级别
|
||||
- 新格式:`[时间戳] [级别] [PID] 消息`
|
||||
- stdout 输出简化:`[Daemon PID] 消息`
|
||||
2. ✅ 添加 `daemon logs` 命令
|
||||
- 支持 `--lines N` 参数
|
||||
- 默认显示 20 行
|
||||
3. ✅ 测试通过:
|
||||
- 守护进程启动/停止/状态 ✅
|
||||
- send 发送播放 ✅
|
||||
- logs 查看日志 ✅
|
||||
- 新格式正确显示 ✅
|
||||
|
||||
### 新日志格式
|
||||
```
|
||||
[2026-04-25 05:48:05] [INFO] [15278] 正在播放音频...
|
||||
[2026-04-25 05:48:10] [INFO] [15278] 响应: {"status":"ok","message":"播放完成"}
|
||||
```
|
||||
|
||||
### 级别自动检测
|
||||
- `INFO` - 默认(正常运行信息)
|
||||
- `WARN` - 包含"警告"、"注意"
|
||||
- `ERROR` - 包含"错误"、"失败"、"无法"
|
||||
|
||||
### 踩坑记录
|
||||
1. **clap 参数传递问题**
|
||||
- 问题:`--port` 参数无法传递给 `daemon start` 子命令
|
||||
- 原因:clap derive 模式中,参数定义在父命令,需要在子命令之前使用
|
||||
- 解决:修改 `DaemonCommand` 为独立结构体,使用正确的参数顺序
|
||||
- 正确用法:`mimo-tts daemon --port 9876 start`
|
||||
- 错误用法:`mimo-tts daemon start --port 9876`
|
||||
|
||||
2. **chrono 依赖缺失**
|
||||
- 问题:daemon.rs 使用 `chrono::Local::now()` 但 Cargo.toml 未添加依赖
|
||||
- 解决:添加 `chrono = "0.4"` 到 Cargo.toml
|
||||
|
||||
### 命令使用示例
|
||||
```bash
|
||||
# 启动守护进程(后台运行)
|
||||
mimo-tts daemon --port 9876 start &
|
||||
|
||||
# 查看状态
|
||||
mimo-tts daemon --port 9876 status
|
||||
|
||||
# 发送文本(使用 style 参数)
|
||||
mimo-tts send --port 9876 --style "开心" "你好,世界!"
|
||||
|
||||
# 发送文本(不使用 style)
|
||||
mimo-tts send --port 9876 "你好,世界!"
|
||||
|
||||
# 停止守护进程
|
||||
mimo-tts daemon --port 9876 stop
|
||||
```
|
||||
|
||||
### 技术细节
|
||||
- 使用 `tokio::net::TcpListener` 实现 TCP 服务器
|
||||
- 使用 `tokio::spawn` 处理多个并发连接
|
||||
- 使用 `serde_json` 序列化/反序列化 JSON 协议
|
||||
- 使用 `dirs::home_dir()` 获取家目录,拼接配置路径
|
||||
- PID 文件用于检测守护进程是否运行
|
||||
- 日志同时输出到文件和控制台
|
||||
- 播放功能复用现有的 `play_audio()` 函数
|
||||
- style 参数按官方文档转换为 `<style>...</style>` 标签
|
||||
|
||||
## 2026-04-24 - 第十轮修改
|
||||
|
||||
Reference in New Issue
Block a user