Docs/Skills and code/skill video-visual-production

.claude/skills/video-visual-production/scripts/score.js

Code129 lines8.3 KBSkill: video-visual-production

What the file says about itself

Ambient score synthesis, deterministic and dependency-free. One beat's score from a small spec: root (MIDI note), chords (arrays of semitone offsets above the root), chord_len_s (number or array), bright (low-pass Hz), reverb (0..1 wet),…

#!/usr/bin/env node
// Ambient score synthesis, deterministic and dependency-free. One beat's score from a small spec:
//   root (MIDI note), chords (arrays of semitone offsets above the root), chord_len_s (number or array),
//   bright (low-pass Hz), reverb (0..1 wet), sub_db / air_db / pulse.db (layer levels relative to the pad, dB),
//   pulse { bpm_from, bpm_to }, arc [[fraction of the beat, dB], ...] (a level curve over the beat).
// It is a pad of detuned sine partials (a warm, organ-like sustained chord) that changes chord slowly with long
// cross-fades, plus an optional sub drone, a faint airy shimmer and a soft low pulse, through a small Schroeder reverb.
// The pad is built at 12 kHz (everything in it is below about 3 kHz) and the caller upsamples. Stereo by detune offsets.
//   node score.js --spec <score-spec.json> --beat B3 --dur 40 --out audition.wav     (a score-only audition clip)
const fs = require('fs');
const path = require('path');
const U = require('./lib/util');
const D = require('./lib/dsp');

const SR = 12000;
const TAU = Math.PI * 2;
const hz = (midi) => 440 * Math.pow(2, (midi - 69) / 12);
const raise = (x) => 0.5 - 0.5 * Math.cos(Math.PI * Math.min(1, Math.max(0, x)));

function onePole(buf, fc) { const a = Math.exp((-TAU * fc) / SR); let y = 0; for (let i = 0; i < buf.length; i++) { y = (1 - a) * buf[i] + a * y; buf[i] = y; } }
function highpass(buf, fc) { const a = Math.exp((-TAU * fc) / SR); let y = 0; for (let i = 0; i < buf.length; i++) { y = (1 - a) * buf[i] + a * y; buf[i] -= y; } }
function rmsOf(a) { let s = 0; for (let i = 0; i < a.length; i += 3) s += a[i] * a[i]; return Math.sqrt(s / Math.max(1, Math.ceil(a.length / 3))) || 1e-9; }

// a Schroeder reverb: four combs and two all-passes; tunings scaled from the classic 44.1 kHz values
function reverb(x, wet, seed) {
  if (!wet) return x;
  const sc = SR / 44100, combs = [1116, 1188, 1277, 1356].map((d) => Math.round((d + seed * 23) * sc)), aps = [556, 441].map((d) => Math.round(d * sc));
  const out = new Float32Array(x.length);
  for (const d of combs) { const buf = new Float32Array(d); let p = 0, lp = 0; for (let i = 0; i < x.length; i++) { const y = buf[p]; lp = 0.7 * y + 0.3 * lp; buf[p] = x[i] + 0.86 * lp; p = (p + 1) % d; out[i] += y / combs.length; } }
  for (const d of aps) { const buf = new Float32Array(d); let p = 0; for (let i = 0; i < out.length; i++) { const b = buf[p], v = out[i] + 0.5 * b; buf[p] = v; out[i] = b - 0.5 * v; p = (p + 1) % d; } }
  const r = new Float32Array(x.length);
  for (let i = 0; i < x.length; i++) r[i] = (1 - wet) * x[i] + wet * 1.6 * out[i];
  return r;
}

function synthBeat(spec, durS) {
  const N = Math.round(durS * SR);
  const pad = [new Float32Array(N), new Float32Array(N)];
  const lens = Array.isArray(spec.chord_len_s) ? spec.chord_len_s : spec.chords.map(() => spec.chord_len_s || 8);
  const sched = []; let t = 0, k = 0;
  while (t < durS) { const len = lens[k % lens.length]; sched.push({ t0: t, len, notes: spec.chords[k % spec.chords.length] }); t += len; k++; }
  const att = Math.min(3.0, spec.attack_s || 2.5), rel = spec.release_s || 3.2;
  const det = [[-7, 0, 5], [-4, 2, 8]], harm = [1, 0.36, 0.15];
  for (const c of sched) {
    const i0 = Math.max(0, Math.round(c.t0 * SR)), i1 = Math.min(N, Math.round((c.t0 + c.len + rel) * SR));
    c.notes.forEach((off, ni) => {
      const f0 = hz(spec.root + off), amp = 1 / (1 + 0.12 * ni), lfoF = 0.06 + 0.017 * ni;
      for (let ch = 0; ch < 2; ch++) {
        const ph = [0, 0, 0], inc = det[ch].map((cents) => (TAU * f0 * Math.pow(2, cents / 1200)) / SR);
        for (let i = i0; i < i1; i++) {
          const tt = i / SR, u = tt - c.t0;
          const env = u < att ? raise(u / att) : u > c.len ? 1 - raise((u - c.len) / rel) : 1;
          let v = 0;
          for (let d = 0; d < 3; d++) { ph[d] += inc[d]; const s = Math.sin(ph[d]), co = Math.cos(ph[d]); v += s * harm[0] + 2 * s * co * harm[1] + (3 * s - 4 * s * s * s) * harm[2]; }
          pad[ch][i] += v * amp * env * (1 + 0.14 * Math.sin(TAU * lfoF * tt + ni + ch));
        }
      }
    });
  }
  for (const ch of pad) { onePole(ch, spec.bright || 1100); onePole(ch, (spec.bright || 1100) * 1.4); highpass(ch, 70); }
  const padR = (rmsOf(pad[0]) + rmsOf(pad[1])) / 2;
  const out = [new Float32Array(N), new Float32Array(N)];
  const gain = (dbv) => Math.pow(10, dbv / 20);
  for (let ch = 0; ch < 2; ch++) for (let i = 0; i < N; i++) out[ch][i] = pad[ch][i] / padR;
  // sub drone: root an octave down, very slow swell
  if (spec.sub_db != null && spec.sub_db > -60) {
    const f = hz(spec.root - 12), g = gain(spec.sub_db);
    for (let i = 0; i < N; i++) { const v = Math.sin((TAU * f * i) / SR) * (0.8 + 0.2 * Math.sin((TAU * 0.05 * i) / SR)) * g * Math.SQRT2; out[0][i] += v; out[1][i] += v; }
  }
  // air: two high partials with slow tremolo, a faint shimmer that keeps a pad from sounding dead
  if (spec.air_db != null && spec.air_db > -60) {
    const g = gain(spec.air_db) * Math.SQRT2;
    [[hz(spec.root + 24), 0.11], [hz(spec.root + 31), 0.07]].forEach(([f, lf], j) => { for (let i = 0; i < N; i++) { const v = Math.sin((TAU * f * i) / SR + j) * (0.55 + 0.45 * Math.sin((TAU * lf * i) / SR + j)) * g * 0.5; out[0][i] += v * (j ? 0.6 : 1); out[1][i] += v * (j ? 1 : 0.6); } });
  }
  // pulse: a soft low thump on the beat, the tempo may ramp over the beat (a clock, a treadmill)
  if (spec.pulse && spec.pulse.db > -60) {
    const g = gain(spec.pulse.db) * 2.2, f = hz(spec.root - 12);
    let tp = 0.4;
    while (tp < durS) {
      const bpm = spec.pulse.bpm_from + ((spec.pulse.bpm_to != null ? spec.pulse.bpm_to : spec.pulse.bpm_from) - spec.pulse.bpm_from) * (tp / durS);
      const i0 = Math.round(tp * SR), len = Math.round(0.28 * SR);
      for (let i = 0; i < len && i0 + i < N; i++) { const e = Math.min(1, (i / SR) / 0.006) * Math.exp(-(i / SR) / 0.07); const v = Math.sin(TAU * f * 1.0 * (i / SR)) * e * g; out[0][i0 + i] += v; out[1][i0 + i] += v; }
      tp += 60 / bpm;
    }
  }
  const res = out.map((c, i) => reverb(c, spec.reverb == null ? 0.3 : spec.reverb, i));
  // level curve over the beat
  if (spec.arc && spec.arc.length) {
    const pts = spec.arc.slice().sort((a, b) => a[0] - b[0]);
    for (let i = 0; i < N; i++) {
      const fr = i / N; let j = 0; while (j + 1 < pts.length && pts[j + 1][0] <= fr) j++;
      const a = pts[j], b = pts[Math.min(j + 1, pts.length - 1)], k2 = b[0] === a[0] ? 0 : Math.min(1, Math.max(0, (fr - a[0]) / (b[0] - a[0])));
      const g = gain(a[1] + (b[1] - a[1]) * k2);
      res[0][i] *= g; res[1][i] *= g;
    }
  }
  return res;
}

// Cubic (Catmull-Rom) upsample of a 12 kHz slice to 48 kHz
function upsample4(x, from, to) {
  const n = (to - from) * 4, out = new Float32Array(n);
  const at = (i) => (i < 0 ? x[0] : i >= x.length ? x[x.length - 1] : x[i]);
  for (let o = 0; o < n; o++) {
    const p = from + o / 4, i = Math.floor(p), f = p - i, a = at(i - 1), b = at(i), c = at(i + 1), d = at(i + 2);
    out[o] = b + 0.5 * f * (c - a + f * (2 * a - 5 * b + 4 * c - d + f * (3 * (b - c) + d - a)));
  }
  return out;
}

module.exports = { synthBeat, upsample4, SR, rmsOf };

if (require.main === module) {
  const spec = U.readJSON(U.fromRoot(U.arg('spec', '')));
  const beat = U.arg('beat', 'B1'), dur = parseFloat(U.arg('dur', '40'));
  const b = { ...(spec.defaults || {}), ...(spec.beats[beat] || {}) };
  const t0 = Date.now();
  const [L, R] = synthBeat(b, dur);
  const n = (L.length) * 4, s = new Float32Array(n * 2), l4 = upsample4(L, 0, L.length), r4 = upsample4(R, 0, R.length);
  let pk = 0; for (let i = 0; i < n; i++) pk = Math.max(pk, Math.abs(l4[i]), Math.abs(r4[i]));
  for (let i = 0; i < n; i++) { s[2 * i] = (l4[i] / pk) * 0.5; s[2 * i + 1] = (r4[i] / pk) * 0.5; }
  const out = U.arg('out', 'score-audition.wav');
  const hdr = Buffer.alloc(44), body = Buffer.alloc(n * 4);
  for (let i = 0; i < n; i++) { body.writeInt16LE(Math.round(s[2 * i] * 32767), i * 4); body.writeInt16LE(Math.round(s[2 * i + 1] * 32767), i * 4 + 2); }
  hdr.write('RIFF', 0); hdr.writeUInt32LE(36 + body.length, 4); hdr.write('WAVEfmt ', 8); hdr.writeUInt32LE(16, 16); hdr.writeUInt16LE(1, 20); hdr.writeUInt16LE(2, 22); hdr.writeUInt32LE(48000, 24); hdr.writeUInt32LE(48000 * 4, 28); hdr.writeUInt16LE(4, 32); hdr.writeUInt16LE(16, 34); hdr.write('data', 36); hdr.writeUInt32LE(body.length, 40);
  fs.writeFileSync(out, Buffer.concat([hdr, body]));
  U.say(`${beat}: ${dur} s of score in ${((Date.now() - t0) / 1000).toFixed(1)} s -> ${out}`);
}

Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.