← Files JuicyLucy AdsARCHIVED FILE

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

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

↓ Download file

#!/usr/bin/env node

import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { parseArgs } from "node:util";
import { duckKeyframes, duckLane, speechSpans } from "./lib/duck.mjs";
import { track } from "./lib/telemetry.mjs";

const { values: args } = parseArgs({
  options: {
    meta: { type: "string" },
    target: { type: "string" },
    duck: { type: "string", default: "0.25" },
    attack: { type: "string", default: "0.15" },
    release: { type: "string", default: "0.4" },
    "merge-gap": { type: "string", default: "0.6" },
    sequential: { type: "boolean", default: false },
    gap: { type: "string", default: "0" },
    offsets: { type: "string" },
    composition: { type: "string" },
    json: { type: "boolean", default: false },
    help: { type: "boolean", short: "h", default: false },
  },
  strict: true,
});

if (args.help) {
  console.log(`media-use audio-duck — generate a volume ducking lane (data-automation)

Usage:
  node audio-duck.mjs --meta audio_meta.json --target "#bgm"

Options:
  --meta          audio_meta.json or JSON word transcript
  --target        GSAP selector for the background audio element
  --duck          Duck multiplier (default: 0.25)
  --attack        Duck-in duration seconds (default: 0.15)
  --release       Restore duration seconds (default: 0.4)
  --merge-gap     Bridge speech gaps smaller than this many seconds (default: 0.6)
  --sequential    Place multi-line meta back to back at composition time
  --gap           Extra seconds between sequential lines (default: 0)
  --offsets       Explicit placement, "l1=0,l2=3.4" (voice id = start seconds)
  --composition   Read target data-volume and data-start from this HTML file
  --json          Output { spans, keyframes, lane }
  --help, -h      Show this help`);
  process.exit(0);
}

try {
  run();
  await track("media_use_duck", { sequential: !!args.sequential });
} catch (err) {
  if (args.json) console.log(JSON.stringify({ ok: false, error: err.message }));
  else console.error(`error: ${err.message}`);
  process.exit(1);
}

function run() {
  if (!args.meta || !args.target) throw new Error("--meta and --target are required");
  const meta = JSON.parse(readFileSync(resolve(args.meta), "utf8"));
  const target = args.target;
  const { baseVolume, clipStart } = readTargetAttrs(args.composition, target);
  const offsets = args.offsets
    ? Object.fromEntries(
        args.offsets.split(",").map((pair) => {
          const [id, t] = pair.split("=");
          return [id.trim(), Number(t)];
        }),
      )
    : undefined;
  const spans = speechSpans(meta, {
    mergeGap: Number(args["merge-gap"]),
    sequential: args.sequential,
    gap: Number(args.gap),
    offsets,
  });
  const keyframes = duckKeyframes(spans, {
    duck: Number(args.duck),
    attack: Number(args.attack),
    release: Number(args.release),
    baseVolume,
  });

  const lane = duckLane(keyframes, { clipStart, baseVolume });

  if (args.json) {
    console.log(JSON.stringify({ spans, keyframes, lane }));
    return;
  }

  console.log(`<!-- auto-duck: ${target} under narration; add to its <audio> element -->`);
  console.log(`data-automation='${JSON.stringify(lane)}'`);
}

function readTargetAttrs(composition, target) {
  if (!composition || !target.startsWith("#")) return { baseVolume: 1, clipStart: 0 };
  const id = target.slice(1);
  const html = readFileSync(resolve(composition), "utf8");
  // ponytail: regex is enough here because this only reads one attribute from
  // one user-authored composition element, not arbitrary HTML.
  const tag = html.match(new RegExp(`<[^>]*\\bid=["']${escapeRegExp(id)}["'][^>]*>`, "i"))?.[0];
  const attr = (name) => Number(tag?.match(new RegExp(`\\b${name}=["']([^"']+)["']`, "i"))?.[1]);
  const volume = attr("data-volume");
  const start = attr("data-start");
  return {
    baseVolume: Number.isFinite(volume) ? volume : 1,
    clipStart: Number.isFinite(start) ? start : 0,
  };
}

function escapeRegExp(value) {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

SHA-256: 6beac3c1c6c0e0375e67a04c184406ced3740cc832bb3ba9a09c796bbe16d40e