From 19eb313befd242bf9af8094fee44a40eeb130d14 Mon Sep 17 00:00:00 2001 From: titor Date: Sat, 25 Apr 2026 05:50:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=88=E6=8A=A4?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B(daemon)=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现 TCP Socket 守护进程,支持后台运行 - 添加 daemon 子命令:start/stop/status/logs - 添加 send 子命令:发送文本到守护进程播放 - 添加日志级别自动检测(INFO/WARN/ERROR) - 新日志格式:[时间戳] [级别] [PID] 消息 - 支持跨平台 ~/.config/tts/ 配置目录 --- Cargo.lock | 134 ++++++++++++- Cargo.toml | 6 +- agents.md | 25 +++ changelog.md | 122 ++++++++++- project.config.toml | 2 +- src/cli.rs | 72 +++++++ src/client.rs | 114 +++++++++++ src/daemon.rs | 478 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 83 ++++++++ taolun.md | 171 ++++++++++++++++ 10 files changed, 1202 insertions(+), 5 deletions(-) create mode 100644 src/client.rs create mode 100644 src/daemon.rs diff --git a/Cargo.lock b/Cargo.lock index 850bc8d..da4d0e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index b85237a..dcb415b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/agents.md b/agents.md index 6a889d0..9b4553c 100644 --- a/agents.md +++ b/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 官方文档支持两种方式:`` 标签(宏观)和文本内 `[xxx]` 标签(细粒度) +**解决方案**: + - style 参数:按官方文档转换为 `` 标签放到文本开头 + - 文本内标签:保留原有逻辑(`[激动]` 等格式) + - 两者可以并存,互不影响 +**经验**:设计 API 时要考虑不同层次的需求,宏观参数和微观标签可以互相补充 diff --git a/changelog.md b/changelog.md index 94c552e..8394112 100644 --- a/changelog.md +++ b/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]` 标签并存) + - 服务端自动转换为 `` 标签(符合官方文档) + - 支持如"开心"、"东北话"、"唱歌"等风格描述 + +### 修改(第十一轮) +- 修改 `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` 独立模块(高内聚低耦合) diff --git a/project.config.toml b/project.config.toml index b46d222..2e2d024 100644 --- a/project.config.toml +++ b/project.config.toml @@ -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" diff --git a/src/cli.rs b/src/cli.rs index 85873c8..25c5f47 100644 --- a/src/cli.rs +++ b/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, + /// 音频格式(可选,默认 wav) + #[arg(short, long)] + format: Option, + /// 风格描述(可选,如:开心、东北话、唱歌等) + #[arg(short, long)] + style: Option, + /// 守护进程端口(默认: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, } /// 配置管理子命令 diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..b9b80b3 --- /dev/null +++ b/src/client.rs @@ -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, + /// 音频格式(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + /// 风格描述(可选) + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, +} + +/// 服务端响应结构(与 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 { + 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, + } +} diff --git a/src/daemon.rs b/src/daemon.rs new file mode 100644 index 0000000..58506f9 --- /dev/null +++ b/src/daemon.rs @@ -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, + /// 音频格式(可选,默认 wav) + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + /// 风格描述(可选,宏观场景风格) + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, +} + +/// 服务端响应结构 +#[derive(Debug, Serialize)] +pub struct DaemonResponse { + /// 状态:ok 或 error + pub status: String, + /// 消息 + pub message: String, +} + +/// 获取配置目录(所有平台统一使用 ~/.config/tts/) +fn get_config_dir() -> Result { + 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 { + Ok(get_config_dir()?.join("ttsd.pid")) +} + +/// 获取日志文件路径 +fn get_log_file_path() -> Result { + Ok(get_config_dir()?.join("ttsd.log")) +} + +/// 获取 socket 文件路径(预留,未来可选 Unix Socket) +#[allow(dead_code)] +fn get_socket_path() -> Result { + 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 = 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::(&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 { + 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 { + // 按官方文档,使用 标签 + final_text = format!("{}", 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 " + )); + } + + // 创建 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(()) +} diff --git a/src/main.rs b/src/main.rs index d5489cc..87aca20 100644 --- a/src/main.rs +++ b/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 => { // 检查参数组合 diff --git a/taolun.md b/taolun.md index 8c1cbcc..0b7892f 100644 --- a/taolun.md +++ b/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 参数 + 文本内标签同时生效 + +### 第十一轮实施完成(继续实施 - 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 参数按官方文档转换为 `` 标签 + ## 2026-04-24 - 第十轮修改