SOC-ESP32S3部分:34-xiaozhi-esp32实现源码分析

飞书文档https://x509p6c8to.feishu.cn/wiki/P6Xhw6JtXilaQhkgcpEcAxmynHgxiaozhi-esp32是一个开源项目,采用 MIT 许可证发布,允许任何人免费使用,也可用于商业用途。旨在帮助更多人入门 AI 硬件开发,了解如何将大语言模型应用到实际硬件设备中,适合对 AI 感兴趣的学生和想要探索新技术的开发者,可通过此项目获得宝贵的学习经验。

已实现功能

  • 网络连接:支持 Wi-Fi 以及 ML307 Cat.1 4G 网络。
  • 按键交互:BOOT 键可用于唤醒和打断操作,支持点击和长按两种触发方式。
  • 语音唤醒:具备离线语音唤醒功能,基于 ESP - SR
  • 语音对话:支持流式语音对话,采用 WebSocket 或 UDP 协议。
  • 语言识别:支持国语、粤语、英语、日语、韩语 5 种语言识别,借助 SenseVoice
  • 声纹识别:能够识别是谁在喊 AI 的名字,基于 [3D Speaker](https://github.com/modelscope/3D - Speaker)。
  • 语音合成与大模型:支持大模型 TTS(火山引擎 或 CosyVoice)和大模型 LLM(Qwen, DeepSeek, Doubao)。
  • 自定义配置:可配置提示词和音色,实现自定义角色。
  • 记忆功能:具备短期记忆,每轮对话后会自我总结。
  • 显示功能:支持 OLED / LCD 显示屏,可显示信号强弱或对话内容,还支持 LCD 显示图片表情。
  • 多语言支持:支持多语言(中文、英文)。

代码结构

基于https://github.com/78/xiaozhi-esp32/tree/v1.5.5版本分析

仓库包含多个文件和文件夹,主要结构如下:

复制代码
.gitignore                    git工程管理文件
CMakeLists.txt                工程cmake构建文件
LICENSE                       MIT License 允许用户自由使用、修改和分发代码,只需在软件或文档中包含原作者的版权声明和许可声明即可。                
README.md                     README说明文档_中文
README_en.md                  README说明文档_英文
README_ja.md                  README说明文档_日文
partitions.csv                分区配置文件:16M Flash的分区表,默认选择,可以通过idf.py menuconfig修改,建议选择支持16M的硬件,后续扩展开发和OTA更方便
partitions_32M_sensecap.csv   分区配置文件:32M Flash的分区表
partitions_4M.csv             分区配置文件:4M Flash的分区表
partitions_8M.csv             分区配置文件:8M Flash的分区表
sdkconfig.defaults            idf sdk默认配置,工程目前支持esp32s3和esp32c3两款芯片,只有s3支持唤醒词,基于乐鑫esp-sr实现
sdkconfig.defaults.esp32c3    idf sdk esp32c3默认配置
sdkconfig.defaults.esp32s3    idf sdk esp32s3默认配置
docs/     文档文件,里面包含README文档使用的图片资料,还有一份服务器websocket交互协议
scripts/  脚本文件,包含音频编码测试脚本、烧录调试脚本、版本打包相关脚本
.github/  GitHub Actions 的配置文件(.yaml 格式),用于定义项目的自动化工作流程,例如代码的持续集成(CI),可以在每次代码推送到仓库时自动运行测试、代码检查等任务
main/     主要源码文件夹

工程主要使用C++语言进行实现,那如何看懂一个C++工程呢?首先要求我们有C++相关的基础,因为我们已经学习过C语言,所以上手C++也会很快,主要是面向对象的特性和C++的一些高级特性即可:

这部分的学习可以参考:https://www.runoob.com/cplusplus/cpp-tutorial.html 进行即可。

你可以快速过一遍,后面配合工程、AI工具进行具体学习。

掌握 C++ 的面向对象编程(OOP)概念。

类和对象

  • 类的定义和使用
  • 对象的创建和初始化

成员函数

  • 成员函数的定义和调用
  • 内联函数

构造函数和析构函数

  • 默认构造函数
  • 参数化构造函数
  • 析构函数

访问控制

  • 公有(public)、私有(private)、保护(protected)

继承

  • 单继承
  • 多继承(了解其复杂性)

多态

  • 虚函数
  • 纯虚函数和抽象类

运算符重载

  • 运算符重载的基本概念
  • 常见运算符的重载

友元函数和友元类

  • 友元函数
  • 友元类

模板

  • 函数模板
  • 类模板

掌握 C++ 的高级主题和最佳实践。

STL (标准模板库)

  • 容器(vector, list, map, set)
  • 算法(sort, find, for_each)
  • 迭代器

异常处理

  • try-catch 块
  • 自定义异常类

智能指针

  • std::unique_ptr
  • std::shared_ptr
  • std::weak_ptr

RAII Resource Acquisition Is Initialization

  • RAII 原则及其应用

文件操作

  • 文件流(ifstream, ofstream, fstream)
  • 文件读写操作

多线程编程

  • 线程的基本概念
  • std::thread
  • 同步机制(mutex, lock_guard, unique_lock)

正则表达式

  • 正则表达式的基本概念
  • 使用 std::regex 进行字符串匹配

C++11/14/17/20 新特性

  • auto 关键字
  • lambda 表达式

工程类图

看C++工程和看C工程有个不同,那就是我们必须要先了解工程的类图,类图很重要,它可以加速我们熟悉工程架构和代码理解。

系统架构

  • 架构设计:通过类之间的继承、组合、依赖关系,了解工程的架构和数据流向。
  • 架构风格:判断是分层架构、组件化架构,还是事件驱动模型。

代码理解

  • 降低认知成本:图形化展示比阅读代码更直观。
  • 关键路径分析:让我们聚焦核心类,忽略辅助类。

整个工程的类图如下,主要围绕Application Board ThingManager 三个大类进行实现

Application Board ThingManager 的说明类

  1. Application 类作为整个应用程序的核心管理类,负责协调和控制各个功能模块的运行,包含了设备状态管理、音频处理、固件升级、任务调度等功能。
  2. Board 类是一个抽象基类,定义了硬件设备的接口,为不同的硬件平台提供统一的操作方法,具体的硬件实现由派生类完成。
  3. ThingManager 类作为物联网设备管理类,负责管理多个 Thing 对象,提供了设备描述信息获取、设备状态获取和设备方法调用等功能。

三者之间的关系

  1. Application 类依赖于 Board 类,通过 Board::GetInstance() 获取硬件设备的实例,实现与硬件的交互。
  2. Application 类会使用 ThingManager 类来管理物联网设备,通过 ThingManager::GetInstance() 获取设备管理实例,实现设备的描述信息获取、状态获取和方法调用等功能。
  3. Board 类和 ThingManager 类之间没有直接的依赖关系,但它们都为 Application 类提供了必要的功能支持。

综上所述,Application 类负责整个应用程序的核心逻辑,Board 类提供硬件设备的接口,ThingManager 类负责物联网设备的管理,三者协同工作,实现了一个完整的应用程序。

main****源码说明

了解了整个工程架构后,我们就可以开始找到程序的入口函数,逐步了解整个工程的实现细节了。

整个工程的入口函数在xiaozhi-esp32/main/main.cc中,初始化默认事件循环和NVS后,通过Application::GetInstance().Start()直接启动应用。

复制代码
extern "C" void app_main(void)
{
    // 初始化默认事件循环
    ESP_ERROR_CHECK(esp_event_loop_create_default());

    // 初始化 NVS flash 以存储 WiFi 配置
    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_LOGW(TAG, "Erasing NVS flash to fix corruption");
        ESP_ERROR_CHECK(nvs_flash_erase());
        ret = nvs_flash_init();
    }
    ESP_ERROR_CHECK(ret);

    // 启动应用程序
    Application::GetInstance().Start();
    // 主线程将退出并释放堆栈内存
}

获取单例对象与设置设备状态

应用启动时,会获取板卡对象,后续所有的板卡相关资源,例如灯光、屏幕、音频codec等等,都由board对象进行获取。

复制代码
void Application::Start() {
    auto& board = Board::GetInstance();
    SetDeviceState(kDeviceStateStarting);
    .....   
}

获取板卡对象时,对象由create_board创建。

复制代码
    static Board& GetInstance() {
        static Board* instance = static_cast<Board*>(create_board());
        return *instance;
    }
   
#define DECLARE_BOARD(BOARD_CLASS_NAME) \
void* create_board() { \
    return new BOARD_CLASS_NAME(); \
}

具体使用哪个板卡类创建对象呢?由xiaozhi-esp32/main/CMakeLists.txt进行选择,CMakeLists.txt根据menuconfig选择的板卡名称决定

这里的名称选择后,会根据xiaozhi-esp32/main/Kconfig.projbuild文件,设置某个宏为true,并存储到xiaozhi-esp32/sdkconfig中

复制代码
xiaozhi-esp32/main/Kconfig.projbuild:
choice BOARD_TYPE
    prompt "Board Type"
    default BOARD_TYPE_BREAD_COMPACT_WIFI
    help
        Board type. 开发板类型
    config BOARD_TYPE_BREAD_COMPACT_WIFI
        bool "面包板新版接线(WiFi)"
    config BOARD_TYPE_BREAD_COMPACT_WIFI_LCD
        bool "面包板新版接线(WiFi)+ LCD"
    config BOARD_TYPE_BREAD_COMPACT_ML307
        bool "面包板新版接线(ML307 AT)"
    config BOARD_TYPE_BREAD_COMPACT_ESP32
        bool "面包板 ESP32 DevKit"
    config BOARD_TYPE_ESP_BOX_3
   
iaozhi-esp32/sdkconfig:
CONFIG_BOARD_TYPE_BREAD_COMPACT_WIFI=y
# CONFIG_BOARD_TYPE_BREAD_COMPACT_WIFI_LCD is not set
# CONFIG_BOARD_TYPE_BREAD_COMPACT_ML307 is not set
# CONFIG_BOARD_TYPE_BREAD_COMPACT_ESP32 is not set   

最终在cmake构建时,会编译对应的.c文件

复制代码
# 根据 BOARD_TYPE 配置添加对应的板级文件
if(CONFIG_BOARD_TYPE_BREAD_COMPACT_WIFI)
    set(BOARD_TYPE "bread-compact-wifi")
elseif(CONFIG_BOARD_TYPE_BREAD_COMPACT_ML307)
    set(BOARD_TYPE "bread-compact-ml307")
elseif(CONFIG_BOARD_TYPE_BREAD_COMPACT_ESP32)
    set(BOARD_TYPE "bread-compact-esp32")
elseif(CONFIG_BOARD_TYPE_DF_K10)
    set(BOARD_TYPE "df-k10")
elseif(CONFIG_BOARD_TYPE_ESP_BOX_3)
    set(BOARD_TYPE "esp-box-3")
    .....
    .....
    ....
   
file(GLOB BOARD_SOURCES
    ${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_TYPE}/*.cc
    ${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_TYPE}/*.c
)

至此,获取到Board实例,然后通过SetDeviceState把设备状态设定为 kDeviceStateStarting,表示应用程序正在启动。

配置显示与音频编解码器

复制代码
auto display = board.GetDisplay();
auto codec = board.GetAudioCodec();
opus_decode_sample_rate_ = codec->output_sample_rate();
opus_decoder_ = std::make_unique<OpusDecoderWrapper>(opus_decode_sample_rate_, 1);
opus_encoder_ = std::make_unique<OpusEncoderWrapper>(16000, 1, OPUS_FRAME_DURATION_MS);
  • board.GetDisplay():从 Board 对象获取显示设备。
  • board.GetAudioCodec():从 Board 对象获取音频编解码器。
  • opus_decode_sample_rate_:记录音频编解码器的输出采样率。
  • opus_decoder_:创建一个 OpusDecoderWrapper 对象,用于音频解码。
  • opus_encoder_:创建一个 OpusEncoderWrapper 对象,用于音频编码。

根据不同板子类型设置音频编码器复杂度

复制代码
if (board.GetBoardType() == "ml307") {
    ESP_LOGI(TAG, "ML307 board detected, setting opus encoder complexity to 5");
    opus_encoder_->SetComplexity(5);
} else {
    ESP_LOGI(TAG, "WiFi board detected, setting opus encoder complexity to 3");
    opus_encoder_->SetComplexity(3);
}
  • 若检测到是 ml307 (4G-AT通信)板子,就将 Opus 编码器的复杂度设为 5,以节省带宽;若为其他板子,则设为 3,以节省 CPU 资源。

配置重采样器

复制代码
if (codec->input_sample_rate() != 16000) {
    input_resampler_.Configure(codec->input_sample_rate(), 16000);
    reference_resampler_.Configure(codec->input_sample_rate(), 16000);
}
  • 若音频编解码器的输入采样率并非 16000 Hz,就对输入重采样器和参考重采样器进行配置,将输入采样率转换为 16000 Hz。

设置音频编解码器的回调函数

复制代码
codec->OnInputReady([this, codec]() {
    BaseType_t higher_priority_task_woken = pdFALSE;
    xEventGroupSetBitsFromISR(event_group_, AUDIO_INPUT_READY_EVENT, &higher_priority_task_woken);
    return higher_priority_task_woken == pdTRUE;
});
codec->OnOutputReady([this]() {
    BaseType_t higher_priority_task_woken = pdFALSE;
    xEventGroupSetBitsFromISR(event_group_, AUDIO_OUTPUT_READY_EVENT, &higher_priority_task_woken);
    return higher_priority_task_woken == pdTRUE;
});
codec->Start();
  • OnInputReady 和 OnOutputReady:分别设置音频输入和输出准备好时的回调函数。当音频输入或输出准备好时,会设置相应的事件标志。
  • codec->Start():启动音频编解码器。

创建主循环任务

复制代码
xTaskCreate([](void* arg) {
    Application* app = (Application*)arg;
    app->MainLoop();
    vTaskDelete(NULL);
}, "main_loop", 4096 * 2, this, 4, nullptr);
  • xTaskCreate:创建一个新的任务,任务名为 main_loop,该任务会调用 Application 对象的 MainLoop() 函数。

启动网络并初始化协议

复制代码
board.StartNetwork();
display->SetStatus(Lang::Strings::LOADING_PROTOCOL);
#ifdef CONFIG_CONNECTION_TYPE_WEBSOCKET
    protocol_ = std::make_unique<WebsocketProtocol>();
#else
    protocol_ = std::make_unique<MqttProtocol>();
#endif
  • board.StartNetwork():启动网络连接。
  • display->SetStatus(Lang::Strings::LOADING_PROTOCOL):在显示设备上显示加载协议的状态信息。
  • 根据配置,选择使用 WebSocket 协议或 MQTT 协议。

设置协议的回调函数

复制代码
protocol_->OnNetworkError([this](const std::string& message) {
    SetDeviceState(kDeviceStateIdle);
    Alert(Lang::Strings::ERROR, message.c_str(), "sad", Lang::Sounds::P3_EXCLAMATION);
});
protocol_->OnIncomingAudio([this](std::vector<uint8_t>&& data) {
    std::lock_guard<std::mutex> lock(mutex_);
    if (device_state_ == kDeviceStateSpeaking) {
        audio_decode_queue_.emplace_back(std::move(data));
    }
});
// 其他回调函数...
protocol_->Start();
  • OnNetworkError:当网络出现错误时,将设备状态设置为 kDeviceStateIdle,并发出警报。
  • OnIncomingAudio:当接收到音频数据时,若设备处于 kDeviceStateSpeaking 状态,就把音频数据加入解码队列。

检查固件版本

复制代码
ota_.SetCheckVersionUrl(CONFIG_OTA_VERSION_URL);
ota_.SetHeader("Device-Id", SystemInfo::GetMacAddress().c_str());
ota_.SetHeader("Client-Id", board.GetUuid());
ota_.SetHeader("Accept-Language", Lang::CODE);
auto app_desc = esp_app_get_description();
ota_.SetHeader("User-Agent", std::string(BOARD_NAME "/") + app_desc->version);

xTaskCreate([](void* arg) {
    Application* app = (Application*)arg;
    app->CheckNewVersion();
    vTaskDelete(NULL);
}, "check_new_version", 4096 * 2, this, 2, nullptr);
  • 对 OTA(Over-the-Air)更新相关的信息进行设置,包括检查版本的 URL 和请求头。
  • 创建一个新任务来检查是否有新的固件版本。

初始化音频处理器(可选)

因为有的板卡不支持本地跑降兆模型,所以这里通过menuconfig配置为可选

复制代码
#if CONFIG_USE_AUDIO_PROCESSOR
    audio_processor_.Initialize(codec->input_channels(), codec->input_reference());
    audio_processor_.OnOutput([this](std::vector<int16_t>&& data) {
        background_task_->Schedule([this, data = std::move(data)]() mutable {
            opus_encoder_->Encode(std::move(data), [this](std::vector<uint8_t>&& opus) {
                Schedule([this, opus = std::move(opus)]() {
                    protocol_->SendAudio(opus);
                });
            });
        });
    });
#endif
  • 若启用了音频处理器,就对其进行初始化,并设置输出回调函数。当音频处理器有输出时,会对音频数据进行编码并通过协议发送出去。

初始化唤醒词检测(可选)

因为有的板卡不支持本地跑唤醒词模型,所以这里通过menuconfig配置为可选

复制代码
#if CONFIG_USE_WAKE_WORD_DETECT
    wake_word_detect_.Initialize(codec->input_channels(), codec->input_reference());
    wake_word_detect_.OnVadStateChange([this](bool speaking) {
        Schedule([this, speaking]() {
            if (device_state_ == kDeviceStateListening) {
                if (speaking) {
                    voice_detected_ = true;
                } else {
                    voice_detected_ = false;
                }
                auto led = Board::GetInstance().GetLed();
                led->OnStateChanged();
            }
        });
    });
    // 其他唤醒词检测回调函数...
    wake_word_detect_.StartDetection();
#endif
  • 若启用了唤醒词检测,就对其进行初始化,并设置相关的回调函数。当检测到语音活动状态变化或唤醒词时,会执行相应的操作。

最终设置

复制代码
SetDeviceState(kDeviceStateIdle);
esp_timer_start_periodic(clock_timer_handle_, 1000000);
  • 把设备状态设置为 kDeviceStateIdle,表示应用程序已准备好。
  • 启动一个周期性定时器。

至此,板卡部分初始化完成。后续的总体流程总结如下:

设备端初始化

  • 设备上电、初始化 Application:
  • 初始化音频编解码器、显示屏、LED 等
  • 连接网络
  • 创建并初始化实现 Protocol 接口的 WebSocket 协议实例(WebsocketProtocol)
  • 进入主循环等待事件(音频输入、音频输出、调度任务等)。

建立 WebSocket 连接

  • 当设备需要开始语音会话时(例如用户唤醒、手动按键触发等),调用 OpenAudioChannel():
  • 根据编译配置获取 WebSocket URL(CONFIG_WEBSOCKET_URL)
  • 设置若干请求头(Authorization, Protocol-Version, Device-Id, Client-Id)
  • 调用 Connect() 与服务器建立 WebSocket 连接

发送客户端 "hello" 消息

  • 连接成功后,设备会发送一条 JSON 消息,示例结构如下:

    json
    {
    "type": "hello",
    "version": 1,
    "transport": "websocket",
    "audio_params": {
    "format": "opus",
    "sample_rate": 16000,
    "channels": 1,
    "frame_duration": 60
    }
    }

  • 其中 "frame_duration" 的值对应 OPUS_FRAME_DURATION_MS(例如 60ms)。

服务器回复 "hello"

  • 设备等待服务器返回一条包含 "type": "hello" 的 JSON 消息,并检查 "transport": "websocket" 是否匹配。
  • 如果匹配,则认为服务器已就绪,标记音频通道打开成功。
  • 如果在超时时间(默认 10 秒)内未收到正确回复,认为连接失败并触发网络错误回调。

后续消息交互

  • 设备端和服务器端之间可发送两种主要类型的数据:
  1. 二进制音频数据(Opus 编码)
  2. 文本 JSON 消息(用于传输聊天状态、TTS/STT 事件、IoT 命令等)
  • 在代码里,接收回调主要分为:
  • OnData(...):
  • 当 binary 为 true 时,认为是音频帧;设备会将其当作 Opus 数据进行解码。
  • 当 binary 为 false 时,认为是 JSON 文本,需要在设备端用 cJSON 进行解析并做相应业务逻辑处理(见下文消息结构)。
  • 当服务器或网络出现断连,回调 OnDisconnected() 被触发:
  • 设备会调用 on_audio_channel_closed_(),并最终回到空闲状态。

关闭 WebSocket 连接

  • 设备在需要结束语音会话时,会调用 CloseAudioChannel() 主动断开连接,并回到空闲状态。
  • 或者如果服务器端主动断开,也会引发同样的回调流程。

工程依赖组件

xiaozhi-esp32/main/idf_component.yml

工程中很多驱动和模块都是以组件方式进行管理,乐鑫组件库参考:https://components.espressif.com/,善用组件库可以大大提高我们的开发效率。

复制代码
## IDF Component Manager Manifest File
dependencies:
  ## LCD驱动
  waveshare/esp_lcd_sh8601: "1.0.2"
  espressif/esp_lcd_ili9341: "==1.2.0"
  espressif/esp_lcd_gc9a01: "^2.0.1"
  espressif/esp_lcd_st77916: "^1.0.1"
  espressif/esp_lcd_spd2010: "==1.0.2"
  espressif/esp_io_expander_tca9554: "==2.0.0"
  espressif/esp_lcd_panel_io_additions: "^1.0.1"
  78/esp_lcd_nv3023: "~1.0.0"
  ## wifi连接
  78/esp-wifi-connect: "~2.3.1"
  ## opus编码
  78/esp-opus-encoder: "~2.1.0"
  ## ml307 4G 模块库
  78/esp-ml307: "~1.7.2"
  ## 字库
  78/xiaozhi-fonts: "~1.3.2"
  ## led库
  espressif/led_strip: "^2.4.1"
  ## codec
  espressif/esp_codec_dev: "~1.3.2"
  ## 音频前端算法-唤醒、降噪、VAD
  espressif/esp-sr: "^1.9.0"
  ## 按键库
  espressif/button: "^3.3.1"
  ## lvgl显示框架
  lvgl/lvgl: "~9.2.2"
  ## 乐鑫LVGL适配库
  esp_lvgl_port: "~2.4.4"
  ## Required IDF version
  idf:
    version: ">=5.3"

类说明

Application****类说明

Application 类是一个核心类,负责管理整个应用程序的生命周期、状态转换、音频处理、网络通信以及系统更新等功能。以下是对 Application 类的详细分析:

类的基本信息

复制代码
单例模式:
通过 static Application& GetInstance() 方法实现单例模式,确保整个应用程序中只有一个 Application 实例。同时,通过 Application(const Application&) = delete; 和 Application& operator=(const Application&) = delete; 禁止拷贝构造和赋值操作,保证单例的唯一性。

枚举类型:
定义了 DeviceState 枚举,用于表示设备的不同状态,如 kDeviceStateIdle(空闲状态)、kDeviceStateListening(监听状态)等。

成员变量

  • 状态管理:

    volatile DeviceState device_state_:记录设备的当前状态,使用 volatile 关键字确保在多任务环境下状态的可见性。
    bool keep_listening_:表示是否持续监听的标志。
    bool aborted_:表示操作是否被中止的标志。
    bool voice_detected_:表示是否检测到语音的标志。

  • 任务调度:

    std::list<std::function<void()>> main_tasks_:存储待执行的任务,使用 std::function 可以灵活地存储不同类型的任务。
    EventGroupHandle_t event_group_:用于事件管理,通过事件组来触发不同的操作。
    esp_timer_handle_t clock_timer_handle_:时钟定时器句柄,用于定时执行某些操作。

  • 音频处理:

    std::list<std::vector<uint8_t>> audio_decode_queue_:音频解码队列,存储待解码的音频数据。
    std::unique_ptr<OpusEncoderWrapper> opus_encoder_ 和 std::unique_ptr<OpusDecoderWrapper> opus_decoder_:分别用于音频编码和解码。
    OpusResampler input_resampler_、OpusResampler reference_resampler_ 和 OpusResampler output_resampler_:用于音频采样率的转换。

  • 其他组件:

    Ota ota_:用于固件升级管理。
    std::unique_ptr<Protocol> protocol_:网络协议的智能指针,负责与服务器进行通信。
    BackgroundTask* background_task_:后台任务指针,用于执行一些耗时的操作

成员函数

构造函数和析构函数

复制代码
构造函数 Application::Application():初始化事件组、后台任务和时钟定时器。
析构函数 Application::~Application():停止并删除时钟定时器,释放后台任务的内存,删除事件组

核心功能函数

复制代码
void Start():应用程序的启动函数,主要完成以下操作:
设置设备状态为 kDeviceStateStarting。
初始化显示和音频编解码器,配置音频采样率和编解码器的复杂度。
启动音频输入和输出的事件监听。
创建主循环任务,处理事件和任务调度。
启动网络连接,初始化网络协议。
检查固件更新,启动时钟定时器。

void MainLoop():主循环函数,通过事件组等待不同的事件(如音频输入就绪、音频输出就绪、任务调度事件),并根据事件类型执行相应的操作。

状态管理函数

复制代码
void SetDeviceState(DeviceState state):设置设备的当前状态。
DeviceState GetDeviceState() const:获取设备的当前状态。

音频处理函数

复制代码
void InputAudio():处理音频输入,包括采样率转换、唤醒词检测、音频编码和发送等操作。
void OutputAudio():处理音频输出,包括音频解码、采样率转换和输出等操作。
void ResetDecoder():重置解码器状态,清空音频解码队列。
void PlaySound(const std::string_view& sound):播放指定的声音,将声音数据添加到音频解码队列。

网络通信函数

复制代码
void StartListening():开始监听音频输入,打开音频通道并发送开始监听的消息。
void StopListening():停止监听音频输入,发送停止监听的消息并设置设备状态为空闲。
void WakeWordInvoke(const std::string& wake_word):当检测到唤醒词时,根据设备状态进行相应的操作,如切换到聊天状态、中止语音播放等。

系统管理函数

复制代码
void CheckNewVersion():检查是否有新的固件版本可用,如果有则进行升级操作。
void ShowActivationCode():显示激活码,并通过语音播报。
void Alert(const char* status, const char* message, const char* emotion, const std::string_view& sound):显示警报信息,包括状态、消息、表情和声音。
void DismissAlert():取消警报信息,恢复设备的空闲状态。
void Reboot():重启设备。

任务调度函数

void Schedule(std::function<void()> callback):将任务添加到主任务列表,并触发任务调度事件。

  • 代码逻辑流程
  • 初始化:在 Start() 函数中完成设备的初始化,包括显示、音频、网络等组件的配置。
  • 主循环:MainLoop() 函数不断等待事件的发生,根据事件类型调用相应的处理函数。
  • 音频处理:InputAudio() 和 OutputAudio() 分别处理音频的输入和输出,通过编解码器和采样率转换器进行数据处理。
  • 网络通信:通过 protocol_ 与服务器进行通信,处理音频通道的打开和关闭、消息的发送和接收等操作。
  • 系统管理:CheckNewVersion() 检查固件更新,ShowActivationCode() 显示激活码,Alert() 和 DismissAlert() 处理警报信息。

Board****类

功能概述

Board 类是一个抽象基类,定义了硬件设备的接口,为不同的硬件平台提供统一的操作方法,具体的硬件实现由派生类完成。

  • 单例模式:通过 GetInstance 方法实现单例模式,确保整个应用程序中只有一个 Board 实例。

    static Board& GetInstance() {
    static Board* instance = static_cast<Board*>(create_board());
    return *instance;
    }

  • 抽象方法:定义了多个纯虚函数,如 GetBoardType、GetAudioCodec、CreateHttp 等,要求派生类必须实现这些方法,以提供具体的硬件操作。

    virtual std::string GetBoardType() = 0;
    virtual AudioCodec* GetAudioCodec() = 0;
    virtual Http* CreateHttp() = 0;

硬件接口封装:提供了一系列方法来获取硬件设备的指针,如 GetBacklight、GetLed、GetDisplay 等,方便应用程序与硬件进行交互。

复制代码
​
virtual Backlight* GetBacklight() { return nullptr; }
virtual Led* GetLed();
virtual Display* GetDisplay();

ThingManager****类

功能概述

ThingManager 类作为物联网设备管理类,负责管理多个 Thing 对象,提供了设备描述信息获取、设备状态获取和设备方法调用等功能。

  • 单例模式:通过 GetInstance 方法实现单例模式,确保整个应用程序中只有一个 ThingManager 实例。

    static ThingManager& GetInstance() {
    static ThingManager instance;
    return instance;
    }

  • 设备管理:通过 AddThing 方法添加 Thing 对象到管理列表中,方便对多个设备进行统一管理。

    void ThingManager::AddThing(Thing* thing) {
    things_.push_back(thing);
    }

  • 描述信息和状态获取:提供 GetDescriptorsJson 和 GetStatesJson 方法,分别用于获取所有设备的描述信息和状态信息,并以 JSON 格式返回。

    std::string ThingManager::GetDescriptorsJson() {
    std::string json_str = "[";
    for (auto& thing : things_) {
    json_str += thing->GetDescriptorJson() + ",";
    }
    if (json_str.back() == ',') {
    json_str.pop_back();
    }
    json_str += "]";
    return json_str;
    }

    bool ThingManager::GetStatesJson(std::string& json, bool delta) {
    // ...
    }

  • 方法调用:提供 Invoke 方法,根据传入的 JSON 命令,查找对应的 Thing 对象并调用其方法。

    void ThingManager::Invoke(const cJSON* command) {
    auto name = cJSON_GetObjectItem(command, "name");
    for (auto& thing : things_) {
    if (thing->name() == name->valuestring) {
    thing->Invoke(command);
    return;
    }
    }
    }

类详细说明

main/iot****ThingManager 类说明

main/iot 中的类主要围绕设备(Thing)的属性、方法和参数管理展开交互,设备这些属性和方法可以被模型控制和调用,工程把这部分的功能划为设备物联网模块,一个设备可以创建多个Thing,然后通过ThingManager进行管理,例如工程创建了电池、背光、LED、扬声器四个Thing,使得模型可以读取电池电量、背光数值,控制背光等等操作,我们也可以自定义自己设备的Thing添加给模型调用。

Thing 类与 PropertyList、MethodList 类

  • Thing 类包含 PropertyList 和 MethodList:Thing 类作为物联网设备的抽象表示,内部包含了 PropertyList 对象 properties_ 和 MethodList 对象 methods_。这意味着每个 Thing 实例都可以拥有自己的属性集合和方法集合。
  • 添加属性和方法:在创建 Thing 实例后,可以通过 properties_ 的相关方法(如 AddBooleanProperty、AddNumberProperty、AddStringProperty)向设备添加属性,通过 methods_ 的相关方法(如 AddMethod)向设备添加方法。例如,在 Battery 类(继承自 Thing)的构造函数中,使用 properties_.AddNumberProperty 和 properties_.AddBooleanProperty 为电池设备添加电量和充电状态属性。

PropertyList 与 Property 类

  • PropertyList 管理 Property 集合:PropertyList 类内部使用 std::vector<Property> 来存储多个 Property 对象。通过 AddBooleanProperty、AddNumberProperty 和 AddStringProperty 等方法,PropertyList 可以将不同类型的 Property 对象添加到集合中。
  • 获取属性信息:PropertyList 提供了 GetDescriptorJson 和 GetStateJson 方法,用于生成所有属性的描述信息和当前状态的 JSON 字符串。这些方法会遍历内部存储的 Property 对象,调用每个 Property 的 GetDescriptorJson 和 GetStateJson 方法,将结果组合成一个完整的 JSON 字符串。

MethodList 与 Method 类

  • MethodList 管理 Method 集合:MethodList 类类似 PropertyList,内部使用 std::vector<Method> 来存储多个 Method 对象。通过 AddMethod 方法,MethodList 可以将新的 Method 对象添加到集合中。

Method 与 ParameterList 类

  • Method 包含 ParameterList:Method 类内部包含一个 ParameterList 对象 parameters_,用于管理该方法所需的参数。在创建 Method 实例时,需要传入一个 ParameterList 对象,以定义方法的参数。
  • 执行方法时传递参数:当调用 Method 的 Invoke 方法时,会将 ParameterList 作为参数传递给回调函数 callback_。回调函数可以根据 ParameterList 中的参数值执行相应的操作。

ParameterList 与 Parameter 类

  • ParameterList 管理 Parameter 集合:ParameterList 类内部使用 std::vector<Parameter> 来存储多个 Parameter 对象。通过 AddParameter 方法,ParameterList 可以将新的 Parameter 对象添加到集合中。
  • 获取参数信息:ParameterList 提供了 GetDescriptorJson 方法,用于生成所有参数的描述信息的 JSON 字符串。该方法会遍历内部存储的 Parameter 对象,调用每个 Parameter 的 GetDescriptorJson 方法,将结果组合成一个完整的 JSON 字符串。

ThingManager 类

  • ThingManager 管理 Thing 集合:ThingManager 类采用单例模式,通过 GetInstance 方法获取唯一实例。该类内部有一个 std::vector<Thing*> 类型的成员变量 things_,用于存储所有的 Thing 对象。
  • 添加和管理设备:在各个 InitializeIot 函数中,通过 iot::ThingManager::GetInstance().AddThing 方法将不同类型的 Thing(如 Speaker、Battery、Backlight 等)添加到 ThingManager 的管理列表中。ThingManager 可以对这些 Thing 对象进行统一管理,例如遍历所有设备,获取它们的属性和方法信息等。

Speaker 类:

  • 继承自 Thing 类,代表扬声器设备。在其构造函数中,定义了设备的属性(如当前音量值)和可远程执行的指令(如设置音量)。

Backlight 类:

  • 继承自 Thing 类,代表屏幕背光设备。在其构造函数中,定义了设备的属性(如当前亮度百分比)和可远程执行的指令(如设置亮度)。

Battery 类:

  • 继承自 Thing 类,代表电池管理设备。在其构造函数中,定义了设备的属性(如当前电量百分比、是否充电中)。

如果需要自定义一个属于自己设备的Thing对象,我们可以参考上述的Speaker 、Backlight、Battery 进行,例如要创建一个表示智能灯泡的 Thing 对象,伪代码如下:

创建 Thing 实例:

复制代码
iot::Thing light("SmartLight", "A smart light bulb");

添加属性:

复制代码
light.properties_.AddBooleanProperty("isOn", "Whether the light is on", []() { return isLightOn; });
light.properties_.AddNumberProperty("brightness", "The brightness of the light", []() { return lightBrightness; });

添加方法:

复制代码
iot::ParameterList turnOnParams;// 可以添加参数到 turnOnParams 中,这里假设无参数
iot::Method turnOnMethod("turnOn", "Turn on the light", turnOnParams, [](const iot::ParameterList& params) {// 实现开灯逻辑
    isLightOn = true;});
light.methods_.AddMethod(turnOnMethod);

将 Thing 添加到 ThingManager 中:

复制代码
iot::ThingManager::GetInstance().AddThing(&light);

通过以上步骤,就完成了一个智能灯泡设备的创建和注册,并且可以通过 ThingManager 对其进行管理,通过 PropertyList 和 MethodList 对其属性和方法进行操作。

main/led Led****类说明

main/led 目录下的代码中,主要涉及 Led、NoLed、SingleLed 和 CircularStrip这几个类

Led 是抽象基类:Led 类定义了一个纯虚函数 OnStateChanged(),这使得 Led 类成为抽象类,不能直接实例化。其主要作用是为派生类提供一个统一的接口,强制派生类实现 OnStateChanged() 方法,以根据设备状态设置 LED 的状态。

复制代码
class Led {
public:
    virtual ~Led() = default;
    // Set the led state based on the device state
    virtual void OnStateChanged() = 0;
};
  • NoLed 继承自 Led:NoLed 类继承了 Led 类,并重写了 OnStateChanged() 方法,但该方法为空实现。这通常用于表示一个没有实际 LED 功能的占位符。

    class NoLed : public Led {
    public:
    virtual void OnStateChanged() override {}
    };

  • SingleLed 继承自 Led:SingleLed 类继承了 Led 类,实现了 OnStateChanged() 方法,用于根据设备状态控制单个 LED 的颜色、闪烁等行为。

    class SingleLed : public Led {
    public:
    SingleLed(gpio_num_t gpio);
    virtual ~SingleLed();
    void OnStateChanged() override;
    // 其他成员函数和变量
    };

  • GpioLed 继承自 Led:GpioLed 类同样继承了 Led 类,实现了 OnStateChanged() 方法,通过 GPIO 控制 LED 的亮度、闪烁和渐变效果。

    class GpioLed : public Led {
    public:
    GpioLed(gpio_num_t gpio, int output_invert=0);
    virtual ~GpioLed();
    void OnStateChanged() override;
    // 其他成员函数和变量
    };

  • CircularStrip 类:继承自 Led 类,实现 OnStateChanged() 方法。该类用于控制环形 LED 灯带,支持设置亮度、颜色,以及实现闪烁、呼吸、跑马灯等动画效果。

    class CircularStrip : public Led {
    public:
    CircularStrip(gpio_num_t gpio, uint8_t max_leds);
    virtual ~CircularStrip();
    void OnStateChanged() override;
    // 其他成员函数和变量
    };

SingleLed 、CircularStrip 和 GpioLed 依赖于 Application 类:SingleLed 、CircularStrip 和 GpioLed 类的 OnStateChanged() 方法中,都通过 Application::GetInstance().GetDeviceState() 获取设备的当前状态,然后根据状态来控制 LED 的行为。这表明 SingleLed 和 GpioLed 类依赖于 Application 类提供的设备状态信息。

复制代码
void xxxxLed::OnStateChanged() {
    auto& app = Application::GetInstance();
    auto device_state = app.GetDeviceState();
    // 根据设备状态控制 LED
}

Board 类聚合 Led 类的对象:在各个 Board 类(如 KevinBoxBoard、CompactWifiBoardLCD 等)中,通过 GetLed() 方法返回一个 Led 类的指针,指向具体的 Led 派生类对象(如 SingleLed)。这表明 Board 类聚合了 Led 类的对象,Board 类可以使用 Led 类提供的接口来控制 LED。

复制代码
class KevinBoxBoard : public WifiBoard{
public:
    virtual Led* GetLed() override {
        static SingleLed led(BUILTIN_LED_GPIO);
        return &led;
    }
};

我们可以看到,这里面基本上实现了大部分场景的灯光需求,如果我们自己的硬件有特殊的灯光需求,我们可以继承Led重新实现即可。

main/display****Display 类说明

main/display 目录下的类主要围绕 Display 类展开,这些类通过继承、组合以及方法调用等方式进行交互,以实现显示功能的管理和控制。

Display 类是核心类,它定义了显示功能的基本接口和状态管理。其主要功能包括:

  • 初始化:在构造函数中加载显示主题、创建通知定时器、更新显示定时器和电源管理锁。

  • 析构:在析构函数中停止并删除定时器,删除相关的显示对象和释放电源管理锁。

  • 功能接口:提供了设置状态、显示通知、设置表情、设置聊天消息、设置图标、设置主题等一系列功能接口。

  • 锁机制:定义了纯虚函数 Lock 和 Unlock,用于实现显示对象的锁定和解锁,需要派生类具体实现。

    Display类构造函数中创建了notification_timer_、update_timer_两个定时器。
    notification_timer_用于更新显示页面中notification_label_和status_label_的显示。
    update_timer_用于根据板卡Board对象更新静音图标、电池图标、网络图标和设备状态。

DisplayLockGuard 类是一个辅助类,用于管理 Display 对象的锁定和解锁。其交互方式如下:

  • 构造函数:在构造时调用 Display 对象的 Lock 方法尝试锁定显示对象,如果锁定失败会输出错误日志。
  • 析构函数:在析构时调用 Display 对象的 Unlock 方法解锁显示对象。

NoDisplay 类继承自 Display 类,是一个特殊的显示类,用于表示没有实际显示设备的情况。其交互方式如下:

  • 重写 Lock 方法:始终返回 true,表示无需锁定。
  • 重写 Unlock 方法:方法体为空,不执行任何操作。

OledDisplay类继承自 Display 类,用于实现基于 SSD1306 OLED 显示屏的显示功能。

  • 构造函数 OledDisplay:初始化 OLED 显示屏和 LVGL 库,并根据分辨率设置 UI。
  • 析构函数 ~OledDisplay:释放相关资源,包括 LVGL 对象和 OLED 面板。
  • SetChatMessage 方法:设置聊天消息的显示内容。

在OledDisplay中适配了SetupUI_128x64和SetupUI_128x32两个分辨率的UI,派生类OledDisplay会重写基类的虚函数,以实现特定显示器的功能。例如,OledDisplay 类重写了 SetChatMessage 方法,通过重写该方法,OledDisplay 类可以根据 OLED 显示器的特点处理聊天消息的显示。

复制代码
void OledDisplay::SetChatMessage(const char* role, const char* content) {
    DisplayLockGuard lock(this);
    if (chat_message_label_ == nullptr) {
        return;
    }
    std::string content_str = content;
    std::replace(content_str.begin(), content_str.end(), '\n', ' ');

    if (content_right_ == nullptr) {
        lv_label_set_text(chat_message_label_, content_str.c_str());
    } else {
        if (content == nullptr || content[0] == '\0') {
            lv_obj_add_flag(content_right_, LV_OBJ_FLAG_HIDDEN);
        } else {
            lv_label_set_text(chat_message_label_, content_str.c_str());
            lv_obj_clear_flag(content_right_, LV_OBJ_FLAG_HIDDEN);
        }
    }
}

LcdDisplay:继承自 Display,是 LCD 显示相关类的基类,包含 LCD 面板的 I/O 句柄和面板句柄等成员,重写基类的虚函数 SetupUI、SetEmotion、SetIcon 等方法。

RgbLcdDisplay、SpiLcdDisplay :继承自LcdDisplay,方便我们根据不同接口的LCD屏幕进行显示部分初始化,在创建具体的显示器对象时,派生类的构造函数会调用基类的构造函数进行初始化。例如,RgbLcdDisplay、SpiLcdDisplay 等类的构造函数会调用 LcdDisplay 的构造函数:

复制代码
// RgbLcdDisplay构造函数
RgbLcdDisplay::RgbLcdDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel,
                             int width, int height, int offset_x, int offset_y,
                             bool mirror_x, bool mirror_y, bool swap_xy,
                             DisplayFonts fonts)
    : LcdDisplay(panel_io, panel, fonts) {
    // ...
}

// SpiLcdDisplay构造函数
SpiLcdDisplay::SpiLcdDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel,
                             int width, int height, int offset_x, int offset_y,
                             bool mirror_x, bool mirror_y, bool swap_xy,
                             DisplayFonts fonts)
    : LcdDisplay(panel_io, panel, fonts) {
    // ...
}

下方是应用创建一个自定义显示屏CustomLcdDisplay的调用步骤:

复制代码
创建 Display 派生类对象(如 CustomLcdDisplay)
    -> 调用基类(如 SpiLcdDisplay)构造函数
        -> 调用基类(如 LcdDisplay)构造函数
            -> 调用基类(如 Display)构造函数
                -> 加载主题设置
                -> 创建通知定时器和更新定时器
                -> 创建电源管理锁
            -> 初始化 LCD 面板句柄和字体
        -> 初始化特定的 LCD 显示设置
    -> 使用 DisplayLockGuard 锁定显示对象
    -> 进行额外的显示设置(如调整样式、添加事件回调)

main/audio_processing****说明

audio_processing主要包含两个类WakeWordDetect和AudioProcessor

WakeWordDetect 类主要负责唤醒词的检测,同时也具备语音活动检测(VAD)的功能。其核心作用是监听音频输入,当检测到预设的唤醒词时,触发相应的操作,并在语音活动状态发生变化时通知系统。

AudioProcessor 类主要用于音频的实时处理,包括语音通信中的音频增强、增益控制等功能。它接收音频数据,对其进行处理,并将处理后的音频数据输出。

这两个类在音频处理流程中相互配合,WakeWordDetect 负责唤醒设备和VAD检测,而 AudioProcessor 负责处理唤醒后,实时交互的音频数据,使得音频变得更干净。

WakeWordDetect****类

在 Initialize 方法中,WakeWordDetect 类会基于乐鑫ESP_SR组件加载唤醒词模型,配置音频前端(AFE)参数,包括回声消除(AEC)、语音增强(SE)、语音活动检测(VAD)和唤醒词检测(WakeNet)等功能,同时启动一个独立的任务 AudioDetectionTask 来执行音频检测。

复制代码
void WakeWordDetect::Initialize(int channels, bool reference) {
    // 加载唤醒词模型
    srmodel_list_t *models = esp_srmodel_init("model");
    for (int i = 0; i < models->num; i++) {
        if (strstr(models->model_name[i], ESP_WN_PREFIX) != NULL) {
            wakenet_model_ = models->model_name[i];
            auto words = esp_srmodel_get_wake_words(models, wakenet_model_);
            std::stringstream ss(words);
            std::string word;
            while (std::getline(ss, word, ';')) {
                wake_words_.push_back(word);
            }
        }
    }

    // 配置AFE参数
    afe_config_t afe_config = {
        // 配置参数...
    };
    afe_detection_data_ = esp_afe_sr_v1.create_from_config(&afe_config);

    // 启动音频检测任务
    xTaskCreate([](void* arg) {
        auto this_ = (WakeWordDetect*)arg;
        this_->AudioDetectionTask();
        vTaskDelete(NULL);
    }, "audio_detection", 4096 * 2, this, 3, nullptr);
}

Feed 方法用于接收音频数据,并将其存储在输入缓冲区中。当缓冲区中的数据量达到一定阈值时,会将数据馈送到 AFE 进行处理。

复制代码
void WakeWordDetect::Feed(const std::vector<int16_t>& data) {
    input_buffer_.insert(input_buffer_.end(), data.begin(), data.end());
    auto feed_size = esp_afe_sr_v1.get_feed_chunksize(afe_detection_data_) * channels_;
    while (input_buffer_.size() >= feed_size) {
        esp_afe_sr_v1.feed(afe_detection_data_, input_buffer_.data());
        input_buffer_.erase(input_buffer_.begin(), input_buffer_.begin() + feed_size);
    }
}

在 AudioDetectionTask 任务中,会不断从 AFE 中获取处理结果。当检测到唤醒词时,会停止检测,并调用 wake_word_detected_callback_ 回调函数通知外部系统。

复制代码
void WakeWordDetect::AudioDetectionTask() {
    while (true) {
        xEventGroupWaitBits(event_group_, DETECTION_RUNNING_EVENT, pdFALSE, pdTRUE, portMAX_DELAY);
        auto res = esp_afe_sr_v1.fetch(afe_detection_data_);
        if (res->wakeup_state == WAKENET_DETECTED) {
            StopDetection();
            last_detected_wake_word_ = wake_words_[res->wake_word_index - 1];
            if (wake_word_detected_callback_) {
                wake_word_detected_callback_(last_detected_wake_word_);
            }
        }
    }
}

同样在 AudioDetectionTask 中,会根据 AFE 返回的 VAD 状态,判断语音活动状态是否发生变化。如果发生变化,会调用 vad_state_change_callback_ 回调函数通知外部系统。

复制代码
void WakeWordDetect::AudioDetectionTask() {
    while (true) {
        // ...
        if (vad_state_change_callback_) {
            if (res->vad_state == AFE_VAD_SPEECH && !is_speaking_) {
                is_speaking_ = true;
                vad_state_change_callback_(true);
            } else if (res->vad_state == AFE_VAD_SILENCE && is_speaking_) {
                is_speaking_ = false;
                vad_state_change_callback_(false);
            }
        }
        // ...
    }
}

WakeWordDetect 类主要与 Application、Protocol 类存在交互关系。

  • Application 包含 WakeWordDetect:Application 类在 Start 方法中初始化 WakeWordDetect 对象,并为其注册回调函数。

    #if CONFIG_USE_WAKE_WORD_DETECT
    wake_word_detect_.Initialize(codec->input_channels(), codec->input_reference());
    #endif

    wake_word_detect_.OnVadStateChange([this](bool speaking) {
    Schedule(this, speaking {
    if (device_state_ == kDeviceStateListening) {
    if (speaking) {
    voice_detected_ = true;
    } else {
    voice_detected_ = false;
    }
    auto led = Board::GetInstance().GetLed();
    led->OnStateChanged();
    }
    });
    });

    wake_word_detect_.OnWakeWordDetected([this](const std::string& wake_word) {
    Schedule(this, &wake_word {
    if (device_state_ == kDeviceStateIdle) {
    xxxx
    } else if (device_state_ == kDeviceStateSpeaking) {
    xxxx
    } else if (device_state_ == kDeviceStateActivating) {
    xxxx
    }
    // Resume detection
    wake_word_detect_.StartDetection();
    });
    });

  • WakeWordDetect 调用 Application 的回调函数:当 WakeWordDetect 检测到唤醒词或语音活动状态变化时,会调用 Application 注册的回调函数。

  • Application 调用 Protocol 的方法:Application 在接收到 WakeWordDetect 的回调后,会调用 Protocol 的方法将唤醒词数据发送到服务器。

    if (!protocol_->OpenAudioChannel()) {
    wake_word_detect_.StartDetection();
    return;
    }

    std::vector<uint8_t> opus;
    // Encode and send the wake word data to the server
    while (wake_word_detect_.GetWakeWordOpus(opus)) {
    protocol_->SendAudio(opus);
    }
    // Set the chat state to wake word detected
    protocol_->SendWakeWordDetected(wake_word);

AudioProcessor****类

在 Initialize 方法中,AudioProcessor 类会配置 AFE 参数,包括语音通信相关的功能,如自动增益控制(AGC)等,同时启动一个独立的任务 AudioProcessorTask 来执行音频处理。

复制代码
void AudioProcessor::Initialize(int channels, bool reference) {
    channels_ = channels;
    reference_ = reference;
    int ref_num = reference_ ? 1 : 0;

    afe_config_t afe_config = {
        .aec_init = false,
        .se_init = true,
        .vad_init = false,
        .wakenet_init = false,
        // 其他配置参数...
    };

    afe_communication_data_ = esp_afe_vc_v1.create_from_config(&afe_config);

    xTaskCreate([](void* arg) {
        auto this_ = (AudioProcessor*)arg;
        this_->AudioProcessorTask();
        vTaskDelete(NULL);
    }, "audio_communication", 4096 * 2, this, 3, NULL);
}

Input 方法用于接收音频数据,并将其存储在输入缓冲区中。当缓冲区中的数据量达到一定阈值时,会将数据馈送到 AFE 进行处理。

复制代码
void AudioProcessor::Input(const std::vector<int16_t>& data) {
    input_buffer_.insert(input_buffer_.end(), data.begin(), data.end());
    auto feed_size = esp_afe_vc_v1.get_feed_chunksize(afe_communication_data_) * channels_;
    while (input_buffer_.size() >= feed_size) {
        auto chunk = input_buffer_.data();
        esp_afe_vc_v1.feed(afe_communication_data_, chunk);
        input_buffer_.erase(input_buffer_.begin(), input_buffer_.begin() + feed_size);
    }
}

在 AudioProcessorTask 任务中,会不断从 AFE 中获取处理后的音频数据,并调用 output_callback_ 回调函数将数据输出给外部系统。

复制代码
void AudioProcessor::AudioProcessorTask() {
    while (true) {
        xEventGroupWaitBits(event_group_, PROCESSOR_RUNNING, pdFALSE, pdTRUE, portMAX_DELAY);
        auto res = esp_afe_vc_v1.fetch(afe_communication_data_);
        if (res != nullptr && res->ret_value != ESP_FAIL) {
            if (output_callback_) {
                output_callback_(std::vector<int16_t>(res->data, res->data + res->data_size / sizeof(int16_t)));
            }
        }
    }
}

AudioProcessor类主要与 Application、Protocol 类存在交互关系。

  • Application 包含 AudioProcessor:Application 类在 Start 方法中初始化 AudioProcessor对象,并为其注册回调函数OnOutput。

    复制代码
      audio_processor_.Initialize(codec->input_channels(), codec->input_reference());
      audio_processor_.OnOutput([this](std::vector<int16_t>&& data) {
          background_task_->Schedule([this, data = std::move(data)]() mutable {
              opus_encoder_->Encode(std::move(data), [this](std::vector<uint8_t>&& opus) {
                  Schedule([this, opus = std::move(opus)]() {
                      protocol_->SendAudio(opus);
                  });
              });
          });
      });
  • Application 主循环中通过codec采集音频,采集的音频发送到AudioProcessor中进行音频处理,当然,如果不开启CONFIG_USE_AUDIO_PROCESSOR,则会直接把音频发送到服务器。

    void Application::InputAudio() {
    xxxx
    #if CONFIG_USE_AUDIO_PROCESSOR
    if (audio_processor_.IsRunning()) {
    audio_processor_.Input(data);
    }
    #else
    if (device_state_ == kDeviceStateListening) {
    background_task_->Schedule(this, data = std::move(data) mutable {
    opus_encoder_->Encode(std::move(data), [this](std::vector<uint8_t>&& opus) {
    Schedule(this, opus = std::move(opus) {
    protocol_->SendAudio(opus);
    });
    });
    });
    }
    #endif
    }

  • AudioProcessor处理完的音频回调,通过 AudioProcessor 发送到服务器。

    复制代码
      audio_processor_.OnOutput([this](std::vector<int16_t>&& data) {
          background_task_->Schedule([this, data = std::move(data)]() mutable {
              opus_encoder_->Encode(std::move(data), [this](std::vector<uint8_t>&& opus) {
                  Schedule([this, opus = std::move(opus)]() {
                      protocol_->SendAudio(opus);
                  });
              });
          });
      });

main/audio_codecs****AudioCodec类说明

AudioCodec:定义了音频编解码器的通用接口和功能,包含设置音量、启用输入输出、启动编解码器等方法,同时定义了纯虚函数 Read 和 Write 供派生类实现。

  • NoAudioCodec 类:继承自 AudioCodec,是一个无音频编解码器的基类,实现了基本的读写方法。
  • NoAudioCodecDuplex 类:继承自 NoAudioCodec,用于双工模式下的无音频编解码。
  • NoAudioCodecSimplex 类:继承自 NoAudioCodec,用于单工模式下的无音频编解码。
  • NoAudioCodecSimplexPdm 类:继承自 NoAudioCodec,用于单工 PDM 模式下的无音频编解码。
  • ATK_NoAudioCodecDuplex 类:继承自 NoAudioCodec,是特定的双工无音频编解码器。
  • Es8311AudioCodec 类:继承自 AudioCodec,是基于 ES8311 芯片的音频编解码器。
  • Es8388AudioCodec 类:继承自 AudioCodec,是基于 ES8388 芯片的音频编解码器。

这些类继承自 AudioCodec 类,并实现了 Read 和 Write 方法,以实现具体的音频编解码功能。

AudioCodec基类中的方法通过多态调用派生类的实现,以完成音频数据的输入和输出,同时,AudioCodec 类提供了事件回调机制,通过 OnInputReady 和 OnOutputReady 方法注册回调函数,当音频输入或输出准备好时,会触发相应的回调函数。

main/protocols****Protocol 类说明

main/protocols 目录下的代码中,主要定义了 Protocol 基类以及两个派生类 WebsocketProtocol 和 MqttProtocol

  • Protocol 类:作为基类,定义了网络通信协议的通用接口和功能,如处理音频数据、JSON 数据、网络错误,以及启动、打开和关闭音频通道等操作。
  • WebsocketProtocol 类:继承自 Protocol 类,使用 WebSocket 协议实现具体的通信功能。
  • MqttProtocol 类:同样继承自 Protocol 类,使用 MQTT 协议实现具体的通信功能。

具体的websocket协议内容可以参考

复制代码
以下是一份基于代码实现整理的 WebSocket 通信协议文档,概述客户端(设备)与服务器之间如何通过 WebSocket 进行交互。该文档仅基于所提供的代码推断,实际部署时可能需要结合服务器端实现进行进一步确认或补充。

---

## 1. 总体流程概览

1. **设备端初始化** 
   - 设备上电、初始化 `Application`: 
     - 初始化音频编解码器、显示屏、LED 等 
     - 连接网络 
     - 创建并初始化实现 `Protocol` 接口的 WebSocket 协议实例(`WebsocketProtocol`) 
   - 进入主循环等待事件(音频输入、音频输出、调度任务等)。

2. **建立 WebSocket 连接** 
   - 当设备需要开始语音会话时(例如用户唤醒、手动按键触发等),调用 `OpenAudioChannel()`: 
     - 根据编译配置获取 WebSocket URL(`CONFIG_WEBSOCKET_URL`) 
     - 设置若干请求头(`Authorization`, `Protocol-Version`, `Device-Id`, `Client-Id`) 
     - 调用 `Connect()` 与服务器建立 WebSocket 连接 

3. **发送客户端 "hello" 消息** 
   - 连接成功后,设备会发送一条 JSON 消息,示例结构如下: 
   ```json
   {
     "type": "hello",
     "version": 1,
     "transport": "websocket",
     "audio_params": {
       "format": "opus",
       "sample_rate": 16000,
       "channels": 1,
       "frame_duration": 60
     }
   }
   ```
   - 其中 `"frame_duration"` 的值对应 `OPUS_FRAME_DURATION_MS`(例如 60ms)。

4. **服务器回复 "hello"** 
   - 设备等待服务器返回一条包含 `"type": "hello"` 的 JSON 消息,并检查 `"transport": "websocket"` 是否匹配。 
   - 如果匹配,则认为服务器已就绪,标记音频通道打开成功。 
   - 如果在超时时间(默认 10 秒)内未收到正确回复,认为连接失败并触发网络错误回调。

5. **后续消息交互** 
   - 设备端和服务器端之间可发送两种主要类型的数据: 
     1. **二进制音频数据**(Opus 编码) 
     2. **文本 JSON 消息**(用于传输聊天状态、TTS/STT 事件、IoT 命令等) 

   - 在代码里,接收回调主要分为: 
     - `OnData(...)`: 
       - 当 `binary` 为 `true` 时,认为是音频帧;设备会将其当作 Opus 数据进行解码。 
       - 当 `binary` 为 `false` 时,认为是 JSON 文本,需要在设备端用 cJSON 进行解析并做相应业务逻辑处理(见下文消息结构)。 

   - 当服务器或网络出现断连,回调 `OnDisconnected()` 被触发: 
     - 设备会调用 `on_audio_channel_closed_()`,并最终回到空闲状态。

6. **关闭 WebSocket 连接** 
   - 设备在需要结束语音会话时,会调用 `CloseAudioChannel()` 主动断开连接,并回到空闲状态。 
   - 或者如果服务器端主动断开,也会引发同样的回调流程。

---

## 2. 通用请求头

在建立 WebSocket 连接时,代码示例中设置了以下请求头:

- `Authorization`: 用于存放访问令牌,形如 `"Bearer <token>"` 
- `Protocol-Version`: 固定示例中为 `"1"` 
- `Device-Id`: 设备物理网卡 MAC 地址 
- `Client-Id`: 设备 UUID(可在应用中唯一标识设备)

这些头会随着 WebSocket 握手一起发送到服务器,服务器可根据需求进行校验、认证等。

---

## 3. JSON 消息结构

WebSocket 文本帧以 JSON 方式传输,以下为常见的 `"type"` 字段及其对应业务逻辑。若消息里包含未列出的字段,可能为可选或特定实现细节。

### 3.1 客户端→服务器

1. **Hello** 
   - 连接成功后,由客户端发送,告知服务器基本参数。 
   - 例:
     ```json
     {
       "type": "hello",
       "version": 1,
       "transport": "websocket",
       "audio_params": {
         "format": "opus",
         "sample_rate": 16000,
         "channels": 1,
         "frame_duration": 60
       }
     }
     ```

2. **Listen** 
   - 表示客户端开始或停止录音监听。 
   - 常见字段: 
     - `"session_id"`:会话标识 
     - `"type": "listen"` 
     - `"state"`:`"start"`, `"stop"`, `"detect"`(唤醒检测已触发) 
     - `"mode"`:`"auto"`, `"manual"` 或 `"realtime"`,表示识别模式。 
   - 例:开始监听 
     ```json
     {
       "session_id": "xxx",
       "type": "listen",
       "state": "start",
       "mode": "manual"
     }
     ```

3. **Abort** 
   - 终止当前说话(TTS 播放)或语音通道。 
   - 例:
     ```json
     {
       "session_id": "xxx",
       "type": "abort",
       "reason": "wake_word_detected"
     }
     ```
   - `reason` 值可为 `"wake_word_detected"` 或其他。

4. **Wake Word Detected** 
   - 用于客户端向服务器告知检测到唤醒词。 
   - 例:
     ```json
     {
       "session_id": "xxx",
       "type": "listen",
       "state": "detect",
       "text": "你好小明"
     }
     ```

5. **IoT** 
   - 发送当前设备的物联网相关信息: 
     - **Descriptors**(描述设备功能、属性等) 
     - **States**(设备状态的实时更新) 
   - 例: 
     ```json
     {
       "session_id": "xxx",
       "type": "iot",
       "descriptors": { ... }
     }
     ```
     或
     ```json
     {
       "session_id": "xxx",
       "type": "iot",
       "states": { ... }
     }
     ```

---

### 3.2 服务器→客户端

1. **Hello** 
   - 服务器端返回的握手确认消息。 
   - 必须包含 `"type": "hello"` 和 `"transport": "websocket"`。 
   - 可能会带有 `audio_params`,表示服务器期望的音频参数,或与客户端对齐的配置。 
   - 成功接收后客户端会设置事件标志,表示 WebSocket 通道就绪。

2. **STT** 
   - `{"type": "stt", "text": "..."}`
   - 表示服务器端识别到了用户语音。(例如语音转文本结果) 
   - 设备可能将此文本显示到屏幕上,后续再进入回答等流程。

3. **LLM** 
   - `{"type": "llm", "emotion": "happy", "text": "😀"}`
   - 服务器指示设备调整表情动画 / UI 表达。 

4. **TTS** 
   - `{"type": "tts", "state": "start"}`:服务器准备下发 TTS 音频,客户端进入 "speaking" 播放状态。 
   - `{"type": "tts", "state": "stop"}`:表示本次 TTS 结束。 
   - `{"type": "tts", "state": "sentence_start", "text": "..."}`
     - 让设备在界面上显示当前要播放或朗读的文本片段(例如用于显示给用户)。 

5. **IoT** 
   - `{"type": "iot", "commands": [ ... ]}`
   - 服务器向设备发送物联网的动作指令,设备解析并执行(如打开灯、设置温度等)。

6. **音频数据:二进制帧** 
   - 当服务器发送音频二进制帧(Opus 编码)时,客户端解码并播放。 
   - 若客户端正在处于 "listening" (录音)状态,收到的音频帧会被忽略或清空以防冲突。

---

## 4. 音频编解码

1. **客户端发送录音数据** 
   - 音频输入经过可能的回声消除、降噪或音量增益后,通过 Opus 编码打包为二进制帧发送给服务器。 
   - 如果客户端每次编码生成的二进制帧大小为 N 字节,则会通过 WebSocket 的 **binary** 消息发送这块数据。

2. **客户端播放收到的音频** 
   - 收到服务器的二进制帧时,同样认定是 Opus 数据。 
   - 设备端会进行解码,然后交由音频输出接口播放。 
   - 如果服务器的音频采样率与设备不一致,会在解码后再进行重采样。

---

## 5. 常见状态流转

以下简述设备端关键状态流转,与 WebSocket 消息对应:

1. **Idle** → **Connecting** 
   - 用户触发或唤醒后,设备调用 `OpenAudioChannel()` → 建立 WebSocket 连接 → 发送 `"type":"hello"`。 

2. **Connecting** → **Listening** 
   - 成功建立连接后,若继续执行 `SendStartListening(...)`,则进入录音状态。此时设备会持续编码麦克风数据并发送到服务器。 

3. **Listening** → **Speaking** 
   - 收到服务器 TTS Start 消息 (`{"type":"tts","state":"start"}`) → 停止录音并播放接收到的音频。 

4. **Speaking** → **Idle** 
   - 服务器 TTS Stop (`{"type":"tts","state":"stop"}`) → 音频播放结束。若未继续进入自动监听,则返回 Idle;如果配置了自动循环,则再度进入 Listening。 

5. **Listening** / **Speaking** → **Idle**(遇到异常或主动中断) 
   - 调用 `SendAbortSpeaking(...)` 或 `CloseAudioChannel()` → 中断会话 → 关闭 WebSocket → 状态回到 Idle。 

---

## 6. 错误处理

1. **连接失败** 
   - 如果 `Connect(url)` 返回失败或在等待服务器 "hello" 消息时超时,触发 `on_network_error_()` 回调。设备会提示"无法连接到服务"或类似错误信息。

2. **服务器断开** 
   - 如果 WebSocket 异常断开,回调 `OnDisconnected()`: 
     - 设备回调 `on_audio_channel_closed_()` 
     - 切换到 Idle 或其他重试逻辑。

---

## 7. 其它注意事项

1. **鉴权** 
   - 设备通过设置 `Authorization: Bearer <token>` 提供鉴权,服务器端需验证是否有效。 
   - 如果令牌过期或无效,服务器可拒绝握手或在后续断开。

2. **会话控制** 
   - 代码中部分消息包含 `session_id`,用于区分独立的对话或操作。服务端可根据需要对不同会话做分离处理,WebSocket 协议为空。

3. **音频负载** 
   - 代码里默认使用 Opus 格式,并设置 `sample_rate = 16000`,单声道。帧时长由 `OPUS_FRAME_DURATION_MS` 控制,一般为 60ms。可根据带宽或性能做适当调整。

4. **IoT 指令** 
   - `"type":"iot"` 的消息用户端代码对接 `thing_manager` 执行具体命令,因设备定制而不同。服务器端需确保下发格式与客户端保持一致。

5. **错误或异常 JSON** 
   - 当 JSON 中缺少必要字段,例如 `{"type": ...}`,客户端会记录错误日志(`ESP_LOGE(TAG, "Missing message type, data: %s", data);`),不会执行任何业务。

---

## 8. 消息示例

下面给出一个典型的双向消息示例(流程简化示意):

1. **客户端 → 服务器**(握手)
   ```json
   {
     "type": "hello",
     "version": 1,
     "transport": "websocket",
     "audio_params": {
       "format": "opus",
       "sample_rate": 16000,
       "channels": 1,
       "frame_duration": 60
     }
   }
   ```

2. **服务器 → 客户端**(握手应答)
   ```json
   {
     "type": "hello",
     "transport": "websocket",
     "audio_params": {
       "sample_rate": 16000
     }
   }
   ```

3. **客户端 → 服务器**(开始监听)
   ```json
   {
     "session_id": "",
     "type": "listen",
     "state": "start",
     "mode": "auto"
   }
   ```
   同时客户端开始发送二进制帧(Opus 数据)。

4. **服务器 → 客户端**(ASR 结果)
   ```json
   {
     "type": "stt",
     "text": "用户说的话"
   }
   ```

5. **服务器 → 客户端**(TTS开始)
   ```json
   {
     "type": "tts",
     "state": "start"
   }
   ```
   接着服务器发送二进制音频帧给客户端播放。

6. **服务器 → 客户端**(TTS结束)
   ```json
   {
     "type": "tts",
     "state": "stop"
   }
   ```
   客户端停止播放音频,若无更多指令,则回到空闲状态。

---

## 9. 总结

本协议通过在 WebSocket 上层传输 JSON 文本与二进制音频帧,完成功能包括音频流上传、TTS 音频播放、语音识别与状态管理、IoT 指令下发等。其核心特征:

- **握手阶段**:发送 `"type":"hello"`,等待服务器返回。 
- **音频通道**:采用 Opus 编码的二进制帧双向传输语音流。 
- **JSON 消息**:使用 `"type"` 为核心字段标识不同业务逻辑,包括 TTS、STT、IoT、WakeWord 等。 
- **扩展性**:可根据实际需求在 JSON 消息中添加字段,或在 headers 里进行额外鉴权。

服务器与客户端需提前约定各类消息的字段含义、时序逻辑以及错误处理规则,方能保证通信顺畅。上述信息可作为基础文档,便于后续对接、开发或扩展。

main/boards****Board 类说明

boards文件夹中,包含了所有已经适配过的板卡,因为每个板卡的外设、IO、资源不一样,所以和板卡有关的模块都可以放在此文件夹中进行初始化。

Board****类

Board 类是一个抽象基类,它定义了一系列纯虚函数,为不同类型的开发板提供了统一的接口。WifiBoard 和 Ml307Board 类继承自 Board 类,分别实现了通过 WiFi 和 ML307(4G模组)进行网络连接的功能。

Board 类:

  • 作为抽象基类,包含了一系列纯虚函数,如 GetBoardType、GetAudioCodec 等,派生类需要实现这些函数。
  • 包含了一些通用的成员变量和方法,如 uuid_ 和 GenerateUuid 方法,用于生成设备的唯一标识。
  • 提供了 GetInstance 静态方法,用于获取 Board 类的单例对象。

WifiBoard 类:

  • 继承自 Board 类,实现了通过 WiFi 进行网络连接的功能。
  • 包含一个 wifi_config_mode_ 成员变量,用于表示是否进入 WiFi 配置模式。
  • 提供了 EnterWifiConfigMode 和 ResetWifiConfiguration 方法,用于进入和重置 WiFi 配置。

Ml307Board 类:

  • 继承自 Board 类,使用 ML307 调制解调器进行网络连接。
  • 包含一个 modem_ 成员变量,用于管理 ML307 调制解调器。
  • 提供了 WaitForNetworkReady 方法,用于等待网络连接就绪。

当前有了WiFi和4G两个类,那我们就可以根据自己的板卡类型,继承对应类实现我们板卡具体外设了,例如多合一板卡,使用的Code是ES8311,屏幕是ST7789,分辨率是172*320,我们就可以创建XiaozhiAioS3类继承WifiBoard,然后初始化对应模块的驱动即可。

I2cDevice****类

I2cDevice 类用于与 I2C 设备进行通信。

复制代码
class I2cDevice {
public:
    I2cDevice(i2c_master_bus_handle_t i2c_bus, uint8_t addr);

protected:
    i2c_master_dev_handle_t i2c_device_;

    void WriteReg(uint8_t reg, uint8_t value);
    uint8_t ReadReg(uint8_t reg);
    void ReadRegs(uint8_t reg, uint8_t* buffer, size_t length);
};
  • 在构造函数中初始化 I2C 设备。
  • 提供 WriteReg、ReadReg 和 ReadRegs 方法,用于读写 I2C 设备的寄存器
Button****类

Button 类用于处理按键事件。

复制代码
class Button {
public:
#if CONFIG_SOC_ADC_SUPPORTED
    Button(const button_adc_config_t& cfg);
#endif
    Button(gpio_num_t gpio_num, bool active_high = false);
    ~Button();

    void OnPressDown(std::function<void()> callback);
    void OnPressUp(std::function<void()> callback);
    void OnLongPress(std::function<void()> callback);
    void OnClick(std::function<void()> callback);
    void OnDoubleClick(std::function<void()> callback);
private:
    gpio_num_t gpio_num_;
    button_handle_t button_handle_ = nullptr;

    std::function<void()> on_press_down_;
    std::function<void()> on_press_up_;
    std::function<void()> on_long_press_;
    std::function<void()> on_click_;
    std::function<void()> on_double_click_;
};
  • 通过构造函数初始化按键。
  • 使用 OnPressDown、OnPressUp、OnLongPress、OnClick 和 OnDoubleClick 方法注册按键事件的回调函数。