Skills › HyperFrames › media-use › duck.mjs
duck.mjs
112 lines · skills/media-use/scripts/lib/duck.mjs at a595755
1import { wordListsFromMediaMeta } from "./words.mjs";
3// audio_meta.json word times are relative to each line's own file.
4export function speechSpans(meta, { mergeGap = 0.6, offsets, sequential = false, gap = 0 } = {}) {
5 const merge = Number(mergeGap);
6 const lists = wordListsFromMediaMeta(meta);
7 const voices = Array.isArray(meta?.voices) ? meta.voices : [];
8 if (lists.filter((words) => words.length > 0).length > 1 && !offsets && !sequential) {
9 throw new Error(
10 "audio_meta has multiple voice lines with file-relative times; pass --sequential or --offsets so spans land at composition time",
11 );
12 }
13 const intervals = [];
14 let cursor = 0;
15 for (let i = 0; i < lists.length; i++) {
16 const voice = voices[i];
17 let offset = 0;
18 if (offsets) {
19 if (lists[i].length === 0) continue;
20 const id = voice?.id ?? String(i);
21 if (!(id in offsets)) throw new Error(`--offsets is missing voice "${id}"`);
22 offset = Number(offsets[id]) || 0;
23 } else if (sequential) {
24 offset = cursor;
25 const lineDuration = Number(voice?.duration_s) || Math.max(...lists[i].map((w) => w.end), 0);
26 cursor += lineDuration + (Number(gap) || 0);
27 }
28 for (const word of lists[i]) {
29 if (word.end > word.start)
30 intervals.push({ start: word.start + offset, end: word.end + offset });
31 }
32 }
33 return mergeIntervals(intervals, Number.isFinite(merge) && merge >= 0 ? merge : 0.6);
34}
36export function duckKeyframes(
37 spans,
38 { duck = 0.25, attack = 0.15, release = 0.4, baseVolume = 1 } = {},
39) {
40 const base = finiteOr(baseVolume, 1);
41 const ducked = round3(base * finiteOr(duck, 0.25));
42 const keyframes = [];
43 for (const span of spans) {
44 keyframes.push({
45 time: round3(Math.max(0, finiteOr(span.start, 0))),
46 volume: ducked,
47 duration: round3(finiteOr(attack, 0.15)),
48 });
49 keyframes.push({
50 time: round3(Math.max(0, finiteOr(span.end, 0))),
51 volume: round3(base),
52 duration: round3(finiteOr(release, 0.4)),
53 });
54 }
55 return keyframes.sort((a, b) => a.time - b.time);
56}
58/** Volume lane for `data-automation`: composition-time keyframes as clip-local ramps. */
59export function duckLane(keyframes, { clipStart = 0, baseVolume = 1 } = {}) {
60 const start = finiteOr(clipStart, 0);
61 const points = [{ t: 0, v: round3(finiteOr(baseVolume, 1)) }];
62 const push = (t, v) => {
63 if (t > points.at(-1).t) points.push({ t: round3(t), v });
64 };
65 for (const kf of keyframes) {
66 const t = Math.max(0, kf.time);
67 push(t, points.at(-1).v);
68 push(t + kf.duration, kf.volume);
69 }
71 const next = points.findIndex((point) => point.t > start);
72 const before = points[next - 1] ?? points.at(-1);
73 const after = points[next];
74 const initial =
75 next === 0
76 ? points[0].v
77 : after
78 ? before.v + ((after.v - before.v) * (start - before.t)) / (after.t - before.t)
79 : before.v;
80 const localPoints = [
81 { t: 0, v: initial },
82 ...points
83 .filter((point) => point.t > start)
84 .map((point) => ({ t: round3(point.t - start), v: point.v })),
85 ];
86 return { version: 1, lanes: [{ target: "volume", points: localPoints }] };
87}
89function mergeIntervals(intervals, mergeGap) {
90 const sorted = intervals
91 .map((range) => ({ start: round3(range.start), end: round3(range.end) }))
92 .sort((a, b) => a.start - b.start || a.end - b.end);
93 const merged = [];
94 for (const range of sorted) {
95 const prev = merged.at(-1);
96 if (prev && (range.start <= prev.end || range.start - prev.end < mergeGap)) {
97 prev.end = Math.max(prev.end, range.end);
98 } else {
99 merged.push({ ...range });
100 }
101 }
102 return merged;
103}
105function finiteOr(value, fallback) {
106 const n = Number(value);
107 return Number.isFinite(n) ? n : fallback;
108}
110function round3(n) {
111 return Math.round(Number(n) * 1000) / 1000;
112}