liquidsoap icon indicating copy to clipboard operation
liquidsoap copied to clipboard

Smart Crossfade on Custom Function Still Duplicates Metadata

Open BusterNeece opened this issue 2 years ago • 2 comments

Liquidsoap 2.1.0 via Debian package on GitHub releases

Describe the bug Because we want crossfading to have custom rules when fading to the live broadcast, we can't just use the standard crossfade function that now has the code in it to avoid duplicate metadata. Therefore, it appears we're still seeing duplicate metadata in circumstances where cross.smart reverts to its default function, which happens more often in shorter tracks with jingles.

Here's our custom crossfade code:

def live_aware_crossfade(old, new) =
    if !to_live then
        # If going to the live show, play a simple sequence
        sequence([fade.out(old.source),fade.in(new.source)])
    else
        # Otherwise, use the smart transition
        cross.smart(old, new, fade_in=2.00, fade_out=2.00)
    end
end

radio = cross(minimum=0., duration=3.00, live_aware_crossfade, radio)

Because we now depend on LS's on_metadata handler for our own now-playing tracks, this means that we often see metadata sent to us in this order:

  • Track A
  • Jingle
  • Track A
  • Track B

Since it's only happening with shorter tracks (like jingles), it's pretty clear that this is caused by the smart crossfade failing over to the default sequence function, which is then prone to duplicating metadata, but for which we don't have the metadata-duplication-prevention code that was added in newer LS versions because we aren't using crossfade directly.

There is a workaround posted to this in a previous thread that involves passing cross.smart a custom function that uses add instead of sequence, but when we used that, it resulted in some unusually long crossfade times as written. It's possible that a small modification to that would work well for us, but I'd like the team's advice here before proceeding.

BusterNeece avatar Sep 10 '22 07:09 BusterNeece

Thanks for reporting.

This could be caused by some residual metadata on the leaving source in the sequence. Have you tried to drop all metadata from this source? Something like this:

def live_aware_crossfade(old, new) =
    if !to_live then
        # If going to the live show, play a simple sequence
        sequence([drop_metadata(fade.out(old.source)),fade.in(new.source)])
    else
        # Otherwise, use the smart transition
        cross.smart(old, new, fade_in=2.00, fade_out=2.00)
    end
end

radio = cross(minimum=0., duration=3.00, live_aware_crossfade, radio)

toots avatar Sep 20 '22 23:09 toots

@toots I can certainly try it, but this is applying to streams that don't have live broadcasts, so that to_live branch shouldn't ever be executing at all. It's definitely using the cross.smart transition, and I can confirm that in the specific case of using the smart crossfade, it does this, but with other crossfades and with crossfading disabled, it doesn't.

BusterNeece avatar Sep 21 '22 00:09 BusterNeece

Following up on this, we've updated to the latest stable version and it appears the issue persists exactly as described. Mitigations include disabling smart crossfade or disabling crossfade altogether.

BusterNeece avatar Oct 26 '22 11:10 BusterNeece

Thanks @BusterNeece. How long are the jingles? Are they under the crossfade duration of 3s?

toots avatar Nov 02 '22 21:11 toots

@toots Not sure that they're under the crossfade duration itself, but they're short enough that the "smart crossfade" doesn't have enough track data to analyze. That seems to be the defining characteristic of the bug.

BusterNeece avatar Nov 02 '22 21:11 BusterNeece

We are about to rewrite the whole metadata layer for the next major release so I would be enclined to too at a work around.

Are you able to add custom liquidsoap operators? You could try to drop this at the beginning of your script:

# Simple transition for crossfade
# @category Source / Track Processing
# @param ~fade_in  Fade-in duration, if any.
# @param ~fade_out Fade-out duration, if any.
# @param a Ending track
# @param b Starting track
def cross.simple(~fade_in=3.,~fade_out=3.,a,b)
  def fade.out(s) = fade.out(type="sin",duration=fade_out,s) end
  def fade.in(s)  = fade.in(type="sin",duration=fade_in,s) end
  add = fun (a,b) -> add(normalize=false,[b, a])

  add(drop_metadata(fade.out(a)),fade.in(b))
end

# Smart transition for crossfade
# @category Source / Track Processing
# @param ~log Default logger
# @param ~fade_in  Fade-in duration, if any.
# @param ~fade_out Fade-out duration, if any.
# @param ~high     Value, in dB, for loud sound level.
# @param ~medium   Value, in dB, for medium sound level.
# @param ~margin   Margin to detect sources that have too different sound level for crossing.
# @param ~default Smart crossfade: transition used when no rule applies (default: sequence).
# @param a Ending track
# @param b Starting track
def cross.smart(~log=fun(x)->log(label="cross.smart",x),
                ~fade_in=3.,~fade_out=3.,
                ~default=(fun (a,b) -> (sequence([a, b]):source)),
                ~high=-15., ~medium=-32., ~margin=4.,
                a, b)
  def fade.out(s) = fade.out(type="sin",duration=fade_out,s) end
  def fade.in(s)  = fade.in(type="sin",duration=fade_in,s) end
  add = fun (a,b) -> add(normalize=false,[b, a])

  # This is for the type system..
  ignore(a.metadata["foo"])
  ignore(b.metadata["foo"])

  if
    # If A and B are not too loud and close, fully cross-fade them.
    a.db_level <= medium and b.db_level <= medium and abs(a.db_level - b.db_level) <= margin
    then
      log("Old <= medium, new <= medium and |old-new| <= margin.")
      log("Old and new source are not too loud and close.")
      log("Transition: crossed, fade-in, fade-out.")
      add(drop_metadata(fade.out(a.source)),fade.in(b.source))

  elsif
    # If B is significantly louder than A, only fade-out A.
    # We don't want to fade almost silent things, ask for >medium.
    b.db_level >= a.db_level + margin and a.db_level >= medium and b.db_level <= high
  then
    log("new >= old + margin, old >= medium and new <= high.")
    log("New source is significantly louder than old one.")
    log("Transition: crossed, fade-out.")
    add(drop_metadata(fade.out(a.source)),b.source)

  elsif
    # Opposite as the previous one.
    a.db_level >= b.db_level + margin and b.db_level >= medium and a.db_level <= high
    then
    log("old >= new + margin, new >= medium and old <= high")
    log("Old source is significantly louder than new one.")
    log("Transition: crossed, fade-in.")
    add(drop_metadata(a.source),fade.in(b.source))

  elsif
    # Do not fade if it's already very low.
    b.db_level >= a.db_level + margin and a.db_level <= medium and b.db_level <= high
  then
    log("new >= old + margin, old <= medium and new <= high.")
    log("Do not fade if it's already very low.")
    log("Transition: crossed, no fade.")
    add(drop_metadata(a.source),b.source)

  # What to do with a loud end and a quiet beginning ?
  # A good idea is to use a jingle to separate the two tracks,
  # but that's another story.

  else
    # Otherwise, A and B are just too loud to overlap nicely, or the
    # difference between them is too large and overlapping would completely
    # mask one of them.
    log("No transition: using default.")
    default(drop_metadata(a.source), b.source)
  end
end

# Crossfade between tracks, taking the respective volume levels into account in
# the choice of the transition.
# @category Source / Track Processing
# @param ~id           Force the value of the source ID.
# @param ~duration     Duration (in seconds) of buffered data from each track \
#                      that is used to compute the transition between tracks.
# @param ~override_duration \
#                      Metadata field which, if present and containing a \
#                      float, overrides the 'duration' parameter for current \
#                      track.
# @param ~fade_in      Fade-in duration, if any.
# @param ~fade_out     Fade-out duration, if any.
# @param ~width        Width of the volume analysis window.
# @param ~conservative Always prepare for a premature end-of-track.
# @param ~minimum      Minimum duration (in sec.) for a cross: \
#                      If the track ends without any warning (e.g. in case of skip) \
#                      there may not be enough data for a decent composition. \
#                      Set to 0. to avoid having transitions after skips, \
#                      or more to avoid transitions on short tracks. \
#                      With a negative default, transitions always occur.
# @param ~default      Smart crossfade: transition used when no rule applies \
#                      (default: sequence).
# @param ~smart        Enable smart crossfading
# @param ~high         Smart crossfade: value, in dB, for loud sound level.
# @param ~medium       Smart crossfade: value, in dB, for medium sound level.
# @param ~margin       Smart crossfade: margin to detect sources that have too different \
#                      sound level for crossing.
# @param ~deduplicate  Crossfade transitions can generate duplicate metadata. When `true`, the operator \
#                      removes duplicate metadata from the returned source.
# @param s             The input source.
def crossfade(~id=null(), ~duration=5.,~override_duration="liq_cross_duration",
              ~fade_in=3.,~fade_out=3.,~smart=false,
              ~default=(fun (a,b) -> (sequence([a, b]):source)),
              ~high=-15., ~medium=-32., ~margin=4., ~deduplicate=true,
              ~minimum=(-1.),~width=2.,~conservative=true,s)
  id = string.id.default(default="crossfade", id)
  def log(~level=3,x) = log(label=id,level=level,x) end

  def simple_transition(a,b)
    list.iter(fun(x)-> log(level=4,"Before: #{x}"), metadata.cover.remove(a.metadata))
    list.iter(fun(x)-> log(level=4,"After : #{x}"), metadata.cover.remove(b.metadata))

    log("Simple transition: crossed, fade-in, fade-out.")
    cross.simple(fade_in=fade_in, fade_out=fade_out, a.source, b.source)
  end

  def smart_transition(a,b)
    list.iter(fun(x)-> log(level=4,"Before: #{x}"), metadata.cover.remove(a.metadata))
    list.iter(fun(x)-> log(level=4,"After : #{x}"), metadata.cover.remove(b.metadata))

    cross.smart(log=log, fade_in=fade_in, fade_out=fade_out, default=default,
                high=high, medium=medium, margin=margin, a, b)
  end

  transition =
    if smart then
      smart_transition
    else
      simple_transition
    end

  let (cross_id, deduplicate_id) = deduplicate ? (null(), null(id)) : (null(id), null())

  s = cross(id=cross_id, width=width, duration=duration, override_duration=override_duration,
            conservative=conservative, minimum=minimum, transition, s)

  if deduplicate then
    metadata.deduplicate(id=deduplicate_id, s)
  else
    s
  end
end

toots avatar Nov 03 '22 22:11 toots

Hi @BusterNeece. I was debugging the upcoming multi tracks branch and found more possible cause for this issue. You might want to checkout the stable build coming up here: https://github.com/savonet/liquidsoap/actions/runs/3684757153

toots avatar Dec 13 '22 11:12 toots

Hmm this one in fact: https://github.com/savonet/liquidsoap/actions/runs/3684757153 or whatever ends up passing the rolling-release-v2.1.x build 😅

toots avatar Dec 13 '22 11:12 toots

@toots I've updated our Rolling Release branch to use your Rolling Release branch, we'll see if that helps with things. Thank you!

BusterNeece avatar Dec 15 '22 03:12 BusterNeece