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
- 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"