agent × 豆包:端到端语音实时交互

把豆包接进机器人------端到端方案

目录

  1. 原来的交互云是怎么工作的
  2. 为什么还要接一个豆包
  3. 豆包的混合编排是怎么回事
  4. 整体架构:接进来之后变成什么样
  5. [集成第一步:把火山 SDK 搬进来](#集成第一步:把火山 SDK 搬进来)
  6. 集成第二步:写封装层
  7. 集成第三步:改项目原有代码
  8. 集成第四步:加配置文件
  9. 整个启动流程串一遍
  10. 数据是怎么流的
  11. 一些设计上的选择

相关文档:接入端到端实时语音大模型

1. 原来的交互云是怎么工作的

在接入豆包之前,机器人只有一个语音交互通道,就是我们自研的交互云(OmniSDK)。它是怎么工作的呢?

1.1 音频采集管线

首先要搞清楚音频是怎么收进来的。打开 module.cpp,在 OnInitialize() 里有一段 LoadInputAudioHandler()

cpp 复制代码
// module.cpp --- LoadInputAudioHandler()
bool AgentModule::LoadInputAudioHandler() {
  input_audio_handler_ = std::make_shared<input::Audio>();
  input_audio_handler_->Init();
  return true;
}

这个 input::Audio 类干的事情就是打开 PortAudio 设备,从麦克风收 PCM 音频流。收到之后经过两层处理:

  1. AEC(回声消除) ------用的 third_party/aec/lib/libAEC3.a。机器人在播 TTS 的时候如果同时开着麦,会把"自己说的话"也录进去。AEC 就是用来消掉这部分回声的,让云端只听到用户的声音。

  2. VAD(语音活动检测) ------用的 third_party/evad/lib/libevad_pipeline.so。判断用户是在说话还是静默中。它会标记三种状态:开始说话(Bos)、持续说话(Continue)、说完(Eos)。

处理完的音频通过 AgentChannel(项目内部的一个发布订阅机制)发出来,Controller 订阅到之后推给 OmniSDK。

1.2 Controller 怎么把音频送给交互云

打开 controller.cpp,音频处理和推送的逻辑在这里:

cpp 复制代码
// controller.cpp --- InitAgentInternalChannel() 中订阅 VAD 音频
AgentChannel::GetInstance().SubscribeFlexible<std::shared_ptr<input::AudioData>>(
    input::kTopicAudioInputBaseName + input::kTopicVadData,
    "aima_agent_config",
    [this](const std::shared_ptr<input::AudioData>& vad_audio_data) {
        ProcessVadAudioData(vad_audio_data);   // ← 处理并推给 OmniSDK
    });
cpp 复制代码
// controller.cpp --- ProcessVadAudioData()
void Controller::ProcessVadAudioData(const std::shared_ptr<input::AudioData>& vad_audio_data) {
  // ... 省略参数校验 ...

  std::shared_ptr<omnissdk::AudioData> audio_data = std::make_shared<omnissdk::AudioData>();
  audio_data->data        = vad_audio_data->raw_data->data();    // PCM 原始数据
  audio_data->len         = size;
  audio_data->status      = vad_status;                          // Bos/Continue/Eos
  audio_data->sample_rate = 16000;
  audio_data->channels    = 1;
  audio_data->precision   = 16;
  audio_data->audio_type  = omnissdk::AudioType::kAudioTypeMicVAD;

  option_.omnis_core->ProcessAudioData(audio_data);   // ← 推给交互云
}

到这里,PCM 音频被 omnis_core_->ProcessAudioData() 通过 WebSocket 发到了我们云端。这就是上行的路。

1.3 交互云处理完怎么回来

交互云在云端做了两件事:ASR 把语音变成文字,NLU 把文字变成意图和动作。处理完的结果通过 OmniSDK 的回调返回来。RegisterOmnisCallback() 在 Controller 里注册了 8 个回调:

cpp 复制代码
// controller.cpp --- RegisterOmnisCallback()
bool Controller::RegisterOmnisCallback() {
  // 1. NLG 结果:云端生成了要播报的文本
  option_.omnis_core->RegisterNlgCallback(
      std::bind(&Controller::HandleNlgResult, this, std::placeholders::_1));

  // 2. 中断事件:用户打断了当前对话
  option_.omnis_core->RegisterInterruptEventCallback(
      std::bind(&Controller::HandleInterruptResult, this, std::placeholders::_1));

  // 3. 动作结果:NLU 解析出了用户意图(想跳舞、想变形...)
  option_.omnis_core->RegisterActionCallback(
      std::bind(&Controller::HandleActionCallback, this, std::placeholders::_1));

  // 4. 唤醒结果:检测到唤醒词("你好元仔")
  option_.omnis_core->RegisterWakeUpCallback(
      std::bind(&Controller::HandleWakeUpResult, this, std::placeholders::_1));

  // 5. ASR 结果:识别出的文本
  option_.omnis_core->RegisterAsrCallback(
      std::bind(&Controller::HandleAsrResult, this, std::placeholders::_1));

  // 6. 自定义事件:语言切换、设备绑定、配置同步等
  option_.omnis_core->RegisterCustomEventCallback(
      std::bind(&Controller::HandleCustomEvent, this, std::placeholders::_1));

  // 7. 错误
  option_.omnis_core->RegisterErrorCallback(
      std::bind(&Controller::HandleError, this, std::placeholders::_1));

  // 8. VAD 状态
  option_.omnis_core->RegisterVadCallback([this](const omnissdk::VadStatusInfo& info) {
      CoreLetMe();
      // 用户开始说话时上传用户信息
  });

  return true;
}

这 8 个回调就是交互云跟机器人之间的全部通信方式。其中最重要的两个是 HandleActionCallbackHandleNlgResult

1.4 有了动作结果之后怎么执行

假设用户说"跳个舞":

复制代码
音频 → OmniSDK → 云端 ASR → NLU → 返回 ActionResult
  {type: "action", value: "dance"}

HandleActionCallback 收到 ActionResult 后,投到线程池异步执行,最终走到 DispatchSkillEvent

cpp 复制代码
// controller.cpp --- HandleActionCallback → DispatchSkillEvent
void Controller::HandleActionCallback(const omnissdk::ActionResult& result) {
  CoreLetMe();
  aimrte::ctx::exe(option_.omnis_sdk_callback_thread_pool).Post([this, result]() {
    DispatchSkillEvent(result);
  });
}
cpp 复制代码
// controller.cpp --- DispatchSkillEvent(精简)
bool Controller::DispatchSkillEvent(const omnissdk::ActionResult& result) {
  // 把 ActionResult 转成 SkillManager 认识的 nlu_event JSON
  nlohmann::json nlu_event;
  nlu_event["type"]   = result.type;    // "action"
  nlu_event["value"]  = result.value;   // "dance"
  nlu_event["params"] = params;

  // 调 SkillManager 执行
  auto taskid = capability::skill::SkillManager::GetInstance().ExecuteSkillAsync(nlu_event);
  return true;
}

SkillManager 会根据 nlu_event 去匹配 skills.json 里对应的技能,然后调对应的 Plugin------动作走 MotionActionPlugin,表情走 EmoticonActionPlugin,移动走 MoveActionPlugin,最终通过 AimRT RPC 打到 MC(运动控制)模块让机器人动起来。

1.5 交互云的问题在哪

这套流程在动作控制上很稳------NLU 是白盒,配错了调配置就行,确认流程也是成熟的。但日常对话体验不太好:

  • :ASR → NLU → NLG → TTS,每个环节都要进出云端,来回加起来 3-5 秒
  • :回复是模板化的,NLG 选的模板,TTS 念出来,没有 LLM 那种自然感
  • :没法闲聊,也没法联网查东西。你问它"今天天气怎么样",它只能回"我不太明白"

这些问题不是配置层面的,是架构层面的------级联管线的先天缺陷。


2. 为什么还要接一个豆包

豆包(Doubao)是火山引擎的端到端实时语音大模型。跟交互云的级联管线不一样,它是一个模型直接处理语音到语音

用户说话 → S2S 模型 → 直接出 TTS 音频回来

没有中间的 ASR、NLU、NLG 各个环节,所以延迟能做到 1-2 秒,回复是 LLM 实时生成的,自然得多。而且它底层是方舟大模型,可以做 Function Call 调用工具,也能联网搜索。

但豆包也有问题。它的 Function Call 准确率不如交互云的 NLU------毕竟是靠 Prompt 驱动的,不是靠规则+专用模型。而且出问题了你也调不了,是个黑盒。

所以结论就很简单了:两个都要。聊天用豆包(自然),动作用交互云(可靠)。


3. 豆包的混合编排是怎么回事

火山那边支持一种叫"混合编排"的模式。端侧只需要维护一条 WebRTC 连接,但云端内部有两个模型在同时工作:

复制代码
用户语音进入云端
  │
  ├── S2S 端到端模型(doubao-s2s-v1)
  │     负责:日常聊天
  │     输出:TTS 音频
  │     如果方舟 LLM 没有 FC 输出 → 采纳这个
  │
  └── 方舟大模型(ArkV3)
        负责:Function Call 决策
        输出:tool_calls JSON
        如果它输出了 FC → 采纳方舟的,压制 S2S

配置上就靠 OutputMode: 1 来启用:

json 复制代码
"S2SConfig": {
    "OutputMode": 1
}

这里有个很重要的点------端侧不需要感知这个切换。S2S 和方舟 LLM 都走同一条 WebRTC 连接,云端自己决定采纳谁的输出。端侧收的就两种东西:要么是 TTS 音频(来自 S2S),要么是 binary message 里的 FC JSON(来自方舟 LLM)。


4. 整体架构:接进来之后变成什么样

集成豆包之后,机器人有了两条语音通路:

复制代码
                     用户说话
                        │
            麦克风 → 音频采集 (PortAudio)
                        │
            ┌───────────┴───────────┐
            ▼                       ▼
     ┌──────────────┐      ┌──────────────────┐
     │  OmniSDK     │      │  ByteRTC SDK     │
     │  (WebSocket) │      │  (WebRTC)        │
     │       │      │      │       │          │
     │  我们的交互云   │      │  火山豆包云       │
     │       │      │      │       │          │
     │  NLU 结果    │      │  TTS 音频 / FC    │
     │       │      │      │       │          │
     │  Handle     │      │  onRoomBinary    │
     │  Action      │      │  MessageReceived │
     │  Callback    │      │       │          │
     │       │      │      │  m_skillCallback │
     │       ▼      │      │       │          │
     │  Dispatch   │      │       ▼          │
     │  SkillEvent  │      │  SkillManager::  │
     │       │      │      │  ExecuteSkill    │
     │       │      │      │  Async           │
     └───────┼──────┘      └───────┼──────────┘
             └──────────┬───────────┘
                        ▼
              SkillManager::ExecuteSkillAsync()
                        │
                        ▼
                  机器人执行动作

要实现上面这个架构,需要分四步来集成:

  1. 把火山 SDK(ByteRTC)的二进制文件搬进项目
  2. 写封装层,把 SDK 包成我们好用的接口
  3. 改项目原有代码(module.cpp、controller.cpp、tts_action_impl.cpp),把豆包"插"进去
  4. 加配置文件(doubao.json、start_voice_chat_template.json)

5. 集成第一步:把火山 SDK 搬进来

火山官方提供的是编译好的 ARM64 SDK,放在 third_party/doubao/VolcEngineRTC_arm/

复制代码
VolcEngineRTC_arm/
├── include/
│   ├── bytertc_engine.h                  ← 引擎主入口
│   ├── bytertc_room.h                    ← 房间管理
│   ├── bytertc_engine_event_handler.h    ← 引擎级回调(50+ 虚函数)
│   ├── bytertc_room_event_handler.h      ← 房间级回调(20+ 虚函数)
│   ├── bytertc_engine_ex.h               ← 引擎扩展接口
│   ├── bytertc_room_ex.h                 ← 房间扩展接口
│   ├── bytertc_game_room.h               ← 游戏房间(用不到)
│   ├── bytertc_rts_room.h                ← RTS 房间(用不到)
│   ├── byterts.h / byterts_room.h        ← 实时信令(用不到)
│   └── rtc/                              ← 详细类型定义
│       ├── bytertc_defines.h             ← 核心枚举和常量
│       ├── bytertc_audio_defines.h       ← 音频相关(采样率、声道、编码)
│       ├── bytertc_video_defines.h       ← 视频相关(分辨率、帧率、格式)
│       ├── bytertc_audio_frame.h         ← 音频帧结构体
│       ├── bytertc_video_frame.h         ← 视频帧结构体
│       ├── bytertc_audio_device_manager.h ← 音频设备管理
│       ├── bytertc_video_device_manager.h ← 视频设备管理
│       ├── bytertc_media_defines.h       ← 媒体流定义(快 8000 行)
│       ├── bytertc_transcoder_define.h   ← 转码定义
│       ├── bytertc_ktv_defines.h         ← KTV 相关(用不到)
│       ├── bytertc_spatial_audio_interface.h ← 空间音频
│       ├── bytertc_range_audio_interface.h   ← 范围音频
│       └── ... 其他各种扩展接口
│
└── lib/
    ├── libVolcEngineRTC.so              ← 31M, 核心引擎
    ├── libRTCFFmpeg.so                  ← 2.7M, FFmpeg 编解码
    ├── libbytenn.so                     ← 29M, 神经网络推理
    ├── libbytertc_fdk-aac_extension.so  ← 0.9M, AAC 编码
    ├── libbytertc_ffmpeg_audio_extension.so ← 4.2M, FFmpeg 音频扩展
    ├── libbytertc_nico_extension.so     ← 0.7M, 降噪美声
    └── libbytertc_vp8codec_extension.so ← 15M, VP8 视频编码

整个 SDK 加起来 80 多 MB,全是二进制。看不到源码,只能看头文件里的接口声明。

CMakeLists.txt 里需要把这些库链进来:

cmake 复制代码
# third_party/doubao/CMakeLists.txt
set(DOUBAO_LIB_DIR ${CMAKE_CURRENT_SOURCE_DIR}/VolcEngineRTC_arm/lib)
set(DOUBAO_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/VolcEngineRTC_arm/include)

link_directories(${DOUBAO_LIB_DIR})
include_directories(${DOUBAO_INCLUDE_DIR})
include_directories(${DOUBAO_INCLUDE_DIR}/rtc)

6. 集成第二步:写封装层

火山 SDK 固然强大,但接口太底层了。让业务代码直接面对它,那 module.cpp 里光初始化代码就要几百行。所以我们在 third_party/doubao/src/ 下写了封装层。

6.1 RTC 引擎封装(rtc_engine_wrapper)

这是封装层的核心,RTCVideoEngineWrapper 继承了两个 SDK 回调接口,把复杂的东西全包在里面:

cpp 复制代码
// rtc_engine_wrapper.h(精简后核心结构)
namespace Doubao {

// 回调类型定义
using SkillCallback = std::function<void(const json &skill_json)>;          // FC 回调
using SubvCallback = std::function<void(bool is_asr, const string& uid, 
                                        const string& text)>;               // ASR/LLM 文字
using JpgEncoderCallback = std::function<bool(
    const uint8_t* data, int width, int height, int step,
    std::vector<uint8_t>& out_jpg)>;                                        // VLM 编码
using AudioPlayCallback = std::function<void(std::shared_ptr<AudioData>)>;   // 音频播放

class RTCVideoEngineWrapper : public bytertc::IRTCEngineEventHandler,
                              public bytertc::IRTCRoomEventHandler {
public:
    // ---- 生命周期 ----
    int init();                     // 创建引擎、初始化音视频
    int joinRoom();                 // 加入房间、启动智能体
    int destory();                  // 退出房间、释放资源

    // ---- 状态查询 ----
    bool isAgentEnabled() const;
    bool isAgentRunning() const;

    // ---- 回调注册(上层模块通过这些注入能力) ----
    void RegisterAudioCallback(const AudioPlayCallback&);     // 收 TTS 音频
    void RegisterSkillCallback(const SkillCallback&);         // 收 FC JSON
    void RegisterSubvCallback(const SubvCallback&);           // 收 ASR/LLM 文字
    void RegisterJpgEncoder(const JpgEncoderCallback& cb);    // 注入 JPEG 编码

    // ---- VLM 视觉 ----
    void SetLatestFrame(const uint8_t* data, int w, int h, int step);  // 缓存摄像头帧
    bool SendVlmRequest(const string& sys_prompt, const string& usr_prompt);

    // ---- 消息发送 ----
    void sendTTSMessageToAgent(const string &content);    // 发 TTS 文本到豆包

    // 引擎端回调(继承 SDK 接口的实现)
    void onConnectionStateChanged(ConnectionState state) override;
    void onRoomMessageReceived(const char *uid, const char *message) override;
    void onRoomBinaryMessageReceived(const char *uid, int size, 
                                     const uint8_t *message) override;
    // ... 50+ 个回调省略

private:
    bytertc::IRTCEngine *m_pVideoEngine = nullptr;   // SDK 引擎实例
    bytertc::IRTCRoom *m_pRtcRoom = nullptr;          // 当前房间

    SkillCallback m_skillCallback;                     // FC 回调
    SubvCallback m_subvCallback;                       // ASR 文字回调
    JpgEncoderCallback m_jpgEncoder;                   // JPEG 编码器
    std::mutex m_skillCallbackMutex;

    // VLM 配置和帧缓存
    string m_vlmUrl, m_vlmModel, m_vlmAuthToken;
    vector<uint8_t> m_latestFrameData;                 // 最新摄像头帧
    int m_latestFrameWidth, m_latestFrameHeight;
    std::mutex m_latestFrameMutex;

    // 音频相关
    std::atomic<bool> m_enableAudioObserver{false};
    std::unique_ptr<AudioFrameObserver> m_audioObserver;
};

} // namespace Doubao

来看看 init()joinRoom() 怎么实现的:

init() 的核心就是调 IRTCEngine::createRTCEngine() 创建引擎,然后配上音频参数:

cpp 复制代码
// rtc_engine_wrapper.cc --- init() 核心逻辑
int RTCVideoEngineWrapper::init() {
    auto appDataIns = AppDataManager::instance()->getAppData();

    // 创建引擎
    bytertc::EngineConfig config;
    config.app_id = appDataIns->app_id.c_str();

    m_pVideoEngine = bytertc::IRTCEngine::createRTCEngine(config, this);
    if (m_pVideoEngine == nullptr) {
        return -1;
    }

    // 初始化视频设备
    initVideoDevice();

    // 初始化音频配置
    auto nRet = initAudioConfig();
    if (nRet) return nRet;

    // 初始化视频配置
    nRet = initVideoConfig();
    if (nRet) return nRet;

    return 0;
}

joinRoom() 关键步骤:创建房间、生成 Token、加入房间、拉起智能体:

cpp 复制代码
// rtc_engine_wrapper.cc --- joinRoom()
int RTCVideoEngineWrapper::joinRoom() {
    auto appDataIns = AppDataManager::instance()->getAppData();

    // 创建房间
    m_pRtcRoom = m_pVideoEngine->createRTCRoom(appDataIns->room_id.c_str());
    m_pRtcRoom->setRTCRoomEventHandler(this);

    // 配置用户信息
    bytertc::UserInfo user;
    user.uid = appDataIns->user_id.c_str();

    bytertc::RTCRoomConfig roomConfig;
    roomConfig.is_auto_subscribe_audio = true;
    roomConfig.is_auto_subscribe_video = false;

    // 生成 Token(AppId + AppKey + RoomId + UserId → HMAC)
    std::string token = TokenGenerator::generate(
        appDataIns->app_id, appDataIns->app_key,
        appDataIns->room_id, appDataIns->user_id);

    // 加入房间
    int nRet = m_pRtcRoom->joinRoom(token.c_str(), user, true, roomConfig);
    if (nRet != 0) return nRet;

    // 发布音频流
    m_pRtcRoom->publishStreamAudio(true);

    // 启动智能体(内部调 StartVoiceChat HTTP API)
    if (m_enableAgent) {
        StartAgentAsync();
    }

    return 0;
}

注意一个细节:StartAgentAsync() 不是直接调,而是异步的,带了重试机制。因为智能体的 HTTP API 可能比 WebRTC 房间建得慢:

cpp 复制代码
// rtc_engine_wrapper.cc --- StartAgentAsync()
void RTCVideoEngineWrapper::StartAgentAsync() {
    constexpr int kMaxRetries = 3;
    std::thread([this]() {
        for (int i = 0; i < kMaxRetries; ++i) {
            bool ok = DoubaoAgentManager::instance()->startAgent(
                m_appData->app_id, m_appData->room_id, m_localUserId);
            if (ok) {
                m_isAgentRunning = true;
                LOG_INFO("agent started (retry " << i << ")");
                return;
            }
            std::this_thread::sleep_for(std::chrono::seconds(1));
        }
        LOG_ERROR("agent failed after " << kMaxRetries << " retries");
    }).detach();
}

6.2 配置管理器(app_data_manager)

AppDataManager 是个单例,唯一的任务是读 JSON 配置文件,解析成结构体。代码不复杂,但很关键------所有模块都从它这儿拿配置。

解析逻辑贴在下面:

cpp 复制代码
// app_data_manager.cc --- parse() 核心部分
bool AppDataManager::parse(const std::string &json_str) {
    auto configJson = json11::Json::parse(json_str, err);

    m_appData = std::make_shared<StuAppData>();
    m_appData->app_id  = configJson["app_id"].string_value();
    m_appData->room_id = configJson["room_id"].string_value();
    m_appData->user_id = configJson["user_id"].string_value();
    m_appData->app_key = configJson["app_key"].string_value();
    m_appData->enable_video = configJson["enable_video"].bool_value();
    m_appData->enable_external_audio = configJson["enable_external_audio"].bool_value();
    // ... 省略中间字段 ...

    // 解析豆包智能体配置
    auto agentConfigJson = configJson["doubao_agent_config"];
    auto agentConfig = std::make_shared<StuDoubaoAgentConfig>();
    agentConfig->enable_doubao_agent = agentConfigJson["enable_doubao_agent"].bool_value();
    agentConfig->token = agentConfigJson["token"].string_value();
    agentConfig->access_id = agentConfigJson["access_id"].string_value();
    agentConfig->secret_access_key = agentConfigJson["secret_access_key"].string_value();
    agentConfig->endpoint = agentConfigJson["endpoint"].string_value();
    agentConfig->enable_s2s = agentConfigJson["enable_s2s"].bool_value();
    agentConfig->s2s_model = agentConfigJson["s2s_model"].string_value();
    agentConfig->s2s_voice_type = agentConfigJson["s2s_voice_type"].string_value();
    agentConfig->language = agentConfigJson["language"].string_value();
    agentConfig->sample_rate = agentConfigJson["sample_rate"].int_value();
    agentConfig->enable_vad = agentConfigJson["enable_vad"].bool_value();
    agentConfig->vad_silence_timeout = agentConfigJson["vad_silence_timeout"].int_value();
    agentConfig->start_request_temple_file = 
        agentConfigJson["start_request_temple_file"].string_value();
    m_appData->doubao_agent_config = agentConfig;

    // 解析 VLM 配置
    auto vlmJson = configJson["vlm"];
    auto vlmCfg = std::make_shared<StuVlmConfig>();
    vlmCfg->url = vlmJson["url"].string_value();
    vlmCfg->model = vlmJson["model"].string_value();
    vlmCfg->system_prompt = vlmJson["system_prompt"].string_value();
    vlmCfg->auth_token = vlmJson["auth_token"].string_value();
    vlmCfg->jpg_quality = vlmJson["jpg_quality"].int_value();
    vlmCfg->timeout_ms = vlmJson["timeout_ms"].int_value();
    m_appData->vlm_config = vlmCfg;

    // 解析音频观察者配置
    auto audioConfigJson = configJson["audio_observer_config"];
    auto audioConfig = std::make_shared<StuAudioObserverConfig>();
    audioConfig->enable_audio_observer = audioConfigJson["enable_audio_observer"].bool_value();
    audioConfig->enable_playback_audio = audioConfigJson["enable_playback_audio"].bool_value();
    audioConfig->enable_remote_user_audio = audioConfigJson["enable_remote_user_audio"].bool_value();
    audioConfig->enable_mixed_audio = audioConfigJson["enable_mixed_audio"].bool_value();
    audioConfig->preroll_ms = audioConfigJson["preroll_ms"].int_value();
    m_appData->audio_observer_config = audioConfig;

    // 校验:如果启用了智能体,必须有密钥
    if (m_appData->doubao_agent_config && 
        m_appData->doubao_agent_config->enable_doubao_agent) {
        if (m_appData->doubao_agent_config->token.empty() ||
            m_appData->doubao_agent_config->secret_access_key.empty()) {
            LOG_WARN("doubao agent enabled but credentials missing!");
            return false;
        }
    }
    return true;
}

// 从文件加载 JSON 字符串,再调 parse()
bool AppDataManager::load(const std::string &json_file) {
    auto data = bytertc::readFile(json_file.c_str(), "rt");
    // ... 提取 JSON 部分 ...
    return parse(str);
}

结构体定义(app_data_manager.h):

cpp 复制代码
struct StuDoubaoAgentConfig {
    bool enable_doubao_agent = false;
    std::string agent_user_id, task_id, app_id, token;
    std::string access_id, secret_access_key;
    std::string region = "cn-north-1";
    std::string endpoint = "https://rtc.volcengineapi.com";
    std::string start_request_temple_file;
    
    // S2S 端到端配置
    bool enable_s2s = true;
    std::string s2s_model = "doubao-s2s-v1";
    std::string s2s_voice_type = "BV700_streaming";
    std::string language = "zh-CN";
    int sample_rate = 16000;
    bool enable_vad = true;
    int vad_silence_timeout = 500;
};

struct StuVlmConfig {
    std::string url, model, system_prompt, auth_token;
    int jpg_quality = 80;
    int timeout_ms = 5000;
};

struct StuAudioObserverConfig {
    bool enable_audio_observer = false;
    bool save_to_file = false;
    std::string file_path;
    bool enable_record_audio = true;
    bool enable_playback_audio = true;       // ← 收豆包 TTS
    bool enable_remote_user_audio = true;
    bool enable_mixed_audio = true;
    int preroll_ms = 100;
};

struct StuAppData {
    std::string app_id, app_key, room_id, user_id;
    bool enable_video, enable_external_audio, enable_external_video;
    std::shared_ptr<StuVideoCaptureConfig> video_capture_config;
    std::shared_ptr<StuVideoEncoderConfig> video_encoder_config;
    std::shared_ptr<StuDoubaoAgentConfig> doubao_agent_config;
    std::shared_ptr<StuAudioObserverConfig> audio_observer_config;
    std::shared_ptr<StuVlmConfig> vlm_config;
};

6.3 智能体管理器(doubao_agent_manager)

WebRTC 只负责音视频传输。但豆包智能体的创建、配置人设、配置 FC 工具、销毁------这些是"控制面"的活,通过 HTTP API 完成。

cpp 复制代码
// doubao_agent_manager.cc --- startAgent() 核心
bool DoubaoAgentManager::startAgent(const string &app_id,
                                    const string &room_id,
                                    const string &target_user_id) {
    // 保存房间信息,后面 StopVoiceChat 要用
    m_roomId = room_id;
    m_rtcAppId = app_id;

    // 构造请求体:读模板 JSON + 填动态参数
    std::string requestBody = buildStartVoiceChatRequest(app_id, room_id, target_user_id);

    // 构造 URL
    std::string apiUrl = m_config->endpoint + 
                         "/?Action=StartVoiceChat&Version=2024-12-01";

    // 生成 V4 签名认证头
    auto authHeaders = generateAuthHeaders("POST", "/", requestBody);
    authHeaders["Accept"] = "application/json";
    authHeaders["Content-Type"] = "application/json";

    // 发送 HTTP POST
    auto response = HttpClient::post(apiUrl, requestBody, authHeaders);

    if (response.status_code != 200) {
        LOG_ERROR("StartVoiceChat failed: " << response.status_code);
        return false;
    }

    m_isRunning = true;
    return true;
}

buildStartVoiceChatRequest() 做拼接------读模板文件,把空字段填上:

cpp 复制代码
// doubao_agent_manager.cc --- buildStartVoiceChatRequest()
std::string DoubaoAgentManager::buildStartVoiceChatRequest(
        const string &app_id, const string &room_id, 
        const string &target_user_id) {
    
    // 读模板文件
    json request_temple_json;
    std::ifstream stream(m_config->start_request_temple_file);
    stream >> request_temple_json;

    // 填入动态值
    request_temple_json["AppId"] = app_id;
    request_temple_json["RoomId"] = room_id;
    request_temple_json["TaskId"] = m_config->task_id;

    // 填入 AgentConfig
    auto& agent = request_temple_json["AgentConfig"];
    agent["UserId"] = m_config->agent_user_id;
    agent["TargetUserId"].push_back(target_user_id);

    // 填入 S2S 鉴权信息
    auto& s2s = request_temple_json["Config"]["S2SConfig"];
    s2s["ProviderParams"]["app"]["appid"] = m_config->app_id;
    s2s["ProviderParams"]["app"]["token"] = m_config->token;

    return request_temple_json.dump();
}

V4 签名的实现(generateAuthHeaders),大致分这几步:

复制代码
1. 对请求体做 SHA256 哈希
2. 构造规范请求字符串(方法+URI+查询参数+头部+签名头+哈希)
3. 对规范请求做 SHA256
4. 构造待签名字符串(HMAC-SHA256 + 时间戳 + 凭据范围 + 哈希)
5. 用 SecretAccessKey 层层 HMAC 算出签名密钥链
   kSecret → kDate → kRegion → kService → kSigning
6. 用签名密钥对字符串签名
7. 组装 Authorization 头
cpp 复制代码
// generateAuthHeaders 的核心签名计算(简化)
std::map<string, string> DoubaoAgentManager::generateAuthHeaders(
        const string &method, const string &uri, 
        const string &body, const string &action) {

    // 时间戳
    auto now = std::time(nullptr);
    string timestamp = /* 格式化成 YYYYMMDDThhmmssZ */;
    string datestamp = timestamp.substr(0, 8);

    // 请求体 SHA256
    string payload_hash = sha256Hex(body);

    // 规范请求
    string canonical_request = method + "\n"
        + "/\n"
        + "Action=" + action + "&Version=2024-12-01\n"
        + "content-type:application/json\n"
        + "host:rtc.volcengineapi.com\n"
        + "x-content-sha256:" + payload_hash + "\n"
        + "x-date:" + timestamp + "\n\n"
        + "content-type;host;x-content-sha256;x-date\n"
        + payload_hash;

    // 规范请求的哈希
    string canonical_hash = sha256Hex(canonical_request);

    // 待签名字符串
    string credential_scope = datestamp + "/cn-beijing/rtc/request";
    string string_to_sign = "HMAC-SHA256\n" + timestamp + "\n" 
                          + credential_scope + "\n" + canonical_hash;

    // HMAC 密钥链:kSecret → kDate → kRegion → kService → kSigning
    auto kDate = hmacSHA256(m_config->secret_access_key, datestamp);
    auto kRegion = hmacSHA256(kDate, "cn-beijing");
    auto kService = hmacSHA256(kRegion, "rtc");
    auto kSigning = hmacSHA256(kService, "request");

    // 最终签名
    auto signature = hmacSHA256Hex(kSigning, string_to_sign);

    // 组装 Authorization 头
    string authorization = "HMAC-SHA256 Credential=" 
        + m_config->access_id + "/" + credential_scope 
        + ", SignedHeaders=content-type;host;x-content-sha256;x-date"
        + ", Signature=" + signature;

    map<string, string> headers;
    headers["Authorization"] = authorization;
    headers["X-Date"] = timestamp;
    headers["X-Content-Sha256"] = payload_hash;
    headers["Content-Type"] = "application/json";
    headers["Host"] = "rtc.volcengineapi.com";
    return headers;
}

6.4 音频帧观察者(audio_frame_observer)

豆包回复的时候,火山云端把合成好的 TTS 音频通过 WebRTC 一帧一帧发回来。AudioFrameObserver 负责接收,继承 SDK 的 IAudioFrameObserver 接口:

cpp 复制代码
// audio_frame_observer.h
namespace Doubao {
class AudioFrameObserver : public bytertc::IAudioFrameObserver {
public:
    struct AudioStats {
        uint64_t recordFrameCount = 0;
        uint64_t playbackFrameCount = 0;      // 豆包 TTS 走了多少帧
        uint64_t remoteUserFrameCount = 0;
        uint64_t mixedFrameCount = 0;
    };

    void RegisterAudioCallback(const AudioPlayCallback &cb);
    void onRecordAudioFrame(const bytertc::IAudioFrame *frame) override;
    void onPlaybackAudioFrame(const bytertc::IAudioFrame *frame) override;
    void onRemoteUserAudioFrame(const bytertc::IAudioFrame *frame) override;
    void onMixedAudioFrame(const bytertc::IAudioFrame *frame) override;

    AudioStats getStats() const { return m_stats; }

private:
    AudioPlayCallback m_audioCallback;   // ← 回调给 AgentModule 播放
    AudioStats m_stats;
};
}

6.5 其他小模块

  • TokenGenerator:根据 AppId、AppKey、RoomId、UserId 生成 WebRTC 房间的入房 Token。用了 Packer 做二进制序列化,utils.h 里有字符串和 hex 的转换工具。
  • http_client:一个轻量的 HTTP POST 封装,用于 VLM 请求和智能体管理 API。
  • util/thread_loop.h :定时循环的线程封装,RTC 引擎初始化时用来周期推送外部音频帧(虽然当前 enable_external_audio: false 所以没用到)。
  • util/json.hpp:nlohmann/json 单头文件库,25000 行。所有 JSON 操作都用它。json11 是另一个 JSON 库,被 app_data_manager 用(因为火山 SDK 的 demo 代码用 json11,我们沿用了)。

7. 集成第三步:改项目原有代码

有了封装层之后,接下来就是把豆包"插"进项目的启动流程里。涉及三个文件的改动。

7.1 module.cpp --- 启动时初始化豆包

AgentModule::OnStart() 里,所有原有启动步骤执行完之后,加入豆包初始化。完整的顺序是这样的:交互云和业务服务要先起来,麦克风要等豆包就绪再开------因为豆包 RTC SDK 内部自己会打开音频设备,如果麦开在豆包之前,可能会有设备占用冲突。

来看改动的代码:

cpp 复制代码
// module.cpp --- AgentModule::OnStart() 中新增的豆包部分
// 位置:info_input_impl_->Start() 之后,input_audio_handler_->OnStart() 之前

// ===== 步骤 1:加载 doubao.json 配置 =====
AIMRTE_WARN("AgentModule OnStart Start Doubao::AppDataManager");
std::string configJsonFilePath{"cfg/t1/doubao.json"};  // FIXME 先给定一个临时的路径
auto appDataIns = Doubao::AppDataManager::instance();
if (!appDataIns->load(configJsonFilePath)) {
    AIMRTE_ERROR("Failed to load appDataIns");
    return false;
}

// ===== 步骤 2:获取 RTC 实例,初始化引擎 =====
AIMRTE_WARN("AgentModule OnStart Start Doubao::RTCVideoEngineWrapper");
auto rtcWrapper = Doubao::RTCVideoEngineWrapper::instance();
auto nRet = rtcWrapper->init();
if (nRet) {
    AIMRTE_ERROR("Failed to init rtcWrapper");
    return false;
}

// ===== 步骤 3:加入 WebRTC 房间 =====
nRet = rtcWrapper->joinRoom();
if (nRet) {
    AIMRTE_ERROR("Failed to join room");
    return false;
}
// 此时 WebRTC 连接已建立,音频通道通了!

// ===== 步骤 4:注册 VLM JPEG 编码回调 =====
rtcWrapper->RegisterJpgEncoder(
    [](const uint8_t* data, int width, int height, int step,
       std::vector<uint8_t>& out_jpg) -> bool {
        if (!data || width <= 0 || height <= 0 || step <= 0) return false;
        cv::Mat frame(height, width, CV_8UC3, const_cast<uint8_t*>(data), step);
        std::vector<int> params = {cv::IMWRITE_JPEG_QUALITY, 80};
        return cv::imencode(".jpg", frame, out_jpg, params);
    });
AIMRTE_INFO("VLM JPG encoder registered to RTC wrapper");

// ===== 步骤 5:把 RTC 指针注入 Controller =====
controller_.SetRtcWrapper(rtcWrapper);
AIMRTE_INFO("VLM rtc wrapper injected into controller");

// ===== 步骤 6:注册音频接收回调(收豆包 TTS → 播放) =====
auto appData = appDataIns->getAppData();
if (appData->audio_observer_config && 
    appData->audio_observer_config->enable_audio_observer) {
    
    rtcWrapper->setAudioObserverConfig(
        appData->audio_observer_config->enable_record_audio,
        appData->audio_observer_config->enable_playback_audio,
        appData->audio_observer_config->enable_remote_user_audio,
        appData->audio_observer_config->enable_mixed_audio);

    if (rtcWrapper->enableAudioFrameObserver(true)) {
        // 注册音频回调------收到豆包 TTS 帧就播
        rtcWrapper->RegisterAudioCallback(
            [this, preroll_ms = appData->audio_observer_config->preroll_ms]
            (const std::shared_ptr<Doubao::AudioData>& data) {
            
            CoreLetMe();  // 收到豆包回复时亮屏

            // 预缓冲逻辑:刚开始收到音频时不立即播,
            // 先缓冲 preroll_ms 毫秒,防止开头卡顿或爆音
            // ... (约 100 行缓冲逻辑,此处省略)
            
            // 正常播放
            output::AudioPlayStream stream;
            stream.audio_data  = data->data;
            stream.data_len    = data->count;
            stream.sample_rate = data->sampleRate;
            stream.channels    = data->channels;
            stream.trace_id    = "test";
            output_audio_handler_->PlayAudioStream(stream);
        });
    }
}

// ===== 步骤 7:注册 FC 技能回调 =====
rtcWrapper->RegisterSkillCallback([this](const nlohmann::json& nlu_input) {
    CoreLetMe();
    capability::skill::SkillManager::GetInstance().ExecuteSkillAsync(nlu_input);
});

// ===== 步骤 8:启用 kPlayback 音频回调 =====
rtcWrapper->enablePlaybackAudioCallback(true);

// ===== 步骤 9:检查智能体是否正常启动 =====
if (rtcWrapper->isAgentEnabled()) {
    if (rtcWrapper->isAgentRunning()) {
        AIMRTE_INFO("豆包智能体已启动并运行中");
    } else {
        AIMRTE_ERROR("豆包智能体启用但未运行------请检查配置文件中的访问密钥");
        return false;
    }
} else {
    AIMRTE_ERROR("豆包智能体未启用------请检查 doubao_agent_config 配置");
    return false;
}

另外在 OnShutdown() 里加了清理:

cpp 复制代码
// module.cpp --- OnShutdown() 新增
Doubao::RTCVideoEngineWrapper::instance()->destory();
// → 退出 RTC 房间、调 StopVoiceChat 销毁智能体、释放引擎资源

7.2 controller.cpp/h --- 视频帧推给 RTC

Controller 里加了一个 rtc_wrapper_ 成员变量,和对应的 setter:

cpp 复制代码
// controller.h 新增
#include <atomic>
#include "rtc_engine_wrapper.h"  // 前向声明 Doubao::RTCVideoEngineWrapper

// 成员变量
std::atomic<Doubao::RTCVideoEngineWrapper*> rtc_wrapper_{nullptr};

// Setter
void Controller::SetRtcWrapper(Doubao::RTCVideoEngineWrapper* w) {
    rtc_wrapper_.store(w, std::memory_order_release);
    AIMRTE_INFO("Controller: rtc wrapper injected ({})", (w ? "ok" : "null"));
}

std::atomic 是因为 RTC 回调在 SDK 自己的线程里跑,Controller 的 HandleVideoData 在主线程跑,两个线程读写同一个指针,必须保证线程安全。

然后在 HandleVideoData() 末尾,每收到一帧摄像头图像,就推一份到 RTC Wrapper 缓存:

cpp 复制代码
// controller.cpp --- HandleVideoData() 末尾新增
auto* wrapper = rtc_wrapper_.load(std::memory_order_acquire);
if (wrapper && frame->isContinuous()) {
    wrapper->SetLatestFrame(frame->data, frame->cols, frame->rows,
                            static_cast<int>(frame->step));
    AIMRTE_DEBUG("VLM: pushed frame to wrapper, {}x{}", frame->cols, frame->rows);
}

这里推给 RTC 的帧不是给豆包实时看的,是缓存下来。后面用户说"你看到了什么"触发 VLM Function Call 时,端侧拿这帧去调 VLM 服务,所以说这是"为 VLM 预留的数据通道"。

7.3 tts_action_impl.cpp --- TTS 改成走豆包

这是集成豆包之后最大的行为变化。原来机器人的 TTS 播报是通过 ROS2 RPC 调本地 TTS 模块(play_tts_proxy_->Func.Call(...))。接入豆包后,TTS 交给豆包云端去做,文字发过去,云端合成语音,通过 WebRTC 把音频传回来,RegisterAudioCallback 里收到后自动播放。

原来的代码被注释保留(方便回退),新的逻辑是:

cpp 复制代码
// tts_action_impl.cpp --- WorkerLoop() 中的改动

// ===== 原来的方式(被注释掉) =====
// aimdk_msgs::srv::PlayTts_Response tts_rsp;
// aimrt::rpc::Context rpc_ctx;
// rpc_ctx.SetTimeout(std::chrono::seconds(4));
// auto res = play_tts_proxy_->Func.Call(rpc_ctx, task->req, tts_rsp).Sync();
// if (!res.OK()) {
//     ret = {false, ""};
// } else {
//     ret = {tts_rsp.tts_resp.is_success, tts_rsp.tts_resp.trace_id};
// }

// ===== 新的方式:发给豆包 =====
auto rtcWrapper = Doubao::RTCVideoEngineWrapper::instance();
rtcWrapper->sendTTSMessageToAgent(task->req.tts_req.text);

// 构造 TTS 状态,发布到 AgentChannel
auto tts_ptr = std::make_shared<TtsStatus>(TtsStatus{
    .text          = task->req.tts_req.text,
    .priority      = task->req.tts_req.priority_level.value,
    .trace_id      = task->req.tts_req.trace_id,
    .tts_status    = ActionStatus::kWaiting,
    .domain        = task->req.tts_req.domain,
    .error_message = ""});
AgentChannel::GetInstance().Publish(kTopicTtsStatus, tts_ptr);
ret = {true, task->req.tts_req.trace_id};

注意:这里还顺手把默认语言从 "en" 改成了 "zh",因为豆包目前只支持中文:

cpp 复制代码
// GetAndPlayNLGText 里的改动
- std::string language = "en";
+ std::string language = "zh";

8. 集成第四步:加配置文件

接入豆包之后,新增了 2 个关键配置文件,改了 1 个。这三个文件各管一摊,缺一不可。

先搞清楚每个文件在整体流程中的位置:

复制代码
开机流程                         对应的配置文件
────────                        ────────────
① 交互云先启动                     llm.config(原有)
② 业务服务                         skills.json(原有)
③ 读 doubao.json ─────────────→  doubao.json  ← 豆包连接、智能体、VLM 配置
④ 初始化 RTC                       ↑ 被 AppDataManager::load() 读了
⑤ 加入房间                          ↑ app_id/token 用于 RTC 鉴权
⑥ 注册回调                          ↑ audio_observer_config 决定收哪些音频
                                 ↑ vlm 决定 VLM 服务的地址
⑦ 启动智能体 ──────────────────→  start_voice_chat_template.json  ← 豆包的人设+FC工具
    HTTP POST StartVoiceChat        ↑ 被 DoubaoAgentManager 读了
    云端拿到模板                     ↑ 整个 JSON 填好参数发给火山
⑧ 开麦                             
                                 agent.yaml  ← 新增了两个 executor 线程
                                   ↑ 被 AimRT 框架在模块初始化时读了

下面逐个细说。

8.1 doubao.json --- 豆包的"连接说明书"

这个文件被 AppDataManager::load() 在开机时读取。什么时候读的?就是在 OnStart() 里,豆包初始化的第一步:

cpp 复制代码
// module.cpp --- 豆包初始化的第一行代码
std::string configJsonFilePath{"cfg/t1/doubao.json"};
auto appDataIns = Doubao::AppDataManager::instance();
appDataIns->load(configJsonFilePath);
// ← 读完这个 JSON,所有配置都在内存里了,后续的 init/joinRoom 都从里面取参数

整个文件可以切成六段来理解:

复制代码
doubao.json
│
├── [第一部分] RTC 基础连接参数
│      app_id, app_key      ← 火山控制台的应用凭证
│      room_id, user_id     ← 房间号和用户名
│      enable_audio         ← 是否启用音频
│      enable_external_audio ← false=让SDK自己采麦
│      audio_device_index: -1 ← 用系统默认麦克风
│
├── [第二部分] 视频参数(当前没用,备用)
│      enable_video: false   ← 不启用视频
│      video_capture_config  ← 分辨率、帧率
│      video_encoder_config  ← 编码参数
│
├── [第三部分] 豆包智能体配置 --- 核心
│      enable_doubao_agent: true  ← 总开关,false 的话豆包不启动
│      enable_s2s: true           ← 用端到端模型还是传统三段式
│      s2s_model: doubao-s2s-v1   ← S2S 模型版本
│      s2s_voice_type             ← 音色
│      language: zh-CN             ← 语言
│      sample_rate: 16000          ← 采样率
│      enable_vad: true            ← VAD 开关
│      vad_silence_timeout: 300   ← 说完话静默多久判定"说完了"
│      token, access_id, secret_access_key ← 鉴权(调 HTTP API 用)
│      endpoint: https://rtc.volcengineapi.com ← 火山 API 地址
│      start_request_temple_file → → → 指向下一个配置文件!
│
├── [第四部分] 音频接收配置
│      enable_audio_observer: true
│      enable_playback_audio: true   ← 收豆包的 TTS 播报
│      enable_remote_user_audio: true ← 收远端音频
│      enable_mixed_audio: true       ← 收混合音频
│      enable_record_audio: false    ← 不收自己的录音
│      preroll_ms: 100               ← 开播前预缓冲 100ms
│
├── [第五部分] VLM 视觉配置
│      url: http://10.3.192.9:8089/...   ← VLM 服务地址(内网)
│      model: service_vlm_dev_h20         ← 模型名称
│      system_prompt: "用简明扼要的中文..."  ← VLM 提示词
│      jpg_quality: 80                    ← JPEG 压缩质量
│      timeout_ms: 5000                   ← 请求超时
│
└── [第六部分] 设备索引
       video_device_index: 0     ← 用第一个摄像头

逐段说明:

第一部分:RTC 基础连接参数

这部分直接决定能不能连上火山引擎的 RTC 服务。app_idapp_key 是在火山控制台创建应用后拿到的,room_iduser_id 是自定义的------一个 room 就是一个"聊天室",机器人加入这个 room,智能体也加入这个 room,两边就能互相听到了。

enable_external_audio: false 是个关键的配置。false 的意思是"让 ByteRTC SDK 自己从 ALSA 打开麦克风设备采集音频",我们的代码不用 push 音频进去。如果设成 true,就需要我们自己调 pushExternalAudioFrame() 往引擎里喂音频数据。当前设为 false 是因为 ByteRTC SDK 内置了音频采集模块,直接用就行。

第二部分:视频参数

当前 enable_video: false,所以这部分暂时没用上。但是如果以后要启用视频通话或者推视频流给豆包做实时视觉理解,就要把这里打开。video_capture_config 定了采集分辨率 1280x720、30fps,video_encoder_config 定了编码后最大码率 3000kbps。

第三部分:豆包智能体配置------最重要的一段

这是整个 doubao.json 最核心的部分。

enable_doubao_agent: true 是总开关。module.cpp 里有一行检查:if (!rtcWrapper->isAgentEnabled()) { return false; }------如果这里是 false,豆包初始化就会失败,机器人就只用交互云。

enable_s2s: true 决定用端到端模型还是传统三段式。端到端模式下,一个 S2S 模型直接处理语音→语音,延迟低、回复自然。传统三段式就是 ASR→LLM→TTS 各走各的。当前的混合编排方案要求 enable_s2s: true

start_request_temple_file: "cfg/t1/start_voice_chat_template.json" 是指向下一个配置文件的路径。DoubaoAgentManager::buildStartVoiceChatRequest() 里会读这个文件,填上动态参数后发给火山创建智能体。

tokenaccess_idsecret_access_key 这三个是调用火山 HTTP API 的鉴权凭据。token 就是普通的 API Token,access_idsecret_access_key 用来做 V4 签名(就是前面 generateAuthHeaders() 里那套 HMAC-SHA256 签名链)。

endpoint: https://rtc.volcengineapi.com 是火山 API 的地址。DoubaoAgentManager 会往这个地址 POST / + ?Action=StartVoiceChat

第四部分:音频接收配置

决定端侧收豆包云端的哪些音频流。enable_playback_audio: true 是最关键的------收豆包的 TTS 播报音频,收回来之后通过 RegisterAudioCallback 注册的回调播放出去,用户才能听到豆包说话。

preroll_ms: 100 是一个小优化。WebRTC 刚开始收到音频时帧可能不完整,如果直接播会导致开头卡一下或者爆音。所以代码里做了一个三段状态机:

复制代码
Silent(静默)
  → 收到第一帧非零数据 → Buffering(缓冲中)
    → jitter_buf 攒够 preroll_ms 毫秒的数据
      → 一次性写入音频播放器 → Streaming(流式播放)
        → 逐帧播放

如果中间收到零帧(静默)→ 回到 Silent

这个状态机在 RegisterAudioCallback 的 lambda 里实现,大概 100 行,核心逻辑就是这样。

第五部分:VLM 视觉配置

VLM(Vision Language Model)视觉问答的配置。用户说"你看到了什么"时,豆包 LLM 输出 system_vlm 的 Function Call,端侧拿到后调用 SendVlmRequest(),发一个 HTTP POST 到这个 vlm.url

vlm.url 指向的是内网部署的 VLM 服务(10.3.192.9:8089),不是火山公有云的服务。vlm.model 指定模型名。vlm.jpg_quality: 80 是摄像头帧压缩成 JPEG 的质量参数------质量太高传输慢,太低 VLM 看不清,80 是个折中值。vlm.timeout_ms: 5000 是 VLM 请求的超时,5 秒。

vlm.system_prompt 是发给 VLM 的系统提示词:"用简明扼要的中文描述内容,口语化的描述,不要带任何格式..."

第六部分:设备索引

video_device_index: 0 指定用第 0 号摄像头。audio_device_index: -1 表示用系统默认音频设备。

8.2 start_voice_chat_template.json --- 豆包的"脑子"

这个文件是整个端到端方案最重要的配置文件------它决定了豆包是谁、会什么、什么情况干什么

什么时候用到的?在 joinRoom()StartAgentAsync()startAgent()buildStartVoiceChatRequest() 这个调用链里:

cpp 复制代码
// doubao_agent_manager.cc --- buildStartVoiceChatRequest()
std::string DoubaoAgentManager::buildStartVoiceChatRequest(...) {
    // 1. 从磁盘读这个模板文件
    std::string request_temple_file = m_config->start_request_temple_file;
    // → "cfg/t1/start_voice_chat_template.json"
    
    json request_temple_json;
    std::ifstream stream(request_temple_file);
    stream >> request_temple_json;     // ← 读完整个 JSON
    
    // 2. 填入动态值(模板里是空的,开机时动态填入)
    request_temple_json["AppId"] = app_id;
    request_temple_json["RoomId"] = room_id;
    request_temple_json["TaskId"] = m_config->task_id;
    
    // 3. 填入 AgentConfig
    auto& agent = request_temple_json["AgentConfig"];
    agent["UserId"] = m_config->agent_user_id;
    agent["TargetUserId"].push_back(target_user_id);
    
    // 4. 填入 S2S 鉴权
    auto& s2s = request_temple_json["Config"]["S2SConfig"];
    s2s["ProviderParams"]["app"]["appid"] = m_config->app_id;
    s2s["ProviderParams"]["app"]["token"] = m_config->token;
    
    // 5. 作为 HTTP Body POST 到火山
    return request_temple_json.dump();
}

文件的大结构:

复制代码
start_voice_chat_template.json
│
├── AppId, RoomId, TaskId      ← 空占位符,运行时会填入
│
├── Config
│   ├── LLMConfig               ← 【决策脑】给方舟大模型看的
│   │   ├── Mode: "ArkV3"       ← 用火山方舟
│   │   ├── EndPointId          ← 模型端点 ID
│   │   ├── SystemMessages      ← ★ System Prompt(FC 的工具选择全在这里面)
│   │   ├── ThinkingType: disabled
│   │   └── Tools[]             ← ★ FC 工具定义(JSON Schema)
│   │       ├── system_vlm       ← VLM 视觉问答
│   │       └── robot_play_music ← 音乐播放
│   │
│   └── S2SConfig               ← 【聊天脑】给 S2S 端到端模型看的
│       ├── OutputMode: 1        ← 混合编排
│       ├── Provider: volcano
│       └── ProviderParams
│           ├── app              ← 鉴权(运行时会填入)
│           └── dialog
│               ├── bot_name: 元仔
│               ├── speaking_style ← 说话风格
│               ├── system_role  ← ★ S2S 的人设 Prompt
│               ├── location     ← 上海浦东
│               └── extra        ← 联网搜索等附加功能
│                   ├── model: 1.2.1.0
│                   ├── enable_volc_websearch: true
│                   └── enable_music: true
│
└── AgentConfig
    ├── TargetUserId             ← 运行时会填入
    ├── WelcomeMessage: 你好我是元仔
    ├── Burst.Enable: true       ← 允许打断
    └── IdleTimeout: 10          ← 10 秒不说话进入空闲

为什么有两个 System Prompt?

因为混合编排模式下有两个模型在同时工作:

复制代码
LLMConfig.SystemMessages → 给方舟大模型看(决定"要不要调工具")
  内容:非常严格的规则,"仅当命中以下词汇才允许触发..."
  风格:白名单+禁止列表,每个 FC 命令都有精确的触发词和禁止词

S2SConfig.system_role → 给 S2S 端到端模型看(决定"怎么说话")
  内容:人设、说话风格、ASR 容错
  风格:松散描述,"你是元仔,一个小朋友..."

两个 Prompt 必须分别维护的原因:方舟 LLM 需要严格的 FC 触发规则(否则会误触发),S2S 模型需要自然的人设描述(否则说话像机器人)。如果混在一起,要么 FC 准确率下降,要么对话质量下降。

SystemMessages 为什么这样设计?

最终版的 System Prompt 的风格是这样的:

markdown 复制代码
# 指令机器人
你只能调用以下工具:
1. robot_play_music
2. system_vlm
除非明确命中下面规则,否则禁止调用任何 function。

# play
仅当用户明确包含"音乐播放"相关词时调用。
允许触发词:播放音乐、来首歌、来点歌、放首歌、听歌、想听歌...
否则禁止触发 play。

# pause
仅以下词允许触发:暂停、pause、暂停音乐

# 禁止规则
以下内容禁止调用任何工具:
需要、好的、可以、yes、ok、向前走、往前走、继续、下一步、上一页

# 最终规则
命中规则才允许调用 function。否则直接普通回复。

这种"白名单+禁止列表"的风格,是为了应对 FC 是黑盒这个现实。如果写的是描述式 Prompt("判断用户意图,选择合适的工具..."),LLM 可能自作主张------比如用户说"好的"时以为是同意播放音乐。白名单风格把触发条件精确到了具体词汇,禁止列表把最常见的误触发词("好的"、"可以"、"需要")全部封死。

Tools 里的 JSON Schema 是干什么的?

不是给人类看的,是给方舟 LLM 看的。LLM 读了这个 Schema 就知道调用工具时应该输出什么格式的 JSON。比如 robot_play_music 的 Schema 告诉 LLM:

json 复制代码
{
    "name": "robot_play_music",
    "parameters": {
        "command": { "enum": ["play", "pause", "resume", "stop", "prev", "next"] },
        "music_name": { "type": "string", "description": "音乐名称" },
        "music_pre_tip": { "type": "string", "description": "播报的TTS文本" }
    },
    "required": ["command"]
}

LLM 看到这个,就知道"如果用户要放音乐,输出一个 JSON,command 必须是这 6 个之一,music_name 可选,music_pre_tip 可选"。

和 doubao.json 的关系

doubao.json 里的 start_request_temple_file 字段指向的就是这个文件:

json 复制代码
// doubao.json 里
"start_request_temple_file": "cfg/t1/start_voice_chat_template.json"

所以 doubao.json 是"管线配置"(连哪个服务器、用什么模型、音频参数),这个文件是"智能体配置"(智能体叫什么、会什么、怎么说话)。两个文件配合起来,豆包才知道自己是什么、怎么干活。

8.3 agent.yaml --- 新增的两个线程池

agent.yaml 是 AimRT 框架的模块配置文件,原来的内容不变,只追加了两个 executor:

yaml 复制代码
- name: AgentModule/hds
  type: asio_thread
  options:
    thread_num: 1
- name: AgentModule/event
  type: asio_thread
  options:
    thread_num: 1

这两个什么时候用到?不是开机时直接读的,而是在 OnInitialize() 阶段,AimRT 框架解析 agent.yaml,创建对应的线程池。后面通过 executor 名字引用:

cpp 复制代码
// module.cpp --- LoadOmniSDK 的时候
option_.controller_option.omnis_sdk_callback_thread_pool = 
    aimrte::ctx::init::Executor("omnis_sdk_callback_thread_pool");
// 类似的,业务代码里会通过名字引用 AgentModule/hds 和 AgentModule/event
  • AgentModule/hds:HDS(Hardware Device Service,硬件设备服务)的线程。HDS 是一个独立的组件,负责监控硬件状态(告警等),以固定频率(20 Hz)发布诊断信息。原来可能跟其他业务共用线程,现在独立出来,避免 HDS 的定时发布被其他任务阻塞。

  • AgentModule/event:事件处理专用线程。接入豆包后,云端 FC 事件流到达时需要一个独立的处理线程------如果跟主音频管线共用线程,FC 执行(比如调 VLM 服务、写磁盘文件)可能阻塞音频传输。

8.4 三个配置文件的关系

复制代码
doubao.json
  │
  ├── app_id, token → RTC 鉴权 + HTTP API 鉴权
  ├── audio_observer_config → 决定收哪些音频流
  ├── vlm → VLM 视觉服务的连接信息
  │
  └── start_request_temple_file ──→ start_voice_chat_template.json
                                        │
                                        ├── LLMConfig → 方舟 LLM 的 System Prompt + FC 工具
                                        └── S2SConfig → S2S 模型的 System Prompt + 语音参数

agent.yaml
  └── AgentModule/hds, AgentModule/event → 独立线程池

简单记:

  • doubao.json = 管线怎么连
  • start_voice_chat_template.json = 智能体怎么做
  • agent.yaml = 线程怎么分

9. 整个启动流程串一遍

机器人开机后,OnStart() 的执行顺序:

复制代码
时间 →

① omnis_core_->Start()
   └── 交互云先连上(WebSocket)

② StartBusiness()
   └── 业务服务启动:GreetingService(迎宾)、GestureRecognition(手势识别)

③ controller_.WakeUp()
   └── 发送唤醒信号,屏幕亮起

④ info_input_impl_->Start()
   └── X2 硬件输入就绪(按键、传感器)

⑤ ┌──────────── 豆包初始化(新增)────────────┐
   │
   │  a. AppDataManager::load("cfg/t1/doubao.json")
   │     └── 读 JSON,解析配置,填到 StuAppData 结构体
   │
   │  b. RTCVideoEngineWrapper::instance()->init()
   │     └── 调 IRTCEngine::createRTCEngine() 创建引擎
   │     └── 配置音频参数(采样率 16k、单声道)
   │     └── 加载 Token
   │
   │  c. rtcWrapper->joinRoom()
   │     └── TokenGenerator 生成入房 Token
   │     └── createRTCRoom + joinRoom
   │     └── publishStreamAudio → 开始推音频流
   │     └── StartAgentAsync → HTTP POST StartVoiceChat → 云端创建智能体
   │     └── 🔗 WebRTC 通道打通!
   │
   │  d. RegisterJpgEncoder(OpenCV 编码 lambda)
   │     └── 把 JPEG 编码能力注入 RTC Wrapper
   │
   │  e. controller_.SetRtcWrapper(rtcWrapper)
   │     └── Controller 拿到 RTC 指针
   │     └── HandleVideoData 开始每帧推送给 RTC
   │
   │  f. RegisterAudioCallback(播放 lambda)
   │     └── 豆包 TTS 音频 → output_audio_handler_->PlayAudioStream()
   │
   │  g. RegisterSkillCallback(SkillManager lambda)
   │     └── 豆包 FC JSON → SkillManager::ExecuteSkillAsync()
   │
   │  h. 检查智能体状态 → isAgentEnabled && isAgentRunning
   │
   └──────────────────────────────────────────┘

⑥ input_audio_handler_->OnStart()
   └── 麦克风最后才开,避免和 ByteRTC SDK 抢音频设备

10. 数据是怎么流的

用户说完一句话后,数据的流向:

复制代码
                    麦克风
                      │
            PortAudio 采集 PCM
                      │
               ┌──────┴──────┐
               ▼              ▼
        OmniSDK 通路      ByteRTC 通路
        (我们推音频)      (SDK 自己采)
               │              │
         WebSocket         WebRTC
               │              │
         我们的交互云         火山豆包云
               │              │
     ┌─────────┴──────┐      │
     │                │      │
   NLU 动作        NLG 文本   │
     │                │      │
     ▼                ▼      ▼
  HandleAction    HandleNlg  TTS 音频 / FC JSON
  Callback        Result         │
     │                │      ┌───┴───┐
     ▼                ▼      ▼       ▼
  Dispatch        直接返回  Audio    onRoomBinary
  SkillEvent      (模板化)  Callback MessageReceived
     │                │      │       │
     │                │      ▼       ▼
     │                │   播放TTS  m_skillCallback
     │                │              │
     └────────┬───────┘              │
              ▼                      ▼
     SkillManager::ExecuteSkillAsync(nlu_json)
              │
              ▼
         Plugin 执行(动作/表情/TTS/移动)
              │
              ▼
         AimRT RPC → MC / TTS / 表情硬件模块

11. 一些设计上的选择

为什么包 Wrapper 不直接调 SDK

火山 SDK 以后可能会升级版本,两三年后可能整个方案都换掉。把 SDK 包在 Wrapper 里,改版本只需改 Wrapper 内部,外围不受影响。

配置放 JSON 不放代码里

开发阶段调参很频繁------换个模型、改个音色、调个 VAD 超时。如果在代码里就要改一次编译一次,放 JSON 里改完重启就行。

回调注入而不是 include

RTC Wrapper 是一个底层通信模块,它不应该 include OpenCV、不应该 include SkillManager。让它保持独立,需要的能力通过注册回调让 AgentModule 注入。这是依赖倒置。

atomic 指针连接 Controller 和 RTC

Controller 在主线程跑,RTC 回调在 SDK 线程跑。rtc_wrapper_std::atomic<RTCVideoEngineWrapper*>,配合 load(memory_order_acquire)store(memory_order_release) 保证两边的读写不出问题。注入之前 Wrapper 是 nullptr,HandleVideoData 会跳过帧推送。

预缓冲 preroll

WebRTC 刚开始收到音频时,帧可能不完整。代码里做了一个状态机:Silent → Buffering → Streaming。缓冲期间把帧攒够 preroll_ms 毫秒,然后一次性扔给音频播放器,之后再逐帧播放。这样可以避免开头卡顿和爆音。

相关推荐
物联网软硬件开发-轨物科技10 小时前
【轨物方案】效率与安全的双重革命:光伏清检一体化机器人解决方案深度解析
安全·机器人
艾莉丝努力练剑10 小时前
【Linux:文件】库的制作与原理进阶
linux·运维·服务器·网络·数据库·c++·人工智能
z2023050810 小时前
RDMA之RDMA 的发展原因和软件架构基础(10)
linux·服务器·网络·人工智能·ai
云边云科技_云网融合10 小时前
企业级网络智能运维体系构建:从被动响应到主动预判
大数据·网络·人工智能
VOOHU-沃虎10 小时前
音频变压器选型指南:阻抗匹配、隔离耐压与低失真设计的工程实践
网络·音频
努力成为AK大王10 小时前
网络层核心(四):路由协议与移动IP全解析
网络·智能路由器·路由选择协议
潜创微科技10 小时前
IT68051+IT6615:4K@60Hz HDMI+USB Over IP 网线延长方案|低延时 100 米无损传输
网络·网络协议·tcp/ip
AI科技星10 小时前
维度原本——基于超复数谱系的全域维度统一理论
c语言·前端·javascript·网络·electron
星辰AI10 小时前
数据质量检查:保障 AI 训练数据的可靠性
人工智能·ai·语言模型