xiaozhi-esp32通过 MCP 接入自建音乐服务技术方案
一、需求分析
1.1 目标场景
用户通过语音与小智设备交互,说出"播放周杰伦的晴天",设备能够:
- 识别用户意图,在自建音乐服务中搜索并定位歌曲
- 拉取音频流,通过扬声器播放
- 支持语音或按键打断、暂停、切歌等控制操作
1.2 用户交互流程
sequenceDiagram
actor 用户
participant 设备 as 小智设备
participant AI as 服务端 AI
participant Music as 自建音乐服务
用户->>设备: "小智小智,播放周杰伦的晴天"
设备->>AI: 上传语音(Opus 流)
AI->>Music: MCP 调用 music.search("晴天 周杰伦")
Music-->>AI: 返回歌曲列表
AI->>Music: MCP 调用 music.get_url(song_id)
Music-->>AI: 返回 audio_url
AI->>设备: MCP 调用 self.music.play_url(url, title)
设备->>Music: HTTP 拉取音频流
设备->>用户: 🔊 播放音乐
AI->>设备: TTS 回复"正在为你播放晴天"
用户->>设备: "停止播放"
设备->>AI: 上传语音
AI->>设备: MCP 调用 self.music.stop
设备->>用户: 🔇 停止播放
1.3 功能需求清单
| 功能 | 优先级 | 说明 |
|---|---|---|
| 搜索歌曲 | P0 | 按关键词搜索,返回歌曲列表 |
| 播放指定歌曲 | P0 | 获取音频 URL,设备端拉流播放 |
| 停止播放 | P0 | 语音或按键打断 |
| 暂停 / 继续 | P1 | 暂停当前播放,保留进度 |
| 切换下一首 | P1 | 播放列表内切歌 |
| 显示歌曲信息 | P1 | 屏幕上显示歌名、歌手 |
| 服务地址可配置 | P1 | 通过 NVS 配置,不硬编码 |
1.4 约束与边界
- 设备端不存储音乐文件,全部通过 HTTP 流式拉取
- 音乐播放与语音对话互斥,播放中不自动触发唤醒词识别
- 自建音乐服务需与设备处于同一局域网,或通过公网 HTTPS 暴露
二、技术分析
2.1 整体架构
方案分为控制链路和音频链路两条独立链路:
graph TB
subgraph 控制链路
USER["用户语音"]
DEVICE_MCP["设备端 McpServer\nself.music.*"]
AI["服务端 AI"]
MUSIC_MCP["音乐服务 MCP Server\nmusic.*"]
end
subgraph 音频链路
HTTP["HTTP 音频流\nMP3 / AAC"]
PIPELINE["ESP-ADF Pipeline\nhttp_stream → decoder → i2s"]
SPK["🔊 扬声器"]
end
USER -->|"语音上传"| AI
AI -->|"MCP JSON-RPC"| MUSIC_MCP
MUSIC_MCP -->|"返回 audio_url"| AI
AI -->|"MCP JSON-RPC"| DEVICE_MCP
DEVICE_MCP -->|"触发 PlayUrl()"| PIPELINE
PIPELINE -->|"HTTP GET"| HTTP
HTTP -->|"音频数据"| PIPELINE
PIPELINE --> SPK
2.2 音乐服务端 MCP 接口设计
音乐服务需实现标准 MCP JSON-RPC Server,暴露以下工具:
classDiagram
class MusicMcpServer {
+music.search(query: string) SongList
+music.get_url(song_id: string) AudioUrl
+music.get_playlist() SongList
}
class SongList {
+songs: Song[]
}
class Song {
+song_id: string
+title: string
+artist: string
+duration: int
}
class AudioUrl {
+audio_url: string
+format: string
+duration: int
}
MusicMcpServer --> SongList
MusicMcpServer --> AudioUrl
SongList --> Song
music.get_url 返回值示例:
{
"audio_url": "http://your-music-server/stream/123.mp3",
"format": "mp3",
"duration": 269
}
音频格式选型:
| 格式 | 推荐度 | 说明 |
|---|---|---|
| MP3 | ⭐⭐⭐ | ESP32-S3 硬件解码,ESP-ADF 内置 decoder,兼容性最好 |
| AAC | ⭐⭐⭐ | 同等码率音质更好,同样有硬件加速 |
| Opus | ⭐⭐ | 可复用现有解码链路,但音乐场景音质较差,不推荐 |
2.3 设备端架构改动分析
graph LR
subgraph existing["现有模块(不改动)"]
AS["AudioService\n编解码 · 队列 · 任务"]
PROTO["Protocol\nWebSocket / MQTT"]
MCP_SERVER["McpServer\n工具注册与调度"]
end
subgraph new_modules["新增与改动"]
PLAY_URL["Application::PlayUrl()\n新增方法"]
STOP["Application::StopPlayback()\n新增方法"]
STATE["DeviceState\n新增 PlayingMusic 状态"]
BOARD_TOOL["Board::InitializeTools()\n注册 self.music.* 工具"]
ADF["ESP-ADF Pipeline\nhttp_stream → mp3_decoder → i2s"]
end
MCP_SERVER -->|"回调触发"| BOARD_TOOL
BOARD_TOOL -->|"Schedule()"| PLAY_URL
PLAY_URL --> STATE
PLAY_URL --> ADF
BOARD_TOOL -->|"Schedule()"| STOP
STOP --> STATE
改动范围总结:
| 文件 | 改动类型 | 说明 |
|---|---|---|
main/device_state.h | 新增枚举值 | 添加 kDeviceStatePlayingMusic |
main/device_state_machine.cc | 新增状态转换规则 | PlayingMusic 的合法转换 |
main/application.h/cc | 新增方法 | PlayUrl()、StopPlayback() |
main/boards/*/xxx_board.cc | 新增工具注册 | self.music.play_url、self.music.stop |
2.4 状态机扩展
在现有状态机基础上新增 PlayingMusic 状态:
stateDiagram-v2
[*] --> Idle
Idle --> Connecting : 用户触发对话
Idle --> PlayingMusic : MCP self.music.play_url
Connecting --> Listening : 连接成功
Connecting --> Idle : 连接失败
Listening --> Speaking : TTS 开始
Listening --> Idle : 超时 / 停止
Speaking --> Listening : TTS 结束
Speaking --> Idle : 用户打断
PlayingMusic --> Idle : 播放结束 / self.music.stop
PlayingMusic --> Connecting : 用户语音唤醒(按键触发)
note right of PlayingMusic
播放中屏蔽唤醒词自动触发
仅支持按键打断进入对话
end note
2.5 音频播放链路设计
基于 ESP-ADF Pipeline 实现 HTTP 流式拉取 + 实时解码播放:
graph LR
HTTP_SRC["音乐服务\nHTTP Server"]
subgraph "ESP-ADF Pipeline(新增)"
HTTP_STREAM["http_stream\n分块拉取"]
DECODER["mp3_decoder / aac_decoder\n实时解码"]
I2S_WRITER["i2s_stream_writer\n输出到扬声器"]
end
SPK["🔊 扬声器\nI2S DMA"]
HTTP_SRC -->|"HTTP GET\n音频流"| HTTP_STREAM
HTTP_STREAM --> DECODER
DECODER --> I2S_WRITER
I2S_WRITER --> SPK
关键参数:
| 参数 | 建议值 | 说明 |
|---|---|---|
| HTTP 缓冲区 | 4~8 KB | 避免 PSRAM 占用过大 |
| MP3 码率 | 128 Kbps | 约需 16 KB/s 带宽,WiFi 下充裕 |
| I2S 采样率 | 44100 Hz | 标准音乐采样率 |
| 播放任务优先级 | 高(同 AudioOutputTask) | 保证播放流畅不卡顿 |
ESP-ADF Pipeline 实现原理
ESP-ADF 是乐鑫官方音频开发框架,提供 Pipeline + Element 流水线抽象。每个 Element 是独立的音频处理单元,Pipeline 负责串联,数据在 Element 之间通过内部 ringbuffer 自动流转,无需手动管理缓冲。
三个 Element 的职责:
http_stream:从音乐服务 HTTP URL 分块拉取音频数据,内部维护环形缓冲区,平滑应对网络抖动,支持 HTTP Range 请求(可实现进度跳转)mp3_decoder/aac_decoder:ESP-ADF 内置解码器,ESP32-S3 上有硬件加速,将压缩的 MP3/AAC 帧解码为 PCM 原始音频数据i2s_stream_writer:将 PCM 数据通过 I2S DMA 写入音频 Codec 芯片(如 ES8311),驱动扬声器发声
与现有 AudioService 的 I2S 资源互斥:
项目现有的 AudioService 也走 I2S 输出,两者共享同一个 I2S 外设,必须串行使用:
- 收到
self.music.play_url调用时,先暂停AudioService的AudioOutputTask - 启动 ESP-ADF Pipeline 独占 I2S 进行音乐播放
- 播放结束或被打断后,调用
audio_pipeline_stop()+audio_pipeline_wait_for_stop()等待任务完全退出(必须等待,否则 I2S 资源未释放) - 调用
audio_pipeline_deinit()释放所有资源,恢复AudioOutputTask
播放控制生命周期:
| 操作 | API | 说明 |
|---|---|---|
| 开始播放 | audio_pipeline_run() | 启动所有 Element 任务 |
| 暂停 | audio_pipeline_pause() | 保留缓冲区状态,可续播 |
| 继续 | audio_pipeline_resume() | 从暂停点继续 |
| 停止 | audio_pipeline_stop() + audio_pipeline_wait_for_stop() | 停止并等待退出 |
| 释放 | audio_pipeline_deinit() | 销毁所有资源 |
事件监听:Pipeline 内置 Event Listener,可监听 AUDIO_ELEMENT_EVENT_FINISHED 事件,在歌曲自然播放结束时自动回调,触发切换下一首或回到 Idle 状态。
为什么音质优于 Opus 方案:
- MP3 128Kbps 频率响应覆盖 20Hz~20kHz,而 Opus 16kHz 单声道截止频率约 8kHz(针对语音优化)
- ESP32-S3 的 I2S 支持 44100Hz 采样率输出,与 CD 音质一致
mp3_decoder基于 libmad 实现,解码精度高,无明显失真
2.6 关键技术决策
| 问题 | 方案 | 原因 |
|---|---|---|
| I2S 资源冲突 | 播放音乐时暂停 Opus 编解码任务 | I2S 外设共享,不能同时写入 |
| 线程安全 | 通过 Application::Schedule() 投递到主循环 | 符合现有跨线程通信规范 |
| 服务地址配置 | 通过 NVS Settings 读取,不硬编码 | 方便后续更换服务器地址 |
| 打断机制 | 播放中屏蔽唤醒词,仅支持按键打断 | 避免音乐声触发误唤醒 |
| 快速验证路径 | 先用 Opus 格式复用现有解码链路 | 零改动验证 MCP 控制链路 |
三、代码实现
3.1 音乐服务端 MCP 接口(Python 示例)
# music_mcp_server.py
from mcp.server import Server
from mcp.server.stdio import stdio_server
import mcp.types as types
app = Server("music-service")
MUSIC_LIBRARY = {
"001": {"title": "晴天", "artist": "周杰伦", "file": "/music/qingtian.mp3"},
"002": {"title": "七里香", "artist": "周杰伦", "file": "/music/qilixiang.mp3"},
}
@app.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="music.search",
description="搜索歌曲",
inputSchema={
"type": "object",
"properties": {"query": {"type": "string", "description": "搜索关键词"}},
"required": ["query"],
},
),
types.Tool(
name="music.get_url",
description="获取歌曲音频流 URL",
inputSchema={
"type": "object",
"properties": {"song_id": {"type": "string"}},
"required": ["song_id"],
},
),
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
if name == "music.search":
query = arguments["query"].lower()
results = [
{"song_id": sid, **info}
for sid, info in MUSIC_LIBRARY.items()
if query in info["title"] or query in info["artist"]
]
return [types.TextContent(type="text", text=str(results))]
if name == "music.get_url":
song_id = arguments["song_id"]
song = MUSIC_LIBRARY.get(song_id)
if not song:
return [types.TextContent(type="text", text='{"error": "song not found"}')]
# 返回 HTTP 可访问的音频 URL
audio_url = f"http://your-music-server:8080/stream/{song_id}.mp3"
result = {"audio_url": audio_url, "format": "mp3", "title": song["title"]}
return [types.TextContent(type="text", text=str(result))]
if __name__ == "__main__":
import asyncio
asyncio.run(stdio_server(app))
3.2 设备端:新增 PlayingMusic 状态
main/device_state.h — 新增枚举值:
enum DeviceState {
kDeviceStateUnknown,
kDeviceStateStarting,
kDeviceStateWifiConfiguring,
kDeviceStateActivating,
kDeviceStateIdle,
kDeviceStateConnecting,
kDeviceStateListening,
kDeviceStateSpeaking,
kDeviceStateUpgrading,
kDeviceStateAudioTesting,
kDeviceStatePlayingMusic, // 新增:音乐播放中
kDeviceStateFatalError,
};
main/device_state_machine.cc — 新增合法状态转换:
// 在 IsValidTransition() 中添加
case kDeviceStatePlayingMusic:
return next == kDeviceStateIdle ||
next == kDeviceStateConnecting; // 按键触发对话时切换
// Idle 可以转换到 PlayingMusic
case kDeviceStateIdle:
return next == kDeviceStateConnecting ||
next == kDeviceStatePlayingMusic || // 新增
next == kDeviceStateWifiConfiguring ||
next == kDeviceStateUpgrading ||
next == kDeviceStateActivating;
3.3 设备端:Application 层新增播放方法
main/application.h — 声明新方法和成员:
// public 方法
void PlayUrl(const std::string& url, const std::string& title = "");
void StopPlayback();
// private 成员
audio_pipeline_handle_t music_pipeline_ = nullptr;
main/application.cc — 实现播放逻辑:
void Application::PlayUrl(const std::string& url, const std::string& title) {
// 如果已有播放中的 pipeline,先停止
StopPlayback();
SetDeviceState(kDeviceStatePlayingMusic);
auto display = Board::GetInstance().GetDisplay();
if (display) {
display->SetChatMessage("assistant", title.empty() ? "正在播放音乐" : title);
}
// 构建 ESP-ADF Pipeline:http_stream → mp3_decoder → i2s_stream
http_stream_cfg_t http_cfg = HTTP_STREAM_CFG_DEFAULT();
http_cfg.url = url.c_str();
audio_element_handle_t http_stream = http_stream_init(&http_cfg);
mp3_decoder_cfg_t mp3_cfg = DEFAULT_MP3_DECODER_CONFIG();
audio_element_handle_t mp3_decoder = mp3_decoder_init(&mp3_cfg);
i2s_stream_cfg_t i2s_cfg = I2S_STREAM_CFG_DEFAULT();
i2s_cfg.type = AUDIO_STREAM_WRITER;
audio_element_handle_t i2s_writer = i2s_stream_init(&i2s_cfg);
audio_pipeline_cfg_t pipeline_cfg = DEFAULT_AUDIO_PIPELINE_CONFIG();
music_pipeline_ = audio_pipeline_init(&pipeline_cfg);
audio_pipeline_register(music_pipeline_, http_stream, "http");
audio_pipeline_register(music_pipeline_, mp3_decoder, "mp3");
audio_pipeline_register(music_pipeline_, i2s_writer, "i2s");
const char* link_tags[] = {"http", "mp3", "i2s"};
audio_pipeline_link(music_pipeline_, link_tags, 3);
audio_pipeline_run(music_pipeline_);
}
void Application::StopPlayback() {
if (music_pipeline_ == nullptr) {
return;
}
audio_pipeline_stop(music_pipeline_);
audio_pipeline_wait_for_stop(music_pipeline_);
audio_pipeline_deinit(music_pipeline_);
music_pipeline_ = nullptr;
SetDeviceState(kDeviceStateIdle);
}
3.4 设备端:板级 MCP 工具注册
在板子实现文件(如 main/boards/kevin-sp-v4-dev/kevin-sp-v4_board.cc)的 InitializeTools() 中注册:
void InitializeTools() {
auto& mcp = McpServer::GetInstance();
// 播放指定 URL 的音频流
mcp.AddTool(
"self.music.play_url",
"播放指定 URL 的音频流",
PropertyList({
Property("url", kPropertyTypeString, "音频流 HTTP URL"),
Property("title", kPropertyTypeString, "歌曲名称", "", false),
}),
[this](const PropertyList& props) -> ReturnValue {
auto url = props["url"].value<std::string>();
auto title = props["title"].value<std::string>();
// 投递到主循环执行,保证线程安全
Application::GetInstance().Schedule([url, title]() {
Application::GetInstance().PlayUrl(url, title);
});
return true;
}
);
// 停止音乐播放
mcp.AddTool(
"self.music.stop",
"停止当前音乐播放",
PropertyList({}),
[this](const PropertyList& props) -> ReturnValue {
Application::GetInstance().Schedule([]() {
Application::GetInstance().StopPlayback();
});
return true;
}
);
}
3.5 快速验证方案(复用 Opus 链路)
如果想跳过 ESP-ADF 快速验证 MCP 控制链路,可让音乐服务返回 Opus 格式音频,直接复用现有解码队列:
// 最简实现:HTTP 拉取 Opus 数据,推入现有解码队列
mcp.AddTool(
"self.music.play_url",
"播放 Opus 格式音频(快速验证用)",
PropertyList({Property("url", kPropertyTypeString, "Opus 音频 URL")}),
[](const PropertyList& props) -> ReturnValue {
auto url = props["url"].value<std::string>();
Application::GetInstance().Schedule([url]() {
// 通过 esp_http_client 拉取 Opus 数据帧
// 直接 push 到 AudioService 的 Decode Queue
AudioService::GetInstance().FetchAndEnqueueOpus(url);
});
return true;
}
);
注意:此方案音质较差(Opus 针对语音优化,非音乐),仅用于快速验证链路可行性。
四、相关文件索引
| 文件 | 改动说明 |
|---|---|
main/device_state.h | 新增 kDeviceStatePlayingMusic 枚举值 |
main/device_state_machine.cc | 新增 PlayingMusic 相关状态转换规则 |
main/application.h | 声明 PlayUrl()、StopPlayback() 及 music_pipeline_ 成员 |
main/application.cc | 实现 PlayUrl()、StopPlayback() |
main/boards/*/xxx_board.cc | 在 InitializeTools() 中注册 self.music.* 工具 |
music_mcp_server.py(新建) | 自建音乐服务的 MCP Server 实现 |
评论区