track-fade.jsjavascript
/**
 * @file Fade-scheduling logic behind scheduleTrackFade(), extracted so it's testable in
 * plain Node (test/track-fade.test.js). UMD-lite, same as automix.js. Computes the Web
 * Audio gain-automation events that fade a track to silence over the video's final
 * moments.
 * @module track-fade
 */

(function (root, factory) {
  const api = factory();
  if (typeof module === 'object' && module.exports) {
    module.exports = api;
  } else {
    root.TrackFade = api;
  }
})(typeof self !== 'undefined' ? self : this, function () {

  /** Length of the end-of-video fade-out, in seconds. @type {number} */
  const TRACK_FADE_SEC = 0.5;

  /**
   * Build the ordered list of Web Audio gain-automation events for a track fade-out.
   *
   * The schedule anchors the gain at `now`: without that anchor a looping track inherits
   * the previous loop's leftover gain (0 after a completed fade) and plays silent for the
   * whole next loop. When `rampSec > 0` the anchor glides to `currentGain` via
   * setTargetAtTime instead of jumping — used when the schedule is being rebuilt because
   * the user just MOVED a volume slider, so the level change itself stays smooth (matching
   * the setTargetAtTime the slider handler would otherwise have done alone). The glide
   * only happens when there's room before the hold: at fadeStart === 0 the two events
   * would collide at the same instant and their ordering is not worth relying on, so it
   * jumps instead.
   *
   * The hold event (at now + fadeStart) must carry the CURRENT gain — a stale value there
   * is what made Auto-Mix jump the level back the moment the fade began (issue #342).
   * @param {Object} args
   * @param {number} args.currentGain Track gain to hold until the fade, linear amplitude.
   * @param {number} args.videoCurrentTime Video playback position, in seconds.
   * @param {number} args.videoDuration Total video duration, in seconds.
   * @param {number} args.now AudioContext.currentTime, in seconds.
   * @param {number} [args.fadeSec=TRACK_FADE_SEC] Fade length, in seconds.
   * @param {number} [args.rampSec=0] Time constant for gliding to `currentGain`, in
   *   seconds; 0 jumps immediately.
   * @returns {Array<{method: string, value: number, time: number, timeConstant: number}>}
   *   Gain-automation events in application order: cancel, anchor, hold, linear ramp to 0
   *   ending exactly at the video's last frame.
   */
  function computeFadeSchedule({ currentGain, videoCurrentTime, videoDuration, now, fadeSec = TRACK_FADE_SEC, rampSec = 0 }) {
    const timeLeft  = videoDuration - videoCurrentTime;
    const fadeStart = Math.max(0, timeLeft - fadeSec);

    const anchor = rampSec > 0 && fadeStart > 0
      ? { method: 'setTargetAtTime', value: currentGain, time: now, timeConstant: rampSec }
      : { method: 'setValueAtTime',  value: currentGain, time: now };

    return [
      { method: 'cancelScheduledValues', time: now },
      anchor,
      { method: 'setValueAtTime', value: currentGain, time: now + fadeStart },
      { method: 'linearRampToValueAtTime', value: 0, time: now + timeLeft },
    ];
  }

  return { TRACK_FADE_SEC, computeFadeSchedule };
});