← Files JuicyLucy AdsARCHIVED FILE

skills/media-use/scripts/lib/duck.mjs

3.19 KB · Oct 3, 2026 · 06:35 UTC

↓ Download file

import { wordListsFromMediaMeta } from "./words.mjs";

// audio_meta.json word times are relative to each line's own file.
export function speechSpans(meta, { mergeGap = 0.6, offsets, sequential = false, gap = 0 } = {}) {
  const merge = Number(mergeGap);
  const lists = wordListsFromMediaMeta(meta);
  const voices = Array.isArray(meta?.voices) ? meta.voices : [];
  if (lists.length > 1 && !offsets && !sequential) {
    throw new Error(
      "audio_meta has multiple voice lines with file-relative times; pass --sequential or --offsets so spans land at composition time",
    );
  }
  const intervals = [];
  let cursor = 0;
  for (let i = 0; i < lists.length; i++) {
    const voice = voices[i];
    let offset = 0;
    if (offsets) {
      const id = voice?.id ?? String(i);
      if (!(id in offsets)) throw new Error(`--offsets is missing voice "${id}"`);
      offset = Number(offsets[id]) || 0;
    } else if (sequential) {
      offset = cursor;
      const lineDuration = Number(voice?.duration_s) || Math.max(...lists[i].map((w) => w.end), 0);
      cursor += lineDuration + (Number(gap) || 0);
    }
    for (const word of lists[i]) {
      if (word.end > word.start)
        intervals.push({ start: word.start + offset, end: word.end + offset });
    }
  }
  return mergeIntervals(intervals, Number.isFinite(merge) && merge >= 0 ? merge : 0.6);
}

export function duckKeyframes(
  spans,
  { duck = 0.25, attack = 0.15, release = 0.4, baseVolume = 1 } = {},
) {
  const base = finiteOr(baseVolume, 1);
  const ducked = round3(base * finiteOr(duck, 0.25));
  const keyframes = [];
  for (const span of spans) {
    keyframes.push({
      time: round3(Math.max(0, finiteOr(span.start, 0))),
      volume: ducked,
      duration: round3(finiteOr(attack, 0.15)),
    });
    keyframes.push({
      time: round3(Math.max(0, finiteOr(span.end, 0))),
      volume: round3(base),
      duration: round3(finiteOr(release, 0.4)),
    });
  }
  return keyframes.sort((a, b) => a.time - b.time);
}

/** Volume lane for `data-automation`: composition-time keyframes as clip-local ramps. */
export function duckLane(keyframes, { clipStart = 0, baseVolume = 1 } = {}) {
  const start = finiteOr(clipStart, 0);
  const points = [{ t: 0, v: round3(finiteOr(baseVolume, 1)) }];
  const push = (t, v) => {
    if (t > points.at(-1).t) points.push({ t: round3(t), v });
  };
  for (const kf of keyframes) {
    const t = Math.max(0, kf.time - start);
    push(t, points.at(-1).v);
    push(t + kf.duration, kf.volume);
  }
  return { version: 1, lanes: [{ target: "volume", points }] };
}

function mergeIntervals(intervals, mergeGap) {
  const sorted = intervals
    .map((range) => ({ start: round3(range.start), end: round3(range.end) }))
    .sort((a, b) => a.start - b.start || a.end - b.end);
  const merged = [];
  for (const range of sorted) {
    const prev = merged.at(-1);
    if (prev && (range.start <= prev.end || range.start - prev.end < mergeGap)) {
      prev.end = Math.max(prev.end, range.end);
    } else {
      merged.push({ ...range });
    }
  }
  return merged;
}

function finiteOr(value, fallback) {
  const n = Number(value);
  return Number.isFinite(n) ? n : fallback;
}

function round3(n) {
  return Math.round(Number(n) * 1000) / 1000;
}

SHA-256: 287f14838458677246cb18ef0355a9e63fc3d8691764c84a769e3c4077ad94b9