Z każdym swoim kolejnym asystentem głosowym mówię sobie: to już był ostatni, nie mam na to czasu…
Tutaj motywacją było pojawienie się opcji stereo mikrofonu i z taką myślą stworzyłem oto taką czarną kulę:
Plik to wydruku znajdziesz tu:
8-ball.zip (4,3 MB)
Sam projekt pochłonął gigantyczną część wolnego czasu (prawie 2 miesiące), 1kg filamentu do drukarki, sporo gotówki i jeszcze więcej nerwów. Postaram się skupić na tym co najważniejsze i na swoich przemyśleniach a nagromadziło się ich mnóstwo. Najważniejszy cel nie został osiągnięty czyli mikrofon stereo. Nikt na razie tego nie uruchomił na esphome i nikt nie zgłosił takiego problemu więc założyłem issue i czekam na odzew.
Z racji tego że udało mi się skompletować 2 takie gadające kule to mam okazję porównać jak sobie radzą różne frameworki z tym samym zadaniem. O ile na arduino zbudowałem do praktycznie w tydzień, o tyle z ESP-IDF były wieczne problemy wynikające bardziej z mojej niewiedzy niż z samego środowiska.
Budowa:
- ESP32 S3 N16R8 (do ESP-IDF)
- ESP32 S3 ZERO (do Arduino)
- 2 głośniki 40mm 3W 4ohm
- 2 wzmacniacze MAX98357
- mikrofon INMP411
- pasek led (wykorzystałem chip SK6812 72d/metr 5V)
- buzzer 3V
- gniazdo usb C
- 3 tact switch 12x12
Kod do ESP-IDF:
substitutions:
leds: "28" # number of diodes in the strip
vol_step: "0.1" # media player volume step
waiting_time: "8s" # duration of listening
# PINOUT
pin_buzzer: GPIO10
pin_leds: GPIO38
pin_lrclk_mic: GPIO16 #WS
pin_bclk_mic: GPIO17 #SCK
pin_din_mic: GPIO15
pin_lrclk_spk: GPIO5
pin_bclk_spk: GPIO6
pin_din_spk: GPIO7
pin_vol_up: "42"
pin_vol_down: "40"
pin_play: "41"
esphome:
name: 8-ball
friendly_name: 8-ball
min_version: 2024.12.4
platformio_options:
board_build.flash_mode: dio
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
flash_size: 16MB
framework:
type: esp-idf
version: recommended
sdkconfig_options:
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y"
CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"
CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB: "y"
CONFIG_ESP32_S3_BOX_BOARD: "y"
CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY: "y"
CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP: "y"
# Settings based on https://github.com/espressif/esp-adf/issues/297#issuecomment-783811702
CONFIG_ESP32_WIFI_STATIC_RX_BUFFER_NUM: "16"
CONFIG_ESP32_WIFI_DYNAMIC_RX_BUFFER_NUM: "512"
CONFIG_ESP32_WIFI_STATIC_TX_BUFFER: "y"
CONFIG_ESP32_WIFI_TX_BUFFER_TYPE: "0"
CONFIG_ESP32_WIFI_STATIC_TX_BUFFER_NUM: "8"
CONFIG_ESP32_WIFI_CACHE_TX_BUFFER_NUM: "32"
CONFIG_ESP32_WIFI_AMPDU_TX_ENABLED: "y"
CONFIG_ESP32_WIFI_TX_BA_WIN: "16"
CONFIG_ESP32_WIFI_AMPDU_RX_ENABLED: "y"
CONFIG_ESP32_WIFI_RX_BA_WIN: "32"
CONFIG_LWIP_MAX_ACTIVE_TCP: "16"
CONFIG_LWIP_MAX_LISTENING_TCP: "16"
CONFIG_TCP_MAXRTX: "12"
CONFIG_TCP_SYNMAXRTX: "6"
CONFIG_TCP_MSS: "1436"
CONFIG_TCP_MSL: "60000"
CONFIG_TCP_SND_BUF_DEFAULT: "65535"
CONFIG_TCP_WND_DEFAULT: "65535" # Adjusted from linked settings to avoid compilation error
CONFIG_TCP_RECVMBOX_SIZE: "512"
CONFIG_TCP_QUEUE_OOSEQ: "y"
CONFIG_TCP_OVERSIZE_MSS: "y"
CONFIG_LWIP_WND_SCALE: "y"
CONFIG_TCP_RCV_SCALE: "3"
CONFIG_LWIP_TCPIP_RECVMBOX_SIZE: "512"
CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST: "y"
CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY: "y"
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC: "y"
CONFIG_MBEDTLS_SSL_PROTO_TLS1_3: "y" # TLS1.3 support isn't enabled by default in IDF 5.1.5
# Enable logging
logger:
# Enable Home Assistant API
api:
encryption:
key: "3d3RcoYaHkHmaFCrx2jgzkpIYjvhMqZAx3ASUXlKu8U="
on_client_connected:
- media_player.volume_set: !lambda "return 0.60;"
- light.turn_on:
id: ring
red: 0%
green: 100%
blue: 0%
brightness: 50%
- delay: 3s
- light.turn_on:
id: ring
red: 0%
green: 100%
blue: 100%
brightness: 50%
- script.execute: reset_ww
- delay: 1s
- light.turn_off: ring
ota:
- platform: esphome
password: "xxxxxxxxxxxxxxxxxxxxxx"
psram:
mode: octal
speed: 80MHz
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
manual_ip:
static_ip: xxxxxxxxxx
gateway: xxxxxxxxxxx
subnet: xxxxxxxxxxx
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "8-Ball Fallback Hotspot"
password: "xxxxxxxxxxxxxx"
captive_portal:
external_components:
- source:
type: git
url: https://github.com/esphome/home-assistant-voice-pe
ref: dev
components:
- media_player
- nabu
refresh: 0s
button:
- platform: restart
name: Reboot
text_sensor: # unlock this option in Home Assistant
- platform: homeassistant
id: assist_satellite
entity_id: assist_satellite.8_ball_assist_satellite
internal: true
on_value:
- if:
condition:
lambda: 'return id(assist_satellite).state == "responding";'
then:
- script.execute: speaking
- wait_until:
lambda: 'return id(assist_satellite).state == "idle";'
- script.execute: reset_ww
- light.turn_off: ring
- if:
condition:
lambda: 'return id(assist_satellite).state == "processing";'
then:
- light.turn_off: ring
- script.execute: reset_ww
- if:
condition:
lambda: 'return id(assist_satellite).state == "listening";'
then:
- script.execute: detecting
- wait_until:
condition:
not:
lambda: 'return id(assist_satellite).state == "listening";'
timeout: ${waiting_time}
- voice_assistant.stop
- light.turn_off: ring
- script.execute: reset_ww
voice_assistant:
id: va
microphone: asr_mic
media_player: player
use_wake_word: false
noise_suppression_level: 2
auto_gain: 31dBFS
on_tts_end:
- delay: 100ms
- script.execute: reset_ww
on_stt_end:
- delay: 100ms
- script.execute: reset_ww
on_error:
- delay: 100ms
- script.execute: reset_ww
- script.execute: error
micro_wake_word:
id: mww
models:
- model: alexa
microphone: asr_mic
on_wake_word_detected:
- voice_assistant.start:
wake_word: !lambda return wake_word;
- delay: 100ms
- rtttl.play: 'two_short:d=4,o=5,b=100:16e6,16e6'
- if:
condition:
lambda: return id(player)->state == media_player::MediaPlayerState::MEDIA_PLAYER_STATE_PLAYING;
then:
- media_player.pause: player
- wait_until:
condition:
lambda: 'return id(assist_satellite).state == "idle";'
timeout: 5s
- media_player.play: player
i2s_audio:
- id: i2s_spk
i2s_lrclk_pin: ${pin_lrclk_spk}
i2s_bclk_pin: ${pin_bclk_spk}
- id: i2s_mic
i2s_lrclk_pin: ${pin_lrclk_mic}
i2s_bclk_pin: ${pin_bclk_mic}
microphone:
- platform: i2s_audio
id: asr_mic
adc_type: external
i2s_din_pin: ${pin_din_mic}
channel: left
i2s_audio_id: i2s_mic
speaker:
- platform: i2s_audio
sample_rate: 48000
i2s_mode: primary
i2s_dout_pin: ${pin_din_spk}
bits_per_sample: 32bit
i2s_audio_id: i2s_spk
dac_type: external
channel: mono
timeout: never
buffer_duration: 100ms
media_player:
- platform: nabu
id: player
name: glosnik
internal: false
speaker:
sample_rate: 48000
volume_increment: ${vol_step}
volume_min: 0.1
volume_max: 1
on_announcement:
- script.execute: speaking
- wait_until:
condition:
media_player.is_idle: player
- light.turn_off: ring
- media_player.stop: player
- script.execute: reset_ww
switch:
- platform: template
id: assist
icon: mdi:account-tie-voice
name: "Asystent"
optimistic: true
restore_mode: RESTORE_DEFAULT_ON
on_turn_on:
- micro_wake_word.start
on_turn_off:
- micro_wake_word.stop
binary_sensor:
- platform: gpio
pin:
number: ${pin_vol_up}
mode: INPUT_PULLUP
inverted: false
name: "Vol+"
internal: true
on_press:
- if:
condition:
lambda: 'return id(player).volume < 1.00;'
then:
- media_player.volume_up
- script.execute: volume
- platform: gpio
pin:
number: ${pin_vol_down}
mode: INPUT_PULLUP
inverted: false
name: "Vol-"
internal: true
on_press:
- if:
condition:
lambda: 'return id(player).volume > 0.0;'
then:
- media_player.volume_down
- script.execute: volume
- platform: gpio
pin:
number: ${pin_play}
mode: INPUT_PULLUP
inverted: false
name: "Play/Pause"
internal: true
on_press:
- media_player.toggle
- script.execute: reset_ww
output:
- platform: ledc
pin: ${pin_buzzer}
id: rtttl_out
rtttl:
output: rtttl_out
id: my_rtttl
light:
- platform: esp32_rmt_led_strip
default_transition_length: 0.5s
id: ring
chipset: SK6812
pin: ${pin_leds}
num_leds: ${leds}
rgb_order: GRB
rmt_channel: 0
name: "Ring"
effects:
- pulse:
name: "Pulse"
transition_length: 300ms
update_interval: 250ms
min_brightness: 15%
max_brightness: 60%
- addressable_color_wipe:
name: "Detecting"
colors:
- red: 10%
green: 100%
blue: 100%
num_leds: 7
gradient: false
- red: 0%
green: 0%
blue: 0%
num_leds: 19
add_led_interval: 12ms
reverse: true
sensor:
- platform: homeassistant
id: vol_led # number of light diodes
internal: true
entity_id: media_player.8_ball_glosnik
attribute: volume_level
accuracy_decimals: 0
filters:
- lambda: return x / ${vol_step} * 2 ; # 2 diodes on one step volume
- round: 0
script:
- id: detecting
then:
- light.turn_on:
id: ring
red: 20%
green: 80%
blue: 100%
brightness: 50%
effect: Detecting
- id: speaking
then:
- light.turn_on:
id: ring
red: 0%
green: 100%
blue: 0%
brightness: 50%
effect: Pulse
- id: error
then:
- light.turn_on:
id: ring
red: 100%
green: 0%
blue: 0%
brightness: 50%
effect: none
- delay: 2s
- light.turn_off: ring
- id: volume
then:
- light.addressable_set:
id: ring
range_from: 0
range_to: !lambda return id(vol_led).state;
red: 100%
green: 5%
blue: 80%
- delay: 30ms
- light.turn_off: ring
- id: reset_ww
then:
- micro_wake_word.stop
- delay: 250ms
- micro_wake_word.start
Można powiedzieć że wreszcie na frameworku IDF pojawia się media player za sprawą wejścia na rynek Voice Assistant PE gdzie został też udostępniony kod: GitHub - esphome/home-assistant-voice-pe: Home Assistant Voice PE. Część ludzi na tym kodzie bazuje swoje projekty i jeśli ktoś by chciał w 8-ballu mieć to samo to nic nie stoi na przeszkodzie Przed tym kodem miałem też media playera opartego na esp-adf ale jednak postanowiłem wykorzystać najświeższą bibliotekę od PE. Mój kod może nie jest tak dopieszczony tak tamten ale nie chciałem burzyć swojej koncepcji obcym projektem. W tym asystencie jest też wykorzystany Micro Wake Word więc “myślenie” odbywa się po stronie płytki i to jest mega plus dla tego rozwiązania bo “nie ma spamu w logach” tak jak jest to w przypadku Arduino. Co do odtwarzania muzyki i ciągle działającego mikrofonu… wg mnie jakość dźwięku jest kiepska (ale może być to też przyczyna innych głośników), głośność jest zdecydowanie niższa niż w przypadku Arduino a działający mikrofon w trakcie grania muzyki praktycznie jest cały czas zagłuszany przez co wybudzenie asystenta graniczy z cudem. Może to być problem mojej konstrukcji głośnika lub mojego kodu bo podobno oryginalny kod od Voice PE nie ma z tym problemów (podobno).
Czas na Arduino:
substitutions:
leds: "24"
vol_step: "0.1"
# PINOUT
pin_buzzer: GPIO6
pin_leds: GPIO5
pin_lrclk_mic: GPIO1 #WS
pin_bclk_mic: GPIO2 #SCK
pin_din_mic: GPIO4
pin_lrclk_spk: GPIO7
pin_bclk_spk: GPIO8
pin_din_spk: GPIO9
pin_vol_up: "12"
pin_vol_down: "11"
pin_play: "13"
esphome:
name: "8a-ball"
friendly_name: "8a-ball"
esp32:
board: esp32-s3-devkitc-1
framework:
type: arduino
psram:
mode: quad
speed: 80MHz
# Enable logging
logger:
# Enable Home Assistant API
api:
encryption:
key: "ossjF+4Ajlf5IOeTrDnflxyU6olNM+VpPauU25xg9U0="
on_client_connected:
- media_player.volume_set: !lambda "return 0.40;"
- light.turn_on:
id: ring
red: 0%
green: 100%
blue: 0%
brightness: 100%
- delay: 2s
- script.execute: reset_ww
- delay: 1s
- light.turn_off: ring
ota:
- platform: esphome
password: "xxxxxxxxxxxxxx"
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
fast_connect: on
manual_ip:
static_ip: xxxxxxxxxxxxxx
gateway: xxxxxxxxxxxxx
subnet: xxxxxxxxxxxxxxxx
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "8a-Ball Fallback Hotspot"
password: "xxxxxxxxxxxxx"
captive_portal:
sensor:
- platform: homeassistant
id: vol_led #ilosc led w zaleznosci od volume
internal: true
entity_id: media_player.8a_ball_glosnik
attribute: volume_level
accuracy_decimals: 0
filters:
- lambda: return x / ${vol_step} * 2 ;
- round: 0
output:
- platform: ledc
pin: ${pin_buzzer}
id: rtttl_out
rtttl:
output: rtttl_out
id: my_rtttl
button:
- platform: restart
name: Reboot
binary_sensor:
- platform: gpio
pin:
number: ${pin_vol_up}
mode: INPUT_PULLUP
inverted: false
name: "Vol+"
internal: true
on_press:
- if:
condition:
lambda: 'return id(player).volume < 1.0;'
then:
- homeassistant.action:
action: media_player.volume_set
data_template:
entity_id: media_player.8a_ball_glosnik
volume_level: "{{ state_attr('media_player.8a_ball_glosnik', 'volume_level') + ${vol_step} }}"
- script.execute: volume
- platform: gpio
pin:
number: ${pin_vol_down}
mode: INPUT_PULLUP
inverted: false
name: "Vol-"
internal: true
on_press:
- if:
condition:
lambda: 'return id(player).volume > 0.0;'
then:
- homeassistant.action:
action: media_player.volume_set
data_template:
entity_id: media_player.8a_ball_glosnik
volume_level: "{{ state_attr('media_player.8a_ball_glosnik', 'volume_level') - ${vol_step} }}"
- script.execute: volume
- platform: gpio
pin:
number: ${pin_play}
mode: INPUT_PULLUP
inverted: false
name: "Play/Pause"
internal: true
on_press:
- homeassistant.action:
action: media_player.media_play_pause
data_template:
entity_id: media_player.8a_ball_glosnik
light:
- platform: fastled_clockless
default_transition_length: 0.5s
id: ring
chipset: WS2812
pin: ${pin_leds}
num_leds: ${leds}
rgb_order: GRB
name: "Ring"
effects:
- pulse:
name: "Pulse"
transition_length: 300ms
update_interval: 200ms
min_brightness: 10%
max_brightness: 100%
- addressable_scan:
name: "Scan"
move_interval: 30ms
scan_width: 8
- addressable_color_wipe:
name: "Detecting"
colors:
- red: 10%
green: 100%
blue: 100%
num_leds: 8
gradient: false
- red: 0%
green: 0%
blue: 0%
num_leds: 19
add_led_interval: 13ms
reverse: true
text_sensor:
- platform: homeassistant
id: assist_satellite
entity_id: assist_satellite.8a_ball_assist_satellite
internal: true
on_value:
- if:
condition:
lambda: 'return id(assist_satellite).state == "responding";'
then:
- script.execute: talk
- wait_until:
lambda: 'return id(assist_satellite).state == "idle";'
- light.turn_off: ring
- script.execute: reset_ww
i2s_audio:
- id: i2s_mic #INMP411
i2s_lrclk_pin: ${pin_lrclk_mic} #WS
i2s_bclk_pin: ${pin_bclk_mic} #SCK
- id: i2s_out #MAX98357A
i2s_lrclk_pin: ${pin_lrclk_spk} #LRCLK
i2s_bclk_pin: ${pin_bclk_spk} #BLCK
media_player:
- platform: i2s_audio
id: player
name: Glosnik
dac_type: external
i2s_audio_id: i2s_out
mode: stereo
i2s_dout_pin: ${pin_din_spk} #DIN,SD
on_play:
- switch.turn_off: assist
on_pause:
- switch.turn_on: assist
on_idle:
- switch.turn_on: assist
on_announcement:
- switch.turn_off: assist
- script.execute: talk
- wait_until:
condition:
media_player.is_idle: player
- light.turn_off: ring
- media_player.stop: player
- switch.turn_on: assist
microphone:
- platform: i2s_audio
id: mic
adc_type: external
channel: left
bits_per_sample: 32bit
i2s_audio_id: i2s_mic
i2s_din_pin: ${pin_din_mic} #DIN,SDIN,SD,SDATA
pdm: false
voice_assistant:
id: va
microphone: mic
media_player: player
use_wake_word: true
conversation_timeout: 60s
noise_suppression_level: 2
auto_gain: 10dBFS
on_listening:
- script.execute: detecting
- rtttl.play: 'two_short:d=4,o=5,b=100:16e6,16e6'
on_tts_start:
- script.execute: talk
on_stt_end:
then:
- delay: 100ms
- wait_until:
not:
media_player.is_playing:
- script.execute: reset_ww
- light.turn_off: ring
on_tts_end:
then:
- delay: 100ms
- wait_until:
not:
media_player.is_playing:
- script.execute: reset_ww
- light.turn_off: ring
on_error:
- light.turn_on:
id: ring
red: 100%
green: 0%
blue: 0%
brightness: 100%
effect: none
- delay: 2s
- script.execute: reset_ww
- light.turn_off: ring
switch:
- platform: template
name: Use wake word
id: use_wake_word
optimistic: true
internal: true
restore_mode: RESTORE_DEFAULT_ON
entity_category: config
on_turn_on:
- lambda: id(va).set_use_wake_word(true);
- if:
condition:
not:
- voice_assistant.is_running
then:
- voice_assistant.start_continuous
on_turn_off:
- voice_assistant.stop
- lambda: id(va).set_use_wake_word(false);
- platform: template
id: assist
icon: mdi:account-tie-voice
name: "Asystent"
optimistic: true
restore_mode: RESTORE_DEFAULT_ON
on_turn_on:
- switch.turn_on: use_wake_word
on_turn_off:
- switch.turn_off: use_wake_word
interval:
- interval: 300s
then:
- if:
condition:
- switch.is_on: assist
then:
- script.execute: reset_ww
- light.turn_off: ring
script:
- id: reset_ww
then:
- switch.turn_off: use_wake_word
- delay: 250ms
- switch.turn_on: use_wake_word
- id: detecting
then:
- light.turn_on:
id: ring
red: 20%
green: 80%
blue: 100%
brightness: 100%
effect: Detecting
- id: talk
then:
- light.turn_on:
id: ring
red: 0%
green: 100%
blue: 0%
brightness: 100%
effect: pulse
- id: volume
then:
- light.addressable_set:
id: ring
range_from: 0
range_to: !lambda return id(vol_led).state;
red: 100%
green: 5%
blue: 80%
- delay: 30ms
- light.turn_off: ring
Jest to po prostu trochę bardziej rozbudowana wersja moich poprzednich kodów. Patrząc w logi działania asystenta zaczynasz sobie zdawać sprawę jaką wielką ma przewagę IDF.
Wykorzystuję tutaj zależność: albo głośnik albo asystent i powiem szczerze że po tylu miesiącach korzystania z takiego rozwiązania wolę dalej ten framework od powyższego. Patrząc w logi działania asystenta zaczynasz sobie zdawać sprawę jaką wielką ma przewagę IDF. Poza wyłączeniem mikfrofonu podczas odtwarzania muzyki zauważyłem jeszcze jedną dosyć istotną różnicę między tymi asystentami:
- częściej (lepiej) reaguje na wywoływanie wake wordem
- fałszywe wybudzenia są na porządku dziennym gdzie w esp-idf praktycznie to nie występuje. Dlatego pojawia się w kodzie przełącznik “Asystent” który wyłącza nadsłuch. Szczególnie przydaję się to podczas gości aby nie czuli się podsłuchiwani
Nie podejmuję w tym wątku tematów silników asystentów bo korzystam cały czas z płatnego Nabu Casy i nie widzę sensu testowania innych wariantów.
Dodatkowo wrzucam jeszcze kilka zdjęć: