From 69b1a978e9624229d2324e68649efd6f118343f6 Mon Sep 17 00:00:00 2001 From: Yeqin Gong Date: Thu, 16 Apr 2026 19:31:53 +0800 Subject: [PATCH] Add NULLLAB-AI-VOX3 Board (#1900) --- main/CMakeLists.txt | 51 ++-- main/Kconfig.projbuild | 9 +- main/boards/nulllab-ai-vox-v3/README.md | 29 +++ .../nulllab-ai-vox-v3/ai_vox3_audio_codec.h | 237 ++++++++++++++++++ .../boards/nulllab-ai-vox-v3/ai_vox3_board.cc | 205 +++++++++++++++ main/boards/nulllab-ai-vox-v3/config.h | 54 ++++ main/boards/nulllab-ai-vox-v3/config.json | 9 + main/boards/nulllab-ai-vox-v3/power_manager.h | 150 +++++++++++ 8 files changed, 718 insertions(+), 26 deletions(-) create mode 100644 main/boards/nulllab-ai-vox-v3/README.md create mode 100644 main/boards/nulllab-ai-vox-v3/ai_vox3_audio_codec.h create mode 100644 main/boards/nulllab-ai-vox-v3/ai_vox3_board.cc create mode 100644 main/boards/nulllab-ai-vox-v3/config.h create mode 100644 main/boards/nulllab-ai-vox-v3/config.json create mode 100644 main/boards/nulllab-ai-vox-v3/power_manager.h diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index f64701d..a1fdd00 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -160,7 +160,7 @@ elseif(CONFIG_BOARD_TYPE_LICHUANG_DEV_C3) set(DEFAULT_EMOJI_COLLECTION twemoji_32) elseif(CONFIG_BOARD_TYPE_EDA_TV_PRO) set(MANUFACTURER "lceda-course-examples") - set(BOARD_TYPE "eda-tv-pro") + set(BOARD_TYPE "eda-tv-pro") elseif(CONFIG_BOARD_TYPE_EDA_ROBOT_PRO) set(MANUFACTURER "lceda-course-examples") set(BOARD_TYPE "eda-robot-pro") @@ -639,12 +639,12 @@ elseif(CONFIG_BOARD_TYPE_XINGZHI_METAL_1_54_WIFI) set(BOARD_TYPE "xingzhi-metal-1.54-wifi") set(BUILTIN_TEXT_FONT font_noto_basic_20_4) set(BUILTIN_ICON_FONT font_awesome_20_4) - set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) elseif(CONFIG_BOARD_TYPE_XINGZHI_ABS_2_0) set(BOARD_TYPE "xingzhi-abs-2.0") set(BUILTIN_TEXT_FONT font_noto_basic_20_4) set(BUILTIN_ICON_FONT font_awesome_20_4) - set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) elseif(CONFIG_BOARD_TYPE_SEEED_STUDIO_SENSECAP_WATCHER) set(BOARD_TYPE "sensecap-watcher") set(BUILTIN_TEXT_FONT font_noto_basic_30_4) @@ -704,7 +704,7 @@ elseif(CONFIG_BOARD_TYPE_ZHENGCHEN_CAM_ML307) set(BOARD_TYPE "zhengchen-cam-ml307") set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) set(BUILTIN_ICON_FONT font_awesome_20_4) - set(DEFAULT_EMOJI_COLLECTION twemoji_64) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) elseif(CONFIG_BOARD_TYPE_SPOTPEAR_ESP32_S3_1_54_MUMA) set(BOARD_TYPE "sp-esp32-s3-1.54-muma") set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) @@ -774,6 +774,11 @@ elseif(CONFIG_BOARD_TYPE_Freenove_ESP32S3_DISPLAY_2_8_LCD) set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) set(BUILTIN_ICON_FONT font_awesome_20_4) set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_AI_VOX3) + set(BOARD_TYPE "nulllab-ai-vox-v3") + set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) endif() if(MANUFACTURER) @@ -895,14 +900,14 @@ file(GLOB LANG_SOUNDS ${CMAKE_CURRENT_SOURCE_DIR}/assets/locales/${LANG_DIR}/*.o # If not en-US, collect en-US audio files as fallback for missing files if(NOT LANG_DIR STREQUAL "en-US") file(GLOB EN_US_SOUNDS ${CMAKE_CURRENT_SOURCE_DIR}/assets/locales/en-US/*.ogg) - + # Extract filenames (without path) from current language set(EXISTING_NAMES "") foreach(SOUND_FILE ${LANG_SOUNDS}) get_filename_component(FILENAME ${SOUND_FILE} NAME) list(APPEND EXISTING_NAMES ${FILENAME}) endforeach() - + # Only add en-US audio files that are missing in current language foreach(EN_SOUND ${EN_US_SOUNDS}) get_filename_component(FILENAME ${EN_SOUND} NAME) @@ -1033,7 +1038,7 @@ list(APPEND FILES_TO_DOWNLOAD "panic_return.aaf" "wake.aaf") foreach(FILENAME IN LISTS FILES_TO_DOWNLOAD) set(REMOTE_FILE "${URL}/${FILENAME}") set(LOCAL_FILE "${EMOJI_DIR}/${FILENAME}") - + # Check if local file exists if(EXISTS ${LOCAL_FILE}) message(STATUS "File ${FILENAME} already exists, skipping download") @@ -1055,31 +1060,31 @@ endif() function(build_default_assets_bin) # Set output path for generated assets.bin set(GENERATED_ASSETS_BIN "${CMAKE_BINARY_DIR}/generated_assets.bin") - + # Prepare arguments for build script set(BUILD_ARGS "--sdkconfig" "${SDKCONFIG}" "--output" "${GENERATED_ASSETS_BIN}" ) - + # Add builtin text font if defined if(BUILTIN_TEXT_FONT) list(APPEND BUILD_ARGS "--builtin_text_font" "${BUILTIN_TEXT_FONT}") endif() - + # Add default emoji collection if defined if(DEFAULT_EMOJI_COLLECTION) list(APPEND BUILD_ARGS "--emoji_collection" "${DEFAULT_EMOJI_COLLECTION}") endif() - + # Add default assets extra files if defined if(DEFAULT_ASSETS_EXTRA_FILES) list(APPEND BUILD_ARGS "--extra_files" "${DEFAULT_ASSETS_EXTRA_FILES}") endif() - + list(APPEND BUILD_ARGS "--esp_sr_model_path" "${ESP_SR_MODEL_PATH}") list(APPEND BUILD_ARGS "--xiaozhi_fonts_path" "${XIAOZHI_FONTS_PATH}") - + # Create custom command to build assets add_custom_command( OUTPUT ${GENERATED_ASSETS_BIN} @@ -1090,15 +1095,15 @@ function(build_default_assets_bin) COMMENT "Building default assets.bin based on configuration" VERBATIM ) - + # Create target for generated assets add_custom_target(generated_default_assets ALL DEPENDS ${GENERATED_ASSETS_BIN} ) - + # Set the generated file path in parent scope set(GENERATED_ASSETS_LOCAL_FILE ${GENERATED_ASSETS_BIN} PARENT_SCOPE) - + message(STATUS "Default assets build configured: ${GENERATED_ASSETS_BIN}") endfunction() @@ -1111,18 +1116,18 @@ function(get_assets_local_file assets_source assets_local_file_var) get_filename_component(ASSETS_FILENAME "${assets_source}" NAME) set(ASSETS_LOCAL_FILE "${CMAKE_BINARY_DIR}/${ASSETS_FILENAME}") set(ASSETS_TEMP_FILE "${CMAKE_BINARY_DIR}/${ASSETS_FILENAME}.tmp") - + # Check if local file exists if(EXISTS ${ASSETS_LOCAL_FILE}) message(STATUS "Assets file ${ASSETS_FILENAME} already exists, skipping download") else() message(STATUS "Downloading ${ASSETS_FILENAME}") - + # Clean up any existing temp file if(EXISTS ${ASSETS_TEMP_FILE}) file(REMOVE ${ASSETS_TEMP_FILE}) endif() - + # Download to temporary file first file(DOWNLOAD ${assets_source} ${ASSETS_TEMP_FILE} STATUS DOWNLOAD_STATUS) @@ -1134,7 +1139,7 @@ function(get_assets_local_file assets_source assets_local_file_var) endif() message(FATAL_ERROR "Failed to download ${ASSETS_FILENAME} from ${assets_source}") endif() - + # Move temp file to final location (atomic operation) file(RENAME ${ASSETS_TEMP_FILE} ${ASSETS_LOCAL_FILE}) message(STATUS "Successfully downloaded ${ASSETS_FILENAME}") @@ -1146,15 +1151,15 @@ function(get_assets_local_file assets_source assets_local_file_var) else() set(ASSETS_LOCAL_FILE "${CMAKE_CURRENT_SOURCE_DIR}/${assets_source}") endif() - + # Check if local file exists if(NOT EXISTS ${ASSETS_LOCAL_FILE}) message(FATAL_ERROR "Assets file not found: ${ASSETS_LOCAL_FILE}") endif() - + message(STATUS "Using assets file: ${ASSETS_LOCAL_FILE}") endif() - + set(${assets_local_file_var} ${ASSETS_LOCAL_FILE} PARENT_SCOPE) endfunction() diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 6e2f37f..9d5ca3b 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -130,7 +130,7 @@ choice BOARD_TYPE depends on IDF_TARGET_ESP32S3 config BOARD_TYPE_BREAD_COMPACT_WIFI_CAM bool "Bread Compact WiFi + LCD + Camera (面包板)" - depends on IDF_TARGET_ESP32S3 + depends on IDF_TARGET_ESP32S3 config BOARD_TYPE_BREAD_COMPACT_ML307 bool "Bread Compact ML307/EC801E (面包板 4G)" depends on IDF_TARGET_ESP32S3 @@ -542,6 +542,9 @@ choice BOARD_TYPE config BOARD_TYPE_Freenove_ESP32S3_DISPLAY_2_8_LCD bool "Freenove ESP32S3 Display 2.8 LCD" depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_AI_VOX3 + bool "NULLLAB-AI-VOX3" + depends on IDF_TARGET_ESP32S3 endchoice choice @@ -674,7 +677,7 @@ endchoice choice DISPLAY_ESP32S3_TOUCH_LCD_1_85C depends on BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_85C prompt "ESP32S3_TOUCH_LCD_1_85C version" - default VERSION_2_0 + default VERSION_2_0 help hardware version config VERSION_1_0 @@ -802,7 +805,7 @@ config USE_DEVICE_AEC || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_3_4C || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4C || BOARD_TYPE_ESP_S3_LCD_EV_Board_2 || BOARD_TYPE_YUNLIAO_S3 \ || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_7 || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_8 || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_10_1 \ || BOARD_TYPE_ESP_VOCAT || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_3_49 || BOARD_TYPE_WAVESHARE_ESP32_S3_RLCD_4_2 || BOARD_TYPE_ZHENGCHEN_CAM || BOARD_TYPE_ZHENGCHEN_CAM_ML307 \ - || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_4_3C || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_54 || BOARD_TYPE_WAVESHARE_ESP32_S3_LCD_0_85) + || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_4_3C || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_54 || BOARD_TYPE_WAVESHARE_ESP32_S3_LCD_0_85 || BOARD_TYPE_AI_VOX3) help To work properly, device-side AEC requires a clean output reference path from the speaker signal and physical acoustic isolation between the microphone and speaker. diff --git a/main/boards/nulllab-ai-vox-v3/README.md b/main/boards/nulllab-ai-vox-v3/README.md new file mode 100644 index 0000000..b65e960 --- /dev/null +++ b/main/boards/nulllab-ai-vox-v3/README.md @@ -0,0 +1,29 @@ +# NULLLAB-AI-VOX3 + +## 概述 + +AI-VOX3是AI VOX的升级款,是一款专为AI语音交互应用设计的高性能嵌入式开发板。其核心采用ESP32-S3-R8芯片,并板载16MB Flash存储器。集成了丰富的硬件资源,支持快速开发与灵活扩展。集五合一功能(AI聊天/天气时钟/无线对讲机MP3音乐播放器/网络电台),同时支持本地语音唤醒、指令识别和语音合成,可广泛应用于智能家居、教育设备和物联网终端等领域。其PCB尺寸兼容乐高插销,可直接安装在积木C款上,便于DIY搭建,同时配套AI-VOX3扩展板与MD40电机驱动板,助力开发者基于板载资源快速构建原型,并通过丰富接口实现个性化功能扩展,大幅缩短开发周期。 + +## 功能特点 + +- 搭载 ESP32-S3R8 高性能 Xtensa 32 位 LX7 双核处理器,主频高达240MHz +- 支持 2.4 GHz Wi-Fi (802.11 b/g/n) 和 Bluetooth 5 (LE),板载天线 +- ESP32-S3R8芯片内置 512 KB SRAM 和 384 KB ROM以及8MB PSRAM,板载16 MB Flash 存储芯片 +- 采用 Type-C 接口,支持程序下载、板载供电及锂电池充电,兼容主流开发环境,简化开发与电源管理流程 +- 采用电源复位按键二合一,将系统复位和开关机集成到Power按键中,短按开机或系统复位,长按关机,简化操作 +- 可接1.54寸240×240分辨率SPI接口LCD(ST7789),提供直观图形化交互界面 +- 预留LCD排线和OLED插口,可以选择OLED或者LCD彩屏显示 +- 板载ES8311音频编码解码器与3W音频放大器(NS4150B),支持高保真音频输入/输出,需外接喇叭 +- 双麦克风设计,板载模拟麦克风,还可以外挂模拟麦克风,支持单麦打断 +- 板载SD Card接口,支持大容量存储扩展 +- 板载BOOT按键、2个按键(GPIO46/45)及WS2812B RGB灯,便于交互调试与状态指示 +- 引出一组8个GPIO排针接口(GPIO43/44/42/48/4/3/2/1),支持多种外设接入 +- 预留一个4pin PH2.0接口,可以方便通过PH2.0供电,也可以和其他主控通讯 +- 配套外接AI-VOX3扩展板,可通过其扩展板的排针接口扩展更多功能 +- 配套外接MD40电机驱动板,可通过其驱动板运行多个电机 +- 板载充电升压5V 2.4A输出一体电路,支持外接锂电池供电,并通过IO18 ADC实时检测电量 +- 支持ESP-IDF、Arduino IDE、AilyBlockly + +## Power按键说明 + +AI-VOX3取消了传统的Reset复位按键,改为使用Power按键统一操作,短按一次Power按键则进行开机或系统复位,长按Power按键则进行关机。 diff --git a/main/boards/nulllab-ai-vox-v3/ai_vox3_audio_codec.h b/main/boards/nulllab-ai-vox-v3/ai_vox3_audio_codec.h new file mode 100644 index 0000000..ea60c9d --- /dev/null +++ b/main/boards/nulllab-ai-vox-v3/ai_vox3_audio_codec.h @@ -0,0 +1,237 @@ +#pragma once +#include +#include +#include +#include +#include + +#include "audio/audio_codec.h" + +class AIVOX3AudioCodec : public AudioCodec { +private: + const audio_codec_data_if_t* data_if_ = nullptr; + const audio_codec_ctrl_if_t* ctrl_if_ = nullptr; + const audio_codec_if_t* codec_if_ = nullptr; + const audio_codec_gpio_if_t* gpio_if_ = nullptr; + + esp_codec_dev_handle_t output_dev_ = nullptr; + esp_codec_dev_handle_t input_dev_ = nullptr; + + // ref buffer used for aec + std::vector ref_buffer_; + int read_pos_ = 0; + int write_pos_ = 0; + + void CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, + gpio_num_t din) { + assert(input_sample_rate_ == output_sample_rate_); + + i2s_chan_config_t chan_cfg = { + .id = I2S_NUM_0, + .role = I2S_ROLE_MASTER, + .dma_desc_num = AUDIO_CODEC_DMA_DESC_NUM, + .dma_frame_num = AUDIO_CODEC_DMA_FRAME_NUM, + .auto_clear = true, + .intr_priority = 0, + }; + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &tx_handle_, &rx_handle_)); + + i2s_std_config_t std_cfg = { + .clk_cfg = {.sample_rate_hz = (uint32_t)output_sample_rate_, + .clk_src = I2S_CLK_SRC_DEFAULT, + .mclk_multiple = I2S_MCLK_MULTIPLE_128}, + .slot_cfg = {.data_bit_width = I2S_DATA_BIT_WIDTH_16BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_STEREO, + .slot_mask = I2S_STD_SLOT_BOTH, + .ws_width = I2S_DATA_BIT_WIDTH_16BIT, + .ws_pol = false, + .bit_shift = true}, + .gpio_cfg = {.mclk = mclk, + .bclk = bclk, + .ws = ws, + .dout = dout, + .din = din, + .invert_flags = {.mclk_inv = false, .bclk_inv = false, .ws_inv = false}}, + }; + + ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg)); + ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle_, &std_cfg)); + ESP_LOGI("AIVOX3AudioCodec", "Duplex channels created"); + } + + virtual int Read(int16_t* dest, int samples) override { + if (input_enabled_) { + if (!input_reference_) { + ESP_ERROR_CHECK_WITHOUT_ABORT( + esp_codec_dev_read(input_dev_, (void*)dest, samples * sizeof(int16_t))); + } else { + int size = samples / input_channels_; + std::vector data(size); + // read mic data + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_read(input_dev_, (void*)data.data(), + data.size() * sizeof(int16_t))); + int j = 0; + int i = 0; + while (i < samples) { + // mic data + dest[i++] = data[j++]; + // ref data + dest[i++] = read_pos_ < write_pos_ ? ref_buffer_[read_pos_++] : 0; + } + + if (read_pos_ == write_pos_) { + read_pos_ = write_pos_ = 0; + } + } + } + return samples; + } + + virtual int Write(const int16_t* data, int samples) override { + if (output_enabled_) { + ESP_ERROR_CHECK_WITHOUT_ABORT( + esp_codec_dev_write(output_dev_, (void*)data, samples * sizeof(int16_t))); + if (input_reference_) { // 板子不支持硬件回采,采用缓存播放缓存来实现回声消除 + if (write_pos_ - read_pos_ + samples > ref_buffer_.size()) { + assert(ref_buffer_.size() >= samples); + // 写溢出,只保留最近的数据 + read_pos_ = write_pos_ + samples - ref_buffer_.size(); + } + if (read_pos_) { + if (write_pos_ != read_pos_) { + memmove(ref_buffer_.data(), ref_buffer_.data() + read_pos_, + (write_pos_ - read_pos_) * sizeof(int16_t)); + } + write_pos_ -= read_pos_; + read_pos_ = 0; + } + memcpy(&ref_buffer_[write_pos_], data, samples * sizeof(int16_t)); + write_pos_ += samples; + } + } + return samples; + } + +public: + AIVOX3AudioCodec(void* i2c_master_handle, i2c_port_t i2c_port, int input_sample_rate, + int output_sample_rate, gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, + gpio_num_t dout, gpio_num_t din, uint8_t es8311_addr, + bool input_reference = false) { + duplex_ = true; // 是否双工 + input_reference_ = input_reference; // 是否使用参考输入,实现回声消除 + if (input_reference) { + ref_buffer_.resize(960 * 2); + } + input_channels_ = 1 + input_reference_; + input_sample_rate_ = input_sample_rate; + output_sample_rate_ = output_sample_rate; + CreateDuplexChannels(mclk, bclk, ws, dout, din); + + // Do initialize of related interface: data_if, ctrl_if and gpio_if + audio_codec_i2s_cfg_t i2s_cfg = { + .port = I2S_NUM_0, + .rx_handle = rx_handle_, + .tx_handle = tx_handle_, + }; + data_if_ = audio_codec_new_i2s_data(&i2s_cfg); + assert(data_if_ != NULL); + + // Output + audio_codec_i2c_cfg_t i2c_cfg = { + .port = i2c_port, + .addr = es8311_addr, + .bus_handle = i2c_master_handle, + }; + ctrl_if_ = audio_codec_new_i2c_ctrl(&i2c_cfg); + assert(ctrl_if_ != NULL); + + gpio_if_ = audio_codec_new_gpio(); + assert(gpio_if_ != NULL); + + es8311_codec_cfg_t es8311_cfg = {}; + es8311_cfg.ctrl_if = ctrl_if_; + es8311_cfg.gpio_if = gpio_if_; + es8311_cfg.codec_mode = ESP_CODEC_DEV_WORK_MODE_BOTH; + es8311_cfg.pa_pin = GPIO_NUM_NC; + es8311_cfg.use_mclk = true; + es8311_cfg.hw_gain.pa_voltage = 5.0; + es8311_cfg.hw_gain.codec_dac_voltage = 3.3; + es8311_cfg.pa_reverted = false; + es8311_cfg.mclk_div = I2S_MCLK_MULTIPLE_128; + codec_if_ = es8311_codec_new(&es8311_cfg); + assert(codec_if_ != NULL); + + esp_codec_dev_cfg_t dev_cfg = { + .dev_type = ESP_CODEC_DEV_TYPE_OUT, + .codec_if = codec_if_, + .data_if = data_if_, + }; + output_dev_ = esp_codec_dev_new(&dev_cfg); + assert(output_dev_ != NULL); + dev_cfg.dev_type = ESP_CODEC_DEV_TYPE_IN; + input_dev_ = esp_codec_dev_new(&dev_cfg); + assert(input_dev_ != NULL); + esp_codec_set_disable_when_closed(output_dev_, false); + esp_codec_set_disable_when_closed(input_dev_, false); + ESP_LOGI("AIVOX3AudioCodec", "AIVOX3AudioCodec initialized"); + } + + virtual ~AIVOX3AudioCodec() { + ESP_ERROR_CHECK(esp_codec_dev_close(output_dev_)); + esp_codec_dev_delete(output_dev_); + ESP_ERROR_CHECK(esp_codec_dev_close(input_dev_)); + esp_codec_dev_delete(input_dev_); + + audio_codec_delete_codec_if(codec_if_); + audio_codec_delete_ctrl_if(ctrl_if_); + audio_codec_delete_gpio_if(gpio_if_); + audio_codec_delete_data_if(data_if_); + } + + virtual void SetOutputVolume(int volume) override { + ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, volume)); + AudioCodec::SetOutputVolume(volume); + } + + virtual void EnableInput(bool enable) override { + if (enable == input_enabled_) { + return; + } + if (enable) { + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = 16, + .channel = 1, + .channel_mask = 0, + .sample_rate = (uint32_t)input_sample_rate_, + .mclk_multiple = I2S_MCLK_MULTIPLE_128, + }; + ESP_ERROR_CHECK(esp_codec_dev_open(input_dev_, &fs)); + ESP_ERROR_CHECK(esp_codec_dev_set_in_gain(input_dev_, 37.5)); + } else { + ESP_ERROR_CHECK(esp_codec_dev_close(input_dev_)); + } + AudioCodec::EnableInput(enable); + } + + virtual void EnableOutput(bool enable) override { + if (enable == output_enabled_) { + return; + } + if (enable) { + // Play 16bit 1 channel + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = 16, + .channel = 1, + .channel_mask = 0, + .sample_rate = (uint32_t)output_sample_rate_, + .mclk_multiple = I2S_MCLK_MULTIPLE_128, + }; + ESP_ERROR_CHECK(esp_codec_dev_open(output_dev_, &fs)); + ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, output_volume_)); + } else { + ESP_ERROR_CHECK(esp_codec_dev_close(output_dev_)); + } + AudioCodec::EnableOutput(enable); + } +}; diff --git a/main/boards/nulllab-ai-vox-v3/ai_vox3_board.cc b/main/boards/nulllab-ai-vox-v3/ai_vox3_board.cc new file mode 100644 index 0000000..af464d1 --- /dev/null +++ b/main/boards/nulllab-ai-vox-v3/ai_vox3_board.cc @@ -0,0 +1,205 @@ +#include +#include +#include +#include +#include + +#include "application.h" +#include "assets/lang_config.h" +#include "button.h" +#include "config.h" +#include "display/lcd_display.h" +#include "dual_network_board.h" +#include "led/single_led.h" + +#include "ai_vox3_audio_codec.h" +#include "power_manager.h" + +#define TAG "AIVOX3" + +class AIVOX3 : public DualNetworkBoard { +private: + Button boot_button_; + Button volume_up_button_; + Button volume_down_button_; + PowerManager* power_manager_; + i2c_master_bus_handle_t codec_i2c_bus_; + LcdDisplay* display_; + + void InitializePowerManager() { + power_manager_ = new PowerManager(BATTERY_LEVEL_PIN, BATTERY_CHARGING_PIN); + } + + void InitializeI2c() { + i2c_master_bus_config_t i2c_bus_cfg = { + .i2c_port = I2C_NUM_0, + .sda_io_num = AUDIO_CODEC_I2C_SDA_PIN, + .scl_io_num = AUDIO_CODEC_I2C_SCL_PIN, + .clk_source = I2C_CLK_SRC_DEFAULT, + .glitch_ignore_cnt = 7, + .intr_priority = 0, + .trans_queue_depth = 0, + .flags = + { + .enable_internal_pullup = 1, + }, + }; + ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_bus_cfg, &codec_i2c_bus_)); + } + + void InitializeSpi() { + spi_bus_config_t buscfg = {}; + buscfg.mosi_io_num = DISPLAY_MOSI_PIN; + buscfg.miso_io_num = GPIO_NUM_NC; + buscfg.sclk_io_num = DISPLAY_CLK_PIN; + buscfg.quadwp_io_num = GPIO_NUM_NC; + buscfg.quadhd_io_num = GPIO_NUM_NC; + buscfg.max_transfer_sz = DISPLAY_WIDTH * DISPLAY_HEIGHT * sizeof(uint16_t); + ESP_ERROR_CHECK(spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO)); + } + + void InitializeLcdDisplay() { + esp_lcd_panel_io_handle_t panel_io = nullptr; + esp_lcd_panel_handle_t panel = nullptr; + + // 液晶屏控制IO初始化 + ESP_LOGD(TAG, "Install panel IO"); + esp_lcd_panel_io_spi_config_t io_config = {}; + io_config.cs_gpio_num = DISPLAY_CS_PIN; + io_config.dc_gpio_num = DISPLAY_DC_PIN; + io_config.spi_mode = DISPLAY_SPI_MODE; + io_config.pclk_hz = 40 * 1000 * 1000; + io_config.trans_queue_depth = 10; + io_config.lcd_cmd_bits = 8; + io_config.lcd_param_bits = 8; + ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI3_HOST, &io_config, &panel_io)); + + // 初始化液晶屏驱动芯片 + ESP_LOGD(TAG, "Install LCD driver"); + esp_lcd_panel_dev_config_t panel_config = {}; + panel_config.reset_gpio_num = DISPLAY_RST_PIN; + panel_config.rgb_ele_order = DISPLAY_RGB_ORDER; + panel_config.bits_per_pixel = 16; + ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(panel_io, &panel_config, &panel)); + + esp_lcd_panel_reset(panel); + + esp_lcd_panel_init(panel); + esp_lcd_panel_invert_color(panel, DISPLAY_INVERT_COLOR); + esp_lcd_panel_swap_xy(panel, DISPLAY_SWAP_XY); + esp_lcd_panel_mirror(panel, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y); + + display_ = new SpiLcdDisplay(panel_io, panel, DISPLAY_WIDTH, DISPLAY_HEIGHT, + DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y, DISPLAY_MIRROR_X, + DISPLAY_MIRROR_Y, DISPLAY_SWAP_XY); + } + + void InitializeButtons() { + boot_button_.OnClick([this]() { + auto& app = Application::GetInstance(); + if (GetNetworkType() == NetworkType::WIFI) { + if (app.GetDeviceState() == kDeviceStateStarting || + app.GetDeviceState() == kDeviceStateWifiConfiguring) { + // cast to WifiBoard + auto& wifi_board = static_cast(GetCurrentBoard()); + wifi_board.EnterWifiConfigMode(); + } + } + app.ToggleChatState(); + }); + + boot_button_.OnLongPress([this]() { + auto& app = Application::GetInstance(); + if (app.GetDeviceState() == kDeviceStateStarting || + app.GetDeviceState() == kDeviceStateWifiConfiguring) { + SwitchNetworkType(); + } + }); + +#if CONFIG_USE_DEVICE_AEC + boot_button_.OnDoubleClick([this]() { + auto& app = Application::GetInstance(); + if (app.GetDeviceState() == kDeviceStateIdle) { + app.SetAecMode(app.GetAecMode() == kAecOff ? kAecOnDeviceSide : kAecOff); + } + }); +#endif + + volume_up_button_.OnClick([this]() { + auto codec = GetAudioCodec(); + auto volume = codec->output_volume() + 10; + if (volume > 100) { + volume = 100; + } + codec->SetOutputVolume(volume); + GetDisplay()->ShowNotification(Lang::Strings::VOLUME + std::to_string(volume)); + }); + + volume_up_button_.OnLongPress([this]() { + GetAudioCodec()->SetOutputVolume(100); + GetDisplay()->ShowNotification(Lang::Strings::MAX_VOLUME); + }); + + volume_down_button_.OnClick([this]() { + auto codec = GetAudioCodec(); + auto volume = codec->output_volume() - 10; + if (volume < 0) { + volume = 0; + } + codec->SetOutputVolume(volume); + GetDisplay()->ShowNotification(Lang::Strings::VOLUME + std::to_string(volume)); + }); + + volume_down_button_.OnLongPress([this]() { + GetAudioCodec()->SetOutputVolume(0); + GetDisplay()->ShowNotification(Lang::Strings::MUTED); + }); + } + + // 物联网初始化,添加对 AI 可见设备 + void InitializeTools() {} + +public: + AIVOX3() + : DualNetworkBoard(ML307_TX_PIN, ML307_RX_PIN, GPIO_NUM_NC, DEFAULT_4G_NETWORK), + boot_button_(BOOT_BUTTON_GPIO), + volume_up_button_(VOLUME_UP_BUTTON_GPIO), + volume_down_button_(VOLUME_DOWN_BUTTON_GPIO) { + InitializeI2c(); + InitializeSpi(); + InitializeLcdDisplay(); + InitializePowerManager(); + InitializeButtons(); + InitializeTools(); + GetBacklight()->RestoreBrightness(); + } + + virtual Led* GetLed() override { + static SingleLed led(BUILTIN_LED_GPIO); + return &led; + } + + virtual AudioCodec* GetAudioCodec() override { + static AIVOX3AudioCodec audio_codec( + codec_i2c_bus_, I2C_NUM_0, AUDIO_INPUT_SAMPLE_RATE, AUDIO_OUTPUT_SAMPLE_RATE, + AUDIO_I2S_GPIO_MCLK, AUDIO_I2S_GPIO_BCLK, AUDIO_I2S_GPIO_WS, AUDIO_I2S_GPIO_DOUT, + AUDIO_I2S_GPIO_DIN, AUDIO_CODEC_ES8311_ADDR, AUDIO_INPUT_REFERENCE); + return &audio_codec; + } + + virtual Display* GetDisplay() override { return display_; } + + virtual Backlight* GetBacklight() override { + static PwmBacklight backlight(DISPLAY_BACKLIGHT_PIN, DISPLAY_BACKLIGHT_OUTPUT_INVERT); + return &backlight; + } + + virtual bool GetBatteryLevel(int& level, bool& charging, bool& discharging) override { + charging = power_manager_->IsCharging(); + discharging = power_manager_->IsDischarging(); + level = power_manager_->GetBatteryLevel(); + return true; + } +}; + +DECLARE_BOARD(AIVOX3); diff --git a/main/boards/nulllab-ai-vox-v3/config.h b/main/boards/nulllab-ai-vox-v3/config.h new file mode 100644 index 0000000..4ea9a52 --- /dev/null +++ b/main/boards/nulllab-ai-vox-v3/config.h @@ -0,0 +1,54 @@ +#pragma once +#include + +#define DEFAULT_4G_NETWORK 0 + +#if CONFIG_USE_DEVICE_AEC +#define AUDIO_INPUT_REFERENCE true +#else +#define AUDIO_INPUT_REFERENCE false +#endif + +#define AUDIO_INPUT_SAMPLE_RATE 24000 +#define AUDIO_OUTPUT_SAMPLE_RATE 24000 + +#define AUDIO_I2S_GPIO_MCLK GPIO_NUM_11 +#define AUDIO_I2S_GPIO_BCLK GPIO_NUM_10 +#define AUDIO_I2S_GPIO_WS GPIO_NUM_8 +#define AUDIO_I2S_GPIO_DOUT GPIO_NUM_7 +#define AUDIO_I2S_GPIO_DIN GPIO_NUM_9 + +#define AUDIO_CODEC_PA_PIN GPIO_NUM_NC +#define AUDIO_CODEC_I2C_SDA_PIN GPIO_NUM_13 +#define AUDIO_CODEC_I2C_SCL_PIN GPIO_NUM_12 +#define AUDIO_CODEC_ES8311_ADDR ES8311_CODEC_DEFAULT_ADDR + +#define BUILTIN_LED_GPIO GPIO_NUM_41 +#define BOOT_BUTTON_GPIO GPIO_NUM_0 +#define VOLUME_UP_BUTTON_GPIO GPIO_NUM_46 +#define VOLUME_DOWN_BUTTON_GPIO GPIO_NUM_45 + +#define DISPLAY_BACKLIGHT_PIN GPIO_NUM_16 +#define DISPLAY_MOSI_PIN GPIO_NUM_21 +#define DISPLAY_CLK_PIN GPIO_NUM_17 +#define DISPLAY_DC_PIN GPIO_NUM_14 +#define DISPLAY_RST_PIN GPIO_NUM_NC +#define DISPLAY_CS_PIN GPIO_NUM_15 + +#define DISPLAY_WIDTH 240 +#define DISPLAY_HEIGHT 240 +#define DISPLAY_MIRROR_X false +#define DISPLAY_MIRROR_Y false +#define DISPLAY_SWAP_XY false +#define DISPLAY_INVERT_COLOR true +#define DISPLAY_RGB_ORDER LCD_RGB_ELEMENT_ORDER_RGB +#define DISPLAY_OFFSET_X 0 +#define DISPLAY_OFFSET_Y 0 +#define DISPLAY_BACKLIGHT_OUTPUT_INVERT false +#define DISPLAY_SPI_MODE 0 + +#define BATTERY_LEVEL_PIN GPIO_NUM_18 +#define BATTERY_CHARGING_PIN GPIO_NUM_47 + +#define ML307_TX_PIN GPIO_NUM_44 +#define ML307_RX_PIN GPIO_NUM_43 diff --git a/main/boards/nulllab-ai-vox-v3/config.json b/main/boards/nulllab-ai-vox-v3/config.json new file mode 100644 index 0000000..9223892 --- /dev/null +++ b/main/boards/nulllab-ai-vox-v3/config.json @@ -0,0 +1,9 @@ +{ + "target": "esp32s3", + "builds": [ + { + "name": "nulllab-ai-vox-v3", + "sdkconfig_append": ["CONFIG_ESP_CONSOLE_NONE=y"] + } + ] +} diff --git a/main/boards/nulllab-ai-vox-v3/power_manager.h b/main/boards/nulllab-ai-vox-v3/power_manager.h new file mode 100644 index 0000000..6fac0c6 --- /dev/null +++ b/main/boards/nulllab-ai-vox-v3/power_manager.h @@ -0,0 +1,150 @@ +#pragma once +#include +#include +#include +#include + +class PowerManager { +private: + static constexpr uint32_t BATTERY_LEVEL_MIN = 2048; + static constexpr uint32_t BATTERY_LEVEL_MAX = 2330; + static constexpr size_t ADC_VALUES_COUNT = 10; + static constexpr size_t CHARGING_COUNT = 5; + + esp_timer_handle_t timer_handle_ = nullptr; + gpio_num_t adc_pin_ = GPIO_NUM_NC; + uint16_t adc_values_[ADC_VALUES_COUNT]; + size_t adc_values_index_ = 0; + size_t adc_values_count_ = 0; + uint8_t battery_level_ = 100; + gpio_num_t charging_pin_ = GPIO_NUM_NC; + bool is_charging_ = false; + int unchanging_count = 0; + + adc_oneshot_unit_handle_t adc_handle_; + adc_channel_t adc_channel_; + + void CheckBatteryStatus() { + if (charging_pin_ != GPIO_NUM_NC) { + bool new_charging_status = gpio_get_level(charging_pin_) == 1; + if (new_charging_status) { + unchanging_count = 0; + } else { + unchanging_count++; + if (is_charging_ && unchanging_count < 5) { + new_charging_status = true; + } + } + is_charging_ = new_charging_status; + } + + ReadBatteryAdcData(); + } + + void ReadBatteryAdcData() { + int adc_value; + ESP_ERROR_CHECK(adc_oneshot_read(adc_handle_, adc_channel_, &adc_value)); + + adc_values_[adc_values_index_] = adc_value; + adc_values_index_ = (adc_values_index_ + 1) % ADC_VALUES_COUNT; + if (adc_values_count_ < ADC_VALUES_COUNT) { + adc_values_count_++; + } + + uint32_t average_adc = 0; + for (size_t i = 0; i < adc_values_count_; i++) { + average_adc += adc_values_[i]; + } + average_adc /= adc_values_count_; + + CalculateBatteryLevel(average_adc); + + ESP_LOGI("PowerManager", "ADC值: %d 平均值: %ld 电量: %u%%", adc_value, average_adc, + battery_level_); + } + + void CalculateBatteryLevel(uint32_t average_adc) { + if (average_adc <= BATTERY_LEVEL_MIN) { + battery_level_ = 0; + } else if (average_adc >= BATTERY_LEVEL_MAX) { + battery_level_ = 100; + } else { + float ratio = static_cast(average_adc - BATTERY_LEVEL_MIN) / + (BATTERY_LEVEL_MAX - BATTERY_LEVEL_MIN); + battery_level_ = ratio * 100; + } + } + +public: + PowerManager(gpio_num_t adc_pin_, gpio_num_t charging_pin) + : adc_pin_(adc_pin_), charging_pin_(charging_pin) { + // 初始化充电引脚 + if (charging_pin_ != GPIO_NUM_NC) { + gpio_config_t io_conf = {}; + io_conf.intr_type = GPIO_INTR_DISABLE; + io_conf.mode = GPIO_MODE_INPUT; + io_conf.pin_bit_mask = (1ULL << charging_pin_); + io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; + io_conf.pull_up_en = GPIO_PULLUP_DISABLE; + gpio_config(&io_conf); + } + + esp_timer_create_args_t timer_args = { + .callback = + [](void* arg) { + PowerManager* self = static_cast(arg); + self->CheckBatteryStatus(); + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "battery_check_timer", + .skip_unhandled_events = true, + }; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &timer_handle_)); + ESP_ERROR_CHECK(esp_timer_start_periodic(timer_handle_, 1000000)); // 1秒 + + InitializeAdc(); + } + + void InitializeAdc() { + adc_unit_t adc_unit; + ESP_ERROR_CHECK(adc_oneshot_io_to_channel(adc_pin_, &adc_unit, &adc_channel_)); + + adc_oneshot_unit_init_cfg_t init_config = { + .unit_id = adc_unit, + .ulp_mode = ADC_ULP_MODE_DISABLE, + }; + ESP_ERROR_CHECK(adc_oneshot_new_unit(&init_config, &adc_handle_)); + + adc_oneshot_chan_cfg_t chan_config = { + .atten = ADC_ATTEN_DB_12, + .bitwidth = ADC_BITWIDTH_12, + }; + ESP_ERROR_CHECK(adc_oneshot_config_channel(adc_handle_, adc_channel_, &chan_config)); + } + + ~PowerManager() { + if (timer_handle_) { + esp_timer_stop(timer_handle_); + esp_timer_delete(timer_handle_); + } + if (adc_handle_) { + adc_oneshot_del_unit(adc_handle_); + } + } + + bool IsCharging() { + // 如果电量已经满了,则不再显示充电中 + if (battery_level_ == 100) { + return false; + } + return is_charging_; + } + + bool IsDischarging() { + // 没有区分充电和放电,所以直接返回相反状态 + return !is_charging_; + } + + uint8_t GetBatteryLevel() { return battery_level_; } +};