ZMC, a whole house audio system

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.

step-02
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

2 Likes