XiaoZhi on Kamoji: voice assistant firmware, end to end
How to build xiaozhi-esp32 for Kamoji: add a new board to the upstream source, patch the mic path, set a wake word of your own, build and flash.
The result is Kamoji as a voice assistant. Say the wake word, talk, and the server answers in Vietnamese through the speaker. Wi-Fi is set up over a hotspot; devices are managed at xiaozhi.me.
Demo
The log excerpts further down tell you the firmware reached each state. The clip tells you something they cannot: how long each step takes on real hardware, and how the panel behaves while it happens. Wi-Fi scan to a spoken reply is about ten seconds, the volume overlay draws over a reply that is still being spoken, and Vietnamese diacritics render correctly at 240×240 in the bundled font. That last one was the open question before the clip existed.
VietBot is the other guide in this section. It speaks the same protocol against a Vietnamese-hosted server, so the board definition and the microphone patch below carry over; the pairing flow and the wake word options are what differ.
1. Hardware
| Part | Kamoji |
|---|---|
| MCU | ESP32-S3 |
| PSRAM | 8 MB, octal mode |
| Flash | 16 MB |
| Display | ST7789 240×240, SPI, write-only |
| Mic | I2S MEMS (INMP441 / SPH0645) |
| Speaker | class-D amp over I2S |
| Buttons | 1 BOOT button + 3 touch pads |
| LED | WS2812 |
Wake word needs the esp-sr AFE, and the AFE requires an ESP32-S3 with PSRAM. Kamoji qualifies.
2. Toolchain
ESP-IDF ≥ 5.5.2. Take the version from xiaozhi's main/idf_component.yml, not from the
README, which lists a different one. Verified build: v5.5.4.
git clone -b v5.5.4 --recursive https://github.com/espressif/esp-idf.git ~/esp/esp-idf-v5.5.4
cd ~/esp/esp-idf-v5.5.4 && ./install.sh esp32s3
You also need python3 and pyserial on the host to read the serial log.
Kamoji uses USB-Serial-JTAG, so there is no separate USB-UART chip. Plugging in gives you
/dev/cu.usbmodem* (macOS) or /dev/ttyACM* (Linux). If no port shows up:
ioreg -p IOUSB -l -w 0 | grep -i "USB JTAG" # macOS
lsusb | grep -i espressif # Linux
3. Get the source
mkdir -p ~/kamoji-xiaozhi && cd ~/kamoji-xiaozhi
git clone https://github.com/78/xiaozhi-esp32.git src
cd src
No --recursive needed: xiaozhi uses the IDF Component Manager, and dependencies are fetched on
the first set-target.
4. Create the kamoji board
Three files in main/boards/kamoji/.
4.1 config.h, the pin map
#ifndef _BOARD_CONFIG_H_
#define _BOARD_CONFIG_H_
#include <driver/gpio.h>
// ---------------------------------------------------------------
// Audio: I2S simplex, no I2C codec
// ---------------------------------------------------------------
#define AUDIO_INPUT_SAMPLE_RATE 16000
#define AUDIO_OUTPUT_SAMPLE_RATE 24000
#define AUDIO_I2S_METHOD_SIMPLEX
#define AUDIO_I2S_MIC_GPIO_WS GPIO_NUM_15 // LRCLK
#define AUDIO_I2S_MIC_GPIO_SCK GPIO_NUM_17 // BCLK
#define AUDIO_I2S_MIC_GPIO_DIN GPIO_NUM_16 // mic DOUT
#define AUDIO_I2S_SPK_GPIO_LRCK GPIO_NUM_21
#define AUDIO_I2S_SPK_GPIO_BCLK GPIO_NUM_48
#define AUDIO_I2S_SPK_GPIO_DOUT GPIO_NUM_47 // amp DIN
// The Kamoji mic sits in the LEFT slot. Reading the RIGHT slot returns all zeros.
#define AUDIO_I2S_SPK_SLOT_MASK I2S_STD_SLOT_LEFT
#define AUDIO_I2S_MIC_SLOT_MASK I2S_STD_SLOT_LEFT
// ---------------------------------------------------------------
// Buttons: touch pads idle low, go high on touch
// ---------------------------------------------------------------
#define BOOT_BUTTON_GPIO GPIO_NUM_0
#define TOUCH_BUTTON_GPIO GPIO_NUM_5 // top pad
#define VOLUME_UP_BUTTON_GPIO GPIO_NUM_6 // right pad
#define VOLUME_DOWN_BUTTON_GPIO GPIO_NUM_2 // left pad
// ---------------------------------------------------------------
// WS2812 LED
// ---------------------------------------------------------------
#define BUILTIN_LED_GPIO GPIO_NUM_4
// ---------------------------------------------------------------
// Display: ST7789 240x240, CS tied to ground so it is write-only
// ---------------------------------------------------------------
#define DISPLAY_BACKLIGHT_PIN GPIO_NUM_13
#define DISPLAY_MOSI_PIN GPIO_NUM_11
#define DISPLAY_CLK_PIN GPIO_NUM_12
#define DISPLAY_DC_PIN GPIO_NUM_14
#define DISPLAY_RST_PIN GPIO_NUM_10
#define DISPLAY_CS_PIN GPIO_NUM_NC
#define LCD_TYPE_ST7789_SERIAL
#define DISPLAY_WIDTH 240
#define DISPLAY_HEIGHT 240
#define DISPLAY_MIRROR_X true
#define DISPLAY_MIRROR_Y true
#define DISPLAY_SWAP_XY false
#define DISPLAY_INVERT_COLOR true
#define DISPLAY_RGB_ORDER LCD_RGB_ELEMENT_ORDER_BGR
#define DISPLAY_OFFSET_X 0
#define DISPLAY_OFFSET_Y 80
#define DISPLAY_BACKLIGHT_OUTPUT_INVERT false
#define DISPLAY_SPI_MODE 3
#endif // _BOARD_CONFIG_H_
Four display values are the easiest to get wrong, and here is why they have to be what they are:
| Value | Reason |
|---|---|
MIRROR_X / MIRROR_Y = true, BGR | The Kamoji panel runs MADCTL 0xC8 = MY | MX | BGR |
OFFSET_Y = 80 | The controller is 240×320; the visible 240×240 window sits 80 px down |
SPI_MODE = 3 | The panel latches data on this clock edge |
CS_PIN = NC | CS is tied to ground on the board, so there is no chip-select signal |
XiaoZhi ships CONFIG_LCD_ST7789_240X240_7PIN. It sets OFFSET_Y = 0 and RGB colour order, so
the image lands 80 px off with the colours wrong.
4.2 kamoji_board.cc, the board implementation
#include "wifi_board.h"
#include "codecs/no_audio_codec.h"
#include "display/lcd_display.h"
#include "application.h"
#include "button.h"
#include "config.h"
#include "led/single_led.h"
#include "assets/lang_config.h"
#include <esp_log.h>
#include <esp_lcd_panel_vendor.h>
#include <esp_lcd_panel_io.h>
#include <esp_lcd_panel_ops.h>
#include <driver/spi_common.h>
#define TAG "KamojiBoard"
class KamojiBoard : public WifiBoard {
private:
Button boot_button_;
Button touch_top_button_;
Button touch_right_button_;
Button touch_left_button_;
LcdDisplay* display_;
void InitializeSpi() {
spi_bus_config_t buscfg = {};
buscfg.mosi_io_num = DISPLAY_MOSI_PIN;
buscfg.miso_io_num = GPIO_NUM_NC; // write-only panel
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;
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 = 80 * 1000 * 1000; // the Kamoji panel handles 80 MHz
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_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() {
auto toggle_chat = [this]() {
auto& app = Application::GetInstance();
if (app.GetDeviceState() == kDeviceStateStarting) {
EnterWifiConfigMode();
return;
}
app.ToggleChatState();
};
boot_button_.OnClick(toggle_chat);
touch_top_button_.OnClick(toggle_chat);
touch_right_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));
});
touch_right_button_.OnLongPress([this]() {
GetAudioCodec()->SetOutputVolume(100);
GetDisplay()->ShowNotification(Lang::Strings::MAX_VOLUME);
});
touch_left_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));
});
touch_left_button_.OnLongPress([this]() {
GetAudioCodec()->SetOutputVolume(0);
GetDisplay()->ShowNotification(Lang::Strings::MUTED);
});
}
public:
KamojiBoard() :
boot_button_(BOOT_BUTTON_GPIO),
// second argument = active_high: Kamoji pads go high on touch
touch_top_button_(TOUCH_BUTTON_GPIO, true),
touch_right_button_(VOLUME_UP_BUTTON_GPIO, true),
touch_left_button_(VOLUME_DOWN_BUTTON_GPIO, true) {
InitializeSpi();
InitializeLcdDisplay();
InitializeButtons();
if (DISPLAY_BACKLIGHT_PIN != GPIO_NUM_NC) {
GetBacklight()->RestoreBrightness();
}
}
virtual Led* GetLed() override {
static SingleLed led(BUILTIN_LED_GPIO);
return &led;
}
virtual AudioCodec* GetAudioCodec() override {
static NoAudioCodecSimplex audio_codec(
AUDIO_INPUT_SAMPLE_RATE, AUDIO_OUTPUT_SAMPLE_RATE,
AUDIO_I2S_SPK_GPIO_BCLK, AUDIO_I2S_SPK_GPIO_LRCK, AUDIO_I2S_SPK_GPIO_DOUT,
AUDIO_I2S_SPK_SLOT_MASK,
AUDIO_I2S_MIC_GPIO_SCK, AUDIO_I2S_MIC_GPIO_WS, AUDIO_I2S_MIC_GPIO_DIN,
AUDIO_I2S_MIC_SLOT_MASK);
return &audio_codec;
}
virtual Display* GetDisplay() override { return display_; }
virtual Backlight* GetBacklight() override {
if (DISPLAY_BACKLIGHT_PIN != GPIO_NUM_NC) {
static PwmBacklight backlight(DISPLAY_BACKLIGHT_PIN, DISPLAY_BACKLIGHT_OUTPUT_INVERT);
return &backlight;
}
return nullptr;
}
};
DECLARE_BOARD(KamojiBoard);
Use the 10-argument NoAudioCodecSimplex overload. The 8-argument one takes no slot mask and
will read the wrong mic channel.
4.3 config.json, the board identity
{
"type": "kamoji",
"target": "esp32s3",
"builds": [
{
"name": "kamoji",
"sdkconfig_append": [
"CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y",
"CONFIG_PARTITION_TABLE_CUSTOM=y",
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/16m.csv\""
]
}
]
}
type is the device name reported to the server. CMake rejects anything outside ^[a-z0-9.-]+$,
so lowercase letters, digits, . and - only.
5. Register the board with the build system
XiaoZhi does not auto-discover board directories. Two places, and missing either one breaks the build.
main/Kconfig.projbuild, inside choice BOARD_TYPE:
config BOARD_TYPE_KAMOJI
bool "Kamoji (ESP32-S3, ST7789 240x240, I2S MEMS mic + class-D amp)"
depends on IDF_TARGET_ESP32S3
main/CMakeLists.txt, as the first branch of the if/elseif chain that picks BOARD_DIR:
if(CONFIG_BOARD_TYPE_KAMOJI)
set(BOARD_DIR "kamoji")
set(BUILTIN_TEXT_FONT font_noto_sans_basic_20_4)
set(BUILTIN_ICON_FONT font_material_symbols_20_4)
set(DEFAULT_EMOJI_COLLECTION noto-color-emoji_64)
elseif(CONFIG_BOARD_TYPE_BREAD_COMPACT_WIFI)
...
The 20_4 fonts with 64 px emoji are what the other 240×240 boards use. Font names are checked
against ^font_noto_sans_basic_([0-9]+)_([0-9]+)$ by regex. Keep elseif spelled correctly:
elsif makes CMake report a syntax error on a line far away from the mistake.
Miss the CMake branch and the build stops immediately with:
FATAL_ERROR: The selected board does not define BOARD_DIR
6. Patch the mic path, and this one is mandatory
Edit NoAudioCodec::Read() in main/audio/codecs/no_audio_codec.cc.
An I2S MEMS mic carries a very large DC bias. On Kamoji it measures around 2.5·10⁸ on the 32-bit scale. The stock code only shifts:
int32_t value = bit32_buffer[i] >> 12;
2.5·10⁸ >> 12 = 61035, past INT16_MAX of 32767, so every sample clips. The wake-word
detector sees a flat constant: the screen still says it is listening, but it never recognises
speech.
Replace it with a one-pole high-pass, time constant ≈ 16 ms, and shift by 11, not 12:
samples = bytes_read / sizeof(int32_t);
static int32_t dc_estimate = 0;
for (int i = 0; i < samples; i++) {
int32_t raw = bit32_buffer[i];
dc_estimate += (raw - dc_estimate) >> 8;
int32_t value = (raw - dc_estimate) >> 11; // 11, not 12
dest[i] = (value > INT16_MAX) ? INT16_MAX
: (value < -INT16_MAX) ? -INT16_MAX : (int16_t)value;
}
return samples;
Two changes from upstream, both needed. Removing the DC bias stops the clipping. The shift then sets the level, and 12 is too quiet to recognise. Same speaker, same distance:
| Shift | Speech peak | Server returns text |
|---|---|---|
>> 12 | 1520 / 32767 (−26 dBFS) | never |
>> 11 | 8000–26000 | yes |
At >> 12 every log line still looks healthy. The mic reads its full 100 frames/second, the AFE
passes that level through, and VAD even flags speech. It is simply too quiet for recognition. Don't go
to >> 10 either: the loudest sample measured here is 26039 at >> 11, still under the ceiling,
but 52078 at >> 10, which is heavy clipping.
This is a property of the microphone, not a board fault: Kamoji's stock firmware has an equivalent filter in its own mic driver.
7. Build configuration
Create sdkconfig.kamoji at the source root:
CONFIG_BOARD_TYPE_KAMOJI=y
# Vietnamese UI and voice prompts
CONFIG_LANGUAGE_ZH_CN=n
CONFIG_LANGUAGE_VI_VN=y
# 16MB flash, v2 partition table (has an assets partition)
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/v2/16m.csv"
# Wake word, see section 8
CONFIG_USE_AFE_WAKE_WORD=y
CONFIG_SR_WN_WN9_ALEXA=y
CONFIG_SR_WN_WN9_NIHAOXIAOZHI_TTS=n
# AFE audio processing (AEC + VAD)
CONFIG_USE_AUDIO_PROCESSOR=y
# Package and flash the assets image (fonts, emoji, wake-word model)
CONFIG_FLASH_DEFAULT_ASSETS=y
# Wi-Fi provisioning over hotspot
CONFIG_USE_HOTSPOT_WIFI_PROVISIONING=y
Then an env.sh next to the source so you don't have to remember it:
source ~/esp/esp-idf-v5.5.4/export.sh
export SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32s3;sdkconfig.kamoji"
sdkconfig.defaults.esp32s3 has to be listed explicitly: once you set SDKCONFIG_DEFAULTS
yourself, idf.py stops appending the per-target file, and that file holds the octal 80 MHz
PSRAM settings.
menuconfigOnce sdkconfig exists it overrides SDKCONFIG_DEFAULTS; the overlay files only supply
defaults for keys sdkconfig doesn't already have. So after editing sdkconfig.kamoji you must
rm sdkconfig and rebuild, otherwise you get the old firmware with no error at all. Editing
via menuconfig leaves sdkconfig.kamoji no longer describing the real firmware, and anyone
rebuilding the project on another machine loses those changes.
8. Wake word
Two decisions here: which detector, and whether the phrase is one of esp-sr's or one of yours. The stock xiaozhi phrase is Chinese; you do not have to live with that.
§6 is a hard prerequisite for everything below: with the mic still at >> 12, no detector
recognises anything, and the log gives you no hint that this is the problem.
8.1 Which engine
esp-sr has two detectors. They share no code, and they take completely different config.
| WakeNet | MultiNet | |
|---|---|---|
| What it is | a network trained for exactly one phrase | a general command recogniser |
| The phrase lives in | the model weights | a string you type in config |
| You type | nothing, just pick a model | letters or phonemes |
| Conversion step | none | yes, and this is where it breaks |
| Sensitivity | high | noticeably lower |
| Your own phrase | no | yes |
One question decides it: does the wake phrase have to be your own words?
- No → WakeNet. Far more reliable, one config line, no code changes.
- Yes → MultiNet. You get any phrase you like, at the cost of exact formatting and lower sensitivity.
WakeNet is more sensitive because it only has to tell one phrase from everything else, so it recognises the overall shape. MultiNet has to decode each unit and match the whole string, so one wrong sound misses.
8.2 A built-in English phrase
Two lines, nothing else:
CONFIG_USE_AFE_WAKE_WORD=y
CONFIG_SR_WN_WN9_ALEXA=y
| Config line | Phrase | Trained on |
|---|---|---|
CONFIG_SR_WN_WN9_ALEXA=y | "Alexa" | real voices |
CONFIG_SR_WN_WN9_HIESP=y | "Hi ESP" | real voices |
CONFIG_SR_WN_WN9_HIMFIVE=y | "Hi M Five" | real voices |
CONFIG_SR_WN_WN9_JARVIS_TTS=y | "Jarvis" | synthesised |
CONFIG_SR_WN_WN9_SOPHIA_TTS=y | "Sophia" | synthesised |
CONFIG_SR_WN_WN9_HEYKIRA_TTS3=y | "Hey Kira" | synthesised |
CONFIG_SR_WN_WN9_MYCROFT_TTS=y | "Mycroft" | synthesised |
CONFIG_SR_WN_WN9_HEYWILLOW_TTS=y | "Hey Willow" | synthesised |
The _tts suffix means the model was trained on synthesised speech and is less reliable than one
trained on real voices. Of esp-sr's 64 models, only three English ones lack that suffix:
wn9_alexa, wn9_hiesp, wn9_himfive. Start there.
Enable exactly one. The model-loading loop in afe_wake_word.cc has no break, so enabling
several only bloats the assets image, and the extra phrases don't work.
Correct log:
AfeAudioEngine: Model 0: wn9_alexa
AFE: AFE Pipeline: [input] -> |VAD(WebRTC)| -> |WakeNet(wn9_alexa,)| -> [output]
AfeAudioEngine: Initialized FD AFE, detector: WakeNet
8.3 Your own phrase, no code changes
This is the one to use if you want HEY KAMOJI. MultiNet6 English takes plain letters, so
there is no conversion step to get wrong.
CONFIG_USE_AFE_WAKE_WORD=n
CONFIG_USE_CUSTOM_WAKE_WORD=y
CONFIG_CUSTOM_WAKE_WORD="HEY KAMOJI"
CONFIG_CUSTOM_WAKE_WORD_DISPLAY="Kamoji"
CONFIG_CUSTOM_WAKE_WORD_THRESHOLD=10
CONFIG_SR_MN_CN_NONE=y
CONFIG_SR_MN_EN_NONE=n
CONFIG_SR_MN_EN_MULTINET6_QUANT=y
Letters are MultiNet6's native unit. esp-sr's docs recommend all caps for MultiNet6. The phrase
is read with English spelling rules, so XIN CHAO comes out sounding English, not Vietnamese. To
get a Vietnamese-ish sound you have to misspell it deliberately, e.g. SIN CHOW.
CONFIG_CUSTOM_WAKE_WORD_DISPLAY is only the label shown on screen and in the log.
If both a Chinese and an English MultiNet model are on, get_language_from_multinet_models() in
scripts/build_default_assets.py returns cn, and your English phrase never matches.
Assets image sizes: mn6_en 3.6 MB, mn7_en 2.6 MB.
There is also a Chinese path (mn7_cn, pinyin syllables like ga mo ji). English speakers don't
need it, so skip it.
8.4 mn7_en, your own phrase via phonemes
MultiNet7 English works on phonemes, not letters. Its config:
CONFIG_CUSTOM_WAKE_WORD="hey kamoji"
CONFIG_SR_MN_CN_NONE=y
CONFIG_SR_MN_EN_NONE=n
CONFIG_SR_MN_EN_MULTINET7_QUANT=y
esp-sr's docs say an internal grapheme-to-phoneme tool is called at runtime when you supply no
phonemes, and esp_mn_commands_add() does contain that branch, but on this board it rejects the
phrase. Feeding the phonemes in directly works. In main/audio/wake_words/custom_wake_word.cc:
#ifdef CONFIG_SR_MN_EN_MULTINET7_QUANT
// flite_g2p.h ships without an extern "C" guard, so including it straight
// from C++ mangles the symbol and the prebuilt C library never matches.
extern "C" {
#include <flite_g2p.h>
}
#endif
and in the command-loading loop of CustomWakeWord::Initialize:
for (int i = 0; i < commands_.size(); i++) {
+#ifdef CONFIG_SR_MN_EN_MULTINET7_QUANT
+ char* ph = flite_g2p(commands_[i].command.c_str(), 1);
+ ESP_LOGW(TAG, "G2PDIAG '%s' -> '%s'", commands_[i].command.c_str(),
+ ph != nullptr ? ph : "(null)");
+ esp_err_t err = esp_mn_commands_phoneme_add(i + 1, commands_[i].command.c_str(), ph);
+ ESP_LOGW(TAG, "G2PDIAG phoneme_add ret=%d (0=OK)", (int)err);
+ // phoneme_add copies the string into the phrase, unlike
+ // esp_mn_commands_add() which frees it for us, so release it here.
+ if (ph != nullptr) { free(ph); }
+#else
esp_mn_commands_add(i + 1, commands_[i].command.c_str());
+#endif
}
Three details that each cost real time to find:
flite_g2p.hhas noextern "C". Include it from C++ and the link dies withundefined reference to '_Z9flite_g2pPKci', because the C++-mangled name never matches the prebuilt C library. The guard is mandatory.esp_mn_commands_phoneme_add()does not free the phoneme string, unlikeesp_mn_commands_add()which frees it internally. Callfree(ph)yourself or leak every boot.- On-device G2P differs from the offline tool.
esp-sr/tool/multinet_g2p.pyusesg2p_en(CMUdict); the runtime uses Flite. Same phrase, two answers:hd KcMoqm("hay ker-MOO-ji") offline versushd KaMbqm("hay ka-MOH-ji") on the board. Don't predict pronunciation from the offline tool. Read theG2PDIAGline in the log, that's the truth.
Correct log:
AfeAudioEngine: Model 0: mn7_en
CustomWakeWord: G2PDIAG 'hey kamoji' -> 'hd KaMbqm'
CustomWakeWord: G2PDIAG phoneme_add ret=0 (0=OK)
1 active speech commands:
Command 1: hd KaMbqm
8.5 Phrase rules
| Syllables | three or more |
| Characters | no digits, no punctuation, no accented letters |
| Language | never mix Chinese and English in one phrase |
| Case | mn6_en ALL CAPS; mn7_en lowercase |
The middle two are esp-sr rules: mixed Chinese and English is unsupported, and command words cannot contain Arabic numerals or special characters.
One- and two-syllable phrases false-trigger constantly, which is why HEY KAMOJI beats plain
KAMOJI. A longer phrase puts more constraints in the FST, so there is less to match by accident.
8.6 Where the phrase actually lives
CONFIG_CUSTOM_WAKE_WORD does not go straight into the C++.
sdkconfig.kamoji CONFIG_CUSTOM_WAKE_WORD="HEY KAMOJI"
│ rm sdkconfig + idf.py build
▼
sdkconfig
│ scripts/build_default_assets.py reads sdkconfig
▼
index.json { "language":"en", "threshold":0.1,
"commands":[{"command":"HEY KAMOJI","action":"wake"}] }
│ packed with the model, fonts and emoji
▼
generated_assets.bin ──esptool──▶ assets partition at 0x800000
At runtime it reads back the other way: ParseWakenetModelConfig() loads index.json from assets,
calls esp_mn_commands_add(), and MultiNet builds the FST from that list.
The direct consequence: the phrase is in the assets partition, not in the app. Flash only
0x20000 and the old phrase stays live. idf.py flash writes everything, so this only bites when
flashing regions by hand.
Check the phrase made it into the image before flashing:
python3 -c "
d = open('build/generated_assets.bin','rb').read()
i = d.find(b'\"multinet_model\"')
print(d[i:i+240].decode('utf-8','replace') if i>=0 else 'NOT FOUND')"
8.7 Sensitivity
CONFIG_CUSTOM_WAKE_WORD_THRESHOLD is a percentage floor on the beam-search score.
| Symptom | Do this |
|---|---|
| Doesn't respond when called | lower it to 10, then 5 |
| Wakes up on its own | raise it to 20, 30, 40 |
The xiaozhi default is 20; this board uses 10. There is no universally correct value. It depends on the room, the background noise and how far away you usually stand. Measure it: call ten times and count the hits, then sit silent for five minutes and count the false wakes.
MultiNet is fast enough that performance is never the culprit. Inference on a 512-sample (32 ms) chunk takes 17–25 ms and the backlog stays flat, so if the wake word doesn't fire, look at the phrase format or the threshold, not at CPU.
9. Build
cd ~/kamoji-xiaozhi/src
source ../env.sh
idf.py set-target esp32s3 # first time only, to fetch dependencies
idf.py build
The first set-target downloads a lot of components and needs network. A full build takes about
5–10 minutes.
What a correct build looks like, near the top:
wakenet models: wn9_alexa (will be packaged)
and at the end:
xiaozhi.bin binary size 0x2bb150 bytes. Smallest app partition is 0x3f0000 bytes. 31% free.
10. Flash
idf.py -p /dev/cu.usbmodem11301 flash
That writes five regions: bootloader, partition table, ota_data, app, and the assets image. NVS is not erased, so saved Wi-Fi and the device binding survive a reflash.
To start over from scratch:
esptool.py --chip esp32s3 -p /dev/cu.usbmodem11301 erase_flash
This wipes NVS too, meaning both the saved Wi-Fi and the device identity: the board generates a new UUID and has to be re-added at xiaozhi.me. To clear only Wi-Fi:
esptool.py -p /dev/cu.usbmodem11301 erase_region 0x9000 0x4000
11. First-run setup
On first boot there is no Wi-Fi yet, so the board opens its own hotspot:
StateMachine: State: starting -> wifi_configuring
WifiConfigurationAp: Access Point started with SSID Xiaozhi-XXXX
esp_netif_lwip: DHCP server started ... IP: 192.168.4.1
- Join the Wi-Fi network
Xiaozhi-XXXX(last four characters come from the MAC). - Open http://192.168.4.1.
- Enter your home Wi-Fi. Leave the Advance tab empty. Filling it writes a custom server address into NVS that then overrides the default server.
- The board reboots, connects, and shows a 6-digit code.
- Add the device with that code at xiaozhi.me.
The assistant's name and personality live at xiaozhi.me, not in the firmware, so changes take effect immediately with no reflash.
In the wifi_configuring state the wake word does not work yet: the firmware only starts the
detector after it is on a network. No response before Wi-Fi is configured is expected.
12. Verify from the log
idf.py -p /dev/cu.usbmodem11301 monitor # Ctrl+] to exit
Lines worth watching at boot:
| Log line | Meaning |
|---|---|
Board: UUID=… SKU=kamoji | board detected correctly |
AfeAudioEngine: Model 0: wn9_alexa | wake-word model loaded from assets |
AFE Pipeline: [input] -> |VAD(WebRTC)| -> |WakeNet(wn9_alexa,)| -> [output] | wake word is in the pipeline |
Initialized FD AFE, detector: WakeNet | running in WakeNet mode |
AudioCodec: Set input enable to true | mic is on |
StateMachine: State: activating -> idle | server connected, ready |
Saying the wake word produces the full sequence:
Application: Wake word detected: Alexa
StateMachine: State: idle -> connecting
StateMachine: State: connecting -> listening
Application: >> Alexa <- what you said, as the server heard it
StateMachine: State: listening -> speaking
Application: << Xin chào, mình nghe đây. <- the reply
StateMachine: State: speaking -> listening
A >> line means mic, WakeNet, network and speech recognition are all working. A << line means
synthesis and the speaker work too. That is the exchange in the demo above.
12.1 Extra lines when you use a custom phrase
MultiNet reports whether your command list loaded. Working:
1 active speech commands:
Command 1: HEY KAMOJI
Rejected:
Command HEY KAMOJI is invalid, please check format.
No commands available, not updating.
49 active speech commands:
Command 1: TfL Mm c qbK
49 is the tell. Your command list came out empty and the model fell back to esp-sr's built-in
set (TELL ME A JOKE, SING A SONG, …). See 49, and you know your phrase never landed.
A successful wake also prints a score line:
Custom wake word detected: command_id=1, string=..., prob=....
13. Troubleshooting
| Symptom | Cause |
|---|---|
FATAL_ERROR: The selected board does not define BOARD_DIR | missing branch in main/CMakeLists.txt (§5) |
Board not listed in menuconfig | missing declaration in Kconfig.projbuild (§5) |
| Image offset by 80 px | DISPLAY_OFFSET_Y is not 80 |
| Wrong colours, red shows as blue | LCD_RGB_ELEMENT_ORDER_BGR missing |
| White screen or garbage | DISPLAY_SPI_MODE is not 3, or CS_PIN is not NC |
| Says it's listening but never recognises speech | DC filter not patched (§6) |
| Wake word fires but the server never returns text | mic shift left at >> 12 instead of >> 11 (§6) |
| Mic completely silent | wrong slot mask, must be I2S_STD_SLOT_LEFT |
| Config changed but firmware identical | didn't rm sdkconfig (§7) |
| Custom phrase rejected, log shows 49 commands | wrong unit type for the model, letters vs phonemes (§8.3, §8.4) |
| Custom English phrase never matches | a Chinese MultiNet model is also enabled (§8.3) |
Link error undefined reference to '_Z9flite_g2p...' | missing extern "C" guard (§8.4) |
| Changed wake word, old phrase still works | assets region not reflashed (§8.6) |
| Wake word ignored before joining a network | by design (§11) |
| Touch pads fire random events | active_high = true missing in the Button constructor |
14. Partition table
partitions/v2/16m.csv:
| Name | Offset | Size | Used for |
|---|---|---|---|
| nvs | 0x9000 | 0x4000 | Wi-Fi, device UUID, settings |
| otadata | 0xd000 | 0x2000 | boot partition selection |
| phy_init | 0xf000 | 0x1000 | RF calibration |
| ota_0 | 0x20000 | 0x3f0000 | app |
| ota_1 | 0x410000 | 0x3f0000 | spare app for OTA updates |
| assets | 0x800000 | 8M | fonts, emoji, wake-word model |
The app uses about 2.9 MB of 4.1 MB, roughly 31% free, enough room for features added later.
15. Updating the source later
The whole delta against upstream is six files:
| Path | Change |
|---|---|
main/boards/kamoji/config.h | new |
main/boards/kamoji/kamoji_board.cc | new |
main/boards/kamoji/config.json | new |
main/Kconfig.projbuild | edit, one block added |
main/CMakeLists.txt | edit, one branch added |
main/audio/codecs/no_audio_codec.cc | edit, DC filter + gain patch |
main/audio/wake_words/custom_wake_word.cc | edit, only for mn7_en (§8.4) |
cd ~/kamoji-xiaozhi/src
git stash
git pull
git stash pop
Two spots to watch when there are conflicts:
main/CMakeLists.txt, where the firstif()branch conflicts easily. If you lose it the build fails loudly (FATAL_ERROR ... BOARD_DIR), so it can't slip through.no_audio_codec.cc, where resolving in favour of upstream drops the DC filter and the mic goes silent with no error. Check with:
grep -c dc_estimate main/audio/codecs/no_audio_codec.cc # must print 3
16. Limits
No Vietnamese model. esp-sr has neither a WakeNet nor a MultiNet model for Vietnamese, so the wake phrase has to borrow English or Chinese sounds. This applies to the wake phrase only. The conversation afterwards is normal Vietnamese, because recognition and synthesis both run on the server.
Your own words with WakeNet reliability means having Espressif train a model. The model
directory has a wn9_customword slot for exactly that; today it holds a copy of a Chinese model
and has no Kconfig entry.
Still unexplained: why esp_mn_commands_add() rejects the phrase while
esp_mn_commands_phoneme_add() accepts the same phoneme string. Ruled out: the Kconfig macro is
present in sdkconfig.h and esp_mn_speech_commands.c does get recompiled; only the G2P branch's
format string exists in the ELF; flite_g2p returns a valid string, not NULL or garbage; and both
functions store identically via esp_mn_phrase_alloc(). The patch in §8.4 routes around it rather
than fixing the cause.