VietBot voice assistant on Kamoji
VietBot is a Vietnamese voice assistant that speaks the xiaozhi protocol. None of its four web flashers lists Kamoji, so you build from source. The hardware turns out to be a near perfect match for what xiaozhi wants, and the work splits in two: a board definition, and one bug fix in shared xiaozhi code that stops the microphone working. Skip the fix and the firmware boots fine, shows "listening", and never hears a word.
Everything below was verified on three boards.
Thirty seconds of the finished build: the board joins Wi-Fi, checks for a newer firmware version, then runs a full conversation turn. Watch the status line at the top move from "Đang lắng nghe" to "Đang nói" while the caption at the bottom shows what it heard and what it answers. Those strings come from the Vietnamese locale, and a microphone that behaves like this is one with the DC filter patch already applied.
VietBot firmware is a standalone ESP-IDF application, not an ESPHome component, and cannot run alongside Kamoji. You lose dynamic eyes, GIF playback, IMU reactions, ESP-NOW, OBD-II, the web server and the settings menu. Take a full flash backup before you start.
You get on-device wake word through ESP-SR WakeNet, streaming ASR plus LLM plus TTS via the VietBot server, an LVGL interface on the 240x240 panel, Vietnamese UI strings and OTA updates.
The IMU on I2C pins 8 and 9, both LDR sensors, and the two lower NeoPixels on GPIO18 go unused. To bring them back, write MCP tools in your board .cc file. main/mcp_server.cc already exposes devices to the LLM, so an IMU tool that reports cornering force is a reasonable weekend project.
XiaoZhi is the other guide in this section. It builds the upstream project against its own server instead, so the board definition and the microphone patch below are the same work; the pairing flow and the wake word options are where the two part company.
Before you start
Confirm the PSRAM, because octal mode at 80 MHz is mandatory for AFE:
python -m esptool --chip esp32s3 --port /dev/cu.usbmodem11301 flash_id
You want Features: WiFi, BLE, Embedded PSRAM 8MB (AP_3v3). Anything reporting 2 MB of quad PSRAM will not run this firmware.
Then back up all 16 MB, which takes about three and a half minutes:
python -m esptool --chip esp32s3 --port /dev/cu.usbmodem11301 -b 921600 \
read_flash 0x0 0x1000000 kamoji-full-16MB.bin
esptool only writes the file once the read finishes, so do not kill it halfway. Check the result is exactly 16777216 bytes before you trust it.
Environment and source
You need ESP-IDF 5.5.2 or newer. The xiaozhi README says 5.4, but main/idf_component.yml declares >=5.5.2 and that is the number that binds. On macOS, ESPHome runs in Docker and cannot help here, so install ESP-IDF natively.
Use ESP-IDF's esptool rather than the Homebrew build, which reads flash roughly nine times slower.
source ~/esp/esp-idf/export.sh
python -m esptool version # v4.12.0
mkdir -p ~/Desktop/vietbot-kamoji && cd ~/Desktop/vietbot-kamoji
git clone --depth 1 https://github.com/phanmemkhoinghiep/vietbot_client.git src
The firmware lives in src/esp32/. Ignore the android/ and python/ clients.
Add the kamoji board
config.h
Create main/boards/kamoji/config.h. The pin map comes from the Kamoji device config in the esphome-ousvn repository.
#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
#define AUDIO_I2S_MIC_GPIO_SCK GPIO_NUM_17
#define AUDIO_I2S_MIC_GPIO_DIN GPIO_NUM_16
#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
// Measured on hardware: mic data sits in the LEFT slot.
// Reading RIGHT returns all zeros.
#define AUDIO_I2S_SPK_SLOT_MASK I2S_STD_SLOT_LEFT
#define AUDIO_I2S_MIC_SLOT_MASK I2S_STD_SLOT_LEFT
// ---- Buttons: capacitive touch pads, active HIGH ----
#define BOOT_BUTTON_GPIO GPIO_NUM_0
#define TOUCH_BUTTON_GPIO GPIO_NUM_5 // top
#define VOLUME_UP_BUTTON_GPIO GPIO_NUM_6 // right
#define VOLUME_DOWN_BUTTON_GPIO GPIO_NUM_2 // left
// ---- LED: WS2812, 2 pixels on the top PCB ----
#define BUILTIN_LED_GPIO GPIO_NUM_4
// ---- Display: ST7789V 240x240, write-only, CS tied to GND ----
#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 // MADCTL 0xC8 = MY|MX|BGR
#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_
Three of those display values are easy to get wrong, and the failure looks like a hardware fault. They came out of the stock firmware rather than guesswork: run esphome compile on the Kamoji config, open src/main.cpp in the matching folder under .esphome/build/, and find 54, 1, 200 in the set_init_sequence array. That is MADCTL 0x36 with payload 0xC8, meaning MY, MX and BGR. The vertical offset of 80 comes from the last template parameter of the MipiSpi declaration in the same file.
xiaozhi ships CONFIG_LCD_ST7789_240X240_7PIN, which looks like the right shortcut but leaves the offset at 0 and the order at RGB. Use it as-is and your image sits 80 pixels off with red and blue swapped.
The board class
Create main/boards/kamoji/kamoji_board.cc by copying main/boards/bread-compact-wifi-lcd/compact_wifi_board_lcd.cc, which already uses the right combination of WifiBoard, NoAudioCodecSimplex, SpiLcdDisplay and SingleLed. Four things change:
// 1. Write-only panel, no MISO line
buscfg.miso_io_num = GPIO_NUM_NC;
// 2. CS is tied to GND, so esp_lcd_panel_io_spi takes -1
io_config.cs_gpio_num = DISPLAY_CS_PIN; // GPIO_NUM_NC
io_config.spi_mode = DISPLAY_SPI_MODE; // 3
io_config.pclk_hz = 80 * 1000 * 1000; // stock firmware drives this panel at 80MHz
// 3. Touch pads are active HIGH, so Button's second argument is true
KamojiBoard() :
boot_button_(BOOT_BUTTON_GPIO),
touch_top_button_(TOUCH_BUTTON_GPIO, true),
touch_right_button_(VOLUME_UP_BUTTON_GPIO, true),
touch_left_button_(VOLUME_DOWN_BUTTON_GPIO, true) { ... }
// 4. Codec constructor takes explicit slot masks
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;
}
Drop the sample board's LampController and LAMP_GPIO, then end the file with DECLARE_BOARD(KamojiBoard);.
Bind the pads like this: top taps toggle a conversation with ToggleChatState(), right adjusts volume up 10 per tap with a long press for 100%, left goes down 10 with a long press to mute. During the starting state, roughly two seconds after reset, a top tap calls EnterWifiConfigMode() instead, which is how you switch Wi-Fi network without erasing NVS and pairing again.
Register the board
main/boards/kamoji/config.json:
{
"target": "esp32s3",
"builds": [
{
"name": "kamoji",
"sdkconfig_append": [
"CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y",
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/16m.csv\""
]
}
]
}
Board dispatch is a hardcoded if/elseif chain, so creating the folder achieves nothing until you edit two files. Add to the top of choice BOARD_TYPE in main/Kconfig.projbuild:
config BOARD_TYPE_KAMOJI
bool "Kamoji (ESP32-S3, ST7789 240x240, INMP441 + I2S amp)"
depends on IDF_TARGET_ESP32S3
And to the head of the dispatch chain in main/CMakeLists.txt:
if(CONFIG_BOARD_TYPE_KAMOJI)
set(BOARD_TYPE "kamoji")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
elseif(CONFIG_BOARD_TYPE_BREAD_COMPACT_WIFI)
...
Fix the microphone
Without this patch the microphone is completely unusable. The firmware boots normally, the display says it is listening, and nothing you say registers.
The MEMS module, either SPH0645LM4H or INMP441 depending on batch, carries a DC offset of roughly 2.5e8 on the 32-bit scale. xiaozhi's NoAudioCodec::Read() converts to 16-bit with a plain raw >> 12 and no DC removal. That offset alone lands near 61035, well past INT16_MAX, so every sample clamps to the maximum and WakeNet receives a flat line. Kamoji's own driver filters DC, which is why the microphone behaves under ESPHome.
In main/audio/codecs/no_audio_codec.cc, replace the conversion loop inside NoAudioCodec::Read():
samples = bytes_read / sizeof(int32_t);
// Filter DC on the 32-bit scale first, the same way the stock board driver does.
// Single pole high-pass, shift of 8, about 16ms at 16kHz.
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;
The shift of 11 rather than 12 matters as much as the filter. At >> 12 speech peaks around 1520 out of 32767, which is roughly -26 dBFS. Everything downstream still looks healthy, the mic delivers its full 100 frames per second and VAD still reports speech, but the server never returns any text. At >> 11 the same speaker at the same distance peaks between 8000 and 26000 and recognition works.
This is shared component code, so it affects every board using NoAudioCodec. Wrap it in #ifdef CONFIG_BOARD_TYPE_KAMOJI if you plan to contribute upstream.
Build
Create sdkconfig.kamoji in the root of esp32/:
CONFIG_BOARD_TYPE_KAMOJI=y
# Vietnamese UI and prompts
CONFIG_LANGUAGE_VI_VN=y
# 16MB flash, partition v2 (2x4MB OTA + 8MB assets)
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/v2/16m.csv"
# Wake word: WakeNet + AFE
CONFIG_USE_AFE_WAKE_WORD=y
CONFIG_USE_AUDIO_PROCESSOR=y
# Bundle fonts, emoji, vi-VN prompts and the wakenet model
CONFIG_FLASH_DEFAULT_ASSETS=y
# First-time Wi-Fi setup over SoftAP
CONFIG_USE_HOTSPOT_WIFI_PROVISIONING=y
# Point straight at the VietBot server
CONFIG_OTA_URL="https://vietbot.vn/ota/"
That last line saves you a manual step per board, and survives NVS erases. Then build:
cd ~/Desktop/vietbot-kamoji/src/esp32
source ~/esp/esp-idf/export.sh
export SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.kamoji"
idf.py set-target esp32s3
idf.py build
Your file has to come after sdkconfig.defaults so it wins. ESP-IDF loads sdkconfig.defaults.esp32s3 on its own. Check the settings took:
grep -E "^CONFIG_(BOARD_TYPE_KAMOJI|LANGUAGE_VI_VN|SPIRAM_MODE_OCT|OTA_URL)=" sdkconfig
A good build gives an app around 2.79 MB against a 4032 KB partition, plus generated_assets.bin around 1.19 MB. The output says dio rather than qio for flash mode, which is correct, since octal PSRAM occupies the pins quad-IO flash would need.
Flash
First time, write all five regions:
python -m esptool --chip esp32s3 --port /dev/cu.usbmodem11301 -b 921600 \
--before default_reset --after hard_reset write_flash \
--flash_mode dio --flash_freq 80m --flash_size 16MB \
0x0 build/bootloader/bootloader.bin \
0x8000 build/partition_table/partition-table.bin \
0xd000 build/ota_data_initial.bin \
0x20000 build/xiaozhi.bin \
0x800000 build/generated_assets.bin
Later code changes only need 0x20000 build/xiaozhi.bin, which takes about 15 seconds and leaves NVS alone, so Wi-Fi and pairing survive.
To wipe Wi-Fi credentials, stored OTA URL, device UUID and volume back to factory state, erase the NVS partition with erase_region 0x9000 0x4000. esptool works at partition granularity and cannot clear one namespace on its own.
Pair with VietBot
| Step | Action |
|---|---|
| 1 | Create an Agent at web.vietbot.vn or mobile.vietbot.vn and pick a Role |
| 2 | Open Device Management, then Link Device |
| 3 | Join the Wi-Fi the board broadcasts, named Xiaozhi-XXXX after its MAC |
| 4 | Open http://192.168.4.1, usually via captive portal |
| 5 | Advance tab, Custom OTA URL, enter https://vietbot.vn/ota/ |
| 6 | Pick your home Wi-Fi, enter the password, Save, board reboots |
| 7 | The display shows a 6-digit code |
| 8 | Type that code into the Link Device box from step 2 |
| 9 | Say the wake word, or tap the top pad, and talk |
Step 5 is not optional unless you baked in CONFIG_OTA_URL. xiaozhi defaults to the Chinese-language xiaozhi.me service, and the board will show you an activation code you have nowhere to enter. NVS beats the compile-time value either way, since main/ota.cc only falls back to CONFIG_OTA_URL when the stored ota_url is empty, so the Advance tab always wins.
Change the wake word
Out of the box the board answers to wn9_nihaoxiaozhi_tts, which is 你好小智 in Chinese. esp-sr has no Vietnamese WakeNet model, so nobody is getting "Kamoji ơi" today. The practical fix is an English model. List all 54 with:
grep -A1 "config SR_WN_WN9_" \
managed_components/espressif__esp-sr/Kconfig.projbuild | grep 'bool "'
| Config symbol | Phrase |
|---|---|
CONFIG_SR_WN_WN9_JARVIS_TTS | "Jarvis" |
CONFIG_SR_WN_WN9_ALEXA | "Alexa" |
CONFIG_SR_WN_WN9_HIESP | "Hi, ESP" |
CONFIG_SR_WN_WN9_SOPHIA_TTS | "Sophia" |
CONFIG_SR_WN_WN9_MYCROFT_TTS | "Mycroft" |
CONFIG_SR_WN_WN9_HEYKIRA_TTS3 | "Hey, Kira" |
CONFIG_SR_WN_WN9_HEYWILLOW_TTS | "Hey, Willow" |
CONFIG_SR_WN_WN9_COMPUTER_TTS | "computer" |
Start with Alexa, Hi ESP or Hi M Five. The _tts suffix on the others means the model was trained on synthesised speech and holds up worse against a real voice, and those three are the only English models without it. Skip computer, which will fire at you mid conversation about your laptop.
Set exactly one model. The symbols are independent booleans rather than a Kconfig choice, and afe_wake_word.cc has no break in its model loop, so with several enabled the last one wins while the phrase list accumulates across all of them and the reported index drifts out of step.
CONFIG_SR_WN_WN9_NIHAOXIAOZHI_TTS=n
CONFIG_SR_WN_WN9_JARVIS_TTS=y
build_default_assets.py packs the model into generated_assets.bin, so after rebuilding you must write 0x800000 as well as 0x20000. Miss it and the old model sits in flash while you wonder why the new phrase does nothing. Neither region touches NVS, so pairing survives.
Confirm from the log after reset:
AfeWakeWord: Model 0: wn9_jarvis_tts
AFE_CONFIG: Set WakeNet Model: wn9_jarvis_tts
A custom English wake phrase
WakeNet phrases live in the model weights, so you cannot invent one. MultiNet decodes whatever string you type instead, at the cost of noticeably lower sensitivity and, on this fork, no barge-in: AudioService::IsAfeWakeWord() uses a dynamic_cast to AfeWakeWord, which returns null for MultiNet, and application.cc consults that before arming the wake word during listening and speaking. You can still wake the board from idle, just not interrupt it mid sentence.
The string format depends on the model, because the FST has to be built from the units the acoustic model emits. mn6_en takes plain letters, which is the shortest path and needs no code changes:
CONFIG_USE_AFE_WAKE_WORD=n
CONFIG_USE_CUSTOM_WAKE_WORD=y
CONFIG_CUSTOM_WAKE_WORD="HEY KAMOJI" # uppercase letters for mn6_en
CONFIG_CUSTOM_WAKE_WORD_DISPLAY="Kamoji" # shown on screen after waking
CONFIG_CUSTOM_WAKE_WORD_THRESHOLD=10 # percent, lower means more sensitive
CONFIG_SR_MN_CN_MULTINET7_QUANT=n
CONFIG_SR_MN_CN_NONE=y
CONFIG_SR_MN_EN_NONE=n
CONFIG_SR_MN_EN_MULTINET6_QUANT=y
CONFIG_SR_WN_WN9_NIHAOXIAOZHI_TTS=n
Four rules on the phrase itself. Three syllables or more, since one and two syllable phrases false-trigger constantly, which is why HEY KAMOJI beats KAMOJI. No digits, punctuation or Vietnamese diacritics. One language per phrase. Uppercase for mn6_en, lowercase for mn7_en. The phrase is pronounced by the model's own spelling rules, so a Vietnamese-sounding phrase has to be misspelled into English, SIN CHOW rather than XIN CHAO.
mn7_en works on phonemes and accepts lowercase, but only through a patch. Letting it call the internal grapheme-to-phoneme tool gets the phrase rejected, so feed the phonemes in yourself, 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
// ...then in the command loop inside CustomWakeWord::Initialize():
char* ph = flite_g2p(commands_[i].command.c_str(), 1);
ESP_LOGW(TAG, "G2PDIAG '%s' -> '%s'", commands_[i].command.c_str(), ph ? ph : "(null)");
esp_mn_commands_phoneme_add(i + 1, commands_[i].command.c_str(), ph);
if (ph != nullptr) { free(ph); } // phoneme_add copies rather than taking ownership
Do not predict the phonemes with esp-sr/tool/multinet_g2p.py. It uses CMUdict while the runtime uses Flite, and the two disagree on the same phrase. Read the G2PDIAG line in the log, which is the truth.
Four ways this fails without saying so
| Trap | What you see | Fix |
|---|---|---|
sdkconfig not deleted before building | new config, identical firmware | sdkconfig overrides SDKCONFIG_DEFAULTS, so rm sdkconfig first |
| Assets region not flashed | the old phrase still wakes it | the phrase lives at 0x800000, not in the app |
| Two MultiNet languages enabled | English phrase never matches | get_language_from_multinet_models() picks cn when it sees both |
Mic gain left at >> 12 | logs look healthy, no text ever comes back | use >> 11, see the microphone section |
That third one has a companion: any MultiNet model present in assets makes AudioService::SetModelsList() choose MultiNet regardless of CONFIG_USE_AFE_WAKE_WORD. Turning models off is what switches engines, not that flag.
Confirm it took
1 active speech commands:
Command 1: HEY KAMOJI
If you see Command HEY KAMOJI is invalid, please check format followed by 49 active speech commands, your list is empty and the model fell back to the esp-sr defaults like TELL ME A JOKE. The number 49 is the tell.
Waking then logs Application: Wake word detected: Kamoji, plus a score line from MultiNet with command_id and prob. Neither appears while the board sits in wifi_configuring or Activating... 1/10, since the wake word is not registered until the device reaches idle. Pair it at vietbot.vn with the 6-digit code first.
Threshold has no correct value, only a correct value for your car. This board runs 10 against a fork default of 20. Lower it to 10 then 5 if calls get missed, raise it toward 30 or 40 if it wakes at nothing. Measure by calling ten times and counting hits, then sitting silent for five minutes and counting false wakes.
Check the logs
A healthy first boot contains these, and each line confirms one piece of the board definition:
LcdDisplay: Use 2MB of PSRAM for image cache <- octal PSRAM OK
Backlight: Set brightness to 75 <- PWM on GPIO13 OK
NoAudioCodec: Simplex channels created <- I2S pin map OK
StateMachine: starting -> wifi_configuring
WifiConfigurationAp: Access Point started with SSID Xiaozhi-XXXX
After pairing you should also see StateMachine: starting -> activating -> idle and the AFE pipeline starting up. Those lines are absent while the board sits in wifi_configuring, and the microphone is not read there at all, since wake word detection only switches on after the server connection is up. Talking to an unpaired board really does reach no one.
Troubleshooting
No /dev/cu.usbmodem*. Check with ioreg -p IOUSB -l -w 0 | grep -i "USB Product Name", which beats system_profiler on Apple Silicon. Usual causes are a charge-only cable, a USB hub (the S3's native USB is picky, plug straight in), or old firmware already in deep sleep, in which case press reset.
"port is busy" while the port exists. Browser Web Serial claims exclusive access. Run lsof on the port, and if a browser appears, click Disconnect on whatever flasher page is open rather than killing it.
Microphone still silent. Log dc, ac_peak and out_peak inside Read() for a second at a time. A steady 32767 means the DC filter is not applied. Zeros with ac_peak of 1 means an empty slot and a wrong SLOT_MASK. Around 3000 when quiet and 15000 plus on a clap next to the mic is normal. Clap rather than talk, since you know for certain a loud sound happened.
Very quiet speaker, with Output volume value (0) is too small in the log. A long press on the left pad wrote 0 to NVS. Tap the right pad a few times.
Image shifted 80 pixels or red and blue swapped. Wrong DISPLAY_OFFSET_Y or DISPLAY_RGB_ORDER. Re-derive both from the MADCTL byte as described above.
Boot loop from the first boot. Suspect CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB=y in sdkconfig.defaults.esp32s3, which kamoji-core.yaml flags with a "causing crash on boot" comment. Three test boards never hit it, but turn it off first if you do.
Going back to Kamoji
With a backup, restore is one command:
python -m esptool --chip esp32s3 --port /dev/cu.usbmodem11301 -b 921600 \
write_flash 0x0 kamoji-full-16MB.bin
Without one, run esphome compile on your Kamoji config and flash the resulting .factory.bin at 0x0. The application and packed storage data are both regenerated. What you lose permanently is NVS, meaning drive statistics and boot count.
Your LittleFS content almost certainly survived the detour. VietBot writes stop at 0x91EE2B while Kamoji's LittleFS starts at 0xA10000, so nothing is overwritten, only hidden by a partition table that does not declare it. The two partition layouts are incompatible, so switching either way means writing a new table. Watch the margin though: VietBot declares its assets partition as a full 8 MB, and if generated_assets.bin ever grows past about 2.1 MB it starts eating into storage for real.
Known limits
The wake word is Chinese out of the box and there is no Vietnamese WakeNet model, covered above. Vietnamese font coverage is unverified, since font_puhui_basic_16_4 is a subset and logs printing full diacritics tells you nothing about what renders on the LCD.
The microphone is mechanically weak. Kamoji's driver carries the comment "Software gain to compensate for PCB acoustic attenuation", so the acoustic path attenuates before any code sees it. xiaozhi relies on AGC inside AFE instead, which is why Read() needs the shift of 11. If it still comes out quiet, >> 10 doubles it again, at the cost of clipping the loud end.
Do not try to self-host with the vietbot_server repository. This firmware speaks the xiaozhi protocol over websocket or MQTT with OPUS, and none of the message types that README describes exist in main/protocols/. Use the hosted server at https://vietbot.vn/ota/.
Getting help
Email build failures to support@kamoji.bot with your ESP-IDF version, the grep output on sdkconfig from the build section, and the first 40 lines of serial output after a reset.