From 905c61f47ad7943dea2cabd9432e16fd450a3410 Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Sat, 9 Nov 2024 17:44:34 -0600 Subject: [PATCH 1/7] Integrate cue_file. --- dune-project | 2 +- scripts/cue_file | 1276 +++++++++++++++++++++++++++++++++ scripts/dune | 18 + src/lang/builtins_lang.ml | 19 + src/libs/autocue.cue_file.liq | 1178 ++++++++++++++++++++++++++++++ src/libs/autocue.liq | 11 + src/libs/dune | 11 + 7 files changed, 2514 insertions(+), 1 deletion(-) create mode 100644 scripts/cue_file create mode 100644 src/libs/autocue.cue_file.liq 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 100644 index 0000000000..a096c92ef3 --- /dev/null +++ b/scripts/cue_file @@ -0,0 +1,1276 @@ +#!/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, + 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, + 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'), +) + +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, tags_json, args.target, args.blankskip, args.noclip) + +if args.force or not skip_analysis: + result = analyse( + filename=args.file, + 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..d1566f53ea 100644 --- a/scripts/dune +++ b/scripts/dune @@ -28,3 +28,21 @@ (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 gen_cue_file) + (target cue_file.new) + (action + (run wget https://raw.githubusercontent.com/savonet/autocue.cue_file/refs/heads/main/cue_file -O %{target}))) + +(rule + (alias gen_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..6ff08951e7 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) + | _ -> assert false) + [ + ("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..e457747f0e --- /dev/null +++ b/src/libs/autocue.cue_file.liq @@ -0,0 +1,1178 @@ +# 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.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 + +# 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()) + + if + 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), + 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) 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..9eb25e6d77 100644 --- a/src/libs/dune +++ b/src/libs/dune @@ -1,3 +1,14 @@ +(rule + (alias gen_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 gen_cue_file) + (action + (diff autocue.cue_file.liq autocue.cue_file.liq.new))) + (install (section (site From 4634235b47a79259e3acea0284d8233b7f3582bb Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Sat, 9 Nov 2024 18:14:36 -0600 Subject: [PATCH 2/7] Sync. --- scripts/cue_file | 0 src/libs/autocue.cue_file.liq | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) mode change 100644 => 100755 scripts/cue_file diff --git a/scripts/cue_file b/scripts/cue_file old mode 100644 new mode 100755 diff --git a/src/libs/autocue.cue_file.liq b/src/libs/autocue.cue_file.liq index e457747f0e..04f34f29e3 100644 --- a/src/libs/autocue.cue_file.liq +++ b/src/libs/autocue.cue_file.liq @@ -290,14 +290,26 @@ def cli_version(command) = ) end +def ffmpeg_version() = + list.hd( + default="", + process.read.lines( + "ffmpeg -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() if + ffmpeg_version != "" + and semver(version)?.major == semver(cli_version)?.major then # Let user know what version (s)he is running @@ -1176,3 +1188,40 @@ def cue_file(~request_metadata, ~file_metadata, filename) = 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() + +() From c81318443fe937bf56c26b22b58b9d71f168a237 Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Sun, 10 Nov 2024 10:56:15 -0600 Subject: [PATCH 3/7] Update. --- .pre-commit-config.yaml | 5 +- scripts/cue_file | 2 +- scripts/dune | 4 +- src/libs/autocue.cue_file.liq | 672 +++++++++++----------------------- src/libs/dune | 4 +- 5 files changed, 221 insertions(+), 466 deletions(-) mode change 100755 => 100644 scripts/cue_file 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/scripts/cue_file b/scripts/cue_file old mode 100755 new mode 100644 index a096c92ef3..57d6206c24 --- a/scripts/cue_file +++ b/scripts/cue_file @@ -38,7 +38,7 @@ # - 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.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! diff --git a/scripts/dune b/scripts/dune index d1566f53ea..5c315c2467 100644 --- a/scripts/dune +++ b/scripts/dune @@ -30,13 +30,13 @@ (liquidsoap-completions.el as emacs/site-lisp/liquidsoap-completions.el))) (rule - (alias gen_cue_file) + (alias update_cue_file) (target cue_file.new) (action (run wget https://raw.githubusercontent.com/savonet/autocue.cue_file/refs/heads/main/cue_file -O %{target}))) (rule - (alias gen_cue_file) + (alias update_cue_file) (action (diff cue_file cue_file.new))) diff --git a/src/libs/autocue.cue_file.liq b/src/libs/autocue.cue_file.liq index 04f34f29e3..5925e37a4b 100644 --- a/src/libs/autocue.cue_file.liq +++ b/src/libs/autocue.cue_file.liq @@ -129,44 +129,45 @@ let settings.autocue.cue_file.overlay_longtail = 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.", + "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.", + "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.", + "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.', + '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.", + "Write back `cue_file_*` tags to original audio file. Ensure you have enough \ + free space to hold a copy of the original file.", false ) @@ -181,8 +182,7 @@ let settings.autocue.cue_file.write_replaygain = let settings.autocue.cue_file.force_analysis = settings.make( description= - 'Force re-analysis even when all needed data could be read from file \ - tags.', + 'Force re-analysis even when all needed data could be read from file tags.', false ) @@ -205,16 +205,20 @@ let settings.autocue.cue_file.use_json_metadata = 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.', + '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) = +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) @@ -244,14 +248,10 @@ end 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 + 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 + if r.test(s) then v = r.exec(s) - #print(v) #print(v) ( @@ -290,94 +290,62 @@ def cli_version(command) = ) end -def ffmpeg_version() = - list.hd( - default="", - process.read.lines( - "ffmpeg -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() if - ffmpeg_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", + '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 - }' + log(level=2, label="autocue.cue_file", + 'The external "#{settings.autocue.cue_file.path()}" \ + is version #{cli_version}' ) - if - print - then + if print then stdlib_print( - 'You are using autocue.cue_file version #{version}.' + 'You are using autocue.cue_file version \ + #{version}.' ) stdlib_print( - 'The external "#{settings.autocue.cue_file.path()}" is version #{ - cli_version - }' + '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!' + 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}!\n\ + Autocue NOT ACTIVATED!' ) - # repeat on console, so standalone can see it - if - print - then + 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!' + 'ERROR: autocue.cue_file v#{version} \ + doesn’t match external "#{settings.autocue.cue_file.path()}" \ + v#{cli_version}!\n\ + Autocue NOT ACTIVATED!' ) end - if - shutdown - then - log( - level=1, - label="autocue.cue_file", - "Shutting down..." - ) - if - print - then - stdlib_print( - "Shutting down..." - ) - end + 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) = @@ -400,13 +368,16 @@ def cue_file(~request_metadata, ~file_metadata, filename) = # 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, + 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) @@ -419,7 +390,8 @@ def cue_file(~request_metadata, ~file_metadata, filename) = log( level=2, label=label, - 'Skipping cue_file for "#{filename}" because cue_file=false forbids it.' + 'Skipping cue_file for "#{filename}" because cue_file=false \ + forbids it.' ) null() else @@ -440,9 +412,8 @@ def cue_file(~request_metadata, ~file_metadata, filename) = log( level=4, label=label, - 'cue_file_blankskip=#{meta["cue_file_blankskip"]}, songtype=#{ - meta["songtype"] - }, jingle_mode=#{meta["jingle_mode"]}' + '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`. @@ -454,39 +425,33 @@ def cue_file(~request_metadata, ~file_metadata, filename) = 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 + 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"] - ) + 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() - ) + 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" + "Blank (silence) skipping active: #{blankskip() > 0.0}, set to #{blankskip()} s" ) log( @@ -521,9 +486,7 @@ def cue_file(~request_metadata, ~file_metadata, filename) = ] ) if noclip then args := list.add('-k', args()) end - if - blankskip() > 0.0 - then + 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 @@ -532,23 +495,17 @@ def cue_file(~request_metadata, ~file_metadata, filename) = if nice then args := list.add('-n', args()) end tempfile = ref("") - if - use_json_metadata - then + 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=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() ) - log( - level=3, - label=label, - "Writing metadata to #{tempfile()}" - ) - file.write(data=json_meta, append=true, tempfile()) args := ['-j', tempfile(), ...args()] end @@ -570,15 +527,9 @@ def cue_file(~request_metadata, ~file_metadata, filename) = "" end - if - use_json_metadata - then + if use_json_metadata then # remove tempfile again - log( - level=4, - label=label, - "Removing #{tempfile()}" - ) + log(level=4, label=label, "Removing #{tempfile()}") file.remove(tempfile()) end @@ -591,8 +542,6 @@ def cue_file(~request_metadata, ~file_metadata, filename) = 'cue_file result for "#{filename}": #{res}' ) - #cue_file_cross_duration, - let json.parse ( { duration, @@ -602,6 +551,7 @@ def cue_file(~request_metadata, ~file_metadata, filename) = cue_file_cross_start_next, cue_file_longtail, cue_file_sustained_ending, + #cue_file_cross_duration, cue_file_loudness, cue_file_loudness_range, cue_file_amplify, @@ -621,6 +571,7 @@ def cue_file(~request_metadata, ~file_metadata, filename) = cue_file_cross_start_next: float, cue_file_longtail: bool, cue_file_sustained_ending: bool, + #cue_file_cross_duration: float, cue_file_loudness: string, cue_file_loudness_range: string, cue_file_amplify: string, @@ -631,99 +582,76 @@ def cue_file(~request_metadata, ~file_metadata, filename) = cue_file_true_peak: float, cue_file_true_peak_db: string } - ) = - #cue_file_cross_duration: float, - - res + ) = 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) - ] - ) + 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 + 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).' + '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 + 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 + [...list.assoc.remove(fst(entry), res), entry] # take cue_file result else - # append new metadata (cue_file) - [...res, entry] - end, - m(), - result() - ) - elsif - meta["cue_file"] == "true" - then + res # take existing metadata (meta) + end + else + [...res, entry] # append new metadata (cue_file) + 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.' + '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 + res # take existing metadata (cue_file) + else + [...res, entry] # append new metadata (meta) + end, + result(), + m() ) - 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 @@ -740,14 +668,9 @@ def cue_file(~request_metadata, ~file_metadata, filename) = 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 + if noclip then + max_amp = -1.0 - true_peak_dB # difference to EBU recommended -1 dBFS + if amp() > max_amp then amp_correction := max_amp - amp() amp := max_amp end @@ -766,202 +689,84 @@ def cue_file(~request_metadata, ~file_metadata, filename) = 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.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.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() - ) + result := list.add(("cue_file_reference_loudness", string.float(decimal_places=2, target) ^ " LUFS"), result()) - if - settings.autocue.cue_file.unify_loudness_correction() - then + 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() - ) + 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() - ) + 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 + 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}' - ) + 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" + 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' - ) + 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()) + 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." - ) + 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() - ) + 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" + 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}' - ) + 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 + 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()) - }.' + '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_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())) @@ -969,9 +774,8 @@ def cue_file(~request_metadata, ~file_metadata, filename) = log( level=3, label=label, - "No fade-in duration given, using default setting (#{ - settings.autocue.cue_file.fade_in() - } s)." + "No fade-in duration given, using default setting \ + (#{settings.autocue.cue_file.fade_in()} s)." ) settings.autocue.cue_file.fade_in() end @@ -983,30 +787,27 @@ def cue_file(~request_metadata, ~file_metadata, filename) = log( level=3, label=label, - "No fade-out duration given, using default setting (#{ - settings.autocue.cue_file.fade_out() - } s)." + "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 + 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 + 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." + "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 @@ -1014,10 +815,8 @@ def cue_file(~request_metadata, ~file_metadata, filename) = 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." + "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 @@ -1033,11 +832,9 @@ def cue_file(~request_metadata, ~file_metadata, filename) = 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." + "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 @@ -1045,8 +842,8 @@ def cue_file(~request_metadata, ~file_metadata, filename) = log( level=2, label=label, - "Given fade-out duration (#{cue_file_fade_out} s) exceeds available \ - time, using #{fade_out} s." + "Given fade-out duration (#{cue_file_fade_out} s) exceeds \ + available time, using #{fade_out} s." ) (fade_out, cue_file_cue_out) end @@ -1061,8 +858,8 @@ def cue_file(~request_metadata, ~file_metadata, filename) = log( level=2, label=label, - "Given fade-in duration (#{cue_file_fade_in} s) exceeds available \ - time, using 0.1 s." + "Given fade-in duration (#{cue_file_fade_in} s) exceeds \ + available time, using 0.1 s." ) 0.1 end @@ -1070,54 +867,35 @@ def cue_file(~request_metadata, ~file_metadata, filename) = # 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() - ) + 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.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.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.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()) + 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"] + 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) + 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 + if list.assoc.mem(lbl, meta) then [(lbl, list.assoc(lbl, meta))] else [] @@ -1128,38 +906,17 @@ def cue_file(~request_metadata, ~file_metadata, filename) = [ ("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_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_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_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_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()) - ), + ("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()), @@ -1167,13 +924,12 @@ def cue_file(~request_metadata, ~file_metadata, filename) = ] { - 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())), + 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 @@ -1223,5 +979,3 @@ autocue.register(name="cue_file", cue_file) # 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/dune b/src/libs/dune index 9eb25e6d77..47047222f8 100644 --- a/src/libs/dune +++ b/src/libs/dune @@ -1,11 +1,11 @@ (rule - (alias gen_cue_file) + (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 gen_cue_file) + (alias update_cue_file) (action (diff autocue.cue_file.liq autocue.cue_file.liq.new))) From c6822b9b32fba25480d7d52c9d85a0bd1f19c64e Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Sun, 10 Nov 2024 11:09:56 -0600 Subject: [PATCH 4/7] Update --- scripts/cue_file | 21 +- src/libs/autocue.cue_file.liq | 700 +++++++++++++++++++++++----------- 2 files changed, 503 insertions(+), 218 deletions(-) diff --git a/scripts/cue_file b/scripts/cue_file index 57d6206c24..cc8600be6e 100644 --- a/scripts/cue_file +++ b/scripts/cue_file @@ -38,7 +38,7 @@ # - 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 numver with autocue.cue_file +# 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! @@ -318,6 +318,7 @@ def override_from_JSON(tags, tags_json={}): def read_tags( filename, + ffprobe=FFPROBE, tags_json={}, target=TARGET_LUFS, blankskip=0.0, @@ -329,7 +330,7 @@ def read_tags( # filename r = subprocess.run( [ - FFPROBE, + ffprobe, "-v", "quiet", "-show_entries", @@ -507,6 +508,7 @@ def add_missing(tags_found, target=TARGET_LUFS, blankskip=0.0, noclip=False): def analyse( filename, + ffmpeg=FFMPEG, target=TARGET_LUFS, overlay=OVERLAY_LU, silence=SILENCE, @@ -530,7 +532,7 @@ def analyse( # lavfi.r128.true_peaks_ch1=1.632 args = [ - FFMPEG, + ffmpeg, "-v", "quiet", "-nostdin", @@ -990,7 +992,7 @@ def write_tags(filename, tags={}, replaygain=False): # eprint(metadata_args) args = [ - FFMPEG, + ffmpeg, '-v', 'quiet', '-nostdin', '-y', @@ -1206,6 +1208,14 @@ parser.add_argument( "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() @@ -1220,11 +1230,12 @@ if args.json: args.json.close() skip_analysis, tags_found = read_tags( - args.file, tags_json, args.target, args.blankskip, args.noclip) + 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, diff --git a/src/libs/autocue.cue_file.liq b/src/libs/autocue.cue_file.liq index 5925e37a4b..8717f9296e 100644 --- a/src/libs/autocue.cue_file.liq +++ b/src/libs/autocue.cue_file.liq @@ -68,6 +68,20 @@ let settings.autocue.cue_file.path = "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= @@ -129,45 +143,44 @@ let settings.autocue.cue_file.overlay_longtail = 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.", + "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.", + "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.", + "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.', + '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.", + "Write back `cue_file_*` tags to original audio file. Ensure you have \ + enough free space to hold a copy of the original file.", false ) @@ -182,7 +195,8 @@ let settings.autocue.cue_file.write_replaygain = let settings.autocue.cue_file.force_analysis = settings.make( description= - 'Force re-analysis even when all needed data could be read from file tags.', + 'Force re-analysis even when all needed data could be read from file \ + tags.', false ) @@ -205,20 +219,16 @@ let settings.autocue.cue_file.use_json_metadata = 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.', + '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 -) = +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) @@ -248,10 +258,14 @@ end 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 + 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 + if + r.test(s) + then v = r.exec(s) + #print(v) #print(v) ( @@ -290,62 +304,106 @@ def cli_version(command) = ) 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 + ffprove_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", + '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}' + log( + level=2, + label="autocue.cue_file", + 'The external "#{settings.autocue.cue_file.path()}" is version #{ + cli_version + }' ) - if print then + if + print + then stdlib_print( - 'You are using autocue.cue_file version \ - #{version}.' + 'You are using autocue.cue_file version #{version}.' ) stdlib_print( - 'The external "#{settings.autocue.cue_file.path()}" \ - is version #{cli_version}' + '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}!\n\ - Autocue NOT ACTIVATED!' + 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 + if + print + then stdlib_print( - 'ERROR: autocue.cue_file v#{version} \ - doesn’t match external "#{settings.autocue.cue_file.path()}" \ - v#{cli_version}!\n\ - Autocue NOT ACTIVATED!' + '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 + 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) = @@ -368,16 +426,13 @@ def cue_file(~request_metadata, ~file_metadata, filename) = # 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, + 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) @@ -390,8 +445,7 @@ def cue_file(~request_metadata, ~file_metadata, filename) = log( level=2, label=label, - 'Skipping cue_file for "#{filename}" because cue_file=false \ - forbids it.' + 'Skipping cue_file for "#{filename}" because cue_file=false forbids it.' ) null() else @@ -412,8 +466,9 @@ def cue_file(~request_metadata, ~file_metadata, filename) = log( level=4, label=label, - 'cue_file_blankskip=#{meta["cue_file_blankskip"]}, songtype=#{meta["songtype"]}, \ - jingle_mode=#{meta["jingle_mode"]}' + '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`. @@ -425,33 +480,39 @@ def cue_file(~request_metadata, ~file_metadata, filename) = 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 + 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"] + 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() - ) + 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" + "Blank (silence) skipping active: #{blankskip() > 0.0}, set to #{ + blankskip() + } s" ) log( @@ -482,11 +543,17 @@ def cue_file(~request_metadata, ~file_metadata, filename) = 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 + 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 @@ -495,17 +562,23 @@ def cue_file(~request_metadata, ~file_metadata, filename) = if nice then args := list.add('-n', args()) end tempfile = ref("") - if use_json_metadata then + 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() + 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 @@ -527,9 +600,15 @@ def cue_file(~request_metadata, ~file_metadata, filename) = "" end - if use_json_metadata then + if + use_json_metadata + then # remove tempfile again - log(level=4, label=label, "Removing #{tempfile()}") + log( + level=4, + label=label, + "Removing #{tempfile()}" + ) file.remove(tempfile()) end @@ -542,6 +621,8 @@ def cue_file(~request_metadata, ~file_metadata, filename) = 'cue_file result for "#{filename}": #{res}' ) + #cue_file_cross_duration, + let json.parse ( { duration, @@ -551,7 +632,6 @@ def cue_file(~request_metadata, ~file_metadata, filename) = cue_file_cross_start_next, cue_file_longtail, cue_file_sustained_ending, - #cue_file_cross_duration, cue_file_loudness, cue_file_loudness_range, cue_file_amplify, @@ -571,7 +651,6 @@ def cue_file(~request_metadata, ~file_metadata, filename) = cue_file_cross_start_next: float, cue_file_longtail: bool, cue_file_sustained_ending: bool, - #cue_file_cross_duration: float, cue_file_loudness: string, cue_file_loudness_range: string, cue_file_amplify: string, @@ -582,76 +661,99 @@ def cue_file(~request_metadata, ~file_metadata, filename) = cue_file_true_peak: float, cue_file_true_peak_db: string } - ) = res + ) = + #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) - ] - ) + 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 + 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).' + '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 - [...list.assoc.remove(fst(entry), res), entry] # take cue_file result + 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 - res # take existing metadata (meta) - end - else - [...res, entry] # append new metadata (cue_file) - end, - m(), - result() - ) - elsif meta["cue_file"] == "true" then + # 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 - res # take existing metadata (cue_file) - else - [...res, entry] # append new metadata (meta) - end, - result(), - m() + '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 @@ -668,9 +770,14 @@ def cue_file(~request_metadata, ~file_metadata, filename) = true_peak_dB = make_float(true_peak_dB) amp = ref(target - loudness) amp_correction = ref(0.0) - if noclip then - max_amp = -1.0 - true_peak_dB # difference to EBU recommended -1 dBFS - if amp() > max_amp then + 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 @@ -689,84 +796,202 @@ def cue_file(~request_metadata, ~file_metadata, filename) = 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.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.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()) + result := + list.add( + ( + "cue_file_reference_loudness", + string.float(decimal_places=2, target) ^ + " LUFS" + ), + result() + ) - if settings.autocue.cue_file.unify_loudness_correction() then + 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()) + 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()) + 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 + 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}') + 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" + 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') + 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()) + 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.") + 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()) + 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" + 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}') + 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 + 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())}.' + '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_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())) @@ -774,8 +999,9 @@ def cue_file(~request_metadata, ~file_metadata, filename) = log( level=3, label=label, - "No fade-in duration given, using default setting \ - (#{settings.autocue.cue_file.fade_in()} s)." + "No fade-in duration given, using default setting (#{ + settings.autocue.cue_file.fade_in() + } s)." ) settings.autocue.cue_file.fade_in() end @@ -787,27 +1013,30 @@ def cue_file(~request_metadata, ~file_metadata, filename) = log( level=3, label=label, - "No fade-out duration given, using default setting \ - (#{settings.autocue.cue_file.fade_out()} s)." + "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 + 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 + 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." + "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 @@ -815,8 +1044,10 @@ def cue_file(~request_metadata, ~file_metadata, filename) = 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." + "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 @@ -832,9 +1063,11 @@ def cue_file(~request_metadata, ~file_metadata, filename) = 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." + "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 @@ -842,8 +1075,8 @@ def cue_file(~request_metadata, ~file_metadata, filename) = log( level=2, label=label, - "Given fade-out duration (#{cue_file_fade_out} s) exceeds \ - available time, using #{fade_out} s." + "Given fade-out duration (#{cue_file_fade_out} s) exceeds available \ + time, using #{fade_out} s." ) (fade_out, cue_file_cue_out) end @@ -858,8 +1091,8 @@ def cue_file(~request_metadata, ~file_metadata, filename) = log( level=2, label=label, - "Given fade-in duration (#{cue_file_fade_in} s) exceeds \ - available time, using 0.1 s." + "Given fade-in duration (#{cue_file_fade_in} s) exceeds available \ + time, using 0.1 s." ) 0.1 end @@ -867,35 +1100,54 @@ def cue_file(~request_metadata, ~file_metadata, filename) = # 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()) + 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.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.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.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()) + 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"] + 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) + 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 + if + list.assoc.mem(lbl, meta) + then [(lbl, list.assoc(lbl, meta))] else [] @@ -906,17 +1158,38 @@ def cue_file(~request_metadata, ~file_metadata, filename) = [ ("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_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_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_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_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())), + ( + "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()), @@ -924,12 +1197,13 @@ def cue_file(~request_metadata, ~file_metadata, filename) = ] { - 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())), + 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 From 4c32c8f973c2532f8bfd8f53aa726aa3d8000ebf Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Sun, 10 Nov 2024 11:36:24 -0600 Subject: [PATCH 5/7] Update. --- scripts/cue_file | 0 scripts/dune | 4 +++- src/libs/autocue.cue_file.liq | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) mode change 100644 => 100755 scripts/cue_file diff --git a/scripts/cue_file b/scripts/cue_file old mode 100644 new mode 100755 diff --git a/scripts/dune b/scripts/dune index 5c315c2467..f26e0e3f62 100644 --- a/scripts/dune +++ b/scripts/dune @@ -33,7 +33,9 @@ (alias update_cue_file) (target cue_file.new) (action - (run wget https://raw.githubusercontent.com/savonet/autocue.cue_file/refs/heads/main/cue_file -O %{target}))) + (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) diff --git a/src/libs/autocue.cue_file.liq b/src/libs/autocue.cue_file.liq index 8717f9296e..d2b49cee98 100644 --- a/src/libs/autocue.cue_file.liq +++ b/src/libs/autocue.cue_file.liq @@ -334,7 +334,7 @@ def is_cue_file_available(~shutdown=false, ~print=false) = if ffmpeg_version != "" and - ffprove_version != "" + ffprobe_version != "" and semver(version)?.major == semver(cli_version)?.major then @@ -543,9 +543,9 @@ def cue_file(~request_metadata, ~file_metadata, filename) = string.float(overlay_longtail, decimal_places=2), '-d', string.float(drop, decimal_places=2), - '-ffmpeg', + '--ffmpeg', settings.autocue.cue_file.ffmpeg(), - '-ffprobe', + '--ffprobe', settings.autocue.cue_file.ffprobe(), filename ] From c885e1078ae347e8702474e651463ad3dc5f6034 Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Sun, 10 Nov 2024 12:37:34 -0600 Subject: [PATCH 6/7] Format. --- scripts/dune | 16 ++++++++++------ src/libs/dune | 12 ++++++++---- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/scripts/dune b/scripts/dune index f26e0e3f62..90bb92121a 100644 --- a/scripts/dune +++ b/scripts/dune @@ -30,12 +30,16 @@ (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})))) + (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) diff --git a/src/libs/dune b/src/libs/dune index 47047222f8..7e4c6d93c7 100644 --- a/src/libs/dune +++ b/src/libs/dune @@ -1,8 +1,12 @@ (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}))) + (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) From aab0fd84681d1f6f76738971d3f2274e7005b172 Mon Sep 17 00:00:00 2001 From: Romain Beauxis Date: Sun, 10 Nov 2024 12:38:26 -0600 Subject: [PATCH 7/7] Don't fail here. --- src/lang/builtins_lang.ml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/builtins_lang.ml b/src/lang/builtins_lang.ml index 6ff08951e7..b2920da85e 100644 --- a/src/lang/builtins_lang.ml +++ b/src/lang/builtins_lang.ml @@ -287,7 +287,7 @@ let _ = (Lang.add_builtin_base ~category:`Configuration ~descr:("Path to configured location site " ^ name) ~base:sites name (`String path) Lang.string_t) - | _ -> assert false) + | _ -> ()) [ ("bin", Sites.Sites.bin); ("cache", Sites.Sites.cache);