内容
# maggid
本地为 Claude Code 提供文本转语音功能,支持 [MCP](https://modelcontextprotocol.io/) 协议。
在 Apple Silicon 设备上通过 [MLX-audio](https://github.com/Blaizzy/mlx-audio) 运行 [Chatterbox Turbo](https://huggingface.co/ResembleAI/chatterbox-turbo)。
Claude 会大声朗读其通知和叙述,每个工作区有独特的语音。
一名 *maggid* 是流动的布道者 —— 与其说是规则的解释者,不如说是故事的讲述者。
这适合于一个只负责发言的服务器。
**要求:** 苹果 Silicon 设备上的 macOS,Python 3.12 或更高版本。
## 安装
```bash
cd ~/projects/maggid
uv sync
# 下载模型(约 3 GB)并写入默认配置:
uv run maggid init
```
使用 `uv sync` 而非 `uv pip install -e .`。前者会安装 `uv.lock` 中的版本,后者会重新解析 `pyproject.toml` 中较宽松的约束,可能导致使用不同的 `fastmcp` 版本。如果环境最终处于混合状态 - 旧软件包树因降级而以 `ImportError: cannot import name ... (unknown location)` 显示 - 请删除 `.venv` 并重新同步,而不是在其上安装。
### 共享守护进程(推荐)
一个进程为所有 Claude Code 会话服务。如果您同时打开多个会话,请运行此模式。
```bash
sed "s|__VENV__|$PWD/.venv|g" launchd/com.maggid.plist \
> ~/Library/LaunchAgents/com.maggid.plist
launchctl load ~/Library/LaunchAgents/com.maggid.plist
claude mcp add --transport http --scope user maggid http://127.0.0.1:8765/mcp
```
每个会话的服务器加载自己的模型副本并拥有自己的扬声器。两个代理同时讲话时会产生重叠的音频,无法解析。一个守护进程为您提供一个模型,一个预热和一个播放队列,因此一个工作区中的紧急 `permission` 消息优先于另一个工作区的 `narrate` 消息。
守护进程在加载时预热模型。您在登录时支付 20 秒的启动时间,而不是在第一条消息上。
### 每个会话的stdio(备用)
```bash
claude mcp add --transport stdio --scope user maggid -- \
/path/to/maggid/.venv/bin/maggid
```
更简单,没有生命周期可管理。服务器的生命周期与会话完全相同。但每个会话都会支付 3 GB 的费用和自己的预热时间,并且并发会话的音频会重叠。
使用 `claude mcp list` 验证任一模式。
## 工具
| 工具 | 目的 |
| ------------------------------------ | -------------------------------------------------- |
| `notify(message, channel?)` | 短任务完成警报 |
| `speak(text, ref_audio?, channel?)` | 较长的叙述,可选地使用克隆语音 |
| `interrupt()` | 停止播放并丢弃积压的消息 |
`ref_audio` 是克隆语音的 WAV 文件路径。**必须超过 5 秒**。它覆盖了工作区语音。`channel` 选择队列优先级;详见下文。
在排队叙述被新事件超越时使用 `interrupt`。它清除整个积压的消息,而不仅仅是最后一条语句。
## 通道
通道是共享队列的优先级类别,因此紧急语音会跳过已经等待的叙述。
| 通道 | 优先级 |
| ------------ | ----------- |
| `permission` | 1(最高) |
| `question` | 2 |
| `notify` | 10 |
| `narrate` | 15 |
没有指定通道的消息使用 `notify` 优先级。
所有内容都以 1.1 的速率播放,通过播放时重采样实现。每个通道的速率曾是 Kokoro 时代的一个想法,但从未实现:整个 1.0 到 1.3 的范围在典型通知上节省不到 0.5 秒。优先级和措辞比速度更好地承载了通道的含义。
在 `~/.config/maggid/channels.toml` 中覆盖优先级:
```toml
[narrate]
priority = 20
```
消息队列最多可容纳 32 条消息。超出此限制后,服务器会丢弃新音频,而不是将其排队到落后于即将播放的语音中。
## 每个工作区身份
当运行多个代理时,有用的问题不是“它说了什么”,而是“哪个代理说的”。
守护进程同时以两种方式回答这个问题。
**口头标签。** 每条消息都以工作区目录名称开头。
来自 `~/projects/spade` 的 `notify("Tests passed")` 被听到的是 *"spade. Tests passed."*。
设置 `prefix = false` 以关闭此功能。
**每个工作区有独特的语音。** 服务器读取客户端通过 MCP `roots` 广播的根目录,并为其分配一个语音池中的语音。
分配存储在 `~/.config/maggid/assignments.json` 中,以根路径为键,因此它们在守护进程重启后仍然存在。
如果您不想使用随机分配的语音,可以固定一个:
```toml
[voices]
spade = "bm_daniel"
```
**并发会话在一个根目录下获得名称,而不是编号。**
在一个目录上有多个终端是正常情况。`~/projects/cfd` 上的第二个会话被听到的是 *"cfd Bonnie"*,有自己的语音。
名称遵循语音的性别,第一个会话保持裸工作区名称。
固定的语音仅适用于第一个会话。
**为什么标签承担了重任。**
语音池中的九种语音来自对每个克隆候选人的评分,范围从 1 到 10,然后解决具有最大最小成对分离(扬声器嵌入余弦)的子集。
即使如此,最佳九个语音仍然有一对余弦距离为 0.86。
Chatterbox 将所有克隆内容拉向其自身字符,并将整个集合压缩到 0.74 到 0.90。
语音本身只能区分四个或五个工作区,而不是九个。
标签才是可扩展的。
超过九个工作区,语音池会确定性地循环,两项目共享一个语音。
只有标签才能区分它们。
## 语音
Chatterbox 没有预设语音。
它从参考剪辑中克隆,这提高了上限,但这意味着您需要提供剪辑。
参考剪辑**必须超过 5 秒**。
最容易的来源是使用 Kokoro 的预设合成剪辑。
这是一个一次性引导,因此其依赖项故意不作为项目依赖项。
Kokoro 需要英语音素栈,拉取 spaCy 和 torch,而 mlx-audio 在 0.4 中已将其删除。
将其安装到一个临时环境中:
```bash
uv pip install 'mlx-audio>=0.4.6' 'misaki[en]>=0.9' soundfile \
'en_core_web_sm@https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl'
```
```python
import mlx.core as mx
import numpy as np
import soundfile as sf
from mlx_audio.tts.utils import load_model
k = load_model("mlx-community/Kokoro-82M-bf16")
text = "A sentence long enough to run past the five second minimum, and one more to be safe."
chunks = [
r.audio for r in k.generate(text=text, voice="am_adam", speed=1.0, lang_code="a")
]
audio = mx.concatenate(chunks) if len(chunks) > 1 else chunks[0]
mx.eval(audio)
sf.write("voices/refs/am_adam.wav", np.asarray(audio), 24000)
```
服务器缓存每个剪辑的编码条件,因此克隆语句的成本约为 300 毫秒,而不是每次调用重新编码参考剪辑的 950 毫秒。
**关于克隆真实语音:** 参考剪辑是模仿能力,无论它来自哪里或用于什么目的。
您自己的语音和合成剪辑是无问题的。
第三方语音需要他们的同意。
此外,上游 Chatterbox 通过 `resemble-perth` 标记其输出,MLX 端口不这样做。
来自此服务器的音频不带有水印。
## 性能
在 M5 Max 上使用短语(“Tests passed.”)测量,三次温暖运行的最佳结果:
| 模型 | 延迟 | RTF | 峰值 RSS |
| ----------------- | ------- | ----- | -------- |
| Kokoro-82M | 37 毫秒 | 0.025 | 0.78 GB |
| Chatterbox 基础 | 660 毫秒 | 0.412 | 3.64 GB |
| Turbo fp16 | 330 毫秒 | 0.235 | 2.95 GB |
| **Turbo 8 位** | 190 毫秒 | 0.164 | 2.98 GB |
| Turbo 4 位 | 200 毫秒 | 0.158 | 2.98 GB |
8 位是默认设置。
它与 4 位速度和内存占用相同,因此量化不会带来更多收益。
RTF 为 0.164 表示合成运行速度约为播放速度的 6 倍,因此扬声器是瓶颈,而不是模型。
更多的代理不会使合成成为约束。
## 架构
```
N × Claude Code ──http──> 一个守护进程 ──> Chatterbox Turbo ──> 音频设备
│
全局优先级队列
```
| 模块 | 责任 |
| ------------- | ---------------------------------------------------- |
| `config.py` | TOML 文件:参考语音、通道优先级 |
| `identity.py` | 工作区根目录、语音分配、会话槽 |
| `synth.py` | 模型加载、条件编译、推理 |
| `playback.py` | 优先级队列、音频设备、重采样 |
| `server.py` | MCP 工具、生命周期、命令行 |
- **序列化合成。**
MLX 的 Metal 命令缓冲区如果两个线程同时评估,则会中止进程,因此所有推理都在一个专用线程上运行。
这不会带来任何成本,约为 190 毫秒每条语句。
- **持久音频流。**
一个 `sounddevice` 输出流在进程生命周期内保持打开状态。
调用 `afplay` 需要大约 1.0 秒的进程和 CoreAudio 启动时间,每次通知为 5 倍的合成时间,这笔费用每次通知都需要支付。
删除它也使 `interrupt` 几乎瞬间完成,约 50 毫秒。
- **播放速率。**
Chatterbox 忽略速度参数,因此服务器在设备写入前重采样,而不是再次运行推理。
## 开发
```bash
uv run --group dev pytest
uv run --group dev ruff check .
uv run --group dev ruff format .
```
测试涵盖配置文件加载、语音和槽分配、口头标签、队列排序和重采样。
它们都不加载模型或打开音频设备。
## 故障排除
**守护进程不可达。** 运行 `launchctl list | grep maggid`,然后读取 `/tmp/maggid.err`。
Claude Code 在其下一个工具调用时重新连接。只有正在进行的语句才会丢失。
**守护进程无音频。** 确认它是 LaunchAgent 而不是 LaunchDaemon。
只有 LaunchAgent 才能访问音频设备。
`sounddevice` 错误出现在 `/tmp/maggid.err` 中。
**“音频提示必须超过 5 秒”。** 您的 `ref_audio` 剪辑太短。
服务器在调用模型之前检查这一点。
**第一次调用很慢。** 模型正在加载,需要约 20 秒。
守护进程在启动时预热。stdio 模式在第一次调用时加载。
## 许可证
MIT