The TV is too loud! The volume number means nothing :/

Have you ever watched a poorly mixed, low-gain movie in stereo where the TV is set to 95% volume and you can still barely hear the talking? And then put on a Dolby 5.1, high-gain Marvel movie where 40% volume is neighbor-shaking loud during explosions? But then you need 46-50% to hear those delicate quiet scenes when the superhero sighs their last breath? Hold onto your remote folks because I want to set mine and drop it…

I have seen a number of projects that use a fixed volume setting (number/percent) in order to keep those loud explosion scenes from disturbing the neighborhood/parent/kid/etc… Yet they still need to have an adjustable limit to deal with those movies/shows that just weren’t mixed very loud.

Therefore I had a few goals and would like to share the progress to date, and perhaps learn from the experience of others with similar endeavors.

My goals were:

  • to have an audible volume limit that is actively monitored
    • as opposed to a static volume setting
  • automatically reduce the volume setting when that threshold is crossed (no human intervention)
    • aka - loud explosion time
  • automatically increase the volume shortly after in order to hear those quiet scenes
    • interested in hearing about tuning strategies as “volume flapping” is undesirable
  • I care about relative sound levels and don’t need a calibrated $1000 microphone. As long as it consistently matches “loud” and “quiet” to my ears I am happy.
    • will use a phone app as an uncalibrated reference. Calibration is not a requirement as long as consistency is achievable.

As with most of my other projects, if a two minute Google search identifies no immediately available product/recommendations I begin looking at sensor module options. And when looking at sensor modules with no prior experience, I start at the bottom. Therefore I picked up a Max4466 based analog microphone amplifier in the hopes of using an ESPHome + ESP32 ADC for the purposes of measuring actual loudness. And while this kinda worked, it was not consitent for me. So I moved on to the next test, a DFRobot SEN0232 with the following specifications;

  • Measuring Range: 30dBA ~ 130dBA
  • Measurement Error: ±1.5dB
  • Frequency Weighted: A Weighted
  • Frequency Response: 31.5Hz ~ 8.5KHz
  • Time Characteristics: 125ms
  • Output Voltage: 0.6 ~ 2.6V
  • The decibel value is linear with the output voltage

A few things that stood out as desirable include;

  • the measuring range fit my unwritten expectations
  • the measurement error seemed good enough for purpose
  • time characteristics seemed quick enough at eight times a second
  • the last bullet is key - linear dB to voltage. This makes any “tuning offset” far less complicated, which comes in to play later.

The ADC

The first thing we need in our device is the ‘ouput voltage’ connected to our ESP32 ADC, and it turns out there are a number of options. Key of which is attenuation:, which is important to match to the target voltage range.

+----------+-------------+-----------------+
|          | attenuation | suggested range |
|    SoC   |     (dB)    |      (mV)       |
+==========+=============+=================+
|          |       0     |    100 ~  950   |
|          +-------------+-----------------+
|          |       2.5   |    100 ~ 1250   |
|   ESP32  +-------------+-----------------+
|          |       6     |    150 ~ 1750   |
|          +-------------+-----------------+
|          |      11     |    150 ~ 2450   |
+----------+-------------+-----------------+

It turns out that this can be quite complicated, and I have tested both 11dB and auto with success.

Next up we need to match the ‘time domain’ of the sensor to match the 125ms specification. And finally we want the voltage we read to “mean something.” According to the Sample Code provided we multiply the ADC voltage by 50.

ADC Codeblock

sensor:
  - platform: adc
    pin: GPIO33
    name: "mic_adc"
    id: mic_adc
    update_interval: 0.125s
    attenuation: auto
    internal: true
    filters:
      - lambda: return ((x * 50) - 15.0);
    unit_of_measurement: "dB"

Tuning offset

Since our sensor is designed for a linear responce, and I am “matching it” to my phone, I have included a fixed offset value to do so. This is unscientific but successful for me, your mileage may vary.

Automatic Volume Reduction

In order to programatically reduce the volume, a strategy needs to be selected. I chose to implement the simplest form; “decrement the volume upon any single instantaneous peak above the desired threshold.”

In order to do so we need a trigger, the adc. A threshold, an HA input_number, and an action. As you can see from the codeblock below;

binary_sensor:
  - platform: template
    name: "mic_trigger"
    id: mic_trigger
    internal: true
    lambda: |-
      if ((id(tv_sonos_playing).state == "playing") and (id(mic_adc).state > id(tv_sonos_too_loud).state)) {
        return true;
      } else {
        return false;
      }
    on_state:
      - if: # too loud turn down volume
          condition:
            binary_sensor.is_on: mic_trigger
          then:
            - homeassistant.service: 
                service: media_player.volume_down
                data:
                  entity_id: media_player.tv_sonos
            - logger.log: "Turned down TV Sonos volume"
            - if:
                condition:
                  lambda: return id(reduced).state < 3;
                then:
                  lambda: |-
                    int newnumber = id(reduced).state + 1;
                    auto call = id(reduced).make_call();
                    call.set_value(newnumber);
                    call.perform();

Tracking the Reduction

You may have noticed from the volume reduction code above that there is a lambda calling id(reduced). This number is used to track the number of reductions in volume for the purposes of increasing it later. This number is intended to limit how much the volume can be increased after the automation reduction. If you recall that one of the goals was to ensure that “volume flapping” does not occur, this number ensures that. If we turn the volume all the way back up to where it started, over and over again, we never reach an equilibrium preventing the volume from becoming a rollercoaster. This can be adjusted to taste.

number:
  - platform: template
    name: reduced
    id: reduced
    optimistic: true
    internal: true
    initial_value: 0
    min_value: 0
    max_value: 3
    step: 1

Automatic Volume Increase

The following codeblock is what I have currently landed on in order to perform the volume increase. In order to meet the goal of “no volume rollercoaster”, I found that attempting to increase the volume at 0.125s intervals did not meet this goal and therefore do not perform this operation in the mic_trigger automation codeblock (commented out in final yaml).

Instead I tried a fixed 5 second interval in the root of the yaml to help calm down the volume increase loop. I selected 5 seconds out of observation/preference, feel free to adjust to suit your needs.

Finally I have included some additional logic to help calm things down as well including;

  • the peak threshold must not have been breached for at least 4 seconds
  • the average dB measured in the previous 3 seconds is quiet enough to warrant a volume increase
  • the parasite hasn’t stolen my volume increase tokens :wink:
  • further thoughts here are welcome
interval:
  - interval: 5 sec
    then:
      - if: # too loud turn down volume
          condition:
            - for:
                time: 4 sec
                condition:
                  binary_sensor.is_off: mic_trigger
            - lambda: return id(reduced).state > 0;
            - lambda: return id(mic_db_3s_avg).state <= id(mic_db_3s_avg_target).state;
          then:
            - homeassistant.service: 
                service: media_player.volume_up
                data:
                  entity_id: media_player.tv_sonos  
            - logger.log: "Turned UP TV Sonos volume"                  
            - lambda: |-
                int reducednumber = id(reduced).state - 1;
                auto reduce_call = id(reduced).make_call();
                reduce_call.set_value(reducednumber);
                reduce_call.perform();    

Observations

It works as desired in a few days of testing. It also opens up new possibilities such as different volume limits (via an HA automation) for music or TV/movies, or time of day etc…

There are additional sensors created for the purposes of observation. I have only presented two metrics to HA for data trending over time.

The YAML

esphome:
  name: tinypico-max4466
  platform: ESP32
  board: esp32dev

substitutions:
  device_name: tinypico-mic

# Enable logging
logger:
  level: INFO

# Enable Home Assistant API
api:
  reboot_timeout: 4h

ota:
  password: !secret my_secret

mdns:
  disabled: true

wifi:
  ssid: !secret my_secret
  password: !secret my_secret
  power_save_mode: none

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: !secret my_secret
    password: !secret my_secret

captive_portal:

web_server:
  port: 80
  version: 2
  include_internal: true
  ota: false

button:
  - platform: restart
    name: Restart $device_name

switch:
  - platform: safe_mode
    internal: true
    name: use_safe_mode
    
number:
  - platform: template
    name: reduced
    id: reduced
    optimistic: true
    internal: true
    initial_value: 0
    min_value: 0
    max_value: 3
    step: 1
    
  - platform: template
    name: avg_target
    id: avg_target
    optimistic: true
    internal: true
    initial_value: -10
    restore_value: true
    min_value: -15
    max_value: -5
    step: 1    

sensor:
  - platform: adc
    pin: GPIO33
    name: "mic_adc"
    id: mic_adc
    update_interval: 0.125s
    # attenuation: 11db
    attenuation: auto
    internal: true
    filters:
      - lambda: return ((x * 50) - 15.0);
    unit_of_measurement: "dB"

  - platform: template
    name: mic_db_3s_avg
    id: mic_db_3s_avg
    unit_of_measurement: "dB"
    update_interval: 0.125s
    lambda: return id(mic_adc).state;
    internal: true
    filters:
      - sliding_window_moving_average:
          window_size: 24

  - platform: template
    name: mic_db_3s_peak
    id: mic_db_3s_peak
    unit_of_measurement: "dB"
    update_interval: 0.125s
    lambda: return id(mic_adc).state;
    internal: true
    filters:
      - max:
          window_size: 24

  - platform: template
    name: mic_db_3s_avg_target
    id: mic_db_3s_avg_target
    unit_of_measurement: "dB"
    update_interval: 3s
    lambda: return (id(tv_sonos_too_loud).state + id(avg_target).state);
    internal: true

  - platform: template
    name: mic_db_60s_avg
    id: avg_decibels
    unit_of_measurement: "dB"
    update_interval: 0.125s
    lambda: return id(mic_adc).state;
    filters:
      - sliding_window_moving_average:
          window_size: 480
          send_every: 480
          send_first_at: 23
          
  - platform: template
    name: mic_db_60s_peak
    id: peak_decibels
    unit_of_measurement: "dB"
    update_interval: 0.125s
    lambda: return id(mic_adc).state;
    filters:
      - max:
          window_size: 480
          send_every: 480
          send_first_at: 23

  - platform: homeassistant
    id: tv_sonos_too_loud
    entity_id: input_number.tv_sonos_too_loud
    on_value:
      then:
        number.set:
          id: reduced
          value: 0

text_sensor:
  - platform: homeassistant
    id: tv_sonos_playing
    entity_id: media_player.tv_sonos

binary_sensor:
  - platform: template
    name: "mic_trigger"
    id: mic_trigger
    internal: true
    lambda: |-
      if ((id(tv_sonos_playing).state == "playing") and (id(mic_adc).state > id(tv_sonos_too_loud).state)) {
        return true;
      } else {
        return false;
      }
    on_state:
      - if: # too loud turn down volume
          condition:
            binary_sensor.is_on: mic_trigger
          then:
            - homeassistant.service: 
                service: media_player.volume_down
                data:
                  entity_id: media_player.tv_sonos
            - logger.log: "Turned down TV Sonos volume"
            - if:
                condition:
                  lambda: return id(reduced).state < 3;
                then:
                  lambda: |-
                    int newnumber = id(reduced).state + 1;
                    auto call = id(reduced).make_call();
                    call.set_value(newnumber);
                    call.perform();
          # else:
          #   while:
          #     condition:
          #       # - for:
          #       #     time: 3s
          #       #     condition:
          #       #       binary_sensor.is_off: mic_trigger
              #   - lambda: return id(reduced).state > 0;
              # then:
              #   - delay: 3s
              #   - homeassistant.service: 
              #       service: media_player.volume_up
              #       data:
              #         entity_id: media_player.tv_sonos  
              #   - logger.log: "Turned UP TV Sonos volume"                  
              #   - lambda: |-
              #       int reducednumber = id(reduced).state - 1;
              #       auto reduce_call = id(reduced).make_call();
              #       reduce_call.set_value(reducednumber);
              #       reduce_call.perform();                    
interval:
  - interval: 5 sec
    then:
      - if: # too loud turn down volume
          condition:
            - for:
                time: 4 sec
                condition:
                  binary_sensor.is_off: mic_trigger
            - lambda: return id(reduced).state > 0;
            - lambda: return id(mic_db_3s_avg).state <= id(mic_db_3s_avg_target).state;
          then:
            - homeassistant.service: 
                service: media_player.volume_up
                data:
                  entity_id: media_player.tv_sonos  
            - logger.log: "Turned UP TV Sonos volume"                  
            - lambda: |-
                int reducednumber = id(reduced).state - 1;
                auto reduce_call = id(reduced).make_call();
                reduce_call.set_value(reducednumber);
                reduce_call.perform();
          else:
            - if: # consecutive loud, decrement reduce potential (parasite)
                condition:
                  - for:
                      time: 250ms
                      condition:
                        binary_sensor.is_on: mic_trigger
                  - lambda: return id(reduced).state > 0;          
                then:
                  - lambda: |-
                      int reducednumber = id(reduced).state - 1;
                      auto reduce_call = id(reduced).make_call();
                      reduce_call.set_value(reducednumber);
                      reduce_call.perform();                

[edit] - now with two mic’s and an I2C ADC!

i2c:
  sda: 23
  scl: 22
  scan: true
  id: bus_a
  
ads1115:
  - address: 0x48

sensor:
  - platform: ads1115
    multiplexer: 'A0_GND'
    gain: 4.096 #(measures up to 4.096V)
    name: "mic_adc"
    id: mic_adc
    update_interval: 0.125s
    internal: true
    filters:
      # - multiply: 50
      # - offset: !lambda "return id(db_offset).state;"
      - lambda: return ((x * 50) + id(db_offset).state);
    unit_of_measurement: "dB"
    on_value:
      - lambda: |-
          int dbpeak = (x / (id(tv_sonos_too_loud).state + 2)) * id(scaling).state;
          int per = id(scaling).state * 0.05;
          dbpeak = dbpeak - per;
          id(disp1).send_command_printf("dbpeakline.val=%i",dbpeak);
          id(disp1).send_command_printf("dbpeaklineplus.val=%i",(dbpeak + 1));
      # - logger.log: 'Updating waveform'

  - platform: ads1115
    multiplexer: 'A1_GND'
    gain: 4.096 #(measures up to 4.096V)
    name: "mic2_adc"
    id: mic2_adc
    update_interval: 0.125s
    internal: true
    filters:
      - lambda: return ((x * 50) + id(db_offset).state + id(db2_offset).state);
    unit_of_measurement: "dB"
2 Likes

I tried something similar probably a year ago but had to abandon it since the kids screaming triggered and made the TV lower which was the opposite effect of what I wanted.
It worked OK as long as the kids was not around.

Good point. Thankfully mine have outgrown this phase.

Here is my “build”

Interesting that you had better success with the 4466 than I did.

I wonder if you deployed a second device, perhaps further into the room, to diffentiate tv from kids in order to reduce the kids screaming false positives.

This is anathema to movie purists, who strive to reproduce exactly what the creator created, even if you can’t hear the bloody dialogue :slight_smile:

1 Like

Sadly it’s not only movies these days.
It’s TV shows also.
:roll_eyes:

Tenet

2 Likes

This project has gotten out of hand…

…it doesn’t need a display. But it has one!

Gotta give it to ya, you were on point @Hellis81

While it took a few days, the SO’s laughing at an internet funny made the TV turn down :face_with_raised_eyebrow:

Figured I would test out my own theory and deployed a second mic. Adjusted to match the same dB reading side-by-side with the original. And then placed further away from the soundbar since the first mic is within a foot.

I can now register the difference between whistling loudly and the TV. Shall see if that solves it. Now the “TV mic must be greater than the dB threshold as well as the mic further away from the soundbar.”

I saw something else that was interesting a few days ago:

Perhaps you can read the cables?

Interesting idea. I am using Sonos so the audio source is internal to the soundbar for music. Perhaps the concept could apply to the optical TV input. But am unclear how it would know the gain applied to the signal after the fact.

This probably have to be connected to a speakers wires.

I need this to bring the volume down when my kid is watching TV too loud. False alerts are not an issue for me at least :blush: