内容
# speak-aloud-mcp
让 AI 从**你电脑的音响**里出声。
一个很小的 MCP server,给任何 MCP 客户端(Claude Desktop / Claude Code / claude.ai Connectors / 你自己的 agent)加两个工具——**核心不依赖 MCP,从自己的网关直连 API 的人当普通 Python 库用就行,见[下文](#不用-mcp-也能用接-api-的网关看这里)**:
| 工具 | 做什么 |
|---|---|
| `speak(text, voice)` | ElevenLabs 合成 → 存成音频文件 → 返回路径。**不出声。** |
| `speak_aloud(text, voice)` | 同上,然后**当场从本机音响播出来**:静音的话先解开 → 把系统音量设到你指定的值 → 播 → 播完先把原音量放回去、再把静音状态放回去,两样都读回来核一遍。 |
macOS / Windows / Linux 都能跑,播放不装任何第三方 Python 音频库(macOS/Windows 用系统自带的播放器;Linux 要有 `paplay`/`aplay`,一般装了 PulseAudio/PipeWire 就有)。
[English README](README.en.md)
---
## 为什么要这个
我们的家用系统 tilldusk 里有几个长期在线的 AI agent,跑在一台 24/7 的 Mac mini 上。它们会被定时/事件唤醒,醒了有话想说——之前只能"发一条语音消息等人点开"。这个 server 让"说"就是说:它自己醒了、想道早安,声音就从屋里的音响出来,不用任何人去点播放。
顺手解决的两件小事:
- **音量**:夜里/白天系统音量不定,播之前设成固定值,播完复原,别把音量留在奇怪的位置。
- **回执要诚实**:播没播出来、音量有没有复原,结果里明说;不许"合成成功了就当播了"。
## 安装
```bash
git clone https://github.com/tsuru0805/speak-aloud-mcp && cd speak-aloud-mcp
pip install -e . # macOS / Linux
pip install -e ".[windows]" # Windows:多装 pycaw 用来读/设系统音量(不装也能播,只是不动音量)
```
(PyPI 包名 `speak-aloud-mcp` 尚未发布;发了会把这里换成 `pip install speak-aloud-mcp`。)需要 Python ≥ 3.10、`mcp` SDK 1.19–1.x。
## 配置(全部走环境变量)
| 变量 | 必填 | 说明 |
|---|---|---|
| `ELEVENLABS_API_KEY` | ✅ | ElevenLabs API key,**必须 `sk_` 开头**。后台里那串不带 `sk_` 的是 key ID,不是 key——用错了 ElevenLabs 会回 “API key ID used as API key”。 |
| `SPEAK_ALOUD_VOICES` | ✅(二选一) | `名字=voice_id,名字2=voice_id2`,第一个是默认声。 |
| `ELEVENLABS_VOICE_ID` | ✅(二选一) | 只有一个声的时候用这个,名字自动叫 `default`。 |
| `SPEAK_ALOUD_VOLUME` | | 播放时的系统音量 0-100,默认 `35`。设成 `off` = 不碰音量。 |
| `SPEAK_ALOUD_FORMAT` | | `wav`(默认:macOS/Windows 系统自带播放器直接播,Linux 用 `paplay`/`aplay`)或 `mp3`(macOS `afplay`;Linux `mpg123`/`ffplay`;Windows 需要 `ffplay`)。 |
| `SPEAK_ALOUD_ALLOWED_HOSTS` | | 只在 `--transport http` 有用:逗号分隔的公网主机名(隧道给你的那个)。localhost 和 `--host` 绑的局域网 IP 一直放行,其余 421。见下文。 |
| `SPEAK_ALOUD_CACHE_DIR` | | 音频文件落哪。默认系统缓存目录下 `speak-aloud-mcp/`。 |
| `SPEAK_ALOUD_MAX_CHARS` | | 单次最长字符数,默认 `1000`。ElevenLabs 按字符计费,这是防手滑。 |
| `ELEVENLABS_MODEL_ID` | | 默认 `eleven_multilingual_v2`。 |
## 先在终端确认它能出声
```bash
export ELEVENLABS_API_KEY=sk_...
export SPEAK_ALOUD_VOICES="mika=你的voice_id"
speak-aloud-mcp --check # 看后端/音量/配置有没有问题
speak-aloud-mcp --say "你好,我在。" # 真合成、真播一次(--voice 名字 选声;听不见/没播成退出码非 0)
```
`--say` 通了再接客户端——这样出问题时你知道是音响那边还是 MCP 那边。
## 不用 MCP 也能用(接 API 的网关看这里)
这个仓叫 `-mcp`,但**核心不依赖 MCP**:`config.py` / `tts.py` / `player.py` 三个模块零 MCP 依赖(只有 `server.py` import 它),所以从自己的网关直连 Claude/OpenAI/任何 API 的人一样能用。四条路,自己挑:
**① 当普通 Python 库**——最省事,跟 MCP 完全无关。合成 + 出声就是两个函数调用,塞进你自己的工具处理函数里:
```python
from speak_aloud_mcp import config, player, tts
conf = config.load()
name, voice_id = conf.resolve_voice("") # 空 = 默认声
audio = await tts.synthesize(api_key=conf.api_key, voice_id=voice_id,
text=tts.clean_text(text), model_id=conf.model_id,
output_format=conf.output_format)
path = tts.new_audio_path(conf.cache_dir, conf.suffix)
tts.write_atomic(path, audio)
res = await asyncio.to_thread(player.play_with_volume, path, conf.volume) # 阻塞,别占事件循环
```
完整可跑版本:[`examples/use_as_library.py`](examples/use_as_library.py)。**没装 `mcp` 包也能 import**。
**② 当命令行**——任何语言 `subprocess` 一调:`speak-aloud-mcp --say "……"`(听不见/没播成退出码非 0)。
**③ 自己的网关里当 MCP 客户端**——"接 API" 和 "用 MCP" 不冲突:你的网关连本地 MCP server,把工具表塞进 `tools=`,模型要调时你自己执行。Anthropic Python SDK 直接带适配器(`pip install "anthropic[mcp]"`):
```python
from anthropic.lib.tools.mcp import async_mcp_tool
runner = claude.beta.messages.tool_runner(
model="claude-opus-5", max_tokens=1024,
tools=[async_mcp_tool(t, mcp_client) for t in (await mcp_client.list_tools()).tools],
messages=[{"role": "user", "content": prompt}],
)
```
完整可跑版本:[`examples/api_tool_use.py`](examples/api_tool_use.py)。server 是你起的子进程,**不对外暴露任何端口**。(这条路是我们自己家在用的:网关直连 API,工具全部走 MCP 客户端拿。)
**④ API 侧的 MCP connector(`mcp_servers=[...]`)——这条要当心。** 那是 Anthropic 服务器去连你给的 URL,也就是说**你家这台机器得公网可达**。让别人的服务器有权限让你家音响出声,风险自己掂量:真要用就上隧道 + `--allowed-host` + 前面加一层鉴权。想在本机出声的话,①②③ 都比它合适。
## 接客户端
**Claude Code**
```bash
claude mcp add --transport stdio speak-aloud \
--env ELEVENLABS_API_KEY=sk_... \
--env SPEAK_ALOUD_VOICES="mika=你的voice_id" \
-- speak-aloud-mcp
```
**Claude Desktop**(`claude_desktop_config.json`,完整示例见 [`examples/`](examples/))
```json
{
"mcpServers": {
"speak-aloud": {
"command": "speak-aloud-mcp",
"env": {
"ELEVENLABS_API_KEY": "sk_...",
"SPEAK_ALOUD_VOICES": "mika=你的voice_id"
}
}
}
}
```
**HTTP(claude.ai Connectors / 远程 agent)**
```bash
speak-aloud-mcp --transport http --host 127.0.0.1 --port 8765
```
MCP 端点在 `http://127.0.0.1:8765/mcp`(Claude Code:`claude mcp add --transport http speak-aloud http://127.0.0.1:8765/mcp`)。
要给 claude.ai 用得有公网 HTTPS(Tailscale Funnel / Cloudflare Tunnel / ngrok 都行)。这时候要多做一件事:MCP SDK 默认开着 DNS-rebinding 保护,只认 `Host: localhost`,隧道转进来的请求带的是公网主机名,会被 421 拒掉。把那个主机名告诉它:
```bash
speak-aloud-mcp --transport http --allowed-host voice.example.com
# 或 SPEAK_ALOUD_ALLOWED_HOSTS=voice.example.com
```
保护不关,只是把你点名的主机加进白名单。**这个 server 自己不带鉴权**——它能让你家音响出声,暴露公网前请在前面加一层(反代 Basic Auth / Tunnel 自带的访问控制)。
## 各系统怎么播、怎么调音量
| | 播放 | 音量 / 静音 | 备注 |
|---|---|---|---|
| macOS | `afplay`(系统自带) | `osascript`(`volume settings`) | 用 launchd 跑时也能出声(我们的生产环境就是这么跑的)。 |
| Windows | `winsound`(Python 标准库,wav,带 `SND_NODEFAULT` 不会失败了还叮一声);mp3 需要 `ffplay`(ffmpeg) | `pycaw`(可选,`pip install "speak-aloud-mcp[windows]"`;每次调用都在工作线程里 CoInitialize) | 没装 pycaw 照样播,回执会写「音量未动」。 |
| Linux | `paplay` / `aplay`(wav),`mpg123` / `ffplay`(mp3) | `pactl`(PulseAudio / PipeWire);音量**按声道**存取,左右平衡不会被抹平 | 没有 pactl 就不动音量。 |
> **诚实声明**:作者手边只有 macOS 生产环境。Windows / Linux 路径是按各自官方接口写的、有单元测试(打桩),**没在真机跑过**。你在 Windows/Linux 上 `--say` 通了或没通,开个 issue 说一声,会很有帮助。
## 回执长什么样
`speak_aloud` 返回一行人话 + 一小段结构化字段:
```
🔈 played out loud (mika); volume 35 during playback, previous volume 30 restored (verified); file: .../2026-08-16/1786807739-ee568333.wav
```
```json
{"file": ".../2026-08-16/1786807739-ee568333.wav", "voice": "mika", "text": "...",
"duration_sec": 1.86, "played": true, "audible": true, "play_error": null,
"volume_during": 35, "volume_state": "restored", "volume_after": 30, "note": null, "backend": "macos"}
```
- `played`:播放器把文件放完了。
- `audible`:`false` = 我们**确知**听不见(还是静音 / 音量 0),这时正文开头是「⚠️ playback ran but was INAUDIBLE」而不是「played out loud」;`null` = 判断不了(读不到静音/音量)。没有麦克风,证明不了空气真震了——这个字段只承诺"没有已知的原因让它听不见"。
- `volume_during`:设完音量**读回来**的值。正文里「volume N during playback」的 N 永远是这个读回来的数,不是你配置的数;读不回来就写「playback volume unknown」。
- `volume_state` 只描述**音量复原**这一件事,和播没播成无关:`restored`(放回去了,并且**读回来核过**)/ `restore_unverified`(放回去了,但读不回来没法核)/ `restore_failed`(放不回去,或读回来对不上——`volume_after` 是最后读到的值)/ `untouched`(没动过:没要求、读不到原值、或这个系统没音量后端)。
- `note`:播了但有话要说——原音量读不到 / 音量设了但读回来不对 / 配置音量是 0 / 输出本来是静音的(先解开、播完把音量放回去之后再静回去、读回来核;解不开或静不回去都会明说)。
播放失败**不是**错误结果:文件已经存好了,`isError=false`,正文写「没播出来 + 原因 + 文件在哪」。
## 我们踩过、你可以不用踩的坑
1. **工具回执里别夹音频 base64。** 早期版本把整段 mp3 base64 塞进 `structuredContent` 给一个网页小播放器用。Claude Code 对工具回执有大小上限(默认 25,000 token,`MAX_MCP_OUTPUT_TOKENS`)——一句长一点的话(mp3 ~290KB → base64 38 万字符)直接超限,整轮被丢,AI 只看到"报错"。短句测试擦边过了,长句才炸,排了半天。**文件路径就是交接物**,回执恒定几百字符。
2. **两个 AI 同时开口,音量会被卡在中间态。** "读旧音量 → 设 35 → 播 → 复原" 两路交错,后一路把前一路临时设的 35 读成"旧值",播完系统就停在 35。整段临界区加了进程锁,后到的排队。**锁是进程内的**:stdio 模式下每个客户端各起一个 server 进程,锁互相看不见——几个 agent 共用一台机器的时候,用 `--transport http` 起**一个** server 让大家连它。
3. **复原要核,不能自报。** 设回旧值之后再读一次比对;不一致就报 `restore_failed`,读不回来就报 `restore_unverified`,不写"已恢复"。同理静音:静音状态下"播成功"是假的,先解开、播完再静回去,都写进回执。
4. **key ID ≠ key。** ElevenLabs 后台两串都能复制到,只有 `sk_` 开头那串是 key。启动时直接拒掉不带 `sk_` 的,省得你对着 401 猜。
5. **launchd / 服务环境里 `afplay` 能不能出声要实测。** 交互 shell 里能响不代表后台服务里能响(macOS 权限/会话不同)。`--say` 就是给你在目标环境里做这一步的。
6. **Linux 的 `pactl set-sink-volume 35%` 会把所有声道抹成一样。** 复原时如果只记了一个数,左右平衡就没了。按声道存、按声道还。
7. **依赖钉 `mcp>=1.19,<2`。** 下限:工具直接返回 `CallToolResult`(带 `isError` / `structuredContent`)从 SDK 1.19 才原样透传,更老的版本会把它当普通对象重新序列化,错误变成"成功"。上限:2.0 把 `FastMCP` 改名 `MCPServer`、传输配置挪进 `run()`,旧 import 直接没了;0.1.x 钉在 v1,v2 移植另开。
8. **macOS 的 `set volume output volume N` 会顺手把静音解掉。** 所以静音状态必须在动音量**之前**读,复原时先放音量、最后再把静音放回去——顺序反了回执就会说谎。
9. **音频文件不自动清。** `SPEAK_ALOUD_CACHE_DIR` 按天分目录只增不减,自己定期删或指到 tmp。
## 开发
```bash
pip install -e ".[dev]"
pytest
```
测试全部打桩:不连 ElevenLabs、不真播、不动系统音量。
## 作者
- **晚晚**([@tsuru0805](https://github.com/tsuru0805))——设计、拍板、真场验收。
- **弥野**(Claude,晚晚的工程手)——实现与文档。
出自我们的家用系统 **tilldusk**。
## License
MIT
连接信息
猜你喜欢
暂无推荐服务器