diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3aa56da0e0..a093285532 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: - id: check-merge-conflict - id: end-of-file-fixer - exclude: dune.inc + exclude: (dune.inc|autocue.cue_file.liq) - id: mixed-line-ending exclude: dune.inc - id: trailing-whitespace @@ -29,6 +29,7 @@ repos: rev: c5eab8dceed09fa985b3cf0ba3fe7f398fc00c04 hooks: - id: liquidsoap-prettier + exclude: autocue.cue_file.liq - repo: https://github.com/pre-commit/mirrors-prettier rev: v4.0.0-alpha.8 @@ -41,7 +42,7 @@ repos: hooks: - id: codespell args: [-w, --ignore-words=.codespellignore] - exclude: ^doc/orig/fosdem2020 + exclude: (fosdem2020|cue_file) - repo: local hooks: diff --git a/dune-project b/dune-project index eae8305866..e0328d0d88 100644 --- a/dune-project +++ b/dune-project @@ -157,7 +157,7 @@ (sedlex (>= 3.2)) (menhir (>= 20180703)) ) - (sites (share libs) (share bin) (share cache) (lib_root lib_root)) + (sites (share libs) (share bin) (share cache) (lib_root lib_root) (libexec scripts)) (synopsis "Liquidsoap language library")) (package diff --git a/scripts/cue_file b/scripts/cue_file new file mode 100755 index 0000000000..cc8600be6e --- /dev/null +++ b/scripts/cue_file @@ -0,0 +1,1287 @@ +#!/usr/bin/env python3 +# encoding: utf-8 + +# cue_file +# 2024-02-08 Moonbase59 - first unpublished test versions +# 2024-03-23 Moonbase59 - first published version +# 2024-04-11 Moonbase59 - Added liq_cross_start_next +# 2024-04-12 Moonbase59 - Rename `liq_duration` -> `liq_cue_duration` +# - Change to create correctly typed JSON (thanks @toots!) +# 2024-04-20 Moonbase59 - optimized skip_analysis for different loudness targets +# - allow loudness values as floats +# 2024-04-24 Moonbase59 - completely remove `liq_cross_duration`, so it won’t +# be written to file’s tags +# 2024-04-25 Moonbase59 - handle old RG1/mp3gain positive loudness reference +# - more sanity checks & adjustments +# - add -n/--nice option, increases nice value by 10 +# 2024-05-01 Moonbase59 - Add (future) ramp & hook points to `tags_to_check` +# 2024-05-02 Moonbase59 - Add -k/--noclip option, prevents clipping by +# correcting liq_amplify. Correction shown in +# liq_amplify_adjustment. Adds liq_true_peak in dBFS. +# 2024-05-04 Moonbase59 - Fix liq_loudness unit: It is LUFS, not dB +# - Add (informational) liq_loudness_range +# 2024-06-02 Moonbase59 - use Mutagen for writing tags to MP4-type files +# - v1.1.0 add version numbering (semver) +# 2024-06-04 Moonbase59 - v1.2.0 Ensure all supported file types tagged safely. +# - Show Mutagen status, supported file types in help. +# - v1.2.1 Much more informative help, nicer formatting. +# - v1.2.2 Limit -t/--target input range to -23.0..0.0 +# - v1.2.3 Limit all params to sensible ranges +# - v2.0.0 Breaking: Add -r/--replaygain overwrite +# - Changed `liq_true_peak` to `liq_true_peak_db`, +# add new `liq_true_peak` (linear, like RG) +# - v2.0.1 Fix `liq_true_peak` reading when it still +# contains ` dBFS` from v1.2.3. +# 2024-06-05 Moonbase59 - No change, just version number. +# 2024-06-08 Moonbase59 - v2.0.3 Fix ffmpeg treating `.ogg` with cover as video +# 2024-06-09 Moonbase59 - v2.1.0 Read/override tags from JSON file (can be stdin) +# - Make variable checking more robust (bool & unit suffixes) +# - Add `liq_fade_in` & `liq_fade_out` tags for reading/writing, +# in case a preprocessor needs to set fade durations. +# 2024-06-11 Moonbase59 - v2.2.0 Sync version number 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. +# 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`. +# 2024-06-15 Moonbase59 - v4.0.1 Catch IndexError in sustained calculation if we are +# already at the end of the track. +# - Remove eprint in slope +# 2024-06-16 Moonbase59 - v4.0.2 Cleanup ending calculation code +# - Use max. LUGS of longtail & right end for sustained +# - Default SUSTAINED_LOUDNESS_DROP to 40.0% instead of 60.0%, +# for slightly tighter/denser playout (community wish) +# 2024-06-18 Moonbase59 - v4.0.3 Change LONGTAIL_EXTRA_LU from -15 to -12, +# most people seem to want transitions a bit tighter +# 2024-07-01 Moonbase59 - v4.0.4 Fix JSON override after analysis +# - Add `-nostdin` to ffmpeg commands, prevents strange +# errors when piping something to cue_file +# - streamline tag conversion code a little +# - only write known tags, not all `liq_*` +# 2024-07-02 Moonbase59 - v4.0.5 Fix minor bugs with file duration +# 2024-07-04 Moonbase59 - v4.0.6 Sync version with autocue.cue_file.liq +# 2024-07-05 Moonbase59 - v4.1.0 Add `liq_cue_file` to known tags, so we can +# forbid external processes to call us again and +# possibly deliver outdated metadata to us. +# 2024-08-05 Moonbase59 - v4.1.1 Fix JSON overriding if `liq_cue_file` is true +# 2024-11-08 toots - Renamed `cue_file_*` prefixes to `cue_file_*` +# +# 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__ = '4.1.1' + +import os +import sys +import tempfile +import subprocess +import argparse +import json +import re +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 + import mutagen.id3 + import mutagen.apev2 + import mutagen.mp4 + import mutagen.aiff + 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 +FFPROBE = "ffprobe" # location of the ffprobe binary +TARGET_LUFS = -18.0 # Reference Loudness Target +# -96 dB/LU is "digital silence" for 16-bit audio. +# A "noise floor" of -60 dB/LU (42 dB/LU below -18 target) is a good value +# to use. +SILENCE = -42.0 # LU below average for cue-in/cue-out trigger ("silence") +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 = -12.0 # reduce 15 dB extra on long tail songs to find overlap point +SUSTAINED_LOUDNESS_DROP = 40.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 +safe_ext = [ + ".mp3", # ID3 + ".flac", ".spx", ".opus", # Vorbis Comment + ".oga", ".ogv", # Vorbis Comment + ".wma", ".wmv", ".asf", # ASF/WMA tags +] + +# For all file types below we need Mutagen; ffmpeg corrupts these + +# MP4-like files using Apple iTunes-type tags +mp4_ext = [".m4a", ".m4b", ".m4p", ".m4v", ".m4r", ".mp4", ".alac"] + +# Ogg Vorbis files using VorbisComment tags +ogg_ext = [".ogg"] + +# ID3v2 file types +id3_ext = [".mp2", ".m2a"] + +# AIFF, non-compat ID3 tags +aiff_ext = [".aiff", ".aif", ".aifc"] + +# WAVE, RIFF, LIST INFO chunk, 'ID3 '/'id3 ' chunks; no 'BWF_' +wav_ext = [".wav"] + +# File types using APEv2 tags +ape_ext = [ + ".mpc", ".mp+", # Musepack + ".wv", # WavPack + ".ofr", ".ofs", # OptimFROG + ".ape", # Monkey's Audio + ".aac", # ADTS/ADIF AAC (raw) +] + +if MUTAGEN_AVAILABLE: + # Add the file types we can't tag safely.with ffmpeg + safe_ext.extend(mp4_ext) + safe_ext.extend(ogg_ext) + safe_ext.extend(id3_ext) + safe_ext.extend(aiff_ext) + safe_ext.extend(ape_ext) + safe_ext.extend(wav_ext) + +# minimum set of tags after "read_tags" that must be there before skipping +# analysis +tags_mandatory = set([ + "duration", + "cue_file_cue_in", + "cue_file_cue_out", + "cue_file_cross_start_next", + "replaygain_track_gain", +]) + + +# bool() returns True for every nonempty string, so use a function +def is_true(v): + if isinstance(v, str): + return v.lower() == 'true' + elif isinstance(v, bool): + return v + else: + raise ValueError('must be bool or str') + +# Need to handle pre-version 3.0.0 `cue_file_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, + "cue_file_amplify_adjustment": float, + "cue_file_amplify": float, # like replaygain_track_gain + "cue_file_blankskip": float_blankskip, # backwards-compatibility, was bool + "cue_file_blank_skipped": is_true, + "cue_file_cross_duration": float, + "cue_file_cross_start_next": float, + "cue_file_cue_duration": float, + "cue_file": is_true, + "cue_file_cue_in": float, + "cue_file_cue_out": float, + "cue_file_fade_in": float, + "cue_file_fade_out": float, + "cue_file_longtail": is_true, + "cue_file_loudness": float, + "cue_file_loudness_range": float, # like replaygain_track_range + "cue_file_reference_loudness": float, # like replaygain_reference_loudness + "cue_file_sustained_ending": is_true, + "cue_file_true_peak_db": float, + "cue_file_true_peak": float, + "r128_track_gain": int, + "replaygain_reference_loudness": float, + "replaygain_track_gain": float, + "replaygain_track_peak": float, + "replaygain_track_range": float, + # reserved for future expansion + "cue_file_hook1_in": float, + "cue_file_hook1_out": float, + "cue_file_hook2_in": float, + "cue_file_hook2_out": float, + "cue_file_hook3_in": float, + "cue_file_hook3_out": float, + "cue_file_ramp1": float, + "cue_file_ramp2": float, + "cue_file_ramp3": float, +} + + +def amplify_correct(target, loudness, true_peak_dB, noclip): + # check if we need to reduce the gain for true peaks + amplify_correction = 0.0 + if noclip: + amplify = target - loudness + max_amp = -1.0 - true_peak_dB # difference to EBU recommended -1 dBFS + if amplify > max_amp: + amplify_correction = max_amp - amplify + amplify = max_amp + else: + amplify = target - loudness + return amplify, amplify_correction + + +# remove " dB", " LU", " dBFS", " dBTP" and " LUFS" suffixes from +# tags_found +def remove_suffix(tags): + suffixed_tags = [ + "cue_file_amplify", "cue_file_amplify_adjustment", + "cue_file_loudness", "cue_file_loudness_range", "cue_file_reference_loudness", + "replaygain_track_gain", "replaygain_track_range", + "replaygain_reference_loudness", + "cue_file_true_peak_db", + "cue_file_true_peak", # in case old " dBFS" values were stored in v1.2.3 + ] + for tag in suffixed_tags: + if tag in tags and isinstance(tags[tag], str): + # 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 + + +# convert tags into their typed variants, ready for calculations +def convert_tags(tags): + items = tags.items() + + # make keys lowercase, include only tags in tags_to_check + tags = { + k.lower(): v for k, + v in items if k.lower() in tags_to_check} + + # remove suffixes from several tags + tags = remove_suffix(tags) + + # convert tag string values to the correct types, listed in tags_to_check + tags = {k: tags_to_check[k](v) for k, v in tags.items()} + + return tags + +# override a (typed) result with JSON overrides +def override_from_JSON(tags, tags_json={}): + # get tags in JSON override file + tags_in_json = convert_tags(tags_json) + + # do NOT overwrite from JSON if `cue_file` is true + if "cue_file" in tags and tags["cue_file"] == True: + pass + else: + # unify, right overwrites left if key in both + tags = {**tags, **tags_in_json} + + return tags + + +def read_tags( + filename, + ffprobe=FFPROBE, + tags_json={}, + target=TARGET_LUFS, + 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 + # ffprobe -v quiet -show_entries + # 'stream=codec_name:stream_tags:format_tags' -print_format json=compact=1 + # filename + r = subprocess.run( + [ + ffprobe, + "-v", + "quiet", + "-show_entries", + "stream=codec_name,duration:stream_tags:format_tags", + "-of", + "json=compact=1", + filename, + ], + stdout=subprocess.PIPE, + # stderr=subprocess.STDOUT, + check=True, + text=True).stdout + + result = json.loads(r) + # eprint(json.dumps(result, indent=2)) + + # get tags in stream #0 (mka, opus, etc.) + try: + stream_tags = result['streams'][0]['tags'] + except KeyError: + stream_tags = {} + + # get tags in format (flac, mp3, etc.) + try: + format_tags = result['format']['tags'] + except KeyError: + format_tags = {} + + tags_in_stream = convert_tags(stream_tags) + tags_in_format = convert_tags(format_tags) + tags_in_json = convert_tags(tags_json) + + # 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} + # eprint(json.dumps(tags_found, indent=2, sort_keys=True)) + + # add duration of stream #0 if it exists and can be converted to float + try: + tags_found["duration"] = float(result['streams'][0]['duration']) + except (KeyError, ValueError): + try: + # we might have a video duration (.mka) like "00:07:01.117000000", + # ignore + del tags_found["duration"] + except KeyError: + pass + + # create replaygain_track_gain from Opus R128_TRACK_GAIN (ref: -23 LUFS) + if "r128_track_gain" in tags_found: + rg = float(tags_found["r128_track_gain"]) / 256 + (target - -23.0) + tags_found["replaygain_track_gain"] = rg + + # add missing cue_file_amplify, if we have replaygain_track_gain + if ("cue_file_amplify" not in tags_found) and ( + "replaygain_track_gain" in tags_found): + tags_found["cue_file_amplify"] = tags_found["replaygain_track_gain"] + + # Handle old RG1/mp3gain positive loudness reference + # "89 dB" (SPL) should actually be -14 LUFS, but as a reference + # it is usually set equal to the RG2 -18 LUFS reference point + if (("replaygain_reference_loudness" in tags_found) + and tags_found["replaygain_reference_loudness"] > 0.0): + tags_found["replaygain_reference_loudness"] -= 107.0 + + # add missing cue_file_reference_loudness, if we have + # replaygain_reference_loudness + if (("cue_file_reference_loudness" not in tags_found) + and ("replaygain_reference_loudness" in tags_found)): + tags_found["cue_file_reference_loudness"] = tags_found["replaygain_reference_loudness"] + + # if both cue_file_cue_in & cue_file_cue_out available, we can calculate + # cue_file_cue_duration + if "cue_file_cue_in" in tags_found and "cue_file_cue_out" in tags_found: + tags_found["cue_file_cue_duration"] = tags_found["cue_file_cue_out"] - \ + tags_found["cue_file_cue_in"] + + # see if we need a re-analysis + skip_analysis = tags_mandatory.issubset(tags_found.keys()) + + # try to avoid re-analysis if we have enough data but different loudness + # target + if ( + skip_analysis + and "cue_file_amplify" in tags_found + and "cue_file_reference_loudness" in tags_found + ): + # adjust cue_file_amplify by loudness target difference, set reference + tags_found["cue_file_amplify"] += (target - + tags_found["cue_file_reference_loudness"]) + tags_found["cue_file_reference_loudness"] = target + else: + # cue_file_amplify or cue_file_reference_loudness missing, must re-analyse + skip_analysis = False + + # we need cue_file_true_peak_db if noclip is requested + if ( + skip_analysis + and "cue_file_true_peak_db" in tags_found + and "cue_file_true_peak" in tags_found # for RG tag writing + and "cue_file_loudness" in tags_found + ): + tags_found["cue_file_amplify"], tags_found["cue_file_amplify_adjustment"] = \ + amplify_correct( + target, + tags_found["cue_file_loudness"], + tags_found["cue_file_true_peak_db"], + noclip + ) + else: + skip_analysis = False + + # if cue_file_blankskip different from requested, we need a re-analysis + if (skip_analysis + and "cue_file_blankskip" in tags_found + and (tags_found["cue_file_blankskip"] != blankskip) + ): + skip_analysis = False + + # cue_file_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 "cue_file_loudness_range" not in tags_found + ): + skip_analysis = False + + # eprint(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=0.0, noclip=False): + # we need not check those in tags_mandatory and those calculated by + # read_tags + + if "cue_file_longtail" not in tags_found: + tags_found["cue_file_longtail"] = False + + if "cue_file_sustained_ending" not in tags_found: + tags_found["cue_file_sustained_ending"] = False + + # if not "cue_file_cross_duration" in tags_found: + # tags_found["cue_file_cross_duration"] = tags_found["cue_file_cue_out"] - tags_found["cue_file_cross_start_next"] + + if "cue_file_amplify" not in tags_found: + tags_found["cue_file_amplify"] = tags_found["replaygain_track_gain"] + + if "cue_file_amplify_adjustment" not in tags_found: + tags_found["cue_file_amplify_adjustment"] = 0.00 # dB + + if "cue_file_loudness" not in tags_found: + tags_found["cue_file_loudness"] = target - \ + tags_found["replaygain_track_gain"] + + if "cue_file_blankskip" not in tags_found: + tags_found["cue_file_blankskip"] = blankskip + + if "cue_file_blank_skipped" not in tags_found: + tags_found["cue_file_blank_skipped"] = False + + if "cue_file_reference_loudness" not in tags_found: + tags_found["cue_file_reference_loudness"] = target + + # for RG tag writing + if "replaygain_track_gain" not in tags_found: + tags_found["replaygain_track_gain"] = tags_found["cue_file_amplify"] + if "replaygain_track_peak" not in tags_found: + tags_found["replaygain_track_peak"] = tags_found["cue_file_true_peak"] + if "replaygain_track_range" not in tags_found: + tags_found["replaygain_track_range"] = tags_found["cue_file_loudness_range"] + if "replaygain_reference_loudness" not in tags_found: + tags_found["replaygain_reference_loudness"] = tags_found["cue_file_reference_loudness"] + + return tags_found + + +def analyse( + filename, + ffmpeg=FFMPEG, + target=TARGET_LUFS, + overlay=OVERLAY_LU, + silence=SILENCE, + longtail_seconds=LONGTAIL_SECONDS, + extra=LONGTAIL_EXTRA_LU, + drop=SUSTAINED_LOUDNESS_DROP, + 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 + # ffmpeg -v quiet -y -i audiofile.ext -vn -af ebur128=target=-18:peak=true:metadata=1,ametadata=mode=print:file=- -f null null + # Output: + # frame:448 pts:2150400 pts_time:44.8 + # lavfi.r128.M=-78.490 + # lavfi.r128.S=-78.566 + # lavfi.r128.I=-18.545 + # lavfi.r128.LRA=5.230 + # lavfi.r128.LRA.low=-23.470 + # lavfi.r128.LRA.high=-18.240 + # lavfi.r128.true_peaks_ch0=1.537 + # lavfi.r128.true_peaks_ch1=1.632 + + args = [ + ffmpeg, + "-v", + "quiet", + "-nostdin", + "-y", + "-i", + filename, + "-vn", + "-af", + "ebur128=target=" + + str(target) + + ":peak=true:metadata=1,ametadata=mode=print:file=-", + "-f", + "null", + "null"] + if nice: + # adds 18 to nice value (almost "ultimately nice", max. is 19) + args.insert(0, "nice") + args.insert(1, "-n") + args.insert(2, "18") + + result = subprocess.run( + args, + stdout=subprocess.PIPE, + # stderr=subprocess.STDOUT, + check=True, + text=True).stdout + + measure = [] + # Extract time "t", momentary (last 400ms) loudness "M" and "I" integrated loudness + # 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*)", + r"frame:.*pts_time:\s*(?P\d+\.?\d*)\s*lavfi\.r128\.M=(?Pnan|[+-]?\d+\.?\d*)\s*.*\s*lavfi\.r128\.I=(?Pnan|[+-]?\d+\.?\d*)\s*(?P(\s*(?!frame:).*)*)", + flags=re.M) + + for match in re.finditer(pattern, result): + m = match.groupdict() + 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 + end = len(measure) + + # get actual duration from last PTS (Presentation Time Stamp) + # This is the last frame, so the total duration is its PTS + frame length + # (100ms) + duration = measure[end - 1][0] + 0.1 + + # get integrated song loudness from last frame, so we can calculate cue_file_amplify + # (the "ReplayGain") from it (difference to desired loudness target) + loudness = measure[end - 1][2] + + # get true peak and LRA values from last frame + # for multi-channel audio, this takes the highest channel value + # true peak result is in dBFS, LRA in LU + last_lines = measure[end - 1][3].splitlines() + true_peak = 0.0 # absolute silence + loudness_range = 0.0 + for line in last_lines: + if line.startswith("lavfi.r128.true_peaks_ch"): + k, v = line.split("=") + true_peak = max(true_peak, float(v)) + if line.startswith("lavfi.r128.LRA="): + k, v = line.split("=") + loudness_range = float(v) + if true_peak > 0.0: + true_peak_dB = 20.0 * math.log10(true_peak) + else: + true_peak_dB = float('-inf') + # eprint(true_peak, true_peak_dB) + + # Find cue-in point (loudness above "silence") + silence_level = loudness + silence + cue_in_time = 0.0 + for i in range(start, end): + if measure[i][1] > silence_level: + cue_in_time = measure[i][0] + start = i + break + # EBU R.128 measures loudness over the last 400ms, + # adjust to zero if we land before 400ms for cue-in + cue_in_time = 0.0 if cue_in_time < 0.4 else cue_in_time + + # Instead of simply reversing the list (measure.reverse()), we henceforth + # use "start" and "end" pointers into the measure list, so we can easily + # check forwards and backwards, and handle partial ranges better. + # This is mainly for early cue-outs due to blanks in file ("hidden tracks"), + # as we need to handle overlaying and long tails correctly in this case. + + cue_out_time = 0.0 + cue_out_time_blank = 0.0 + + # 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: + # eprint("Checking for blank") + end_blank = end + i = start + while i in range(start, end): + if measure[i][1] <= silence_level: + cue_out_time_blank_start = measure[i][0] + cue_out_time_blank_stop = measure[i][0] + blankskip + end_blank = i + 1 + 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 + # 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 + # 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 + # eprint(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" + for i in reversed(range(start, end)): + if measure[i][1] > silence_level: + cue_out_time = measure[i][0] + end = i + 1 + # 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) + + # Adjust cue-out and "end" point if we're working with blank detection. + # Also set a flag (`cue_file_blank_skipped`) so we can later see if cue-out is + # early. + blank_skipped = False + if blankskip: + # cue out PAST the current frame (100ms) -- no, reverse that + # cue_out_time_blank = cue_out_time_blank + 0.1 + # 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 + end = end_blank + + # Find overlap point (where to start next song), backwards from end, + # by checking if song loudness goes below overlay start volume + 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 loudness drop over arbitrary number of measure elements + # Split into left & right part, use avg momentary loudness & time of each + def calc_ending(elements): + l = len(elements) + if l < 1: + raise ValueError("need at least one measure point to calculate") + 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[:] + # eprint(l, l2, len(p1), len(p2)) + t = elements[l2][0] # time of midpoint + x1 = sum(i[0] for i in p1) # sum time + x2 = sum(i[0] for i in p2) # sum time + y1 = sum(i[1] for i in p1) # sum momentary loudness + y2 = sum(i[1] for i in p2) # sum momentary loudness + # calculate averages + if l2 > 0: + x1 /= len(p1) + x2 /= len(p2) + y1 /= len(p1) + y2 /= len(p2) + dx = x2 - x1 + dy = y2 - y1 + # removed slope (m = dy/dx), not really needed + # use math.atan2 instead of math.atan, determines quadrant, handles + # errors + # slope angle clockwise in degrees + angle = math.degrees(math.atan2(dy, dx)) + mid_time = elements[l2][0] # midpoint time in seconds + mid_lufs = elements[l2][1] # midpoint momentary loudness in LUFS + try: + lufs_ratio_pct = (1 - (y1 / y2)) * 100.0 # ending LUFS ratio in % + except ZeroDivisionError: + lufs_ratio_pct = (1 - float("inf")) * 100.0 + # eprint( + # f"Left: {x1:.2f} s, {y1:.2f} LUFS avg ({len(p1)/10:.2f} s), " + # f"Right: {x2:.2f} s, {y2:.2f} LUFS avg ({len(p2)/10:.2f} s), " + # f"angle={angle:.2f}°, " + # f"Drop: {lufs_ratio_pct:.2f}%" + # ) + return mid_time, mid_lufs, angle, 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") + + # Calculation can only be done if we have at least one measure point. + # We don’t if we’re already at the end. (Badly cut file?) + if range(start_next_idx, end): + _, _, _, lufs_ratio_pct, end_lufs = calc_ending( + measure[start_next_idx:end]) + eprint(f"Overlay: {loudness+overlay:.2f} LUFS, Longtail: {loudness + overlay + extra:.2f} LUFS, Measured end avg: {end_lufs:.2f} LUFS, Drop: {lufs_ratio_pct:.2f}%") + # We want to keep songs with a sustained ending intact, so if the + # calculated loudness drop at the end (LUFS ratio) is smaller than + # the set percentage, we check again, by reducing the loudness + # to look for by the maximum of end loudness and set extre longtail + # loudness + if lufs_ratio_pct < drop: + sustained = True + start_next_level = max(end_lufs, 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) + else: + eprint("Already at end of track (badly cut?), no ending to analyse.") + + # 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_longtail = 0.0 + for i in reversed(range(start, end)): + if measure[i][1] > start_next_level: + start_next_time_longtail = measure[i][0] + break + 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"Overlay 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 + # (a value of 0.0 is invalid in Liquidsoap) + cross_duration = cue_out_time - start_next_time + + amplify, amplify_correction = amplify_correct( + target, loudness, true_peak_dB, noclip) + + # We now also return start_next_time + + # NOTE: Liquidsoap doesn’t currently accept `cue_file_cross_duration=0.`, + # or `cue_file_cross_start_next == cue_file_cue_out`, but this can happen. + # We adjust for that in the Liquidsoap protocol, because other AutoDJ + # applications might want the correct values. + + # return a dict + return ({ + "duration": duration, + "cue_file_cue_duration": cue_duration, + "cue_file_cue_in": cue_in_time, + "cue_file_cue_out": cue_out_time, + "cue_file_cross_start_next": start_next_time, + "cue_file_longtail": longtail, + "cue_file_sustained_ending": sustained, + # "cue_file_cross_duration": cross_duration, + "cue_file_loudness": loudness, + "cue_file_loudness_range": loudness_range, + "cue_file_amplify": amplify, + "cue_file_amplify_adjustment": amplify_correction, + "cue_file_reference_loudness": target, + "cue_file_blankskip": blankskip, + "cue_file_blank_skipped": blank_skipped, + "cue_file_true_peak": true_peak, + "cue_file_true_peak_db": true_peak_dB, + # for RG writing + "replaygain_track_gain": amplify, + "replaygain_track_peak": true_peak, + "replaygain_track_range": loudness_range, + "replaygain_reference_loudness": target + }) + + +def write_tags(filename, tags={}, replaygain=False): + # Add the cue_file_* tags (and only these) + # Only touch replaygain_track_gain or R128_TRACK_GAIN if so requested. + # Only write tags to files if we can safely do so. + filename = Path(filename) + + # Only write if "safe" file type and file is writable. + if filename.suffix.casefold() in safe_ext and os.access(filename, os.W_OK): + # This doesn’t work cross-device! + # 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) + # eprint(temp) + + rg_tags = [ + "replaygain_track_gain", + "replaygain_track_peak", + "replaygain_track_range", + "replaygain_reference_loudness" + ] + # copy only `cue_file_*`, float with 2 decimals, bools and strings lowercase + tags_new = {k: "{:.2f}".format(v) + if isinstance(v, float) else str(v).lower() + for k, v in tags.items() if k in tags_to_check or k in rg_tags + } + # cue_file_true_peak & replaygain_track_peak have 6 decimals, fix it + if "cue_file_true_peak" in tags_new: + tags_new["cue_file_true_peak"] = "{:.6f}".format(tags["cue_file_true_peak"]) + if "replaygain_track_peak" in tags_new: + tags_new["replaygain_track_peak"] = "{:.6f}".format( + tags["replaygain_track_peak"]) + + # pre-calculate Opus R128_TRACK_GAIN (ref: -23 LUFS), just in case + target = tags["cue_file_reference_loudness"] + og = str(int((tags["cue_file_amplify"] - (target - -23.0)) * 256)) + tags_new["R128_TRACK_GAIN"] = og + + # add the units + tags_new["cue_file_amplify"] += " dB" + tags_new["cue_file_amplify_adjustment"] += " dB" + tags_new["cue_file_loudness"] += " LUFS" + tags_new["cue_file_loudness_range"] += " LU" + tags_new["cue_file_reference_loudness"] += " LUFS" + tags_new["cue_file_true_peak_db"] += " dBFS" + tags_new["replaygain_track_gain"] += " dB" + tags_new["replaygain_track_range"] += " dB" + tags_new["replaygain_reference_loudness"] += " LUFS" + + if replaygain: + # delete unwanted tags + if filename.suffix.casefold() == ".opus": + # for Opus, delete the `replaygain_*` tags + for k in rg_tags: + tags_new.pop(k, None) + else: + # for all others, delete Opus Track Gain tag + del tags_new["R128_TRACK_GAIN"] + else: + # no ReplayGain override, remove all "gain" type tags + for k in rg_tags: + tags_new.pop(k, None) + del tags_new["R128_TRACK_GAIN"] + + # Never write "duration" to tags + if "duration" in tags_new: + del tags_new["duration"] + + # 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 + f = mutagen.mp4.MP4(filename) + if f.tags is None: + f.add_tags() + for k, v in tags_new.items(): + f[f'----:com.apple.iTunes:{k}'] = bytes(v, 'utf-8') + f.save() + + elif MUTAGEN_AVAILABLE and filename.suffix.casefold() in id3_ext: + # Additional file types that use ID3v2 tags; abstract + try: + t = mutagen.id3.ID3(filename) + except mutagen.id3.ID3NoHeaderError: + # No ID3 tags? Create an empty block. + t = mutagen.id3.ID3() + for k, v in tags_new.items(): + # encoding: LATIN1 (ISO-8859-1) + t.add(mutagen.id3.TXXX(encoding=0, desc=k, text=[v])) + t.save(filename, v2_version=4, v23_sep='; ') + + elif MUTAGEN_AVAILABLE and filename.suffix.casefold() in ape_ext: + # File types using APEv2 tags; abstract + try: + t = mutagen.apev2.APEv2(filename) + except mutagen.apev2.APENoHeaderError: + # No APE tags? Create an empty block. + t = mutagen.apev2.APEv2() + for k, v in tags_new.items(): + t[k] = v + t.save(filename) + + elif MUTAGEN_AVAILABLE and filename.suffix.casefold() in ogg_ext: + # Ogg file types using VorbisComment tags + f = mutagen.oggvorbis.OggVorbis(filename) + if f.tags is None: + f.add_tags() + for k, v in tags_new.items(): + f[k] = v + f.save(filename) + + elif MUTAGEN_AVAILABLE and filename.suffix.casefold() in aiff_ext: + # AIFF with ID3 tags needs special handling + f = mutagen.aiff.AIFF(filename) + if f.tags is None: + f.add_tags() + for k, v in tags_new.items(): + # encoding: LATIN1 (ISO-8859-1) + f.tags.add(mutagen.id3.TXXX(encoding=0, desc=k, text=[v])) + f.save(filename, v2_version=4, v23_sep='; ') + + elif MUTAGEN_AVAILABLE and filename.suffix.casefold() in wav_ext: + # WAV special 'id3 '/'ID3 ' RIFF chunk containing ID3v2 tags + # non-standard, but we can’t use 'BWF_' broadcasting yet + f = mutagen.wave.WAVE(filename) + if f.tags is None: + f.add_tags() + for k, v in tags_new.items(): + # encoding: LATIN1 (ISO-8859-1) + f.tags.add(mutagen.id3.TXXX(encoding=0, desc=k, text=[v])) + f.save(filename, v2_version=4, v23_sep='; ') + + else: + # Use ffmpeg for the "safe" file types it doesn’t corrupt. + metadata_args = [] + for k, v in tags_new.items(): + metadata_args.extend(['-metadata', f'{k}={v}']) + # eprint(metadata_args) + + args = [ + ffmpeg, + '-v', 'quiet', + '-nostdin', + '-y', + '-i', str(filename.absolute()), + '-map_metadata', '0', + # '-movflags', 'use_metadata_tags', + *metadata_args, + '-c', 'copy', + str(temp) + ] + proc = subprocess.run(args, stdout=subprocess.PIPE, check=True) + + # mv temp original; atomic + os.replace(str(temp), str(filename.absolute())) + + return + + +# CLI command parser and help text +class Range(argparse.Action): + def __init__(self, minimum=None, maximum=None, *args, **kwargs): + self.min = minimum + self.max = maximum + # kwargs["metavar"] = "[%d-%d]" % (self.min, self.max) + super().__init__(*args, **kwargs) + + def __call__(self, parser, namespace, value, option_string=None): + if not (self.min <= value <= self.max): + msg = "invalid choice: %r (range %.1f to %.1f)" % ( + value, + self.min, + self.max, + ) + raise argparse.ArgumentError(self, msg) + setattr(namespace, self.dest, value) + + +class CustomFormatter(argparse.ArgumentDefaultsHelpFormatter): + """Format output of help text nicely. + + This works like ``ArgumentDefaultsHelpFormatter`` plus + ``RawDescriptionHelpFormatter`` in one: It wraps arguments, prolog + and epilog nicely at terminal width and also keeps newlines in + prolog and epilog intact. + """ + + def _fill_text(self, text, width, indent): + lines = text.strip().splitlines(keepends=True) + new = [] + # if 'replace_whitespace=False' is used, textwrap makes a mess + # we need to call it line by line + for line in lines: + new.append( + "\n".join( + textwrap.wrap( + line, + width, + initial_indent=indent, + subsequent_indent=indent, + replace_whitespace=False, + ) + ) + ) + return "\n".join(new) + + +parser = argparse.ArgumentParser( + description=f""" +Analyse audio file for cue-in, cue-out, overlay and EBU R128 loudness data, results as JSON. Optionally writes tags to original audio file, avoiding unnecessary re-analysis and getting results MUCH faster. This software is mainly intended for use with my Liquidsoap \"autocue:\" protocol. + +%(prog)s {__version__} supports writing tags to these file types: +{', '.join(sorted(safe_ext))}. +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()))}. + +The absolute minimum set to (possibly) avoid a re-analysis is: +{', '.join(sorted(tags_mandatory))}. + +A full audio file analysis can take some time. %(prog)s tries to avoid a (re-)analysis if all required data can be read from existing tags in the file. + +Please report any issues to https://github.com/Moonbase59/autocue/issues +""", + formatter_class=CustomFormatter) + +parser.add_argument( + "-V", + "--version", + action='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", + "--target", + minimum=-23.0, + maximum=0.0, + action=Range, + default=TARGET_LUFS, + help="LUFS reference target; %(min).1f to %(max).1f", + type=float) +parser.add_argument( + "-s", + "--silence", + minimum=-96.0, + maximum=0.0, + action=Range, + default=SILENCE, + help="LU below integrated track loudness for cue-in & cue-out points " + "(silence removal at beginning & end of a track)", + type=float) +parser.add_argument( + "-o", + "--overlay", + minimum=-96.0, + maximum=0.0, + action=Range, + default=OVERLAY_LU, + help="LU below integrated track loudness to trigger next track", + type=float) +parser.add_argument( + "-l", + "--longtail", + minimum=0.0, + maximum=60.0, + action=Range, + default=LONGTAIL_SECONDS, + help="More than so many seconds of calculated overlay duration are considered " + "a long tail, and will force a recalculation using --extra, thus keeping long " + "song endings intact", + type=float) +parser.add_argument( + "-x", + "--extra", + minimum=-96.0, + maximum=0.0, + action=Range, + default=LONGTAIL_EXTRA_LU, + 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", + help="Clipping prevention: Lowers track gain if needed, to avoid peaks " + "going above -1 dBFS. Uses true peak values of all audio channels.", + default=False, + action='store_true') +parser.add_argument( + "-b", + "--blankskip", + 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", + help="Write Liquidsoap cue_file_* tags to file. Ensure you have enough " + "free space to hold a copy of the original file.", + default=False, + action='store_true') +parser.add_argument( + "-r", + "--replaygain", + help="Write ReplayGain tags to file (track only, no album). Useful if " + "your files have no previous RG tags. Only valid if -w/--write is also " + "specified.", + default=False, + action='store_true') +parser.add_argument( + "-f", + "--force", + help="Force re-analysis, even if tags exist", + default=False, + action='store_true') +parser.add_argument( + "-n", + "--nice", + help="Linux/MacOS only: Use nice? Will run analysis at nice level 18.", + default=False, + action='store_true') +parser.add_argument( + "-j", + "--json", + help="Read/override tags from a JSON file. Use - to read from stdin. " + "Intended for pre-processing software which can, for instance, fill in " + "values from their database here.", + type=argparse.FileType('r'), +) +parser.add_argument( + "--ffmpeg", + default=FFMPEG, + help="Path to the ffmpeg command line binary") +parser.add_argument( + "--ffprobe", + default=FFPROBE, + help="Path to the ffprobe command line binary") + +args = parser.parse_args() + +# read JSON from stdin or file, containing "overriding" or missing tags +# intended for pre-processing software +tags_json = {} +if args.json: + try: + tags_json = json.load(args.json) + except json.decoder.JSONDecodeError: + pass + args.json.close() + +skip_analysis, tags_found = read_tags( + args.file, args.ffprobe, tags_json, args.target, args.blankskip, args.noclip) + +if args.force or not skip_analysis: + result = analyse( + filename=args.file, + ffmpeg=args.ffmpeg, + target=args.target, + overlay=args.overlay, + silence=args.silence, + longtail_seconds=args.longtail, + extra=args.extra, + drop=args.drop, + blankskip=args.blankskip, + nice=args.nice, + noclip=args.noclip + ) + # allow to override even the analysis results (not if `cue_file=true`) + if args.json: + result = override_from_JSON(result, tags_json) + # override duration, seems ffprobe can be more exact + if "duration" in tags_found: + result["duration"] = tags_found["duration"] +else: + result = add_missing(tags_found, args.target, args.blankskip, args.noclip) + +# eprint(result) + +if args.write: + write_tags(args.file, result, args.replaygain) + +# prepare JSON result +# we use "dB" instead of "LU" units, because LS & others don’t understand "LU" +cue_file_result = { + "duration": result['duration'], + "cue_file_cue_duration": result['cue_file_cue_duration'], + "cue_file_cue_in": result['cue_file_cue_in'], + "cue_file_cue_out": result['cue_file_cue_out'], + "cue_file_cross_start_next": result['cue_file_cross_start_next'], + "cue_file_longtail": result["cue_file_longtail"], + "cue_file_sustained_ending": result['cue_file_sustained_ending'], + # "cue_file_cross_duration": result['cue_file_cross_duration'], + "cue_file_loudness": f"{result['cue_file_loudness']:.2f} LUFS", + "cue_file_loudness_range": f"{result['cue_file_loudness_range']:.2f} LU", + "cue_file_amplify": f"{result['cue_file_amplify']:.2f} dB", + "cue_file_amplify_adjustment": f"{result['cue_file_amplify_adjustment']:.2f} dB", + "cue_file_reference_loudness": f"{result['cue_file_reference_loudness']:.2f} LUFS", + "cue_file_blankskip": result['cue_file_blankskip'], + "cue_file_blank_skipped": result['cue_file_blank_skipped'], + "cue_file_true_peak": result['cue_file_true_peak'], + "cue_file_true_peak_db": f"{result['cue_file_true_peak_db']:.2f} dBFS", +} + +# output compact (one line) JSON, for use in Liquidsoap "autocue:" protocol +json_output = json.dumps(cue_file_result) +print(json_output) diff --git a/scripts/dune b/scripts/dune index b96c64c5e6..90bb92121a 100644 --- a/scripts/dune +++ b/scripts/dune @@ -28,3 +28,27 @@ (liquidsoap-mode.el as emacs/site-lisp/liquidsoap-mode.el) (liquidsoap-completion.el as emacs/site-lisp/liquidsoap-completion.el) (liquidsoap-completions.el as emacs/site-lisp/liquidsoap-completions.el))) + +(rule + (alias update_cue_file) + (target cue_file.new) + (action + (progn + (run + wget + https://raw.githubusercontent.com/savonet/autocue.cue_file/refs/heads/main/cue_file + -O + %{target}) + (run chmod +x %{target})))) + +(rule + (alias update_cue_file) + (action + (diff cue_file cue_file.new))) + +(install + (section + (site + (liquidsoap-lang scripts))) + (package liquidsoap-core) + (files cue_file)) diff --git a/src/lang/builtins_lang.ml b/src/lang/builtins_lang.ml index 7d9411957c..b2920da85e 100644 --- a/src/lang/builtins_lang.ml +++ b/src/lang/builtins_lang.ml @@ -276,3 +276,22 @@ let _ = [("", Lang.univ_t (), Some Lang.null, None)] (Lang.univ_t ()) (fun p -> List.assoc "" p) + +let sites = Lang.add_module ~base:liquidsoap "sites" + +let _ = + List.iter + (function + | name, path :: _ -> + ignore + (Lang.add_builtin_base ~category:`Configuration + ~descr:("Path to configured location site " ^ name) + ~base:sites name (`String path) Lang.string_t) + | _ -> ()) + [ + ("bin", Sites.Sites.bin); + ("cache", Sites.Sites.cache); + ("lib_root", Sites.Sites.lib_root); + ("libs", Sites.Sites.libs); + ("scripts", Sites.Sites.scripts); + ] diff --git a/src/libs/autocue.cue_file.liq b/src/libs/autocue.cue_file.liq new file mode 100644 index 0000000000..d2b49cee98 --- /dev/null +++ b/src/libs/autocue.cue_file.liq @@ -0,0 +1,1255 @@ +# autocue.cue_file.liq +# 2024-04-10 - Moonbase59 +# 2024-04-12 - Toots: re-organize to integrate as core autocue implementation. +# 2024-04-12 - Moonbase59 - re-introduce `liq_duration` as `liq_cue_duration`. +# 2024-04-19 - Moonbase59 - rename to "autocue.cue_file.liq" +# - update to use same `cue_file` as master branch +# 2024-04-20 - Moonbase59 - allow floats as loudness values +# 2024-04-24 - Moonbase59 - rework to follow same logic as autocue2 +# 2024-04-25 - Moonbase59 - handle old RG1/mp3gain positive loudness reference +# - add nice option (+10) for Linux users +# 2024-04-30 - Toots & Moonbase59 - Fix extra_metadata bug, make these optional +# replaygain_track_gain, +# replaygain_reference_loudness +# 2024-05-02 - Moonbase59 - add clipping prevention logic (cue_file -k) +# 2024-05-04 - Moonbase59 - Add (informational) liq_loudness_range +# 2024-06-04 - Moonbase59 - v2.0.0 Breaking: Add -r/--replaygain overwrite +# - Changed `liq_true_peak` to `liq_true_peak_db`, +# add new `liq_true_peak` (linear, like RG) +# 2024-06-05 - Moonbase59 - v2.0.2 Initial display of version, at log level 2. +# 2024-06-08 - Moonbase59 - v2.0.3 Sync version number with cue_file +# 2024-06-09 - Moonbase59 - v2.1.0 Sync version number with cue_file +# 2024-06-11 - Moonbase59 - v2.2.0 JSON override tags for cue_file in temp file: +# 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. +# 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. +# 2024-06-15 - Moonbase59 - v4.0.1 - Sync with cue_file version +# 2024-06-16 - Moonbase59 - v4.0.2 - Allow `-8.33dB` type values with no blank +# 2024-06-18 - Moonbase59 - v4.0.3 - Changed overlay_longtail from -15 to -12, +# most people seem to want transitions a bit tighter +# 2024-07-01 - Moonbase59 - v4.0.4 - Sync with cue_file version +# 2024-07-02 - Moonbase59 - v4.0.5 - Sync with cue_file version +# 2024-07-04 - Moonbase59 - v4.0.6 - Make duration non-overridable, i.e., +# it’s ALWAYS taken from the cue_file result. +# 2024-07-05 - Moonbase59 - v4.1.0 - New `liq_cue_file handling, allows to +# ignore overrides for cue_file data if true. This is +# mainly for fast-changing files like news or time, +# for which LS/AzuraCast might not yet have updated +# the metadata. +# - not set = default (metadata can override cue_file) +# - false = don’t autocue (still use metadata if present) +# - true = cue_file results override metadata +# 2024-08-05 - Moonbase59 - v4.1.1 Sync with cue_file version +# 2024-08-19 - Toots - Fix compatibility with `2.3.x`, rename `liq_*` prefixed metadata to `cue_file_*` + +# Lots of debugging output for AzuraCast in this, will be removed eventually. + +# --- Copy-paste Azuracast LS Config, second input box BEGIN --- + +let version = "4.1.1" + +# Initialize settings for cue_file autocue implementation +let settings.autocue.cue_file = () + +let settings.autocue.cue_file.path = + settings.make( + description= + "Path of the cue_file binary.", + "cue_file" + ) + +let settings.autocue.cue_file.ffmpeg = + settings.make( + description= + "Path of the ffmpeg binary.", + "ffmpeg" + ) + +let settings.autocue.cue_file.ffprobe = + settings.make( + description= + "Path of the ffprobe binary.", + "ffprobe" + ) + +let settings.autocue.cue_file.fade_in = + settings.make( + description= + "Default fade-in duration if not specified by the user.", + 0.1 + ) + +let settings.autocue.cue_file.fade_out = + settings.make( + description= + "Default fade-out duration if not specified by the user.", + 2.5 + ) + +let settings.autocue.cue_file.timeout = + settings.make( + description= + "Timeout (in seconds) for cue_file executions.", + 60.0 + ) + +let settings.autocue.cue_file.target = + settings.make( + description= + "Loudness target in LUFS.", + -18.0 + ) + +let settings.autocue.cue_file.silence = + settings.make( + description= + "Silence level (for cue points) in LU below track loudness.", + -42.0 + ) + +let settings.autocue.cue_file.overlay = + settings.make( + description= + "Start overlay level in LU below track loudness.", + -8.0 + ) + +let settings.autocue.cue_file.longtail = + settings.make( + description= + "More than so many seconds of calculated overlay are considered a long \ + tail.", + 15.0 + ) + +let settings.autocue.cue_file.overlay_longtail = + settings.make( + description= + "Extra LU level below overlay loudness, to recalculate songs with long \ + tails.", + -12.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. Otherwise, it has a hard ending.", + 40.0 + ) + +let settings.autocue.cue_file.noclip = + settings.make( + description= + "Clipping prevention: Lowers track gain if needed, to avoid peaks going \ + above -1 dBFS. Uses true peak values of all audio channels.", + false + ) + +let settings.autocue.cue_file.blankskip = + settings.make( + description= + "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 = + settings.make( + description= + 'Unify `replaygain_track_gain` and `cue_file_amplify`. If enabled, this \ + will ensure both have the same value, with `replaygain_track_gain` taking \ + precedence if seen, and we have a `replaygain_reference_loudness`. Allows \ + scripts to amplify on either value, without loudness jumps.', + true + ) + +let settings.autocue.cue_file.write_tags = + settings.make( + description= + "Write back `cue_file_*` tags to original audio file. Ensure you have \ + enough free space to hold a copy of the original file.", + false + ) + +let settings.autocue.cue_file.write_replaygain = + settings.make( + description= + "Write ReplayGain tags to file (track only, no album). Useful if your \ + files have no previous RG tags. Only valid if `write_tags` is also true.", + false + ) + +let settings.autocue.cue_file.force_analysis = + settings.make( + description= + 'Force re-analysis even when all needed data could be read from file \ + tags.', + false + ) + +let settings.autocue.cue_file.nice = + settings.make( + description= + 'Linux/MacOS only: Use nice for `cue_file` operations?', + false + ) + +let settings.autocue.cue_file.use_json_metadata = + settings.make( + description= + 'Send metadata to `cue_file` as JSON, allowing to override/add to \ + autocue-relevant metadata stored in file tags. This can help to avoid \ + unnecessary re-analysis runs.', + true + ) + +let settings.autocue.cue_file.ignored_overrides = + settings.make( + description= + 'List of cue_file results that cannot be overridden by existing metadata \ + or annotations. One such field is `duration`, as it is not a tag, and \ + determined otherwise.', + ['duration'] + ) + +stdlib_metadata = metadata + +# metadata.json.stringify only exports a limited set, use our own +def meta_json_stringify(~compact=false, ~json5=false, m) = + m = metadata.cover.remove(m) + data = json() + list.iter(fun (v) -> data.add(fst(v), snd(v)), m) + json.stringify(json5=json5, compact=compact, data) +end + +# Need to handle pre-version 3.0.0 `cue_file_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) = + # 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/(?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 + + if + r.test(s) + then + v = r.exec(s) + + #print(v) + #print(v) + ( + { + version=v[0], + major=int_of_string(v.groups["major"]), + minor=int_of_string(v.groups["minor"]), + patch=int_of_string(v.groups["patch"]), + prerelease=v.groups["prerelease"], + build=v.groups["build"] + } + : + { + version: string, + major?: int, + minor?: int, + patch?: int, + prerelease?: string, + build?: string + } + ) + else + {version=s} + end +end + +# Get version of a CLI command +def cli_version(command) = + list.hd( + default="", + process.read.lines( + #timeout=2., + command ^ + " --version" + ) + ) +end + +def ffmpeg_version() = + list.hd( + default="", + process.read.lines( + "#{settings.autocue.cue_file.ffmpeg()} -version" + ) + ) +end + +def ffprobe_version() = + list.hd( + default="", + process.read.lines( + "#{settings.autocue.cue_file.ffprobe()} -version" + ) + ) +end + +# Check Autocue setup, shutdown if desired, print to terminal if desired +stdlib_shutdown = shutdown +stdlib_print = print + +def is_cue_file_available(~shutdown=false, ~print=false) = + cli_version = cli_version(settings.autocue.cue_file.path()) + ffmpeg_version = ffmpeg_version() + ffprobe_version = ffprobe_version() + + if + ffmpeg_version != "" + and + ffprobe_version != "" + and + semver(version)?.major == semver(cli_version)?.major + then + # Let user know what version (s)he is running + log( + level=2, + label="autocue.cue_file", + 'You are using autocue.cue_file version #{version}.' + ) + log( + level=2, + label="autocue.cue_file", + 'The external "#{settings.autocue.cue_file.path()}" is version #{ + cli_version + }' + ) + if + print + then + stdlib_print( + 'You are using autocue.cue_file version #{version}.' + ) + stdlib_print( + 'The external "#{settings.autocue.cue_file.path()}" is version #{ + cli_version + }' + ) + end + true + else + log( + level=1, + label="autocue.cue_file", + 'ERROR: autocue.cue_file v#{version} doesn’t match external "#{ + settings.autocue.cue_file.path() + }" v#{cli_version}!\nAutocue NOT ACTIVATED!' + ) + + # repeat on console, so standalone can see it + if + print + then + stdlib_print( + 'ERROR: autocue.cue_file v#{version} doesn’t match external "#{ + settings.autocue.cue_file.path() + }" v#{cli_version}!\nAutocue 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) = + timeout = settings.autocue.cue_file.timeout() + target = settings.autocue.cue_file.target() + silence = settings.autocue.cue_file.silence() + 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() + force_analysis = settings.autocue.cue_file.force_analysis() + nice = settings.autocue.cue_file.nice() + noclip = settings.autocue.cue_file.noclip() + use_json_metadata = settings.autocue.cue_file.use_json_metadata() + + label = "autocue.cue_file" + + # combine request & file metadata into one list, where + # request_metadata (annotations) takes precedence + metadata = + list.fold( + fun (res, entry) -> + if list.assoc.mem(fst(entry), res) then res else [...res, entry] end, + request_metadata, + file_metadata + ) + + m = ref(metadata) + + # so we can use meta["something"] + meta = m() + + if + meta["cue_file"] == "false" + then + log( + level=2, + label=label, + 'Skipping cue_file for "#{filename}" because cue_file=false forbids it.' + ) + null() + else + log( + level=3, + label=label, + 'Now autocueing: "#{filename}"' + ) + + l = list.sort.natural(stdlib_metadata.cover.remove(meta)) + log( + level=4, + label=label, + 'Metadata seen for "#{filename}":' + ) + list.iter(fun (v) -> log(level=4, label=label, "#{v}"), l) + + log( + level=4, + label=label, + 'cue_file_blankskip=#{meta["cue_file_blankskip"]}, songtype=#{ + meta["songtype"] + }, jingle_mode=#{meta["jingle_mode"]}' + ) + + # Blank skipping can be set globally using `settings.autocue.cue_file.blankskip`. + # For AzuraCast, we override that setting if we detect "jingle_mode", + # i.e. a track from a playlist that has "Hide Metadata from Listeners" set. + # For standalone Liquidsoap, the ultimate override is `cue_file_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) ? 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 := 0.0 end + end + + # Handle annotated `cue_file_blankskip`, the ultimate switch + # Pre-v3.0.0 compatibility: Check for true/false (now float) + if + list.assoc.mem("cue_file_blankskip", meta) + then + blankskip := + null.get( + default=0.0, + something_to_float( + true_value=settings.autocue.cue_file.blankskip(), + meta["cue_file_blankskip"] + ) + ) + m := list.assoc.remove("cue_file_blankskip", m()) + m := + list.add( + ("cue_file_blankskip", string.float(decimal_places=2, blankskip())), + m() + ) + end + + log( + level=3, + label=label, + "Blank (silence) skipping active: #{blankskip() > 0.0}, set to #{ + blankskip() + } s" + ) + + log( + level=3, + label=label, + "Clipping prevention active: #{noclip}" + ) + + log( + level=3, + label=label, + "Writing tags: #{write_tags}, including ReplayGain: #{write_replaygain}" + ) + + # set up CLI arguments + args = + ref( + [ + '-t', + string.float(target, decimal_places=2), + '-s', + string.float(silence, decimal_places=2), + '-o', + string.float(overlay, decimal_places=2), + '-l', + string.float(longtail, decimal_places=2), + '-x', + string.float(overlay_longtail, decimal_places=2), + '-d', + string.float(drop, decimal_places=2), + '--ffmpeg', + settings.autocue.cue_file.ffmpeg(), + '--ffprobe', + settings.autocue.cue_file.ffprobe(), + filename + ] + ) + if noclip then args := list.add('-k', args()) end + if + blankskip() > 0.0 + then + 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 + if nice then args := list.add('-n', args()) end + + tempfile = ref("") + 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, m()) + log( + level=4, + label=label, + "Writing metadata to #{tempfile()}: #{json_meta}" + ) + log( + level=3, + label=label, + "Writing metadata to #{tempfile()}" + ) + file.write(data=json_meta, append=true, tempfile()) + args := ['-j', tempfile(), ...args()] + end + + res = + try + list.hd( + default="", + process.read.lines( + timeout=timeout, + process.quote.command(settings.autocue.cue_file.path(), args=args()) + ) + ) + catch err do + log( + level=2, + label=label, + 'cue_file error: #{err}' + ) + "" + end + + if + use_json_metadata + then + # remove tempfile again + log( + level=4, + label=label, + "Removing #{tempfile()}" + ) + file.remove(tempfile()) + end + + if + res != "" + then + log( + level=3, + label=label, + 'cue_file result for "#{filename}": #{res}' + ) + + #cue_file_cross_duration, + + let json.parse ( + { + duration, + cue_file_cue_duration, + cue_file_cue_in, + cue_file_cue_out, + cue_file_cross_start_next, + cue_file_longtail, + cue_file_sustained_ending, + cue_file_loudness, + cue_file_loudness_range, + cue_file_amplify, + cue_file_amplify_adjustment, + cue_file_reference_loudness, + cue_file_blankskip, + cue_file_blank_skipped, + cue_file_true_peak, + cue_file_true_peak_db + } + : + { + duration: float, + cue_file_cue_duration: float, + cue_file_cue_in: float, + cue_file_cue_out: float, + cue_file_cross_start_next: float, + cue_file_longtail: bool, + cue_file_sustained_ending: bool, + cue_file_loudness: string, + cue_file_loudness_range: string, + cue_file_amplify: string, + cue_file_amplify_adjustment: string, + cue_file_reference_loudness: string, + cue_file_blankskip: float, + cue_file_blank_skipped: bool, + cue_file_true_peak: float, + cue_file_true_peak_db: string + } + ) = + #cue_file_cross_duration: float, + + res + + # must stringify, because metadata & annotations are strings + result = + ref( + [ + ("duration", string(duration)), + ("cue_file_cue_duration", string(cue_file_cue_duration)), + ("cue_file_cue_in", string(cue_file_cue_in)), + ("cue_file_cue_out", string(cue_file_cue_out)), + ("cue_file_cross_start_next", string(cue_file_cross_start_next)), + ("cue_file_longtail", string(cue_file_longtail)), + ("cue_file_sustained_ending", string(cue_file_sustained_ending)), + #("cue_file_cross_duration", string(cue_file_cross_duration)), + ("cue_file_loudness", cue_file_loudness), + ("cue_file_loudness_range", cue_file_loudness_range), + ("cue_file_amplify", cue_file_amplify), + ("cue_file_amplify_adjustment", cue_file_amplify_adjustment), + ("cue_file_reference_loudness", cue_file_reference_loudness), + ("cue_file_blankskip", string(cue_file_blankskip)), + ("cue_file_blank_skipped", string(cue_file_blank_skipped)), + ("cue_file_true_peak", string(cue_file_true_peak)), + ("cue_file_true_peak_db", cue_file_true_peak_db) + ] + ) + + # `cue_file` determines what happens now: + # tag absent - normal handling, existing metadata preferred + # false - we'll never arrive here (don’t process, use existing metadata) + # true - cue_file metadata preferred (for news, time, etc.) + + if + meta["cue_file"] == "" + then + # no `cue_file`, existing metadata preferred + log( + level=4, + label=label, + 'Existing metadata can override cue_file results (default; no cue_file \ + seen).' + ) + result := + list.fold( + fun (res, entry) -> + if + list.assoc.mem(fst(entry), res) + then + if + list.mem( + fst(entry), settings.autocue.cue_file.ignored_overrides() + ) + then + # take cue_file result + [...list.assoc.remove(fst(entry), res), entry] + else + # take existing metadata (meta) + res + end + else + # append new metadata (cue_file) + [...res, entry] + end, + m(), + result() + ) + elsif + meta["cue_file"] == "true" + then + # `cue_file=true`, cue_file metadata preferred + log( + level=3, + label=label, + 'cue_file results override existing metadata because cue_file=true \ + tells us to.' + ) + result := + list.fold( + fun (res, entry) -> + if + list.assoc.mem(fst(entry), res) + then + # take existing metadata (cue_file) + res + else + # append new metadata (meta) + [...res, entry] + end, + result(), + m() + ) + end + + # make a suffixed string a float + def make_float(s) = + # find first number, make float & return + r = r/[+-]?\d*\.?\d+/g.exec(s) + float_of_string(default=0.0, r[0]) + end + + # Re-calculate amplify and amplify_correction, using true_peak + def amplify_correct(target, loudness, true_peak_dB, noclip) = + # check if we need to reduce the gain for true peaks + loudness = make_float(loudness) + true_peak_dB = make_float(true_peak_dB) + amp = ref(target - loudness) + amp_correction = ref(0.0) + if + noclip + then + # difference to EBU recommended -1 dBFS + max_amp = -1.0 - true_peak_dB + if + amp() > max_amp + then + amp_correction := max_amp - amp() + amp := max_amp + end + end + (amp(), amp_correction()) + end + + # Override cue_file_amplify, cue_file_amplify_adjustment & cue_file_reference_loudness, + # using clipping prevention as requested + # cue_file_loudness & cue_file_true_peak_db are always in the cue_file result + let (amp, amp_correction) = + amplify_correct( + target, + list.assoc("cue_file_loudness", result()), + list.assoc("cue_file_true_peak_db", result()), + noclip + ) + result := list.assoc.remove("cue_file_amplify", result()) + result := + list.add( + ( + "cue_file_amplify", + string.float(decimal_places=2, amp) ^ + " dB" + ), + result() + ) + result := list.assoc.remove("cue_file_amplify_adjustment", result()) + result := + list.add( + ( + "cue_file_amplify_adjustment", + string.float(decimal_places=2, amp_correction) ^ + " dB" + ), + result() + ) + result := list.assoc.remove("cue_file_reference_loudness", result()) + result := + list.add( + ( + "cue_file_reference_loudness", + string.float(decimal_places=2, target) ^ + " LUFS" + ), + result() + ) + + if + settings.autocue.cue_file.unify_loudness_correction() + then + # We wish to avoid loudness jumps in all possible cases, + # so bring `replaygain_track_gain` and `cue_file_amplify` in line. + # NOTE: This also works for different loudness targets, if + # files have been tagged with a valid replaygain_reference_loudness. + if + list.assoc.mem("replaygain_track_gain", result()) + then + if + list.assoc.mem("replaygain_reference_loudness", result()) + then + la = + list.assoc( + default= + "0.00 dB", + "cue_file_amplify", + result() + ) + rg = + list.assoc( + default= + "0.00 dB", + "replaygain_track_gain", + result() + ) + rgf = make_float(rg) + rgr = + list.assoc( + default= + string.float(decimal_places=2, target) ^ + " dB", + "replaygain_reference_loudness", + result() + ) + rgrf = ref(make_float(rgr)) + + # Handle old RG1/mp3gain positive loudness reference + # "89 dB" (SPL) should actually be -14 LUFS, but as a reference + # it is usually set equal to the RG2 -18 LUFS reference point + if rgrf() > 0. then rgrf := rgrf() - 107. end + + # adjust replaygain_track_gain by loudness target difference, set reference + # we can safely do that since we NEVER write back replaygain_* tags + # Clipping prevention wins over simple RG adjusting + if + noclip + then + # override replaygain_track_gain with already calculated cue_file_amplify + result := list.assoc.remove("replaygain_track_gain", result()) + result := list.add(("replaygain_track_gain", la), result()) + rg = + string.float(decimal_places=2, rgf + (target - rgrf())) ^ + " dB" + log( + level=3, + label=label, + 'Clipping prevention: Adjusted calculated replaygain_track_gain \ + from #{rg} to #{la}' + ) + else + # simply calculate new RG + rg = + string.float(decimal_places=2, rgf + (target - rgrf())) ^ + " dB" + result := list.assoc.remove("replaygain_track_gain", result()) + result := list.add(("replaygain_track_gain", rg), result()) + + # Set cue_file_amplify to the same value + result := list.assoc.remove("cue_file_amplify", result()) + result := list.add(("cue_file_amplify", rg), result()) + + # And reset cue_file_amplify_adjustment + result := + list.assoc.remove("cue_file_amplify_adjustment", result()) + result := + list.add( + ( + "cue_file_amplify_adjustment", + "0.00 dB" + ), + result() + ) + log( + level=3, + label=label, + 'Replaced cue_file_amplify=#{la} with #{rg} from adjusted \ + replaygain_track_gain' + ) + end + + # set replaygain_reference_loudness to new target + rgr = + string.float(decimal_places=2, target) ^ + " LUFS" + result := + list.assoc.remove("replaygain_reference_loudness", result()) + result := list.add(("replaygain_reference_loudness", rgr), result()) + else + log( + level=3, + label=label, + "Can't override cue_file_amplify from replaygain_track_gain, \ + replaygain_reference_loudness missing." + ) + end + else + # no `replaygain_track_gain` seen? insert one, using calculated `cue_file_amplify` + rg = + list.assoc( + default= + "0.00 dB", + "cue_file_amplify", + result() + ) + result := list.add(("replaygain_track_gain", rg), result()) + + # also insert a `replaygain_reference_loudness` + rgr = + string.float(decimal_places=2, target) ^ + " LUFS" + result := list.assoc.remove("replaygain_reference_loudness", result()) + result := list.add(("replaygain_reference_loudness", rgr), result()) + log( + level=3, + label=label, + 'Inserted replaygain_track_gain #{rg} and \ + replaygain_reference_loudness #{rgr}' + ) + end + end + + # Show any clipping prevention adjustments + amp_correction_dB = + list.assoc( + default= + "0.00 dB", + "cue_file_amplify_adjustment", + result() + ) + if + noclip + and + + amp_correction_dB != + "0.00 dB" + + then + log( + level=3, + label=label, + 'Clipping prevention: Adjusted cue_file_amplify by #{ + amp_correction_dB + } because track’s true peak is #{ + list.assoc("cue_file_true_peak_db", result()) + }.' + ) + end + + # Adjust fades and cue-out, if necessary + cue_file_cue_in = float_of_string(list.assoc("cue_file_cue_in", result())) + cue_file_cue_out = + float_of_string(list.assoc("cue_file_cue_out", result())) + cue_file_cross_start_next = + float_of_string(list.assoc("cue_file_cross_start_next", result())) + cue_file_fade_in = + try + float_of_string(list.assoc("cue_file_fade_in", result())) + catch _ do + log( + level=3, + label=label, + "No fade-in duration given, using default setting (#{ + settings.autocue.cue_file.fade_in() + } s)." + ) + settings.autocue.cue_file.fade_in() + end + + cue_file_fade_out = + try + float_of_string(list.assoc("cue_file_fade_out", result())) + catch _ do + log( + level=3, + label=label, + "No fade-out duration given, using default setting (#{ + settings.autocue.cue_file.fade_out() + } s)." + ) + settings.autocue.cue_file.fade_out() + end + + # User might have set cue-out but not start_next, correct + cue_file_cross_start_next = + if + cue_file_cross_start_next <= cue_file_cue_out + then + cue_file_cross_start_next + else + start_next = cue_file_cue_out - cue_file_fade_out + if + start_next > cue_file_cue_in + then + # we have enough room for the fade-out + log( + level=3, + label=label, + "Given cue_file_cross_start_next (#{cue_file_cross_start_next} s) \ + > cue-out point (#{cue_file_cue_out} s), set to #{start_next} s." + ) + start_next + else + # not enough room for fade-out, set to cue-out + log( + level=3, + label=label, + "Given cue_file_cross_start_next (#{cue_file_cross_start_next} s) \ + > cue-out point (#{cue_file_cue_out} s), set to #{ + cue_file_cue_out + } s." + ) + cue_file_cue_out + end + end + + # Adjust cue_out according to user-supplied fade_out + let (cue_file_fade_out, cue_file_cue_out) = + if + cue_file_cross_start_next + cue_file_fade_out < cue_file_cue_out + then + cue_out = cue_file_cross_start_next + cue_file_fade_out + overlay_duration = cue_file_cue_out - cue_file_cross_start_next + log( + level=3, + label=label, + "Given fade-out (#{cue_file_fade_out} s) < overlay duration (#{ + overlay_duration + } s), moving cue-out point from #{cue_file_cue_out} s to #{ + cue_out + } s." + ) + (cue_file_fade_out, cue_out) + else + fade_out = cue_file_cue_out - cue_file_cross_start_next + log( + level=2, + label=label, + "Given fade-out duration (#{cue_file_fade_out} s) exceeds available \ + time, using #{fade_out} s." + ) + (fade_out, cue_file_cue_out) + end + + # Check for invalid fade.in + let cue_file_fade_in = + if + cue_file_fade_in < cue_file_cue_out - cue_file_cue_in + then + cue_file_fade_in + else + log( + level=2, + label=label, + "Given fade-in duration (#{cue_file_fade_in} s) exceeds available \ + time, using 0.1 s." + ) + 0.1 + end + + # correct `cue_file_cue_duration` + cue_file_cue_duration = cue_file_cue_out - cue_file_cue_in + result := list.assoc.remove("cue_file_cue_duration", result()) + result := + list.add( + ( + "cue_file_cue_duration", + string.float(decimal_places=2, cue_file_cue_duration) + ), + result() + ) + + # Update result + result := list.assoc.remove("cue_file_cue_out", result()) + result := + list.add(("cue_file_cue_out", string(cue_file_cue_out)), result()) + result := list.assoc.remove("cue_file_cross_start_next", result()) + result := + list.add( + ("cue_file_cross_start_next", string(cue_file_cross_start_next)), + result() + ) + result := list.assoc.remove("cue_file_fade_in", result()) + result := + list.add(("cue_file_fade_in", string(cue_file_fade_in)), result()) + result := list.assoc.remove("cue_file_fade_out", result()) + result := + list.add(("cue_file_fade_out", string(cue_file_fade_out)), result()) + + # now remove everything that’s not autocue-relevant + # so we don’t blow up decoder and annotation metadata + def fl(k, _) = + tags = + ["duration", "replaygain_track_gain", "replaygain_reference_loudness"] + string.contains(prefix="cue_file_", k) or list.mem(k, tags) + end + result := list.assoc.filter((fl), result()) + + l = list.sort.natural(stdlib_metadata.cover.remove(result())) + log.important( + label=label, + 'Metadata added/corrected for "#{filename}":' + ) + list.iter(fun (v) -> log.important(label=label, "#{v}"), l) + + # for optional meta elements that aren’t guaranteed to be in result, + # like replaygain_track_gain, replaygain_reference_loudness + def optional_meta(lbl, meta) = + if + list.assoc.mem(lbl, meta) + then + [(lbl, list.assoc(lbl, meta))] + else + [] + end + end + + extra_metadata = + [ + ("duration", list.assoc("duration", result())), + ("cue_file_amplify", list.assoc("cue_file_amplify", result())), + ( + "cue_file_amplify_adjustment", + list.assoc("cue_file_amplify_adjustment", result()) + ), + ( + "cue_file_cue_duration", + list.assoc("cue_file_cue_duration", result()) + ), + ("cue_file_longtail", list.assoc("cue_file_longtail", result())), + ( + "cue_file_sustained_ending", + list.assoc("cue_file_sustained_ending", result()) + ), + ("cue_file_loudness", list.assoc("cue_file_loudness", result())), + ( + "cue_file_loudness_range", + list.assoc("cue_file_loudness_range", result()) + ), + ( + "cue_file_reference_loudness", + list.assoc("cue_file_reference_loudness", result()) + ), + ("cue_file_blankskip", list.assoc("cue_file_blankskip", result())), + ( + "cue_file_blank_skipped", + list.assoc("cue_file_blank_skipped", result()) + ), + ("cue_file_true_peak", list.assoc("cue_file_true_peak", result())), + ( + "cue_file_true_peak_db", + list.assoc("cue_file_true_peak_db", result()) + ), + ...optional_meta("replaygain_track_gain", result()), + ...optional_meta("replaygain_track_peak", result()), + ...optional_meta("replaygain_track_range", result()), + ...optional_meta("replaygain_reference_loudness", result()) + ] + + { + amplify=list.assoc("cue_file_amplify", result()), + cue_in=float_of_string(list.assoc("cue_file_cue_in", result())), + cue_out=float_of_string(list.assoc("cue_file_cue_out", result())), + fade_in=float_of_string(list.assoc("cue_file_fade_in", result())), + fade_out=float_of_string(list.assoc("cue_file_fade_out", result())), + start_next= + float_of_string(list.assoc("cue_file_cross_start_next", result())), + extra_metadata=extra_metadata + } + else + log( + level=2, + label=label, + 'No autocue data found for "#{filename}"' + ) + null() + end + end +end + +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 and do the check! +# Here's a list of all possible settings with their defaults + +# settings.autocue.cue_file.path := "cue_file" +# settings.autocue.cue_file.fade_in := 0.1 # seconds +# settings.autocue.cue_file.fade_out := 2.5 # seconds +# settings.autocue.cue_file.timeout := 60.0 # seconds +# settings.autocue.cue_file.target := -18.0 # LUFS +# settings.autocue.cue_file.silence := -42.0 # LU below track loudness +# 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 := 40.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` & `cue_file_amplify` +# settings.autocue.cue_file.write_tags := false # write cue_file_* tags back to file +# settings.autocue.cue_file.write_replaygain := false # write ReplayGain tags back to file +# settings.autocue.cue_file.force_analysis := false # force re-analysis even if tags found +# 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(is_cue_file_available(shutdown=true, print=false)) + +# `enable_autocue_metadata()` will autocue ALL files Liquidsoap processes. +# You can disable it for selected sources using 'annotate:cue_file=false'. +# Remember you won't get `cue_file_amplify` data then -- expect loudness jumps! +# enable_autocue_metadata() diff --git a/src/libs/autocue.liq b/src/libs/autocue.liq index ad1856ddab..674e8a5ea7 100644 --- a/src/libs/autocue.liq +++ b/src/libs/autocue.liq @@ -1111,3 +1111,14 @@ protocol.add( "Adding automatically computed cues/crossfade metadata", syntax="autocue:uri" ) + +# Cue_file implementation + +%include "autocue.cue_file.liq" + +settings.autocue.cue_file.path := + path.concat(liquidsoap.sites.scripts, "cue_file") +on_start( + fun () -> + if is_cue_file_available() then settings.autocue.preferred := "cue_file" end +) diff --git a/src/libs/dune b/src/libs/dune index a599104f57..7e4c6d93c7 100644 --- a/src/libs/dune +++ b/src/libs/dune @@ -1,3 +1,18 @@ +(rule + (alias update_cue_file) + (target autocue.cue_file.liq.new) + (action + (run + wget + https://raw.githubusercontent.com/savonet/autocue.cue_file/refs/heads/main/autocue.cue_file.liq + -O + %{target}))) + +(rule + (alias update_cue_file) + (action + (diff autocue.cue_file.liq autocue.cue_file.liq.new))) + (install (section (site