Smooth media player volume fade with multiple curve functions

I struggled to find any comprehensive solutions on the Home Assistant forums for fading media player volumes with common attenuation curves. I really don’t like abrupt changes to audio volume, so I put this script together.

Media Player Volume Fade

This script fades the volume of a target_player media player, starting at it’s current volume_level, to a user-defined target_volume over the user-defined duration in seconds. It also applies one of three curve algorithms to shape the transition of the fade. These algorithms result in a far more natural fade than simply increasing/decreasing volume in linear steps.

For those interested in the code; the script is fully commented, and I’ve put together a quick breakdown of how it works at the bottom of the post.

Use-case Ideas

  • Gentle Alarm Clock

    An alarm sound gently fading in over a long period of time.
  • “Soft”-mute

    A button on your dashboard or Android lock-screen to softly mute all the media players in your house over 3 seconds; instead of the instant, deafening silence when you hit pause.
  • Lullaby Fadeout

    A lullaby automation to very slowly fade out sounds that help you sleep (white-noise/rainforest) once a certain amount of time has passed since it was started before playback stops.

How to use it

  1. Copy the script to your clipboard from either:
    • fade-volume.yaml (direct link) on my GitHub repo.
    • or, from the code snippet at the bottom of this post.
  2. Paste it at the very bottom of your script config file at /config/scripts.yaml.
  3. Reload scripts under Configuration > Server Controls > YAML configuration reloading.
  4. Create a new automaton with the GUI editor.
  5. Under the Actions section of the automation, select:
    • Call Service for the action type
    • and, script.fade_volume for the service.
  6. Fill out the script with a target media player, volume and duration, then hit save.
  7. Scroll to the top of the automation GUI and hit run actions on the right-hand side

If you have something playing on your media player, you should hear the volume changed based on the settings you entered in the automation.

Below is a more real-world example use-case of the script in action.


Automation Example - Morning Alarm

This is an example of the script added to an automation in the GUI editor. Here it’s used to gently fade in an audio file as an alarm in the morning.

Here’s a breakdown of what each automation step looks like as the exported yaml code in automations.yaml:

Trigger

  • Time = 7am
  - platform: time
    at: 07:00:00

Actions

  1. Set bedroom Sonos volume to 0.
  - service: media_player.volume_set
    data:
      volume_level: 0
    target:
      entity_id: media_player.bedroom_sonos
  1. Play soft_alarm_music.wav on the media player.
  - service: media_player.play_media
    data:
      media_content_id: http://192.168.20.5:8123/local/soft_alarm_music.wav
      media_content_type: music
    target:
      entity_id: media_player.bedroom_sonos

[Fade Volume Script]

  1. This is where the fade script is called, changing the volume gently from 0% to 60% over a minute.
  - service: script.fade_volume
    data:
      curve: logarithmic
      target_player: media_player.bedroom_sonos
      duration: 60
      target_volume: 0.6

  1. And if that doesn’t wake me up, once the volume fade script is done, I finish off the automation with a light.turn_on action to increase to full over 2 minutes.
  - service: light.turn_on
    target:
      entity_id: light.bedroom_lamp
    data:
      transition: 120
      brightness: 255
  mode: single

All together

The final automation code that’s exported into automations.yaml would look like this:

- id: '1635153241041'
  alias: Morning Alarm
  description: ''
  trigger:
  - platform: time
    at: 07:00:00
  condition: []
  action:
  - service: media_player.volume_set
    data:
      volume_level: 0
    target:
      entity_id: media_player.bedroom_sonos
  - service: media_player.play_media
    data:
      media_content_id: http://192.168.20.5:8123/local/soft_alarm_music.wav
      media_content_type: music
    target:
      entity_id: media_player.bedroom_sonos
  - service: script.fade_volume
    data:
      curve: logarithmic
      target_player: media_player.bedroom_sonos
      duration: 60
      target_volume: 0.6
  - service: light.turn_on
    target:
      entity_id: light.bedroom_lamp
    data:
      transition: 120
      brightness: 255
  mode: single

Details

Input Parameters

Name Type Range Required Default Description
target_player media_player entity yes - Media player entity to perform the fade on.
target_volume float 0.0 - 1.0 yes 0.5 Normalised volume_level to fadeto by the end of the duration.
duration float 0.1 - 60 yes 5.0 Length of time in seconds the fade should take.
curve selector logarithmic, bezier, linear yes logarithmic Shaping algorithm applied to the volume change over the fade duration.

Curve Algorithms:

f(x) is the instantaneous amplitude added to the original for each step, where x is the normalised time value based on the duration, starting at 0 and ending at 1.

  • (red) Linear: f(x) = x
  • (blue) Bezier: f(x) = x / (1 + (1 - x ))
  • (green) Logarithmic: f(x) = x * x * (3 - 2x)


Script code

Add this to your Home Assistant config scripts.yaml, and use it anywhere that allows a service call (such as automations):

fade_volume:
  alias: Fade the volume of a media player
  mode: restart
  # User-defined inputs to use.
  fields:
    target_player:
      name: Target media player
      description: "Target media player of volume fade."
      required: true
      example: media_player.lounge_sonos
      selector:
        entity:
          domain: media_player
    target_volume:
      name: Target volume
      description: "Volume the media play will be at the end of the fade duration."
      required: true
      default: 0.5
      example: '0.5'
      selector:
        number:
          max: 1.0
          min: 0.0
          step: 0.01
          mode: slider
    duration:
      name: Fade duration
      description: "Length of time in seconds the fade should take."
      required: true
      default: 5
      example: '5'
      selector:
        number:
          max: 60
          min: 0.1
          step: 0.1
          mode: box
          unit_of_measurement: "s"
    curve:
      name: Fade curve algorithm
      description: "Shape of the fade curve to apply."
      required: true
      default: 'logarithmic'
      example: 'logarithmic'
      selector:
        select:
          options:
            - logarithmic
            - bezier
            - linear
  variables:
    # Hard-coded temporal granularlity.
    steps_per_second: 10
    # An integer denoting the total steps required to fade based on the user-defined duration and steps per second.
    total_steps: "{{ (steps_per_second * duration) | int(0) }}"
    # Define the difference between start point and target, used to scale each fade step.
    start_volume: "{{ state_attr(target_player, 'volume_level') | float(0) }}"
    start_diff: "{{ (target_volume - start_volume) | float(0) }}"
  sequence:
    - repeat:
        # Only continue if the following conditions are true:
        while:
            # Pre-calculated total step index has not been reached.
          - condition: template
            value_template: "{{ repeat.index < total_steps }}"
            # Media player's current volume is not close to the target, otherwise we're just wasting processing time.
          - condition: template
            value_template: "{{ ((state_attr(target_player, 'volume_level') - target_volume) | abs) > 0.001 }}"
        sequence:
          - service: media_player.volume_set
            data_template:
              entity_id: '{{ target_player }}'
              # Defines x as the normalised time over the duration based on the repeat index.
              # Then applies the fade curve on each step, multiplied by the difference factor.
              volume_level: >
                {% set t = repeat.index / total_steps %}
                {% if curve == 'logarithmic' %}
                  {{ (start_volume + (t / (1 + (1 - t))) * start_diff) | float(0) }}
                {% elif curve == 'bezier' %}
                  {{ (start_volume + (t * t * (3 - 2 * t)) * start_diff) | float(0) }}
                {% else %}
                  {{ (start_volume + t * start_diff) | float(0) }}
                {% endif %}
          # Pause to limit the update rate.
          # Apparently HA has issues with sub-second accuracy, so 100ms will have to do.
          - delay: '00:00:00.1'
    - service: media_player.volume_set
      data_template:
        entity_id: '{{ target_player }}'
        volume_level: '{{ target_volume }}'

For the Script Junkies

Code Explanation

The script works first calculating the number of total_steps required to fade based on the user-defined duration multiplied by a hard-coded step_duration of 100ms (or 10 per second). For example, a duration of 5 seconds equates to 500 steps.

It determines the difference between the media player’s current volume and the user-defined target_volume. It applies this value as a factor to the shaped fade amount, adds it to the original volume, and applies it to the media player entity volume_level for each step in a while loop.

Limitation

Timing Problem

From what I could gather, Home Assistant calls its services on a 1 second clock. I don’t know the details, however it’s clear that sub-second delay calls aren’t timed perfectly. So don’t expect the fade duration to be perfect. The larger the duration, the more noticeable the duration discrepancy will be.

Potential Workaround

To make this script duration-accurate, instead of defining total_steps at the start of the script, a steps_left value could be used, defined by the script’s start_time, end_time (which would be fixed), and the current_time for each iteration of the loop. The repeat condition would end at the pre-defined end-time, with the fade amount increasing or decreasing each step depending on if the calls are lagging or ahead… but I’ve already spent way too much time on this, so be my guest :slight_smile:

8 Likes

Looks neat.

the only thing I would suggest is to show an example of using it in an automation.

likely there are lots of people who won’t have any idea how to pass data to the script.

Cheers @finity - good call.

1 Like

Great work!

I’m a newbie to HA and your script and guide covered everything I needed to know to get this working for me, thank you so much!

One question though, is there a way to stop a fade once it has started?

1 Like

I can’t thank you enough for this. It’s absolutely brilliantly and should be integrated officially into HA. Nice one.

1 Like

It’s great to hear that the guide got you all the way through, so thanks for sharing the feedback. Yes, it would absolutely be possible to stop the fade - and actually that could be a great addition feature I might add soon.

In the meantime you can add an additional hard-coded condition to the while section in the script sequence to the script on your own HA system.

NOTE: This will add the condition to any and all automations that use this script. To integrate this feature into the script as a user-defined variable, you’d need to add another field into the script include it in the while statement. But let’s just hard-code it now as an example.

Say you had a switch to turn on light.bedroom_light next to your bed and want the morning alarm music to stop fading in when you on the light…

  sequence:
    - repeat:
        # Only continue if the following conditions are true:
        while:

            # [EXAMPLE LIGHT CONDITION]
            # Bedroom light is off
          - condition: template
            value_template: "{{is_state("light.bedroom_light", "off")}}"
            # [END EXAMPLE LIGHT CONDITION]

            # Pre-calculated total step index has not been reached.
          - condition: template
            value_template: "{{ repeat.index < total_steps }}"
            # Media player's current volume is not close to the target, otherwise we're just wasting processing time.
          - condition: template
            value_template: "{{ ((state_attr(target_player, 'volume_level') - target_volume) | abs) > 0.001 }}"

But you can replace light.bedroom_light with any other entity. For instance, you could create an input boolean called input_boolean.i_am_awake that gets triggered by any number of conditions could be used to determine that you’re awake, e.g. phone activity, your phone’s tracked “away” status, etc, etc.

In that case, you’d change the condition value template to {{is_state("input_boolean.i_am_awake", "false")}}.

If you haven’t already discovered the Developer Tools (the hammer icon just above the Configuration button in your sidebar), they can really help to double-check the validity of your script code. Specifically:

The States tab provides a super convenient way to view the current state and attributes of any entity in your network.

And the Template tab is a great scratch-pad to copy bits of scripts and mess with the code. It evaluates each line of code immediately and displays its output on the right hand side panel.

Enjoy Home Assistant!

Thanks so much for the feedback, that’s an awesome complement!