Smooth media player volume fade with multiple curve functions

First of all. Great automation! Thanks for sharing it!

I had an issue were HA crashed (and rebooted) when using it with google cast to fade in over 15 min. I never found the exact error message or issue.

I did however solve it by decreasing the update rate.

I changed:
- delay: '00:00:00.1 to - delay: '00:00:00.5'
and
steps_per_second: 10 to steps_per_second: 2

It now work flawlessly for my use case!

this would be amazing

For those struggling with timing, we got sub-second resolution in automations & scripts in 0.113 but there still seem to be limitations. I’ve been testing on a 2018 Mac Mini (3 GHz 6-Core Intel Core i5)- which should have plenty of horsepower for this- and the fastest I can adjust Spotify’s volume is about every 350 msec. Maybe it’s an internet thing with the Spotify integration phoning home, I don’t know.

That’s still three changes per second though so if your performance is the same as mine and you get the delta right it should be smooth enough.

@fmon For whatever it’s worth, I shared a light-fader script that I had written the other day, and in that script I haven’t had any trouble updating lights as often as every ≈110 ms or so.

Perhaps the latency might be on Spotify’s side?

Seems likely. Thanks Ashley!

1 Like

I’ve modified this script into a version that takes an input number helper to store the players current volume to save or restore the volume automatically. Script will only restore volume when turned down to prevent automations from fighting the user for volume changes and will only save volume when above zero.

alias: Media player save/restore w/fade
sequence:
  - choose:
      - conditions:
          - condition: template
            value_template: "{{ state_attr( target_player, 'volume_level')|float(0) > 0 }}"
          - condition: template
            value_template: "{{ mode == 'Save' }}"
        sequence:
          - service: input_number.set_value
            data:
              value: "{{ state_attr( target_player, 'volume_level')|float(0)}}"
            target:
              entity_id: "{{ save_helper }}"
  - repeat:
      while:
        - condition: template
          value_template: "{{ repeat.index < total_steps }}"
        - condition: template
          value_template: >-
            {{ (((state_attr(target_player, "volume_level") |float(0)) -
            target_volume) | abs) > 0.001 }}
      sequence:
        - service: media_player.volume_set
          data_template:
            entity_id: "{{ target_player }}"
            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 %}
        - delay: "00:00:00.1"
  - service: media_player.volume_set
    data_template:
      entity_id: "{{ target_player }}"
      volume_level: "{{ target_volume }}"
    enabled: true
mode: parallel
fields:
  target_player:
    name: Target media player
    description: Target media player of volume fade.
    required: true
    example: media_player.example_speaker
    selector:
      entity:
        domain: media_player
  save_helper:
    name: Input Number to save volume
    description: Must be range 0 to 1, step size 0.01.
    required: true
    example: input_number.example_speaker_volume
    selector:
      entity:
        filter:
          domain: input_number
  duration:
    name: Fade duration
    description: Length of time in seconds the fade should take.
    required: true
    example: "2"
    selector:
      number:
        mode: box
        min: 0
        max: 100000
        unit_of_measurement: s
  curve:
    name: Fade curve algorithm
    description: Shape of the fade curve to apply.
    required: true
    example: logarithmic
    selector:
      select:
        options:
          - logarithmic
          - bezier
          - linear
  mode:
    name: Fade Direction
    description: Save or Restore volume
    required: true
    example: Save
    selector:
      select:
        options:
          - Save
          - Restore
variables:
  steps_per_second: 10
  target_volume: >-
    {% if( mode == "Save" ) %}
      0
    {% elif( mode == "Restore" and ( ( state_attr( target_player,
    'volume_level')|float(0)) < 0.01 )) %}
      {{ states( save_helper ) }}
    {% else %}
      {{ state_attr( target_player, 'volume_level')|float(0) }}
    {% endif %}
  total_steps: "{{ (steps_per_second * duration) | int(0) }}"
  start_volume: "{{ state_attr(target_player, 'volume_level') | float(0) }}"
  start_diff: "{{ (target_volume - start_volume) | float(0) }}"
icon: mdi:tune-vertical
max: 10

I took the liberty to rewrite the script and base it on the time passed during the fade.

  • no more deviation based on how ‘fast’ the script runs - fades take as long as specified by caller.
  • duration now uses the duration selector type (HH:MM:SS format)
  • the time between fade steps can be adjusted from 10 - 2000ms to accomodate media players that do not like commands in rapid succession
  • Possibility to abort a fade by emitting a custom event media_player_fade_volume_abort with the matching target_player in the additional data.

The Updated Script

media_player_fade_volume:
  alias: Fade the volume of a media player
  mode: restart
  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
          min: 0
          step: 0.01
          mode: slider
    duration:
      name: Fade duration
      description: Length of time the fade should take.
      required: true
      default:
        hours: 00
        minutes: 00
        seconds: 05
      selector:
        duration:
    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
    fade_step_timeout:
      name: "Time between volume steps in milliseconds. [Script Tuning]"
      description: >-
        Smaller value -> smoother fade steps (but could cause problems with some players interpreting this as bad behaviour).
        Bigger value -> less events to media player (which could be needed in case media player is overwhelmed by rapid commands).

        500ms (2 fade steps per second) is a good compromise - no audible steps and most players should handle the load fine.
      required: true
      default: 500
      example: "500"
      selector:
        number:
          max: 2000
          min: 10
          step: 10
          mode: slider
          unit_of_measurement: ms
  variables:
    start_volume: "{{ state_attr(target_player, 'volume_level') | float(0) }}"
    start_timestamp: "{{ as_timestamp(now()) }}"
    fade_volume_diff: "{{ (target_volume - start_volume) | float(0) }}"
    fade_duration: "{{ duration.hours * 3600 + duration.minutes * 60 + duration.seconds }}"
    fade_duration_cutoff: "{{ fade_duration - 0.2 }}"
  sequence:
    - alias: "Set the media player volume in incremental steps for the fade duration."
      repeat:
        sequence:
          - alias: "Set next volume step. Value based on time progress of fade, start/end volume and algorithm."
            service: media_player.volume_set
            data_template:
              entity_id: "{{ target_player }}"
              volume_level: >-
                {%- set relative_fade_pos = (as_timestamp(now()) - start_timestamp) / fade_duration %}
                {%- if curve == 'logarithmic' %}
                  {{ (start_volume + (relative_fade_pos / (1 + (1 - relative_fade_pos))) * fade_volume_diff) | float(0) }}
                {%- elif curve == 'bezier' %}
                  {{ (start_volume + (relative_fade_pos * relative_fade_pos * (3 - 2 * relative_fade_pos)) * fade_volume_diff) | float(0) }}
                {%- else %}
                  {{ (start_volume + relative_fade_pos * fade_volume_diff) | float(0) }}
                {%- endif %}
          - alias: "Fade can be aborted by sending an event - wait for event before we continue with next fade step."
            wait_for_trigger:
              - alias: "'media_player_fade_volume_abort' event with matching target_player entity"
                platform: event
                event_type: "media_player_fade_volume_abort"
                event_data:
                  target_player: "{{ target_player }}"
            timeout:
              milliseconds: "{{ fade_step_timeout }}"
          - alias: "If abort event was received we stop script execution right away. Volume remains at current value."
            if:
              - alias: "Received the 'media_player_fade_volume_abort' event with target_player of current script instance."
                condition: template
                value_template: "{{ wait.trigger != none }}"
            then:
              - stop: "Script aborted by 'media_player_fade_volume_abort' event."
        until:
          - alias: "Time passed in fade is close to desired duration."
            condition: template
            value_template: "{{ (as_timestamp(now()) - start_timestamp) >= fade_duration_cutoff }}"
    - alias: "Ensure media player is set to target volume after fade finished"
      service: media_player.volume_set
      data_template:
        entity_id: "{{ target_player }}"
        volume_level: "{{ target_volume }}"
  icon: mdi:tune-vertical

Example Event to Abort Ongoing Fade

event_type: media_player_fade_volume_abort
data:
  target_player: media_player.lounge_sonos

Media player has to be the same as the script is currently fading the volume on.

Parallelism

Currently the script restarts if started again which aborts the ongoing fade and starts the new one.
It would be cool to be able to run this script in parallel for multiple media players.
Currently I only have one player so no need for this use-case but it was the first thing I thought about when I had this finished :slight_smile:
For this it would be needed to check

  • change mode to parallel
  • check before script starts fading the volume if script is already running for same media player
  • script stops itself if it is already running for the same media player
4 Likes

Amazing, exactly what I was looking for, Game Changer, no more abrupt off/on

Thank you so much

I just want to say thank you for this. I prefer to code in Python using PyScript because my brain, for whatever reason, struggles to code in YAML till this day. So I converted this to a python function and it works flawlessly, link to post.

Many thanks for your reworking of this script to be time based, @foberleitner—it’s working super reliably on my system!

And if it might be helpful for anyone else, I’ve also adjusted your version of the script to support parallel calls, and I thought that I’d share that here:

(To be clear, I didn’t happen implement any of the sensible-if-I-had-the-time-to-implement-them safety checks that you had outlined in your earlier comment, such as “check before script starts fading the volume if script is already running for same media player”—but as long as those who use this script know what they’re doing, perhaps this might be fine?)

fade_the_volume_of_a_media_player:
  alias: Fade the volume of a media player
  mode: parallel
  max: 10
  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
          min: 0
          step: 0.01
          mode: slider
    duration:
      name: Fade duration
      description: Length of time the fade should take.
      required: true
      default:
        hours: 0
        minutes: 0
        seconds: 5
      selector:
        duration:
    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
    fade_step_timeout:
      name: Time between volume steps in milliseconds. [Script Tuning]
      description: 'Smaller value -> smoother fade steps (but could cause problems
        with some players interpreting this as bad behaviour). Bigger value -> less
        events to media player (which could be needed in case media player is overwhelmed
        by rapid commands).

        500ms (2 fade steps per second) is a good compromise - no audible steps and
        most players should handle the load fine.'
      required: true
      default: 500
      example: '500'
      selector:
        number:
          max: 2000
          min: 10
          step: 10
          mode: slider
          unit_of_measurement: ms
  variables:
    start_volume: '{{ state_attr(target_player, ''volume_level'') | float(0) }}'
    start_timestamp: '{{ as_timestamp(now()) }}'
    fade_volume_diff: '{{ (target_volume - start_volume) | float(0) }}'
    fade_duration: '{{ duration.hours * 3600 + duration.minutes * 60 + duration.seconds
      }}'
    fade_duration_cutoff: '{{ fade_duration - 0.2 }}'
  sequence:
  - alias: Set the media player volume in incremental steps for the fade duration.
    repeat:
      sequence:
      - alias: Set next volume step. Value based on time progress of fade, start/end
          volume and algorithm.
        data_template:
          entity_id: '{{ target_player }}'
          volume_level: "{%- set relative_fade_pos = (as_timestamp(now()) - start_timestamp)
            / fade_duration %} {%- if curve == 'logarithmic' %}\n  {{ (start_volume
            + (relative_fade_pos / (1 + (1 - relative_fade_pos))) * fade_volume_diff)
            | float(0) }}\n{%- elif curve == 'bezier' %}\n  {{ (start_volume + (relative_fade_pos
            * relative_fade_pos * (3 - 2 * relative_fade_pos)) * fade_volume_diff)
            | float(0) }}\n{%- else %}\n  {{ (start_volume + relative_fade_pos * fade_volume_diff)
            | float(0) }}\n{%- endif %}"
        action: media_player.volume_set
      - alias: Fade can be aborted by sending an event - wait for event before we
          continue with next fade step.
        wait_for_trigger:
        - alias: '''media_player_fade_volume_abort'' event with matching target_player
            entity'
          platform: event
          event_type: media_player_fade_volume_abort
          event_data:
            target_player: '{{ target_player }}'
        timeout:
          milliseconds: '{{ fade_step_timeout }}'
      - alias: If abort event was received we stop script execution right away. Volume
          remains at current value.
        if:
        - alias: Received the 'media_player_fade_volume_abort' event with target_player
            of current script instance.
          condition: template
          value_template: '{{ wait.trigger != none }}'
        then:
        - stop: Script aborted by 'media_player_fade_volume_abort' event.
      until:
      - alias: Time passed in fade is close to desired duration.
        condition: template
        value_template: '{{ (as_timestamp(now()) - start_timestamp) >= fade_duration_cutoff
          }}'
  - alias: Ensure media player is set to target volume after fade finished
    data_template:
      entity_id: '{{ target_player }}'
      volume_level: '{{ target_volume }}'
    action: media_player.volume_set
  icon: mdi:tune-vertical

Is anyone triggering this through NodeRed? I would love some insight on how you’re making it work. Been looking for something like this for a while!

But I have been struggling to figure out how to trigger it through NodeRed.

I have iterated on the script once more. Instead of setting the new volume at fixed intervals, it now calculates the required interval to change the volume in 0.005 volume steps. As an example, if the volume is supposed to be faded from 0.8 to 0.4 over the course of 10 minutes, then the script would change the volume every 10 * 60 / (0.8 - 0.4) * 0.005 = 7.5 seconds, although at most every 0.5 seconds.
This seems more sensible to me, especially for longer durations.

I have also implemented a check that not more than one script can run on the same media player.

As a new feature, setting the argument pause_and_restore to true will pause playback after fade and restore original volume. This is especially useful after fading to 0 (or close to).

script:
  media_player_fade_volume:
    mode: parallel
    max: 10
    alias: Fade the volume of a media player
    fields:
      target_player:
        name: Target media player
        description: Target media player of volume fade.
        required: true
        example: media_player.lounge_sonos
        selector:
          entity:
            filter:
              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
            min: 0
            step: 0.01
            mode: slider
      duration:
        name: Fade duration
        description: Length of time the fade should take.
        required: true
        default:
          hours: 00
          minutes: 00
          seconds: 05
        selector:
          duration:
      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
      pause_and_restore:
        name: Pause & Restore
        description: Pause playback after fade and restore original volume?
        default: false
        selector:
          boolean:

    variables:
      start_volume: "{{ state_attr(target_player, 'volume_level') | float(0) }}"
      fade_volume_diff: "{{ (target_volume - start_volume) | float(0) }}"
      fade_duration: "{{ duration.hours * 3600 + duration.minutes * 60 + duration.seconds }}"
      fade_step_timeout: "{{ max(fade_duration / (max(fade_volume_diff | abs, 0.0001) * 200), 0.005 ) | float(0) }}"
      start_timestamp: "{{ as_timestamp(now()) }}"
    sequence:
      - alias: "Cancel existing fade scripts for this media player"
        event: "media_player_fade_volume_abort"
        event_data:
          target_player: "{{ target_player }}"
      - alias: "Abort if duration is 0."
        if: 
          - condition: template
            value_template: "{{ fade_duration == 0 }}"
        then:
          - stop: "Can't fade, duration supplied is 0."
            error: true
      - alias: "No volume to change."
        if: 
          - condition: template
            value_template: "{{ fade_volume_diff == 0 }}"
        then:
          - stop: "Nothing to fade, target volume == start volume."
      - alias: "Set the media player volume in incremental steps for the fade duration."
        repeat:
          sequence:
            - alias: "Set next volume step. Value based on time progress of fade, start/end volume and algorithm."
              service: media_player.volume_set
              data_template:
                entity_id: "{{ target_player }}"
                volume_level: >-
                  {%- set relative_fade_pos = (as_timestamp(now()) - start_timestamp) / fade_duration %}
                  {%- if curve == 'logarithmic' %}
                    {{ (start_volume + (relative_fade_pos / (1 + (1 - relative_fade_pos))) * fade_volume_diff) | float(0) }}
                  {%- elif curve == 'bezier' %}
                    {{ (start_volume + (relative_fade_pos * relative_fade_pos * (3 - 2 * relative_fade_pos)) * fade_volume_diff) | float(0) }}
                  {%- else %}
                    {{ (start_volume + relative_fade_pos * fade_volume_diff) | float(0) }}
                  {%- endif %}
            - alias: "Fade can be aborted by sending an event - wait for event before we continue with next fade step."
              wait_for_trigger:
                - alias: "'media_player_fade_volume_abort' event with matching target_player entity"
                  platform: event
                  event_type: "media_player_fade_volume_abort"
                  event_data:
                    target_player: "{{ target_player }}"
              timeout:
                seconds: "{{ fade_step_timeout }}"
            - alias: "If abort event was received we stop script execution right away. Volume remains at current value."
              if:
                - alias: "Received the 'media_player_fade_volume_abort' event with target_player of current script instance."
                  condition: template
                  value_template: "{{ wait.trigger != none }}"
              then:
                - stop: "Script aborted by 'media_player_fade_volume_abort' event."
          until:
            - alias: "Time passed in fade is close to desired duration."
              condition: template
              value_template: "{{ (as_timestamp(now()) - start_timestamp) >= fade_duration - 0.2 }}"
      - if:
        - condition: template
          value_template: "{{ pause_and_restore }}"
        then:
          - alias: "Pause player"
            service: media_player.media_pause
            target:
              entity_id: "{{ target_player }}"
      - alias: "Ensure media player is set to target volume after fade finished"
        service: media_player.volume_set
        data_template:
          entity_id: "{{ target_player }}"
          volume_level: "{{ start_volume if pause_and_restore else target_volume }}"
    icon: mdi:tune-vertical
1 Like

That is pretty simple.

Head over to Settings → Automations & Scenes → Scripts
Create a new Script and edit in yaml
Paste the script here
Trigger the script with Nodered like so:

Sometimes it gets stuck executing the script… that leaves me with some workaround media player stop commands. But I think it’s an issue with the sonos players being unresponsive at times.

Thank you!

I did get it working and it’s awesome, but I suspect my device is getting banned from using it too much, haha!

Hi,
I would like to try the revised code put up on 15 August but cannot get it to start on my docker installation.

I have added the text to the end of my script file and it will not start. I have also removed the word script: and removed 2 spaces on each line in an attempt to reformat.

I do not seem to have enough experience with yaml files, can someone please repost a version that I could use?

Thanks

Hi,
Has anyone succeeded in making the script work with Echo Dot ?

I tried the original one, the revisited versions of @foberleitner, @handcoding and @Caligo but no success.
I tried to change the steps from 100 to 2000 ms.

Sometimes it works and fades, but most of the time, it just set the volume directly to the goal or does nothing.

1 Like

@Suelis I’ve been using this script with a Sonos speaker that has Alexa built in (and it works fine), but I haven’t tried this script with an Amazon Echo.

I hope that you find an answer that helps you, though!

1 Like

Does anyone have a companion script that will change all media players based on a time of day by calling this script? I’m still somewhat new and haven’t found a parameter-based volume adjusting script, and want to use this. Probably doing bad keyword searches.

You should probably use a timed automation for this. And within this automation use this script to corresponding functionality…

Is it possible to use a variable (helper) as the target_volume?
Never mind: it seems possible after finding the right syntax…