How to loop play sound files [Solved]

Tags: #<Tag:0x00007fc41d169818> #<Tag:0x00007fc41d169728>

@pnbruckner

can report that they are close, but most certainly not identical. My final setup with the 2 identical scripts is definitely better, or more secure.
Had already experienced the timing issues when using 1 executable script and and looping, as is yours. That is happening with your script also. It somehow is more sensitive for HA internals than using 2 scripts both executing the play script. They don’t have to wait for one another.

The delay seems more robust than the wait_template.

tested in a complete comparison, only needing the boolean to flip for either loop script:

with the below code:

  sound_bite:
    alias: Sound bite
    sequence:
      - service: media_player.play_media
        data_template:
          entity_id: >
            {{states('sensor.sound_bite_player')}}
          media_content_id: >
            {{states('sensor.sound_bite')}}
          media_content_type: 'music'

  play_sound_bite:
    alias: Play sound bite
    sequence:
      - service: script.sound_bite
      - delay:
          seconds: >
           {{state_attr(states('sensor.sound_bite_player'),'media_duration')|int}}
      - condition: template
        value_template: >
          {{is_state('input_boolean.loop_sound_bite','on')}}
      - service_template: >
          script.play_sound_bite_{{ 'loop' if is_state('input_boolean.play_loop_or_alt','off') else 'loop_alt' }}

  play_sound_bite_loop:
    alias: Play sound bite loop
    sequence:
      - service: script.sound_bite
      - delay:
          seconds: >
           {{state_attr(states('sensor.sound_bite_player'),'media_duration')|int}}
      - condition: template
        value_template: >
          {{is_state('input_boolean.loop_sound_bite','on')}}
      - service: script.play_sound_bite

  play_sound_bite_loop_alt:
    alias: Play sound bite loop alt
    sequence:
      - wait_template: >
          {{ is_state('script.play_sound_bite', 'off') }}
      - service: script.play_sound_bite

too bad we can’t post videos, because its fun to see the scripts flip and take control

btw your suggestion Is better when changing sound bites in between, and the attribute ‘media_duration’ hasn’t been adjusted accordingly yet. That is 1 time only, and of course, only relevant in this testing scenario. But noteworthy. --> Cookbook

Fair enough. It wasn’t obvious to me that that’s how you were using the input_boolean.

I offer you another solution, based on a timer.

Just like your solution, it uses one input_boolean to start/stop looping.

input_boolean:
  loop_sound_bite:
    name: loop sound bite

Create one timer. Its duration will be assigned by an automation.

timer:
  looper:

Create three automations:

  1. Looping controller
  2. Looper timer started
  3. Looper timer finished

The first automation is triggered by input_boolean.loop_sound_bite and simply starts/stops the timer (i.e. starts/stops looping).

The key feature here is that the timer’s duration is determined by the media_duration attribute of your sensor.sound_bite_player. In other words, the timer runs for as long as the media requires to play.

- alias: 'Looping controller'
  trigger:
    platform: state
    entity_id: input_boolean.loop_sound_bite
  action:
    service_template: "{{'timer.start' if trigger.to_state.state == 'on' else 'timer.finish'}}"
    data_template:
      entity_id: timer.looper
      duration: >-
        {% set x = state_attr(states('sensor.sound_bite_player'),'media_duration')|int %}
        {{ x if trigger.to_state.state == 'on' else 0 }}

The second automation is triggered when the timer starts. It’s job is to simply start playing the media.

- alias: 'Looper timer started'
  trigger:
  - platform: event
    event_type: timer.started
    event_data:
      entity_id: timer.looper
  action:
    - service: media_player.play_media
      data_template:
        entity_id: "{{states('sensor.sound_bite_player')}}"
        media_content_id: "{{states('sensor.sound_bite')}}"
        media_content_type: 'music'

The third and final automation is triggered when the timer finishes. It’s job is to restart the timer but only if input_boolean.loop_sound_bite is on.

- alias: 'Looper timer finished'
  trigger:
  - platform: event
    event_type: timer.finished
    event_data:
      entity_id: timer.looper
  condition:
    condition: state
    entity_id: input_boolean.loop_sound_bite
    state: 'on'
  action:
  - service: timer.start
    entity_id: timer.looper

I couldn’t test this solution using a media_player (I don’t have one available). However, I simply replaced the media_player with a light and made it toggle on/off every 5 seconds. It works well.

In practice you may need to add a second or two to the timer’s duration to ensure it provides enough time for the media_player to finish playing the media.

Thanks, I will test that.

But, tbh, Ive already tried several configurations, and I can tell you a light behaves completely different from a media_player. ‘Simply’ replacing it probably won’t work.

As with the suggestion made by @pnbruckner above, that would be perfect used with a light entity. However the media_player entity causes, of suffers, which ever you prefer, timing issues, and that makes these strategies based on timing rather insecure.

Since you use the media_duration for the timer, (not ‘simply replaced’ at all!) this might work just fine, and I really really appreciate that!

Will report back, thanks!

if id make it like that, what would be the advantage of placing it first in line? Other than quitting the script immediately?

The technique should work well with a media_player because I used it when I developed a Text-to-Speech driver for Premise (7 years ago). One of the features from its description:

AudioQueue
All phrases are buffered in a queue (“AudioQueue”) and delivered to the Audio Card in sequence. You can create several spoken phrases, in rapid succession, and they will wait their turn to be played by the Audio Card.

If you submit 5 different text-phrases to the driver, one after another, it converts each one into a WAV file and enqueues each one (into a FIFO buffer). Now the queue contains 5 sound files to process where each file plays for a different duration. A timer is used to ‘pump’ each sound file through the queue.

  • The timer’s duration is set to the sound file’s duration.
  • When the timer starts, it plays the sound file.
  • When the timer stops, if there’s something left to play in the queue, the timer is restarted with its duration set to the next sound file’s duration.
  • This process continues until the queue is empty.

I used the term ‘Job’ to refer to each item in the queue to be processed. Here’s a sample of the driver’s PlayAudio method. It’s in VBScript so its fairly easy to read.

PlayAudio submits the sound file to this.AudioSpeechOutput and calls this.StartAudioTimer using the current sound file’s duration (this.AudioSpeechOutput.Duration.Seconds).

Actually, it pads the duration, by 2 seconds, to account for variability and to add a tiny amount of ‘dead air’ between each sound file it plays.

It’s been working well for over 7 years. :slight_smile:

I am considering to chang the template from:

{{state_attr(states('sensor.sound_bite_player'),'media_duration')|int}}

to

{{state_attr(states('sensor.sound_bite_player'),'media_duration')|round|int}}.

this would ensure that file that are over the .5 get rounded to the next int, and not cut off.

that tight also take away the need for an extra delay.

Yes, good idea. You’ll notice in my old code that I also adjusted the duration with round (and added 2 seconds).

' VBScript
this.StartAudioTimer(round(.Duration.Seconds + 2))

Not sure if this Premise driver technique is comparable to HA? Thing is I think it isn’t as much the architecture, but more the real life HA instance with its timing and processes, and not to mention processing power, that makes time related calculations and wait_templates rather insecure. Theory and practice …

But, it’s very interesting indeed, especially the link and description, which points out another thing I had to take care of this week (only just started using these google home players, TTS, and sound files in HA…)
I solved the interruption of radio playing by my TTS messages like below. If a tts message needs to be announced, it checks if radio is playing. If yes, it turns on the boolean, which is signal for the script to resume playing radio after the message has finished. Here i still use a delay of 20 seconds, but i will replace that soon now I know how. Please have a look if you find some spots to improve on?:

  intercom_message:
    alias: 'Intercom message'
    sequence:
      - condition: template
        value_template: >
          {{ is_state('input_boolean.announce_intercom', 'on') }}
      - service: script.radio_paused_for_message
      - service: media_player.volume_set
        data_template:
          entity_id: >
            {{states('sensor.intercom')}}
          volume_level: >
            {{  states('input_number.intercom_volume')|float }}
#            # {{ states('sensor.intercom_volume')|float  }}
      - service: tts.google_say
        data_template:
          language: >
            {{states('input_select.intercom_language')|lower}}
          entity_id: >
            {{states('sensor.intercom')}}
          message: >
            {{message}}
      - condition: template
        value_template: >
          {{is_state('input_boolean.radio_paused','on')}}
      - delay:
          seconds: 20
#      - wait_template: >
#          {{states(states('sensor.intercom')) != 'playing'}}
      - service: script.play_radio
      - service: input_boolean.turn_off
        entity_id: input_boolean.radio_paused

  radio_paused_for_message:
    alias: 'Radio paused for message'
    sequence:
      - condition: template
        value_template: >
          {{states('sensor.intercom') == states('sensor.media_player')}}
      - condition: template
        value_template: >
          {{states(states('sensor.media_player')) == 'playing'}}
      - service: input_boolean.turn_on
        entity_id: input_boolean.radio_paused
  • It’s not a matter of the software but the design pattern.
  • It’s an event-driven pattern, where the process moves along at the speed of the timer’s events.
  • It’s portable across languages and platforms.

strangest thing: ive creatd a dedicated Looper package, and all code is correct, checked it all in the dev-template.

Wont run though, no error, nothing. only this:

25
havent seen that before…(maybe shouldn’t have renamed the file during restart…)

btw @123, on start, the duration isn’t set yet, wont that prevent the timer from starting ? might take 0 as value when the media_file hasn’t been set yet?

The timer’s initial duration is set by the ‘Looping controller’ automation. When the input_boolean is set to ‘on’, it triggers ‘Looping controller’.

duration: >-
  {% set x = state_attr(states('sensor.sound_bite_player'),'media_duration')|int %}
  {{ x if trigger.to_state.state == 'on' else 0 }}

I guess if the result of state_attr is 0 then the timer will never start.

correct. and when no files has been played yet, the media_duration = 0. Ive tested that just now.

yes, at startup even None:

34

and that’s why I play the file first in my other scripts. so that the attribute gets set, and the templates can be used.

even so, if I play the file manually first to set the media_duration, the automation looper timer started wont start.
I can trigger it manually, and it plays the files correctly

I fully admit to having no knowledge of how your sensor.sound_bite_player works. I assumed that when it was assigned media to play, it determined the media’s duration and set the attribute. I didn’t know you had to play the media first to get its duration.

You’ll notice that in my driver’s code, there’s a similar need to begin playing the media first before its duration can be determined. However, it has flexibility similar to AppDaemon whereas here we are constrained by YAML and templates.

If we can’t determine the media’s duration prior to playing it, then my solution requires revision. I haven’t given it any deep thought yet but I feel it may not be worth the effort. The final result may lose its current simplicity and become a less attractive alternative to your existing solution.


EDIT
If you plan to play a limited selection of sound clips, a workaround would be to pre-calculate and store each clip’s duration in a dictionary. Then the automation would simply lookup the sound clip’s duration and set the timer accordingly. Not the most flexible of workarounds but it would be serviceable.

mmm, Im not giving up just yet. It seems to be an issue of the automatons not getting triggered at all.
after playing the file manually the first time, all should be set to flow correctly, but none of the automations do.

Ill recheck my code and the documentation to see if I have some spacing or indentation issues…

here’s my package:

update
testing with a preset duration

timer:
  looper:
    duration: '00:00:20'
##############################################################################################################
# Package for Looper timer
# 20190411 @mariusthvdb
# based on community https://community.home-assistant.io/t/how-to-loop-play-sound-files-solved/110533
# @123 suggested a timer looper
# see: https://community.home-assistant.io/t/how-to-loop-play-sound-files-solved/110533/27?u=mariusthvdb
# ##############################################################################################################


##############################################################################################################
# Inputs
##############################################################################################################

input_boolean:
  loop_sound_bite_timer:
    name: Loop sound bite (timer)
##############################################################################################################
# Timer
##############################################################################################################

timer:
  looper:

##############################################################################################################
# Automation
##############################################################################################################

automation:
# The first automation is triggered by input_boolean.loop_sound_bite_timer and simply starts/stops the timer (i.e. starts/stops looping).

  - alias: 'Looping controller'
    trigger:
      platform: state
      entity_id: input_boolean.loop_sound_bite_timer
    action:
      service_template: >
        {{'timer.start' if trigger.to_state.state == 'on' else 'timer.finish'}}
      data_template:
        entity_id: timer.looper
        duration: >
          {% set x = state_attr(states('sensor.sound_bite_player'),'media_duration')|round|int %}
          {{ x if trigger.to_state.state == 'on' else 0 }}

# The second automation is triggered when the timer starts. It’s job is to simply start playing the media.

  - alias: 'Looper timer started'
    trigger:
      platform: event
      event_type: timer.started
      event_data:
        entity_id: timer.looper
    action:
      service: media_player.play_media
      data_template:
        entity_id: >
          {{states('sensor.sound_bite_player')}}
        media_content_id: >
          {{states('sensor.sound_bite')}}
        media_content_type: 'music'

# The third and final automation is triggered when the timer finishes. It’s job is to restart the timer but 
# only if input_boolean.loop_sound_bite is on.

  - alias: 'Looper timer finished'
    trigger:
      platform: event
      event_type: timer.finished
      event_data:
        entity_id: timer.looper
    condition:
      condition: state
      entity_id: input_boolean.loop_sound_bite_timer
      state: 'on'
    action:
      service: timer.start
      entity_id: timer.looper

Before diving too deep, confirm the automations are all ‘on’ and haven’t somehow been (inexplicably) turned ‘off’.

If they are on then first thing to check is if ‘Looper controller’ is being triggered because that’s what gets the whole thing going. If it is being triggered then the next possible source of failure is it doesn’t start the timer with a duration > 0.

I think I may have found the problem.

You defined the timer with an initial duration using this format: '00:00:20'

I recall while experimenting with timers that it will now expect its duration to always be supplied in that format. So the template has to convert integer seconds into the hours:minutes:seconds format.

{{ x if trigger.to_state.state == 'on' else 0 | timestamp_custom('%H:%M:%S', false) }}

I havent done that yet, I started off with your code. didn’t work. Ive now set the initial duration: no difference.
Will check now with your timestamp

and yes the automatons are on, but I will set initial_state to be sure…

well, nothing happening . a mystery. especially since controller and finished have both been triggered, but the started has never been triggered :wink:

while the timer is idle:


note the duration set to 00:00:02, which is the actual duration of the sound_bite

changed the template to:

      {% set x = state_attr(states('sensor.sound_bite_player'),'media_duration')|round|int %}
      {{ (x if states('input_boolean.loop_sound_bite_timer') == 'on' 
           else 0 )| timestamp_custom('%H:%M:%S', false) }}

because otherwise only the 0 (off state) would get the correct timestamp.

  • You’re saying ‘Looping controller’ and ‘Looper timer finished’ have been triggered.
  • The indication of 00:00:02 suggests ‘Looping controller’ did its job and called timer.start service with duration 2 seconds.

What you’re saying is there is no evidence that the timer actually started. In other words, the sound bite didn’t play.

I’m not sure how to interpret these results. If timer.start is called with duration: 2 then it must have started the timer. It produces a timer.started event which would trigger ‘Looper timer started’. :thinking::man_shrugging:

As an experiment, replace the action in ‘Looper timer started’ with something simpler like toggling some other input_boolean. If it fails to do that then we’ve ruled out the possibility it’s somehow related to operating a media_player.