원문 캡처 · github.com
원문 캡처 · github.com

Whistle 음성 인식기를 ESP32-S3에서 실행하는 프로젝트다. 버튼을 누른 채 “flash the light blue”라고 말하면 LED가 파란색으로 깜박인다. 음성 인식은 10달러짜리 마이크로컨트롤러에서 네트워크·클라우드·휴대전화 없이 작동하며, 정해진 문구를 찾는 대신 말한 내용을 전사하고 7개 언어의 명령을 이해한다.

이 프로젝트가 사용하는 모델은 Cactus Compute의 Whistle이며 라이선스는 Apache 2.0이다. Cactus는 ESP32 빌드나 Whistle 엔진 소스를 제공하지 않는다. 이 프로젝트는 의존성 없는 독립적인 C99 구현이며, 공식 엔진과 텐서 값을 하나씩 대조했다고 설명한다.

필요한 부품과 배선

  • ESP32-S3 보드 N16R8: 플래시 16MB, 옥탈 PSRAM 8MB가 필요하다. 모델 자체는 14.5MB다. ESP32-S3-DevKitC-1 N16R8 또는 복제품을 사용할 수 있다.
  • INMP441 I²S 마이크 모듈: 흔히 판매되는 원형 브레이크아웃 보드다.
  • 순간식 버튼: 촉각 스위치면 된다.
  • 점퍼선과 브레드보드: 전선 8개가 필요하다.
  • 보드의 기본 USB 포트에 연결할 USB 케이블.

RGB LED는 보드에 이미 있는 WS2812를 사용한다. 배선은 다음과 같다.

  • INMP441 VDD는 3V3에 연결한다. 5V가 아니라 3.3V를 사용한다.
  • INMP441 GND는 GND에 연결한다.
  • INMP441 L/R은 GND에 연결한다. 펌웨어가 읽는 왼쪽 채널을 선택한다.
  • INMP441 SCK는 GPIO4에 연결한다. I²S 비트 클록이다.
  • INMP441 WS는 GPIO5에 연결한다. I²S 워드 선택 신호다.
  • INMP441 SD는 GPIO6에 연결한다. I²S 데이터다.
  • 버튼은 GPIO7과 GND 사이에 연결한다. 내부 풀업을 사용하므로 저항은 필요 없다.
  • WS2812 LED는 GPIO48에 연결한다. 보드에 이미 실장돼 있다.

복제품은 헤더 핀 배치가 다를 수 있으므로 보드에 표시된 GPIO 번호를 기준으로 연결한다. LED가 다른 핀에 연결된 경우에는 esp32/whistle_demo/main/main.c 맨 위의 ONBOARD_GPIO를 바꾼다. DevKitC-1 v1.1은 GPIO38을 사용한다. 배선 그림은 인쇄용 PNG 파일로도 제공된다.

다른 작업에 앞서 마이크부터 확인한다. 프로젝트 설명에 따르면 느슨한 점퍼선이 가장 흔한 문제다. esp32/mic_test는 버튼을 누르는 동안 녹음하고, tools/mic_capture.py는 각 녹음 파일을 WAV로 저장해 들을 수 있게 한다. 음성 신호는 노이즈 바닥보다 충분히 커야 하며, 예시로 약 -45dBFS 대 -68dBFS가 제시된다. jumps=0이어야 한다. 0xAAAA처럼 정확한 비트 패턴이 이어지면 SCK, WS 또는 전원 접촉이 불량한 것이다.

설치와 펌웨어 기록

ESP-IDF v5.5 이상, Python 3, CMake가 필요하다. 모델을 내려받고 4개 디코더 레이어로 줄인 뒤 펌웨어와 모델을 각각 기록한다.

sh
# 1. 호스트 도구
python3 -m venv .venv
.venv/bin/pip install numpy rich pyserial huggingface_hub

# 2. Whistle(16.9 MB)을 가져와 디코더 레이어 4개로 줄인다(14.5 MB)
mkdir -p model/whistle && .venv/bin/python -c "
from huggingface_hub import hf_hub_download; import shutil
shutil.copy(hf_hub_download('Cactus-Compute/whistle','whistle.cact'), 'model/whistle/whistle.cact')"
.venv/bin/python tools/whistle_strip.py model/whistle/whistle.cact 4 model/whistle/whistle_d4.cact

# 3. 펌웨어(ESP-IDF가 PATH에 있어야 한다)
. $HOME/esp/esp-idf/export.sh
cd esp32/whistle_demo
idf.py set-target esp32s3 && idf.py build
idf.py -p /dev/cu.usbmodemXXXX flash # 부트로더, 파티션 테이블, 앱
cd ../..

# 4. 모델을 별도 플래시 파티션에 기록한다(약 100초)
esptool.py --chip esp32s3 -p /dev/cu.usbmodemXXXX -b 921600 \
 write_flash 0x210000 model/whistle/whistle_d4.cact

포트는 macOS에서 ls /dev/cu.usb*, Linux에서 ls /dev/ttyACM*로 찾는다. 모델은 한 번만 기록하면 된다. 코드를 바꾼 뒤에는 3단계만 다시 실행한다.

모델을 줄이는 이유는 전체 모델이 보드의 플래시 용량보다 크기 때문이다. Whistle의 디코더는 계층형 구조로, 2개 이상의 각 깊이마다 단독으로 작동하도록 학습됐다. whistle_strip.py는 지정한 깊이에 필요한 레이어만 남긴다. 모델 파티션이 플래시 끝까지 이어지도록 설정하면 깊이 4 모델이 들어간다. 설정 파일은 esp32/whistle_demo/partitions.csv다.

동작 중 전사 결과와 감지 언어, LED 동작, 각 단계에 걸린 시간을 콘솔에서 보려면 다음 명령을 실행한다. 콘솔 없이도 보드는 작동한다.

sh
.venv/bin/python tools/needle_tui.py --whistle --serial /dev/cu.usbmodemXXXX

보드에서 사용하기

모델을 불러오는 동안 LED가 약 1초간 희미한 파란색으로 켜졌다가 꺼진다. 이 상태면 사용할 준비가 된 것이다.

  • 버튼을 누르면 LED가 흰색으로 켜지고 녹음을 시작한다.
  • 명령을 말한다.
  • 버튼을 놓으면 LED가 희미하게 점멸하며 음성을 전사한 뒤 명령을 실행한다.

명령에는 색상(red, green, blue, yellow, purple, white)이 들어가며, 깜박임 또는 점멸, 점멸 속도, 지속 시간을 지정할 수 있다. 기본 지속 시간은 3초다. 예를 들어 “Make it red.”는 빨간색 고정, “Turn the light green for five seconds.”는 초록색으로 5초간 켜기, “Flash the light blue.”는 파란색 깜박임, “Blink purple every half a second.”는 보라색으로 초당 두 번 점멸하는 명령이다.

빨간색으로 두 번 깜박이면 잡음만 들은 것이고, 주황색으로 한 번 깜박이면 단어는 들었지만 색상은 찾지 못한 것이다. 맨색 이름만 말하기보다 짧은 문장으로 말하는 편이 잘 인식됐다. 시험 음성 6개에서 “make it red”는 86~93%, “red”만 말했을 때는 44% 인식됐다.

언어는 자동으로 감지한다. 아래 7개 언어가 모두 보드에 동시에 포함된다.

언어명령 예시색상 단어
영어Flash the light blue.red, green, blue, yellow, purple, white
프랑스어Fais clignoter la lumière en violet.rouge, vert, bleu, jaune, violet, blanc
스페인어Pon la luz amarilla.rojo, verde, azul, amarillo, morado, blanco
독일어Lass das Licht rot blinken.rot, grün, blau, gelb, lila, weiß
이탈리아어Fai lampeggiare la luce verde.rosso, verde, blu, giallo, viola, bianco
네덜란드어Laat het licht blauw knipperen.rood, groen, blauw, geel, paars, wit
폴란드어Niech światło miga na czerwono.czerwony, zielony, niebieski, żółty, fioletowy, biały

각 언어의 ‘깜박이다’는 프랑스어 clignoter, 스페인어 parpadear, 독일어 blinken, 이탈리아어 lampeggiare, 네덜란드어 knipperen, 폴란드어 migać다. 1부터 10까지의 숫자와 ‘절반’을 각 언어에서 사용할 수 있다. 예를 들면 toutes les deux secondes 또는 alle zwei Sekunden처럼 말한다.

처리 시간과 인식 결과

보드에서 240MHz, 깊이 4로 2초 길이의 영어 명령을 측정한 결과, 전체 처리 시간은 6.3초였다.

단계시간
로그-멜 스펙트로그램0.1초
합성곱 스템0.9초
인코더(8개 레이어)3.2초
디코더(9개 토큰)2.2초
전체6.3초

발화가 길수록 인코더가 80ms마다 생기는 모든 프레임을 처리하므로 시간이 더 걸린다. 다른 언어는 토큰 수가 더 많을 수 있다. 3.2초짜리 프랑스어 문장은 12.6초가 걸렸다.

행렬 곱셈은 S3의 PIE 벡터 유닛에서 int8로 실행되며, 명령 하나당 곱셈-누산 16회를 처리한다. 프로젝트 설명에 따르면 Whistle 가중치는 int8 코드북 값으로 정확히 디코딩되고 활성값에는 폭 128인 그룹마다 하나의 스케일을 적용하므로 정확도는 바뀌지 않는다. 360개 시험 클립에서 색상 명령을 맞힌 비율은 int8에서 80%, 부동소수점에서 78%였다. 같은 명령을 처음 작동한 빌드에서는 23.9초가 걸렸다.

명령 정확도는 색상과 깜박임/고정 여부를 모두 맞힌 경우를 뜻한다. macOS 합성 음성 6개, 표현 방식 5개, 색상 6개를 사용해 깨끗한 음성과 실제 INMP441 잡음을 섞은 음성을 평가했다. 둘째 행은 Cactus의 전체 모델을 공식 엔진과 동일한 파서로 실행한 결과다.

측정 대상영어독일어프랑스어스페인어이탈리아어네덜란드어폴란드어
이 보드(깊이 4), 깨끗한 음성/잡음 포함78 / 76%57 / 55%72 / 67%80 / 85%63 / 65%42 / 50%70 / 43%
공식 엔진, 전체 모델-37 / 32%63 / 48%63 / 63%42 / 40%13 / 17%40 / 30%
언어를 올바르게 감지한 비율98%70%88%84%71%52%83%

프로젝트는 보드가 전체 모델보다 대체로 나은 결과를 낸 이유로 펌웨어가 각 언어의 색상 및 깜박임 단어 쪽으로 디코딩을 편향시키는 점을 든다. 첫 전사 결과에 색상이 없으면 다음으로 가능성이 높은 언어 두 개에서도 다시 디코딩한다. 이 결과에서 색상 단어가 나오고 모델이 그 언어를 거의 비슷한 확률로 찾을 때만 받아들인다. 기준은 토큰당 0.2나트 이내다. 기준을 느슨하게 하면 일상 문장이 색상 단어를 포함한 엉뚱한 문장으로 바뀌었다.

영어·스페인어·프랑스어는 안정적이고 독일어·이탈리아어·폴란드어도 작동한다. 네덜란드어는 약하다. 프로젝트는 Whistle 자체의 언어 감지가 해당 클립 가운데 약 절반만 네덜란드어로 판단하는 점을 원인으로 든다. 색상 단어가 전혀 없는 문장 137개 중 2개가 명령으로 처리됐다.

일반 음성에서는 깊이 4가 LibriSpeech 발화 73개에서 단어 오류율 11.7%를 기록했다. 깊이 2는 15.3%였다. 시험 클립을 제공하면 tools/eval_whistle.py와 tools/eval_languages.py로 이 수치를 재현할 수 있다고 설명한다.

문제 해결

대부분의 문제는 오류 메시지 없이 발생한다. 보드는 계속 실행되고 모델도 그럴듯한 텍스트를 계속 출력할 수 있다.

  • 직렬 출력이 없다: 펌웨어는 기본 USB 포트 콘솔을 사용하며 설정은 CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y다. 보드가 UART 브리지를 통해 연결됐다면 sdkconfig.defaults에서 해당 줄을 지우고 sdkconfig를 삭제한 뒤 다시 빌드한다.
  • 보드가 waiting for download에서 멈춘다: pyserial 기본 설정으로 기본 포트를 열면 칩이 다운로드 모드에 고정될 수 있다. 프로젝트 도구는 DTR과 RTS를 낮게 설정해 포트를 연다. 초기화하려면 다음 명령을 실행한다.
sh
esptool.py --after watchdog_reset read_mac
  • 오류 메시지 없이 부팅 루프가 발생한다: PSRAM 라인 모드가 잘못된 경우다. 8MB 부품은 옥탈 모드인 CONFIG_SPIRAM_MODE_OCT=y를 사용해야 하며, 프로젝트 기본 설정에는 이미 적용돼 있다.
  • 작동하지만 약 1.5배 느리다: ESP-IDF는 기본적으로 S3를 160MHz로 설정한다. CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y는 문서에 명시된 최대 주파수이며 오버클록이 아니다. 프로젝트 기본 설정에 이미 적용돼 있다.
  • 빈 방에서 “Thank you.”라고 전사한다: 공식 엔진을 포함한 Whistle은 마이크 잡음 10초를 “Thank you.”로 전사할 수 있다. 펌웨어는 버튼을 누르는 사이에 노이즈 바닥을 측정하고, 그보다 높은 음성이 감지될 때만 모델을 실행한다.
  • 오디오 대역을 제한하거나 음량을 키우지 않는다: Whistle 블로그는 전처리 대역이 250~3500Hz라고 설명하지만, 배포된 필터뱅크는 31~7969Hz를 처리한다. 대역 통과 필터를 적용한 클립에서는 “turn the light red”가 “turn the leg red”로 바뀌었다. 엔진이 게인을 정규화하므로 낮은 음량보다 클리핑이 더 큰 문제다.
  • 클릭음이 다른 소리를 작게 만든다: 엔진은 각 클립의 최고점을 1로 맞춘다. 버튼 클릭이나 INMP441의 약 85ms 시작 과도 현상처럼 단일 신호가 튀면 음성 신호가 작아진다. 펌웨어는 마이크 클록을 계속 유지하고 버튼을 누른 직후 처음 몇 밀리초를 버린다.
  • 보드에서 double을 사용하지 않는다: S3의 부동소수점 장치는 단정밀도이므로 double은 소프트웨어로 에뮬레이션된다. double 누산기 하나와 FFT의 double 회전 인자가 2초 클립 처리 시간을 24초로 늘렸다. 출력은 여전히 올바르기 때문에 호스트에서는 문제가 드러나지 않았다.

보드 없이 시험하기

호스트 빌드는 같은 엔진을 컴퓨터에서 실행한다. clip.wav는 16kHz, 모노, 16비트여야 한다. 첫 번째 명령에 -DCMAKE_C_FLAGS=-DND_PROFILE을 추가하면 단계별 처리 시간을 확인할 수 있다.

sh
cmake -S host -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build -j8
./build/wh_run model/whistle/whistle_d4.cact clip.wav --led-keywords --gate 12
.venv/bin/python tools/needle_tui.py --whistle --local clip.wav

콘솔 회귀 테스트는 앞서 설치한 rich 의존성을 사용하며, 모델·보드·직렬 연결은 필요 없다. 저장소 루트에서 실행한다.

sh
.venv/bin/python -m unittest discover -s tests -v

엔진 검증과 저장소 구성

Whistle의 소스는 공개되지 않았고 공식 엔진은 미리 빌드된 라이브러리다. tools/whistle_ref.py는 NumPy 참조 구현이다. 문서화되지 않은 세부 사항은 tools/whistle_probe.py로 라이브러리를 조사해 파악했으며, 모든 텐서 값을 학습 체크포인트와 대조했다고 프로젝트는 설명한다.

sh
.venv/bin/pip install cactus-needle safetensors
.venv/bin/python tools/whistle_cact.py model/whistle/whistle.cact # 텐서, 크기, 깊이
.venv/bin/python tools/check_whistle.py model/whistle/whistle.cact ./build/wh_run path/to/*.wav

검증 결과는 다음과 같다.

검사결과
whistle.safetensors와 텐서 676개 비교문제 0개
C와 NumPy 로짓 비교(증분 처리와 전체 시퀀스)차이 < 6e-5, 최댓값 인덱스 동일
NumPy 인코더와 공식 엔진 비교(음성 프레임)중앙값 약 1%, 최악 < 3%
전사 결과와 needle.transcribe() 비교동일하거나 대소문자·문장 부호만 다름
--depth 2..4와 공식 needle --audio-depth 비교동일
whistle_strip.py 출력과 전체 모델의 --depth 비교로짓 비트 단위로 동일

cactus-needle은 기본 설정에서 익명 사용량을 전송한다. NEEDLE_TELEMETRY=0으로 끌 수 있으며, 프로젝트 도구는 전송하지 않도록 설정돼 있다.

저장소에는 의존성 없는 이식형 C99 엔진, ESP32 음성-LED 펌웨어, 마이크·버튼 확인용 프로그램, 호스트 도구, 콘솔 UI와 모델·평가 도구, 배선 문서가 들어 있다. engine/nd_whistle.c는 Whistle의 로그-멜 처리와 인코더·디코더를, nd_ledcmd.c는 7개 언어의 전사 결과를 LED 명령으로 바꾸는 기능을 담는다. nd_quant.c에는 int8 PIE 경로를 포함한 Cactus-Quants 커널이, nd_cact.c에는 .cact v3/v4 컨테이너 판독기가 있다. nd_model.c 등은 Needle 2용이다. esp32/whistle_demo/는 버튼을 눌러 말하면 LED를 제어하는 펌웨어, esp32/mic_test/는 마이크와 버튼 점검 프로그램, esp32/needle_demo/는 Needle 2 데모다. 호스트 도구는 host/, 콘솔 UI와 모델 도구·참조 구현·평가 도구는 tools/, 배선 문서는 docs/에 있다. 배선도는 tools/wiring_diagram.py로 그린다.

이 프로젝트는 같은 보드에 Cactus의 4,500만 매개변수 도구 호출 언어 모델인 Needle 2를 포팅하면서 시작됐다. Whistle은 더 새로운 Needle 3 컨테이너(.cact v4)로 배포되고 디코더는 Needle 3 블록이므로 엔진은 두 형식을 모두 읽는다. Needle 2 데모는 esp32/needle_demo에 남아 있으며, 해당 저장소의 README에 설명이 있다. Whistle, Needle 2와 가중치는 Cactus Compute의 것이며 Apache 2.0 라이선스를 따른다. 가중치는 이 저장소에 포함되지 않고 설치 2단계에서 Hugging Face로부터 내려받는다. 이 프로젝트의 코드도 Apache 2.0 라이선스다.