I have a wired audio system at home to all the rooms in my house. For more then 10 years this was running on a XAP800 unit and controlled by HA. But recently I had encountered some serious problems. Why? Well the unit consumes 35W all the time so I started to turn it off at night or when I was not at home. This wasn’t something the old unit liked and broke down. 6 months later the replacement unit also gave up. Now, also my last replacement went dead and it was time to make something else.
Based on the years of using the XAP800 I know what is a must to have and what is never used. Like having volume control for every room which is overkill. Most of the rooms stayed at the same volume level for all the years. So here is my scope for this project.
Design considerations
- Low on power consumption
- 10 outputs
- 2 inputs (more on this later)
- Volume control on some outputs
- All outputs can be mixed
- All outputs can be muted
- All outputs amps will be 3W each
- All outputs will be mono (more on that later)
- Modular build for easy repair
- ESP32-S3 controlled by HA/ESPHome(home Assistant)
I will describe my design decisions in detail and explain why they are made this way. First of all, the 3W output. Why just 3W and nothing more? Well because it is really enough for me. I know I will get a discussion such as this is better or that is to soft. But my audio system is used for announcements and background music only. Imagine the background music in elevators and shops. Not annoying loud, but it can be nonetheless. 3W has proven to be absolutely enough. Nothing more, nothing less. But if you need more then it will raise the costs for larger amps, better wires, connectors, filters, components, power supply and more power consumption.
Having said about this let’s continue.
The simplest setup is just an ESP32-S3 N16R8 which has enough pins, CPU and memory. Connect this with a MAX98357a 3W DAC module, a switch and a speaker. Looks nice but is rather useless in this form.
But let’s take a closer look at the switch. If you can control this switch with the ESP then it will be already much better. To do this you can use a 4066 chip. This IC got 4 controllable switches in one small package.
The MAX98357 has a balanced output so you need to switch 2 wires. And if you want the output to be in stereo then you need 4 switches. This results in one 4066 IC per output!! That is a lot. Because i do not need stereo I can have 2 output per IC, but it can be better. If you can remove the balanced output then one wire of the output can be connected to GND and you need to switch just one wire. Doing so you can switch 4 outputs with just one 4066 IC.
Also pumping 3W output into the tiny 4066 switches is very bad for the sound quality and result in a lot of crosstalk between the outputs. To resolve this you can make the output van the MAX98357 unbalanced with a audio trafo, but a much better solution would be using a different DAC. That is what i did and used a PCM5102 which got a unbalance headphone line output, but the output is also available on the pins.
As you can see there is now a amplifier placed after the 4066 switch. The NS8002 3W amplifiers I use are small and used in battery powered devices and that make them more energy efficient. They have also a mute pin so that checks a design criteria too. At the input I place a trim pot meter, so I can fine tune the sound level in each room.
If copy this stage multiple times and connect them to the output of the DAC you can distribute the audio to the different rooms and turn it on or off as you please. It takes one 4066 IC for every 4 output channels. That means that for my requirement of 10 outputs I need 2,5 or 3 4066 IC’s.
Using HA means this the ESP is seen as a media player to which you can stream audio from whatever source you like. I use mainly a internet radio source but also streaming services, local stored audio files and generated TTS messages when something is ready like the washing machine.
When doing a specific announcement you often want to make these to a certain room without effecting the music played in another room. Or you want to stream a different audio source too just to the bedroom and nowhere else. For that you need a second ESP media player as input. This ESP get it’s own switches, but their outputs are connected to the inputs of the amplifiers. By switching the audio in a room off and the announcement switch on you can stream your announcement to that room without disturbing the other rooms. This will need also 2,5 4066 IC which brings the total chip count at 5 4066 IC’s.
Now let’s talk about volume control. By now 23 pins are used to control 10 outputs and maybe 1 for the onboard LED to give you some status feedback. It is possible to use digital pot meter, but they consume 2 pins each. That means that you have to choose which output will have volume control or offload the volume control to the other ESP. I just needed volume control in 4 rooms. The living room for obvious reasons, kitchen because of the hood, bedroom and bathroom for when the the fan kicks in. This means that I will need 8 additional pins. Finding that many pins that are not strapping pins and other reserved pins will be tricky. Also the digital pot meters I saw need special coding and circuitry to function.
Can I do better? Yes I can.
A LED/LDR combo can be simply diy made and controlled with just one pin. I used a SMD LED with was very bright. That means you have to do some testing to make the right combo. The resistor to the LED is 4K7 and the pin supply the PWM 3V3 output. But still I had to cover the LED with a piece of white paper to damp the brightness enough. In the yaml file of the ESP you can see that i control the output pin from 0 to 15 in steps of 0.1. This gave me enough control over the LED brightness and thus the volume.
That leave me to one more things. To cover the garden area I use a garden speaker set that get it’s input from a FM transmitter module. Audio is fed in via 3.5mm jack plug. This means that the input is unbalanced and you may remember my talk about the MAX98357 having a balanced output. The same is true for the NS8002 amplifiers. To be able the stream audio to my garden is have to make output unbalanced. I did that with a 600:600 audio trafo.
The end result
Just as everything else also the volume control is made modular. The units are wrapped in heat shrink to keep external light that might affect the volume control out.
![Amps and volume units]
On the right the volume module and on the left the amplifiers modules. All outputs labelled. Wiring is wrapped together and for easy disassembly and assembly I made connectors on them so they will fit nicely and secure and the only one way.
![Fun bit]
Nothing that adds to the function of the unit, but it is fun and just looks good. I use the onboard LED to display the status of the unit but when it is all boxed in then it is of no use. To solve this I made a small 3D printed adapter that cover the onboard LED. An insert that hold glass fiber wires plugged in and the wires are let out to the front panel.
![Amps and volume units]
In the past I had problems with speaker wire connections. But this time I use the right solution to connect wires quick and nicely. 10 outputs, 1 extra unbalanced one and a spare.
And for the main requirement, saving power.
Power consumption less than 1W @ 5V
substitutions:
name: "zmc"
friendly_name: 'Zoelen Media Center'
startup_volume: '80%'
zmc_radio: 'ZMC radio'
preset_woonkamer_volume: '2.0'
preset_badkamer_volume: '3.0'
preset_slaapkamer_volume: '3.8'
preset_keuken_volume: '4.5'
# AMPLIFIERS
zmc_output1_2: 'Woonkamer'
zmc_output3: 'Keuken'
zmc_output4: 'Badkamer'
zmc_output5: 'Slaapkamer'
zmc_output6: 'Achtertuin'
zmc_output7_8: 'Hobbykamer'
zmc_output9: 'Kleedkamer'
zmc_output10: 'Hal'
zmc_output11: 'Zolder'
zmc_output12: 'Washok'
# MUTE CONTROLE
zmc_mute1: 'Mute washok'
zmc_mute2: 'Mute zolder'
zmc_mute3: 'Mute hal'
zmc_mute4: 'Mute kleedkamer'
zmc_mute5: 'Mute hobbykamer'
zmc_mute6: 'Mute achtertuin'
zmc_mute7: 'Mute keuken'
zmc_mute8: 'Mute slaapkamer'
zmc_mute9: 'Mute badkamer'
zmc_mute10: 'Mute woonkamer'
# VOLUME CONTROL
zmc_volume1: 'Keuken volume'
zmc_volume2: 'Woonkamer volume'
zmc_volume3: 'Slaapkamer volume'
zmc_volume4: 'Badkamer volume'
# ================================
# === Please do not edit below ===
# ================================
music_player_idle_id: "1"
music_player_playing_id: "2"
music_player_muted_id: "3"
esp_speaker_not_ready_phase_id: "10"
esp_speaker_error_phase_id: "11"
esphome:
name: ${name}
friendly_name: ${friendly_name}
min_version: 2024.6.0
name_add_mac_suffix: false
project:
name: AA_van_Zoelen.ZMC
version: '5.0.0'
on_boot:
priority: 600
then:
- script.execute: control_led
- delay: 30s
- media_player.volume_set:
id: media_out
volume: ${startup_volume}
- if:
condition:
lambda: return id(init_in_progress);
then:
- lambda: id(init_in_progress) = false;
- script.execute: control_led
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
framework:
type: arduino
psram:
mode: octal # Please change this to quad for N8R2 and octal for N16R8
speed: 80MHz
globals:
- id: init_in_progress
type: bool
restore_value: false
initial_value: "true"
- id: esp_speaker_phase
type: int
restore_value: false
initial_value: ${esp_speaker_not_ready_phase_id}
logger:
api:
encryption:
key: !secret api_key
on_client_connected:
- script.execute: control_led
on_client_disconnected:
- script.execute: control_led
ota:
platform: esphome
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
on_connect:
- script.execute: control_led
on_disconnect:
- script.execute: control_led
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: !secret fallback_ssid
password: !secret fallback_password
captive_portal:
i2s_audio:
- id: i2s_audio_bus
i2s_lrclk_pin: GPIO6
i2s_bclk_pin: GPIO7
media_player:
- platform: i2s_audio
id: media_out
name: ${zmc_radio}
dac_type: external
i2s_audio_id: i2s_audio_bus
i2s_dout_pin: GPIO8
mode: mono
on_play:
- logger.log: "Playback started!"
- lambda: id(esp_speaker_phase) = ${music_player_playing_id};
- if:
condition:
switch.is_on: status_led
then:
- script.execute: control_led
on_pause:
- logger.log: "Playback paused!"
- lambda: id(esp_speaker_phase) = ${music_player_muted_id};
- if:
condition:
switch.is_on: status_led
then:
- script.execute: control_led
on_idle:
- logger.log: "Playback finished!"
- lambda: id(esp_speaker_phase) = ${music_player_idle_id};
- if:
condition:
switch.is_on: status_led
then:
- script.execute: control_led
switch:
- platform: template
name: "Status LED"
id: status_led
optimistic: true
restore_mode: RESTORE_DEFAULT_ON
icon: mdi:bell
on_turn_off:
- light.turn_off:
id: onboard_led
on_turn_on:
- script.execute: control_led
#https://esphome.io/guides/configuration-types#config-pin-schema
# Positions as seen from below. Leftside is 1
# SWITCHES
- platform: gpio # WOONKAMER
pin:
number: 11
mode:
output: True
pulldown: true
id: zmc_output1_2
name: ${zmc_output1_2}
- platform: gpio # KEUKEN
pin:
number: 5
mode:
output: True
pulldown: true
id: zmc_output3
name: ${zmc_output3}
- platform: gpio # BADMAKER
pin:
number: 12
mode:
output: True
pulldown: true
id: zmc_output4
name: ${zmc_output4}
- platform: gpio # SLAAPKAMER
pin:
number: 9
mode:
output: True
pulldown: true
id: zmc_output5
name: ${zmc_output5}
- platform: gpio # ACHTERTUIN
pin:
number: 15
mode:
output: True
pulldown: true
id: zmc_output6
name: ${zmc_output6}
- platform: gpio # HOBBYKAMER
pin:
number: 16
mode:
output: True
pulldown: true
id: zmc_output7_8
name: ${zmc_output7_8}
- platform: gpio # KLEEDKAMER
pin:
number: 4
mode:
output: True
pulldown: true
id: zmc_output9
name: ${zmc_output9}
- platform: gpio # HAL
pin:
number: 10
mode:
output: True
pulldown: true
id: zmc_output10
name: ${zmc_output10}
- platform: gpio # ZOLDER
pin:
number: 18
mode:
output: True
pulldown: true
id: zmc_output11
name: ${zmc_output11}
- platform: gpio # WASHOK
pin:
number: 17
mode:
output: True
pulldown: true
id: zmc_output12
name: ${zmc_output12}
# MUTE
# MUTE AMPLIFIER 1
- platform: gpio
pin:
number: 19
mode:
output: True
pulldown: true
id: zmc_mute1
name: ${zmc_mute1}
# MUTE AMPLIFIER 2
- platform: gpio
pin:
number: 42
mode:
output: True
pulldown: true
id: zmc_mute2
name: ${zmc_mute2}
# MUTE AMPLIFIER 3
- platform: gpio
pin:
number: 41
mode:
output: True
pulldown: true
id: zmc_mute3
name: ${zmc_mute3}
# MUTE AMPLIFIER 4
- platform: gpio
pin:
number: 40
mode:
output: True
pulldown: true
id: zmc_mute4
name: ${zmc_mute4}
# MUTE AMPLIFIER 5
- platform: gpio
pin:
number: 39
mode:
output: True
pulldown: true
id: zmc_mute5
name: ${zmc_mute5}
# MUTE AMPLIFIER 6
- platform: gpio
pin:
number: 38
mode:
output: True
pulldown: true
id: zmc_mute6
name: ${zmc_mute6}
# MUTE AMPLIFIER 7
- platform: gpio
pin:
number: 47
mode:
output: True
pulldown: true
id: zmc_mute7
name: ${zmc_mute7}
# MUTE AMPLIFIER 8
- platform: gpio
pin:
number: 20
mode:
output: True
pulldown: true
id: zmc_mute8
name: ${zmc_mute8}
# MUTE AMPLIFIER 9
- platform: gpio
pin:
number: 21
mode:
output: True
pulldown: true
id: zmc_mute9
name: ${zmc_mute9}
# MUTE AMPLIFIER 10 !!! STRAPPING PIN !!!
- platform: gpio
pin:
number: 3
mode:
output: True
pulldown: true
id: zmc_mute10
name: ${zmc_mute10}
light:
- platform: esp32_rmt_led_strip
id: onboard_led
rgb_order: GRB
pin: GPIO48
num_leds: 1
rmt_channel: 0
chipset: ws2812
effects:
- pulse:
name: "Slow Pulse"
transition_length: 250ms
update_interval: 250ms
min_brightness: 60%
max_brightness: 80%
- pulse:
name: "Fast Pulse"
transition_length: 100ms
update_interval: 100ms
min_brightness: 60%
max_brightness: 80%
- pulse:
name: "Playing"
min_brightness: 20%
max_brightness: 40%
transition_length: 3s # defaults to 1s
update_interval: 3s
# VOLUME CONTROL
output:
- platform: ledc
id: zmc_volume1 # KEUKEN
pin: 14
- platform: ledc # WOONKAMER
id: zmc_volume2
pin: 13
- platform: ledc # SLAAPKAMER
id: zmc_volume3
pin: 2
- platform: ledc # BADKAMER
id: zmc_volume4
pin: 1
# https://esphome.io/components/output/ledc.html
number:
- platform: template # KEUKEN
name: ${zmc_volume1}
id: volume1_slider
max_value: 15.0
min_value: 0.0
step: 0.1
mode: slider
initial_value: ${preset_woonkamer_volume}
optimistic: true
on_value:
then:
# Must be turned on before setting frequency & level
- output.turn_on: zmc_volume1
- output.ledc.set_frequency:
id: zmc_volume1
frequency: "19531Hz"
# level sets the %age time the PWM is on
- output.set_level:
id: zmc_volume1
level: !lambda |-
// output value must be in range 0 - 1.0
// return id(volume1_slider).state / 100.0;
float result = 0.0;
int value = id(volume1_slider).state;
if (value < 4) result = value / 200.0;
if ((value >= 4 ) && (value < 8)) result = value / 150.0;
if ((value >= 8 ) && (value < 14)) result = value / 100.0;
if (value >= 14) result = 1.0;
return result;
- platform: template # KEUKEN
name: ${zmc_volume2}
id: volume2_slider
max_value: 15.0
min_value: 0.0
step: 0.1
mode: slider
initial_value: ${preset_keuken_volume}
optimistic: true
on_value:
then:
# Must be turned on before setting frequency & level
- output.turn_on: zmc_volume2
- output.ledc.set_frequency:
id: zmc_volume2
frequency: "19531Hz"
# level sets the %age time the PWM is on
- output.set_level:
id: zmc_volume2
level: !lambda |-
// output value must be in range 0 - 1.0
// return id(volume2_slider).state / 100.0;
float result = 0.0;
int value = id(volume2_slider).state;
if (value < 4) result = value / 200.0;
if ((value >= 4 ) && (value < 8)) result = value / 150.0;
if ((value >= 8 ) && (value < 14)) result = value / 100.0;
if (value >= 14) result = 1.0;
return result;
- platform: template # BADKAMER
name: ${zmc_volume3}
id: volume3_slider
max_value: 15.0
min_value: 0.0
step: 0.1
mode: slider
initial_value: ${preset_badkamer_volume}
optimistic: true
on_value:
then:
# Must be turned on before setting frequency & level
- output.turn_on: zmc_volume3
- output.ledc.set_frequency:
id: zmc_volume3
frequency: "19531Hz"
# level sets the %age time the PWM is on
- output.set_level:
id: zmc_volume3
level: !lambda |-
// output value must be in range 0 - 1.0
// return id(volume3_slider).state / 100.0;
float result = 0.0;
int value = id(volume3_slider).state;
if (value < 4) result = value / 200.0;
if ((value >= 4 ) && (value < 8)) result = value / 150.0;
if ((value >= 8 ) && (value < 14)) result = value / 100.0;
if (value >= 14) result = 1.0;
return result;
- platform: template # SLAAPKAMER
name: ${zmc_volume4}
id: volume4_slider
max_value: 15.0
min_value: 0.0
step: 0.1
mode: slider
initial_value: ${preset_slaapkamer_volume}
optimistic: true
on_value:
then:
# Must be turned on before setting frequency & level
- output.turn_on: zmc_volume4
- output.ledc.set_frequency:
id: zmc_volume4
frequency: "19531Hz"
# level sets the %age time the PWM is on
- output.set_level:
id: zmc_volume4
level: !lambda |-
// output value must be in range 0 - 1.0
// return id(volume4_slider).state / 100.0;
float result = 0.0;
int value = id(volume4_slider).state;
if (value < 4) result = value / 200.0;
if ((value >= 4 ) && (value < 8)) result = value / 150.0;
if ((value >= 8 ) && (value < 14)) result = value / 100.0;
if (value >= 14) result = 1.0;
return result;
script:
- id: control_led
then:
- if:
condition:
lambda: return !id(init_in_progress);
then:
- if:
condition:
wifi.connected:
then:
- if:
condition:
api.connected:
then:
- lambda: |
switch(id(esp_speaker_phase)) {
case ${music_player_idle_id}:
id(media_player_idle_state).execute();
break;
case ${music_player_playing_id}:
id(media_player_playing_state).execute();
break;
case ${music_player_muted_id}:
id(media_player_muted_state).execute();
break;
case ${esp_speaker_not_ready_phase_id}:
id(control_led_esp_speaker_not_ready_phase).execute();
break;
case ${esp_speaker_error_phase_id}:
id(control_led_esp_speaker_error_phase).execute();
break;
default:
break;
}
else:
- script.execute: control_led_no_ha_connection_state
else:
- script.execute: control_led_no_ha_connection_state
else:
- script.execute: control_led_init_state
# Media player stated playing displayed via the on board LED
- id: media_player_playing_state
then:
- light.turn_on:
id: onboard_led
blue: 5%
red: 5%
green: 0%
brightness: 5%
effect: "Playing"
# Media player stated muted displayed via the on board LED
- id: media_player_muted_state
then:
- light.turn_on:
id: onboard_led
blue: 60%
red: 0%
green: 0%
brightness: 65%
effect: "Fast Pulse"
# Media player stated idle displayed via the on board LED
- id: media_player_idle_state
then:
- light.turn_on:
id: onboard_led
blue: 0%
red: 15%
green: 15%
brightness: 15%
effect: "None"
# Script executed during initialisation
- id: control_led_init_state
then:
- light.turn_on:
id: onboard_led
blue: 0%
red: 0%
green: 100%
brightness: 50%
effect: "Fast Pulse"
# Script executed when the device has no connection to Home Assistant
- id: control_led_no_ha_connection_state
then:
- light.turn_on:
id: onboard_led
blue: 0%
red: 50%
green: 0%
brightness: 50%
effect: "Slow Pulse"
# Script executed when the esp speaker is not ready
- id: control_led_esp_speaker_not_ready_phase
then:
- light.turn_off:
id: onboard_led
# Script executed when the esp speaker encounters an error
- id: control_led_esp_speaker_error_phase
then:
- light.turn_on:
id: onboard_led
blue: 0%
red: 100%
green: 0%
brightness: 98%
effect: "none"
# Diagnosics
sensor:
- platform: wifi_signal # Reports the WiFi signal strength/RSSI in dB
name: "WiFi Signal dB"
id: wifi_signal_db
update_interval: 60s
entity_category: "diagnostic"
- platform: copy # Reports the WiFi signal strength in %
source_id: wifi_signal_db
name: "WiFi Signal Percent"
filters:
- lambda: return min(max(2 * (x + 100.0), 0.0), 100.0);
unit_of_measurement: "Signal %"
entity_category: "diagnostic"
device_class: ""
- platform: uptime
name: Uptime Sensor
# Diagnosics
button:
- platform: restart
id: "restart_device"
name: "Restart"
entity_category: "diagnostic"
# Diagnosics
text_sensor:
# Expose WiFi information as sensors.
- platform: wifi_info
ip_address:
name: IP
ssid:
name: SSID
bssid:
name: BSSID