diff --git a/CHANGELOG.md b/CHANGELOG.md index af4c98c..7f66456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # autocue changelog +### 2024-06-12 – v3.0.0 + +#### New feature + +- **Adjustable minimum silence length** for blank skipping, + i.e., cueing out early when silence is found in the track, + for instance from a long pause and "hidden tracks". +- A much requested feature many have been waiting for (including myself)! +- This new function adds _almost no extra CPU load_. +- Longer silent parts and "hidden tracks" will be _detected accurately_, + without many false triggers, and the track cued out early, with a + perfect transition. +- Long tail handling, overlay point detection (next song start) and + fading stay active as before—they just work from the new cue-out point. +- The new cue-out point will be set _at the beginning_ of the specified + and detected silent part. We don’t want to produce "dead air", after all. +- `cue_file` still supports the `-b`/`--blankskip` option, which will + use a default of `2.5` seconds minimum silence duration. +- **Note:** If using _only_ `-b`/`--blankskip` _without a value_, you + should use it as the _last parameter_ and then add a `--` before the + filename, to signify "end of parameters". This is standard syntax and + you probably know it already from other programs. + **Example:** + ``` + $ cue_file -kfwrb -- "Nirvana - Something in the Way _ Endless, Nameless.mp3" + ``` +- You can add the desired duration after `-b`/`--blankskip` in seconds, + it is a new optional parameter. + **Example:** + ``` + $ cue_file -k -f -w -r -b 5.0 "Nirvana - Something in the Way _ Endless, Nameless.mp3" + ``` +- In Liquidsoap/AzuraCast, you can use this _setting_ which defaults + to zero (`0.00`) and means "disabled": + ``` + settings.autocue.cue_file.blankskip := 0.0 + ``` + +#### Recommendation + +- I recommend _not_ to use this feature on jingles, dry sweepers or liners, + advertisements and podcast episodes. Especially spoken text can contain + some pauses which might trigger an early cue-out. +- Nobody wants a podcast episode to end in the middle, or even risk losing + revenue from ads! +- You can easily prevent this and **turn off blank skipping** + - by tagging a file with the `liq_blankskip` tag set to `0.00`, + - by _not_ using `cue_file`’s `-b`/`--blankskip` option when writing tags, + - by prefixing `annotate:liq_blankskip=0.00` on playlists. + - An annotation has precedence over a file tag. +- In some special cases we **automatically turn off blank skipping:** + - SAM Broadcaster: _Song category_ is _not_ "Song" (S). For all other + categories like News (N), Jingles (J), Ads (A), etc. We look for + the `songtype` tag here. This is useful if you use a common music + library, or have files that have been tagged using SAM categories. + - AzuraCast: _Hide Metadata from Listeners ("Jingle Mode")_ is selected + for a playlist. This sets a `jingle_mode=true` annotation we honor. + +#### Breaking Changes + +- `liq_blankskip` is now a _float_, not a _boolean_ anymore! +- **Re-tagging recommended!** Sorry for that, but I hope the new functionality + will outweigh the effort. +- Both `cue_file` and `autocue.cue_file.liq` will handle the "old" tags + gracefully, using `0.00` for former `false` and your setting or + the default of `2.50` s for former `true`. +- `0.00` (zero) now means "blankskip disabled". + ### 2024-06-11 – v2.2.1 - Make JSON override switchable (`settings.autocue.cue_file.use_json_metadata`). diff --git a/autocue.cue_file.liq b/autocue.cue_file.liq index 5d0da75..ddf9731 100644 --- a/autocue.cue_file.liq +++ b/autocue.cue_file.liq @@ -23,6 +23,9 @@ # Allows passing annotate/database overrides to # cue_file, to reduce re-analysis runs even more. # 2024-06-11 - Moonbase59 - v2.2.1 Make JSON override switchable +# 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. # Lots of debugging output for AzuraCast in this, will be removed eventually. @@ -36,7 +39,7 @@ let settings.autocue.cue_file.version = settings.make( description= "Software version of autocue.cue_file. Should coincide with `cue_file`.", - "2.2.1" + "3.0.0" ) let settings.autocue.cue_file.path = @@ -115,8 +118,12 @@ let settings.autocue.cue_file.noclip = let settings.autocue.cue_file.blankskip = settings.make( description= - 'Skip blank (silence) within song (get rid of "hidden tracks").', - false + "Skip blank (silence) within track if longer than `blankskip` seconds \ + (get rid of \"hidden tracks\"). \ + Sets the cue-out point to where the silence begins. Don't use this \ + with spoken or TTS-generated text, as it will often cut the message \ + short. Zero (0.0) to switch off.", + 0.0 ) let settings.autocue.cue_file.unify_loudness_correction = @@ -257,24 +264,34 @@ def cue_file(~request_metadata, ~file_metadata, filename) = # For standalone Liquidsoap, the ultimate override is `liq_blankskip`. # This can even be used to switch blank skipping ON if is globally off. blankskip = ref(blankskip) - blankskip := list.assoc.mem("jingle_mode", meta) ? false : blankskip() + blankskip := list.assoc.mem("jingle_mode", meta) ? 0.0 : blankskip() # SAM Broadcaster compat: Switch blankskip off for all songtypes != "S" if list.assoc.mem("songtype", meta) then if meta["songtype"] != "S" then - blankskip := false + blankskip := 0.0 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 - blankskip := bool_of_string(default=false, meta["liq_blankskip"]) + 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 log( level=3, label=label, - "Blank (silence) skipping active: #{blankskip()}" + "Blank (silence) skipping active: #{blankskip() > 0.0}, set to #{blankskip()} s" ) log( @@ -307,7 +324,10 @@ def cue_file(~request_metadata, ~file_metadata, filename) = ] ) if noclip then args := list.add('-k', args()) end - if blankskip() then args := list.add('-b', 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 if write_replaygain then args := list.add('-r', args()) end if force_analysis then args := list.add('-f', args()) end @@ -317,8 +337,8 @@ def cue_file(~request_metadata, ~file_metadata, filename) = if use_json_metadata then # write metadata to temp file for cue_file to pick up tempfile := file.temp("cue_file", ".json") - json_meta = meta_json_stringify(compact=true, meta) - log(level=4, label=label, "Writing metadata to #{tempfile()}: #{json_meta}") + json_meta = meta_json_stringify(compact=true, m()) + log(level=3, label=label, "Writing metadata to #{tempfile()}: #{json_meta}") log(level=3, label=label, "Writing metadata to #{tempfile()}") file.write( data=json_meta, @@ -394,7 +414,7 @@ def cue_file(~request_metadata, ~file_metadata, filename) = liq_amplify: string, liq_amplify_adjustment: string, liq_reference_loudness: string, - liq_blankskip: bool, + liq_blankskip: float, liq_blank_skipped: bool, liq_true_peak: float, liq_true_peak_db: string @@ -432,7 +452,7 @@ def cue_file(~request_metadata, ~file_metadata, filename) = else [...res, entry] end, - meta, + m(), result() ) @@ -694,6 +714,7 @@ def cue_file(~request_metadata, ~file_metadata, filename) = ("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())), + ("liq_blankskip", list.assoc("liq_blankskip", result())), ("liq_blank_skipped", list.assoc("liq_blank_skipped", result())), ("liq_true_peak", list.assoc("liq_true_peak", result())), ("liq_true_peak_db", list.assoc("liq_true_peak_db", result())), @@ -755,7 +776,7 @@ log(level=2, label="autocue.cue_file", # settings.autocue.cue_file.longtail := 15.0 # seconds # settings.autocue.cue_file.overlay_longtail := -15.0 # extra LU # settings.autocue.cue_file.noclip := false # clipping prevention like loudgain's `-k` -# settings.autocue.cue_file.blankskip := false # skip silence in tracks +# 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` # settings.autocue.cue_file.write_tags := false # write liq_* tags back to file # settings.autocue.cue_file.write_replaygain := false # write ReplayGain tags back to file diff --git a/cue_file b/cue_file index c042ce8..21099b3 100755 --- a/cue_file +++ b/cue_file @@ -40,12 +40,15 @@ # in case a preprocessor needs to set fade durations. # 2024-06-11 Moonbase59 - v2.2.0 Sync version numver with autocue.cue_file # 2024-06-11 Moonbase59 - v2.2.1 Sync version number with autocue.cue_file +# 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. # # Originally based on an idea and some code by John Warburton (@Warblefly): # https://github.com/Warblefly/TrackBoundaries __author__ = 'Matthias C. Hormann' -__version__ = '2.2.1' +__version__ = '3.0.0' import os import tempfile @@ -82,6 +85,7 @@ 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 NICE = False # use Linux/MacOS nice? # These file types can be handled correctly by ffmpeg @@ -147,13 +151,28 @@ def is_true(v): else: 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 + elif isinstance(v, str): + try: + return float(v) + except ValueError: + if v.lower() == 'true': + return args.blankskip + else: + return 0.0 + else: + return v + # these are the tags to check when reading/writing tags from/to files tags_to_check = { "duration": float, "liq_amplify_adjustment": float, "liq_amplify": float, # like replaygain_track_gain - "liq_blankskip": is_true, + "liq_blankskip": float_blankskip, # backwards-compatibility, was bool "liq_blank_skipped": is_true, "liq_cross_duration": float, "liq_cross_start_next": float, @@ -204,7 +223,7 @@ def read_tags( filename, tags_json={}, target=TARGET_LUFS, - blankskip=False, + blankskip=0.0, noclip=False): # NOTE: Older ffmpeg/ffprobe don’t read ID3 tags if RIFF chunk found, # see https://trac.ffmpeg.org/ticket/9848 @@ -361,23 +380,23 @@ def read_tags( # if liq_blankskip different from requested, we need a re-analysis if (skip_analysis - and "liq_blankskip" in tags_found - and (tags_found["liq_blankskip"] != blankskip) - ): + and "liq_blankskip" in tags_found + and (tags_found["liq_blankskip"] != blankskip) + ): skip_analysis = False # liq_loudness_range is only informational but we want to show correct values # can’t blindly take replaygain_track_range—it might be in different unit if (skip_analysis - and "liq_loudness_range" not in tags_found - ): + and "liq_loudness_range" not in tags_found + ): skip_analysis = False # print(skip_analysis, json.dumps(tags_found, indent=2, sort_keys=True)) return skip_analysis, tags_found -def add_missing(tags_found, target=TARGET_LUFS, blankskip=False, noclip=False): +def add_missing(tags_found, target=TARGET_LUFS, blankskip=0.0, noclip=False): # we need not check those in tags_mandatory and those calculated by # read_tags @@ -426,7 +445,7 @@ def analyse( silence=SILENCE, longtail_seconds=LONGTAIL_SECONDS, extra=LONGTAIL_EXTRA_LU, - blankskip=False, + blankskip=0.0, nice=NICE, noclip=False): # ffmpeg -v quiet -y -i audiofile.ext -vn -af ebur128=target=-18:metadata=1,ametadata=mode=print:file=- -f null null @@ -475,6 +494,7 @@ def analyse( # from ebur128 filter. Measured every 100ms. # With some file types, like MP3, M can become "nan" (not-a-number), # which is a valid float in Python. Usually happens on very silent parts. + # We convert these to float("-inf") for comparability in silence detection. # FIXME: This relies on "I" coming two lines after "M" pattern = re.compile( # r"frame:.*pts_time:\s*(?P\d+\.?\d*)\s*lavfi\.r128\.M=(?Pnan|[+-]?\d+\.?\d*)\s*.*\s*lavfi\.r128\.I=(?Pnan|[+-]?\d+\.?\d*)", @@ -483,8 +503,11 @@ def analyse( for match in re.finditer(pattern, result): m = match.groupdict() - measure.append([float(m["t"]), float( - m["M"]), float(m["I"]), m["rest"]]) + measure.append([ + float(m["t"]), + float(m["M"]) if not math.isnan(float(m["M"])) else float("-inf"), + float(m["I"]), + m["rest"]]) # range to watch (for later blank skip) start = 0 @@ -540,20 +563,35 @@ def analyse( cue_out_time_blank = 0.0 # Cue-out when silence starts within a song, like "hidden tracks". - # Check forward in this case, and trust EBU R128’s 400ms to be long enough, - # checking for loudness going below the defined silence level. - # NOTE: This shouldn’t be used with TTS-generated jingles and spoken text, - # because the pauses in speech will trigger the detection and cut off the - # text! + # Check forward in this case, looking for a silence of specified length. if blankskip: # print("Checking for blank") end_blank = end - for i in range(start, end): + i = start + while i in range(start, end): if measure[i][1] <= silence_level: - cue_out_time_blank = measure[i][0] + cue_out_time_blank_start = measure[i][0] + cue_out_time_blank_stop = measure[i][0] + blankskip end_blank = i + 1 - # print(f"Found cue-out blank: {end_blank}, {cue_out_time_blank}") - break + while i < end and measure[i][1] <= silence_level and measure[i][0] <= cue_out_time_blank_stop: + 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}") + 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)") + 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}") + i += 1 + continue + else: + i += 1 # Normal cue-out: check backwards, from the end, for loudness above # "silence" @@ -934,11 +972,19 @@ parser.add_argument( parser.add_argument( "-b", "--blankskip", - help="Skip blank (silence) within song (get rid of \"hidden tracks\"). " - "Sets the cue-out point to where the silence begins. Don't use this with " - "spoken or TTS-generated text, as it will often cut the message short.", - default=False, - action='store_true') + minimum=0.0, + maximum=60.0, + action=Range, + nargs='?', + default=0.0, # zero = no blankskip + const=BLANKSKIP, # default if only `-b` used (backwards compatibility) + help=f"Skip blank (silence) within track if longer than [BLANKSKIP] seconds " + f"(get rid of \"hidden tracks\"). " + f"Sets the cue-out point to where the silence begins. Don't use this with " + f"spoken or TTS-generated text, as it will often cut the message short. " + f"Zero (0.0) to switch off. " + f"Omitting [BLANKSKIP] defaults to {BLANKSKIP} s.", + type=float) parser.add_argument( "-w", "--write", @@ -976,7 +1022,6 @@ parser.add_argument( ) args = parser.parse_args() -# args.target = float(args.target) # read JSON from stdin or file, containing "overriding" or missing tags # intended for pre-processing software diff --git a/test_autocue.cue_file.liq b/test_autocue.cue_file.liq index 1d4f881..d2736d7 100644 --- a/test_autocue.cue_file.liq +++ b/test_autocue.cue_file.liq @@ -33,14 +33,14 @@ to_live = ref(false) 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 := 60.0 +settings.autocue.cue_file.timeout := 120.0 # settings.autocue.cue_file.target := -14.0 # not recommended # settings.autocue.cue_file.silence := -42.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.noclip := true # clipping prevention -settings.autocue.cue_file.blankskip := true +settings.autocue.cue_file.blankskip := 2.5 # settings.autocue.cue_file.unify_loudness_correction := true # settings.autocue.cue_file.write_tags := true # testing # settings.autocue.cue_file.write_replaygain := true # testing @@ -72,7 +72,7 @@ uri = "/home/matthias/Musik/Other/Jingles/Short" #jingles = playlist(prefix='autocue:annotate:jingle_mode="true",liq_blankskip="false",' # ^ 'liq_fade_in="0.10",liq_fade_out="0.10"' # ^ ':', uri) -jingles = playlist(prefix='annotate:jingle_mode="true",liq_blankskip="false",' +jingles = playlist(prefix='annotate:jingle_mode="true",liq_blankskip=0.00,' ^ 'liq_fade_in="0.10",liq_fade_out="0.10"' ^ ':', uri)