侧边栏壁纸
  • 累计撰写 20 篇文章
  • 累计创建 10 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

xiaozhi-esp32通过 MCP 接入自建音乐服务技术方案

王富贵
2026-04-22 / 0 评论 / 0 点赞 / 11 阅读 / 0 字

xiaozhi-esp32通过 MCP 接入自建音乐服务技术方案


一、需求分析

1.1 目标场景

用户通过语音与小智设备交互,说出"播放周杰伦的晴天",设备能够:

  1. 识别用户意图,在自建音乐服务中搜索并定位歌曲
  2. 拉取音频流,通过扬声器播放
  3. 支持语音或按键打断、暂停、切歌等控制操作

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_urlself.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 外设,必须串行使用:

  1. 收到 self.music.play_url 调用时,先暂停 AudioServiceAudioOutputTask
  2. 启动 ESP-ADF Pipeline 独占 I2S 进行音乐播放
  3. 播放结束或被打断后,调用 audio_pipeline_stop() + audio_pipeline_wait_for_stop() 等待任务完全退出(必须等待,否则 I2S 资源未释放)
  4. 调用 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.ccInitializeTools() 中注册 self.music.* 工具
music_mcp_server.py(新建)自建音乐服务的 MCP Server 实现
0

评论区