From 2ac2959e0386e3900b3fb3e0341abb43cb484421 Mon Sep 17 00:00:00 2001 From: Moonbase59 Date: Fri, 14 Jun 2024 20:12:48 +0200 Subject: [PATCH] =?UTF-8?q?v4.0.0=20=E2=80=93=20Even=20better=20transition?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 61 +++++++ autocue.cue_file.liq | 238 ++++++++++++++++++++++++--- cue_file | 171 ++++++++++++++++--- minimal_example_autocue.cue_file.liq | 19 ++- test_autocue.cue_file.liq | 24 ++- 5 files changed, 449 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b818466..29dab1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ # autocue changelog +### 2024-06-14 - v4.0.0 + +#### New features + +- **Even better transitions**, using _both_ "longtail" logic and the new "sustained endings" feature by @RM-FM, which we could even improve on, in a collaborative work. We are really happy with the results! + +- Combined with the existing _blankskip_ handling (whose default is now 5.0 seconds instead of 2.5 s, to avoid false triggers), we are able to handle the **greatest variety of possible song endings with great transitions**. Be it a _cold end_, a _long fade_, a _trick ending_, an _unexpected noise, chord, riff or vocal at the end_ — we catch them all. Pure listening enjoyment! + +- If you’re interested, here’s part of our testing set. Try it out for yourself! + **Songs with difficult endings:** + - Beatles, The - Strawberry Fields Forever + - Ben Folds Five - Underground + - Black, Mary - Columbus + - Darkness on Demand - Quicksand + - Def Leppard - Let It Go + - Electric Light Orchestra - Don't Bring Me Down + - ella_henderson_rudimental_-_alibi_feat._rudimental + - Global Deejays - Get Up (feat. Technotronic) + - J.B.O. - Ein Fest + - Nirvana - Something in the Way _ Endless, Nameless + - Pink Floyd - Goodbye Cruel World + - Queen - Bohemian Rhapsody + - radiomonster.fm_-_dropin_01 + - R.E.M. - Losing My Religion + - robbie_williams_-_angels + - Stürmer, Christina - Ich lebe + - test-5-15-5-15-5s + - testfiles.txt + - TLC - Waterfalls + - Toto - Africa + - Vega, Suzanne - Tom's Diner (vocals only version) + - Walker, Tom - Leave a Light On + - Who, The - Won't Get Fooled Again + - Wonder, Stevie - Another Star + +- New "sanity check" that (also) checks if your external `cue_file` and the Liquidsoap code have matching versions (a suggestion by John Chewter (@JohnnyC1951)), and shuts down otherwise. _After_ your settings, simply use this code: + ``` + # Your settings go here... + + # Check Autocue setup, print result, shutdown if problems + # The check results will also be in the log. + # Returns a bool: true=ok, false=error. We ignore that here. + # Set `print=true` for standalone scripts, `false` for AzuraCast. + ignore(check_autocue_setup(shutdown=true, print=true)) + ``` + +#### Recommendation + +- Always use the `check_autocue_setup` function _after_ your settings, in both standalone scripts and AzuraCast. It will not only check versions, but also set the crossfading duration to a correct value, based on your fade-out setting. + +#### Breaking changes + +- New informational boolean value `liq_sustained_ending` in `cue_file` result, file tags, and Liquidsoap metadata. It shows whether the "sustained endings" feature has been used to determine the "start next song" overlay point. + +- Analysed timings shown on console, in case you manually tag a file or just want to check the results. This output don’t affect normal operation, it is done to `stderr`: + ``` + Start next times: 257.50/261.30/0.00 s (normal/sustained/longtail), using: 261.30 s. + Cue out time: 263.20 s + ``` + + ### 2024-06-12 – v3.0.0 #### New feature diff --git a/autocue.cue_file.liq b/autocue.cue_file.liq index 8b0515c..196561e 100644 --- a/autocue.cue_file.liq +++ b/autocue.cue_file.liq @@ -26,6 +26,13 @@ # 2024-06-11 - Moonbase59 - v3.0.0 Add variable blankskip (0.0=off) # - BREAKING: `liq_blankskip` now flot, not bool anymore! # Pre-v3.0.0 tags will be read graciously. +# 2024-06-12 - Moonbase59 - v3.0.1 Increase default min. silence to 5.0 s +# 2024-06-13 - Moonbase59 - v4.0.0 Add `liq_sustained_ending`, +# something_to_float() for old `liq_blankskip` tags. +# - Add `-d` to cue_file call +# 2024-06-14 - Moonbase59 - Add external `cue_file` version check and a +# `check_autocue_setup` function to be used after +# the user-defined settings. # Lots of debugging output for AzuraCast in this, will be removed eventually. @@ -39,7 +46,15 @@ let settings.autocue.cue_file.version = settings.make( description= "Software version of autocue.cue_file. Should coincide with `cue_file`.", - "3.0.0" + "4.0.0" + ) + +# Internal only! Not a user setting. +let settings.autocue.cue_file.version_external = + settings.make( + description= + "Software version of external `cue_file`.", + "(unknown)" ) let settings.autocue.cue_file.path = @@ -107,6 +122,14 @@ let settings.autocue.cue_file.overlay_longtail = -15.0 ) +let settings.autocue.cue_file.sustained_loudness_drop = + settings.make( + description= + "Consider track to have a sustained ending if its loudness at the end \ + does NOT drop more than so many percent.", + 60.0 + ) + let settings.autocue.cue_file.noclip = settings.make( description= @@ -189,6 +212,153 @@ def meta_json_stringify( json.stringify(json5=json5, compact=compact, data) end +# Need to handle pre-version 3.0.0 `liq_blankskip`: was bool, is now float` +# @vitoyucepi, in: https://github.com/savonet/liquidsoap/discussions/3965#discussioncomment-9744430 +def something_to_float(~true_value=1., value) = + value_string = string.case(string(value)) + possible_float = + try + float_of_string(value_string) + catch _ do + null() + end + possible_bool = + try + bool_of_string(value_string) ? true_value : 0. + catch _ do + null() + end + (possible_float ?? possible_bool) ?? 0. +end + +# Deconstruct a SemVer version, return a record +def semver(s) = + s = null.get(default="", s) + # SemVer RegEx, see https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + #r = r/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm + r = r/(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm + v = r.exec(s) + #print(v) + { + version = v[0], + major = v.groups["major"], + minor = v.groups["minor"], + patch = v.groups["patch"], + prerelease = v.groups["prerelease"], + build = v.groups["build"] + } +end + +# Compare two SemVers +# The return value is negative if ver1 < ver2, +# zero if ver1 == ver2 and strictly positive if ver1 > ver2 +def semver_compare(s1, s2) = + s1 = null.get(default="", s1) + s2 = null.get(default="", s2) + v1 = semver(s1) + v2 = semver(s2) + + if v1.major == v2.major and v1.minor == v2.minor and v1.patch == v2.patch then + 0 + elsif v1.major > v2.major then + 1 + elsif v1.major >= v2.major and v1.minor > v2.minor then + 1 + elsif v1.major >= v2.major and v1.minor >= v2.minor and v1.patch > v2.patch then + 1 + else + -1 + end +end + +# Get version of a CLI command +def file_semver(command) = + res = + list.hd( + default="", + process.read.lines( + #timeout=2., + command ^ " --version" + ) + ) + semver(res) +end + +# Check Autocue setup, shutdown if desired, print to terminal if desired +stdlib_shutdown = shutdown +stdlib_print = print + +def check_autocue_setup(~shutdown=false, ~print=false) = + settings.autocue.cue_file.version_external := file_semver(settings.autocue.cue_file.path()).version + + if semver_compare( + settings.autocue.cue_file.version(), + settings.autocue.cue_file.version_external() + ) == 0 + then + # set this so annotations (priority 5) can still override autocue values + settings.autocue.metadata.priority := 10 + settings.autocue.preferred := "cue_file" + # use our values in any case + settings.autocue.amplify_behavior := "keep" + # avoid dead air from reconcile, reset default 3.0s to our fade_out duration + settings.autocue.target_cross_duration := settings.autocue.cue_file.fade_out() + # Let user know what version (s)he is running + log(level=2, label="autocue.cue_file", + 'You are using autocue.cue_file version \ + #{settings.autocue.cue_file.version()}.' + ) + log(level=2, label="autocue.cue_file", + 'The external "#{settings.autocue.cue_file.path()}" \ + is version #{settings.autocue.cue_file.version_external()}' + ) + log(level=2, label="autocue.cue_file", + 'Setting `settings.autocue.target_cross_duration` to \ + #{settings.autocue.cue_file.fade_out()} s, from \ + `settings.autocue.cue_file.fade_out`.' + ) + if print then + stdlib_print( + 'You are using autocue.cue_file version \ + #{settings.autocue.cue_file.version()}.' + ) + stdlib_print( + 'The external "#{settings.autocue.cue_file.path()}" \ + is version #{settings.autocue.cue_file.version_external()}' + ) + stdlib_print( + 'Setting `settings.autocue.target_cross_duration` to \ + #{settings.autocue.cue_file.fade_out()} s, from \ + `settings.autocue.cue_file.fade_out`.' + ) + end + true + else + log(level=1, label="autocue.cue_file", + 'ERROR: autocue.cue_file v#{settings.autocue.cue_file.version()} \ + doesn’t match external "#{settings.autocue.cue_file.path()}" \ + v#{settings.autocue.cue_file.version_external()}!\n\ + Autocue NOT ACTIVATED!' + ) + # repeat on console, so standalone can see it + if print then + stdlib_print( + 'ERROR: autocue.cue_file v#{settings.autocue.cue_file.version()} \ + doesn’t match external "#{settings.autocue.cue_file.path()}" \ + v#{settings.autocue.cue_file.version_external()}!\n\ + Autocue NOT ACTIVATED!' + ) + end + if shutdown then + log(level=1, label="autocue.cue_file", "Shutting down...") + if print then stdlib_print("Shutting down...") end + stdlib_shutdown(code=2) + end + false + end +end + + # Compute cue_file data # @flag extra def cue_file(~request_metadata, ~file_metadata, filename) = @@ -198,6 +368,7 @@ def cue_file(~request_metadata, ~file_metadata, filename) = overlay = settings.autocue.cue_file.overlay() longtail = settings.autocue.cue_file.longtail() overlay_longtail = settings.autocue.cue_file.overlay_longtail() + drop = settings.autocue.cue_file.sustained_loudness_drop() blankskip = settings.autocue.cue_file.blankskip() write_tags = settings.autocue.cue_file.write_tags() write_replaygain = settings.autocue.cue_file.write_replaygain() @@ -273,19 +444,36 @@ def cue_file(~request_metadata, ~file_metadata, filename) = end end + # # Handle annotated `liq_blankskip`, the ultimate switch + # # Pre-v3.0.0 compatibility: Check for true/false (now float) + # if list.assoc.mem("liq_blankskip", meta) then + # b = meta["liq_blankskip"] + # if b == "true" then + # blankskip := blankskip() + # elsif b == "false" then + # blankskip := 0.0 + # else + # blankskip := float_of_string(default=0.0, b) + # end + # m := list.assoc.remove("liq_blankskip", m()) + # m := list.add(("liq_blankskip", string.float(decimal_places=2, blankskip())), m()) + # end + # Handle annotated `liq_blankskip`, the ultimate switch # Pre-v3.0.0 compatibility: Check for true/false (now float) if list.assoc.mem("liq_blankskip", meta) then - b = meta["liq_blankskip"] - if b == "true" then - blankskip := blankskip() - elsif b == "false" then - blankskip := 0.0 - else - blankskip := float_of_string(default=0.0, b) - end + blankskip := null.get( + default=0.0, + something_to_float( + true_value=settings.autocue.cue_file.blankskip(), + meta["liq_blankskip"] + ) + ) m := list.assoc.remove("liq_blankskip", m()) - m := list.add(("liq_blankskip", string.float(decimal_places=2, blankskip())), m()) + m := list.add( + ("liq_blankskip", string.float(decimal_places=2, blankskip())), + m() + ) end log( @@ -320,12 +508,13 @@ def cue_file(~request_metadata, ~file_metadata, filename) = string.float(longtail, decimal_places=2), '-x', string.float(overlay_longtail, decimal_places=2), + '-d', + string.float(drop, decimal_places=2), filename ] ) if noclip then args := list.add('-k', args()) end if blankskip() > 0.0 then - #args := list.add('-b', args()) args := ['-b', string.float(blankskip(), decimal_places=2), ...args()] end if write_tags then args := list.add('-w', args()) end @@ -389,6 +578,7 @@ def cue_file(~request_metadata, ~file_metadata, filename) = liq_cue_out, liq_cross_start_next, liq_longtail, + liq_sustained_ending, #liq_cross_duration, liq_loudness, liq_loudness_range, @@ -408,6 +598,7 @@ def cue_file(~request_metadata, ~file_metadata, filename) = liq_cue_out: float, liq_cross_start_next: float, liq_longtail: bool, + liq_sustained_ending: bool, #liq_cross_duration: float, liq_loudness: string, liq_loudness_range: string, @@ -430,6 +621,7 @@ def cue_file(~request_metadata, ~file_metadata, filename) = ("liq_cue_out", string(liq_cue_out)), ("liq_cross_start_next", string(liq_cross_start_next)), ("liq_longtail", string(liq_longtail)), + ("liq_sustained_ending", string(liq_sustained_ending)), #("liq_cross_duration", string(liq_cross_duration)), ("liq_loudness", liq_loudness), ("liq_loudness_range", liq_loudness_range), @@ -711,6 +903,7 @@ def cue_file(~request_metadata, ~file_metadata, filename) = ("liq_amplify_adjustment", list.assoc("liq_amplify_adjustment", result())), ("liq_cue_duration", list.assoc("liq_cue_duration", result())), ("liq_longtail", list.assoc("liq_longtail", result())), + ("liq_sustained_ending", list.assoc("liq_sustained_ending", result())), ("liq_loudness", list.assoc("liq_loudness", result())), ("liq_loudness_range", list.assoc("liq_loudness_range", result())), ("liq_reference_loudness", list.assoc("liq_reference_loudness", result())), @@ -744,26 +937,18 @@ def cue_file(~request_metadata, ~file_metadata, filename) = end end -autocue.register(name="cue_file", cue_file) # set this so annotations (priority 5) can still override autocue values settings.autocue.metadata.priority := 10 settings.autocue.preferred := "cue_file" +# use our values in any case +settings.autocue.amplify_behavior := "keep" # avoid dead air from reconcile, reset default 3.0s to our fade_out duration settings.autocue.target_cross_duration := settings.autocue.cue_file.fade_out() - -# Let user know what version (s)he is running -log(level=2, label="autocue.cue_file", - 'You are using autocue.cue_file version \ - #{settings.autocue.cue_file.version()}.' -) -log(level=2, label="autocue.cue_file", - 'Assure that the external "#{settings.autocue.cue_file.path()}" \ - has the same version!' -) +autocue.register(name="cue_file", cue_file) # --- Copy-paste Azuracast LS Config, second input box END --- -# Don't forget to add your settings after this. +# Don't forget to add your settings after this and do the check! # Here's a list of all possible settings with their defaults # settings.autocue.cue_file.path := "cue_file" @@ -775,6 +960,7 @@ log(level=2, label="autocue.cue_file", # settings.autocue.cue_file.overlay := -8.0 # LU below track loudness # settings.autocue.cue_file.longtail := 15.0 # seconds # settings.autocue.cue_file.overlay_longtail := -15.0 # extra LU +# settings.autocue.cue_file.sustained_loudness_drop := 60.0 # max. percent drop to be considered sustained # settings.autocue.cue_file.noclip := false # clipping prevention like loudgain's `-k` # settings.autocue.cue_file.blankskip := 0.0 # skip silence in tracks # settings.autocue.cue_file.unify_loudness_correction := true # unify `replaygain_track_gain` & `liq_amplify` @@ -784,6 +970,12 @@ log(level=2, label="autocue.cue_file", # settings.autocue.cue_file.nice := false # Linux/MacOS only: Use NI=18 for analysis # settings.autocue.cue_file.use_json_metadata := true # pass metadata to `cue_file` as JSON +# Check Autocue setup, print result, shutdown if problems +# The check results will also be in the log. +# Returns a bool: true=ok, false=error. We ignore that here. +# set `print=true` for standalone scripts, `false` for AzuraCast +# ignore(check_autocue_setup(shutdown=true, print=false)) + # `enable_autocue_metadata()` will autocue ALL files Liquidsoap processes. # You can disable it for selected sources using 'annotate:liq_cue_file=false'. # Remember you won't get `liq_amplify` data then -- expect loudness jumps! diff --git a/cue_file b/cue_file index 21099b3..3be4088 100755 --- a/cue_file +++ b/cue_file @@ -43,14 +43,22 @@ # 2024-06-11 Moonbase59 - v3.0.0 Add variable blankskip (0.0=off) # - BREAKING: `liq_blankskip` now flot, not bool anymore! # Pre-v3.0.0 tags will be read graciously. +# 2024-06-12 Moonbase59 - v3.0.1 Increase default min. silence to 5.0 s +# 2024-06-13 Moonbase59 - Add `liq_sustained_ending`. +# 2024-06-13 Moonbase59 & RM-FM - v4.0.0 Add sustained ending analysis, +# a collaborative work. +# Breaking: JSON & metadata (`liq_sustained_ending`) +# 2024-06-14 Moonbase59 - Add mutagen vserion to `-V`/`--version`. # # Originally based on an idea and some code by John Warburton (@Warblefly): # https://github.com/Warblefly/TrackBoundaries +# Some collaborative work with RM-FM (@RM-FM): Sustained ending analysis. __author__ = 'Matthias C. Hormann' -__version__ = '3.0.0' +__version__ = '4.0.0' import os +import sys import tempfile import subprocess import argparse @@ -60,6 +68,14 @@ import math from pathlib import Path import textwrap +# like print(), but prints to stderr + + +def eprint(*args, **kwargs): + """Print to stderr, nicely.""" + print(*args, file=sys.stderr, **kwargs) + + # see if we have Mutagen and import it if available try: import mutagen @@ -70,8 +86,10 @@ try: import mutagen.wave import mutagen.oggvorbis MUTAGEN_AVAILABLE = True + MUTAGEN_VERSION = mutagen.version_string except ImportError: MUTAGEN_AVAILABLE = False + MUTAGEN_VERSION = "(not installed)" # Default presets FFMPEG = "ffmpeg" # location of the ffmpeg binary @@ -85,7 +103,8 @@ OVERLAY_LU = -8.0 # LU below average for overlay trigger (start next song) # more than LONGTAIL_SECONDS below OVERLAY_LU are considered a "long tail" LONGTAIL_SECONDS = 15.0 LONGTAIL_EXTRA_LU = -15.0 # reduce 15 dB extra on long tail songs to find overlap point -BLANKSKIP = 2.5 # min. seconds silence to detect blank +SUSTAINED_LOUDNESS_DROP = 60.0 # max. percent drop to be considered sustained +BLANKSKIP = 5.0 # min. seconds silence to detect blank NICE = False # use Linux/MacOS nice? # These file types can be handled correctly by ffmpeg @@ -152,6 +171,8 @@ def is_true(v): raise ValueError('must be bool or str') # Need to handle pre-version 3.0.0 `liq_blankskip`: was bool, is now float` + + def float_blankskip(v): if isinstance(v, bool): return float(v) * args.blankskip # True=1, False=0 @@ -185,6 +206,7 @@ tags_to_check = { "liq_loudness": float, "liq_loudness_range": float, # like replaygain_track_range "liq_reference_loudness": float, # like replaygain_reference_loudness + "liq_sustained_ending": is_true, "liq_true_peak_db": float, "liq_true_peak": float, "r128_track_gain": int, @@ -247,7 +269,7 @@ def read_tags( text=True).stdout result = json.loads(r) - # print(json.dumps(result, indent=2)) + # eprint(json.dumps(result, indent=2)) # get tags in stream #0 (mka, opus, etc.) try: @@ -276,7 +298,7 @@ def read_tags( # unify, right overwrites left if key in both # tags_found = tags_in_stream | tags_in_format | tags_in_json tags_found = {**tags_in_stream, **tags_in_format, **tags_in_json} - # print(json.dumps(tags_found, indent=2, sort_keys=True)) + # eprint(json.dumps(tags_found, indent=2, sort_keys=True)) # add duration of stream #0 try: @@ -302,10 +324,11 @@ def read_tags( ] for tag in suffixed_tags: if tag in tags and isinstance(tags[tag], str): - if tags[tag].endswith( - (" dB", " LU", " dBFS", " dBTP", " LUFS")): - number, _, _ = tags[tag].rpartition(" ") - tags[tag] = number + # No need to check for unit name, only using defined tags + m = re.search(r'([+-]?\d*.?\d+)', tags[tag]) + if m is not None: + tags[tag] = m.group() + return tags # remove suffixes from several tags @@ -392,7 +415,7 @@ def read_tags( ): skip_analysis = False - # print(skip_analysis, json.dumps(tags_found, indent=2, sort_keys=True)) + # eprint(skip_analysis, json.dumps(tags_found, indent=2, sort_keys=True)) return skip_analysis, tags_found @@ -403,6 +426,9 @@ def add_missing(tags_found, target=TARGET_LUFS, blankskip=0.0, noclip=False): if "liq_longtail" not in tags_found: tags_found["liq_longtail"] = False + if "liq_sustained_ending" not in tags_found: + tags_found["liq_sustained_ending"] = False + # if not "liq_cross_duration" in tags_found: # tags_found["liq_cross_duration"] = tags_found["liq_cue_out"] - tags_found["liq_cross_start_next"] @@ -445,6 +471,7 @@ def analyse( silence=SILENCE, longtail_seconds=LONGTAIL_SECONDS, extra=LONGTAIL_EXTRA_LU, + drop=SUSTAINED_LOUDNESS_DROP, blankskip=0.0, nice=NICE, noclip=False): @@ -539,7 +566,7 @@ def analyse( true_peak_dB = 20.0 * math.log10(true_peak) else: true_peak_dB = float('-inf') - # print(true_peak, true_peak_dB) + # eprint(true_peak, true_peak_dB) # Find cue-in point (loudness above "silence") silence_level = loudness + silence @@ -565,7 +592,7 @@ def analyse( # Cue-out when silence starts within a song, like "hidden tracks". # Check forward in this case, looking for a silence of specified length. if blankskip: - # print("Checking for blank") + # eprint("Checking for blank") end_blank = end i = start while i in range(start, end): @@ -577,17 +604,17 @@ def analyse( i += 1 if i >= end: # ran into end of track, reset end_blank - # print(f"Blank at {cue_out_time_blank_start} too short: {measure[end-1][0] - cue_out_time_blank_start}") + # eprint(f"Blank at {cue_out_time_blank_start} too short: {measure[end-1][0] - cue_out_time_blank_start}") end_blank = end break if measure[i][0] >= cue_out_time_blank_stop: # found silence long enough, set cue-out to its begin cue_out_time_blank = cue_out_time_blank_start - # print(f"Found blank: {cue_out_time_blank_start}–{measure[i][0]} ({measure[i][0] - cue_out_time_blank_start} s)") + # eprint(f"Found blank: {cue_out_time_blank_start}–{measure[i][0]} ({measure[i][0] - cue_out_time_blank_start} s)") break else: # found silence too short, continue search - # print(f"Blank at {cue_out_time_blank_start} too short: {measure[i][0] - cue_out_time_blank_start}") + # eprint(f"Blank at {cue_out_time_blank_start} too short: {measure[i][0] - cue_out_time_blank_start}") i += 1 continue else: @@ -599,7 +626,7 @@ def analyse( if measure[i][1] > silence_level: cue_out_time = measure[i][0] end = i + 1 - # print(f"Found cue-out: {end}, {cue_out_time}") + # eprint(f"Found cue-out: {end}, {cue_out_time}") break # cue out PAST the current frame (100ms) -- no, reverse that cue_out_time = max(cue_out_time, duration - cue_out_time) @@ -611,7 +638,7 @@ def analyse( if blankskip: # cue out PAST the current frame (100ms) -- no, reverse that # cue_out_time_blank = cue_out_time_blank + 0.1 - # print(f"cue-out blank: {cue_out_time_blank}, cue-out: {cue_out_time}") + # eprint(f"cue-out blank: {cue_out_time_blank}, cue-out: {cue_out_time}") if 0.0 < cue_out_time_blank < cue_out_time: cue_out_time = cue_out_time_blank blank_skipped = True @@ -622,26 +649,101 @@ def analyse( cue_duration = cue_out_time - cue_in_time start_next_level = loudness + overlay start_next_time = 0.0 + start_next_idx = end for i in reversed(range(start, end)): if measure[i][1] > start_next_level: start_next_time = measure[i][0] + start_next_idx = i break start_next_time = max(start_next_time, cue_out_time - start_next_time) + # eprint(f"Start next: {start_next_time:.2f}") + + # Calculate slope over arbitrary number of measure elements + # Split into left & right part, use avg momentary loudness of each + def slope(elements): + l = len(elements) + l2 = l // 2 + p1 = elements[:l2] if l >= 2 else elements[:] + # leave out midpoint if we have an odd number of elements + # this is mainly for sliding window techniques + # and guarantees both halves are the same size + p2 = elements[l2 + l % 2:] if l >= 2 else elements[:] + t = elements[l2][0] # time of midpoint + # eprint(l, l2, len(p1), len(p2)) + y1 = sum(i[1] for i in p1) # sum momentary loudness + y2 = sum(i[1] for i in p2) # sum momentary loudness + if l2 > 0: + y1 /= len(p1) # average + y2 /= len(p2) # average + m = (y2 - y1) / (l / 10) # l are units of 100ms = 1/10 second + m = 0.0 if math.isnan(m) else m # slope zero if result NaN + d = math.degrees(math.atan(m)) # phi in degrees + time = elements[l2][0] # midpoint time in seconds + lufs = elements[l2][1] # midpoint momentary loudness in LUFS + try: + lufs_ratio_pct = (1 - (y1 / y2)) * 100.0 # LUFS ratio in % + except ZeroDivisionError: + lufs_ratio_pct = (1 - float("inf")) * 100.0 + # eprint( + # f"Left: {y1:.2f} LUFS avg ({len(p1)/10:.2f} s), " + # f"Right: {y2:.2f} LUFS avg ({len(p2)/10:.2f} s), " + # f"Drop: {lufs_ratio_pct:.2f}%" + # ) + return time, lufs, m, d, lufs_ratio_pct, y2 + + # Check for "sustained ending", comparing loudness ratios at end of song + sustained = False + start_next_time_sustained = 0.0 + # eprint(f"Index: {start_next_idx}–{end}, Silence: {silence_level:.2f} LUFS, Start Next Level: {start_next_level:.2f} LUFS") + # start_next_time = sliding_window(measure[start_next_idx:end], 21) + time, lufs, m, degrees, lufs_ratio_pct, max_lufs = slope( + measure[start_next_idx:end]) + # eprint(f"Slope m={m:.2f}, {degrees:.2f}°, LUFS Ratio Left:Right {lufs_ratio_pct:.2f}%") + if lufs_ratio_pct < drop: + sustained = True + start_next_level = loudness + overlay + extra + # eprint(f"Sustained; Recalc with {start_next_level} LUFS") + start_next_time_sustained = 0.0 + for i in reversed(range(start, end)): + if measure[i][1] > start_next_level: + start_next_time_sustained = measure[i][0] + break + start_next_time_sustained = max( + start_next_time_sustained, + cue_out_time - start_next_time_sustained) # We want to keep songs with a long fade-out intact, so if the calculated # overlap is longer than the "longtail_seconds" time, we check again, by reducing # the loudness to look for by an additional "extra" amount of LU longtail = False - + start_next_time_longtail = 0.0 if (cue_out_time - start_next_time) > longtail_seconds: longtail = True start_next_level = loudness + overlay + extra - start_next_time = 0.0 + start_next_time_longtail = 0.0 for i in reversed(range(start, end)): if measure[i][1] > start_next_level: - start_next_time = measure[i][0] + start_next_time_longtail = measure[i][0] break - start_next_time = max(start_next_time, cue_out_time - start_next_time) + start_next_time_longtail = max( + start_next_time_longtail, + cue_out_time - start_next_time_longtail) + + # Consolidate results from sustained and longtail + start_next_time_new = max( + start_next_time, + start_next_time_sustained, + start_next_time_longtail + ) + eprint( + f"Start next times: {start_next_time:.2f}/" + f"{start_next_time_sustained:.2f}/" + f"{start_next_time_longtail:.2f} s " + f"(normal/sustained/longtail), " + f"using: {start_next_time_new:.2f} s." + ) + start_next_time = start_next_time_new + eprint(f"Cue out time: {cue_out_time:.2f} s") # Now that we know where to start the next song, calculate Liquidsoap's # cross duration from it, allowing for an extra 0.1s of overlap -- no, reverse @@ -666,6 +768,7 @@ def analyse( "liq_cue_out": cue_out_time, "liq_cross_start_next": start_next_time, "liq_longtail": longtail, + "liq_sustained_ending": sustained, # "liq_cross_duration": cross_duration, "liq_loudness": loudness, "liq_loudness_range": loudness_range, @@ -696,7 +799,7 @@ def write_tags(filename, tags={}, replaygain=False): # temp_file_handle, temp = tempfile.mkstemp(prefix="cue_file.", suffix=filename.suffix) # So we use the same folder, to be able to do an atomic move. temp = filename.with_suffix('.tmp' + filename.suffix) - # print(temp) + # eprint(temp) rg_tags = [ "replaygain_track_gain", @@ -747,7 +850,7 @@ def write_tags(filename, tags={}, replaygain=False): tags_new.pop(k, None) del tags_new["R128_TRACK_GAIN"] - # print(replaygain, temp, json.dumps(tags_new, indent=2)) + # eprint(replaygain, temp, json.dumps(tags_new, indent=2)) if MUTAGEN_AVAILABLE and filename.suffix.casefold() in mp4_ext: # MP4-like files using Apple iTunes type tags @@ -816,7 +919,7 @@ def write_tags(filename, tags={}, replaygain=False): metadata_args = [] for k, v in tags_new.items(): metadata_args.extend(['-metadata', f'{k}={v}']) - # print(metadata_args) + # eprint(metadata_args) args = [ FFMPEG, @@ -894,6 +997,8 @@ Analyse audio file for cue-in, cue-out, overlay and EBU R128 loudness data, resu More file types are available when Mutagen is installed ({MUTAGEN_AVAILABLE}). """, epilog=f""" +Note %(prog)s will use the LARGER value from the sustained ending and longtail calculations to set the next track overlay point. This ensures special song endings are always kept intact in transitions. + %(prog)s {__version__} knows about these tags: {', '.join(sorted(tags_to_check.keys()))}. @@ -910,8 +1015,9 @@ parser.add_argument( "-V", "--version", action='version', - version='%(prog)s {version}'.format( - version=__version__)) + version=f"%(prog)s {__version__}\nmutagen {MUTAGEN_VERSION}") + # version='%(prog)s {version}'.format( + # version=__version__)) parser.add_argument("file", help="File to be processed") parser.add_argument( "-t", @@ -962,6 +1068,17 @@ parser.add_argument( help="Extra LU below overlay loudness to trigger next track for songs " "with long tail", type=float) +parser.add_argument( + "-d", + "--drop", + minimum=0, + maximum=100.0, + action=Range, + default=SUSTAINED_LOUDNESS_DROP, + help="Max. percent loudness drop at the end to be still considered " + "having a sustained ending. Such tracks will be recalculated using " + "--extra, keeping the song ending intact. Zero (0.0) to switch off.", + type=float) parser.add_argument( "-k", "--noclip", @@ -1044,6 +1161,7 @@ if args.force or not skip_analysis: silence=args.silence, longtail_seconds=args.longtail, extra=args.extra, + drop=args.drop, blankskip=args.blankskip, nice=args.nice, noclip=args.noclip @@ -1051,7 +1169,7 @@ if args.force or not skip_analysis: else: result = add_missing(tags_found, args.target, args.blankskip, args.noclip) -# print(result) +# eprint(result) if args.write: write_tags(args.file, result, args.replaygain) @@ -1065,6 +1183,7 @@ liq_result = { "liq_cue_out": result['liq_cue_out'], "liq_cross_start_next": result['liq_cross_start_next'], "liq_longtail": result["liq_longtail"], + "liq_sustained_ending": result['liq_sustained_ending'], # "liq_cross_duration": result['liq_cross_duration'], "liq_loudness": f"{result['liq_loudness']:.2f} LUFS", "liq_loudness_range": f"{result['liq_loudness_range']:.2f} LU", diff --git a/minimal_example_autocue.cue_file.liq b/minimal_example_autocue.cue_file.liq index aa79984..db30d44 100644 --- a/minimal_example_autocue.cue_file.liq +++ b/minimal_example_autocue.cue_file.liq @@ -1,18 +1,25 @@ # minimal_example_autocue.cue_file.liq # 2024-04-09 - Moonbase59 # 2024-04-19 - Moonbase59 - renamed to "minimal_example_autocue.cue_file.liq" +# 2024-06-14 - Moonbase59 - update for autocue v4.0.0 # Minimal example for the `autocue2` protocol. # Uses one playlist and outputs to sound card. %include "autocue.cue_file.liq" -settings.autocue.cue_file.path := "./cue_file" -#settings.autocue.cue_file.fade_in := 0.1 -#settings.autocue.cue_file.fade_out := 2.5 -#settings.autocue.cue_file.blankskip := true -settings.autocue.preferred := "cue_file" -settings.autocue.amplify_behavior := "keep" +# Your special non-default settings go here +# settings.autocue.cue_file.path := "cue_file" +# settings.autocue.cue_file.fade_in := 0.1 +# settings.autocue.cue_file.fade_out := 2.5 +# settings.autocue.cue_file.blankskip := 0.0 + +# Check Autocue setup, print result, shutdown if problems +# The check results will also be in the log. +# Returns a bool: true=ok, false=error. We ignore that here. +# set `print=true` for standalone scripts, `false` for AzuraCast +ignore(check_autocue_setup(shutdown=true, print=true)) + enable_autocue_metadata() # --- Use YOUR playlist here! --- diff --git a/test_autocue.cue_file.liq b/test_autocue.cue_file.liq index d2736d7..80baf8e 100644 --- a/test_autocue.cue_file.liq +++ b/test_autocue.cue_file.liq @@ -30,7 +30,7 @@ to_live = ref(false) # --- Copy-paste your settings into AzuraCast, second input box AFTER above file --- -settings.autocue.cue_file.path := "cue_file" +# settings.autocue.cue_file.path := "cue_file" # settings.autocue.cue_file.fade_in := 0.1 # settings.autocue.cue_file.fade_out := 2.5 settings.autocue.cue_file.timeout := 120.0 @@ -39,20 +39,25 @@ settings.autocue.cue_file.timeout := 120.0 # settings.autocue.cue_file.overlay := -8.0 # settings.autocue.cue_file.longtail := 15.0 # settings.autocue.cue_file.overlay_longtail := -15.0 +# settings.autocue.cue_file.sustained_loudness_drop := 60.0 settings.autocue.cue_file.noclip := true # clipping prevention -settings.autocue.cue_file.blankskip := 2.5 +settings.autocue.cue_file.blankskip := 5.0 # settings.autocue.cue_file.unify_loudness_correction := true -# settings.autocue.cue_file.write_tags := true # testing -# settings.autocue.cue_file.write_replaygain := true # testing -# settings.autocue.cue_file.force_analysis := true # testing +# settings.autocue.cue_file.write_tags := false # testing +# settings.autocue.cue_file.write_replaygain := false # testing +# settings.autocue.cue_file.force_analysis := false # testing settings.autocue.cue_file.nice := true # Linux/MacOS only! # settings.autocue.cue_file.use_json_metadata := true # pass metadata to `cue_file` as JSON +# Check Autocue setup, print result, shutdown if problems +# The check results will also be in the log. +# Returns a bool: true=ok, false=error. We ignore that here. +# set `print=true` for standalone scripts, `false` for AzuraCast +ignore(check_autocue_setup(shutdown=true, print=true)) + # `enable_autocue_metadata()` will autocue ALL files Liquidsoap processes. # You can disable it for selected sources using 'annotate:liq_cue_file=false'. # Remember you won't get `liq_amplify` data then -- expect loudness jumps! -settings.autocue.preferred := "cue_file" -settings.autocue.amplify_behavior := "keep" enable_autocue_metadata() # --- Copy-paste END --- @@ -61,9 +66,9 @@ enable_autocue_metadata() #uri = "/home/matthias/Musik/Playlists/Radio/Classic Rock.m3u" uri = "/home/matthias/media/videostream/yyy" #songs = playlist(prefix="autocue2:", uri) -#songs = playlist(prefix='annotate:liq_dummy="DUMMY":', uri) +songs = playlist(prefix='annotate:liq_dummy="DUMMY":', uri) # Test: Play 15s snippets by overriding some settings! -songs = playlist(prefix='annotate:liq_dummy="DUMMY",liq_cue_in=30.0,liq_cue_out=45.0,liq_cross_start_next=44.0,liq_fade_in=1.0,liq_fade_out=2.5:', uri) +#songs = playlist(prefix='annotate:liq_dummy="DUMMY",liq_cue_in=30.0,liq_cue_out=45.0,liq_cross_start_next=44.0,liq_fade_in=1.0,liq_fade_out=2.5:', uri) # --- Use YOUR playlist here! --- uri = "/home/matthias/Musik/Other/Jingles/Short" @@ -87,6 +92,7 @@ radio = random(weights=[1,1], [songs, jingles]) radio = amplify(1.,override="liq_amplify",radio) # "Fake" LUFS playout adjust. Calculate at -18, play at -14 ;-) +# Good to use if you write tags (at -18 LUFS standard). # radio = amplify(lin_of_dB(4.0), override=null(), radio)