feat: 添加守护进程(daemon)模式

- 实现 TCP Socket 守护进程,支持后台运行
- 添加 daemon 子命令:start/stop/status/logs
- 添加 send 子命令:发送文本到守护进程播放
- 添加日志级别自动检测(INFO/WARN/ERROR)
- 新日志格式:[时间戳] [级别] [PID] 消息
- 支持跨平台 ~/.config/tts/ 配置目录
This commit is contained in:
2026-04-25 05:50:28 +08:00
parent f81d1ab979
commit 19eb313bef
10 changed files with 1202 additions and 5 deletions

134
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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 时要考虑不同层次的需求,宏观参数和微观标签可以互相补充

View File

@@ -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` 独立模块(高内聚低耦合)

View File

@@ -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"

View File

@@ -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
View 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
View 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(())
}

View File

@@ -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
View File

@@ -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.rsTCP 服务器、请求处理、PID 管理、日志)
3. ✅ 创建 src/client.rsTCP 客户端、请求发送、响应处理)
4. ✅ 修改 src/cli.rsDaemon、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 - 第十轮修改