diff --git a/pop/marimba/lullabies/lib/core.mjs b/pop/marimba/lullabies/lib/core.mjs new file mode 100644 index 0000000000..fd05d33d7e --- /dev/null +++ b/pop/marimba/lullabies/lib/core.mjs @@ -0,0 +1,175 @@ +// core.mjs — the shared lullaby render core for the marimbaba-riff lane. +// +// Reuses the real AC marimba synth (pop/marimba/synths/marimba.mjs — +// modal mallet model, the same one behind marimbaba.mp3) so every +// variation sounds like the instrument, not a re-synth. A variation just +// builds an `events` array and calls renderLullaby(); this module does the +// stereo mix (per-event equal-power pan), a soft Schroeder reverb, peak +// normalize + gentle fades, a 16-bit WAV, and a tender ffmpeg mp3 master. +// +// Event shape: { preset, startSec, midi, durSec, gain, decayMul?, pan? } + +import { renderMarimba, MARIMBA_PRESETS } from "../../synths/marimba.mjs"; +import { writeFileSync, mkdirSync, unlinkSync } from "node:fs"; +import { dirname, resolve, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { homedir, tmpdir } from "node:os"; + +export const SR = 48_000; +export { MARIMBA_PRESETS }; + +// ── note-name → MIDI (C4 = 60). Accepts "C5", "Bb4", "F#3", "A4". ────────── +const SEMI = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }; +export function m(name) { + if (typeof name === "number") return name; + const mt = /^([A-Ga-g])([#b]?)(-?\d+)$/.exec(name.trim()); + if (!mt) throw new Error(`bad note: ${name}`); + let s = SEMI[mt[1].toUpperCase()]; + if (mt[2] === "#") s += 1; else if (mt[2] === "b") s -= 1; + return (parseInt(mt[3], 10) + 1) * 12 + s; +} + +// ── soft Schroeder reverb (4 combs + 2 allpass), stereo, dreamy + glassy ── +function reverb(L, R, { wet = 0.28, decay = 0.82, damp = 0.4 } = {}) { + const n = L.length; + const cds = [0.0297, 0.0371, 0.0411, 0.0437]; + const CD = cds.map((d) => Math.floor(d * SR)); + const cbL = CD.map((d) => new Float32Array(d)); + const cbR = CD.map((d) => new Float32Array(d)); + const ci = CD.map(() => 0); + const lpL = CD.map(() => 0), lpR = CD.map(() => 0); + const AD = [Math.floor(0.005 * SR), Math.floor(0.0017 * SR)]; + const abL = AD.map((d) => new Float32Array(d)); + const abR = AD.map((d) => new Float32Array(d)); + const ai = AD.map(() => 0); + const apFb = 0.5; + for (let i = 0; i < n; i++) { + const inL = L[i], inR = R[i]; + let cL = 0, cR = 0; + for (let k = 0; k < CD.length; k++) { + const dL = cbL[k][ci[k]], dR = cbR[k][ci[k]]; + cL += dL; cR += dR; + lpL[k] = dL * (1 - damp) + lpL[k] * damp; + lpR[k] = dR * (1 - damp) + lpR[k] * damp; + cbL[k][ci[k]] = inL + lpL[k] * decay; + cbR[k][ci[k]] = inR + lpR[k] * decay; + ci[k] = (ci[k] + 1) % CD[k]; + } + cL /= CD.length; cR /= CD.length; + for (let k = 0; k < AD.length; k++) { + const dL = abL[k][ai[k]], dR = abR[k][ai[k]]; + const oL = -apFb * cL + dL, oR = -apFb * cR + dR; + abL[k][ai[k]] = cL + apFb * oL; + abR[k][ai[k]] = cR + apFb * oR; + ai[k] = (ai[k] + 1) % AD[k]; + cL = oL; cR = oR; + } + L[i] += cL * wet; R[i] += cR * wet; + } +} + +// healing bed — a constant pure-sine drone under the whole piece at a +// "healing" frequency (default 528 Hz, the Solfeggio MI / "repair" tone) +// plus its sub-octave for body, with a very slow amplitude breath and +// long self-fades. Quiet enough to be felt, not to fight the melody. +// Disable with opts.healing = false; retune with opts.healingHz. +function healingBed(L, R, { hz = 528, gain = 0.05 } = {}) { + const n = L.length; + const dp1 = TAU_ * hz / SR, dp2 = TAU_ * (hz / 2) / SR, dlfo = TAU_ * 0.07 / SR; + let p1 = 0, p2 = 0, pl = 0; + const fade = Math.floor(2.5 * SR); + for (let i = 0; i < n; i++) { + p1 += dp1; p2 += dp2; pl += dlfo; + let env = 0.78 + 0.22 * Math.sin(pl); + if (i < fade) env *= i / fade; + if (i > n - fade) env *= (n - i) / fade; + const v = (Math.sin(p1) * 0.55 + Math.sin(p2) * 0.45) * gain * env; + L[i] += v; R[i] += v; + } +} +const TAU_ = Math.PI * 2; + +function writeWav16(path, L, R) { + const n = L.length, bytes = n * 4; + const buf = Buffer.alloc(44 + bytes); + buf.write("RIFF", 0); buf.writeUInt32LE(36 + bytes, 4); buf.write("WAVE", 8); + buf.write("fmt ", 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); + buf.writeUInt16LE(2, 22); buf.writeUInt32LE(SR, 24); buf.writeUInt32LE(SR * 4, 28); + buf.writeUInt16LE(4, 32); buf.writeUInt16LE(16, 34); + buf.write("data", 36); buf.writeUInt32LE(bytes, 40); + let o = 44; + for (let i = 0; i < n; i++) { + const l = Math.max(-1, Math.min(1, L[i])), r = Math.max(-1, Math.min(1, R[i])); + buf.writeInt16LE((l < 0 ? l * 32768 : l * 32767) | 0, o); o += 2; + buf.writeInt16LE((r < 0 ? r * 32768 : r * 32767) | 0, o); o += 2; + } + writeFileSync(path, buf); +} + +const expand = (p) => (p && p.startsWith("~/") ? resolve(homedir(), p.slice(2)) : p); + +// the gentle lullaby master: warm, quiet, lots of room. Soft top, low +// loudness target so it breathes; a tender limiter, no aggressive comp. +const MASTER = [ + "highpass=f=30", + "equalizer=f=180:t=q:w=1.1:g=1.0", // a little warmth + "equalizer=f=320:t=q:w=1.2:g=-1.0", // de-mud + "equalizer=f=9000:t=q:w=0.9:g=-1.4", // soften the mallet edge + "treble=g=-1.0:f=11000", + "lowpass=f=15500", + "acompressor=threshold=-22dB:ratio=1.8:attack=40:release=320:makeup=1.6:knee=8", + "loudnorm=I=-17:TP=-1.5:LRA=12", + "alimiter=limit=0.95:attack=12:release=160", +].join(","); + +// renderLullaby(events, opts) — mix, reverb, normalize, fade, master to mp3. +// opts: { name, out?, tailSec?, reverb?, peak?, fadeIn?, fadeOut?, title? } +export function renderLullaby(events, opts = {}) { + const { name = "lullaby", tailSec = 4.0, peak = 0.86 } = opts; + const HERE = opts.here ?? process.cwd(); + let end = 0; + for (const e of events) end = Math.max(end, (e.startSec ?? 0) + (e.durSec ?? 0)); + const N = Math.ceil((end + tailSec) * SR); + const L = new Float32Array(N), R = new Float32Array(N); + + for (const e of events) { + if (!Number.isFinite(e.midi) || !(e.durSec > 0) || (e.gain ?? 0) === 0) continue; + const seg = renderMarimba(e, { sampleRate: SR }); + const s0 = Math.floor((e.startSec ?? 0) * SR); + const pan = e.pan ?? 0; + const ang = (pan * 0.5 + 0.5) * (Math.PI / 2); + const gL = Math.cos(ang), gR = Math.sin(ang); + for (let i = 0; i < seg.length; i++) { + const d = s0 + i; if (d < 0 || d >= N) continue; + L[d] += seg[i] * gL; R[d] += seg[i] * gR; + } + } + + if (opts.healing !== false) { + healingBed(L, R, { hz: opts.healingHz ?? 528, gain: opts.healingGain ?? 0.05 }); + } + reverb(L, R, opts.reverb ?? {}); + + let pk = 0; + for (let i = 0; i < N; i++) { const a = Math.max(Math.abs(L[i]), Math.abs(R[i])); if (a > pk) pk = a; } + if (pk > 0) { const g = peak / pk; for (let i = 0; i < N; i++) { L[i] *= g; R[i] *= g; } } + const fin = Math.floor((opts.fadeIn ?? 0.8) * SR), fout = Math.floor((opts.fadeOut ?? 3.5) * SR); + for (let i = 0; i < fin && i < N; i++) { const g = 0.5 - 0.5 * Math.cos(Math.PI * i / fin); L[i] *= g; R[i] *= g; } + for (let i = 0; i < fout && i < N; i++) { const g = 0.5 - 0.5 * Math.cos(Math.PI * i / fout); const x = N - 1 - i; L[x] *= g; R[x] *= g; } + + const outMp3 = resolve(expand(opts.out) ?? resolve(HERE, "..", "out", `${name}.mp3`)); + mkdirSync(dirname(outMp3), { recursive: true }); + // scratch WAV lives in the OS tmpdir (never beside the mp3) and is + // deleted after mastering, so out/ only ever holds finished .mp3s. + const rawWav = join(tmpdir(), `juke-${name}-${process.pid}-raw.wav`); + writeWav16(rawWav, L, R); + const r = spawnSync("ffmpeg", [ + "-hide_banner", "-y", "-loglevel", "error", "-i", rawWav, "-af", MASTER, + "-c:a", "libmp3lame", "-b:a", "320k", + "-metadata", `title=${opts.title ?? name}`, "-metadata", "artist=jeffrey", "-metadata", "album=lullabies", + outMp3, + ], { stdio: "inherit" }); + try { unlinkSync(rawWav); } catch {} + if (r.status !== 0) throw new Error("ffmpeg master failed"); + return { mp3: outMp3, durationSec: N / SR }; +} diff --git a/pop/marimba/lullabies/lib/marimbaba.mjs b/pop/marimba/lullabies/lib/marimbaba.mjs new file mode 100644 index 0000000000..9019b84772 --- /dev/null +++ b/pop/marimba/lullabies/lib/marimbaba.mjs @@ -0,0 +1,101 @@ +// marimbaba.mjs — the canonical marimbaba lullaby melody as reusable data, +// transcribed from pop/marimba/marimbaba.np (F major, 3/4, ~56 BPM, 24 bars). +// It is itself a riff on the whistlegraph platter phrases — the "unspoken +// lyric" in the .np: hush-hush / twinkle-little-star / mommy-wow-wow / +// slinky ba-ba-ba-bap / sleep-now-little-one. +// +// buildMarimbaba(opts) returns a flat event array (seconds) for core.mjs. +// Variations import this, then transpose / re-tempo / re-voice / ornament, +// or splice the MOTIFS to riff a new tune over the same DNA. + +import { m } from "./core.mjs"; + +// per-voice ring stretch (dreamier than physically accurate) + default pan. +export const DECAY = { rosewood: 1.8, bass: 1.8, kalimba: 1.75, vibraphone: 1.4, vibraphone_off: 1.4, staccato: 1.0, kelon: 1.3, xylophone: 0.9 }; +const PAN = { rosewood: 0, bass: 0, kalimba: 0.32, vibraphone: 0.18, vibraphone_off: -0.18, staccato: -0.22, kelon: 0.1, xylophone: 0.25 }; + +// The score as tuples: [voice, bar, beatWithinBar, noteName, beats, gain]. +// Bars 0-indexed, 3 beats per bar. Faithful to marimbaba.np + the renderer. +export const SCORE = [ + // ── [hush hush] bars 0-3 — descending sigh ── + ["rosewood",0,0,"C5",1,0.55],["rosewood",0,1,"A4",1,0.55],["rosewood",0,2,"F4",1,0.55], + ["rosewood",1,0,"F4",3,0.45], + ["rosewood",2,0,"A4",1.5,0.55],["rosewood",2,1.5,"G4",1.5,0.55], + ["rosewood",3,0,"F4",3,0.55], + ["bass",0,0,"F2",3,0.5],["bass",1,0,"F2",3,0.5],["bass",2,0,"F2",3,0.5],["bass",3,0,"F2",3,0.5], + // ── [twinkle] bars 4-9 — climbing-falling waves ── + ["rosewood",4,0,"F5",1,0.6],["rosewood",4,1,"A5",1,0.6],["rosewood",4,2,"C6",0.5,0.65],["rosewood",4,2.5,"A5",0.5,0.55], + ["rosewood",5,0,"G5",3,0.55], + ["rosewood",6,0,"F5",1,0.55],["rosewood",6,1,"G5",1,0.55],["rosewood",6,2,"A5",0.5,0.6],["rosewood",6,2.5,"G5",0.5,0.5], + ["rosewood",7,0,"F5",3,0.55], + ["rosewood",8,0,"Bb5",1,0.6],["rosewood",8,1,"D6",1,0.65],["rosewood",8,2,"Bb5",1,0.55], + ["rosewood",9,0,"C6",1.5,0.6],["rosewood",9,1.5,"A5",1.5,0.55], + ["bass",4,0,"F2",3,0.45],["bass",5,0,"F3",3,0.45],["bass",6,0,"F2",3,0.45],["bass",7,0,"F3",3,0.45],["bass",8,0,"F2",3,0.45],["bass",9,0,"F3",3,0.45], + ["vibraphone_off",4,0,"F4",9,0.18],["vibraphone_off",4,0,"A4",9,0.18],["vibraphone_off",4,0,"C5",9,0.18], + ["vibraphone_off",7,0,"Bb4",9,0.18],["vibraphone_off",7,0,"D5",9,0.18],["vibraphone_off",7,0,"F5",9,0.18], + // ── [wow wow wow] bars 10-13 — held wobble ── + ["vibraphone",10,0,"G5",6,0.45],["vibraphone",10,0,"Bb5",6,0.4], + ["rosewood",12,0,"A5",1,0.55],["rosewood",12,1,"G5",1,0.55],["rosewood",12,2,"A5",1,0.55], + ["rosewood",13,0,"F5",3,0.55], + ["kalimba",10,2.5,"D6",0.5,0.3],["kalimba",11,1,"F6",0.5,0.25],["kalimba",12,2.5,"C6",0.5,0.3],["kalimba",13,1.5,"A5",0.5,0.25], + ["bass",10,0,"F2",3,0.35],["bass",12,0,"F2",3,0.35], + // ratatatata — staccato 16th tumble across bar 11 + ["staccato",11,0,"A5",0.22,0.34],["staccato",11,0.1875,"G5",0.22,0.34],["staccato",11,0.375,"A5",0.22,0.34],["staccato",11,0.5625,"G5",0.22,0.34], + ["staccato",11,0.75,"F5",0.22,0.34],["staccato",11,0.9375,"G5",0.22,0.34],["staccato",11,1.125,"A5",0.22,0.34],["staccato",11,1.3125,"C6",0.22,0.34], + // ── [ba-ba-ba bap] bars 14-17 — slinky-dog wobble ── + ["rosewood",14,0,"A5",0.5,0.6],["rosewood",14,0.5,"G5",0.5,0.55],["rosewood",14,1,"A5",1,0.6],["rosewood",14,2,"F5",1,0.65], + ["rosewood",15,0,"C6",0.5,0.6],["rosewood",15,0.5,"Bb5",0.5,0.55],["rosewood",15,1,"C6",1,0.6],["rosewood",15,2,"A5",1,0.65], + ["rosewood",16,0,"G5",1,0.55],["rosewood",16,1,"F5",1,0.55],["rosewood",16,2,"E5",1,0.5], + ["rosewood",17,0,"F5",3,0.55], + ["kalimba",14,2,"F6",1,0.28],["kalimba",15,2,"A5",1,0.28], + ["bass",14,0,"F2",3,0.45],["bass",15,0,"C3",3,0.45],["bass",16,0,"Eb2",3,0.45],["bass",17,0,"F2",3,0.45], + // ── [sleep now] bars 18-23 — final settling descent ── + ["rosewood",18,0,"C5",1.5,0.5],["rosewood",18,1.5,"A4",1.5,0.5], + ["rosewood",19,0,"G4",1,0.5],["rosewood",19,1,"F4",1,0.5],["rosewood",19,2,"F4",1,0.45], + ["rosewood",20,0,"A4",1,0.5],["rosewood",20,1,"G4",1,0.5],["rosewood",20,2,"F4",1,0.5], + ["rosewood",21,0,"F4",3,0.5], + ["rosewood",22,0,"F3",3,0.45], + ["bass",18,0,"F2",3,0.4],["bass",20,0,"F2",3,0.4],["bass",22,0,"F2",3,0.35], +]; + +// Named scale-degree motif cells (in C-relative semitone offsets from the +// key root, octave-marked) so variations can re-key / re-mode and re-voice +// the whistlegraph phrases. Pair with a root midi + a scale. +export const MOTIFS = { + hush: [["C5",1],["A4",1],["F4",1],["F4",3]], // descending sigh + twinkle: [["F5",1],["A5",1],["C6",0.5],["A5",0.5],["G5",3]], // climbing wave + flyHigh: [["Bb5",1],["D6",1],["Bb5",1],["C6",1.5],["A5",1.5]], // butterfly / "way up high" + wow: [["G5",1],["Bb5",1],["A5",1],["G5",1],["A5",1],["F5",3]], // mommy-wow wobble + baba: [["A5",0.5],["G5",0.5],["A5",1],["F5",1],["C6",0.5],["Bb5",0.5],["C6",1],["A5",1]], // slinky-dog + sleep: [["C5",1.5],["A4",1.5],["G4",1],["F4",1],["F4",1],["F4",3]], // settle +}; + +// the whistlegraph platter phrases the lullaby chants (for naming / docs). +export const WHISTLEGRAPH = ["butterfly-cosplayer", "mommy-wow", "slinky-dog", "lately-when-i-fly"]; + +// buildMarimbaba — turn SCORE into seconds-based events. +// opts: { bpm=56, beatsPerBar=3, transpose=0, leadPreset="rosewood", +// gainMul=1, decayMul=1, swing=0 } +// transpose is in semitones; leadPreset replaces "rosewood"; swing drags +// off-beats (fraction of a beat). Returns a fresh event array. +export function buildMarimbaba(opts = {}) { + const { bpm = 56, beatsPerBar = 3, transpose = 0, leadPreset = "rosewood", gainMul = 1, decayMul = 1, swing = 0, filter = null } = opts; + const BEAT = 60 / bpm, BAR = beatsPerBar * BEAT; + const out = []; + for (const [voice0, bar, beat, note, beats, gain] of SCORE) { + if (filter && !filter(voice0, bar, beat, note)) continue; + const voice = voice0 === "rosewood" ? leadPreset : voice0; + const frac = beat - Math.floor(beat); + const sw = (frac > 0.4 && frac < 0.6) ? swing * BEAT : 0; // nudge the &s + out.push({ + preset: voice, + startSec: bar * BAR + beat * BEAT + sw, + midi: m(note) + transpose, + durSec: beats * BEAT, + gain: gain * gainMul, + decayMul: (DECAY[voice] ?? 1) * decayMul, + pan: PAN[voice] ?? 0, + }); + } + return out; +} diff --git a/pop/marimba/lullabies/variations/_template.mjs b/pop/marimba/lullabies/variations/_template.mjs new file mode 100644 index 0000000000..603cf4faa9 --- /dev/null +++ b/pop/marimba/lullabies/variations/_template.mjs @@ -0,0 +1,23 @@ +// _template.mjs — the faithful marimbaba lullaby (the seed all 15 riff on). +// A variation = (1) build the marimbaba events with some transform, and/or +// (2) splice MOTIFS into a new tune over the same DNA, then renderLullaby(). +// +// Run: node variations/_template.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby } from "../lib/core.mjs"; +import { buildMarimbaba } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const events = buildMarimbaba({ bpm: 56, transpose: 0, leadPreset: "rosewood" }); + +const { mp3, durationSec } = renderLullaby(events, { + name: "_template", + here: HERE, + title: "marimbaba (seed)", + reverb: { wet: 0.3, decay: 0.82, damp: 0.4 }, + fadeOut: 4.0, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/cometbaba.mjs b/pop/marimba/lullabies/variations/cometbaba.mjs new file mode 100644 index 0000000000..9cc7d22de6 --- /dev/null +++ b/pop/marimba/lullabies/variations/cometbaba.mjs @@ -0,0 +1,330 @@ +// cometbaba.mjs — a streaking comet across the night, in D major. +// +// The marimbaba "twinkle" DNA (MOTIFS.twinkle — the climbing-falling wave, the +// hidden twinkle-little-star lyric) is taken and FLUNG UPWARD. The development +// strategy is RAPID ASCENDING SCALAR RUNS: each pass the comet streaks higher, +// the runs accelerate (longer, faster, climbing register), the twinkle head +// re-stated at the top of each arc — until a final blazing run launches into a +// long sparkling tail-off where the dust drifts back down and dims to one star. +// +// Intro — a far point of light, a slow rising glint (the comet appears). +// Pass 1 — twinkle head stated low, then a gentle ascending run lifts it. +// Pass 2 — the head an octave up; a longer, quicker run climbs past it. +// Pass 3 — the head higher still; an accelerating run streaks the whole sky. +// Apex — the comet at perihelion: a blazing full-register run, twinkle blazed +// at the top, then it tips over. +// Tail — a LONG sparkling tail-off: descending shimmer cascades, the dust +// settling, kelon + glockenspiel drifting down to one last star. +// +// D major throughout. The twinkle contour (do-do-sol-sol-la-la-sol) and the +// climbing-wave shape stay the recognizable thread under all the streaking. +// +// Run: node variations/cometbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +// ── D major mode: snap any midi to the nearest scale pitch ────────────────── +const ROOT = 2; // D +const MAJOR = [0, 2, 4, 5, 7, 9, 11]; +const SCALE = MAJOR.map((d) => (d + ROOT) % 12); // D-relative pitch classes +function snap(midi) { + const pc = ((midi % 12) + 12) % 12; + let best = 0, bestD = 99; + for (const d of SCALE) { + const dist = Math.min(Math.abs(d - pc), 12 - Math.abs(d - pc)); + if (dist < bestD) { bestD = dist; best = d; } + } + return midi + (best - pc); +} +const snapName = (name, oct = 0) => snap(m(name) + oct * 12); + +// D-major scale-degree → absolute midi (deg 0 = D in baseOct, wraps octaves) +function degToMidi(deg, baseOct = 5) { + const o = Math.floor(deg / 7); + const within = ((deg % 7) + 7) % 7; + return 12 * (baseOct + 1) + ROOT + MAJOR[within] + 12 * o; +} + +// ── timing — ~58 BPM (a touch slower than 64 so the comet breathes) ───────── +const BPM = 58; +const BEAT = 60 / BPM; +let BAR = 4 * BEAT; + +const ev = []; +let t = 0; // running cursor in seconds + +const GLOCK_DEC = 1.95; // high stars hang in the air +const KELON_DEC = (DECAY.kelon ?? 1.3) * 1.25; // warm wooden bed, stretched + +// ── primitive emitters ────────────────────────────────────────────────────── + +// glockenspiel star — the comet's light, way up high (default 2 octaves up) +function star(beatIn, name, beats, gain = 0.3, octShift = 2, pan = 0.2) { + ev.push({ + preset: "glockenspiel", + startSec: t + beatIn * BEAT, + midi: snapName(name, octShift), + durSec: beats * BEAT * 1.1, + gain, + decayMul: GLOCK_DEC, + pan, + }); +} +// star by absolute midi (for runs computed in degrees) +function starMidi(beatIn, midi, beats, gain = 0.3, pan = 0.2, dec = GLOCK_DEC) { + ev.push({ preset: "glockenspiel", startSec: t + beatIn * BEAT, midi: snap(midi), durSec: beats * BEAT * 1.1, gain, decayMul: dec, pan }); +} +// faint shimmer — one fragile re-strike filling the gaps +function shimmer(beatIn, name, gain = 0.12, octShift = 2, pan = -0.24) { + ev.push({ preset: "glockenspiel", startSec: t + beatIn * BEAT, midi: snapName(name, octShift), durSec: 0.5 * BEAT, gain, decayMul: GLOCK_DEC * 1.1, pan }); +} +// warm kelon bed (the wooden glow the comet leaves) +function bed(beatIn, name, beats, gain = 0.32, octShift = 0, pan = -0.08) { + ev.push({ preset: "kelon", startSec: t + beatIn * BEAT, midi: snapName(name, octShift), durSec: beats * BEAT * 1.18, gain, decayMul: KELON_DEC, pan }); +} +function bedMidi(beatIn, midi, beats, gain = 0.32, pan = -0.08) { + ev.push({ preset: "kelon", startSec: t + beatIn * BEAT, midi: snap(midi), durSec: beats * BEAT * 1.18, gain, decayMul: KELON_DEC, pan }); +} +// low root, grounding the phrase (kelon, kept low + clear, no remap) +function root(name, gain = 0.3, beats = null) { + ev.push({ preset: "kelon", startSec: t, midi: m(name), durSec: (beats ? beats * BEAT : BAR) * 1.12, gain, decayMul: KELON_DEC * 1.05, pan: 0 }); +} + +const nextPhrase = (mul = 1) => { t += BAR * mul; }; + +// ── the twinkle head as D-major scale-degree cells (the thread) ────────────── +// twinkle "do do sol sol la la sol" mapped to D major degrees: +// D D A A B B A (deg 0 0 4 4 5 5 4) +const HEAD = [[0, .5], [0, .5], [4, .5], [4, .5], [5, .5], [5, .5], [4, 1]]; + +// emit the twinkle head at a given starting octave, returning where it ends +function head(beatIn, baseOct, gStar = 0.3, gBed = 0.28, octShift = 2, pan = 0.18) { + let b = beatIn; + for (const [deg, beats] of HEAD) { + const midi = degToMidi(deg, baseOct); + starMidi(b, midi + octShift * 12, beats, gStar, pan); + bedMidi(b, midi - 12, beats, gBed); + b += beats; + } + return b; +} + +// ── the core development device: a RAPID ASCENDING SCALAR RUN ──────────────── +// climbs `steps` scale-degrees starting at `startDeg`, over `dur` beats, the +// notes accelerating (denser toward the top) and swelling — a comet streak. +function ascRun(beatIn, startDeg, steps, dur, baseOct, octShift, { + gain0 = 0.12, gain1 = 0.26, accel = 1.0, pan = 0.0, dec = GLOCK_DEC, +} = {}) { + // sub-beat positions: an accelerating ramp (gaps shrink toward the top) + const pos = []; + let acc = 0; + const weights = []; + for (let i = 0; i < steps; i++) weights.push(Math.pow(accel, -i)); // later = shorter + const total = weights.reduce((a, w) => a + w, 0); + for (let i = 0; i < steps; i++) { pos.push(acc); acc += weights[i] / total * dur; } + for (let i = 0; i < steps; i++) { + const f = i / Math.max(1, steps - 1); + const deg = startDeg + i; + const midi = degToMidi(deg, baseOct) + octShift * 12; + const g = gain0 + (gain1 - gain0) * f; // swell up the run + const p = (i % 2 ? 1 : -1) * pan; // ping-pong the streak across the sky + starMidi(beatIn + pos[i], midi, 0.4, g, p, dec); + } +} + +// ===================================================================== +// INTRO — a far point of light: one slow rising glint, the comet appears +// ===================================================================== +root("D2", 0.24); +star(0.5, "D5", 1.4, 0.18, 1, -0.2); +shimmer(1.8, "F#5", 0.1, 1, 0.22); +star(2.6, "A5", 1.6, 0.2, 1, 0.18); +shimmer(3.6, "D6", 0.1, 1, -0.2); +nextPhrase(); + +// ===================================================================== +// PASS 1 — twinkle head stated low; a gentle ascending run lifts it away. +// ===================================================================== +root("D2", 0.3); +head(0, 4, 0.28, 0.28, 0, 0.0); // head in the mid register, plain +// a soft rising run answers in the second half — the first faint streak +ascRun(2.0, 0, 8, 2.0, 4, 0, { gain0: 0.1, gain1: 0.22, accel: 1.08, pan: 0.26 }); +nextPhrase(); + +// brief landing — a held A (dominant), the comet catching breath +root("A2", 0.26); +bed(0, "A4", 4, 0.26, 0); +bed(0, "D5", 4, 0.2, 0); +star(0.6, "A5", 1.4, 0.2, 1, 0.2); +shimmer(2.4, "C#6", 0.1, 1, -0.22); +// a small early streak crosses the rest, foreshadowing the climb +ascRun(2.6, 2, 6, 1.4, 4, 0, { gain0: 0.08, gain1: 0.18, accel: 1.06, pan: 0.24 }); +nextPhrase(); + +// ===================================================================== +// PASS 2 — the head an octave UP; a longer, quicker run climbs past it. +// ===================================================================== +root("D2", 0.3); +head(0, 5, 0.3, 0.24, 0, 0.16); // head one octave higher (brighter) +// the run starts mid-phrase, longer and a touch faster, climbing two octaves +ascRun(1.5, 0, 12, 2.5, 4, 0, { gain0: 0.1, gain1: 0.26, accel: 1.1, pan: 0.3 }); +shimmer(3.9, "F#6", 0.1, 1, -0.2); +nextPhrase(); + +// ===================================================================== +// PASS 3 — the head higher still; an accelerating run streaks the whole sky. +// ===================================================================== +root("G2", 0.3); // lift to the subdominant — buoyant +head(0, 5, 0.3, 0.22, 0, 0.2); +// the streak now begins almost with the head and rips upward, fast + bright +ascRun(0.5, -3, 16, 3.2, 4, 0, { gain0: 0.1, gain1: 0.3, accel: 1.14, pan: 0.34 }); +nextPhrase(); + +// a short suspension before perihelion — a held shimmering dominant chord +root("A2", 0.26); +bed(0, "A4", 4, 0.24, 0); +bed(0, "C#5", 4, 0.18, 0); +bed(0, "E5", 4, 0.18, 0); +shimmer(0.8, "A5", 0.12, 1, 0.2); +shimmer(2.0, "E6", 0.1, 1, -0.22); +shimmer(3.2, "C#6", 0.09, 1, 0.18); +nextPhrase(); + +// ===================================================================== +// PASS 4 — the head re-voiced high in kelon below while a streak rides over; +// the comet visibly accelerating toward its closest approach. +// ===================================================================== +root("D2", 0.3); +// the head's contour walked in warm kelon, mid register (the thread held) +{ + let b = 0; + for (const [deg, beats] of HEAD) { + bedMidi(b, degToMidi(deg, 5) - 12, beats * 1.1, 0.24); + b += beats; + } +} +// a bright fast streak rides across the whole bar over the head +ascRun(0.25, -2, 18, 3.5, 4, 0, { gain0: 0.1, gain1: 0.3, accel: 1.13, pan: 0.34 }); +nextPhrase(); + +// final breath before perihelion — a hushed held tonic, the sky waiting +root("D2", 0.26); +bed(0, "D4", 4, 0.24, 0); +bed(0, "F#4", 4, 0.18, 0); +bed(0, "A4", 4, 0.18, 0); +shimmer(0.7, "D6", 0.11, 1, 0.2); +shimmer(2.1, "A5", 0.1, 1, -0.22); +shimmer(3.3, "F#6", 0.08, 1, 0.18); +nextPhrase(); + +// ===================================================================== +// APEX — perihelion: a blazing FULL-REGISTER run, the twinkle head blazed +// at the very top, then the comet tips over and starts to fall. +// ===================================================================== +BAR *= 1.05; // the apex breathes a hair wider +root("D2", 0.32); +bed(0, "D4", BAR / BEAT, 0.24, 0); +bed(0, "A4", BAR / BEAT, 0.2, 0); +// the longest, fastest ascending streak — from low, ripping clear to the top +ascRun(0.0, -7, 22, 3.4, 4, 0, { gain0: 0.1, gain1: 0.34, accel: 1.12, pan: 0.36 }); +// the twinkle head BLAZED at the apex, way up high, the comet's crown +head(2.4, 6, 0.3, 0.0, 0, 0.22); +nextPhrase(); +BAR /= 1.05; + +// ===================================================================== +// TAIL — a LONG sparkling tail-off. the comet falls away: descending +// shimmer cascades over a slowly settling kelon glow, dimming to one star. +// ===================================================================== + +// descending cascade helper — mirror of ascRun, decelerating + dimming +function descCascade(beatIn, startDeg, steps, dur, baseOct, octShift, { + gain0 = 0.22, gain1 = 0.08, decel = 1.08, pan = 0.0, +} = {}) { + const pos = []; + let acc = 0; + const weights = []; + for (let i = 0; i < steps; i++) weights.push(Math.pow(decel, i)); // later = longer (slowing) + const total = weights.reduce((a, w) => a + w, 0); + for (let i = 0; i < steps; i++) { pos.push(acc); acc += weights[i] / total * dur; } + for (let i = 0; i < steps; i++) { + const f = i / Math.max(1, steps - 1); + const deg = startDeg - i; + const midi = degToMidi(deg, baseOct) + octShift * 12; + const g = gain0 + (gain1 - gain0) * f; // dim as the dust falls + const p = (i % 2 ? 1 : -1) * pan; + starMidi(beatIn + pos[i], midi, 0.5, g, p, GLOCK_DEC * 1.1); + } +} + +// tail bar 1 — the big sparkling fall, the head's contour drifting down +BAR *= 1.1; // begin a gentle ritard for the descent +root("D2", 0.28); +bed(0, "F#4", BAR / BEAT, 0.22, 0); +bed(0, "A4", BAR / BEAT, 0.18, 0); +descCascade(0.0, 14, 16, 3.6, 4, 0, { gain0: 0.24, gain1: 0.1, decel: 1.1, pan: 0.3 }); +nextPhrase(); + +// tail bar 2 — a smaller, softer secondary fall; kelon answers below +BAR *= 1.12; +root("G2", 0.26); +bed(0, "B3", BAR / BEAT, 0.24, 0); +bed(0, "D4", BAR / BEAT, 0.18, 0); +descCascade(0.2, 9, 10, 3.2, 4, 0, { gain0: 0.18, gain1: 0.08, decel: 1.12, pan: 0.26 }); +// a low kelon echo of the twinkle head, slow + warm — the thread, settling +{ + let b = 0; + for (const [deg, beats] of [[4, 1], [4, 1], [5, 1], [4, 1]]) { + bedMidi(b, degToMidi(deg, 4) - 12, beats * 1.2, 0.22); + b += beats; + } +} +nextPhrase(); + +// tail bar 2.5 — a last gentle drift of dust, very soft, high and slow +BAR *= 1.14; +root("A2", 0.24); +bed(0, "A3", BAR / BEAT, 0.22, 0); +bed(0, "C#4", BAR / BEAT, 0.16, 0); +descCascade(0.3, 6, 7, 3.0, 4, 0, { gain0: 0.13, gain1: 0.06, decel: 1.14, pan: 0.22 }); +nextPhrase(); + +// tail bar 3 — the dust nearly settled: a resting D-major chord, faint glints +BAR *= 1.18; +root("D2", 0.28); +bed(0, "F#4", BAR / BEAT, 0.24, 0); +bed(0, "A4", BAR / BEAT, 0.2, 0); +bed(0, "D5", BAR / BEAT, 0.2, 0); +star(0.5, "D5", 2, 0.16, 2, 0.18); +shimmer(2.2, "A5", 0.1, 2, -0.22); +shimmer(3.6, "F#5", 0.08, 2, 0.18); +nextPhrase(); + +// the very last star — one distant point of light, soft and high, fading out +ev.push({ + preset: "glockenspiel", + startSec: t + 0.4 * BEAT, + midi: snapName("D5", 2), + durSec: 1.2 * BEAT, + gain: 0.1, + decayMul: GLOCK_DEC * 1.35, + pan: 0.16, +}); + +// ── render ─────────────────────────────────────────────────────────────────── +const { mp3, durationSec } = renderLullaby(ev, { + name: "cometbaba", + here: HERE, + title: "cometbaba", + reverb: { wet: 0.38, decay: 0.87, damp: 0.28 }, // wide, glassy, deep-space air + fadeIn: 0.7, + fadeOut: 5.2, + tailSec: 6.0, + peak: 0.82, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/deepbaba.mjs b/pop/marimba/lullabies/variations/deepbaba.mjs new file mode 100644 index 0000000000..fa017b01dc --- /dev/null +++ b/pop/marimba/lullabies/variations/deepbaba.mjs @@ -0,0 +1,210 @@ +// deepbaba.mjs — the marimbaba lullaby sunk into the bass-marimba register, +// then developed as a single AUGMENTATION → FRAGMENTATION arc. +// +// Identity kept: F DORIAN, the bass-marimba lead (warm low "bass" voice), +// the womb ~48 BPM rocking-chair feel, and the marimbaba DNA — the hush +// descent (C5-A4-F4) and the sleep cadence still thread through. +// +// THE DEVELOPMENT: the piece is one long melodic transformation. +// I. AUGMENTATION — the hush theme stated VAST and slow: each note a deep +// low bell held many beats, a grand statement where a heartbeat lives. +// II. FRAGMENTATION — the long tune is broken into shorter, more active +// bass cells that converse: the theme's intervals get diminished into +// quicker runs, sequenced up by step, inverted, tossed call-and-response +// between a low and a slightly-higher bass voice (left/right), the +// density accelerating until the cells trill and tumble. +// III. RE-AUGMENTATION — the fragments slow and re-fuse: the sleep cadence +// returns, hugely stretched again, putting the tune back to bed. +// A rocking-chair F2->C2 sway breathes under the whole arc. +// +// Run: node variations/deepbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 48; +const BEAT = 60 / BPM; + +// ── mode remap: fold a midi to the nearest degree of F dorian ── +const ROOT = 5; // F +const DORIAN = [0, 2, 3, 5, 7, 9, 10]; +function toDorian(midi) { + const rel = ((midi - ROOT) % 12 + 12) % 12; + let best = DORIAN[0], bestD = 99; + for (const d of DORIAN) { + for (const c of [d, d - 12, d + 12]) { + const dist = Math.abs(rel - c); + if (dist < bestD) { bestD = dist; best = c; } + } + } + return midi + (best - rel); +} + +// ── motif helpers: a cell is [[noteName, beats], ...] ────────────────────── +const toMidi = (cell) => cell.map(([n, b]) => [m(n), b]); +const transpose = (cell, semis) => cell.map(([mi, b]) => [mi + semis, b]); +const diminish = (cell, f) => cell.map(([mi, b]) => [mi, b * f]); +const retrograde = (cell) => [...cell].reverse(); +function invert(cell) { + // mirror intervals around the first note's pitch. + const pivot = cell[0][0]; + return cell.map(([mi, b]) => [pivot - (mi - pivot), b]); +} + +const events = []; +const F = (cell) => cell.map(([mi, b]) => [toDorian(mi), b]); // snap to mode + +// emit a melodic cell of [midi, beats] onto the bass voice, returning end time. +function emitCell(cell, startSec, opts = {}) { + const { gain = 0.5, ring = 1.25, pan = 0, octave = 0, slur = 1.0, decayMul } = opts; + let t = startSec; + for (const [mi, beats] of cell) { + const dur = beats * BEAT; + if (mi != null) { + events.push({ + preset: "bass", + startSec: t, + midi: mi + 12 * octave, + durSec: dur * slur * ring, + gain, + decayMul: decayMul ?? 1.45, + pan, + }); + } + t += dur; + } + return t; +} + +// the marimbaba hush theme, in the low register (the seed sat -12; we go a bit +// lower still so the grand statement sits in the chest). +const HUSH = F(transpose(toMidi(MOTIFS.hush), -24)); // C3-A2-F2-F2(held) +const SLEEP = F(transpose(toMidi(MOTIFS.sleep), -24)); // settle cadence +const TWINKLE = F(transpose(toMidi(MOTIFS.twinkle), -24)); +const BABA = F(transpose(toMidi(MOTIFS.baba), -24)); + +// ════════════════════════════════════════════════════════════════════════ +// MOVEMENT I — AUGMENTATION. The hush theme stated vast: each note stretched +// ~5x, deep and slow, a grand low bell. ~0–22s +// ════════════════════════════════════════════════════════════════════════ +let t = 0; +const AUG = 5.0; // every hush note swells to five times its length +// add a low fifth shimmer under the very first note to announce the register. +events.push({ preset: "bass", startSec: 0, midi: m("F1"), durSec: 9 * BEAT, gain: 0.42, decayMul: 1.9, pan: 0 }); +events.push({ preset: "bass", startSec: 0, midi: m("C2"), durSec: 7 * BEAT, gain: 0.24, decayMul: 1.8, pan: 0.05 }); +t = emitCell(diminish(HUSH, AUG), 0, { gain: 0.6, ring: 1.35, pan: -0.04, decayMul: 1.7, slur: 0.95 }); +// a vibraphone halo under the augmented statement — Fm9 womb cushion. +for (const [note, g, pan] of [["F3", 0.11, -0.12], ["Ab3", 0.09, 0.1], ["C4", 0.09, -0.07], ["G3", 0.07, 0.13]]) { + events.push({ preset: "vibraphone", startSec: 0, midi: m(note), durSec: t, gain: g, decayMul: 1.8, pan }); +} + +// ════════════════════════════════════════════════════════════════════════ +// MOVEMENT II — FRAGMENTATION. The long tune breaks into shorter, quicker +// bass cells that converse — diminished, sequenced, inverted, tossed L/R, +// density accelerating. ~22–110s +// ════════════════════════════════════════════════════════════════════════ + +// Stage A — the hush, now at "normal" length, but split into a question (first +// two notes) and an answer (last two), tossed between low-left and higher-right +// bass voices. Sequence the pair up by step three times (F dorian climb). +const q = F([toDorian(m("C3")), toDorian(m("A2"))].map((mi) => [mi, 1])); +const a = F([toDorian(m("F2")), toDorian(m("G2"))].map((mi) => [mi, 1])); +let stageT = t + 0.5 * BEAT; +for (let s = 0; s < 4; s++) { + const up = 2 * s; // step the conversation up the mode + stageT = emitCell(transpose(q, up), stageT, { gain: 0.5, pan: -0.18, ring: 1.1, octave: 0 }); + stageT += 0.15 * BEAT; + // the answer comes back a touch brighter and to the right (a 2nd voice). + stageT = emitCell(transpose(a, up), stageT, { gain: 0.46, pan: 0.2, ring: 1.05, octave: 1 }); + stageT += 0.35 * BEAT; +} + +// Stage B — diminish the twinkle climb into a faster run and invert its return: +// the contour rises (fragmented from the tune) then mirrors back down. +const twDim = diminish(TWINKLE, 0.6); +stageT = emitCell(twDim, stageT + 0.3 * BEAT, { gain: 0.5, pan: -0.1, ring: 1.0, octave: 1 }); +stageT = emitCell(diminish(invert(TWINKLE), 0.6), stageT + 0.1 * BEAT, { gain: 0.46, pan: 0.16, ring: 0.95, octave: 1 }); + +// keep the rocking sway alive under stages A–B. +for (let bar = 0; bar < 8; bar++) { + const b0 = t + bar * 3 * BEAT; + events.push({ preset: "bass", startSec: b0, midi: m("F2"), durSec: 2.1 * BEAT, gain: 0.34, decayMul: 1.55, pan: -0.05 }); + events.push({ preset: "bass", startSec: b0 + 2 * BEAT, midi: m("C2"), durSec: 1.4 * BEAT, gain: 0.26, decayMul: 1.5, pan: 0.05 }); +} + +// Stage C — the baba "slinky" cell becomes the engine of the fastest section: +// diminished hard, sequenced down a step each pass, with a call (low-left) and +// an echo a beat later (right, octave up) — a little stretto canon. Density +// climbs as the slur tightens. +const babaDim = diminish(BABA, 0.5); +let fastT = stageT + 0.4 * BEAT; +const swayStart = fastT; +for (let pass = 0; pass < 5; pass++) { + const down = -1 * pass; // drift the canon downward + const cell = transpose(babaDim, down); + const slur = 0.95 - pass * 0.08; // tighten = more active / staccato + emitCell(cell, fastT, { gain: 0.5, pan: -0.2, ring: 0.95, octave: 0, slur }); + // the canon voice: a beat behind, octave up, to the right — a conversation. + emitCell(cell, fastT + 0.5 * BEAT, { gain: 0.34, pan: 0.22, ring: 0.85, octave: 1, slur }); + fastT += (3.8 - pass * 0.4) * BEAT; // passes crowd closer: accelerating density +} + +// trill flurry at the peak: a fast oscillation on the 5th, the most active point +// before things relax. retrograde of a tiny hush fragment, repeated quick. +const trillCell = [[toDorian(m("C3")), 0.25], [toDorian(m("D3")), 0.25]]; +let trillT = fastT - 1.0 * BEAT; +for (let i = 0; i < 10; i++) { + emitCell(trillCell, trillT, { gain: 0.3 - i * 0.005, pan: i % 2 ? 0.18 : -0.18, ring: 0.7, octave: 1, slur: 0.9 }); + trillT += 0.5 * BEAT; +} + +// rocking sway under the fast canon section, but quicker (the chair speeds up). +for (let i = 0; i < 14; i++) { + const b0 = swayStart + i * 1.5 * BEAT; + const note = i % 2 ? "C2" : "F2"; + events.push({ preset: "bass", startSec: b0, midi: m(note), durSec: 1.3 * BEAT, gain: 0.3, decayMul: 1.45, pan: i % 2 ? 0.06 : -0.06 }); +} + +// ════════════════════════════════════════════════════════════════════════ +// MOVEMENT III — RE-AUGMENTATION. Fragments slow and re-fuse: the sleep +// cadence returns hugely stretched, putting the tune back to bed. +// ════════════════════════════════════════════════════════════════════════ +let coda = trillT + 1.2 * BEAT; +// one last fragmented breath — the hush question, slowing (ritard) into the cadence. +let ritT = coda; +for (let i = 0; i < 3; i++) { + const stretch = 1.4 + i * 0.6; // each step longer: the chair coming to rest + ritT = emitCell(diminish(q, stretch), ritT, { gain: 0.42 - i * 0.03, pan: i % 2 ? 0.08 : -0.08, ring: 1.4, octave: 0, decayMul: 1.7 }); + ritT += 0.4 * BEAT; +} + +// the sleep cadence, re-augmented (~3.4x) — vast and final, mirroring Movement I. +const RE_AUG = 2.9; +const codaStart = ritT + 0.4 * BEAT; +// deep root pedal under the coda. +events.push({ preset: "bass", startSec: codaStart, midi: m("F1"), durSec: 14 * BEAT, gain: 0.4, decayMul: 1.95, pan: 0 }); +events.push({ preset: "bass", startSec: codaStart, midi: m("C2"), durSec: 9 * BEAT, gain: 0.22, decayMul: 1.85, pan: 0.05 }); +// vibraphone halo returns, bookending the opening cushion. +for (const [note, g, pan] of [["F3", 0.1, -0.1], ["Ab3", 0.08, 0.12], ["C4", 0.08, -0.06]]) { + events.push({ preset: "vibraphone", startSec: codaStart, midi: m(note), durSec: 16 * BEAT, gain: g, decayMul: 1.9, pan }); +} +const endT = emitCell(diminish(SLEEP, RE_AUG), codaStart, { gain: 0.5, ring: 1.4, pan: -0.03, decayMul: 1.8, slur: 0.98 }); + +// final sway slowing to a stop on the deepest F. +events.push({ preset: "bass", startSec: endT, midi: m("F1"), durSec: 10 * BEAT, gain: 0.36, decayMul: 2.0, pan: 0 }); + +const { mp3, durationSec } = renderLullaby(events, { + name: "deepbaba", + here: HERE, + title: "deepbaba (F dorian, bass register)", + reverb: { wet: 0.3, decay: 0.86, damp: 0.5 }, + fadeIn: 1.5, + fadeOut: 6.0, + tailSec: 6.0, + peak: 0.84, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/dorianbaba.mjs b/pop/marimba/lullabies/variations/dorianbaba.mjs new file mode 100644 index 0000000000..c4f90d141a --- /dev/null +++ b/pop/marimba/lullabies/variations/dorianbaba.mjs @@ -0,0 +1,293 @@ +// dorianbaba.mjs — a D-dorian folk ROUND on the marimbaba seed, developed as +// CANON & STRETTO. The lead (kelon) sings a wistful dorian thread drawn from +// the marimbaba motifs; a second marimba (kalimba — the humming voice) chases +// it as a strict canon, entering a bar later at the 4th/5th. Then the chase +// TIGHTENS: each return crowds the answer closer to the leader (stretto) — +// a bar, then half a bar, then a beat, then a single beat-and-a-half overlap — +// until the two voices braid into a dense contrapuntal weave. Finally the +// imitation relaxes: the answers fall away and a single line tucks the round +// in, alone, as a folk goodnight. +// +// Identity kept: D dorian (root D, raised-6th B natural = the "hope"), kelon +// lead, ~56 BPM lullaby lilt in 3, kalimba as the second/answering voice, the +// hush "descending sigh" as the recurring head of the round, and a re-rooted +// D/G/A folk bass. The MELODY now travels hard: the head is sequenced, +// inverted, augmented and diminished as it passes between the two voices. +// +// Run: node variations/dorianbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 56; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; + +// ── D dorian: root pitch-class D (2), scale {0,2,3,5,7,9,10} ───────────────── +const ROOT_PC = m("D4") % 12; // 2 +const DORIAN = [0, 2, 3, 5, 7, 9, 10]; // D E F G A B C + +// All seven scale degrees as absolute pitch classes, for snapping & for +// transposing strictly along the mode (a "diatonic" transpose, so the canon +// answer at the 4th/5th stays in D dorian rather than going chromatic). +const SCALE_PCS = DORIAN.map((d) => (ROOT_PC + d) % 12); + +// Fold a midi note to the nearest pitch in D dorian (ties resolve downward so +// the tender minor color is favored). +function snap(midi) { + let best = midi, bestD = Infinity; + for (let oct = -1; oct <= 1; oct++) { + for (const deg of DORIAN) { + const pc = (ROOT_PC + deg) % 12; + const base = Math.round((midi - pc) / 12) * 12 + pc + oct * 12; + const d = Math.abs(base - midi); + if (d < bestD - 1e-6 || (Math.abs(d - bestD) < 1e-6 && base < best)) { + best = base; bestD = d; + } + } + } + return best; +} + +// Give a midi its scale-degree index (0..6) within D dorian, plus octave, so +// we can move it by N scale steps and land on a real dorian pitch. +function degreeOf(midi) { + const snapped = snap(midi); + const pc = ((snapped % 12) + 12) % 12; + const idx = SCALE_PCS.indexOf(pc); + const oct = Math.floor((snapped - SCALE_PCS[idx]) / 12); + return { idx, oct }; +} + +// Diatonic transpose: move a midi note up/down by `steps` scale degrees, +// staying inside D dorian. steps=+3 => up a 4th, +4 => up a 5th (folk round). +function diatonic(midi, steps) { + const { idx, oct } = degreeOf(midi); + const total = idx + steps; + const o = oct + Math.floor(total / 7); + const i = ((total % 7) + 7) % 7; + return SCALE_PCS[i] + (o + 1) * 12; +} + +// ── the ROUND HEAD: a single dorian line, as [note, beats] cells ──────────── +// Built from the marimbaba DNA: the hush sigh, the twinkle climb, the baba +// wobble, the sleep settle — folded to dorian and re-strung into one singable +// subject that we can hand back and forth as a canon. ~8 beats long. +const cell = (m, b) => [m, b]; +function fold(motif) { return motif.map(([n, b]) => [snap(m(n)), b]); } + +// HEAD — the subject of the round (the thing the second voice will chase). +// hush sigh -> a little rise -> resolve. Recognizably the marimbaba hush. +const HEAD = [ + ...fold(MOTIFS.hush).slice(0, 3), // C5 A4 F4 (descending sigh) + cell(snap(m("G4")), 1), cell(snap(m("A4")), 1), // rise back up + cell(snap(m("F4")), 2), // settle +]; + +// invert intervals around the first note (mirror up/down) — a fresh contour +// from the same DNA, used when the round turns over. +function invert(line) { + const pivot = line[0][0]; + return line.map(([n, b], i) => (i === 0 ? [n, b] : [snap(2 * pivot - n), b])); +} +// retrograde — play the line backwards. +function retro(line) { return [...line].reverse(); } +// augment / diminish durations. +function scaleDur(line, k) { return line.map(([n, b]) => [n, b * k]); } +// diatonic sequence: move the whole line by N scale steps. +function seq(line, steps) { return line.map(([n, b]) => [diatonic(n, steps), b]); } +// octave displace selected notes for wild leaps. +function octave(line, k = 1) { return line.map(([n, b]) => [n + 12 * k, b]); } + +// ── event emitter for a melodic line ──────────────────────────────────────── +const events = []; +function play(line, startBeat, { preset = "kelon", gain = 0.34, pan = 0, decayMul = 1.4, durMul = 1.05, gapafter = 0 } = {}) { + let t = startBeat; + for (const [midi, beats] of line) { + events.push({ + preset, + startSec: t * BEAT, + midi, + durSec: beats * durMul * BEAT, + gain, + decayMul: (DECAY[preset] ?? 1.4) * decayMul, + pan, + }); + t += beats; + } + return t + gapafter; // returns the beat where the line ends +} + +// ── BASS: a slow D / G / A dorian folk pedal under the whole round ─────────── +function bassNote(noteName, startBeat, beats, gain = 0.46) { + events.push({ + preset: "bass", + startSec: startBeat * BEAT, + midi: m(noteName), + durSec: beats * BEAT, + gain, + decayMul: (DECAY.bass ?? 1.8) * 1.25, + pan: 0, + }); +} + +// ── soft dorian pad to seat the harmony (vibraphone_off) ──────────────────── +function pad(notes, startBeat, beats, gain = 0.12, pan = -0.2) { + for (const n of notes) { + events.push({ + preset: "vibraphone_off", + startSec: startBeat * BEAT, + midi: m(n), + durSec: beats * BEAT, + gain, + decayMul: (DECAY.vibraphone_off ?? 1.4) * 1.4, + pan, + }); + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// THE ARC — one continuous beat clock; sections measured in beats (3/bar). +// ════════════════════════════════════════════════════════════════════════════ +let B = 0; // global beat clock + +// ── SECTION 1 — the SUBJECT, alone (a simple statement) ───────────────────── +// The lead sings the round head once, unaccompanied but for the bass, so the +// ear learns the tune before the chase begins. +bassNote("D2", B, 6, 0.42); +play(HEAD, B + 0, { preset: "kelon", gain: 0.36, pan: -0.12 }); +B += 8; // head is ~8 beats + +// ── SECTION 2 — CANON at the distance of a BAR (the round opens) ──────────── +// Lead restates the head (now with a small extension); the second voice +// (kalimba) enters ONE BAR (3 beats) later at the 4th above — strict canon, +// the classic folk-round entry. We do this twice so the round circles. +function dux(line, at, opts) { return play(line, at, { preset: "kelon", gain: 0.34, pan: -0.14, ...opts }); } +function comes(line, at, steps, opts) { + // the answer: same line, diatonically transposed (4th/5th), kalimba voice. + return play(seq(line, steps), at, { preset: "kalimba", gain: 0.24, pan: 0.34, decayMul: 1.45, ...opts }); +} + +bassNote("D2", B, 9, 0.4); +bassNote("G2", B + 9, 9, 0.4); +// pass A: lead head, kalimba answer a BAR later at the 4th (+3 steps) +dux(HEAD, B); +comes(HEAD, B + 3, 3, { gain: 0.22 }); +// a sparse Dm pad glow under the round +pad(["D4", "F4", "A4"], B, 9, 0.11); +B += 9; +// pass B: lead head sequenced UP a step (the tune travels); answer at the 5th. +bassNote("A2", B, 9, 0.4); +const headUp = seq(HEAD, 1); +dux(headUp, B, { pan: -0.16 }); +comes(headUp, B + 3, 4, { gain: 0.22 }); // answer a 5th above +pad(["E4", "G4", "B4"], B, 9, 0.11); // Em-ish color, B natural = the hope +B += 9; + +// ── SECTION 3 — STRETTO: the answers crowd closer and closer ──────────────── +// Same head, but each restatement shortens the gap before the answer enters: +// a full bar -> half a bar -> a beat -> a beat-and-a-half overlap that braids +// the two voices into one shimmering weave. We also diminish (compress) the +// head a little each time so the lines accelerate — the round tightening. +function strettoPass(at, gapBeats, durMul, headLine, leadSteps, answerSteps, leadPan, ansPan) { + const lead = headLine; + dux(scaleDur(lead, durMul), at, { pan: leadPan, durMul: 1.0 }); + comes(scaleDur(lead, durMul), at + gapBeats, answerSteps, { pan: ansPan, gain: 0.23, durMul: 1.0 }); + return at; +} + +bassNote("D2", B, 6, 0.4); +// gap = a full bar (3 beats), tune travels down a step +strettoPass(B, 3, 1.0, seq(HEAD, -1), 0, 4, -0.18, 0.36); +pad(["C4", "E4", "G4"], B, 6, 0.1); +B += 6; + +bassNote("G2", B, 6, 0.4); +// gap = half a bar (1.5 beats); diminish the head to 0.8 — it speeds up +strettoPass(B, 1.5, 0.82, HEAD, 0, 3, -0.2, 0.38); +pad(["G4", "Bb4", "D5"], B, 6, 0.1); +B += 6; + +bassNote("A2", B, 6, 0.4); +// gap = one beat; invert the head so the second turn of the round mirrors +const invHead = invert(HEAD); +strettoPass(B, 1.0, 0.7, invHead, 0, 4, -0.22, 0.4); +pad(["D4", "A4", "E5"], B, 6, 0.1); +B += 5; + +bassNote("D2", B, 6, 0.4); +// gap = a beat-and-a-half overlap, but now a THIRD voice (vibraphone) joins — +// the densest braid: three entries inside two bars, the round at full weave. +const tightHead = scaleDur(HEAD, 0.6); // strongly diminished — quick run +dux(tightHead, B, { pan: -0.2, durMul: 0.95 }); +comes(tightHead, B + 1.0, 3, { pan: 0.36, gain: 0.22 }); // 4th, one beat behind +play(seq(tightHead, 4), B + 1.5, // 5th, on vibraphone + { preset: "vibraphone", gain: 0.16, pan: 0.05, decayMul: 1.2, durMul: 0.95 }); +pad(["D4", "F4", "A4", "C5"], B, 7, 0.1); +B += 6; + +// ── SECTION 4 — the WEAVE peaks: cascading canon of fragments ─────────────── +// Fragment the head to its first 3 notes (the hush sigh) and fire it as a +// quick rising sequence in close canon — bright glockenspiel sparks chasing +// the kelon, a flurry that lifts the round to its highest point before it +// relaxes. Octave-displaced for a couple of wide, surprising leaps. +const frag = HEAD.slice(0, 3); // C5 A4 F4 sigh, diminished +const sigh = scaleDur(frag, 0.5); +bassNote("D3", B, 6, 0.38); +let f = B; +for (let i = 0; i < 4; i++) { + const up = seq(sigh, i); // sequence the sigh upward + dux(i === 3 ? octave(up, 1) : up, f, { pan: -0.18 + i * 0.04, gain: 0.3 - i * 0.01 }); + // a glockenspiel spark answers a 5th up, three-quarters of a beat behind + play(seq(up, 4), f + 0.75, + { preset: "glockenspiel", gain: 0.14, pan: 0.3, decayMul: 1.3, durMul: 0.9 }); + f += 1.5; +} +pad(["G4", "B4", "D5", "A5"], B, 8, 0.11); // G(add9) — the hopeful B-natural glow +B += 8; + +// ── SECTION 5 — the round RELAXES back to a single line (resolution) ───────── +// The imitation falls away. The lead sings the head once more, augmented +// (stretched, calm), in its home register — alone now, the second voice only +// humming the final resting note an octave up, like the round closing. +bassNote("D2", B, 12, 0.4); +pad(["D4", "F4", "A4"], B, 12, 0.1); +const calm = scaleDur(HEAD, 1.25); // augmented — slow goodnight +const closeEnd = dux(calm, B, { gain: 0.32, pan: -0.06, durMul: 1.15, decayMul: 1.5 }); +// one soft kalimba answer of just the last note, an octave up — the round's +// final echo, then silence. +const lastMidi = HEAD[HEAD.length - 1][0]; +events.push({ + preset: "kalimba", + startSec: (closeEnd - 1) * BEAT, + midi: lastMidi + 12, + durSec: 3.2 * BEAT, + gain: 0.2, + decayMul: (DECAY.kalimba ?? 1.75) * 1.5, + pan: 0.3, +}); +// a low D bell, tucking in. +events.push({ + preset: "kalimba", + startSec: (closeEnd + 0.6) * BEAT, + midi: m("D4"), + durSec: 3.6 * BEAT, + gain: 0.18, + decayMul: (DECAY.kalimba ?? 1.75) * 1.55, + pan: 0.24, +}); + +const { mp3, durationSec } = renderLullaby(events, { + name: "dorianbaba", + here: HERE, + title: "dorianbaba", + reverb: { wet: 0.34, decay: 0.85, damp: 0.36 }, + fadeIn: 1.0, + fadeOut: 4.5, + tailSec: 5.0, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/driftwoodbaba.mjs b/pop/marimba/lullabies/variations/driftwoodbaba.mjs new file mode 100644 index 0000000000..a3c623c720 --- /dev/null +++ b/pop/marimba/lullabies/variations/driftwoodbaba.mjs @@ -0,0 +1,287 @@ +// driftwoodbaba.mjs — a deep, drifting riff on the marimbaba lullaby, carried +// on a slow current. +// +// Direction: F DORIAN (F G Ab Bb C D Eb), bass + gamelan in the LOW register, +// ~46 BPM. Drifting and deep, like driftwood on a slow tide — the tune does +// not repeat, it WANDERS and STRETCHES, sinking ever lower as it goes. +// +// DEVELOPMENT STRATEGY — SLOW GENERATIVE DRIFT + AUGMENTATION: +// - A single deterministic "drift" walker steps through F-dorian scale degrees +// with a seeded, weighted bias (gentle downward pull, occasional lift), so +// the melody wanders a new path every pass yet always sounds like the same +// hand. The hush sigh seeds the very first steps so the marimbaba contour is +// legible at the surface before the current takes it. +// - AUGMENTATION: each successive phrase plays SLOWER and LONGER than the last. +// A `stretch` factor grows pass over pass (1.0 → ~2.4×), so durations dilate +// like wood waterlogging — the line literally drifts toward stillness. +// - DESCENT: the walker's register center sinks pass over pass (octave drops + +// a downward step bias), so the wood travels deeper into the bass/gamelan +// register. The final cadence is the marimbaba "sleep" settle, augmented and +// transposed down two octaves — the driftwood coming to rest on the seabed. +// - The recognizable thread: opening hush sigh seeds the drift; the slinky +// "baba" wobble surfaces mid-current as a gamelan eddy; the sleep cadence +// closes it, all in F (the marimbaba home key, now its dorian sibling). +// +// Run: node variations/driftwoodbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 46; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; // 3/4, like the marimbaba + +// ── F DORIAN scale (root pc = 5). Degrees: F G Ab Bb C D Eb. ──────────────── +const ROOT = 5; // F +const DORIAN = [0, 2, 3, 5, 7, 9, 10]; +function snap(midi) { + const pc = ((midi % 12) + 12) % 12; + const rel = ((pc - ROOT) % 12 + 12) % 12; + let best = DORIAN[0], bestD = 99; + for (const s of DORIAN) { + const d = Math.min(Math.abs(s - rel), 12 - Math.abs(s - rel)); + if (d < bestD) { bestD = d; best = s; } + } + const base = midi - rel; + let cand = base + best; + if (cand - midi > 6) cand -= 12; + if (midi - cand > 6) cand += 12; + return cand; +} + +// ── degree-walk in F dorian. A note is an absolute scale "index" where 0 is +// F2 and each +1 climbs one dorian degree (so the walker thinks in steps, +// not semitones, and stays in the mode by construction). ────────────────── +const F2 = m("F2"); +function degToMidi(deg) { + const oct = Math.floor(deg / 7); + const i = ((deg % 7) + 7) % 7; + return F2 + oct * 12 + DORIAN[i]; +} + +// deterministic LCG so every drift is repeatable (driftwood follows the tide, +// not chaos). +function lcg(seed) { + let s = (seed * 2654435761) >>> 0; + return () => ((s = (s * 1103515245 + 12345) >>> 0) / 4294967296); +} + +// ── THE DRIFT WALKER ──────────────────────────────────────────────────────── +// drift(opts) returns a cell of [midi, beats] pairs. It steps through dorian +// degrees with a gentle downward bias and a stretching note-length, so the +// melody wanders, lengthens, and sinks. `seed` keys the path; `seedSteps` +// optionally forces the opening intervals (we seed it with the hush sigh). +function drift(out, { + startBar, startDeg, steps, voice = "gamelan", baseBeats = 1.0, stretch = 1.0, + downBias = 0.5, gain = 0.42, panBase = 0, decayMul = 1.7, seed = 1, + seedSteps = null, gapBeats = 0.0, +} = {}) { + const rnd = lcg(seed); + let deg = startDeg; + let beat = 0; + // step menu (in dorian degrees) and weights, biased downward by downBias. + const stepMenu = [-3, -2, -1, 0, +1, +2, +3]; + for (let i = 0; i < steps; i++) { + let step; + if (seedSteps && i < seedSteps.length) { + step = seedSteps[i]; + } else { + // weight: notes near the center, biased down by downBias (0..1). + const r = rnd(); + // build a biased pick: blend two rolls so middle steps dominate, then + // shade the result downward. + const blend = (rnd() + rnd()) / 2; // triangular, centered ~0.5 + let idx = Math.floor(blend * stepMenu.length); + idx = Math.max(0, Math.min(stepMenu.length - 1, idx)); + step = stepMenu[idx]; + if (r < downBias && step > 0) step = -step; // pull descending + } + deg += step; + // keep the wood in a sane low-mid band: gently reflect off the floor/ceiling + if (deg > startDeg + 6) deg -= 7; + if (deg < startDeg - 8) deg += 7; + + const beats = baseBeats * stretch * (0.85 + 0.3 * rnd()); // length jitter + const midi = degToMidi(deg); + // pan drifts slowly with the index — the wood sliding across the stereo tide + const pan = Math.max(-0.5, Math.min(0.5, panBase + Math.sin(i * 0.6) * 0.22)); + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi, + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1.3) * decayMul, + pan, + }); + beat += beats + gapBeats; + } + return { endBeat: beat, endDeg: deg }; // for chaining +} + +// ── soft, deep bass root under a span (kept clear and low). ────────────────── +function bass(out, startBar, name, lenBars = 2, gain = 0.4) { + out.push({ + preset: "bass", + startSec: startBar * BAR, + midi: snap(m(name)), + durSec: lenBars * BAR, + gain, + decayMul: DECAY.bass * 1.5, + pan: 0, + }); +} + +// ── low gamelan pad/eddy — a sustained dorian dyad/triad breathing under a +// span, deep in the register so it feels like underwater light. ─────────── +const padDeg = (deg, oct) => + snap(m(["F", "G", "Ab", "Bb", "C", "D", "Eb"][deg] + oct)); +function eddy(out, startBar, lenBars, triad, baseOct, gain = 0.16) { + const pans = [-0.24, 0.0, 0.24]; + for (let i = 0; i < triad.length; i++) { + out.push({ + preset: "vibraphone_off", + startSec: startBar * BAR, + midi: padDeg(triad[i], baseOct), + durSec: lenBars * BAR + BEAT, + gain, + decayMul: 2.0, + pan: pans[i] ?? 0, + }); + } +} + +// ── a single low gamelan "knock" — a struck point of light. ────────────────── +function knock(out, { startBar, beat, name, beats, gain = 0.22, pan = 0 }) { + out.push({ + preset: "gamelan", + startSec: startBar * BAR + beat * BEAT, + midi: snap(m(name)), + durSec: beats * BEAT, + gain, + decayMul: (DECAY.gamelan ?? 1.3) * 1.8, + pan, + }); +} + +const events = []; + +// ════════════════════════════════════════════════════════════════════════ +// ARC: driftwood on a slow current — each pass slower, longer, lower. +// The hush sigh seeds the drift; the current carries it down through a +// gamelan eddy of the slinky "baba"; it settles on the seabed (sleep). +// ════════════════════════════════════════════════════════════════════════ + +// hush sigh as DORIAN-degree steps, to seed the walker so the marimbaba +// contour opens the piece: C5 A4 F4 F4 → roughly degrees down a 3rd, down a +// 3rd, hold. In degree-steps from the start that reads as a descending sigh. +const HUSH_SEED = [-2, -2, 0, +1, -1]; // gentle falling open, slight lift, settle + +// ── PASS 0 (bars 0–5): the hush sigh, near the surface. stretch 1.0, the +// walker barely wandering yet — the tune as we first hear it. Mid register +// (startDeg ~ degree of C4-ish). ────────────────────────────────────────── +{ + // start around C4 in degree-space: C is dorian degree 4 (F G Ab Bb C), + // two octaves up from F2 → deg = 2*7 + 4 = 18. + drift(events, { + startBar: 0, startDeg: 18, steps: 8, voice: "gamelan", + baseBeats: 1.1, stretch: 1.0, downBias: 0.45, gain: 0.44, + decayMul: 1.7, seed: 3, seedSteps: HUSH_SEED, gapBeats: 0.15, + }); + bass(events, 0, "F2", 3, 0.42); + bass(events, 3, "F2", 3, 0.42); + eddy(events, 0, 6, [0, 2, 4], 3, 0.15); // F-Ab-C low pad + knock(events, { startBar: 2, beat: 1.5, name: "C4", beats: 2, gain: 0.16, pan: 0.28 }); +} + +// ── PASS 1 (bars 6–12): the current takes it. stretch ~1.3, the walker +// wanders freely now, register center dropped a step. A little deeper, a +// little slower. ─────────────────────────────────────────────────────────── +{ + drift(events, { + startBar: 6, startDeg: 16, steps: 9, voice: "gamelan", + baseBeats: 1.1, stretch: 1.35, downBias: 0.55, gain: 0.42, + decayMul: 1.8, seed: 11, gapBeats: 0.2, + }); + bass(events, 6, "F2", 3, 0.38); + bass(events, 9, "Bb2", 3, 0.38); // subdominant drift (dorian IV is major) + eddy(events, 6, 7, [3, 5, 0], 3, 0.14); // Bb-D-F + knock(events, { startBar: 7, beat: 2.0, name: "Bb3", beats: 2.5, gain: 0.15, pan: -0.26 }); + knock(events, { startBar: 11, beat: 0.5, name: "D4", beats: 2, gain: 0.14, pan: 0.3 }); +} + +// ── PASS 2 (bars 13–20): MID-CURRENT EDDY — the slinky "baba" wobble surfaces, +// now in the low gamelan register and augmented (slow). The recognizable +// thread, made deep and unhurried. stretch ~1.7. Register sinking further. ─ +{ + // seed the walker with the baba contour as degree-steps (its up-down wobble). + // baba is roughly: A5 G5 A5 F5 C6 Bb5 C6 A5 → up/down small steps. + const BABA_SEED = [-1, +1, -2, +4, -1, +1, -2, -1]; + drift(events, { + startBar: 13, startDeg: 13, steps: 9, voice: "gamelan", + baseBeats: 1.0, stretch: 1.7, downBias: 0.5, gain: 0.4, + decayMul: 1.9, seed: 23, seedSteps: BABA_SEED, gapBeats: 0.25, + }); + bass(events, 13, "Ab2", 3, 0.36); + bass(events, 16, "Eb2", 3, 0.36); // bVI region — deep dorian colour + bass(events, 19, "F2", 3, 0.36); + eddy(events, 13, 4, [2, 4, 6], 3, 0.15); // Ab-C-Eb + eddy(events, 17, 4, [0, 2, 4], 3, 0.14); // back toward F + knock(events, { startBar: 14, beat: 1.0, name: "Eb4", beats: 3, gain: 0.14, pan: -0.3 }); + knock(events, { startBar: 18, beat: 1.5, name: "Ab3", beats: 3, gain: 0.13, pan: 0.28 }); +} + +// ── PASS 3 (bars 21–28): the slowest, deepest WANDER — stretch ~2.1, the wood +// waterlogged, drifting in long low tones. Register dropped a whole octave +// in spirit (startDeg low), the walker barely moving, sinking. ───────────── +{ + drift(events, { + startBar: 21, startDeg: 9, steps: 8, voice: "gamelan", + baseBeats: 1.0, stretch: 2.1, downBias: 0.62, gain: 0.4, + decayMul: 2.0, seed: 37, gapBeats: 0.35, + }); + bass(events, 21, "F2", 3, 0.36); + bass(events, 24, "Eb2", 3, 0.34); + bass(events, 27, "F2", 3, 0.34); + eddy(events, 21, 8, [0, 2, 4], 2, 0.15); // very low F pad — the seabed glow + knock(events, { startBar: 23, beat: 1.0, name: "C3", beats: 4, gain: 0.13, pan: 0.24 }); +} + +// ── PASS 4 (bars 29–36): SEABED — the marimbaba "sleep" cadence, augmented +// (stretch ~2.4) and transposed down two octaves, played near-flat. The +// driftwood comes to rest. The recognizable settle, very deep, very slow. ── +{ + // sleep motif: C5 A4 G4 F4 F4 F4 → settling descent. Lay it as degree-steps, + // way down low, with long augmented durations and almost no wandering. + const SLEEP_SEED = [-2, -1, -1, 0, 0, -1]; // descend and rest + drift(events, { + startBar: 29, startDeg: 8, steps: 7, voice: "gamelan", + baseBeats: 1.0, stretch: 2.4, downBias: 0.7, gain: 0.42, + decayMul: 2.2, seed: 5, seedSteps: SLEEP_SEED, gapBeats: 0.4, + }); + // a final hush echo, the lowest of all — wood touching bottom. + bass(events, 29, "F2", 3, 0.36); + bass(events, 32, "F2", 3, 0.34); + bass(events, 35, "F2", 3, 0.34); + eddy(events, 29, 8, [0, 2, 4], 2, 0.16); // long resolving low F + knock(events, { startBar: 31, beat: 1.0, name: "F3", beats: 5, gain: 0.13, pan: -0.22 }); + knock(events, { startBar: 34, beat: 0.5, name: "Ab2", beats: 6, gain: 0.12, pan: 0.2 }); + knock(events, { startBar: 35, beat: 1.5, name: "F2", beats: 5, gain: 0.12, pan: 0 }); +} + +const { mp3, durationSec } = renderLullaby(events, { + name: "driftwoodbaba", + here: HERE, + title: "driftwoodbaba", + reverb: { wet: 0.44, decay: 0.9, damp: 0.3 }, // deep, wide underwater room + fadeIn: 2.0, + fadeOut: 6.0, + tailSec: 5.0, + peak: 0.84, + healingHz: 396, // Solfeggio UT, low + grounding — suits the deep dorian +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/flybaba.mjs b/pop/marimba/lullabies/variations/flybaba.mjs new file mode 100644 index 0000000000..61ad6401b5 --- /dev/null +++ b/pop/marimba/lullabies/variations/flybaba.mjs @@ -0,0 +1,225 @@ +// flybaba.mjs — "lately, when i fly" — the butterfly motif lifts the whole +// piece up and away by literally FLYING THROUGH KEYS. +// +// The marimbaba seed has a butterfly phrase tucked into bars 8-9 (the flyHigh +// MOTIF: Bb5 D6 Bb5 C6 A5 — "way up high"). flybaba pulls that motif out front +// and makes it the LEAD, then develops it with CONTINUOUS UPWARD MODULATION — +// a chain of truck-driver key changes. The butterfly tune is restated again +// and again, each restatement bumped up into a NEW KEY (a whole step, then a +// minor third, then a step…) so it climbs an ecstatic ladder: F → G → Bb → +// C → D → F, the register and brightness rising with every lift. The whole +// sky-bed (vibraphone pad + bass root) modulates with it, so each key feels +// like a fresh altitude. At the very top the tune trills and shivers in the +// glockenspiel's brightest air; then one long, calm glide carries it all the +// way back down to ground-level F, where the marimbaba hush sigh sets it to +// sleep — closing the frame it opened with. +// +// We keep marimbaba's DNA: the descending hush sigh opens & closes it, the +// twinkle wave answers, the flyHigh contour is the recurring thread — but the +// melody now TRAVELS through six keys instead of restating in place. Voicing: +// kalimba + glockenspiel lead over a vibraphone sky and a clear bass, ~56 BPM, +// F major home, floating-away mood intact. +// +// Run: node variations/flybaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 56; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; // 3/4, like the seed + +const events = []; +const push = (preset, startSec, midi, durSec, gain, decayMul, pan) => + events.push({ preset, startSec, midi, durSec, gain, decayMul, pan }); +const at = (bar, beat) => bar * BAR + beat * BEAT; + +// ── tiny note-cell toolkit (cells are [noteName, beats]) ──────────────────── +const TR = (cell, semis) => + cell.map(([n, b]) => [m(n) + semis, b]); // transpose into midi numbers +const RETRO = (cell) => [...cell].reverse(); +const AUG = (cell, k) => cell.map(([n, b]) => [n, b * k]); + +// Play a cell (already in midi) starting at bar/beat, return the bar AFTER it. +function play(preset, cell, startBar, startBeat, gain, decayMul, panBase, panSpread = 0.18) { + let bar = startBar, beat = startBeat; + const total = cell.reduce((s, [, b]) => s + b, 0); + let acc = 0; + for (const [midi, beats] of cell) { + const pan = panBase + (acc / Math.max(total, 1)) * panSpread; + push(preset, at(bar, beat), midi, beats * BEAT, gain, decayMul, pan); + beat += beats; acc += beats; + while (beat >= 3) { beat -= 3; bar += 1; } + } + return bar + (beat > 0 ? 1 : 0); +} + +// ── the butterfly LEAD cell — flyHigh re-contoured to climb (low→high) ────── +// flyHigh = [Bb5,D6,Bb5,C6,A5]; re-shaped as a hopeful ASCENT that ends on a +// ringing leap. This is the recurring thread carried through every key. +const FLY = [ + ["A5", 1.5], + ["C6", 1.5], + ["D6", 1], + ["C6", 1], + ["F6", 3], // the leap "up high", left to ring +]; +// a compact answer tail (the twinkle wave, diminished) to round each key off. +const TAIL = [["G6", 0.5], ["F6", 0.5], ["E6", 0.5], ["D6", 0.5], ["C6", 1]]; + +// ── soft vibraphone sky: an open major triad on the CURRENT key root ──────── +function sky(startBar, bars, rootMidi, gain, bright = 0) { + // root, third(+4), fifth(+7), plus a top octave color when bright + const tones = [rootMidi, rootMidi + 4, rootMidi + 7]; + if (bright) tones.push(rootMidi + 12); + for (let i = 0; i < tones.length; i++) { + push("vibraphone_off", at(startBar, 0), tones[i], bars * BAR, gain, + DECAY.vibraphone_off * 1.3, i === tones.length - 1 ? -0.2 : 0.16); + } +} + +// ── clear, sensible bass: the key root, low ───────────────────────────────── +function bass(bar, rootMidi, gain = 0.4) { + push("bass", at(bar, 0), rootMidi, 3 * BEAT, gain, DECAY.bass, 0); +} + +// ── the hush sigh — marimbaba's "hush-hush" — frames the flights ──────────── +function hush(startBar, transpose, gain, pan, stretch = 1.0) { + let bar = startBar, beat = 0; + for (const [note, beats] of MOTIFS.hush) { + push("kalimba", at(bar, beat), m(note) + transpose, beats * stretch * BEAT, + gain, DECAY.kalimba * 1.4, pan); + beat += beats; + while (beat >= 3) { beat -= 3; bar += 1; } + } +} + +// a quick ascending grace flurry (the lift between keys — the wing-beat) +function flurry(startBar, startBeat, fromMidi, steps, gain) { + // chromatic-ish run up a perfect fifth into the next key's root area + const ivals = [0, 2, 4, 5, 7]; // a little major run + let beat = startBeat, bar = startBar; + for (let i = 0; i < steps; i++) { + push("glockenspiel", at(bar, beat), fromMidi + ivals[i % ivals.length] + Math.floor(i / ivals.length) * 12, + 0.18 * BEAT, gain, DECAY.kalimba, 0.12 + i * 0.02); + beat += 0.25; + while (beat >= 3) { beat -= 3; bar += 1; } + } +} + +// ═══════════════════════════ ARRANGE ════════════════════════════════════════ +// The KEY LADDER. Each entry = { name, root (bass midi), shift (semitones to +// transpose FLY/TAIL from F-major home), bright, preset }. The butterfly tune +// climbs F → G → Bb → C → D, then glides home to F. shift is measured from +// the home key F (the FLY cell as written sits in F major already). +const HOME = m("F2"); // bass home root + +// ── 0) GROUND LEVEL: low hush sigh + open F sky (bars 0-3) ────────────────── +hush(0, -12, 0.34, -0.12, 1.2); +sky(0, 4, m("F3"), 0.15); +bass(0, HOME); bass(2, HOME); + +// ── 1) FIRST FLIGHT — key of F (home). kalimba, soft. (bars 4-8) ──────────── +let bar = 4; +play("kalimba", TR(FLY, 0), bar, 0, 0.34, DECAY.kalimba * 1.5, -0.14); +sky(4, 4, m("F3"), 0.15); +bass(4, HOME); bass(6, HOME); +// answer: the twinkle wave (mid-piece reply), in F +play("vibraphone", TR(TAIL, -7), 7, 0, 0.30, DECAY.vibraphone * 1.3, 0.22, 0.1); + +// ── wing-beat lift up to G ────────────────────────────────────────────────── +flurry(8, 1.5, m("E5"), 6, 0.22); + +// ── 2) SECOND FLIGHT — MODULATE UP A WHOLE STEP to G major (+2). (bars 9-13) ─ +play("kalimba", TR(FLY, 2), 9, 0, 0.35, DECAY.kalimba * 1.45, -0.04, 0.2); +sky(9, 4, m("G3"), 0.15, 0); // sky lifts to G +bass(9, m("G2")); bass(11, m("D3")); +play("vibraphone", TR(TAIL, -5), 12, 0, 0.30, DECAY.vibraphone * 1.3, 0.2, 0.12); + +// ── wing-beat lift up toward Bb (a minor third up) ────────────────────────── +flurry(13, 1.5, m("G5"), 7, 0.24); + +// ── 3) THIRD FLIGHT — MODULATE UP A MINOR THIRD to Bb major (+5). (bars 14-18) +play("kalimba", TR(FLY, 5), 14, 0, 0.36, DECAY.kalimba * 1.4, 0.02, 0.22); +sky(14, 4, m("Bb3"), 0.15, 1); // brighter sky, top color +bass(14, m("Bb2")); bass(16, m("F3")); +// a high hush echo drifting far right as it keeps rising +hush(17, 0, 0.22, 0.32, 1.3); + +// ── wing-beat lift toward C ───────────────────────────────────────────────── +flurry(18, 1.5, m("Bb5"), 6, 0.25); + +// ── 4) FOURTH FLIGHT — MODULATE UP A STEP to C major (+7). glockenspiel takes +// the lead here — the air gets bright and glassy. (bars 19-23) ────────── +play("glockenspiel", TR(FLY, 7), 19, 0, 0.30, DECAY.kalimba * 1.3, 0.06, 0.24); +sky(19, 4, m("C4"), 0.13, 1); +bass(19, m("C2")); bass(21, m("G2")); +play("vibraphone", TR(TAIL, 0), 22, 0, 0.28, DECAY.vibraphone * 1.3, 0.2, 0.14); + +// ── wing-beat lift to the summit, D ───────────────────────────────────────── +flurry(23, 1.5, m("C6"), 6, 0.26); + +// ── 5) SUMMIT FLIGHT — MODULATE UP A STEP to D major (+9). highest, widest, +// ecstatic. glockenspiel, plus a shimmering trill at the peak. (bars 24-28) +play("glockenspiel", TR(FLY, 9), 24, 0, 0.30, DECAY.kalimba * 1.25, 0.12, 0.26); +sky(24, 5, m("D4"), 0.12, 1); +bass(24, m("D2")); bass(26, m("A2")); +// the peak trill — a fast oscillation on the top F#/A, the butterfly hovering +{ + let t = at(27, 0); + const top = [m("A6"), m("F#6")]; + for (let i = 0; i < 8; i++) { + push("glockenspiel", t, top[i % 2], 0.16, 0.22, DECAY.kalimba, 0.18); + t += 0.16; + } +} + +// ── 6) THE LONG GLIDE BACK TO EARTH — one slow descending arc that steps the +// keys back down D → C → Bb → G → F, augmented (stretched) so it feels +// like a calm settling spiral. We use the FLY contour in RETROGRADE + +// AUGMENTED, sinking through the ladder, register dropping each step. +// (bars 29-39) ────────────────────────────────────────────────────────── +const glideKeys = [ + { shift: 9, root: m("D3"), oct: 0 }, // D + { shift: 7, root: m("C3"), oct: -2 }, // C + { shift: 5, root: m("Bb2"), oct: -4 }, // Bb + { shift: 2, root: m("G2"), oct: -7 }, // G +]; +let gb = 29; +const GLIDE = AUG(RETRO(FLY), 1.4); // backwards + stretched = a sinking sigh +for (const k of glideKeys) { + const cell = TR(GLIDE, k.shift + k.oct).map(([mi, b]) => [mi, b]); + play("vibraphone", cell, gb, 0, 0.26, DECAY.vibraphone * 1.4, 0.0, 0.12); + sky(gb, 2, k.root, 0.12, 0); + bass(gb, k.root - 12); + gb += 2; +} + +// ── 7) HOME / GROUND: the hush sigh returns low in F to set it down gently, +// and one last high F left ringing off into the sky. (bars 37-41) ─────── +const homeBar = gb; // ~37 +hush(homeBar, -12, 0.30, -0.1, 1.4); +sky(homeBar, 3, m("F3"), 0.13); +bass(homeBar, HOME); bass(homeBar + 2, HOME); +// a final sleep-settle of marimbaba's closing descent, low and slow +play("kalimba", TR(MOTIFS.sleep, -12), homeBar, 0, 0.0, DECAY.kalimba, 0); // (silent placeholder removed below) +events.pop(); // drop the placeholder +play("kalimba", AUG(MOTIFS.sleep.map(([n, b]) => [m(n) - 12, b]), 1.2), homeBar + 3, 0, 0.24, DECAY.kalimba * 1.4, -0.08, 0.1); +// last butterfly note, high F, drifting away +push("kalimba", at(homeBar + 6, 0), m("F6"), 4.0 * BEAT, 0.20, DECAY.kalimba * 1.7, 0.28); + +const { mp3, durationSec } = renderLullaby(events, { + name: "flybaba", + here: HERE, + title: "flybaba", + reverb: { wet: 0.42, decay: 0.9, damp: 0.3 }, // big airy sky for the lift + fadeIn: 1.0, + fadeOut: 5.5, + tailSec: 6.0, + peak: 0.84, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/gamelanbaba.mjs b/pop/marimba/lullabies/variations/gamelanbaba.mjs new file mode 100644 index 0000000000..505babdb67 --- /dev/null +++ b/pop/marimba/lullabies/variations/gamelanbaba.mjs @@ -0,0 +1,296 @@ +// gamelanbaba.mjs — INTERLOCKING KOTEKAN development of the marimbaba seed. +// +// IDENTITY (kept): F whole-tone shimmer {0,2,4,6,8,10}, no leading tone, a +// floating slendro-tinted haze; bronze `gamelan` + `glockenspiel` halo; ~54 BPM; +// the long F bass anchors the dream; the held "wow" gong-wobble is still the +// metallic heart and the cadence still settles to F. +// +// DEVELOPMENT — kotekan (polos + sangsih). The marimbaba contour is reborn as +// ONE fast composite line that NO single voice plays: a bronze `gamelan` part +// (polos) plays one subset of the pulse and a `glockenspiel` part (sangsih) +// plays the complementary off-pulse, the two imbricated notes braiding into a +// glittering machine. Across the piece the interlock DENSIFIES (quarter → +// eighth → triplet → sixteenth nyog-cag) and the FIGURATION SHIFTS (which voice +// takes the on/off beats flips; the cell inverts and is sequenced up the +// whole-tone ladder), building simple statement → escalating glitter machine → +// resolution back to the slow wow-gong haze. +// +// Run: node variations/gamelanbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 54; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; + +// ── F whole-tone: root pitch-class F (5), scale {0,2,4,6,8,10} ─────────────── +const ROOT_PC = m("F4") % 12; // 5 +const WHOLETONE = [0, 2, 4, 6, 8, 10]; + +// Fold a midi note to the nearest whole-tone pitch (ties resolve upward → bright). +function snap(midi, rootPc = ROOT_PC, scale = WHOLETONE) { + let best = midi, bestD = Infinity; + for (let oct = -1; oct <= 1; oct++) { + for (const deg of scale) { + const pc = (rootPc + deg) % 12; + const base = Math.round((midi - pc) / 12) * 12 + pc + oct * 12; + const d = Math.abs(base - midi); + if (d < bestD - 1e-6 || (Math.abs(d - bestD) < 1e-6 && base > best)) { + best = base; bestD = d; + } + } + } + return best; +} + +// ── whole-tone ladder helpers (everything lives on the 6-step grid) ────────── +// Build the absolute midi for a ladder index relative to F4 (=65). +// Index 0 = F4; each step is one whole tone; 6 steps = an octave. +const F4 = m("F4"); // 65 +function lad(idx) { + const oct = Math.floor(idx / 6); + const deg = ((idx % 6) + 6) % 6; + return F4 + WHOLETONE[deg] + 12 * oct; +} +// invert a ladder index around a pivot (mirror the contour on the same grid). +const invert = (idx, pivot) => 2 * pivot - idx; + +const events = []; +const push = (e) => events.push(e); + +// per-voice ring + warmth tuned for bronze haze. +const GMUL = 0.9; + +// ════════════════════════════════════════════════════════════════════════════ +// KOTEKAN ENGINE +// Given a melodic CONTOUR as ladder indices (one per composite pulse), split it +// into polos (on-pulse) + sangsih (off-pulse) by alternating which physical +// voice strikes which pulse. `flip` swaps who takes the on/off beats. The two +// voices together sound every pulse — the interlock — but each alone is gapped. +// ════════════════════════════════════════════════════════════════════════════ +function kotekan(contour, opts) { + const { + startBar, pulse, // pulse = beats per composite note (density) + polos = "gamelan", sangsih = "glockenspiel", + polosOct = 0, sangsihOct = 1, + gainP = 0.32, gainS = 0.22, + flip = false, // swap which voice gets even/odd pulses + panP = -0.18, panS = 0.3, + ring = 1.35, // duration as a multiple of pulse (overlap = haze) + pivot = null, // if set, sangsih mirrors polos around this index + } = opts; + let t = startBar * BAR; + contour.forEach((idx, i) => { + const onPolos = flip ? (i % 2 === 1) : (i % 2 === 0); + const voice = onPolos ? polos : sangsih; + const oct = onPolos ? polosOct : sangsihOct; + const gain = (onPolos ? gainP : gainS) * GMUL; + const pan = onPolos ? panP : panS; + // sangsih can take the mirror pitch (kotekan parts often answer in mirror). + let useIdx = idx; + if (!onPolos && pivot != null) useIdx = invert(idx, pivot); + push({ + preset: voice, + startSec: t, + midi: lad(useIdx) + 12 * oct, + durSec: pulse * BEAT * ring, + gain, + decayMul: (DECAY[voice] ?? 1.3) * 1.5, + pan, + }); + t += pulse * BEAT; + }); + return t / BAR; // end bar (float) +} + +// ── the marimbaba "thread" rendered as a ladder contour ────────────────────── +// hush sigh C5 A4 F4 → ladder ~ [ +? ]. Map the seed pitches onto the grid so +// the contour is RECOGNIZABLE (descending sigh, climbing twinkle, the cadence). +// We compute ladder indices by snapping the seed midi then measuring grid steps. +function toLad(noteName) { + const sn = snap(m(noteName)); + // nearest ladder index to sn + let best = 0, bestD = Infinity; + for (let k = -12; k <= 18; k++) { + const d = Math.abs(lad(k) - sn); + if (d < bestD) { bestD = d; best = k; } + } + return best; +} + +// Seed contours (ladder indices) drawn from the marimbaba motifs ---------------- +const HUSH = ["C5", "A4", "F4", "G4", "F4"].map(toLad); // descending sigh +const TWINKLE = ["F5", "A5", "C6", "A5", "G5", "F5", "G5", "A5"].map(toLad); // climbing wave +const BABA = ["A5", "G5", "A5", "F5", "C6", "Bb5", "C6", "A5"].map(toLad); // slinky +const SLEEP = ["C5", "A4", "G4", "F4", "F4"].map(toLad); // settle + +// expand a short contour to a steady stream of `n` pulses by looping + walking +function stream(seed, n) { + const out = []; + for (let i = 0; i < n; i++) out.push(seed[i % seed.length]); + return out; +} +// sequence a contour up the ladder by `step` whole-tones each repeat +function sequence(seed, reps, step) { + const out = []; + for (let r = 0; r < reps; r++) for (const x of seed) out.push(x + step * r); + return out; +} + +// ════════════════════════════════════════════════════════════════════════════ +// FORM — an arc of densifying, shifting interlock. +// ════════════════════════════════════════════════════════════════════════════ + +// helper: a low bronze gong anchor under a region (keeps the dream grounded) +function gong(bar, note, beats, gain = 0.34, pan = 0) { + push({ + preset: "gamelan", + startSec: bar * BAR, + midi: snap(m(note)), + durSec: beats * BEAT, + gain: gain * GMUL, + decayMul: (DECAY.gamelan ?? 1.3) * 2.0, + pan, + }); +} +// helper: whole-tone bronze pad (vibraphone_off), wide + slow, under a region. +function pad(bar, notes, beats, gain = 0.12, pan = -0.22) { + for (const n of notes) { + push({ + preset: "vibraphone_off", + startSec: bar * BAR, + midi: snap(m(n)), + durSec: beats * BEAT, + gain: gain * GMUL, + decayMul: (DECAY.vibraphone_off ?? 1.4) * 1.6, + pan, + }); + } +} + +// ── SECTION A (bars 0-5): SPARSE STATEMENT ─────────────────────────────────── +// quarter-pulse kotekan of the hush sigh — slow, just the two voices kissing +// the on/off beats so you HEAR the interlock being born. Bronze pad bed below. +pad(0, ["F4", "A4", "B4"], 18, 0.12); +gong(0, "F2", 18, 0.3); +kotekan(stream(HUSH, 18), { + startBar: 0, pulse: 1.0, // quarter-note interlock (slow) + gainP: 0.30, gainS: 0.18, ring: 1.6, +}); + +// ── SECTION B (bars 6-11): EIGHTH-NOTE INTERLOCK, twinkle climbs ───────────── +// density doubles to eighth-pulses; the climbing twinkle contour is sequenced +// UP the whole-tone ladder (+1 step each pass) so the machine spirals brighter. +pad(6, ["F4", "A4", "C5", "E5"], 18, 0.13, 0.2); +gong(6, "F3", 18, 0.26); +gong(9, "A2", 9, 0.22, -0.2); +{ + const twk = sequence(TWINKLE, 3, 1); // climb the ladder, anhemitonic spiral + kotekan(twk, { + startBar: 6, pulse: 0.5, // eighth-note interlock + gainP: 0.28, gainS: 0.2, ring: 1.4, + panP: -0.22, panS: 0.34, + }); +} + +// ── SECTION C (bars 12-15): THE WOW GONG-WOBBLE returns (slow center) ──────── +// breathe: foreground the held "wow" wobble as slow bronze dyads — the metallic +// heart — answered by a glockenspiel mirror a beat behind (call/response). +const WOW = MOTIFS.wow; // [G5,Bb5,A5,G5,A5,F5] +{ + let bar = 12, beat = 0; + for (const [note, beats] of WOW) { + const lead = snap(m(note)); + push({ + preset: "gamelan", startSec: bar * BAR + beat * BEAT, + midi: lead, durSec: beats * 1.3 * BEAT, gain: 0.4 * GMUL, + decayMul: (DECAY.gamelan ?? 1.3) * 1.8, pan: -0.1, + }); + // augmented-color bronze third below + push({ + preset: "gamelan", startSec: bar * BAR + beat * BEAT, + midi: lead - 4, durSec: beats * 1.3 * BEAT, gain: 0.22 * GMUL, + decayMul: (DECAY.gamelan ?? 1.3) * 1.8, pan: -0.1, + }); + // glockenspiel mirror answer, a hair behind, octave up + push({ + preset: "glockenspiel", startSec: bar * BAR + beat * BEAT + 0.5 * BEAT, + midi: lead + 12, durSec: beats * 1.1 * BEAT, gain: 0.18 * GMUL, + decayMul: 2.0, pan: 0.34, + }); + beat += beats; + while (beat >= 3) { beat -= 3; bar += 1; } + } + gong(12, "F2", 12, 0.3); +} + +// ── SECTION D (bars 16-21): NYOG-CAG MACHINE — triplet → sixteenth glitter ─── +// the climax: the interlock densifies to triplets then sixteenths and the +// FIGURATION FLIPS (sangsih now takes the on-beat). The baba slinky contour is +// inverted (mirror) and braided so the composite line is a fast glittering line +// neither bronze nor bell plays alone. +pad(16, ["G4", "B4", "D5"], 18, 0.12, 0.22); +gong(16, "Eb2", 9, 0.28); +gong(18, "C3", 9, 0.26, 0.18); +gong(20, "F2", 9, 0.3); +{ + // triplet braid of the baba contour (bars 16-17) + const baba3 = stream(BABA, 18); + kotekan(baba3, { + startBar: 16, pulse: 1 / 3, // triplet eighths + gainP: 0.24, gainS: 0.18, ring: 1.2, + flip: true, // figuration shift: bell takes the on-beat + pivot: BABA[0] + 4, // sangsih answers in whole-tone mirror + }); +} +{ + // sixteenth nyog-cag glitter, inverted + sequenced (bars 18-21) — the machine + const pivotIdx = BABA[0] + 2; + const invBaba = BABA.map((x) => invert(x, pivotIdx)); + const climax = sequence(invBaba, 4, -1).concat(sequence(BABA, 4, 1)); + kotekan(climax, { + startBar: 18, pulse: 0.25, // sixteenth-note interlock — full glitter + gainP: 0.2, gainS: 0.17, ring: 1.1, + polosOct: 0, sangsihOct: 1, + panP: -0.26, panS: 0.34, + }); +} + +// ── SECTION E (bars 22-27): RESOLUTION — interlock thins back to a sigh ─────── +// the machine decelerates: eighth → quarter → half, the SLEEP settle contour +// folding the two voices back into single tones, returning to the slow haze. +pad(22, ["F4", "A4", "C5"], 18, 0.13); +gong(22, "F2", 12, 0.3); +{ + // eighth, then quarter, then the cadence — a written ritardando in density + kotekan(stream(SLEEP, 8), { startBar: 22, pulse: 0.5, gainP: 0.24, gainS: 0.18, ring: 1.5 }); + kotekan(stream(SLEEP, 6), { startBar: 24, pulse: 1.0, gainP: 0.26, gainS: 0.18, ring: 1.6 }); +} + +// final cadence: a low bronze gong tonic + a far high wind-bell shimmer. +gong(26, "F2", 8, 0.36); +push({ + preset: "glockenspiel", + startSec: 26 * BAR + 0.5 * BEAT, + midi: snap(m("F6")), + durSec: 5 * BEAT, gain: 0.16 * GMUL, decayMul: 2.4, pan: 0.3, +}); +// a last polos/sangsih kiss on the tonic — the machine exhaling. +push({ preset: "gamelan", startSec: 26 * BAR, midi: snap(m("F4")), durSec: 6 * BEAT, gain: 0.3 * GMUL, decayMul: 2.2, pan: -0.15 }); +push({ preset: "glockenspiel", startSec: 26 * BAR + 0.75 * BEAT, midi: snap(m("C6")), durSec: 4 * BEAT, gain: 0.14 * GMUL, decayMul: 2.4, pan: 0.3 }); + +const { mp3, durationSec } = renderLullaby(events, { + name: "gamelanbaba", + here: HERE, + title: "gamelanbaba", + reverb: { wet: 0.42, decay: 0.88, damp: 0.3 }, // long glassy bronze room + fadeIn: 1.2, + fadeOut: 5.0, + tailSec: 6.0, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/hushabye.mjs b/pop/marimba/lullabies/variations/hushabye.mjs new file mode 100644 index 0000000000..bd3948743f --- /dev/null +++ b/pop/marimba/lullabies/variations/hushabye.mjs @@ -0,0 +1,327 @@ +// hushabye.mjs — the most classic, simple lullaby, given a real set of +// gentle THEME & VARIATIONS on a hushabye cadence. +// +// Direction: C major, rosewood, ~54 BPM, 3/4. Tender and timeless. Where +// moonbaba scatters the tune across the registers, hushabye does the +// opposite — it states one plain, singable hushabye theme and then keeps it +// in plain sight while dressing it five different ways, the way an old +// music-box turns the same little tune over and over until a child is asleep. +// +// DEVELOPMENT STRATEGY — THEME & VARIATIONS (classical): +// THEME (plain) the bare hushabye cadence, one voice, lots of air +// VAR I (decorated) same tune, ornamented — passing tones, grace +// neighbors, the line filled in like a music box +// VAR II (bass-takes-tune) the melody migrates DOWN into the bass marimba +// while a soft glock keeps the high frame +// VAR III (sparkle finale) the tune in canon — rosewood lead answered a +// bar later by a glockenspiel sparkle echo, the +// brightest, most awake pass before sleep +// VAR IV (hushed statement) the plainest pass of all, even barer than the +// theme, half-tempo feel, settling to a held C — +// the final goodnight +// +// The hushabye DNA = a recognizable thread kept in EVERY pass: the opening +// descending sigh (taken from the marimbaba "hush" motif, re-keyed to C) and +// the falling 3-2-1 (E-D-C) hushabye cadence that ends each variation. +// +// Run: node variations/hushabye.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 56; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; // 3/4 + +// ── C major scale-snap (root pc = 0). Keep every voice in the mode. ────────── +const ROOT = 0; // C +const MAJOR = [0, 2, 4, 5, 7, 9, 11]; +function snap(midi) { + const pc = ((midi % 12) + 12) % 12; + const rel = ((pc - ROOT) % 12 + 12) % 12; + let best = MAJOR[0], bestD = 99; + for (const s of MAJOR) { + const d = Math.min(Math.abs(s - rel), 12 - Math.abs(s - rel)); + if (d < bestD) { bestD = d; best = s; } + } + const base = midi - rel; + let cand = base + best; + if (cand - midi > 6) cand -= 12; + if (midi - cand > 6) cand += 12; + return cand; +} + +const LEAD = "rosewood"; + +// ── THE HUSHABYE THEME ────────────────────────────────────────────────────── +// A plain, singable cradle tune in C major, 3/4. It opens with the marimbaba +// "hush" descending sigh (re-keyed F→C, i.e. transpose +7), then rocks gently +// and lands on the 3-2-1 (E-D-C) hushabye cadence. Cells are [midi, beats]. +// +// We keep it as named PHRASES so every variation can quote/decorate exactly +// the same line and the listener always hears "the same tune again." +const cell = (motif, semis = 7) => + MOTIFS[motif].map(([name, beats]) => [snap(m(name) + semis), beats]); + +// hush sigh, re-keyed into C (G5-E5-C5, C5-held) — the recognizable opener. +const HUSH = cell("hush"); // ["C5"+7=G5, "A4"+7=E5, "F4"+7=C5, "C5" held] + +// the rocking middle: a small up-and-back wave, then the cradle dip. +const ROCK = [ + ["G4", 1], ["A4", 1], ["G4", 1], // bar a: rock up and back + ["E4", 1.5], ["G4", 1.5], // bar b: gentle lift + ["A4", 1], ["G4", 1], ["E4", 1], // bar c: rock back down + ["D4", 3], // bar d: rest on the 2 +].map(([n, b]) => [snap(m(n)), b]); + +// the hushabye cadence: the falling 3-2-1 that closes every pass (E-D-C), +// preceded by a little neighbor so it sings "hush-a-bye, good-night". +const CADENCE = [ + ["E4", 1], ["F4", 1], ["E4", 1], // hush-a- + ["D4", 1.5], ["E4", 1.5], // -bye, + ["E4", 1], ["D4", 1], ["C4", 1], // good- + ["C4", 3], // -night (home) +].map(([n, b]) => [snap(m(n)), b]); + +// ── melodic operators (arrays of [midi, beats]) ───────────────────────────── +// transpose a whole cell by an interval, keeping it in the mode. +function tpose(c, semis) { return c.map(([midi, beats]) => [snap(midi + semis), beats]); } +// invert a cell around its first note (mirror the intervals), folded to mode. +function invert(c) { + const axis = c[0][0]; + return c.map(([midi, beats]) => [snap(axis - (midi - axis)), beats]); +} +// retrograde — play the cell backwards. +function retro(c) { return [...c].reverse(); } + +// ORNAMENT — fill the line in, music-box style: between two melody notes insert +// a soft passing/neighbor tone, and split longer notes into a gentle pair. +// Returns a new, denser cell with the same overall contour & length. +function ornament(c) { + const out = []; + for (let i = 0; i < c.length; i++) { + const [midi, beats] = c[i]; + const next = c[i + 1]; + if (beats >= 1 && next) { + // split the note: first half stays, second half steps toward the next. + const dir = Math.sign(next[0] - midi) || (i % 2 ? 1 : -1); + const passing = snap(midi + dir * 2); // a scale step toward the target + const half = beats / 2; + out.push([midi, half]); + out.push([passing, half]); + } else if (beats >= 3) { + // a long held note becomes note + upper-neighbor + return (a turn). + const t = beats / 3; + out.push([midi, t]); + out.push([snap(midi + 2), t]); + out.push([midi, t]); + } else { + out.push([midi, beats]); + } + } + return out; +} + +// ── layering helpers ──────────────────────────────────────────────────────── +// lay a cell into events starting at a bar/beat with a single voice. +function lay(out, c, { startBar, beat0 = 0, voice = LEAD, gain = 0.5, decayMul = 1.7, pan = 0 } = {}) { + let beat = beat0; + for (const [midi, beats] of c) { + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi, + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * decayMul, + pan, + }); + beat += beats; + } + return beat; +} + +// soft bass root under a bar span (kept low + clear, the cradle's rock). +function bass(out, startBar, name, lenBars = 1, gain = 0.4) { + out.push({ + preset: "bass", + startSec: startBar * BAR, + midi: snap(m(name)), + durSec: lenBars * BAR, + gain, + decayMul: DECAY.bass * 1.4, + pan: 0, + }); +} + +// a single soft high sparkle (glock/kalimba) — a music-box glint. +function glint(out, { startBar, beat, name, beats = 1.5, voice = "glockenspiel", gain = 0.11, pan = 0.3 }) { + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi: snap(m(name)), + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * 1.7, + pan, + }); +} + +// vibraphone_off pad (C-major triad voicing) breathing under a span of bars. +const padName = (deg, oct) => ["C", "D", "E", "F", "G", "A", "B"][deg] + oct; +function pad(out, startBar, lenBars, triad, baseOct, gain = 0.12) { + for (let i = 0; i < triad.length; i++) { + out.push({ + preset: "vibraphone_off", + startSec: startBar * BAR, + midi: snap(m(padName(triad[i], baseOct))), + durSec: lenBars * BAR + BEAT, + gain, + decayMul: 2.0, + pan: i === 0 ? -0.2 : i === 1 ? 0.0 : 0.2, + }); + } +} + +const events = []; + +// ════════════════════════════════════════════════════════════════════════ +// THEME & VARIATIONS on the hushabye cadence. +// Each pass is one statement of HUSH → ROCK → CADENCE (≈ 9 bars), dressed +// differently. The bass rocks I–IV–V–I underneath throughout. +// ════════════════════════════════════════════════════════════════════════ + +// cradle bass: a gentle I–vi–IV–V–I rock under one ~9-bar statement. +function cradleBass(startBar, gain = 0.4) { + bass(events, startBar + 0, "C2", 1, gain); + bass(events, startBar + 1, "A2", 1, gain * 0.95); + bass(events, startBar + 2, "F2", 1, gain); + bass(events, startBar + 3, "C2", 1, gain * 0.95); + bass(events, startBar + 4, "G2", 1, gain); + bass(events, startBar + 5, "C2", 1, gain * 0.95); + bass(events, startBar + 6, "F2", 1, gain); + bass(events, startBar + 7, "G2", 1, gain); + bass(events, startBar + 8, "C2", 1, gain); +} + +// lay one full statement (theme melody) at startBar with a chosen voice/gain. +// HUSH = 4 cells / 6 beats (2 bars), ROCK = 4 bars, CADENCE = 3 bars → 9 bars. +function statement(c1, c2, c3, { startBar, voice = LEAD, gain = 0.5, decayMul = 1.7, pan = 0 }) { + // HUSH occupies bars 0–1 (6 beats), ROCK bars 2–5 (12 beats? it's 4 bars), + // CADENCE bars 6–8 (9 beats). Lay them sequentially in beats from startBar. + let beat = lay(events, c1, { startBar, beat0: 0, voice, gain, decayMul, pan }); + beat = lay(events, c2, { startBar, beat0: beat, voice, gain, decayMul, pan }); + lay(events, c3, { startBar, beat0: beat, voice, gain, decayMul, pan }); +} + +const STMT = 9; // bars per statement + +// ── THEME (bars 0–8): the bare hushabye cadence. One rosewood voice, lots of +// air, a quiet bass rock, the faintest pad. Just the tune. ─────────────── +{ + const b = 0; + statement(HUSH, ROCK, CADENCE, { startBar: b, gain: 0.5, decayMul: 1.8 }); + cradleBass(b, 0.4); + pad(events, b, 9, [0, 2, 4], 3, 0.10); // soft C pad bed +} + +// ── VAR I — DECORATED (bars 9–17): the same tune, ornamented like a music +// box. Passing tones and turns fill the line; a glock adds a glint or two. +// The contour and cadence stay identical — clearly "the same tune again." ─ +{ + const b = STMT * 1; + statement(ornament(HUSH), ornament(ROCK), ornament(CADENCE), { + startBar: b, gain: 0.46, decayMul: 1.7, + }); + cradleBass(b, 0.38); + pad(events, b, 9, [0, 2, 4], 3, 0.10); + // a couple of high music-box glints answering the phrase ends. + glint(events, { startBar: b + 1, beat: 2.2, name: "C6", gain: 0.10, pan: 0.32 }); + glint(events, { startBar: b + 5, beat: 1.0, name: "G5", gain: 0.10, pan: -0.26, voice: "kalimba" }); + glint(events, { startBar: b + 8, beat: 0.5, name: "E6", beats: 2, gain: 0.09, pan: 0.3 }); +} + +// ── VAR II — BASS TAKES THE TUNE (bars 18–26): the melody migrates DOWN an +// octave into the bass marimba; a soft glock keeps the high frame (the +// hush sigh up top) so the tune is "held" between registers. Tender, deep. ─ +{ + const b = STMT * 2; + // the tune, down an octave, in the bass voice. + statement(tpose(HUSH, -12), tpose(ROCK, -12), tpose(CADENCE, -12), { + startBar: b, voice: "bass", gain: 0.34, decayMul: 1.6, pan: 0, + }); + // a thin rosewood high counter-line: the hush sigh up an octave, sparse. + lay(events, tpose(HUSH, 12), { startBar: b, beat0: 0, voice: LEAD, gain: 0.22, decayMul: 1.9, pan: 0.18 }); + // glock keeps a high frame at the cadence so the descent is "answered" up top. + glint(events, { startBar: b + 6, beat: 0, name: "E6", beats: 3, gain: 0.10, pan: 0.3 }); + glint(events, { startBar: b + 7, beat: 1.5, name: "D6", beats: 1.5, gain: 0.09, pan: 0.32 }); + glint(events, { startBar: b + 8, beat: 0, name: "C6", beats: 3, gain: 0.10, pan: 0.28 }); + pad(events, b, 9, [0, 2, 4], 3, 0.11); + // bass cradle moves up an octave so it doesn't clash with the bass melody. + bass(events, b + 0, "C3", 1, 0.30); bass(events, b + 2, "F3", 1, 0.30); + bass(events, b + 4, "G3", 1, 0.30); bass(events, b + 6, "F3", 1, 0.30); + bass(events, b + 8, "C3", 1, 0.30); +} + +// ── VAR III — SPARKLE FINALE (bars 27–35): the brightest, most awake pass. +// Rosewood states the tune; a glockenspiel CANON answers it a bar later an +// octave up — a music box catching the light. Kalimba droplets sparkle. ─── +{ + const b = STMT * 3; + // lead voice, a touch brighter. + statement(HUSH, ROCK, CADENCE, { startBar: b, gain: 0.48, decayMul: 1.6, pan: -0.12 }); + // glockenspiel canon: the SAME tune, one bar later, an octave up, softer. + statement(tpose(HUSH, 12), tpose(ROCK, 12), tpose(CADENCE, 12), { + startBar: b + 1, voice: "glockenspiel", gain: 0.13, decayMul: 1.7, pan: 0.3, + }); + cradleBass(b, 0.38); + pad(events, b, 9, [0, 2, 4], 3, 0.10); + // kalimba droplets sprinkled between phrases. + const drops = [ + [b + 2, 2.0, "C6", -0.26], [b + 4, 2.5, "E6", 0.3], + [b + 6, 1.0, "G6", 0.34], [b + 7, 2.0, "C6", -0.24], + ]; + for (const [bar, bt, n, pn] of drops) { + glint(events, { startBar: bar, beat: bt, name: n, beats: 1.5, voice: "kalimba", gain: 0.10, pan: pn }); + } +} + +// ── VAR IV — HUSHED FINAL STATEMENT (bars 36–45): the plainest pass of all, +// even barer than the theme. Only HUSH and CADENCE (the ROCK middle is +// dropped — the child is nearly asleep), stretched and quiet, settling onto +// a long held C. The recognizable thread, said once more, then goodnight. ─ +{ + const b = STMT * 4; + // hush sigh, very soft, the home opener one last time. + let beat = lay(events, HUSH, { startBar: b, beat0: 0, voice: LEAD, gain: 0.40, decayMul: 2.0 }); + // skip ROCK — go straight to the cadence, the goodnight. + lay(events, CADENCE, { startBar: b, beat0: beat, voice: LEAD, gain: 0.38, decayMul: 2.1 }); + // a final, very faint glock echo of the 3-2-1 up top. + glint(events, { startBar: b + 4, beat: 0, name: "E6", beats: 3, gain: 0.08, pan: 0.3 }); + glint(events, { startBar: b + 4, beat: 1.5, name: "D6", beats: 2, gain: 0.07, pan: 0.32 }); + glint(events, { startBar: b + 5, beat: 0, name: "C6", beats: 4, gain: 0.08, pan: 0.28 }); + // the long home cadence in the bass, settling to C. + bass(events, b + 0, "C2", 2, 0.34); + bass(events, b + 2, "G2", 2, 0.32); + bass(events, b + 4, "F2", 1, 0.30); + bass(events, b + 5, "C2", 3, 0.34); // home, held + pad(events, b, 8, [0, 2, 4], 3, 0.12); // long resolving C pad + // one last extra-low, extra-quiet C to put the room to sleep. + bass(events, b + 6, "C2", 3, 0.26); +} + +const { mp3, durationSec } = renderLullaby(events, { + name: "hushabye", + here: HERE, + title: "hushabye", + reverb: { wet: 0.34, decay: 0.85, damp: 0.4 }, // warm, intimate nursery room + fadeIn: 1.2, + fadeOut: 5.0, + tailSec: 5.5, + peak: 0.84, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/lanternbaba.mjs b/pop/marimba/lullabies/variations/lanternbaba.mjs new file mode 100644 index 0000000000..adb4ee9144 --- /dev/null +++ b/pop/marimba/lullabies/variations/lanternbaba.mjs @@ -0,0 +1,408 @@ +// lanternbaba.mjs — a flickering-lantern riff on the marimbaba lullaby, lit +// by kalimba and tapped by woodblock. +// +// Direction: F# minor, ~56 BPM, kalimba-led with a soft woodblock pulse. A +// lantern's light is never quite steady — it gutters, flares, dapples the +// walls. So the tune does too. +// +// DEVELOPMENT STRATEGY — STOCHASTIC FLICKER ORNAMENTATION: +// - A STEADY CORE PHRASE (the whistlegraph contour: hush → twinkle → wow → +// baba → sleep, re-keyed into F# minor) is the lantern's flame: it is +// always there, always recognizable. +// - Around that flame, a DETERMINISTIC-RANDOM flicker engine sprinkles grace +// notes, mordents, neighbor-tone shivers and tiny broken-chord cells. The +// randomness is seeded (an LCG) so every render is identical, but the +// placement, octave and length of the flicker feels gusty and alive. +// - The FLICKER INTENSITY breathes across the arc: it starts as a faint +// glow (rare, soft graces), swells to a guttering flare at the apex (dense +// dapple, brief flame-up runs), then SETTLES — the flicker thins and dims +// as the lantern is carried to sleep, leaving the bare core phrase. +// - A recognizable thread survives every flare: the core melody is laid down +// FIRST and loudest, the flicker only orbits it, and the final cadence +// settles to F# in the home octave with the flame nearly still. +// +// Run: node variations/lanternbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 56; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; // 3/4, like the source + +// ── F# natural-minor scale-fold (root pc = 6). Snap voices into the mode. ── +const ROOT = 6; // F# +const MINOR = [0, 2, 3, 5, 7, 8, 10]; // natural minor +function snap(midi) { + const pc = ((midi % 12) + 12) % 12; + const rel = ((pc - ROOT) % 12 + 12) % 12; + let best = MINOR[0], bestD = 99; + for (const s of MINOR) { + const d = Math.min(Math.abs(s - rel), 12 - Math.abs(s - rel)); + if (d < bestD) { bestD = d; best = s; } + } + const base = midi - rel; + let cand = base + best; + if (cand - midi > 6) cand -= 12; + if (midi - cand > 6) cand += 12; + return cand; +} + +// step up/down N scale degrees from a midi already in the mode. +function step(midi, deg) { + const pc = ((midi % 12) + 12) % 12; + const rel = ((pc - ROOT) % 12 + 12) % 12; + let idx = MINOR.indexOf(rel); + if (idx < 0) { // not in mode → snap first + return step(snap(midi), deg); + } + let oct = Math.floor((midi - (ROOT)) / 12); // rough octave bucket + let n = idx + deg; + while (n < 0) { n += 7; oct -= 1; } + while (n >= 7) { n -= 7; oct += 1; } + return snap(midi) + (MINOR[n] - rel) + 12 * (Math.floor((idx + deg) / 7) - 0) * 0 + + ((idx + deg) >= 7 ? 12 : 0) * 0; // keep simple; fix below +} + +// The above got fiddly; use a clean degree walker instead. +const DEG_BASE = m("F#3"); // anchor degree-0 here +function degToMidi(d) { + // d is an integer scale-degree offset from F#3 (degree 0). + const oct = Math.floor(d / 7); + const within = ((d % 7) + 7) % 7; + return DEG_BASE + 12 * oct + MINOR[within]; +} +function midiToDeg(midi) { + // nearest scale degree to a (snapped) midi. + const snapped = snap(midi); + let best = 0, bestD = 999; + for (let d = -21; d <= 28; d++) { + const mm = degToMidi(d); + const dd = Math.abs(mm - snapped); + if (dd < bestD) { bestD = dd; best = d; } + } + return best; +} + +// ── the source MOTIFS are written in F major. Re-key to F# minor: take each +// note as a scale degree of the source contour and re-cast it onto the F# +// minor scale, so the *shape* (the whistlegraph contour) survives but the +// color turns minor and lantern-warm. We do this by mapping F-major +// degrees → F#-minor degrees of the same index. ───────────────────────── +const FMAJ_ROOT = m("F4") % 12; // 5 +const FMAJ = [0, 2, 4, 5, 7, 9, 11]; +function fmajDeg(midi) { + // degree index (with octave) of a note in F major. + const pc = ((midi - FMAJ_ROOT) % 12 + 12) % 12; + let idx = FMAJ.indexOf(pc); + if (idx < 0) { // chromatic (Bb etc handled), snap to nearest + let best = 0, bd = 99; + for (let i = 0; i < FMAJ.length; i++) { + const dd = Math.min(Math.abs(FMAJ[i] - pc), 12 - Math.abs(FMAJ[i] - pc)); + if (dd < bd) { bd = dd; best = i; } + } + idx = best; + } + const ref = m("F4"); + const oct = Math.round((midi - ref - (FMAJ[idx])) / 12); + return oct * 7 + idx; +} + +// re-cast an F-major motif into an F#-minor cell of [midi, beats], dropped to +// a cozy lantern register (a tad lower than the source). +function cell(motif, octShift = -1) { + return MOTIFS[motif].map(([name, beats]) => { + const d = fmajDeg(m(name)); + return [degToMidi(d + octShift * 7), beats]; + }); +} + +// transpose a cell by scale-degrees (sequencing). +function seq(c, degs) { return c.map(([midi, beats]) => [degToMidi(midiToDeg(midi) + degs), beats]); } +// invert a cell around its first note, in scale degrees. +function invert(c) { + const axis = midiToDeg(c[0][0]); + return c.map(([midi, beats]) => [degToMidi(axis - (midiToDeg(midi) - axis)), beats]); +} +// retrograde. +function retro(c) { return [...c].reverse(); } + +// ── deterministic flicker RNG (LCG) ───────────────────────────────────────── +function lcg(seed) { + let s = (seed * 2654435761) >>> 0; + return () => ((s = (s * 1103515245 + 12345) >>> 0) / 4294967296); +} + +// ── lay the CORE phrase — kalimba flame, the steady recognizable thread. ───── +function lay(out, c, { startBar, beat0 = 0, voice = "kalimba", gain = 0.5, decayMul = 1.7, pan = 0.18 } = {}) { + let beat = beat0; + for (let i = 0; i < c.length; i++) { + const [midi, beats] = c[i]; + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi, + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * decayMul, + pan, + }); + beat += beats; + } + return beat; +} + +// ── THE FLICKER ENGINE ─────────────────────────────────────────────────── +// For a span of beats anchored on a core cell, sprinkle deterministic-random +// ornaments: grace notes (a step above/below a core note), neighbor-tone +// shivers, and brief broken-chord flame-ups. `intensity` (0..1) scales how +// often and how dense the flicker is; `seed` keeps it repeatable. +function flicker(out, coreCell, { startBar, beat0 = 0, intensity = 0.5, seed = 1, voice = "kalimba", gainBase = 0.22, pan = 0.3 } = {}) { + const rnd = lcg(seed); + let beat = beat0; + for (let i = 0; i < coreCell.length; i++) { + const [midi, beats] = coreCell[i]; + const noteStart = beat; + + // (a) GRACE before the core note — a quick step from above or below that + // "lights" the note. Fires more as intensity rises. + if (rnd() < intensity * 0.7 && beat > beat0 + 0.05) { + const dir = rnd() < 0.5 ? 1 : -1; + const gm = degToMidi(midiToDeg(midi) + dir); // a scale-step away + const glen = 0.12 + rnd() * 0.1; + out.push({ + preset: voice, + startSec: startBar * BAR + (noteStart - glen) * BEAT, + midi: gm, + durSec: glen * BEAT * 1.6, + gain: gainBase * (0.6 + rnd() * 0.4), + decayMul: (DECAY[voice] ?? 1) * 1.3, + pan: pan + (rnd() - 0.5) * 0.3, + }); + } + + // (b) NEIGHBOR-TONE SHIVER — a faint upper-neighbor dapple part-way + // through a longer core note (the flame wavering on the wall). + if (beats >= 1 && rnd() < intensity * 0.6) { + const nm = degToMidi(midiToDeg(midi) + (rnd() < 0.6 ? 1 : 2)); + const at = noteStart + beats * (0.45 + rnd() * 0.3); + out.push({ + preset: voice, + startSec: startBar * BAR + at * BEAT, + midi: nm + (rnd() < 0.3 ? 12 : 0), // sometimes flares an octave up + durSec: (0.25 + rnd() * 0.25) * BEAT, + gain: gainBase * (0.4 + rnd() * 0.35), + decayMul: (DECAY[voice] ?? 1) * 1.2, + pan: pan + (rnd() - 0.5) * 0.4, + }); + } + + beat += beats; + } + + // (c) FLAME-UP RUN — at higher intensity, a brief broken-chord/ scalewise + // sparkle run somewhere in the span (a gust catching the wick). + if (rnd() < intensity * 0.8) { + const runLen = 2 + Math.floor(rnd() * 3); // 2..4 notes + const anchorDeg = midiToDeg(coreCell[Math.floor(rnd() * coreCell.length)][0]); + const up = rnd() < 0.6; + const stepBy = rnd() < 0.5 ? 1 : 2; // step or skip (broken chord) + const totalBeats = beat - beat0; + const startB = beat0 + rnd() * Math.max(0.1, totalBeats - runLen * 0.25); + const nlen = 0.18 + rnd() * 0.1; + for (let k = 0; k < runLen; k++) { + const dg = anchorDeg + (up ? 1 : -1) * stepBy * k + 7; // lift an octave for sparkle + out.push({ + preset: voice, + startSec: startBar * BAR + (startB + k * nlen * 1.1) * BEAT, + midi: degToMidi(dg), + durSec: nlen * BEAT * 1.4, + gain: gainBase * (0.5 + rnd() * 0.3) * (1 - k * 0.12), + decayMul: (DECAY[voice] ?? 1) * 1.2, + pan: pan + (rnd() - 0.5) * 0.5, + }); + } + } +} + +// ── woodblock pulse — the steady carrying footstep under the lantern. A soft +// tick on beat 1 (and a quieter ghost) keeps the cradle rocking. ───────── +function woodPulse(out, startBar, lenBars, { gain = 0.16, ghost = true } = {}) { + for (let b = 0; b < lenBars; b++) { + out.push({ + preset: "woodblock", + startSec: (startBar + b) * BAR, + midi: m("F#4"), + durSec: 0.2 * BEAT, + gain, + decayMul: 0.9, + pan: -0.28, + }); + if (ghost) { + out.push({ + preset: "woodblock", + startSec: (startBar + b) * BAR + 2 * BEAT, + midi: m("C#4"), + durSec: 0.18 * BEAT, + gain: gain * 0.55, + decayMul: 0.9, + pan: -0.34, + }); + } + } +} + +// soft bass root under a span (kept low + clear). +function bass(out, startBar, name, lenBars = 1, gain = 0.4) { + out.push({ + preset: "bass", + startSec: startBar * BAR, + midi: snap(m(name)), + durSec: lenBars * BAR, + gain, + decayMul: DECAY.bass * 1.4, + pan: 0, + }); +} + +// vibraphone_off pad — a warm lantern halo holding the harmony. +const padName = (deg, oct) => snap(m(["F#", "G#", "A", "B", "C#", "D", "E"][deg] + oct)); +function pad(out, startBar, lenBars, triad, baseOct, gain = 0.12) { + for (let i = 0; i < triad.length; i++) { + out.push({ + preset: "vibraphone_off", + startSec: startBar * BAR, + midi: padName(triad[i], baseOct), + durSec: lenBars * BAR + BEAT, + gain, + decayMul: 2.0, + pan: i === 0 ? -0.2 : i === 1 ? 0.0 : 0.2, + }); + } +} + +const events = []; + +// ════════════════════════════════════════════════════════════════════════ +// ARC: faint glow → kindling → guttering flare (apex) → settling to sleep. +// Flicker intensity rises then falls; the core phrase is constant. +// ════════════════════════════════════════════════════════════════════════ + +// ── PASS 0 (bars 0–3): FAINT GLOW. The hush sigh as the bare flame, almost +// no flicker — just one or two soft graces so the contour reads clean. ─── +{ + const hush = cell("hush"); + lay(events, hush, { startBar: 0, gain: 0.5, decayMul: 1.8, pan: 0.16 }); + flicker(events, hush, { startBar: 0, intensity: 0.18, seed: 3, gainBase: 0.16, pan: 0.3 }); + woodPulse(events, 0, 4, { gain: 0.14 }); + bass(events, 0, "F#2", 2, 0.42); + bass(events, 2, "F#2", 2, 0.42); + pad(events, 0, 4, [0, 2, 4], 3, 0.11); // F# minor halo (F#-A-C#) +} + +// ── PASS 1 (bars 4–9): KINDLING. The twinkle climb, flicker waking up +// (intensity ~0.4). A sequenced answer a 3rd up, dappled differently. ─── +{ + const tw = cell("twinkle"); + lay(events, tw, { startBar: 4, gain: 0.48, decayMul: 1.7, pan: 0.14 }); + flicker(events, tw, { startBar: 4, intensity: 0.4, seed: 11, gainBase: 0.2, pan: 0.32 }); + + const tw3 = seq(tw, 2); // up a scale-3rd + lay(events, tw3, { startBar: 6, gain: 0.44, decayMul: 1.7, pan: 0.2 }); + flicker(events, tw3, { startBar: 6, intensity: 0.45, seed: 17, gainBase: 0.2, pan: 0.34 }); + + const fly = cell("flyHigh"); + lay(events, fly, { startBar: 8, gain: 0.45, decayMul: 1.6, pan: 0.22 }); + flicker(events, fly, { startBar: 8, intensity: 0.5, seed: 23, gainBase: 0.21, pan: 0.36 }); + + woodPulse(events, 4, 6, { gain: 0.15 }); + bass(events, 4, "F#2", 2, 0.36); bass(events, 6, "A2", 2, 0.36); bass(events, 8, "D2", 2, 0.36); + pad(events, 4, 2, [0, 2, 4], 3, 0.11); // F#m + pad(events, 6, 2, [2, 4, 6], 3, 0.11); // A major-ish (A-C#-E) + pad(events, 8, 2, [5, 0, 2], 3, 0.11); // D (D-F#-A) +} + +// ── PASS 2 (bars 10–15): GUTTERING FLARE — the apex. The "wow" wobble and +// the "baba" slinky-bap are the core flame; flicker is DENSE (intensity +// ~0.85), full of grace shivers and flame-up runs catching the gusts. ─── +{ + const wow = cell("wow"); + lay(events, wow, { startBar: 10, gain: 0.46, decayMul: 1.7, pan: 0.12 }); + flicker(events, wow, { startBar: 10, intensity: 0.85, seed: 31, gainBase: 0.24, pan: 0.34 }); + + // an inverted echo — the flame answering itself across the wall. + const wowI = invert(wow); + lay(events, wowI, { startBar: 12, gain: 0.36, decayMul: 1.7, pan: -0.16 }); + flicker(events, wowI, { startBar: 12, intensity: 0.8, seed: 37, gainBase: 0.22, pan: -0.3 }); + + const baba = cell("baba"); + lay(events, baba, { startBar: 14, gain: 0.44, decayMul: 1.6, pan: 0.16 }); + flicker(events, baba, { startBar: 14, intensity: 0.9, seed: 43, gainBase: 0.24, pan: 0.36 }); + + // a couple of glockenspiel embers floating over the flare + for (const [b, bt, nm, pn] of [[11, 1.4, "C#6", 0.34], [13, 0.6, "F#6", 0.3], [15, 1.8, "A5", -0.26]]) { + events.push({ preset: "glockenspiel", startSec: b * BAR + bt * BEAT, midi: snap(m(nm)), durSec: 1.5 * BEAT, gain: 0.1, decayMul: (DECAY.glockenspiel ?? 1) * 1.6, pan: pn }); + } + + woodPulse(events, 10, 6, { gain: 0.16 }); + bass(events, 10, "C#2", 2, 0.34); bass(events, 12, "F#2", 2, 0.34); bass(events, 14, "D2", 2, 0.34); + pad(events, 10, 2, [4, 6, 1], 3, 0.12); // C# (dominant-ish) + pad(events, 12, 2, [0, 2, 4], 3, 0.12); // F#m + pad(events, 14, 2, [5, 0, 2], 3, 0.12); // D +} + +// ── PASS 3 (bars 16–19): the gust passes — flicker THINNING (intensity +// falling 0.5 → 0.3). The twinkle returns in retrograde, the flame +// folding back toward stillness. ─────────────────────────────────────── +{ + const twR = retro(cell("twinkle")); + lay(events, twR, { startBar: 16, gain: 0.42, decayMul: 1.7, pan: 0.14 }); + flicker(events, twR, { startBar: 16, intensity: 0.45, seed: 53, gainBase: 0.19, pan: 0.3 }); + + const w = cell("wow"); + lay(events, w, { startBar: 18, gain: 0.38, decayMul: 1.7, pan: 0.12 }); + flicker(events, w, { startBar: 18, intensity: 0.3, seed: 59, gainBase: 0.17, pan: 0.28 }); + + woodPulse(events, 16, 4, { gain: 0.13 }); + bass(events, 16, "B2", 2, 0.32); bass(events, 18, "C#2", 2, 0.32); + pad(events, 16, 2, [3, 5, 0], 3, 0.11); // B (B-D-F#) + pad(events, 18, 2, [4, 6, 1], 3, 0.11); // C# +} + +// ── PASS 4 (bars 20–25): SETTLING TO SLEEP. The sleep cadence comes home, +// the flicker nearly out (intensity ~0.12) — a last faint ember or two, +// then the bare flame on F#. The lantern is set down beside the cradle. ─ +{ + const sleep = cell("sleep"); + lay(events, sleep, { startBar: 20, gain: 0.46, decayMul: 1.9, pan: 0.1 }); + flicker(events, sleep, { startBar: 20, intensity: 0.14, seed: 67, gainBase: 0.14, pan: 0.26 }); + + // a final, faint, close hush echo — the recognizable thread, signed off. + const hush = cell("hush"); + lay(events, hush, { startBar: 23, gain: 0.4, decayMul: 2.0, pan: 0.08 }); + flicker(events, hush, { startBar: 23, intensity: 0.1, seed: 71, gainBase: 0.12, pan: 0.22 }); + + woodPulse(events, 20, 3, { gain: 0.1, ghost: false }); + bass(events, 20, "F#2", 2, 0.36); bass(events, 22, "C#2", 2, 0.32); bass(events, 24, "F#2", 2, 0.32); + pad(events, 20, 6, [0, 2, 4], 3, 0.12); // F#m — long resolving glow + // one last ember fading over the home tonic. + events.push({ preset: "glockenspiel", startSec: 23 * BAR + 1.0 * BEAT, midi: snap(m("F#6")), durSec: 4 * BEAT, gain: 0.08, decayMul: (DECAY.glockenspiel ?? 1) * 1.7, pan: 0.3 }); + events.push({ preset: "kalimba", startSec: 24 * BAR + 1.5 * BEAT, midi: snap(m("C#5")), durSec: 3 * BEAT, gain: 0.09, decayMul: DECAY.kalimba * 1.8, pan: -0.22 }); +} + +const { mp3, durationSec } = renderLullaby(events, { + name: "lanternbaba", + here: HERE, + title: "lanternbaba", + reverb: { wet: 0.4, decay: 0.86, damp: 0.36 }, // warm, glowing lantern-room + healingHz: 396, // Solfeggio UT — grounding, sits under F# minor + fadeIn: 1.4, + fadeOut: 6.0, + tailSec: 6.0, + peak: 0.84, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/lydianbaba.mjs b/pop/marimba/lullabies/variations/lydianbaba.mjs new file mode 100644 index 0000000000..e14b6d4525 --- /dev/null +++ b/pop/marimba/lullabies/variations/lydianbaba.mjs @@ -0,0 +1,257 @@ +// lydianbaba.mjs — a floating, wondrous F-lydian riff on the marimbaba seed, +// developed hard into an ASCENDING MELODIC SEQUENCE. +// +// Identity kept: F lydian (raised 4th, Bb → B natural = the weightless #11 +// shimmer), a kalimba lead over a slow vibraphone_off pad, ~58 BPM 3/4, and +// the flyHigh / butterfly ("way up high") motif as the recognizable thread. +// +// Development: the flyHigh motif does not merely repeat — it climbs in real +// sequences. Each restatement is transposed one lydian step higher, then by +// thirds, terracing up the scale into shimmering rising plateaus that keep +// lifting and never quite cadence — wondrous vertigo — until a final gentle +// float back down to the F-lydian tonic and sleep. +// +// Run: node variations/lydianbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { buildMarimbaba, MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 58; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; + +// ── F lydian: root pitch-class F (5), scale {0,2,4,6,7,9,11} ──────────────── +const ROOT_PC = m("F4") % 12; // 5 +const LYDIAN = [0, 2, 4, 6, 7, 9, 11]; + +// Fold a midi note to the nearest pitch in the target scale (keeps octave +// region; ties resolve upward so the raised 4th brightens rather than dulls). +function snap(midi, rootPc = ROOT_PC, scale = LYDIAN) { + let best = midi, bestD = Infinity; + for (let oct = -1; oct <= 1; oct++) { + for (const deg of scale) { + const pc = (rootPc + deg) % 12; + const base = Math.round((midi - pc) / 12) * 12 + pc + oct * 12; + const d = Math.abs(base - midi); + if (d < bestD - 1e-6 || (Math.abs(d - bestD) < 1e-6 && base > best)) { + best = base; bestD = d; + } + } + } + return best; +} + +// ── diatonic step helper: move a midi note up/down N degrees within lydian ── +// Snap the note to scale, find its scale index, shift by `steps`, rebuild. +function degreeShift(midi, steps, rootPc = ROOT_PC, scale = LYDIAN) { + const snapped = snap(midi, rootPc, scale); + const pc = ((snapped % 12) + 12) % 12; + const rel = ((pc - rootPc) % 12 + 12) % 12; + let idx = scale.indexOf(rel); + if (idx < 0) { + // not exactly on a degree — fall back to nearest + let bd = Infinity; + scale.forEach((deg, i) => { const dd = Math.abs(deg - rel); if (dd < bd) { bd = dd; idx = i; } }); + } + const n = scale.length; + const baseOct = Math.floor((snapped - (rootPc + scale[idx])) / 12); + const newIdx = ((idx + steps) % n + n) % n; + const octCarry = Math.floor((idx + steps) / n); + return (baseOct + octCarry) * 12 + rootPc + scale[newIdx]; +} + +const MELODIC = new Set(["kalimba", "vibraphone", "vibraphone_off", "staccato", "glockenspiel"]); + +// ── 1) the body: marimbaba, re-tempo'd, kalimba lead, dropped to lydian ───── +// We keep the opening (hush / twinkle, bars 0-9) and the closing settle +// (bars 18-23) as the recognizable frame, but CUT the middle (wow/baba/sleep +// transition, bars 10-17) — the ascending sequence terraces fill that span +// instead. Filter the held vibe triads (we lay a fresh lydian pad by hand). +const KEEP_BARS = (bar) => bar <= 9 || bar >= 18; +const body = buildMarimbaba({ + bpm: BPM, + transpose: 0, + leadPreset: "kalimba", + gainMul: 0.78, + decayMul: 1.25, + filter: (voice, bar) => voice !== "vibraphone_off" && KEEP_BARS(bar), +}).map((e) => { + if (MELODIC.has(e.preset)) return { ...e, midi: snap(e.midi) }; + return e; // bass roots stay as-is (F / C / Eb sit fine under lydian) +}); + +// ── 2) a hand-laid F-lydian pad (vibraphone_off), wide and slow ───────────── +const pad = []; +function chord(bar, notes, beats, gain = 0.16, pan = -0.2) { + for (const n of notes) { + pad.push({ + preset: "vibraphone_off", + startSec: bar * BAR, + midi: typeof n === "number" ? n : m(n), + durSec: beats * BEAT, + gain, + decayMul: (DECAY.vibraphone_off ?? 1.4) * 1.3, + pan, + }); + } +} +// Frame pads: Fmaj9(#11) over the opening twinkle, B-natural reach, then a +// settle pad under the final descent. +chord(4, ["F4", "A4", "C5", "E5"], 9); // bars 4-6 +chord(7, ["G4", "B4", "D5", "F5"], 9); // bars 7-9 — B natural = #11 glow +chord(18, ["F4", "A4", "C5"], 12, 0.14); // bars 18-20 settle +chord(21, ["F4", "C5", "G5"], 9, 0.12); // bars 21-23 open fifth/ninth rest + +// ── 3) THE ASCENDING TERRACES — flyHigh climbing through F lydian ──────────── +// flyHigh = [Bb5,D6,Bb5,C6,A5]; snapped to lydian its Bb folds to B natural +// (the signature #11). We sequence it: each terrace transposes the whole +// motif up by a chosen number of lydian DEGREES, so the butterfly keeps +// lifting. Steps: +0, +1, +2 (stepwise), then +4, +6 (by thirds) — the +// climb accelerates and widens, stacking shimmering plateaus. + +const terraces = []; // melodic events +const climbPad = []; // a moving supporting chord under each terrace + +// Place one statement of flyHigh starting at `startBar`, transposed `degShift` +// lydian degrees from its base, with a per-terrace timing stretch. +function terrace(startBar, degShift, opts = {}) { + const { + leadPreset = "kalimba", + gain = 0.26, + pan = 0.0, + stretch = 1.0, // beat-duration multiplier (augmentation) + grace = false, // add a glockenspiel grace flurry above + graceGain = 0.14, + } = opts; + let beat = 0; + let bar = startBar; + let lastMidi = null; + for (const [note, beats] of MOTIFS.flyHigh) { + const midi = degreeShift(snap(m(note)), degShift); + lastMidi = midi; + terraces.push({ + preset: leadPreset, + startSec: bar * BAR + beat * BEAT, + midi, + durSec: beats * 1.15 * stretch * BEAT, + gain, + decayMul: (DECAY[leadPreset] ?? 1.5) * 1.4, + pan, + }); + // sparkling grace note a third above, on the longer tones + if (grace && beats >= 1) { + terraces.push({ + preset: "glockenspiel", + startSec: bar * BAR + (beat + 0.5) * BEAT, + midi: degreeShift(midi, 2) + 12, + durSec: 0.6 * BEAT, + gain: graceGain, + decayMul: (DECAY.glockenspiel ?? 1.5) * 1.3, + pan: pan + 0.18, + }); + } + beat += beats * stretch; + while (beat >= 3) { beat -= 3; bar += 1; } + } + return { endBar: bar, endBeat: beat, lastMidi }; +} + +// Supporting moving chord (root + #11 color) for a terrace, panned wide-left. +function climbChord(bar, rootName, beats, degShift) { + const root = degreeShift(snap(m(rootName)), degShift); + for (const off of [0, 2, 4]) { // stacked thirds in lydian + climbPad.push({ + preset: "vibraphone_off", + startSec: bar * BAR, + midi: degreeShift(root, off), + durSec: beats * BEAT, + gain: 0.12, + decayMul: (DECAY.vibraphone_off ?? 1.4) * 1.3, + pan: -0.28, + }); + } +} + +// The terrace plan — bars 10 onward. flyHigh spans ~6 beats = 2 bars each. +// Stepwise climb first, then accelerate by thirds, density tightening. +// T0 bar10 +0 (statement, in register, kalimba) +// T1 bar12 +1 (one step up) +// T2 bar14 +2 (another step — three rising plateaus, stepwise) +// T3 bar15.5 +4 (leap by a third, faster, glock grace sparkles) +// T4 bar16.5 +6 (highest terrace, by another third, shimmering, never lands) +// Then a falling cascade floats it back down into the closing settle (bar18). + +// T0 — the butterfly appears, plain. +terrace(10, 0, { gain: 0.26, pan: -0.05, stretch: 1.0 }); +climbChord(10, "F4", 6, 0); + +// T1 — one lydian step higher, brighter pan, slight lift in gain. +terrace(12, 1, { gain: 0.25, pan: 0.12, stretch: 1.0, grace: true, graceGain: 0.11 }); +climbChord(12, "F4", 6, 1); + +// T2 — two steps up, the third stepwise plateau; grace sparkles increase. +terrace(14, 2, { gain: 0.24, pan: 0.22, stretch: 0.85, grace: true, graceGain: 0.12 }); +climbChord(14, "F4", 5, 2); + +// T3 — now the climb leaps by a THIRD (+4 degrees), compressed/faster (vertigo). +terrace(15.5, 4, { gain: 0.22, pan: 0.3, stretch: 0.7, grace: true, graceGain: 0.13 }); +climbChord(15, "F4", 4, 4); + +// T4 — the highest terrace, another third up (+6), shimmering and unresolved. +terrace(16.5, 6, { leadPreset: "glockenspiel", gain: 0.18, pan: 0.36, stretch: 0.62, grace: true, graceGain: 0.12 }); +climbChord(16, "F4", 4, 6); + +// ── 4) the gentle float DOWN — a descending lydian cascade releasing the +// vertigo back to the tonic, handing off into the closing settle at bar 18. ── +const cascade = []; +{ + // From the apex (top of flyHigh +6 ≈ very high) trickle down the lydian + // scale in a soft, slowing run — like the butterfly spiralling to rest. + const top = degreeShift(snap(m("D6")), 6); // apex-ish reference + let bar = 17, beat = 1.0; + const steps = [0, -1, -2, -3, -4, -5, -6, -7]; // descend lydian degrees + let dur = 0.5; + for (let i = 0; i < steps.length; i++) { + const midi = degreeShift(top, steps[i]); + cascade.push({ + preset: i < 4 ? "glockenspiel" : "kalimba", + startSec: bar * BAR + beat * BEAT, + midi, + durSec: (dur + i * 0.06) * BEAT * 1.4, + gain: 0.2 - i * 0.012, + decayMul: (DECAY[i < 4 ? "glockenspiel" : "kalimba"] ?? 1.6) * 1.5, + pan: 0.3 - i * 0.06, + }); + beat += dur + i * 0.05; // gently slowing + while (beat >= 3) { beat -= 3; bar += 1; } + } +} + +// a single high kalimba grace note as the very last butterfly wingbeat, +// settling on the F-lydian tonic up high after everything has come to rest. +const finalWing = { + preset: "kalimba", + startSec: 23 * BAR + 0.4 * BEAT, + midi: snap(m("F6")), + durSec: 3 * BEAT, + gain: 0.2, + decayMul: (DECAY.kalimba ?? 1.75) * 1.5, + pan: 0.32, +}; + +const events = [...body, ...pad, ...climbPad, ...terraces, ...cascade, finalWing]; + +const { mp3, durationSec } = renderLullaby(events, { + name: "lydianbaba", + here: HERE, + title: "lydianbaba", + reverb: { wet: 0.38, decay: 0.87, damp: 0.33 }, + fadeIn: 1.0, + fadeOut: 4.5, + tailSec: 5.0, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/meadowbaba.mjs b/pop/marimba/lullabies/variations/meadowbaba.mjs new file mode 100644 index 0000000000..352d94c32c --- /dev/null +++ b/pop/marimba/lullabies/variations/meadowbaba.mjs @@ -0,0 +1,282 @@ +// meadowbaba.mjs — an open-meadow folk riff on the marimbaba lullaby, +// GROWN ONE NOTE AT A TIME. +// +// Direction: G major pentatonic, rosewood + kalimba, ~60 BPM. Wide-sky folk +// warmth — sun on long grass, nothing hurried. The melody is not stated and +// repeated; it is BUILT additively, Reich-style, so the tune assembles itself +// in front of you and then, at dusk, unbuilds. +// +// DEVELOPMENT STRATEGY — ADDITIVE PHRASE-BUILDING (Reich "Music for 18"): +// - We define ONE full folk melody, FOLK, in G major pentatonic — a tune +// that is itself the marimbaba contour re-moded (hush sigh → climbing +// twinkle wave → wow lift → slinky bap → sleep cadence), folded onto the +// five-note scale so it reads as open-meadow folk. +// - The piece starts with the FIRST 2 NOTES of that melody, looped. Each +// repetition ADDS ONE MORE NOTE from the front of the tune, so the phrase +// grows organically: 2 → 3 → 4 → … until the whole folk tune blooms. +// - At the crest, the full tune sings out clear (rosewood lead, kalimba +// answer, soft pad + bass under). Then it SHEDS notes back down — losing +// one from the tail each pass — until only the opening two notes remain, +// and the meadow goes to sleep on them. +// - A recognizable marimbaba thread survives: the contour is the whistlegraph +// phrase set, the descending-then-climbing shape is intact, and the final +// cadence settles to G (the home root) exactly as marimbaba settles to F. +// +// Run: node variations/meadowbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 60; +const BEAT = 60 / BPM; // 1.0s — slow, breathing +// We work in free phrase-time (cumulative seconds), not bars, because the +// additive process grows the phrase length pass by pass. + +// ── G major PENTATONIC fold (G A B D E). Snap any midi onto the five-note +// scale so every voice stays in the open-meadow mode, no leading tones. ── +const ROOT = 7; // G +const PENTA = [0, 2, 4, 7, 9]; // G A B D E +function snap(midi) { + const pc = ((midi % 12) + 12) % 12; + const rel = ((pc - ROOT) % 12 + 12) % 12; + let best = PENTA[0], bestD = 99; + for (const s of PENTA) { + const d = Math.min(Math.abs(s - rel), 12 - Math.abs(s - rel)); + if (d < bestD) { bestD = d; best = s; } + } + const base = midi - rel; + let cand = base + best; + if (cand - midi > 6) cand -= 12; + if (midi - cand > 6) cand += 12; + return cand; +} +const g = (name) => snap(m(name)); + +const LEAD = "rosewood"; + +// ════════════════════════════════════════════════════════════════════════ +// THE FOLK TUNE — assembled from the marimbaba MOTIFS, re-moded into G +// pentatonic. Each entry is [midi, beats]. This is the tune the additive +// process grows toward and then sheds. ~16 notes: a complete little folk +// melody with a rise, a peak, and a homeward fall. +// ════════════════════════════════════════════════════════════════════════ +const FOLK = [ + // opening sigh (hush, re-moded) — the two seed notes the meadow starts on + ["D5", 1.0], // 0 + ["B4", 1.0], // 1 + ["G4", 1.5], // 2 — settle to home + // climbing meadow wave (twinkle re-moded) + ["A4", 0.5], // 3 + ["B4", 1.0], // 4 + ["D5", 1.0], // 5 + ["E5", 1.5], // 6 — first crest + // the lift (wow / "way up high") + ["D5", 0.5], // 7 + ["E5", 1.0], // 8 + ["G5", 2.0], // 9 — top of the sky + // slinky bap coming down (baba re-moded) + ["E5", 0.5], // 10 + ["D5", 0.5], // 11 + ["B4", 1.0], // 12 + // sleep cadence home + ["A4", 1.0], // 13 + ["G4", 1.0], // 14 + ["G4", 2.0], // 15 — final settle +].map(([n, b]) => [g(n), b]); + +// ── small developers (arrays of [midi,beats]) ────────────────────────────── +// take the first k notes of the tune (the growing phrase) +const grow = (k) => FOLK.slice(0, Math.max(2, k)); +// take the first k notes (the shedding phrase loses from the tail) +const shed = (k) => FOLK.slice(0, Math.max(2, k)); +// transpose a phrase by semitones, re-snapping into the pentatonic +const tpose = (c, semis) => c.map(([midi, beats]) => [snap(midi + semis), beats]); + +// ── lay a phrase into events at an absolute start time (seconds). Returns the +// time AFTER the phrase (for chaining passes back to back). High notes +// drift a touch right so the meadow has air. ───────────────────────────── +function lay(out, c, { at, voice = LEAD, gain = 0.5, decayMul = 1.6, panBase = 0 } = {}) { + let t = at; + for (const [midi, beats] of c) { + const reg = (midi - 67) / 18; // ~ -1..+1 around G4..G5 + const pan = Math.max(-0.4, Math.min(0.4, panBase + reg * 0.18)); + out.push({ + preset: voice, + startSec: t, + midi, + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * decayMul, + pan, + }); + t += beats * BEAT; + } + return t; +} + +// kalimba "dewdrop" — a single soft high answer note. +function drop(out, { at, name, beats = 1.0, gain = 0.2, pan = 0.3, voice = "kalimba" }) { + out.push({ + preset: voice, + startSec: at, + midi: g(name), + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * 1.7, + pan, + }); +} + +// vibraphone_off pad — a slow open-fifth meadow haze under a span. +function pad(out, { at, names, lenSec, gain = 0.12 }) { + names.forEach((nm, i) => { + out.push({ + preset: "vibraphone_off", + startSec: at, + midi: g(nm), + durSec: lenSec, + gain, + decayMul: 2.0, + pan: i === 0 ? -0.2 : i === 1 ? 0.0 : 0.2, + }); + }); +} + +// soft bass root, kept low + clear. +function bass(out, { at, name, lenSec, gain = 0.38 }) { + out.push({ + preset: "bass", + startSec: at, + midi: g(name), + durSec: lenSec, + gain, + decayMul: DECAY.bass * 1.4, + pan: 0, + }); +} + +const events = []; +let t = 0; + +// gap between additive passes — a held breath so each new note is heard. +const REST = 0.9 * BEAT; + +// ════════════════════════════════════════════════════════════════════════ +// PART I — DAWN: the phrase GROWS one note per pass (2 → full tune). +// Each pass is the first k notes of FOLK; k climbs by 1. Bass + pad fade +// in as the tune fills out, like sun rising over the field. +// ════════════════════════════════════════════════════════════════════════ +const GROW_FROM = 2; +const GROW_TO = FOLK.length; // 16 +// grow by 1 note while the phrase is short, then by larger steps as it fills, +// so the additive bloom is audible up front without the back half dragging. +const growSteps = [2, 3, 4, 5, 7, 9, 12, GROW_TO]; +for (const k of growSteps) { + const phrase = grow(k); + const start = t; + // the lead grows the phrase + const gain = 0.4 + 0.12 * (k / GROW_TO); // swells gently as it fills + t = lay(events, phrase, { at: start, gain, decayMul: 1.7 }); + const span = t - start; + + // the newest note gets a faint kalimba dewdrop echo a beat later — so the + // ear notices the ADDITION each pass. + if (k > GROW_FROM) { + const [newMidi] = FOLK[k - 1]; + drop(events, { + at: start + span + 0.15 * BEAT, + name: ["G", "A", "B", "D", "E"][((newMidi % 12) + 12) % 12 % 5] + "5", + beats: 1.0, + gain: 0.13 + 0.04 * (k / GROW_TO), + pan: 0.32, + }); + } + + // harmony fades in as the phrase passes the midpoint + if (k >= 5) { + bass(events, { at: start, name: k % 2 === 0 ? "G2" : "D2", lenSec: span, gain: 0.3 }); + } + if (k >= 8) { + pad(events, { at: start, names: ["G3", "D4", "B4"], lenSec: span + REST, gain: 0.1 }); + } + + t += REST; +} + +// ════════════════════════════════════════════════════════════════════════ +// PART II — NOON: the FULL TUNE in bloom, twice. First plain, then with a +// kalimba canon a phrase-fifth above answering — the meadow at full light. +// ════════════════════════════════════════════════════════════════════════ +{ + // full tune, clear and warm + const start = t; + t = lay(events, FOLK, { at: start, gain: 0.54, decayMul: 1.8 }); + const span = t - start; + bass(events, { at: start, name: "G2", lenSec: span / 2, gain: 0.36 }); + bass(events, { at: start + span / 2, name: "D2", lenSec: span / 2 + REST, gain: 0.34 }); + pad(events, { at: start, names: ["G3", "D4", "B4"], lenSec: span + REST, gain: 0.12 }); + t += REST; +} +{ + // full tune again with a kalimba canon (tune up a 4th) entering a beat late — + // two folk singers across the field. + const start = t; + t = lay(events, FOLK, { at: start, gain: 0.5, decayMul: 1.8 }); + const span = t - start; + lay(events, tpose(FOLK, 5), { + at: start + 2 * BEAT, voice: "kalimba", gain: 0.18, decayMul: 1.6, panBase: 0.28, + }); + bass(events, { at: start, name: "G2", lenSec: span / 2, gain: 0.36 }); + bass(events, { at: start + span / 2, name: "E2", lenSec: span / 2 + REST, gain: 0.32 }); + pad(events, { at: start, names: ["E3", "B3", "G4"], lenSec: span + REST, gain: 0.12 }); + t += REST; +} + +// ════════════════════════════════════════════════════════════════════════ +// PART III — DUSK: the phrase SHEDS notes, one per pass (full → 2). The +// tune unbuilds and the meadow settles onto its two seed notes, then home. +// ════════════════════════════════════════════════════════════════════════ +// shed in a few quick steps so dusk settles without dragging. +const shedSteps = [11, 8, 5, 3]; +const SHED_FROM = shedSteps[0]; +for (const k of shedSteps) { + const phrase = shed(k); + const start = t; + const gain = 0.46 - 0.06 * ((SHED_FROM - k) / SHED_FROM); // softening + t = lay(events, phrase, { at: start, gain, decayMul: 1.9 }); + const span = t - start; + if (k >= 6) { + bass(events, { at: start, name: "G2", lenSec: span, gain: 0.28 }); + pad(events, { at: start, names: ["G3", "D4", "B4"], lenSec: span + REST, gain: 0.09 }); + } else { + bass(events, { at: start, name: "G2", lenSec: span + REST, gain: 0.24 }); + } + t += REST * 1.2; // breaths lengthen as it falls asleep +} + +// ── final settle: the two seed notes one last time, then home on G, faint. ── +{ + const start = t; + t = lay(events, [FOLK[0], FOLK[1]], { at: start, gain: 0.38, decayMul: 2.0 }); + t = lay(events, [[g("G4"), 3.0]], { at: t, gain: 0.34, decayMul: 2.1 }); + bass(events, { at: start, name: "G2", lenSec: t - start, gain: 0.24 }); + pad(events, { at: start, names: ["G3", "D4", "G4"], lenSec: t - start + 2, gain: 0.1 }); + drop(events, { at: t - 1.5 * BEAT, name: "D6", beats: 3.0, gain: 0.08, pan: -0.26 }); +} + +const { mp3, durationSec } = renderLullaby(events, { + name: "meadowbaba", + here: HERE, + title: "meadowbaba", + reverb: { wet: 0.36, decay: 0.85, damp: 0.36 }, // open-air, soft glass + fadeIn: 1.4, + fadeOut: 5.5, + tailSec: 6.0, + peak: 0.84, + healingHz: 639, // Solfeggio FA — connection/warmth, sits well in G +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/minorbaba.mjs b/pop/marimba/lullabies/variations/minorbaba.mjs new file mode 100644 index 0000000000..5555be95f7 --- /dev/null +++ b/pop/marimba/lullabies/variations/minorbaba.mjs @@ -0,0 +1,248 @@ +// minorbaba.mjs — the marimbaba hush-theme put through the four mirror forms. +// +// Identity kept: F natural minor, rosewood lead, ~52 BPM lullaby feel, the soft +// Fm vibraphone pad, kalimba droplets. But the MELODY now develops by canon- +// table transformation: the hush "descending sigh" theme is stated as a PRIME, +// then answered by its INVERSION (intervals mirrored — the sigh climbs instead +// of falls), its RETROGRADE (the phrase played backwards), and the inverted- +// retrograde, the four forms calling to one another as the bass sinks F → Eb → +// Db → C. At the darkest point (the C / lowered area) the contour literally +// turns upside-down: the inversion takes the lead and the sigh becomes a rise. +// The sleep cadence returns at the end as a recognizable thread, mirrored once +// more before it finally settles to the true descending hush. +// +// Run: node variations/minorbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 52; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; + +// ── F natural minor scale machinery ───────────────────────────────────────── +// We work in scale-degree space so inversion mirrors *diatonic* intervals (the +// sigh stays in-mode when flipped, no chromatic mush). Degree 0 = F. +const ROOT = m("F4"); // 65 — degree 0 anchor +const STEPS = [0, 2, 3, 5, 7, 8, 10]; // F natural minor semitone offsets +const PCS = STEPS; // pitch-classes of the mode + +// midi -> diatonic degree index (… can go negative / >6 across octaves) +function toDegree(midi) { + const rel = midi - ROOT; + const oct = Math.floor(rel / 12); + const pc = ((rel % 12) + 12) % 12; + // nearest scale pc + let best = 0, bestD = 99; + for (let i = 0; i < PCS.length; i++) { + const d = Math.abs(PCS[i] - pc); + if (d < bestD) { bestD = d; best = i; } + } + return oct * 7 + best; +} +// diatonic degree index -> midi +function toMidi(deg) { + const oct = Math.floor(deg / 7); + const idx = ((deg % 7) + 7) % 7; + return ROOT + oct * 12 + STEPS[idx]; +} + +// ── theme as [noteName, beats] cells, snapped into degree space ────────────── +// PRIME = the hush sigh, the recognizable DNA (C5 A4 F4 / settle). +const cell = (pairs) => pairs.map(([n, b]) => [toDegree(m(n)), b]); +const HUSH = cell(MOTIFS.hush); // [[deg(C5),1],[deg(A4),1],[deg(F4),1],[deg(F4),3]] +const SLEEP = cell(MOTIFS.sleep); // the settle cadence + +// ── the four classical forms, operating on [degree, beats] cells ──────────── +// Inversion: mirror each interval around the first note's degree (axis). +function invert(cells, axis = cells[0][0]) { + return cells.map(([d, b]) => [axis - (d - axis), b]); +} +// Retrograde: reverse note order, keep each note's own duration with it. +function retrograde(cells) { + return cells.slice().reverse(); +} +// Retrograde-inversion: do both. +function retroInvert(cells, axis = cells[0][0]) { + return retrograde(invert(cells, axis)); +} +// Transpose by diatonic steps (sequence the form up/down the mode). +function seq(cells, steps) { + return cells.map(([d, b]) => [d + steps, b]); +} +// Octave displace some notes for wilder leaps (every other note up/down). +function displace(cells, pattern) { + return cells.map(([d, b], i) => [d + (pattern[i % pattern.length] ?? 0) * 7, b]); +} +// Augment / diminish durations. +function scaleDur(cells, k) { return cells.map(([d, b]) => [d, b * k]); } + +// ── event helpers ─────────────────────────────────────────────────────────── +const events = []; +function play(cells, startSec, opts = {}) { + const { preset = "rosewood", gain = 0.5, decayMul = 1.4, pan = 0, gap = 1.0, octave = 0 } = opts; + let t = startSec; + for (const [deg, beats] of cells) { + const dur = beats * BEAT; + events.push({ + preset, + startSec: t, + midi: toMidi(deg) + octave * 12, + durSec: dur * 1.05, // slight ring overlap + gain, + decayMul, + pan, + }); + t += dur * gap; + } + return t; // end time +} +const bass = (note, startSec, beats, gain = 0.4) => + events.push({ preset: "bass", startSec, midi: m(note), durSec: beats * BEAT, gain, decayMul: 1.55, pan: 0 }); +const padFm = (root, startSec, beats, gain = 0.15, pan = 0) => { + // a soft Fm-color triad rooted on `root` (degree-built minor triad). + const base = toDegree(m(root)); + for (const off of [0, 2, 4]) { + events.push({ + preset: "vibraphone", + startSec, midi: toMidi(base + off), + durSec: beats * BEAT, gain: gain - off * 0.004, + decayMul: 1.6, pan, + }); + } +}; +const drop = (note, startSec, gain = 0.15, pan = 0.3) => + events.push({ preset: "kalimba", startSec, midi: toMidi(toDegree(m(note))), durSec: 1.2 * BEAT, gain, decayMul: 1.7, pan }); + +// ════════════════════════════════════════════════════════════════════════════ +// SECTION I — STATEMENT. The prime hush, spoken plainly. (bars 0–3) +// ════════════════════════════════════════════════════════════════════════════ +padFm("F3", 0, 4 * BAR, 0.15, 0.14); +bass("F2", 0, 3, 0.46); bass("F2", 1 * BAR, 3, 0.46); +bass("F2", 2 * BAR, 3, 0.44); bass("F2", 3 * BAR, 3, 0.4); +let cur = play(HUSH, 0.5 * BEAT, { gain: 0.55, decayMul: 1.45, pan: -0.05 }); +// echo the sigh a sixth down, very quiet (kalimba), so the contour imprints. +play(seq(HUSH, -3), 2 * BAR + 0.5 * BEAT, { preset: "kalimba", gain: 0.2, decayMul: 1.7, pan: 0.34 }); + +// ════════════════════════════════════════════════════════════════════════════ +// SECTION II — ANSWER BY INVERSION. The sigh turns and climbs. (bars 4–7) +// Harmony begins to sink: bass slips F → Eb. +// ════════════════════════════════════════════════════════════════════════════ +let s2 = 4 * BAR; +bass("F2", s2, 3, 0.44); bass("F2", s2 + BAR, 3, 0.42); +bass("Eb2", s2 + 2 * BAR, 3, 0.44); bass("Eb2", s2 + 3 * BAR, 3, 0.42); +padFm("Ab3", s2 + 2 * BAR, 2 * BAR, 0.13, -0.12); +// inversion of hush — the descending sigh now ASCENDS. answered against the +// fading prime: a stretto where the prime (low) and its inversion (high) overlap. +play(scaleDur(HUSH, 1.1), s2 + 0.0, { gain: 0.34, decayMul: 1.5, pan: -0.25, octave: -1 }); // shadow prime, low +play(invert(HUSH), s2 + BEAT, { gain: 0.55, decayMul: 1.4, pan: 0.2 }); // bright inversion +// grace-note flurry trailing the inversion's peak (diminished run up the mode). +let g2 = s2 + 2.2 * BAR; +for (const st of [0, 1, 2, 3, 4]) { + events.push({ preset: "kalimba", startSec: g2, midi: toMidi(toDegree(m("F5")) + st), durSec: 0.9 * BEAT, gain: 0.18 - st * 0.012, decayMul: 1.6, pan: 0.32 }); + g2 += 0.28 * BEAT; +} + +// ════════════════════════════════════════════════════════════════════════════ +// SECTION III — RETROGRADE. The phrase runs backwards. (bars 8–11) +// Bass sinks further: Eb → Db. A second voice answers in canon (retrograde +// chasing the prime by one beat). +// ════════════════════════════════════════════════════════════════════════════ +let s3 = 8 * BAR; +bass("Db2", s3, 3, 0.44); bass("Db2", s3 + BAR, 3, 0.42); +bass("Db2", s3 + 2 * BAR, 3, 0.42); bass("Db2", s3 + 3 * BAR, 3, 0.4); +padFm("Db3", s3, 4 * BAR, 0.13, 0.12); +// retrograde of hush in the lead — F F A C reversed contour, but extended via +// augmentation so the backwards sigh is long and searching. +const RH = scaleDur(retrograde(HUSH), 1.0); +play(RH, s3 + 0.0, { gain: 0.52, decayMul: 1.5, pan: -0.1 }); +// canon: the *prime* chases it a beat later, an octave up, quieter (the memory +// of the original answering its own reversal). +play(HUSH, s3 + BEAT, { gain: 0.3, decayMul: 1.5, pan: 0.28, octave: 1 }); +// re-rhythmed retrograde fragment (hemiola: the 3/4 grouped in 2s) on kelon. +const frag = retrograde(HUSH).slice(0, 3); // just the turn +let h3 = s3 + 2.4 * BAR; +for (const [deg, b] of [...frag, ...seq(frag, -2)]) { + events.push({ preset: "kelon", startSec: h3, midi: toMidi(deg), durSec: 0.8 * BEAT, gain: 0.3, decayMul: 1.3, pan: 0.05 }); + h3 += 0.5 * BEAT; // 2-against-3 +} + +// ════════════════════════════════════════════════════════════════════════════ +// SECTION IV — THE DARKEST POINT. Bass on the lowered C; the contour turns +// fully upside down — the RETROGRADE-INVERSION leads, the most transformed +// form, the sigh now a backwards rise. (bars 12–16) +// ════════════════════════════════════════════════════════════════════════════ +let s4 = 12 * BAR; +bass("C2", s4, 3, 0.46); bass("C2", s4 + BAR, 3, 0.44); +bass("C2", s4 + 2 * BAR, 3, 0.42); bass("Ab1", s4 + 3 * BAR, 3, 0.42); +bass("Ab1", s4 + 4 * BAR, 3, 0.4); +// held minor pad — Cm color over the trough. +padFm("C4", s4, 3 * BAR, 0.15, -0.1); +padFm("Ab3", s4 + 3 * BAR, 2 * BAR, 0.14, 0.1); +// retrograde-inversion in the lead, augmented, brooding low then leaping high +// via octave displacement (wild upside-down leaps at the trough). +const RI = displace(scaleDur(retroInvert(HUSH), 1.15), [0, 1, 0, 1]); +play(RI, s4 + 0.5 * BEAT, { gain: 0.5, decayMul: 1.55, pan: 0.0 }); +// all four forms briefly stacked as a quiet stretto haze (the "everything at +// once" climax of the development) — each entering a beat apart, far apart in +// register, hushed so it stays musical not muddy. +const stack = [ + [HUSH, 0, 0.2, -0.3, "rosewood"], + [invert(HUSH), 1, 1.0, 0.3, "kalimba"], + [retrograde(HUSH), 2, -1.0, -0.2, "vibraphone"], + [retroInvert(HUSH), 3, 0.0, 0.2, "kelon"], +]; +let sb = s4 + 2 * BAR; +for (const [form, beatOff, oct, pan, preset] of stack) { + play(scaleDur(form, 1.0), sb + beatOff * BEAT, { preset, gain: preset === "rosewood" ? 0.3 : 0.18, decayMul: 1.55, pan, octave: oct }); +} +// a single bright droplet at the very bottom — a star over the trough. +drop("C6", s4 + 3.5 * BAR, 0.16, 0.34); + +// ════════════════════════════════════════════════════════════════════════════ +// SECTION V — RECOVERY & RETURN. The harmony lifts back C → Eb → F; the four +// forms relax back toward the prime, the sleep cadence returns mirrored once, +// then rights itself into the true descending hush. (bars 17–24) +// ════════════════════════════════════════════════════════════════════════════ +let s5 = 17 * BAR; +bass("Eb2", s5, 3, 0.42); bass("Eb2", s5 + BAR, 3, 0.4); +bass("F2", s5 + 2 * BAR, 3, 0.42); +padFm("Eb3", s5, 2 * BAR, 0.13, -0.1); +padFm("F3", s5 + 2 * BAR, 2 * BAR, 0.14, 0.1); +// sleep cadence first appears INVERTED (still rising — not yet at peace) … +play(invert(SLEEP), s5 + 0.5 * BEAT, { gain: 0.42, decayMul: 1.5, pan: 0.15 }); +// quiet inversion-of-hush answer fading, the last upside-down gesture. +play(invert(HUSH), s5 + 1.8 * BAR, { preset: "kalimba", gain: 0.18, decayMul: 1.7, pan: 0.32 }); + +// … then the cadence RIGHTS itself: the true descending sleep, the recognizable +// thread, low and warm, putting the contour back the right way up. (bars 20–24) +let s6 = 20 * BAR; +bass("F2", s6, 6, 0.38); +bass("C2", s6 + 2 * BAR, 3, 0.36); +bass("F2", s6 + 3 * BAR, 6, 0.34); +padFm("F3", s6, 5 * BAR, 0.14, 0.12); +// the prime sleep settle, an octave low, the home statement. +let endT = play(SLEEP, s6 + 0.5 * BEAT, { gain: 0.46, decayMul: 1.5, pan: 0, octave: -1 }); +// faint kalimba droplets tracing the final descent, snapped to minor. +for (const [n, off] of [["F5", 0], ["Eb5", 1.4], ["Db5", 2.6], ["C5", 3.8], ["F4", 5.0]]) { + drop(n, s6 + 1.2 * BAR + off * BEAT, 0.15, 0.3); +} +// last low Fm root to put it to bed. +bass("F2", endT - BEAT, 8, 0.3); +padFm("F2", endT - BEAT, 8, 0.12, 0); + +const { mp3, durationSec } = renderLullaby(events, { + name: "minorbaba", + here: HERE, + title: "minorbaba (F natural minor — four mirror forms)", + reverb: { wet: 0.34, decay: 0.85, damp: 0.42 }, + fadeIn: 1.2, + fadeOut: 5.5, + tailSec: 5.5, + peak: 0.82, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/mistbaba.mjs b/pop/marimba/lullabies/variations/mistbaba.mjs new file mode 100644 index 0000000000..459d49e63d --- /dev/null +++ b/pop/marimba/lullabies/variations/mistbaba.mjs @@ -0,0 +1,257 @@ +// mistbaba.mjs — a whole-tone MIST riff on the marimbaba seed, now GENERATIVE. +// +// Identity unchanged: ~50 BPM, F whole-tone scale {0,2,4,6,8,10} (no perfect +// fifths, no leading tone — nothing resolves), foregrounded vibraphone_off pad +// clouds, sparse rosewood lead + kalimba glints, long smeary reverb. You fall +// asleep inside one breathing fog. +// +// What changed: the melody no longer restates the hush/sleep contours and +// stops. Instead a single seed phrase — the hush descent (C5-A4-F4) snapped to +// whole-tone — is fed through a DETERMINISTIC ALEATORIC WANDER. Each phrase is +// the previous one with one or two notes nudged a whole-tone step (sometimes an +// octave displaced), chosen by a seeded PRNG so it is reproducible but never +// repeats. Over ~24 phrases the tune drifts clean across the whole-tone field, +// always becoming something else — dreamlike, unmoored. The hush contour is the +// genome; everything you hear is a mutation of it. It comes home at the end: +// the wander is gently pulled back toward the original hush + sleep cadence so +// the mist exhales into the recognizable seed before fading. +// +// Run: node variations/mistbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 50; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; + +// ── F whole-tone: root pitch-class F (5), scale {0,2,4,6,8,10} ─────────────── +const ROOT_PC = m("F4") % 12; // 5 +const WHOLETONE = [0, 2, 4, 6, 8, 10]; + +// Fold a midi note to the nearest pitch in the whole-tone scale (keeps the +// octave region; ties resolve downward so things settle / sink into sleep). +function snap(midi, rootPc = ROOT_PC, scale = WHOLETONE) { + let best = midi, bestD = Infinity; + for (let oct = -1; oct <= 1; oct++) { + for (const deg of scale) { + const pc = (rootPc + deg) % 12; + const base = Math.round((midi - pc) / 12) * 12 + pc + oct * 12; + const d = Math.abs(base - midi); + if (d < bestD - 1e-6 || (Math.abs(d - bestD) < 1e-6 && base < best)) { + best = base; bestD = d; + } + } + } + return best; +} + +// ── seeded PRNG (mulberry32) — deterministic so the wander is reproducible ─── +function rng(seed) { + let a = seed >>> 0; + return () => { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +const rand = rng(0x6D157BABA & 0x7fffffff); // "mistbaba" seed + +// ── the whole-tone ladder the wander walks ────────────────────────────────── +// All whole-tone pitches across the lead's comfortable register, as a sorted +// index space. Mutations step ±1 / ±2 rungs (and occasionally ±octave) so the +// tune always stays on the scale but never settles. +const LADDER = []; +for (let mid = m("D4"); mid <= m("D6"); mid++) { + const s = snap(mid); + if (!LADDER.includes(s)) LADDER.push(s); +} +LADDER.sort((a, b) => a - b); +const ladderIdx = (midi) => { + const s = snap(midi); + let bi = 0, bd = Infinity; + for (let i = 0; i < LADDER.length; i++) { + const d = Math.abs(LADDER[i] - s); + if (d < bd) { bd = d; bi = i; } + } + return bi; +}; +const clampIdx = (i) => Math.max(0, Math.min(LADDER.length - 1, i)); + +// ── seed genome: the hush sigh, snapped to whole-tone, as ladder indices ───── +// [idx, beats] cells. This is what gets mutated, generation after generation. +const seedHush = MOTIFS.hush.map(([note, beats]) => [ladderIdx(m(note)), beats]); + +// mutate(phrase) — copy the phrase, nudge one or two notes by a whole-tone step +// (rarely an octave leap), and sometimes re-rhythm a single cell. Pure of the +// previous phrase; deterministic via the shared rand(). +function mutate(phrase, intensity = 1) { + const next = phrase.map((c) => c.slice()); + const nNudge = 1 + (rand() < 0.45 * intensity ? 1 : 0); + for (let k = 0; k < nNudge; k++) { + const i = Math.floor(rand() * next.length); + const roll = rand(); + let step; + if (roll < 0.12 * intensity) step = (rand() < 0.5 ? -1 : 1) * 5; // octave-ish leap (5 rungs ≈ 8ve) + else if (roll < 0.5) step = (rand() < 0.5 ? -1 : 1); // ±1 whole-tone + else step = (rand() < 0.5 ? -2 : 2); // ±2 whole-tones + next[i][0] = clampIdx(next[i][0] + step); + } + // occasional gentle re-rhythm: stretch or split one cell's duration. + if (rand() < 0.3 * intensity) { + const i = Math.floor(rand() * next.length); + next[i][1] = Math.max(0.5, next[i][1] + (rand() < 0.5 ? -0.5 : 1)); + } + return next; +} + +// pull(phrase, target, amount) — nudge each note partway back toward a target +// phrase's contour (used to bring the wander home at the end). amount in rungs. +function pull(phrase, target, amount) { + return phrase.map((c, i) => { + const t = target[i % target.length][0]; + const cur = c[0]; + const dir = Math.sign(t - cur); + const stepped = clampIdx(cur + dir * Math.min(amount, Math.abs(t - cur))); + return [stepped, c[1]]; + }); +} + +const events = []; + +// ── 1) the pad mist (UNCHANGED identity) — overlapping whole-tone clouds ────── +// Slow vibraphone_off stacks, each started before the last decays, so the fog +// never clears. These hold the harmonic ground the melody drifts over. The +// chain is longer now to give the wander room to travel. +function cloud(startBar, beats, notes, gain, pan) { + for (const n of notes) { + events.push({ + preset: "vibraphone_off", + startSec: startBar * BAR, + midi: snap(m(n)), + durSec: beats * BEAT, + gain, + decayMul: (DECAY.vibraphone_off ?? 1.4) * 1.6, + pan, + }); + } +} +cloud(0, 15, ["F3", "G3", "A4", "B4"], 0.2, -0.22); +cloud(4, 15, ["A3", "B3", "C5", "D5"], 0.18, 0.2); +cloud(8, 15, ["G3", "A3", "C5", "Eb5"], 0.18, -0.18); +cloud(12, 15, ["F3", "Ab3", "B4", "C5"], 0.17, 0.22); +cloud(16, 16, ["D3", "F3", "A4", "B4"], 0.17, -0.2); +cloud(20, 16, ["G3", "B3", "C5", "D5"], 0.16, 0.18); +cloud(24, 16, ["Ab3", "B3", "Eb5", "F5"], 0.16, -0.21); +cloud(28, 16, ["F3", "A3", "B4", "C5"], 0.16, 0.2); +cloud(32, 16, ["D3", "G3", "A4", "G4"], 0.16, -0.16); // settle, low + +// ── 2) the GENERATIVE WANDER — the melody that never repeats ────────────────── +// Start from the hush genome and emit one mutated phrase per ~2 bars, walking +// continuously through the mist. Each phrase = the previous one, nudged. The +// lead voice subtly cross-fades rosewood/kalimba/vibraphone as it drifts so the +// timbre wanders too. Notes ring long and overlap, smearing into the pad. +const LEAD_VOICES = ["rosewood", "rosewood", "kalimba", "rosewood", "vibraphone"]; + +function emitPhrase(phrase, startBar, voice, gain, pan, beatScale, durMul, octaveShift) { + let bar = startBar, beat = 0; + for (const [idx, beats] of phrase) { + const span = beats * beatScale; + const midi = LADDER[clampIdx(idx)] + octaveShift; + events.push({ + preset: voice, + startSec: bar * BAR + beat * BEAT, + midi, + durSec: span * durMul * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1.7) * 1.5, + pan, + }); + beat += span; + while (beat >= 3) { beat -= 3; bar += 1; } + } + return bar + (beat > 0 ? 1 : 0); +} + +// Drift across bars 1..27. ~14 generations, ~1.85 bars apart (overlapping). +let phrase = seedHush.slice(); +const GENERATIONS = 14; +const GEN_STEP = 1.85; +const startBars = []; +for (let g = 0; g < GENERATIONS; g++) startBars.push(Math.round((1 + g * GEN_STEP) * 10) / 10); + +for (let g = 0; g < GENERATIONS; g++) { + // mutate from the previous phrase (the seed is generation 0, played as-is). + if (g > 0) { + // intensity swells toward the middle then eases — an arc of strangeness. + const t = g / (GENERATIONS - 1); + const intensity = 0.6 + 1.1 * Math.sin(Math.PI * t); // 0.6 → ~1.7 → 0.6 + phrase = mutate(phrase, intensity); + } + const startBar = startBars[g]; + // wander the timbre + register so it feels like the tune is travelling. + const voice = LEAD_VOICES[g % LEAD_VOICES.length]; + const pan = 0.34 * Math.sin(g * 1.31); // slow stereo drift + // octave displacement drifts the whole line up into chimes, then sinks back. + const t = g / (GENERATIONS - 1); + const octaveShift = (Math.sin(Math.PI * t - 0.3) > 0.6 ? 12 : 0) - (t > 0.85 ? 12 : 0); + // gentler gains for kalimba/vibraphone so the lead breathes evenly. + const baseGain = voice === "kalimba" ? 0.2 : voice === "vibraphone" ? 0.24 : 0.3; + const gain = baseGain * (0.85 + 0.3 * Math.sin(g * 0.7)); // subtle dynamic swell + const beatScale = 2.0 + 0.5 * Math.sin(g * 0.9); // breathe the tempo of phrases + const durMul = 1.3 + 0.2 * (rand() - 0.5); + emitPhrase(phrase, startBar, voice, gain, pan, beatScale, durMul, octaveShift); +} + +// ── 3) coming home — the wander is pulled back toward the seed cadence ───────── +// After the long drift, gently haul the current phrase back toward the original +// hush, then state the seed's sleep settle as the last recognizable word. The +// mist exhales into the tune it was always derived from. +let homing = phrase.slice(); +homing = pull(homing, seedHush, 2); +emitPhrase(homing, 28, "rosewood", 0.26, -0.1, 2.4, 1.4, 0); + +homing = pull(homing, seedHush, 4); +emitPhrase(homing, 31, "rosewood", 0.24, 0.1, 2.5, 1.4, 0); + +// the seed's sleep settle, whole-tone-snapped — last word in the fog. +const sleepCells = MOTIFS.sleep.map(([note, beats]) => [ladderIdx(m(note)), beats]); +emitPhrase(sleepCells, 34, "rosewood", 0.22, 0, 2.2, 1.5, 0); + +// ── 4) far kalimba glints — a few distant chimes catching light in the fog ──── +// Single whole-tone tonics/9ths up high, scattered so they never form a phrase +// of their own — just sparkle threading through the wandering tune. +const glints = [ + [7, "F6", 0.15, 0.34], + [14, "G6", 0.13, 0.3], + [21, "B5", 0.15, 0.36], + [27, "F6", 0.12, 0.28], + [33, "Ab5", 0.13, 0.32], +]; +for (const [bar, note, gain, pan] of glints) { + events.push({ + preset: "kalimba", + startSec: bar * BAR + (rand() < 0.5 ? 0 : 1.5) * BEAT, + midi: snap(m(note)), + durSec: 4 * BEAT, + gain, + decayMul: (DECAY.kalimba ?? 1.75) * 1.6, + pan, + }); +} + +const { mp3, durationSec } = renderLullaby(events, { + name: "mistbaba", + here: HERE, + title: "mistbaba", + reverb: { wet: 0.4, decay: 0.88, damp: 0.32 }, + fadeIn: 2.0, + fadeOut: 6.0, + tailSec: 6.0, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/moonbaba.mjs b/pop/marimba/lullabies/variations/moonbaba.mjs new file mode 100644 index 0000000000..1051f44478 --- /dev/null +++ b/pop/marimba/lullabies/variations/moonbaba.mjs @@ -0,0 +1,271 @@ +// moonbaba.mjs — a moonlit, weightless riff on the marimbaba lullaby, now +// SHATTERED ACROSS THE REGISTERS. +// +// Direction: still Eb major, ~50 BPM, vibraphone-led dream haze, with the +// vibraphone_off pad breathing underneath. But the MELODY no longer sits in +// one register: it is scattered across 3–4 octaves, Webern-style pointillism +// softened for vibraphone. The same tune leaps high↔low so it feels vast and +// re-discovered on every pass. +// +// DEVELOPMENT STRATEGY — OCTAVE-DISPLACEMENT & REGISTER EXPLOSION: +// - The whistlegraph contour (hush → twinkle → wow → baba → sleep) is kept +// as the melodic DNA, but each note is octave-displaced. Consecutive notes +// jump registers, so the moon's tune scatters into starlight. +// - The displacement WIDENS across the piece: passes start near (±1 oct), +// then explode to ±2/3 octaves at the apex (the "wow" float becomes a +// register supernova), then COLLAPSE back into a close low register for +// the moonset — the moon sinking into a single quiet octave. +// - A recognizable thread survives: the descending hush sigh opens close, +// the final cadence settles to Eb in the home octave, and the contour +// (up-and-falling waves, the wobble, the slinky bap) is intact under the +// octave scatter. +// +// Run: node variations/moonbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 50; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; // 3/4 + +// ── Eb major scale-fold (root pc = 3). Snap melodic voices to the mode. ── +const ROOT = 3; // Eb +const MAJOR = [0, 2, 4, 5, 7, 9, 11]; +function snap(midi) { + const pc = ((midi % 12) + 12) % 12; + const rel = ((pc - ROOT) % 12 + 12) % 12; + let best = MAJOR[0], bestD = 99; + for (const s of MAJOR) { + const d = Math.min(Math.abs(s - rel), 12 - Math.abs(s - rel)); + if (d < bestD) { bestD = d; best = s; } + } + const base = midi - rel; + let cand = base + best; + if (cand - midi > 6) cand -= 12; + if (midi - cand > 6) cand += 12; + return cand; +} + +const LEAD = "vibraphone"; + +// ── motif → Eb-folded MIDI cells. The marimbaba MOTIFS are written in F; +// transpose -2 into Eb territory then snap into the mode. Each cell is a +// list of [midi, beats] pairs we can octave-displace freely. ────────────── +function cell(motif) { + return MOTIFS[motif].map(([name, beats]) => [snap(m(name) - 2), beats]); +} + +// ── OCTAVE-DISPLACEMENT ENGINE ───────────────────────────────────────────── +// scatter(cell, opts) returns a fresh cell whose notes are nudged by whole +// octaves. `spread` is the max number of octaves a note may jump (the +// register width); `center` shifts the whole cloud up/down; `seed` makes the +// scatter deterministic so a pass is repeatable; `pattern` (optional) forces +// the per-note octave offsets so we can hand-shape the contour. +function lcg(seed) { + let s = (seed * 2654435761) >>> 0; + return () => ((s = (s * 1103515245 + 12345) >>> 0) / 4294967296); +} +function scatter(c, { spread = 1, center = 0, seed = 1, pattern = null } = {}) { + const rnd = lcg(seed); + return c.map(([midi, beats], i) => { + let oct; + if (pattern) { + oct = pattern[i % pattern.length]; + } else { + // bias alternate notes to opposite extremes so the line zig-zags hard + const reach = Math.round(rnd() * spread); + const dir = i % 2 === 0 ? 1 : -1; + oct = dir * reach; + } + return [midi + center * 12 + oct * 12, beats]; + }); +} + +// transpose a whole cell by an interval (for sequencing) before scattering. +function tpose(c, semis) { return c.map(([midi, beats]) => [snap(midi + semis), beats]); } +// invert a cell around its first note (mirror the intervals). +function invert(c) { + const axis = c[0][0]; + return c.map(([midi, beats]) => [snap(axis - (midi - axis)), beats]); +} +// retrograde — play the cell backwards. +function retro(c) { return [...c].reverse(); } + +// ── lay a cell into events at a bar, voice, with optional ornament tail. ───── +function lay(out, c, { startBar, voice = LEAD, gain = 0.5, panSpread = 0.34, decayMul = 1.6, beat0 = 0 } = {}) { + let beat = beat0; + const baseBar = startBar; + for (let i = 0; i < c.length; i++) { + const [midi, beats] = c[i]; + // pan follows register: high notes drift right, low notes left → the + // scatter is spatial as well as registral. + const reg = (midi - 63) / 24; // ~ -1..+1 across the explosion + const pan = Math.max(-0.5, Math.min(0.5, reg * panSpread)); + out.push({ + preset: voice, + startSec: baseBar * BAR + beat * BEAT, + midi, + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * decayMul, + pan, + }); + beat += beats; + } + return beat; // beats consumed (for chaining) +} + +// glockenspiel/kalimba "stars" — a single high sparkle. +function star(out, { startBar, beat, name, beats, voice = "glockenspiel", gain = 0.13, pan = 0.3 }) { + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi: snap(m(name)), + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * 1.6, + pan, + }); +} + +// vibraphone_off pad (Eb-folded triad) breathing under a span of bars. +const padDeg = (deg, oct) => snap(m(["Eb", "F", "G", "Ab", "Bb", "C", "D"][deg] + oct)); +function pad(out, startBar, lenBars, triad, baseOct, gain = 0.13) { + for (let i = 0; i < triad.length; i++) { + out.push({ + preset: "vibraphone_off", + startSec: startBar * BAR, + midi: padDeg(triad[i], baseOct), + durSec: lenBars * BAR + BEAT, + gain, + decayMul: 2.0, + pan: i === 0 ? -0.22 : i === 1 ? 0.0 : 0.22, + }); + } +} + +// soft bass root under a bar (kept low + clear). +function bass(out, startBar, name, lenBars = 1, gain = 0.4) { + out.push({ + preset: "bass", + startSec: startBar * BAR, + midi: snap(m(name)), + durSec: lenBars * BAR, + gain, + decayMul: DECAY.bass * 1.4, + pan: 0, + }); +} + +const events = []; + +// ════════════════════════════════════════════════════════════════════════ +// ARC: close → widening → register explosion → collapse to moonset. +// ════════════════════════════════════════════════════════════════════════ + +// ── PASS 0 (bars 0–3): the hush sigh, almost CLOSE — the moon's tune as we +// first hear it. Only a tiny ±1-oct displacement on the last note so the +// recognizable descending sigh stays legible before it scatters. ───────── +{ + const hush = cell("hush"); + lay(events, scatter(hush, { pattern: [0, 0, 0, -1] }), { startBar: 0, gain: 0.5, decayMul: 1.7 }); + bass(events, 0, "Eb2", 2, 0.42); + bass(events, 2, "Eb2", 2, 0.42); + pad(events, 0, 4, [0, 2, 4], 4, 0.12); // Eb pad bed + star(events, { startBar: 2, beat: 2.2, name: "Bb5", beats: 1.5, gain: 0.09, pan: 0.3 }); +} + +// ── PASS 1 (bars 4–9): twinkle, beginning to SCATTER (±1 oct). The climbing +// wave still climbs but each step hops a register — first glints of the +// explosion. ─────────────────────────────────────────────────────────── +{ + const tw = cell("twinkle"); + lay(events, scatter(tw, { spread: 1, seed: 7 }), { startBar: 4, gain: 0.48, panSpread: 0.4 }); + // answer: the same wave, sequenced up a 3rd, scattered the other way (canon-ish) + lay(events, scatter(tpose(tw, 4), { spread: 1, seed: 13 }), { startBar: 6, gain: 0.42, panSpread: 0.42 }); + // bars 8–9: a flyHigh fragment, scatter ±1, as a high lift + lay(events, scatter(cell("flyHigh"), { spread: 1, seed: 21, center: 0 }), { startBar: 8, gain: 0.44, panSpread: 0.45 }); + bass(events, 4, "Eb2", 2, 0.36); bass(events, 6, "Ab2", 2, 0.36); bass(events, 8, "Bb2", 2, 0.36); + pad(events, 4, 3, [0, 2, 4], 4, 0.12); + pad(events, 7, 3, [3, 5, 0], 4, 0.12); // Ab + // kalimba grace flurries answering the climbs + star(events, { startBar: 5, beat: 1.0, name: "G5", beats: 1, voice: "kalimba", gain: 0.13, pan: -0.26 }); + star(events, { startBar: 9, beat: 0.5, name: "C6", beats: 1.5, voice: "glockenspiel", gain: 0.12, pan: 0.34 }); +} + +// ── PASS 2 (bars 10–15): THE REGISTER EXPLOSION. The "wow" wobble is no +// longer a held dyad — it detonates across 3 octaves, then the baba +// slinky-bap is hurled even wider (±3 oct). This is the apex: vast, +// re-discovered, weightless. Cascading glock starlight overhead. ───────── +{ + // wow, scattered ±2 octaves with a hand-shaped zig-zag for a clear leap arc + const wow = cell("wow"); + lay(events, scatter(wow, { pattern: [0, 2, -1, 2, -2, 1] }), { startBar: 10, gain: 0.42, panSpread: 0.5, decayMul: 1.9 }); + // an inverted echo of the wow, one bar later, scattered the opposite way — + // the tune answering itself across the void (stretto). + lay(events, scatter(invert(wow), { pattern: [-2, 1, -1, 2, 0, 2] }), { startBar: 12, gain: 0.34, panSpread: 0.5, decayMul: 1.9 }); + // baba slinky-bap flung the WIDEST — ±3 octaves, the supernova + const baba = cell("baba"); + lay(events, scatter(baba, { spread: 3, seed: 41 }), { startBar: 14, gain: 0.4, panSpread: 0.5, decayMul: 1.7 }); + + // cascading glockenspiel + kalimba starlight raining through the explosion + const cascade = [ + [10, 2.0, "Eb6", "glockenspiel", 0.34], [11, 0.5, "Bb5", "kalimba", -0.28], + [11, 1.6, "G6", "glockenspiel", 0.38], [12, 2.2, "Eb5", "kalimba", -0.3], + [13, 0.8, "C6", "glockenspiel", 0.32], [13, 2.0, "Ab6", "glockenspiel", 0.4], + [14, 1.2, "F6", "glockenspiel", 0.36], [15, 0.6, "Bb6", "glockenspiel", 0.42], + [15, 2.0, "G5", "kalimba", -0.26], + ]; + for (const [b, bt, n, v, pn] of cascade) star(events, { startBar: b, beat: bt, name: n, beats: 1.5, voice: v, gain: 0.11, pan: pn }); + + // bass holds the harmonic floor clear under the chaos + bass(events, 10, "Bb2", 2, 0.34); bass(events, 12, "Eb2", 2, 0.34); bass(events, 14, "Ab2", 2, 0.34); + pad(events, 10, 4, [4, 6, 1], 4, 0.12); // Bb (dominant) under the float + pad(events, 14, 2, [3, 5, 0], 4, 0.12); // Ab +} + +// ── PASS 3 (bars 16–19): the explosion begins COLLAPSING. The twinkle wave +// returns in retrograde (the tune folding back on itself), displacement +// narrowing from ±2 to ±1, pulling the scattered stars back together. ──── +{ + const twR = retro(cell("twinkle")); + lay(events, scatter(twR, { spread: 2, seed: 55 }), { startBar: 16, gain: 0.4, panSpread: 0.42, decayMul: 1.7 }); + lay(events, scatter(cell("wow"), { spread: 1, seed: 61 }), { startBar: 18, gain: 0.36, panSpread: 0.34, decayMul: 1.7 }); + bass(events, 16, "Ab2", 2, 0.34); bass(events, 18, "Bb2", 2, 0.34); + pad(events, 16, 4, [4, 6, 1], 4, 0.12); // Bb resolving toward home + star(events, { startBar: 17, beat: 1.5, name: "Eb6", beats: 2, gain: 0.1, pan: 0.32 }); +} + +// ── PASS 4 (bars 20–25): MOONSET. The sleep cadence collapses into a single +// CLOSE, LOW register — the moon sinking into one quiet octave. No +// displacement now: the tune comes home, recognizable and settled. ─────── +{ + const sleep = cell("sleep"); + // pull the whole cadence down into the low-mid register (center -1) and + // play it flat (no scatter) — the collapse made literal. + lay(events, tpose(sleep, -12), { startBar: 20, gain: 0.46, panSpread: 0.18, decayMul: 1.9 }); + // a final, faint, close hush echo to close the frame (recognizable thread) + lay(events, tpose(cell("hush"), -12), { startBar: 23, gain: 0.4, panSpread: 0.16, decayMul: 2.0 }); + bass(events, 20, "Eb2", 2, 0.36); bass(events, 22, "Bb2", 2, 0.34); bass(events, 24, "Eb2", 2, 0.34); + pad(events, 20, 6, [0, 2, 4], 4, 0.13); // Eb — long resolving moonset + // one last, very high, very soft star fading out over the home octave + star(events, { startBar: 23, beat: 1.0, name: "Eb6", beats: 4, gain: 0.08, pan: 0.3 }); + star(events, { startBar: 24, beat: 1.5, name: "Bb5", beats: 3, voice: "kalimba", gain: 0.09, pan: -0.24 }); +} + +const { mp3, durationSec } = renderLullaby(events, { + name: "moonbaba", + here: HERE, + title: "moonbaba", + reverb: { wet: 0.42, decay: 0.88, damp: 0.34 }, // deep, glassy moon-room + fadeIn: 1.6, + fadeOut: 6.0, + tailSec: 6.0, + peak: 0.82, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/musicboxbaba.mjs b/pop/marimba/lullabies/variations/musicboxbaba.mjs new file mode 100644 index 0000000000..e687b47b99 --- /dev/null +++ b/pop/marimba/lullabies/variations/musicboxbaba.mjs @@ -0,0 +1,271 @@ +// musicboxbaba.mjs — a wind-up nursery music box that ACCELERATES. +// +// C major, ~74 BPM, high register. STRATEGY: ACCELERATING DIMINUTION. +// The box winds UP. The twinkle theme is first stated plainly in slow +// quarter notes on the warm kelon body. Then each restatement HALVES the +// note values — quarters → 8ths → 16ths → 32nd flurries — and grows turns +// and mordents, the tempo of the comb tightening, until a dizzying +// glockenspiel spiral whirls at the peak. Then the spring catches: a sudden +// ritard UNWINDS it back through 16ths and 8ths to slow single notes, the +// last tine clicking once as the mainspring runs out. +// +// The seed is marimbaba (F major, 3/4, ~56 BPM) — here remapped to C major +// and re-voiced as a wind-up box. The recognizable thread: the twinkle +// climbing wave and the hush/sleep descending sigh-cadence survive every +// diminution; only their SPEED and ornamentation transform. +// +// Run: node variations/musicboxbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +// ── mode remap: fold any midi to the nearest pitch in C major ────────────── +const ROOT = 0; // C +const MAJOR = [0, 2, 4, 5, 7, 9, 11]; +function snap(midi) { + const pc = ((midi - ROOT) % 12 + 12) % 12; + let best = MAJOR[0], bestD = 99; + for (const d of MAJOR) { + const dist = Math.min(Math.abs(d - pc), 12 - Math.abs(d - pc)); + if (dist < bestD) { bestD = dist; best = d; } + } + return midi + (best - pc); +} +const snapName = (name, oct = 0) => snap(m(name) + oct * 12); + +// next scale tone above/below within C major (for turns & mordents) ───────── +function scaleStep(midi, dir) { + let x = midi + dir; + for (let i = 0; i < 3; i++) { if (snap(x) === x) return x; x += dir; } + return snap(x); +} + +// ── timing ───────────────────────────────────────────────────────────────── +const BPM = 74; +const BEAT = 60 / BPM; + +const ev = []; +let t = 0; // running cursor in seconds + +const KELON_DEC = (DECAY.kelon ?? 1.3) * 1.5; +const GLOCK_DEC = 1.7; + +// ── voices ───────────────────────────────────────────────────────────────── +// warm wooden box body +function kel(at, midi, dur, gain, pan = -0.08, decMul = KELON_DEC) { + ev.push({ preset: "kelon", startSec: at, midi, durSec: dur, gain, decayMul: decMul, pan }); +} +// bright comb tine +function tine(at, midi, gain = 0.24, dur = 0.4 * BEAT, pan = 0.28) { + ev.push({ preset: "glockenspiel", startSec: at, midi, durSec: dur, gain, decayMul: GLOCK_DEC, pan }); +} +// wound base +function root(name, gain = 0.32, dur = 3 * BEAT * 1.1, at = t) { + ev.push({ preset: "bass", startSec: at, midi: m(name), durSec: dur, gain, decayMul: 1.7, pan: 0 }); +} + +// ── the recognizable thread: a single melodic line (scale degrees, C major) ─ +// Drawn from MOTIFS.twinkle (climbing wave) + the hush sigh tail, flattened +// into ONE contour of note names. Every diminution re-renders THIS line at a +// different note-value, so the tune is always the same — only faster. +const THEME = ["C5", "E5", "G5", "C6", "A5", "G5", "E5", "G5", // twinkle climb + curl + "F5", "A5", "C6", "B5", "A5", "G5", "F5", "E5"]; // wave + sigh down +// cadence tag (the hush/sleep descending sigh — survives to the very end) +const CADENCE = ["G5", "F5", "E5", "C5"]; + +// ── ornament generators (turns / mordents) on a scale tone ───────────────── +// mordent: note, lower-neighbor, note (three quick grace strikes) +function mordent(midi) { + return [midi, scaleStep(midi, -1), midi]; +} +// turn: upper-neighbor, note, lower-neighbor, note (four-note curl) +function turn(midi) { + return [scaleStep(midi, 1), midi, scaleStep(midi, -1), midi]; +} + +// ── render one diminution pass of THEME ──────────────────────────────────── +// noteVal = seconds per theme note. voice "kel" (slow) or "tine" (fast). +// ornament = none | "mordent" | "turn" applied to a few accent notes. +// octShift moves the whole pass; gain shapes loudness. +function pass(notes, noteVal, { voice = "kel", ornament = null, octShift = 0, + gain = 0.4, accentEvery = 4, pan } = {}) { + let cur = t; + notes.forEach((name, i) => { + const base = snapName(name, octShift); + const accent = (i % accentEvery === 0); + if (ornament && accent && noteVal < 0.55 * BEAT) { + // squeeze the ornament into this note's slot + const orn = ornament === "turn" ? turn(base) : mordent(base); + const g = noteVal / orn.length; + orn.forEach((mi, k) => { + const at = cur + k * g; + if (voice === "tine") tine(at, mi + 12, gain * (k === orn.length - 1 ? 1 : 0.7), Math.max(0.12, g * 1.2), pan); + else kel(at, mi, Math.max(0.12, g * 1.4), gain * (k === orn.length - 1 ? 1 : 0.72), pan); + }); + } else { + if (voice === "tine") tine(cur, base + 12, gain * (accent ? 1 : 0.8), Math.max(0.14, noteVal * 1.2), pan); + else kel(cur, base, noteVal * 1.25, gain * (accent ? 1 : 0.85), pan); + } + cur += noteVal; + }); + return cur; // end time +} + +// ===================================================================== +// WIND-UP — the key turning: a fast fragile rising clockwork sparkle. +// ===================================================================== +root("C2", 0.3, 3 * BEAT * 1.2); +["C5", "E5", "G5", "C6", "E6", "G6"].forEach((n, i) => + tine(t + i * 0.5 * BEAT, snapName(n, 1) - 12, 0.16 + i * 0.012)); +t += 3 * BEAT; + +// one more half-turn of the key, a touch faster +root("G2", 0.28, 2.2 * BEAT); +["G5", "B5", "D6", "G6"].forEach((n, i) => + tine(t + i * 0.4 * BEAT, snapName(n, 1) - 12, 0.16)); +t += 2.2 * BEAT; + +// ===================================================================== +// STATEMENT — quarter notes. The theme stated plainly, slow & warm. +// kelon body, one tine ghost per phrase-head. This is the box at rest. +// ===================================================================== +root("C2", 0.32, 8 * BEAT); +root("A2", 0.3, 8 * BEAT, t + 8 * BEAT); +t = pass(THEME, 1.0 * BEAT, { gain: 0.42, accentEvery: 8 }); +// a lone answering tine at the cadence head +tine(t - 8 * BEAT, snapName("C6"), 0.18); + +// gentle plain cadence (slow sigh) +root("G2", 0.3, 4 * BEAT); +t = pass(CADENCE, 1.0 * BEAT, { gain: 0.4, accentEvery: 99 }); + +// STATEMENT (repeat) — same plain quarters, an octave-down warm answer so +// the theme is fully memorable before the winding begins. +root("F2", 0.3, 8 * BEAT); +root("C2", 0.3, 8 * BEAT, t + 8 * BEAT); +t = pass(THEME, 1.0 * BEAT, { gain: 0.4, octShift: -1, accentEvery: 8, pan: 0.06 }); +tine(t - 6 * BEAT, snapName("G6") - 12, 0.13); +root("G2", 0.3, 4 * BEAT); +t = pass(CADENCE, 1.0 * BEAT, { gain: 0.38, octShift: -1, accentEvery: 99, pan: 0.06 }); + +// ===================================================================== +// DIMINUTION 1 — eighth notes. Twice as fast. First mordents bloom. +// kelon still leads but tines start doubling the accents. +// ===================================================================== +root("C2", 0.3, 8 * BEAT); +root("F2", 0.3, 8 * BEAT, t + 4 * BEAT); +const d1End = pass(THEME, 0.5 * BEAT, { gain: 0.4, ornament: "mordent", accentEvery: 4 }); +// tine ghost doubling the climb, an octave up +let gt = t; +["C5", "E5", "G5", "C6"].forEach((n, i) => { tine(gt + i * BEAT, snapName(n, 1), 0.13); }); +t = d1End; + +// ===================================================================== +// DIMINUTION 2 — sixteenth notes. Turns now. Voice splits: kelon body + +// glockenspiel tine line interlocking like the comb teeth speeding up. +// ===================================================================== +root("C2", 0.28, 8 * BEAT); +root("G2", 0.28, 8 * BEAT, t + 4 * BEAT); +const startD2 = t; +// kelon takes the line at 16ths with turns on accents +const d2End = pass(THEME, 0.25 * BEAT, { gain: 0.36, ornament: "turn", accentEvery: 4 }); +// glockenspiel shadows it a 16th late, octave up, sparser (offset canon) +let ct = startD2 + 0.125 * BEAT; +THEME.forEach((name, i) => { + if (i % 2 === 0) tine(ct, snapName(name, 1), 0.12, 0.22 * BEAT); + ct += 0.25 * BEAT; +}); +t = d2End; + +// ===================================================================== +// PEAK — 32nd-note FLURRY + a dizzying glockenspiel spiral. +// The comb is whirring. THEME compressed into 32nds on tines, octave up, +// while kelon holds a shimmering pedal and a spiraling arpeggio cascades. +// ===================================================================== +root("C3", 0.3, 6 * BEAT); +// shimmering held kelon pedal chord (C major) under the whirl +kel(t, snapName("E4"), 6 * BEAT, 0.22, -0.1, KELON_DEC * 1.3); +kel(t, snapName("G4"), 6 * BEAT, 0.2, 0.0, KELON_DEC * 1.3); +kel(t, snapName("C5"), 6 * BEAT, 0.18, 0.1, KELON_DEC * 1.3); + +const peakStart = t; +// THEME at 32nds on the tines — the dizzy flurry, octave up +t = pass(THEME, 0.125 * BEAT, { voice: "tine", gain: 0.2, ornament: "turn", accentEvery: 4 }); +// THEME again, retrograde, still 32nds — the spiral doubles back +const RETRO = [...THEME].reverse(); +t = pass(RETRO, 0.125 * BEAT, { voice: "tine", gain: 0.19, octShift: 0, accentEvery: 4 }); + +// the glockenspiel SPIRAL: a continuous up-down arpeggio cascade over the +// flurry — C-major across three octaves, accelerating then settling. +const spiralPitches = ["C5","E5","G5","B5","C6","E6","G6","E6","C6","B5","G5","E5","C5","E5","G5","C6"]; +let sp = peakStart; +let step = 0.16 * BEAT; +spiralPitches.forEach((n, i) => { + tine(sp, snapName(n, 1) - (i > 7 ? 12 : 0), 0.14, 0.2 * BEAT, i % 2 ? 0.34 : -0.34); + sp += step; + step *= 0.97; // wind tighter +}); + +// a top-of-the-spiral shimmer burst — the highest, fastest tines +let bs = peakStart + 3 * BEAT; +["G6","C6","E6","G6","C6","A5","G6","E6"].forEach((n, i) => { + tine(bs, snapName(n, 1) - 12, 0.13 - i * 0.004, 0.18 * BEAT, i % 2 ? 0.3 : -0.3); + bs += 0.09 * BEAT; +}); + +// ===================================================================== +// UNWIND — the spring catches. Sudden ritard, note-values DOUBLING back: +// 16ths → 8ths → quarters → slow single notes. Ornaments fall away. +// The hush/sleep cadence returns, slower each time, as it runs down. +// ===================================================================== +// (re-render shrinking fragments of THEME, each pass slower & quieter) + +// unwind A — 16ths, descending fragment, ornament thinning +root("C2", 0.28, 4 * BEAT); +const unA = ["C6","B5","A5","G5","F5","E5","G5","E5"]; +t = pass(unA, 0.25 * BEAT, { gain: 0.3, ornament: "mordent", accentEvery: 8 }); + +// unwind B — 8ths, slowing, plainer +root("F2", 0.26, 4 * BEAT); +const unB = ["A5","G5","F5","E5","C5","E5"]; +t = pass(unB, 0.5 * BEAT * 1.12, { gain: 0.3, accentEvery: 99 }); + +// unwind C — quarters, the cadence sigh, plain +root("G2", 0.26, 5 * BEAT); +t = pass(CADENCE, 1.0 * BEAT * 1.25, { gain: 0.3, accentEvery: 99 }); +// faint tine echoes trailing behind, slowing +tine(t - 3.0 * BEAT, snapName("G6") - 12, 0.1); +tine(t - 1.6 * BEAT, snapName("E6") - 12, 0.09); + +// unwind D — slow single notes, the spring nearly stopped (deep ritard) +root("C2", 0.26, 6 * BEAT); +kel(t, snapName("C5"), 2.6 * BEAT, 0.3, -0.06, KELON_DEC * 1.3); t += 2.0 * BEAT; +kel(t, snapName("A4"), 2.8 * BEAT, 0.28, 0.02, KELON_DEC * 1.35); t += 2.4 * BEAT; +tine(t - 0.5 * BEAT, snapName("A5"), 0.1); + +// final lone resolve — the mainspring stops +root("C2", 0.24, 6 * BEAT); +kel(t, snapName("C4"), 5 * BEAT, 0.3, 0.0, KELON_DEC * 1.5); +kel(t, snapName("E4"), 5 * BEAT, 0.22, 0.06, KELON_DEC * 1.5); +kel(t, snapName("G4"), 5 * BEAT, 0.18, -0.06, KELON_DEC * 1.5); +tine(t + 0.2 * BEAT, snapName("C6"), 0.12); +// the last click of the comb, off the grid, very soft +tine(t + 2.4 * BEAT, snapName("E5"), 0.09, 0.6 * BEAT, 0.2); +t += 5 * BEAT; + +// ── render ───────────────────────────────────────────────────────────────── +const { mp3, durationSec } = renderLullaby(ev, { + name: "musicboxbaba", + here: HERE, + title: "musicboxbaba", + reverb: { wet: 0.34, decay: 0.84, damp: 0.34 }, + fadeIn: 0.5, + fadeOut: 4.5, + tailSec: 5.0, + peak: 0.82, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/pentababa.mjs b/pop/marimba/lullabies/variations/pentababa.mjs new file mode 100644 index 0000000000..4196318c4c --- /dev/null +++ b/pop/marimba/lullabies/variations/pentababa.mjs @@ -0,0 +1,244 @@ +// pentababa.mjs — F-major-pentatonic PROCESS music on the marimbaba DNA. +// +// Pentatonic drops the 4th (Bb) and 7th (E) from F major, leaving only the +// five most consonant degrees — F G A C D. Nothing can clash, so the same +// five tones can be recombined endlessly without ever needing to resolve. +// +// DEVELOPMENT STRATEGY — PERMUTATIONAL RECOMBINATION (minimalist process): +// We treat the five pitches as a SET and systematically recombine them across +// the whole piece — rotate the cell, reorder it, interleave TWO simultaneous +// orderings (a phasing canon), and shift which note is emphasized each phrase. +// A single 5-note cell becomes an ever-shifting kaleidoscope: the melody +// travels and mutates the entire time instead of restating the tune. We keep +// the marimbaba thread by opening and closing on the hush sigh (C-A-F, all +// in-scale) and by keeping the kalimba lead, ~60 BPM feel, big open gamelan +// room, and F-pedal bass — the same MOOD, a wildly different melody. +// +// Run: node variations/pentababa.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 60; +const BEAT = 60 / BPM; + +// ── F major pentatonic spelled across octaves: F G A C D ───────────────────── +// The SET we permute. Octave 4 = the "home" register; we lift/drop by ±12. +const SET = ["F4", "G4", "A4", "C5", "D5"]; // 5 pitch classes, ascending +const N = SET.length; + +// ── permutation helpers (the process engine) ───────────────────────────────── +const rotate = (arr, k) => arr.map((_, i) => arr[(i + k) % arr.length]); +const retro = (arr) => [...arr].reverse(); +// "interleave" two orderings into one stream a0 b0 a1 b1 ... (stretto / weave) +const interleave = (a, b) => { + const out = []; + for (let i = 0; i < Math.max(a.length, b.length); i++) { + if (i < a.length) out.push(a[i]); + if (i < b.length) out.push(b[i]); + } + return out; +}; +// move a note up/down whole octaves +const oct = (note, n) => { + const mm = m(note) + 12 * n; + return mm; +}; + +const LEAD = "kalimba"; +const events = []; +let cursor = 0; // running seconds cursor for the lead voice + +// emit one lead note; advance cursor by `beats`. +function lead(note, beats, gain = 0.34, opts = {}) { + const midi = typeof note === "number" ? note : m(note); + events.push({ + preset: opts.preset ?? LEAD, + startSec: cursor, + midi, + durSec: beats * BEAT * (opts.ring ?? 1), + gain, + decayMul: (DECAY[opts.preset ?? LEAD] ?? 1.75) * (opts.decayMul ?? 1.5), + pan: opts.pan ?? 0.18, + }); + cursor += beats * BEAT; +} + +// a free "answer" voice at an absolute time (canon / bonang ping, no cursor). +function ping(atSec, note, beats, gain = 0.2, opts = {}) { + const midi = typeof note === "number" ? note : m(note); + events.push({ + preset: opts.preset ?? "gamelan", + startSec: atSec, + midi, + durSec: beats * BEAT * (opts.ring ?? 1), + gain, + decayMul: (DECAY[opts.preset ?? "gamelan"] ?? 1.75) * (opts.decayMul ?? 1.6), + pan: opts.pan ?? -0.3, + }); +} + +// a sustained F pedal bass that holds the room together (octave per call). +function pedal(atSec, beats, note = "F2", gain = 0.42) { + events.push({ + preset: "bass", + startSec: atSec, + midi: m(note), + durSec: beats * BEAT, + gain, + decayMul: (DECAY.bass ?? 1.8) * 1.2, + pan: 0, + }); +} + +// ============================================================================= +// PHASE 0 — STATEMENT: the marimbaba hush sigh, slow & spacious, on kalimba. +// The recognizable thread. C-A-F descending, all in pentatonic. +// ============================================================================= +pedal(cursor, 12, "F2", 0.4); +for (const [note, beats] of MOTIFS.hush) { + lead(note, beats * 1.2, 0.3, { ring: 1.3, pan: -0.06 }); +} +ping(cursor - BEAT * 1.5, oct("C6", 0), 3, 0.16, { pan: 0.34 }); // a far chime echo +cursor += BEAT * 1.0; // breathe + +// ============================================================================= +// PHASE 1 — ROTATIONS: the bare set, ascending, then rotated one step each +// pass. Each pass lands its emphasis (a louder, longer note) on a new degree +// so the "tonic of the phrase" keeps shifting — same 5 notes, new center. +// ============================================================================= +pedal(cursor, 15, "F2", 0.38); +{ + let passStart = cursor; + for (let k = 0; k < N; k++) { + const cell = rotate(SET, k); // ascending order, rotated start + cell.forEach((note, i) => { + const emph = i === 0; // emphasize the rotation's head note + lead(note, emph ? 0.75 : 0.5, emph ? 0.4 : 0.28, { + pan: -0.2 + (i / N) * 0.4, // sweep the cell L→R + ring: emph ? 1.4 : 1.0, + }); + }); + // a sparse gamelan answer an octave up on the head of the next rotation + ping(passStart + 0.25 * BEAT, oct(rotate(SET, (k + 1) % N)[0], 1), 2, 0.14, { + pan: 0.32, + }); + passStart = cursor; + } +} +cursor += BEAT * 0.5; + +// ============================================================================= +// PHASE 2 — TWO-VOICE WEAVE (stretto): interleave an ASCENDING ordering with a +// simultaneous DESCENDING (retrograde) ordering into one rippling stream. +// Octave-displace the descending member so the line leaps wildly while still +// only ever using the five tones — the kaleidoscope opening up. +// ============================================================================= +pedal(cursor, 18, "F3", 0.34); +{ + const up = SET; // F G A C D + const down = retro(SET); // D C A G F + // weave them, lifting the descending member up an octave for big leaps + const woven = []; + for (let i = 0; i < N; i++) { + woven.push([up[i], 0]); + woven.push([down[i], 1]); // [note, octaveShift] + } + woven.forEach(([note, o], i) => { + const fast = i % 2 === 1; // the leaping voice is shorter/brighter + lead(oct(note, o), fast ? 0.375 : 0.5, fast ? 0.24 : 0.34, { + pan: fast ? 0.34 : -0.28, // the two voices sit on opposite sides + ring: fast ? 0.9 : 1.2, + }); + }); + // bonang canon: echo the whole weave a beat later, an octave up, quieter + let echoT = cursor - woven.length * 0.4375 * BEAT + BEAT * 0.5; + for (const [note, o] of woven) { + ping(echoT, oct(note, o + 1), 1.5, 0.12, { pan: 0.38 }); + echoT += 0.4375 * BEAT; + } +} +cursor += BEAT * 0.5; + +// ============================================================================= +// PHASE 3 — DIMINUTION / ACCELERATION: compress the rotating cell into faster +// and faster runs (a hemiola of 5 against 3), driving toward a peak. This is +// the most "insane" travelling — the same set spun into cascading sextuplets. +// ============================================================================= +pedal(cursor, 12, "F2", 0.34); +{ + const speeds = [0.5, 0.375, 0.3, 0.25, 0.2]; // beats/note — accelerating + speeds.forEach((dur, pass) => { + const cell = rotate(SET, pass * 2 % N); // rotate by 2 (a different cycle) + // run the cell up, then its retrograde down — a wave per pass + const run = [...cell, ...retro(cell).slice(1)]; + run.forEach((note, i) => { + // climb octaves as we accelerate so the peak literally rises + const o = pass >= 3 ? 1 : 0; + const g = 0.22 + pass * 0.02; + lead(oct(note, o), dur, Math.min(g, 0.3), { + pan: -0.3 + ((i % N) / N) * 0.6, + ring: 0.8, + }); + }); + }); + // a high gamelan peak crowning the acceleration + ping(cursor, oct("D5", 1), 4, 0.22, { pan: 0.3, ring: 1.4 }); + ping(cursor + 0.5 * BEAT, oct("A4", 1), 3.5, 0.18, { pan: -0.32, ring: 1.4 }); +} +cursor += BEAT * 1.0; + +// ============================================================================= +// PHASE 4 — AUGMENTED RECOMBINATION (the come-down): take the SAME set but +// stretch it way out, reordered by a "skip" permutation (every other note), +// landing emphasis on F again to steer the ear home. Sparse, wide, gamelan. +// ============================================================================= +pedal(cursor, 21, "F2", 0.38); +{ + // skip-permutation: indices 0,2,4,1,3 → F A D G C — a fresh contour, same set + const skip = [0, 2, 4, 1, 3].map((i) => SET[i]); + skip.forEach((note, i) => { + const emph = note === "F4"; + lead(note, emph ? 3 : 2, emph ? 0.34 : 0.26, { + ring: 1.5, + pan: -0.1 + (i / skip.length) * 0.2, + }); + // a far octave answer between the long notes + ping(cursor - BEAT * 1.0, oct(skip[(i + 2) % skip.length], 1), 3, 0.13, { + pan: i % 2 ? 0.36 : -0.34, + }); + }); +} +cursor += BEAT * 1.0; + +// ============================================================================= +// PHASE 5 — RETURN: the hush sigh again, the spine, now high & far as a +// wondrous echo floating up to rest — bookending the whole process. +// ============================================================================= +pedal(cursor, 12, "F2", 0.34); +for (const [note, beats] of MOTIFS.hush) { + lead(oct(note, 1), beats * 1.4, 0.18, { + preset: "gamelan", + ring: 1.5, + pan: 0.3, + decayMul: 1.8, + }); +} +// one last low F to settle the room +ping(cursor - BEAT * 2, "F4", 4, 0.16, { preset: "kalimba", pan: -0.1, ring: 1.4 }); + +// ============================================================================= +const { mp3, durationSec } = renderLullaby(events, { + name: "pentababa", + here: HERE, + title: "pentababa", + reverb: { wet: 0.4, decay: 0.88, damp: 0.32 }, // big open room for the gaps + fadeIn: 1.0, + fadeOut: 5.0, + tailSec: 6.0, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/prismbaba.mjs b/pop/marimba/lullabies/variations/prismbaba.mjs new file mode 100644 index 0000000000..eec1da22ff --- /dev/null +++ b/pop/marimba/lullabies/variations/prismbaba.mjs @@ -0,0 +1,287 @@ +// prismbaba.mjs — the marimbaba hush/twinkle phrase REFRACTED THROUGH MODES, +// a prism held up to the lullaby so the same light bends a new color each pass. +// +// Direction: E modal home, kelon lead, ~58 BPM. The tune keeps ONE fixed +// scale-degree contour (the whistlegraph hush → twinkle → wow → baba → sleep +// shapes, reduced to degrees) and re-illuminates it by swapping the MODE +// underneath: Ionian → Lydian → Dorian → Aeolian → back to Ionian. Same steps +// of the staircase, repainted — a TRUE mode remap, never a transposition, so +// degree 3 brightens to major and dims to minor, degree 4 lifts to the Lydian +// #4 and settles back, degree 6/7 darken into Aeolian, all while the melodic +// contour stays instantly recognizable. +// +// DEVELOPMENT STRATEGY — MODAL REFRACTION: +// - Phrases are stored as SCALE DEGREES (1-based) with octave marks, not notes. +// - deg→midi(mode, root) maps a degree through whichever 7-note mode is lit. +// - Each pass restates the same contour through a new mode; the listener hears +// "the same tune" wearing a new color of light. The arc: +// P0 Ionian (warm noon) — hush, stated plainly so we learn the contour +// P1 Lydian (bright dawn) — twinkle, the #4 raising the roof +// P2 Dorian (cool dusk) — wow + baba, minor-but-hopeful, the b3 cooling +// P3 Aeolian (deep night) — twinkle inverted, the b6/b7 darkest +// P4 Ionian (return) — sleep cadence comes home, the prism set down +// - A drone pedal on E threads every pass so the modes are heard as colors of +// ONE home, not as key changes — that pedal IS the prism's white light. +// +// Run: node variations/prismbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 58; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; // 3/4 — the marimbaba waltz lilt + +const LEAD = "kelon"; +const ROOT = m("E4"); // E modal home + +// ── THE MODES (semitone offsets for degrees 1..7) ────────────────────────── +// Same degree numbers, different light. This is the prism: one contour, many +// colorings. Degree d (1-based) → MODE[mode][d-1] semitones above the root. +const MODES = { + ionian: [0, 2, 4, 5, 7, 9, 11], // major — warm noon + lydian: [0, 2, 4, 6, 7, 9, 11], // #4 — bright dawn (the raised roof) + dorian: [0, 2, 3, 5, 7, 9, 10], // b3 b7 — cool, minor-hopeful dusk + aeolian: [0, 2, 3, 5, 7, 8, 10], // b3 b6 b7 — deep night +}; + +// ── degree cell → midi cell, refracted through a mode ─────────────────────── +// A degree is written "d" or "dᵒ" via a {deg, oct} record; we store them as +// [degree, octaveShift, beats]. octaveShift is in octaves relative to ROOT's +// octave. refract() bends the whole cell through the lit mode. +function refract(cell, mode) { + const tab = MODES[mode]; + return cell.map(([deg, oct, beats]) => { + const idx = ((deg - 1) % 7 + 7) % 7; + const extraOct = Math.floor((deg - 1) / 7); // degrees > 7 wrap up octaves + return [ROOT + tab[idx] + 12 * (oct + extraOct), beats]; + }); +} + +// ── reduce the marimbaba MOTIFS to mode-free DEGREE contours (in F major, the +// motif key). We read each motif note's scale degree relative to F so the +// whistlegraph shapes survive as pure contour, ready to be re-lit on E. ──── +const F_ROOT = m("F4"); +const F_MAJOR = [0, 2, 4, 5, 7, 9, 11]; +function noteToDegree(midi) { + // nearest degree of F major; returns [degree(1-based, can exceed 7), oct] + const rel = midi - F_ROOT; + const pc = ((rel % 12) + 12) % 12; + let bestI = 0, bestD = 99; + for (let i = 0; i < 7; i++) { + const d = Math.min(Math.abs(F_MAJOR[i] - pc), 12 - Math.abs(F_MAJOR[i] - pc)); + if (d < bestD) { bestD = d; bestI = i; } + } + const oct = Math.round((rel - F_MAJOR[bestI]) / 12); + return [bestI + 1, oct]; // degree (1..7), octave shift relative to F4 +} +function contourOf(motif) { + return MOTIFS[motif].map(([name, beats]) => { + const [deg, oct] = noteToDegree(m(name)); + return [deg, oct, beats]; + }); +} + +// the five whistlegraph contours, stored as pure scale degrees on E now: +const C = { + hush: contourOf("hush"), + twinkle: contourOf("twinkle"), + flyHigh: contourOf("flyHigh"), + wow: contourOf("wow"), + baba: contourOf("baba"), + sleep: contourOf("sleep"), +}; + +// ── contour transforms (operate on DEGREE cells, before refraction) ───────── +// invert a degree-contour around its first degree (mirror, stays diatonic). +function invert(cell) { + const [d0, o0] = [cell[0][0], cell[0][1]]; + const abs0 = (d0 - 1) + 7 * o0; + return cell.map(([deg, oct, beats]) => { + const abs = (deg - 1) + 7 * oct; + const mir = 2 * abs0 - abs; + return [(((mir % 7) + 7) % 7) + 1, Math.floor(mir / 7), beats]; + }); +} +// retrograde — same contour, walked backwards. +function retro(cell) { return [...cell].reverse(); } +// sequence — shift the whole contour up/down by N diatonic degrees. +function seq(cell, steps) { + return cell.map(([deg, oct, beats]) => { + const abs = (deg - 1) + 7 * oct + steps; + return [(((abs % 7) + 7) % 7) + 1, Math.floor(abs / 7), beats]; + }); +} +// gently lengthen final note for a breathing cadence. +function breathe(cell, extra = 1) { + const c = cell.map((x) => [...x]); + c[c.length - 1][2] += extra; + return c; +} + +// ── lay a refracted midi cell into events ────────────────────────────────── +function lay(out, midiCell, { startBar, beat0 = 0, voice = LEAD, gain = 0.5, decayMul = 1.5, panSpread = 0.28 } = {}) { + let beat = beat0; + for (const [midi, beats] of midiCell) { + const reg = (midi - ROOT) / 24; // register → pan, the prism fans in space + const pan = Math.max(-0.45, Math.min(0.45, reg * panSpread)); + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi, + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * decayMul, + pan, + }); + beat += beats; + } + return beat; +} + +// ── E pedal drone (the prism's white light) — vibraphone_off, soft + long. ── +function pedal(out, startBar, lenBars, mode, gain = 0.13) { + const tab = MODES[mode]; + // root + 5th always; the colored 3rd of the lit mode sits on top so the + // pedal itself faintly carries the pass's color. + const triad = [ROOT - 12, ROOT - 12 + tab[4], ROOT + tab[2]]; + const pans = [-0.2, 0.0, 0.22]; + for (let i = 0; i < triad.length; i++) { + out.push({ + preset: "vibraphone_off", + startSec: startBar * BAR, + midi: triad[i], + durSec: lenBars * BAR + BEAT, + gain, + decayMul: 2.0, + pan: pans[i], + }); + } +} + +// ── soft bass root, kept low + clear ─────────────────────────────────────── +function bass(out, startBar, midi, lenBars = 2, gain = 0.38) { + out.push({ + preset: "bass", + startSec: startBar * BAR, + midi, + durSec: lenBars * BAR, + gain, + decayMul: DECAY.bass * 1.4, + pan: 0, + }); +} + +// ── a single sparkle in the lit mode (glockenspiel/kalimba prism glints) ──── +function glint(out, { startBar, beat, deg, oct, beats, mode, voice = "glockenspiel", gain = 0.11, pan = 0.3 }) { + const tab = MODES[mode]; + const idx = ((deg - 1) % 7 + 7) % 7; + const extraOct = Math.floor((deg - 1) / 7); + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi: ROOT + tab[idx] + 12 * (oct + extraOct), + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * 1.7, + pan, + }); +} + +const events = []; + +// ════════════════════════════════════════════════════════════════════════ +// ARC: one contour, refracted Ionian → Lydian → Dorian → Aeolian → Ionian. +// ════════════════════════════════════════════════════════════════════════ + +// ── PASS 0 · IONIAN (bars 0–5): warm noon. Hush stated plainly so the ear +// learns the contour before the prism turns. ──────────────────────────── +{ + const mode = "ionian"; + lay(events, refract(C.hush, mode), { startBar: 0, gain: 0.52, decayMul: 1.7 }); + // a soft answering hush, sequenced up a 3rd (degrees), same mode + lay(events, refract(seq(C.hush, 2), mode), { startBar: 2, gain: 0.42, decayMul: 1.7 }); + lay(events, refract(breathe(C.hush, 1), mode), { startBar: 4, gain: 0.46, decayMul: 1.8 }); + bass(events, 0, ROOT - 24, 2, 0.4); bass(events, 2, ROOT - 24, 2, 0.4); bass(events, 4, ROOT - 24, 2, 0.38); + pedal(events, 0, 6, mode, 0.12); + glint(events, { startBar: 1, beat: 1.5, deg: 5, oct: 1, beats: 1.5, mode, gain: 0.09, pan: 0.3 }); +} + +// ── PASS 1 · LYDIAN (bars 6–11): bright dawn. The twinkle climbing-wave, now +// the #4 lifting the roof — the same wave reaches a brighter ceiling. ──── +{ + const mode = "lydian"; + lay(events, refract(C.twinkle, mode), { startBar: 6, gain: 0.5, panSpread: 0.36 }); + // answer: twinkle sequenced up a 4th (lands the #4 high → max shimmer) + lay(events, refract(seq(C.twinkle, 3), mode), { startBar: 8, gain: 0.44, panSpread: 0.4 }); + // flyHigh fragment — the butterfly catching the Lydian light + lay(events, refract(C.flyHigh, mode), { startBar: 10, gain: 0.44, panSpread: 0.42 }); + bass(events, 6, ROOT - 24, 2, 0.35); bass(events, 8, ROOT - 12 + MODES[mode][4], 2, 0.34); bass(events, 10, ROOT - 24, 2, 0.34); + pedal(events, 6, 6, mode, 0.12); + glint(events, { startBar: 7, beat: 1.0, deg: 4, oct: 2, beats: 1.5, mode, voice: "kalimba", gain: 0.12, pan: -0.26 }); // the #4 glinting + glint(events, { startBar: 11, beat: 0.5, deg: 1, oct: 2, beats: 1.5, mode, gain: 0.11, pan: 0.34 }); +} + +// ── PASS 2 · DORIAN (bars 12–17): cool dusk. The wow wobble + baba slinky-bap +// refracted minor-but-hopeful — the b3 cools the tune, the natural 6 keeps +// it from going fully sad. The same wobble, a cloud crossing the sun. ───── +{ + const mode = "dorian"; + lay(events, refract(C.wow, mode), { startBar: 12, gain: 0.46, decayMul: 1.6, panSpread: 0.3 }); + // baba slinky-bap, refracted dorian + lay(events, refract(C.baba, mode), { startBar: 14, gain: 0.44, decayMul: 1.5, panSpread: 0.34 }); + // a sequenced echo of the wow a step down — dusk deepening + lay(events, refract(seq(C.wow, -1), mode), { startBar: 16, gain: 0.38, decayMul: 1.6, panSpread: 0.28 }); + bass(events, 12, ROOT - 24, 2, 0.34); bass(events, 14, ROOT - 24 + MODES[mode][3], 2, 0.34); bass(events, 16, ROOT - 24, 2, 0.34); + pedal(events, 12, 6, mode, 0.12); + glint(events, { startBar: 13, beat: 2.0, deg: 3, oct: 2, beats: 1.5, mode, gain: 0.1, pan: 0.3 }); // the cooled b3 + glint(events, { startBar: 15, beat: 1.5, deg: 6, oct: 1, beats: 1.5, mode, voice: "kalimba", gain: 0.11, pan: -0.28 }); +} + +// ── PASS 3 · AEOLIAN (bars 18–23): deep night, the darkest light. Twinkle +// INVERTED (the wave folding back into itself) and refracted aeolian so the +// b6/b7 pull it lowest — the same contour seen from the far side of the +// prism, in shadow. ────────────────────────────────────────────────────── +{ + const mode = "aeolian"; + lay(events, refract(invert(C.twinkle), mode), { startBar: 18, gain: 0.44, decayMul: 1.6, panSpread: 0.3 }); + // retrograde hush, aeolian — the opening sigh walked backwards in the dark + lay(events, refract(retro(C.hush), mode), { startBar: 20, gain: 0.4, decayMul: 1.7 }); + // wow once more, aeolian, lowest color, sequenced down — night's deepest point + lay(events, refract(seq(C.wow, -2), mode), { startBar: 22, gain: 0.36, decayMul: 1.7, panSpread: 0.26 }); + bass(events, 18, ROOT - 24, 2, 0.34); bass(events, 20, ROOT - 24 + MODES[mode][5], 2, 0.32); bass(events, 22, ROOT - 24, 2, 0.32); + pedal(events, 18, 6, mode, 0.12); + glint(events, { startBar: 19, beat: 1.5, deg: 6, oct: 1, beats: 2, mode, gain: 0.09, pan: 0.32 }); // the b6 in shadow + glint(events, { startBar: 23, beat: 1.0, deg: 7, oct: 1, beats: 2, mode, voice: "kalimba", gain: 0.09, pan: -0.24 }); +} + +// ── PASS 4 · IONIAN RETURN (bars 24–29): the prism set down. The sleep cadence +// comes home to warm noon — the tune resolved, recognizable, settled. A +// final close hush echo closes the frame. ─────────────────────────────── +{ + const mode = "ionian"; + lay(events, refract(C.sleep, mode), { startBar: 24, gain: 0.48, decayMul: 1.9, panSpread: 0.18 }); + // a final, faint, close hush echo — the recognizable thread, last light. + lay(events, refract(breathe(C.hush, 2), mode), { startBar: 27, gain: 0.42, decayMul: 2.0, panSpread: 0.16 }); + bass(events, 24, ROOT - 24, 2, 0.36); bass(events, 26, ROOT - 12 + MODES[mode][4], 2, 0.33); bass(events, 28, ROOT - 24, 2, 0.33); + pedal(events, 24, 6, mode, 0.13); // long resolving E pedal — prism back to white + glint(events, { startBar: 27, beat: 1.0, deg: 1, oct: 2, beats: 4, mode, gain: 0.08, pan: 0.3 }); + glint(events, { startBar: 28, beat: 1.5, deg: 5, oct: 1, beats: 3, mode, voice: "kalimba", gain: 0.09, pan: -0.24 }); +} + +const { mp3, durationSec } = renderLullaby(events, { + name: "prismbaba", + here: HERE, + title: "prismbaba", + reverb: { wet: 0.4, decay: 0.86, damp: 0.36 }, // glassy prism-room + healingHz: 639, // Solfeggio FA — sits kindly under E modal home + fadeIn: 1.4, + fadeOut: 6.0, + tailSec: 6.0, + peak: 0.84, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/rockingbaba.mjs b/pop/marimba/lullabies/variations/rockingbaba.mjs new file mode 100644 index 0000000000..08ab3f368d --- /dev/null +++ b/pop/marimba/lullabies/variations/rockingbaba.mjs @@ -0,0 +1,263 @@ +// rockingbaba.mjs — GRADUAL ELABORATION OVER OSTINATO. +// +// The rocking-chair bass never changes: a slow, hypnotic F2->C2->F2 sway in +// every 3/4 bar (the chair tipping forward and rolling back) holds rock-steady +// from first breath to last. Over that unbroken ground, the MELODY is the whole +// drama — it is born as just TWO notes (the marimbaba "hush" sigh: A4->F4) and +// then grows, bar by bar, into an ever-more-ornate line: +// +// • two notes — the bare sigh, A4->F4, all the room in the world. +// • + passing tones — the gap between them filled in (A G F). +// • + neighbor figs — each note circled by its upper/lower neighbor. +// • + turns — four-note turns (above-note-below-note) bloom on beats. +// • + sequences — the turn figure sequenced up the pentatonic, climbing. +// • + cascading runs — full descending pentatonic cascades, grace flurries, +// the marimbaba "twinkle" and "baba" cells diminished +// into fast tumbles. THIS is the most-awake bloom. +// • then LIQUIDATION — the runs thin, the turns drop their tails, the +// neighbors fall away, until the melody is two notes again (A4->F4), then +// one note, then silence — sleep wins, but the chair keeps rocking a few +// beats more before it, too, comes to rest. +// +// Identity kept: F MAJOR PENTATONIC, rosewood lead, the rocking ~50 BPM 3/4 +// sway, the gentle blooming rings, and the marimbaba thread — every bloom is +// built from the hush / twinkle / baba / sleep MOTIF cells, just developed hard. +// +// Run: node variations/rockingbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 50; // slow rocking +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; // 3/4 +const DECAY = 2.1; // long rosewood rings, blooming into each other + +// ── F MAJOR PENTATONIC as an ordered ladder of MIDI pitches across octaves. +// We address melody by SCALE INDEX (…, F4=0, G4=1, A4=2, C5=3, D5=4, F5=5, …) +// so transpose / sequence / neighbor / turn are just integer math on the +// ladder. Root F, degrees {F,G,A,C,D}. ────────────────────────────────────── +const PENTA_PCS = [5, 7, 9, 0, 2]; // F G A C D (pitch classes) +const LADDER = []; +for (let oct = 1; oct <= 7; oct++) { + for (const pc of PENTA_PCS) { + LADDER.push((oct + 1) * 12 + pc); // MIDI; pc already 0..11 + } +} +LADDER.sort((a, b) => a - b); +// index of a named pitch, snapped to nearest ladder rung. +function idx(note) { + const target = typeof note === "number" ? note : m(note); + let best = 0, bestD = 99; + for (let i = 0; i < LADDER.length; i++) { + const d = Math.abs(LADDER[i] - target); + if (d < bestD) { bestD = d; best = i; } + } + return best; +} +const pitch = (i) => LADDER[Math.max(0, Math.min(LADDER.length - 1, Math.round(i)))]; + +// anchor degrees we keep returning to (the marimbaba sigh A4->F4). +const A4 = idx("A4"); +const F4 = idx("F4"); +const F5 = idx("F5"); +const A5 = idx("A5"); +const C6 = idx("C6"); + +// ── melody helpers — a "cell" is [scaleIndex, beats] tuples. ──────────────── +const transpose = (cell, steps) => cell.map(([i, b]) => [i + steps, b]); +const retrograde = (cell) => [...cell].reverse(); +function invert(cell, pivot = cell[0][0]) { + return cell.map(([i, b]) => [2 * pivot - i, b]); +} +// sequence: repeat a cell N times, shifting by `step` ladder-rungs each copy. +function sequence(cell, n, step) { + const out = []; + for (let k = 0; k < n; k++) out.push(...transpose(cell, k * step)); + return out; +} +// neighbor figure: note, upper-neighbor, note (decorate a single index). +const neighbor = (i, b, up = 1) => [[i, b / 2], [i + up, b / 4], [i, b / 4]]; +// turn: above, note, below, note — the classic four-note ornament. +const turn = (i, b) => [[i + 1, b / 4], [i, b / 4], [i - 1, b / 4], [i, b / 4]]; +// descending pentatonic run of `len` rungs starting at top, total `beats`. +function runDown(top, len, beats) { + const out = [], step = beats / len; + for (let k = 0; k < len; k++) out.push([top - k, step]); + return out; +} +function runUp(bottom, len, beats) { + const out = [], step = beats / len; + for (let k = 0; k < len; k++) out.push([bottom + k, step]); + return out; +} + +// ── place a melody cell of [scaleIndex, beats] into events at a bar/beat. ──── +const events = []; +function placeMelody(cell, barStart, gain, opts = {}) { + const { gainSlope = 0, decayMul = DECAY, pan = 0, durMul = 1.25 } = opts; + let beatPos = 0; + const n = cell.length; + cell.forEach(([i, b], k) => { + const t = barStart * BAR + beatPos * BEAT; + const g = gain + gainSlope * (k / Math.max(1, n - 1)); + events.push({ + preset: "rosewood", + startSec: t, + midi: pitch(i), + durSec: b * BEAT * durMul, + gain: Math.max(0.12, g), + decayMul, + pan: pan + (i - F5) * 0.012, // gently fan high notes right + }); + beatPos += b; + }); +} + +// grace-note flurry just BEFORE a beat: tiny soft pickups leading into `toIdx`. +function grace(barStart, beatInBar, fromIdx, toIdx, gain) { + const steps = toIdx - fromIdx; + const count = Math.min(4, Math.abs(steps)); + if (count === 0) return; + const dir = Math.sign(steps); + const gBeat = 0.085; // each grace ~very fast + for (let k = 0; k < count; k++) { + const i = toIdx - dir * (count - k); + events.push({ + preset: "rosewood", + startSec: barStart * BAR + (beatInBar - (count - k) * gBeat) * BEAT, + midi: pitch(i), + durSec: 0.4 * BEAT, + gain: gain * 0.55, + decayMul: DECAY * 0.8, + pan: 0.05, + }); + } +} + +// ══════════════════════════════════════════════════════════════════════════ +// THE UNCHANGING ROCKING-CHAIR BASS OSTINATO — F2 down-rock, C2 up-rock, every +// bar, rock-steady, hypnotic. It runs the whole length unchanged in feel. +// ══════════════════════════════════════════════════════════════════════════ +const TOTAL_BARS = 30; +for (let bar = 0; bar < TOTAL_BARS; bar++) { + events.push({ + preset: "bass", + startSec: bar * BAR, + midi: m("F2"), + durSec: 2.3 * BEAT, + gain: 0.5, + decayMul: 1.9, + pan: -0.07, + }); + events.push({ + preset: "bass", + startSec: bar * BAR + 1.5 * BEAT, + midi: m("C2"), + durSec: 1.5 * BEAT, + gain: 0.4, + decayMul: 1.85, + pan: 0.07, + }); + // a soft mid "rock" pad on the F to thicken the ground without changing it. + if (bar % 2 === 0) { + events.push({ + preset: "bass", + startSec: bar * BAR, + midi: m("F3"), + durSec: 2.6 * BEAT, + gain: 0.16, + decayMul: 2.0, + pan: 0.0, + }); + } +} + +// ══════════════════════════════════════════════════════════════════════════ +// THE MELODY ARC — born two notes, bloom to ornate, liquidate to two notes. +// Each stage hangs on the SAME marimbaba sigh skeleton (A4 -> F4), elaborated. +// ══════════════════════════════════════════════════════════════════════════ + +// ─ Stage 0 (bars 0-3): TWO NOTES. The bare hush sigh. Vast space. ─ +placeMelody([[A4, 1], [F4, 2]], 0, 0.42, { gainSlope: -0.04 }); +placeMelody([[A4, 1.5], [F4, 1.5]], 2, 0.4, { gainSlope: -0.03 }); + +// ─ Stage 1 (bars 4-7): + PASSING TONES. Fill the A->F gap (A G F), and a +// rising answer F->G->A so the line starts to walk. ─ +placeMelody([[A4, 1], [A4 - 1, 1], [F4, 1]], 4, 0.44); // A G F +placeMelody([[F4, 1], [F4 + 1, 1], [A4, 1]], 5, 0.44); // F G A (answer) +placeMelody([[A4, 0.75], [A4 - 1, 0.75], [F4, 1.5]], 6, 0.44); // quicker A G F +placeMelody([[F4, 1.5], [A4, 1.5]], 7, 0.42); // back to the sigh, wider + +// ─ Stage 2 (bars 8-11): + NEIGHBOR FIGURES. Each pillar note circled by its +// upper neighbor; the sigh decorated at both ends. ─ +placeMelody([...neighbor(A4, 1.5, 1), ...neighbor(F4, 1.5, 1)], 8, 0.46); +placeMelody([...neighbor(A4, 1, 1), [A4 - 1, 1], ...neighbor(F4, 1, 1)], 9, 0.46); +// lower-neighbor variant + a small reach up to C5 +placeMelody([[A4, 0.75], [A4 - 1, 0.5], [A4, 0.75], [idx("C5"), 1]], 10, 0.48); +placeMelody([...neighbor(A4, 1, -1), [F4, 1.5]], 11, 0.46, { gainSlope: -0.02 }); + +// ─ Stage 3 (bars 12-15): + TURNS, sequenced. The four-note turn blooms on the +// sigh tones, then the turn figure climbs the pentatonic (sequence up). ─ +placeMelody([...turn(A4, 1.5), ...turn(F4, 1.5)], 12, 0.5); +placeMelody(sequence(turn(F4, 1), 3, 1), 13, 0.5, { gainSlope: 0.04 }); // turns climbing +placeMelody([...turn(idx("C5"), 1), ...turn(A4, 1), ...turn(F4, 1)], 14, 0.5); // turns descending +// a turn that resolves up into the high octave — first reach for the bloom. +placeMelody([...turn(A4, 1), ...runUp(A4, 4, 1), [F5, 1]], 15, 0.52, { gainSlope: 0.05 }); + +// ─ Stage 4 (bars 16-20): CASCADING RUNS — the most ornate, most-awake bloom. +// marimbaba "twinkle" + "baba" cells diminished into fast tumbles, grace +// flurries, sequenced runs spanning two octaves. ─ +// twinkle, diminished and pushed up an octave (climbing wave). +const twinkleCell = MOTIFS.twinkle.map(([nn, b]) => [idx(nn), b * 0.5]); +placeMelody(twinkleCell, 16, 0.56, { gainSlope: 0.05, pan: 0.06 }); +grace(16, 0, F5, A5, 0.5); +// baba slinky cell, diminished — fast wobble tumble. +const babaCell = MOTIFS.baba.map(([nn, b]) => [idx(nn), b * 0.6]); +placeMelody(babaCell, 17, 0.56, { gainSlope: 0.04, pan: -0.05 }); +// a big cascading descending run from the top of the bloom, two octaves. +placeMelody(runDown(C6, 9, 3), 18, 0.58, { gainSlope: -0.03, durMul: 0.9, pan: 0.08 }); +grace(18, 0, A5, C6, 0.55); +// sequenced rising runs answering the cascade (climb back up, hemiola-ish). +placeMelody([...runUp(F4 + 7, 4, 1.5), ...runUp(F4 + 9, 4, 1.5)], 19, 0.56, { gainSlope: 0.04, pan: 0.05 }); +// the peak: a turn + grace-flurry + cascade, the line at its most ornate. +placeMelody([...turn(C6, 0.75), ...runDown(C6, 6, 2.25)], 20, 0.58, { gainSlope: -0.04, durMul: 0.85, pan: 0.07 }); +grace(20, 0, A5, C6, 0.56); + +// ─ Stage 5 (bars 21-25): LIQUIDATION. Runs thin to turns, turns drop their +// tails to neighbors, neighbors fall to passing tones — gravity pulling the +// line back down toward the sigh, each bar simpler & quieter than the last. ─ +placeMelody([...runDown(A5, 5, 1.5), ...turn(F5, 1.5)], 21, 0.5, { gainSlope: -0.05 }); // run -> turn +placeMelody([...turn(F5, 1), ...neighbor(idx("C5"), 1, 1), [A4, 1]], 22, 0.46, { gainSlope: -0.05 }); // turn -> neighbor +placeMelody([...neighbor(A4, 1.5, 1), [A4 - 1, 0.75], [F4, 0.75]], 23, 0.42, { gainSlope: -0.05 }); // neighbor -> passing +placeMelody([[A4, 1], [A4 - 1, 1], [F4, 1]], 24, 0.38, { gainSlope: -0.04 }); // passing tones only (A G F) +placeMelody([[A4, 1.5], [F4, 1.5]], 25, 0.34, { gainSlope: -0.03 }); // TWO NOTES again — the sigh returns + +// ─ Stage 6 (bars 26-29): sleep wins. Two notes -> one note -> silence, while +// the rocking chair keeps swaying then slows to rest. The marimbaba "sleep" +// cell, reduced to its last falling pair, sunk low and barely-there. ─ +placeMelody([[A4, 2], [F4, 4]], 26, 0.3, { gainSlope: -0.06, durMul: 1.4 }); // last sigh, stretched +placeMelody([[F4, 6]], 28, 0.24, { durMul: 1.6 }); // ONE NOTE — the held breath +// a single deep root far below, the eyelid closing. +events.push({ preset: "rosewood", startSec: 28 * BAR + 1.5 * BEAT, midi: pitch(F4) - 12, durSec: 7 * BEAT, gain: 0.18, decayMul: DECAY, pan: 0 }); + +// the rocking chair coming to rest a few beats after the melody dies: the last +// down-rock holds long and fades — but it does NOT change pattern, just slows. +events.push({ preset: "bass", startSec: TOTAL_BARS * BAR, midi: m("F1"), durSec: 9 * BEAT, gain: 0.36, decayMul: 2.2, pan: 0 }); +events.push({ preset: "bass", startSec: TOTAL_BARS * BAR + 2 * BEAT, midi: m("C2"), durSec: 4 * BEAT, gain: 0.24, decayMul: 2.0, pan: 0.06 }); + +const { mp3, durationSec } = renderLullaby(events, { + name: "rockingbaba", + here: HERE, + title: "rockingbaba (F major pentatonic, gradual elaboration over ostinato)", + reverb: { wet: 0.32, decay: 0.88, damp: 0.5 }, + fadeIn: 1.8, + fadeOut: 5.0, + tailSec: 3.5, + peak: 0.84, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/snowbaba.mjs b/pop/marimba/lullabies/variations/snowbaba.mjs new file mode 100644 index 0000000000..a2cff95b8c --- /dev/null +++ b/pop/marimba/lullabies/variations/snowbaba.mjs @@ -0,0 +1,269 @@ +// snowbaba.mjs — a falling-snow riff on the marimbaba lullaby, dissolved into +// DRIFTING SINGLE NOTES that sink through the registers and settle to silence. +// +// Direction: Bb major, glockenspiel, ~50 BPM. The whistlegraph tune (hush → +// twinkle → wow → baba → sleep) is scattered into solitary, octave-displaced +// flakes — each note drifts down a register or two from the last, the line +// ever sparser and slower, until the snow has settled and only the room rings. +// +// DEVELOPMENT STRATEGY — DOWNWARD OCTAVE-DISPLACED POINTILLISM: +// - The marimbaba contour is kept as the melodic DNA, but the tune is broken +// into single drifting notes — no chords in the lead, just flakes. +// - Each successive note is biased DOWNWARD: a descending octave-walk runs +// under the cells so the whole field of notes sinks through the registers +// as the piece proceeds (the snow falling from sky to ground). +// - The motion THINS and SLOWS: early passes drift in regular eighths/quarters, +// later passes stretch the inter-onset gaps and drop notes, so the flurry +// becomes the last few flakes, then one, then the bed alone. +// - A recognizable thread survives: the descending hush sigh opens it (snow +// starting), the wow wobble and slinky baba are legible inside the drift, +// and the final sleep cadence settles to Bb in the low home octave — +// the snow at rest on the ground. +// +// Run: node variations/snowbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 50; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; // 3/4 + +// ── Bb major scale-fold (root pc = 10). Snap melodic voices to the mode. ── +const ROOT = 10; // Bb +const MAJOR = [0, 2, 4, 5, 7, 9, 11]; +function snap(midi) { + const pc = ((midi % 12) + 12) % 12; + const rel = ((pc - ROOT) % 12 + 12) % 12; + let best = MAJOR[0], bestD = 99; + for (const s of MAJOR) { + const d = Math.min(Math.abs(s - rel), 12 - Math.abs(s - rel)); + if (d < bestD) { bestD = d; best = s; } + } + const base = midi - rel; + let cand = base + best; + if (cand - midi > 6) cand -= 12; + if (midi - cand > 6) cand += 12; + return cand; +} + +const LEAD = "glockenspiel"; + +// ── motif → Bb-folded MIDI cells. The marimbaba MOTIFS are written in F; +// transpose +5 up into Bb territory, snap into the mode. Each cell is a +// list of [midi, beats] pairs we drift downward and re-rhythm freely. ── +function cell(motif) { + return MOTIFS[motif].map(([name, beats]) => [snap(m(name) + 5), beats]); +} + +// transpose a whole cell (for sequencing) and re-snap into Bb. +function tpose(c, semis) { return c.map(([midi, beats]) => [snap(midi + semis), beats]); } +// retrograde — play the cell backwards (the snow re-falling). +function retro(c) { return [...c].reverse(); } + +// ── DOWNWARD DRIFT ENGINE ──────────────────────────────────────────────── +// drift(cell, opts) scatters each note into a SINGLE drifting flake whose +// octave sinks as the cell proceeds. `start` is the octave offset of the first +// flake; `fall` is how many octaves the whole cell descends across its length; +// `jitter` adds gentle per-note wobble (deterministic via seed) so flakes +// don't fall in a straight column. The pitches stay in the mode after the +// octave shift. +function lcg(seed) { + let s = (seed * 2654435761) >>> 0; + return () => ((s = (s * 1103515245 + 12345) >>> 0) / 4294967296); +} +function drift(c, { start = 0, fall = 1, jitter = 0, seed = 1 } = {}) { + const rnd = lcg(seed); + const n = Math.max(1, c.length - 1); + return c.map(([midi, beats], i) => { + // smooth descent across the cell + small octave wobble on some flakes + const sink = -Math.round((i / n) * fall); + const wob = jitter && rnd() < jitter ? (rnd() < 0.5 ? -1 : 1) : 0; + return [snap(midi + (start + sink + wob) * 12), beats]; + }); +} + +// ── lay a cell as drifting flakes: gaps OPEN as `spacing` grows, so the field +// thins over the piece. `spacing` multiplies the rests between flakes; +// `slow` multiplies each flake's own beats (longer ring as snow settles). +// Pan follows register: high flakes drift right, low flakes left, so the +// fall is spatial as well as registral. ───────────────────────────────── +function lay(out, c, { startBar, beat0 = 0, voice = LEAD, gain = 0.46, panSpread = 0.42, decayMul = 1.7, spacing = 1, slow = 1 } = {}) { + let beat = beat0; + for (let i = 0; i < c.length; i++) { + const [midi, beats] = c[i]; + const reg = (midi - 70) / 24; // ~ -1..+1 across the fall + const pan = Math.max(-0.5, Math.min(0.5, reg * panSpread)); + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi, + durSec: beats * slow * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * decayMul, + pan, + }); + beat += beats * spacing; + } + return beat; +} + +// a lone flake — a single drifting glockenspiel/kalimba note. +function flake(out, { startBar, beat, name, beats, voice = LEAD, gain = 0.2, pan = 0.3, decayMul = 1.8, oct = 0 }) { + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi: snap(m(name) + oct * 12), + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * decayMul, + pan, + }); +} + +// vibraphone_off pad (Bb-folded triad) breathing under a span of bars — the +// quiet grey sky behind the snow. +const padDeg = (deg, oct) => snap(m(["Bb", "C", "D", "Eb", "F", "G", "A"][deg] + oct)); +function pad(out, startBar, lenBars, triad, baseOct, gain = 0.12) { + for (let i = 0; i < triad.length; i++) { + out.push({ + preset: "vibraphone_off", + startSec: startBar * BAR, + midi: padDeg(triad[i], baseOct), + durSec: lenBars * BAR + BEAT, + gain, + decayMul: 2.1, + pan: i === 0 ? -0.22 : i === 1 ? 0.0 : 0.22, + }); + } +} + +// soft bass root under a bar (kept low + clear) — the ground gathering snow. +function bass(out, startBar, name, lenBars = 1, gain = 0.38) { + out.push({ + preset: "bass", + startSec: startBar * BAR, + midi: snap(m(name)), + durSec: lenBars * BAR, + gain, + decayMul: DECAY.bass * 1.4, + pan: 0, + }); +} + +const events = []; + +// ════════════════════════════════════════════════════════════════════════ +// ARC: first flakes from the sky → a sinking flurry → ever sparser, slower +// → the last flakes → settled on the ground, silence. +// ════════════════════════════════════════════════════════════════════════ + +// ── PASS 0 (bars 0–3): FIRST FLAKES. The hush sigh as single high glints, +// each a register lower than the last — snow beginning to fall from a +// high, still sky. Dense-ish, regular drift. ───────────────────────────── +{ + const hush = cell("hush"); + lay(events, drift(hush, { start: 1, fall: 1, jitter: 0.25, seed: 3 }), + { startBar: 0, gain: 0.42, decayMul: 1.7, spacing: 1, slow: 1 }); + // a couple of stray early flakes overhead + flake(events, { startBar: 1, beat: 0.6, name: "F6", beats: 1.5, gain: 0.16, pan: 0.34 }); + flake(events, { startBar: 2, beat: 1.4, name: "Bb6", beats: 1.5, gain: 0.14, pan: -0.3, voice: "kalimba" }); + bass(events, 0, "Bb2", 2, 0.36); + bass(events, 2, "Bb2", 2, 0.36); + pad(events, 0, 4, [0, 2, 4], 4, 0.11); // Bb sky +} + +// ── PASS 1 (bars 4–9): the flurry THICKENS, twinkle climbing then sinking. +// The climbing wave still climbs in pitch-class but each step is dragged a +// register downward — the snow swirling up briefly then falling. Two +// sequenced passes drift further down. ─────────────────────────────────── +{ + const tw = cell("twinkle"); + lay(events, drift(tw, { start: 1, fall: 1, jitter: 0.3, seed: 7 }), + { startBar: 4, gain: 0.44, panSpread: 0.44, spacing: 1, slow: 1 }); + // answer a 3rd below, drifting one octave lower — the field deepening + lay(events, drift(tpose(tw, -3), { start: 0, fall: 1, jitter: 0.3, seed: 11 }), + { startBar: 6, gain: 0.4, panSpread: 0.44, spacing: 1.1, slow: 1.05 }); + // a flyHigh fragment, but DRIFTING DOWN — the highest flakes already sinking + lay(events, drift(cell("flyHigh"), { start: 0, fall: 2, jitter: 0.2, seed: 17 }), + { startBar: 8, gain: 0.4, panSpread: 0.45, spacing: 1.1, slow: 1.1 }); + bass(events, 4, "Bb2", 2, 0.32); bass(events, 6, "Eb2", 2, 0.32); bass(events, 8, "F2", 2, 0.32); + pad(events, 4, 3, [0, 2, 4], 4, 0.11); + pad(events, 7, 3, [3, 5, 0], 4, 0.11); // Eb + flake(events, { startBar: 5, beat: 2.2, name: "G6", beats: 1.5, gain: 0.13, pan: 0.34 }); + flake(events, { startBar: 9, beat: 0.6, name: "D6", beats: 2, gain: 0.13, pan: -0.28, voice: "kalimba" }); +} + +// ── PASS 2 (bars 10–15): the wow wobble and slinky baba, now DRIFTING DOWN +// through the middle registers. Still recognizable, but each gesture sinks +// a full octave or two over its length, and the gaps begin to OPEN — the +// flurry starting to thin as the snow finds its weight. ────────────────── +{ + const wow = cell("wow"); + lay(events, drift(wow, { start: 0, fall: 2, jitter: 0.25, seed: 23 }), + { startBar: 10, gain: 0.42, panSpread: 0.42, decayMul: 1.8, spacing: 1.15, slow: 1.1 }); + // slinky baba, drifting one octave lower again, gaps opening further + const baba = cell("baba"); + lay(events, drift(tpose(baba, -5), { start: 0, fall: 2, jitter: 0.2, seed: 29 }), + { startBar: 13, gain: 0.4, panSpread: 0.42, decayMul: 1.8, spacing: 1.25, slow: 1.15 }); + // scattered low flakes catching the light as they pass + flake(events, { startBar: 11, beat: 2.0, name: "Bb4", beats: 2, gain: 0.16, pan: -0.24, voice: "kalimba" }); + flake(events, { startBar: 14, beat: 1.5, name: "D5", beats: 2.5, gain: 0.15, pan: 0.22 }); + bass(events, 10, "F2", 2, 0.3); bass(events, 12, "Bb2", 2, 0.3); bass(events, 14, "Eb2", 2, 0.3); + pad(events, 10, 3, [4, 6, 1], 4, 0.11); // F (dominant) + pad(events, 13, 3, [0, 2, 4], 4, 0.11); // Bb +} + +// ── PASS 3 (bars 16–21): EVER SPARSER, SLOWER. The twinkle wave returns in +// retrograde (the snow re-falling, settling), pulled down into the low-mid +// register; the spacing stretches wide and each flake rings longer. Only a +// few flakes left aloft now. ───────────────────────────────────────────── +{ + const twR = retro(cell("twinkle")); + lay(events, drift(twR, { start: -1, fall: 1, jitter: 0.15, seed: 37 }), + { startBar: 16, gain: 0.38, panSpread: 0.3, decayMul: 1.9, spacing: 1.5, slow: 1.4 }); + // a sparse hush echo, very low and slow — almost the last of it + lay(events, drift(tpose(cell("hush"), -12), { start: 0, fall: 1, jitter: 0 }), + { startBar: 19, gain: 0.36, panSpread: 0.2, decayMul: 2.0, spacing: 1.7, slow: 1.6 }); + bass(events, 16, "Eb2", 2, 0.3); bass(events, 18, "F2", 2, 0.3); bass(events, 20, "Bb2", 2, 0.3); + pad(events, 16, 3, [3, 5, 0], 4, 0.11); // Eb + pad(events, 19, 4, [4, 6, 1], 4, 0.11); // F resolving toward home + flake(events, { startBar: 17, beat: 2.0, name: "Bb5", beats: 3, gain: 0.11, pan: 0.3 }); + flake(events, { startBar: 20, beat: 1.0, name: "F5", beats: 3, gain: 0.1, pan: -0.24, voice: "kalimba" }); +} + +// ── PASS 4 (bars 22–27): SETTLED. The sleep cadence comes home, low and +// close, in slow single flakes — the snow at rest on the ground. The last +// notes drift wide apart, the very last one alone, then only the bed rings +// to silence. ──────────────────────────────────────────────────────────── +{ + const sleep = cell("sleep"); + // the cadence flat in the low home octave, very slow, gaps wide — the + // collapse to stillness made literal. + lay(events, drift(tpose(sleep, -12), { start: 0, fall: 0, jitter: 0 }), + { startBar: 22, gain: 0.44, panSpread: 0.16, decayMul: 2.0, spacing: 1.9, slow: 1.7 }); + bass(events, 22, "Bb2", 2, 0.32); bass(events, 24, "F2", 2, 0.3); bass(events, 26, "Bb2", 2, 0.3); + pad(events, 22, 6, [0, 2, 4], 4, 0.12); // Bb — long resolving ground + + // the final few flakes, ever farther apart, the last one alone over silence. + flake(events, { startBar: 24, beat: 2.0, name: "Bb4", beats: 4, gain: 0.13, pan: 0.26 }); + flake(events, { startBar: 26, beat: 1.0, name: "F4", beats: 4, gain: 0.11, pan: -0.2, voice: "kalimba" }); + flake(events, { startBar: 27, beat: 2.0, name: "Bb3", beats: 5, gain: 0.1, pan: 0.0 }); // last flake, settled +} + +const { mp3, durationSec } = renderLullaby(events, { + name: "snowbaba", + here: HERE, + title: "snowbaba", + reverb: { wet: 0.4, decay: 0.86, damp: 0.36 }, // soft, snow-muffled room + fadeIn: 1.4, + fadeOut: 6.5, + tailSec: 6.5, + peak: 0.82, + healingHz: 852, // Solfeggio LA — high, crystalline, suits the snow + Bb +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/spiralbaba.mjs b/pop/marimba/lullabies/variations/spiralbaba.mjs new file mode 100644 index 0000000000..d08c7e6e63 --- /dev/null +++ b/pop/marimba/lullabies/variations/spiralbaba.mjs @@ -0,0 +1,373 @@ +// spiralbaba.mjs — a hypnotic, endlessly-climbing riff on the marimbaba +// lullaby, built as a CONTINUOUS TRANSPOSITION SPIRAL. +// +// Direction: A minor, kelon-led, ~58 BPM. The whistlegraph motif climbs by a +// constant interval on every repetition — a Shepard-tone illusion in marimba: +// each pass starts a fixed step higher than the last, while voices fade IN at +// the bottom and fade OUT at the top so the ear never finds the seam. The +// spiral rises and rises (forever, it seems), reaches a hovering apex, then +// UNWINDS — the same constant step, now descending — and finally settles home +// on A. +// +// DEVELOPMENT STRATEGY — CONTINUOUS TRANSPOSITION SPIRAL (Shepard-ish): +// - One short "spiral cell" (a folded hush→twinkle contour) is the seed. +// - Each repetition is transposed by a CONSTANT interval (STEP = +2 semis, +// a whole tone) — pass k sits at k*STEP above the seed. +// - To sustain the endless-rise illusion, every pass is voiced as a STACK of +// octave copies (a Shepard chord). A slow triangular spectral window +// weights each octave by gain: octaves near the window center are loud, +// octaves at the extremes are near-silent. As the cell climbs, copies +// crossing the top edge fade OUT while fresh copies fade IN at the bottom — +// so the perceived register stays put while the pitch class keeps rising. +// - The bass/healing root also spirals (a slow circle-of-A drone) but folds +// octave so it never actually leaves the low register. +// - At the apex the spiral HOVERS (a held shimmer), then the same engine runs +// with STEP negated: the tune unwinds, descending endlessly, until it lands +// on home A and the room exhales. +// +// Recognizable thread: the descending hush sigh opens it (close, legible), the +// twinkle climb is the spiral's DNA, and the final cadence is the marimbaba +// sleep settle, home on A. +// +// Run: node variations/spiralbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 58; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; // 3/4, like the marimbaba + +// ── A natural-minor scale-fold (root pc = 9, A). Snap melodic voices. ─────── +const ROOT = 9; // A +const MINOR = [0, 2, 3, 5, 7, 8, 10]; // natural minor +function snap(midi) { + const pc = ((midi % 12) + 12) % 12; + const rel = ((pc - ROOT) % 12 + 12) % 12; + let best = MINOR[0], bestD = 99; + for (const s of MINOR) { + const d = Math.min(Math.abs(s - rel), 12 - Math.abs(s - rel)); + if (d < bestD) { bestD = d; best = s; } + } + const base = midi - rel; + let cand = base + best; + if (cand - midi > 6) cand -= 12; + if (midi - cand > 6) cand += 12; + return cand; +} + +const LEAD = "kelon"; + +// ── the SPIRAL SEED CELL — a folded hush→twinkle contour, in A-minor land. +// The marimbaba MOTIFS are written in F; transpose +4 to lift toward A and +// snap into the minor mode. We keep this as [pitchClass-ish midi, beats] +// pairs and let the spiral engine octave-stack them. Each note carries a +// contour STEP (semitones above the cell's own root) so we can rebuild the +// chromatic class freely at any spiral height. ────────────────────────── +function cell(motif) { + return MOTIFS[motif].map(([name, beats]) => [snap(m(name) + 4), beats]); +} + +// The seed: a compact, hypnotic rise — three steps up then a gentle fall, +// looped feeling. Built from hush (sigh) + the head of twinkle (climb), so the +// whistlegraph thread is audible. We reduce to a single octave of "pitch +// content" (the engine supplies the octaves). +const SEED = (() => { + const h = cell("hush"); // C5 A4 F4 F4 → folded to A-minor sigh + const t = cell("twinkle"); // F5 A5 C6 A5 G5 → climbing wave head + // a 6-note hypnotic loop: down-sigh then up-climb, all in beats of 1–1.5 + return [ + [h[0][0], 1], [h[1][0], 1], [h[2][0], 1], // sigh down + [t[1][0], 1], [t[2][0], 1], [t[4][0], 1.5], // climb up + linger + ]; +})(); + +// ── SHEPARD SPECTRAL WINDOW ───────────────────────────────────────────────── +// Given a midi pitch, weight by how close it is to the window center. A +// triangular window over [lo, hi] in midi: peak gain at center, 0 at edges. +// This is what fades octaves in at the bottom and out at the top as the +// spiral climbs — the engine of the endless-rise illusion. +const WIN_LO = m("A2"); // 45 +const WIN_HI = m("A6"); // 93 +const WIN_CTR = (WIN_LO + WIN_HI) / 2; +const WIN_HALF = (WIN_HI - WIN_LO) / 2; +function shepardWeight(midi) { + const d = Math.abs(midi - WIN_CTR) / WIN_HALF; // 0 center .. 1 edge + if (d >= 1) return 0; + // raised-cosine for a smoother, dreamier crossfade than a hard triangle + return 0.5 + 0.5 * Math.cos(Math.PI * d); +} + +// ── lay one SPIRAL PASS: the seed cell transposed by `transpose` semitones, +// voiced as a stack of octave copies, each weighted by the Shepard window. +// Octaves whose weight is ~0 are skipped (silent edges). Returns nothing; +// pushes events. The perceived register stays centered while the pitch +// class spirals. ─────────────────────────────────────────────────────── +function spiralPass(out, { startBar, transpose, voice = LEAD, gainMul = 1, panBase = 0, decayMul = 1.7, octaves = [-2, -1, 0, 1, 2] }) { + let beat = 0; + for (let i = 0; i < SEED.length; i++) { + const [seedMidi, beats] = SEED[i]; + const base = snap(seedMidi + transpose); + const t = startBar * BAR + beat * BEAT; + // pan drifts gently with position in the cell so the spiral feels like it + // turns in space as it climbs. + const pan = Math.max(-0.5, Math.min(0.5, panBase + (i / SEED.length - 0.5) * 0.5)); + for (const oct of octaves) { + const midi = base + oct * 12; + const w = shepardWeight(midi); + if (w < 0.06) continue; // skip the faded-out edges + const gain = 0.5 * w * gainMul; + if (gain < 0.025) continue; + out.push({ + preset: voice, + startSec: t, + midi, + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * decayMul, + pan, + }); + } + beat += beats; + } +} + +// ── a low, octave-folded root drone that follows the spiral's harmonic +// center but never leaves the low register (keeps the bottom clear). ────── +function root(out, { startBar, transpose, lenBars = 1, gain = 0.4 }) { + // fold the spiral root into the A1..A2 octave so it spirals in pitch-class + // while staying anchored low. + let r = snap(m("A2") + ((transpose % 12) + 12) % 12); + while (r > m("A2")) r -= 12; + if (r < m("A1")) r += 12; + out.push({ + preset: "bass", + startSec: startBar * BAR, + midi: r, + durSec: lenBars * BAR, + gain, + decayMul: DECAY.bass * 1.4, + pan: 0, + }); +} + +// ── a vibraphone_off shimmer pad (A-minor triad, octave-folded into a fixed +// mid register) breathing under a span — the haze the spiral turns inside. ─ +function pad(out, { startBar, lenBars, transpose, gain = 0.12 }) { + // a minor triad rooted at the (folded) spiral root, kept in one register so + // it reads as a steady haze, not a moving chord. + const r = snap(m("A4") + (((transpose % 12) + 12) % 12)); + const triad = [r, snap(r + 3), snap(r + 7)]; + for (let i = 0; i < triad.length; i++) { + let v = triad[i]; + while (v > m("C5")) v -= 12; // keep the pad mid, not bright + out.push({ + preset: "vibraphone_off", + startSec: startBar * BAR, + midi: v, + durSec: lenBars * BAR + BEAT, + gain, + decayMul: 2.0, + pan: i === 0 ? -0.2 : i === 1 ? 0.0 : 0.2, + }); + } +} + +// ── a single high "spiral glint" sparkle (glockenspiel / kalimba). ────────── +function glint(out, { startBar, beat, midi, beats = 1.5, voice = "glockenspiel", gain = 0.1, pan = 0.3 }) { + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi: snap(midi), + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * 1.7, + pan, + }); +} + +const events = []; + +// ════════════════════════════════════════════════════════════════════════ +// ARC: legible hush → endless ascent spiral → hovering apex → endless +// descent (unwind) → settle home on A. +// Each pass is 3 bars (one cell = 6.5 beats ≈ a little over 2 bars; we give +// it a 3-bar slot so passes overlap-ring and breathe). STEP = whole tone. +// ════════════════════════════════════════════════════════════════════════ + +const STEP = 2; // constant spiral interval — a whole tone per pass +const PASS_BARS = 3; // bars per spiral pass + +// ── INTRO (bars 0–2): the hush sigh, CLOSE and legible, no spiral yet — so +// the marimbaba thread registers before the illusion begins. ───────────── +{ + const h = cell("hush"); + let beat = 0; + for (const [midi, beats] of h) { + events.push({ + preset: LEAD, startSec: 0 * BAR + beat * BEAT, midi: snap(midi), + durSec: beats * BEAT, gain: 0.5, decayMul: DECAY[LEAD] * 1.8, pan: -0.05, + }); + beat += beats; + } + root(events, { startBar: 0, transpose: 0, lenBars: 2, gain: 0.4 }); + pad(events, { startBar: 0, lenBars: 3, transpose: 0, gain: 0.1 }); + glint(events, { startBar: 2, beat: 1.2, midi: m("E6"), beats: 1.5, gain: 0.08, pan: 0.3 }); +} + +// ── ASCENT SPIRAL (bars 3 …): the seed climbs by STEP every pass. The +// Shepard window keeps the perceived register steady while the pitch class +// rises endlessly. Gain swells slightly toward the apex. ───────────────── +const ASCENT_PASSES = 6; // 6 * whole tone = ascending through ~an octave of class +let bar = 3; +for (let k = 0; k < ASCENT_PASSES; k++) { + const transpose = k * STEP; + // gain breathes up toward the apex, then the apex pass is the fullest. + const gainMul = 0.85 + 0.15 * (k / (ASCENT_PASSES - 1)); + spiralPass(events, { + startBar: bar, + transpose, + voice: LEAD, + gainMul, + panBase: (k % 2 === 0 ? -0.08 : 0.08), // alternate the turn direction + decayMul: 1.7, + }); + // the spiraling low root (octave-folded) under each pass + root(events, { startBar: bar, transpose, lenBars: 2, gain: 0.36 }); + if (k % 2 === 0) { + root(events, { startBar: bar + 1, transpose: transpose + STEP, lenBars: 1, gain: 0.3 }); + } + // haze pad refreshed every couple passes + if (k % 2 === 0) pad(events, { startBar: bar, lenBars: 2, transpose, gain: 0.11 }); + // rising glints — a kalimba/glock sparkle that itself climbs by STEP, + // reinforcing the spiral overhead. Octave-fold the glint into a fixed-ish + // high band so it too "rises forever". + let g = m("A5") + transpose; + while (g > m("C6")) g -= 12; // keep glints in a steady high band + glint(events, { + startBar: bar, beat: 2.0, midi: g + 12, + beats: 1.5, voice: k % 3 === 0 ? "glockenspiel" : "kalimba", + gain: 0.1, pan: k % 2 === 0 ? 0.32 : -0.28, + }); + bar += PASS_BARS; +} + +// ── APEX HOVER (≈ 2 bars): the spiral stops climbing and HOVERS — a held +// Shepard shimmer at the top of the ascent, the tune suspended. ────────── +const apexT = ASCENT_PASSES * STEP; +{ + // sustain the seed's top notes as a shimmering chord across octaves + const topMidi = snap(SEED[3][0] + apexT - STEP); // the climb peak class + for (const oct of [-2, -1, 0, 1, 2]) { + const mi = topMidi + oct * 12; + const w = shepardWeight(mi); + if (w < 0.06) continue; + events.push({ + preset: "vibraphone", + startSec: bar * BAR, + midi: mi, + durSec: 2 * BAR + BEAT, + gain: 0.4 * w, + decayMul: 1.6, + pan: oct % 2 === 0 ? -0.18 : 0.18, + }); + } + root(events, { startBar: bar, transpose: apexT, lenBars: 2, gain: 0.34 }); + pad(events, { startBar: bar, lenBars: 2, transpose: apexT, gain: 0.12 }); + glint(events, { startBar: bar, beat: 1.0, midi: m("E6"), beats: 2.5, gain: 0.1, pan: 0.34 }); + glint(events, { startBar: bar + 1, beat: 0.5, midi: m("A5"), beats: 2, voice: "kalimba", gain: 0.09, pan: -0.26 }); + bar += 2; +} + +// ── UNWIND / DESCENT SPIRAL (bars …): the same engine, STEP negated. The +// tune unwinds, the pitch class spiraling DOWN while the Shepard window +// holds the register — an endless fall back toward home. Gain eases off. ── +const DESCENT_PASSES = 5; +for (let k = 0; k < DESCENT_PASSES; k++) { + const transpose = apexT - (k + 1) * STEP; + const gainMul = 0.85 - 0.18 * (k / (DESCENT_PASSES - 1)); // settling + spiralPass(events, { + startBar: bar, + transpose, + voice: LEAD, + gainMul, + panBase: (k % 2 === 0 ? 0.08 : -0.08), + decayMul: 1.8, + }); + root(events, { startBar: bar, transpose, lenBars: 2, gain: 0.34 }); + if (k % 2 === 1) pad(events, { startBar: bar, lenBars: 2, transpose, gain: 0.1 }); + // falling glints + let g = m("A5") + transpose; + while (g < m("A4")) g += 12; + while (g > m("C6")) g -= 12; + glint(events, { + startBar: bar, beat: 1.5, midi: g + 12, + beats: 1.5, voice: k % 3 === 0 ? "glockenspiel" : "kalimba", + gain: 0.085, pan: k % 2 === 0 ? -0.3 : 0.3, + }); + bar += PASS_BARS; +} + +// ── HOME SETTLE (final bars): the marimbaba sleep cadence, transpose 0 — the +// spiral lands home on A, recognizable and resolved. No octave stack now: +// a single, close, low register. The room exhales. ─────────────────────── +{ + const sleep = cell("sleep"); + let beat = 0; + for (const [midi, beats] of sleep) { + let v = snap(midi); + while (v > m("C5")) v -= 12; // bring the cadence down, intimate + events.push({ + preset: LEAD, + startSec: bar * BAR + beat * BEAT, + midi: v, + durSec: beats * BEAT, + gain: 0.46, + decayMul: DECAY[LEAD] * 1.9, + pan: -0.04, + }); + beat += beats; + } + // a final faint hush echo — the thread closing the frame, home on A + const h = cell("hush"); + let b2 = 0; + const echoBar = bar + 3; + for (const [midi, beats] of h) { + let v = snap(midi); + while (v > m("A4")) v -= 12; + events.push({ + preset: LEAD, + startSec: echoBar * BAR + b2 * BEAT, + midi: v, + durSec: beats * BEAT, + gain: 0.38, + decayMul: DECAY[LEAD] * 2.0, + pan: 0.04, + }); + b2 += beats; + } + root(events, { startBar: bar, transpose: 0, lenBars: 2, gain: 0.34 }); + root(events, { startBar: bar + 2, transpose: 0, lenBars: 2, gain: 0.3 }); + root(events, { startBar: echoBar, transpose: 0, lenBars: 2, gain: 0.28 }); + pad(events, { startBar: bar, lenBars: 6, transpose: 0, gain: 0.12 }); // long home haze + // one last very high, very soft glint fading over the home octave + glint(events, { startBar: bar + 1, beat: 1.0, midi: m("A6"), beats: 4, gain: 0.07, pan: 0.3 }); + glint(events, { startBar: echoBar, beat: 1.5, midi: m("E6"), beats: 3, voice: "kalimba", gain: 0.07, pan: -0.24 }); +} + +const { mp3, durationSec } = renderLullaby(events, { + name: "spiralbaba", + here: HERE, + title: "spiralbaba", + reverb: { wet: 0.4, decay: 0.88, damp: 0.36 }, // deep, hypnotic spiral-room + healingHz: 852, // Solfeggio LA / "intuition" — a high A-ish glow over A minor + fadeIn: 1.6, + fadeOut: 6.0, + tailSec: 6.0, + peak: 0.82, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/starbaba.mjs b/pop/marimba/lullabies/variations/starbaba.mjs new file mode 100644 index 0000000000..e57af2dff1 --- /dev/null +++ b/pop/marimba/lullabies/variations/starbaba.mjs @@ -0,0 +1,343 @@ +// starbaba.mjs — THEME & VARIATIONS on the twinkle star (à la Mozart K.265). +// +// C major, ~58 BPM feel, glockenspiel starlight identity preserved. The seed +// (marimbaba: F major, 3/4 — the "twinkle-little-star" lyric hidden inside +// MOTIFS.twinkle) is stated plainly, then put through a real escalating set of +// variations the way Mozart spun "Ah! vous dirai-je, Maman": +// +// Theme — the bare twinkle melody, glockenspiel stars over a warm bed. +// Var I — the same tune, lightly decorated; the bed answers in turn. +// Var II — running 16ths swarm above the long melody notes (toccata sparkle). +// Var III— the melody dissolved into broken arpeggios (Alberti starfields). +// Var IV — the BASS takes the tune; high glints decorate it from above. +// Var V — a glittering DOUBLE-TIME finale, the whole sky cascading. +// Coda — a hushed final statement, the sky dimming to one last star. +// +// Everything stays in C major; the melody is remapped (not transposed) and +// then fragmented, diminished, re-rhythmed, octave-displaced and re-voiced +// hard — but the do-do-sol-sol-la-la-sol contour and the falling cadence +// stay recognizable as the thread. +// +// Run: node variations/starbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +// ── mode remap: fold any midi to the nearest pitch in C major ────────────── +const ROOT = 0; // C +const MAJOR = [0, 2, 4, 5, 7, 9, 11]; +function snap(midi) { + const pc = ((midi - ROOT) % 12 + 12) % 12; + let best = MAJOR[0], bestD = 99; + for (const d of MAJOR) { + const dist = Math.min(Math.abs(d - pc), 12 - Math.abs(d - pc)); + if (dist < bestD) { bestD = dist; best = d; } + } + return midi + (best - pc); +} +const snapName = (name, oct = 0) => snap(m(name) + oct * 12); +// the C-major scale as absolute midi, for arpeggio / scalar helpers +const SCALE = MAJOR; +function degToMidi(deg, baseOct = 5) { + // deg 0 = C(baseOct); negative/large degrees wrap octaves + const o = Math.floor(deg / 7); + const within = ((deg % 7) + 7) % 7; + return 12 * (baseOct + 1) + SCALE[within] + 12 * o; +} + +// ── timing — 58 BPM; phrases of 4 beats (the rhyme's natural meter) ───────── +const BPM = 58; +const BEAT = 60 / BPM; +let BAR = 4 * BEAT; // mutable so the final phrases can ritard + +const ev = []; +let t = 0; // running cursor in seconds, advanced phrase by phrase + +const ROSE_DEC = (DECAY.rosewood ?? 1.8) * 1.05; // warm, long ring +const GLOCK_DEC = 1.9; // let the high stars hang in the air + +// ── primitive emitters ───────────────────────────────────────────────────── + +// glockenspiel star, up high — the melody itself (default two octaves up) +function star(beatIn, name, beats, gain = 0.3, octShift = 2, pan = 0.22) { + ev.push({ + preset: "glockenspiel", + startSec: t + beatIn * BEAT, + midi: snapName(name, octShift), + durSec: beats * BEAT * 1.1, + gain, + decayMul: GLOCK_DEC, + pan, + }); +} +// star by absolute midi (for arpeggio runs computed in degrees) +function starMidi(beatIn, midi, beats, gain = 0.3, pan = 0.22, dec = GLOCK_DEC) { + ev.push({ preset: "glockenspiel", startSec: t + beatIn * BEAT, midi: snap(midi), durSec: beats * BEAT * 1.1, gain, decayMul: dec, pan }); +} +// faint shimmer — a single fragile re-strike, fills the gaps +function shimmer(beatIn, name, gain = 0.12, octShift = 2, pan = -0.26) { + ev.push({ preset: "glockenspiel", startSec: t + beatIn * BEAT, midi: snapName(name, octShift), durSec: 0.45 * BEAT, gain, decayMul: GLOCK_DEC * 1.1, pan }); +} +// soft rosewood bed (warm wood under the melody) +function bed(beatIn, name, beats, gain = 0.34, octShift = 0, pan = -0.06) { + ev.push({ preset: "rosewood", startSec: t + beatIn * BEAT, midi: snapName(name, octShift), durSec: beats * BEAT * 1.15, gain, decayMul: ROSE_DEC, pan }); +} +function bedMidi(beatIn, midi, beats, gain = 0.34, pan = -0.06) { + ev.push({ preset: "rosewood", startSec: t + beatIn * BEAT, midi: snap(midi), durSec: beats * BEAT * 1.15, gain, decayMul: ROSE_DEC, pan }); +} +// low rosewood root, grounding each phrase (kept low + clear, no remap) +function root(name, gain = 0.32, beats = null) { + ev.push({ preset: "rosewood", startSec: t, midi: m(name), durSec: (beats ? beats * BEAT : BAR) * 1.1, gain, decayMul: ROSE_DEC * 1.1, pan: 0 }); +} +// deeper bass voice for the Var IV bass-melody +function bassNote(beatIn, name, beats, gain = 0.4, pan = 0) { + ev.push({ preset: "bass", startSec: t + beatIn * BEAT, midi: snapName(name, 0), durSec: beats * BEAT * 1.1, gain, decayMul: (DECAY.bass ?? 1.8) * 1.05, pan }); +} + +const nextPhrase = (mul = 1) => { t += BAR * mul; }; + +// ── the THEME as scale-degree cells (C major), the recognizable thread ────── +// "twinkle twinkle little star": C C G G A A G (do do sol sol la la sol) +// "how I wonder what you are": F F E E D D C +// "up above the world so high": G G F F E E D +// "like a diamond in the sky": G G F F E E D +const L1 = [["C5", .5], ["C5", .5], ["G5", .5], ["G5", .5], ["A5", .5], ["A5", .5], ["G5", 1]]; +const L2 = [["F5", .5], ["F5", .5], ["E5", .5], ["E5", .5], ["D5", .5], ["D5", .5], ["C5", 1]]; +const L3 = [["G5", .5], ["G5", .5], ["F5", .5], ["F5", .5], ["E5", .5], ["E5", .5], ["D5", 1]]; +// the chord under each line (root names for bass) and a bed-degree helper +const CHORD = { I: [0, 2, 4], IV: [3, 5, 0], V: [4, 6, 1], ii: [1, 3, 5], vi: [5, 0, 2] }; + +// ===================================================================== +// INTRO — a single distant star wakes, a soft rising glint +// ===================================================================== +root("C2", 0.24); +star(0.5, "C5", 1, 0.2); +shimmer(1.6, "E5", 0.11); +star(2.2, "G5", 1.6, 0.22); +shimmer(3.2, "C6", 0.11); +nextPhrase(); + +// ===================================================================== +// THEME — the bare twinkle melody, stars over a warm bed +// ===================================================================== +function themeLine(cells, rootName, bedShift = -1, gStar = 0.3, gBed = 0.3) { + root(rootName, 0.3); + let b = 0; + for (const [name, beats] of cells) { + star(b, name, beats, gStar); + bed(b, name, beats, gBed, bedShift); + b += beats; + } +} +themeLine(L1, "C2"); nextPhrase(); +themeLine(L2, "F2"); nextPhrase(); +themeLine(L3, "G2"); nextPhrase(); +// line 4 = repeat of L3 ("like a diamond") — close the theme on a half-lift +themeLine(L3, "G2"); shimmer(3.4, "D6", 0.1, 2); nextPhrase(); + +// ===================================================================== +// VARIATION I — same tune, lightly decorated; bed ANSWERS in call/response +// each melody note gets a tiny grace glint; the rosewood echoes a beat late. +// ===================================================================== +function varI(cells, rootName) { + root(rootName, 0.28); + let b = 0; + for (const [name, beats] of cells) { + star(b, name, beats, 0.28); + // grace-note flurry just before the beat (upper neighbor) + shimmer(b - 0.12, name, 0.08, 2); + // bed answers a half-beat later, an octave under — the "response" + bed(b + 0.5, name, Math.max(beats, 0.5), 0.22, -1, 0.1); + b += beats; + } +} +varI(L1, "C2"); nextPhrase(); +varI(L2, "F2"); nextPhrase(); + +// ===================================================================== +// VARIATION II — running 16ths SWARM above the long held melody notes. +// the melody is augmented (held) in the bed; a glockenspiel toccata of +// step-wise 16ths sparkles overhead (diminution of the contour). +// ===================================================================== +function varII(cells, rootName, climbBase) { + root(rootName, 0.26); + // long, augmented melody underneath (bed holds each note longer) + let b = 0; + for (const [name, beats] of cells) { + bed(b, name, beats, 0.3, -1); + b += beats; + } + // a continuous 16th-note swarm of stars across the whole 4 beats: + // an up-and-down scalar wave that orbits the harmony's top notes. + const STEPS = 16; // sixteenth notes + for (let i = 0; i < STEPS; i++) { + const phase = i / STEPS; + // triangle wave 0..6..0 over the C-major scale, riding upward each bar + const tri = Math.round(6 * (1 - Math.abs(1 - 2 * phase))); + const deg = climbBase + tri; + const g = 0.14 + 0.05 * (1 - Math.abs(1 - 2 * phase)); // swell mid-phrase + starMidi(i * 0.25, degToMidi(deg, 6), 0.3, g, i % 2 ? 0.28 : -0.24); + } +} +varII(L1, "C2", 0); nextPhrase(); +varII(L2, "F2", 3); nextPhrase(); // start the swarm from F + +// ===================================================================== +// VARIATION III — the melody DISSOLVED into broken arpeggios (Alberti star- +// fields). each beat is a rolled chord of the harmony, the melody note on top; +// rosewood walks the bass line. the contour survives as the top of each chord. +// ===================================================================== +function arpBeat(beatIn, chordDegs, topName, gain = 0.2) { + // roll low→high inside a beat: 4 sixteenths, top = the melody note + const base = 5; + const ds = chordDegs; + for (let i = 0; i < 3; i++) { + starMidi(beatIn + i * 0.18, degToMidi(ds[i % ds.length] + 7, base), 0.6, gain, i % 2 ? 0.3 : -0.3); + } + // melody note crowns the beat, brighter, up high + star(beatIn + 0.5, topName, 0.6, gain + 0.12, 2); +} +function varIII(cells, rootName, chordSeq) { + root(rootName, 0.26); + let b = 0, ci = 0; + for (const [name, beats] of cells) { + const ch = chordSeq[ci % chordSeq.length]; + arpBeat(b, CHORD[ch], name, 0.18); + // rosewood walking bass under the broken chords + bed(b, name, beats, 0.2, -2, -0.04); + b += beats; ci++; + } +} +// I–I–V–V–vi–vi–V over line 1 +varIII(L1, "C2", ["I", "I", "V", "V", "vi", "vi", "V"]); nextPhrase(); +// IV–IV–I–I–ii–ii–V over line 2 +varIII(L2, "F2", ["IV", "IV", "I", "I", "ii", "ii", "V"]); nextPhrase(); + +// ===================================================================== +// VARIATION IV — the BASS takes the tune (octave-displaced down, augmented), +// while high glints decorate it from above (inverted shimmer counter-melody). +// ===================================================================== +function varIV(cells, rootName) { + root(rootName, 0.2); // lighter root so the bass-melody reads + let b = 0; + for (const [name, beats] of cells) { + // melody in the bass, two octaves down from where stars sang it + bassNote(b, name, Math.max(beats * 1.4, 0.7), 0.34, -0.05); + // a single high decorating star, inverted contour (mirror around G5) + const pivot = m("G5"); + const inv = 2 * pivot - m(name); + starMidi(b + 0.45, snap(inv) + 24, 0.5, 0.16, 0.3); + if (beats >= 1) shimmer(b + 0.7, name, 0.08, 3, -0.3); + b += beats; + } +} +varIV(L1, "C2"); nextPhrase(); +varIV(L3, "G2"); nextPhrase(); + +// ===================================================================== +// VARIATION V — glittering DOUBLE-TIME finale. the melody compressed into a +// half-bar, then echoed, the whole sky cascading: stars + bed + sparkle storm. +// ===================================================================== +function varV(cells, rootName, chordDegs) { + root(rootName, 0.3); + // compress the 7-note line into 2 beats (diminution), then play it TWICE + const compressed = cells.map(([n, beats]) => [n, beats * 0.5]); + let half = 0; + for (let rep = 0; rep < 2; rep++) { + let b = half; + for (const [name, beats] of compressed) { + star(b, name, beats, 0.26, 2, rep ? 0.3 : -0.3); + if (rep === 0) bed(b, name, Math.max(beats, 0.4), 0.22, -1); + b += beats; + } + half = b; // second statement starts where the first ended + } + // a cascading 16th arpeggio storm beneath, sweeping the chord top to bottom + for (let i = 0; i < 16; i++) { + const deg = chordDegs[i % chordDegs.length] + 7 + (i < 8 ? 7 : 0); + starMidi(i * 0.25, degToMidi(deg, 5), 0.3, 0.1, i % 2 ? 0.32 : -0.32); + } +} +varV(L1, "C2", CHORD.I); nextPhrase(); +varV(L3, "G2", CHORD.V); nextPhrase(); +// finale tag — a single rocketing C-major run up the sky, then a bright peak +{ + root("C2", 0.28); + for (let i = 0; i < 12; i++) { + starMidi(i * 0.22, degToMidi(i, 5) + 7, 0.5, 0.12 + i * 0.006, i % 2 ? 0.3 : -0.3); + } + bed(0, "C4", 4, 0.24, 0); + bed(0, "G4", 4, 0.2, 0); + star(3.0, "C5", 1.5, 0.3, 2); // the topmost star, the climax +} +nextPhrase(); + +// ===================================================================== +// CODA — a hushed FINAL STATEMENT, the sky dimming to one last star. +// MOTIFS.hush descending sigh, plain twinkle head, ritarding. +// ===================================================================== +BAR *= 1.18; // begin the ritard +{ + root("C2", 0.26); + // the bare twinkle head one more time, slow + quiet (theme recalled) + const head = [["C5", 1], ["C5", 1], ["G5", 1], ["G5", 1]]; + let b = 0; + for (const [name, beats] of head) { + star(b, name, beats, 0.18, 2); + bed(b, name, beats, 0.22, -1); + b += beats; + } +} +nextPhrase(); + +BAR *= 1.25; +{ + root("C2", 0.26); + const hush = MOTIFS.hush; // [["C5",1],["A4",1],["F4",1],["F4",3]] + let b = 0; + for (const [name, beats] of hush.slice(0, 3)) { + bed(b, name, beats, 0.3, 0); + star(b, name, beats, 0.14, 1); + b += beats; + } +} +nextPhrase(); + +// final resting chord — low C major, a last lone star fading +BAR *= 1.35; +root("C2", 0.28); +bed(0, "E4", BAR / BEAT, 0.24, 0); +bed(0, "G4", BAR / BEAT, 0.2, 0); +bed(0, "C5", BAR / BEAT, 0.22, 0); +star(0.4, "C5", 2, 0.16, 2); +shimmer(2.2, "G5", 0.09, 2); +shimmer(3.6, "E5", 0.07, 2); +nextPhrase(); + +// the very last star — a single distant point, soft and high +ev.push({ + preset: "glockenspiel", + startSec: t + 0.4 * BEAT, + midi: snapName("C5", 2), + durSec: 1.0 * BEAT, + gain: 0.1, + decayMul: GLOCK_DEC * 1.3, + pan: 0.18, +}); + +// ── render ───────────────────────────────────────────────────────────────── +const { mp3, durationSec } = renderLullaby(ev, { + name: "starbaba", + here: HERE, + title: "starbaba", + reverb: { wet: 0.36, decay: 0.86, damp: 0.3 }, // wide, glassy, starlit air + fadeIn: 0.7, + fadeOut: 4.8, + tailSec: 5.5, + peak: 0.82, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/tidebaba.mjs b/pop/marimba/lullabies/variations/tidebaba.mjs new file mode 100644 index 0000000000..8a4570a4b6 --- /dev/null +++ b/pop/marimba/lullabies/variations/tidebaba.mjs @@ -0,0 +1,280 @@ +// tidebaba.mjs — an ocean-rocking riff on the marimbaba lullaby, breathing +// in and out like the tide. +// +// Direction: C major, ~52 BPM, vibraphone-led with rosewood for the wave +// crests. The whistlegraph contour (hush → twinkle → wow → baba → sleep) is +// kept as the melodic DNA, but here it is shaped by the SWELL of the sea. +// +// DEVELOPMENT STRATEGY — PHRASE EXPANSION / CONTRACTION + DYNAMIC SWELLS: +// - Each "tide" is a phrase that GROWS then RECEDES. A motif cell is first +// stated short and quiet (the water far out), then re-stated longer and +// louder — note durations stretch, the cell is extended by sequencing its +// own tail, gains crest — then it CONTRACTS again: durations shrink, the +// phrase sheds notes from the end, and the dynamic ebbs back to a whisper. +// - Across the whole piece the tides themselves grow: early tides are small +// (a 2–3 bar swell), the middle tide is the spring tide (the big wave, +// widest expansion + loudest crest), and the late tides recede to a single +// low lapping cadence — the sea going to sleep. +// - A recognizable thread survives: the descending hush sigh opens the first +// tide, the slinky baba bap rides the spring-tide crest, and the final +// sleep cadence settles to C in the home octave like the last wave on sand. +// +// Run: node variations/tidebaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 52; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; // 3/4 — a slow rocking meter + +// ── C major scale-snap (root pc = 0). Keep every voice in the mode. ───────── +const ROOT = 0; // C +const MAJOR = [0, 2, 4, 5, 7, 9, 11]; +function snap(midi) { + const pc = ((midi % 12) + 12) % 12; + const rel = ((pc - ROOT) % 12 + 12) % 12; + let best = MAJOR[0], bestD = 99; + for (const s of MAJOR) { + const d = Math.min(Math.abs(s - rel), 12 - Math.abs(s - rel)); + if (d < bestD) { bestD = d; best = s; } + } + const base = midi - rel; + let cand = base + best; + if (cand - midi > 6) cand -= 12; + if (midi - cand > 6) cand += 12; + return cand; +} + +const LEAD = "vibraphone"; +const CREST = "rosewood"; // the wave-crest voice, brighter mallet + +// ── motif → C-folded MIDI cells. The marimbaba MOTIFS are written in F; +// transpose -5 down into C territory then snap into the mode. Each cell is +// a list of [midi, beats] pairs we can stretch / extend / shed freely. ──── +function cell(motif) { + return MOTIFS[motif].map(([name, beats]) => [snap(m(name) - 5), beats]); +} + +// ── PHRASE-SHAPING ENGINE ─────────────────────────────────────────────────── + +// stretch(cell, factor) — multiply every note duration: the tide rolling in +// slow (factor > 1) or pulling out fast (factor < 1). The melody breathes. +function stretch(c, factor) { + return c.map(([midi, beats]) => [midi, beats * factor]); +} + +// extend(cell, n) — GROW the phrase by sequencing its own tail: append the +// last `n` notes again, each lifted one scale step, like a wave reaching +// further up the sand each surge. This is the expansion half of a tide. +function extend(c, n, stepUp = 2) { + const tail = c.slice(Math.max(0, c.length - n)); + const more = tail.map(([midi, beats]) => [snap(midi + stepUp), beats]); + return [...c, ...more]; +} + +// shed(cell, n) — CONTRACT the phrase: drop the last `n` notes. The water +// not reaching as far now — the ebb. The contraction half of a tide. +function shed(c, n) { + return c.slice(0, Math.max(1, c.length - n)); +} + +// transpose a whole cell by an interval (for sequencing). +function tpose(c, semis) { return c.map(([midi, beats]) => [snap(midi + semis), beats]); } + +// invert a cell around its first note (mirror the intervals) — the undertow. +function invert(c) { + const axis = c[0][0]; + return c.map(([midi, beats]) => [snap(axis - (midi - axis)), beats]); +} + +// ── lay a cell into events with a per-note DYNAMIC SWELL. `swell` shapes the +// gains across the phrase: a half-cosine hump so the middle of the phrase +// is loudest (the wave cresting) and the edges are quiet (the trough). +// `crest` is the peak gain, `trough` the edge gain. ────────────────────── +function lay(out, c, { + startBar, beat0 = 0, voice = LEAD, + crest = 0.5, trough = 0.32, panBase = 0, panSpread = 0.3, + decayMul = 1.6, +} = {}) { + let beat = beat0; + const N = c.length; + for (let i = 0; i < N; i++) { + const [midi, beats] = c[i]; + // swell envelope: 0 at the edges, 1 at the centre of the phrase. + const t = N > 1 ? i / (N - 1) : 0.5; + const swell = 0.5 - 0.5 * Math.cos(2 * Math.PI * t); // hump + const gain = trough + (crest - trough) * swell; + // pan drifts gently with register so the tide moves across the stereo sand + const reg = (midi - 60) / 18; + const pan = Math.max(-0.5, Math.min(0.5, panBase + reg * panSpread)); + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi, + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * decayMul, + pan, + }); + beat += beats; + } + return beat; // beats consumed +} + +// vibraphone_off pad — a C-folded triad breathing under a span of bars, the +// deep ocean swell beneath the surface waves. +const padDeg = (deg, oct) => snap(m(["C", "D", "E", "F", "G", "A", "B"][deg] + oct)); +function pad(out, startBar, lenBars, triad, baseOct, gain = 0.12) { + for (let i = 0; i < triad.length; i++) { + out.push({ + preset: "vibraphone_off", + startSec: startBar * BAR, + midi: padDeg(triad[i], baseOct), + durSec: lenBars * BAR + BEAT, + gain, + decayMul: 2.0, + pan: i === 0 ? -0.24 : i === 1 ? 0.0 : 0.24, + }); + } +} + +// soft bass root under a bar (kept low + clear) — the seabed. +function bass(out, startBar, name, lenBars = 1, gain = 0.4) { + out.push({ + preset: "bass", + startSec: startBar * BAR, + midi: snap(m(name)), + durSec: lenBars * BAR, + gain, + decayMul: DECAY.bass * 1.4, + pan: 0, + }); +} + +// a single high foam-sparkle (glockenspiel / kalimba) — sea spray off a crest. +function spray(out, { startBar, beat, name, beats, voice = "glockenspiel", gain = 0.11, pan = 0.3 }) { + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi: snap(m(name)), + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * 1.6, + pan, + }); +} + +const events = []; + +// ════════════════════════════════════════════════════════════════════════ +// ARC: small tides → swelling tides → the spring tide (big wave) → +// receding tides → the last lapping cadence. +// ════════════════════════════════════════════════════════════════════════ + +// ── TIDE 1 (bars 0–5): the FIRST SMALL TIDE. The hush sigh, far out and +// quiet, stated short — then re-stated stretched a little longer and a +// touch louder (the water rolling in), then contracted back to a whisper. +{ + const hush = cell("hush"); + // far out: short + quiet + lay(events, stretch(hush, 0.9), { startBar: 0, crest: 0.34, trough: 0.24, decayMul: 1.7, panSpread: 0.22 }); + // rolling in: stretched longer, a little louder, extended one surge further + lay(events, stretch(extend(hush, 2), 1.15), { startBar: 2, crest: 0.46, trough: 0.3, decayMul: 1.8, panSpread: 0.26 }); + // ebbing: shed the tail, pull back quiet + lay(events, stretch(shed(hush, 1), 0.85), { startBar: 4, crest: 0.34, trough: 0.22, decayMul: 1.9, panSpread: 0.2 }); + bass(events, 0, "C2", 2, 0.4); bass(events, 2, "C2", 2, 0.38); bass(events, 4, "G2", 2, 0.36); + pad(events, 0, 6, [0, 2, 4], 3, 0.11); // C deep swell + spray(events, { startBar: 3, beat: 1.6, name: "G5", beats: 1.5, gain: 0.08, pan: 0.3 }); +} + +// ── TIDE 2 (bars 6–11): a LARGER SWELL. The twinkle wave climbs in, this time +// extended TWICE and stretched as it crests, the rosewood taking the very +// top of the wave; then the undertow (inverted fragment) drags it back. +{ + const tw = cell("twinkle"); + // climbing in, growing: extend +2 notes, stretch slightly + lay(events, stretch(extend(tw, 2), 1.1), { startBar: 6, crest: 0.5, trough: 0.32, voice: LEAD, decayMul: 1.7, panSpread: 0.3 }); + // the crest itself — same wave a third higher on the bright rosewood, + // extended further, the loudest of this tide + lay(events, stretch(extend(tpose(tw, 4), 3), 1.05), { startBar: 9, crest: 0.56, trough: 0.36, voice: CREST, decayMul: 1.5, panSpread: 0.32 }); + bass(events, 6, "C2", 2, 0.34); bass(events, 8, "F2", 2, 0.34); bass(events, 10, "G2", 2, 0.34); + pad(events, 6, 3, [0, 2, 4], 3, 0.11); // C + pad(events, 9, 3, [3, 5, 0], 3, 0.11); // F + spray(events, { startBar: 7, beat: 1.2, name: "E5", beats: 1.5, voice: "kalimba", gain: 0.11, pan: -0.26 }); + spray(events, { startBar: 11, beat: 0.5, name: "C6", beats: 2, gain: 0.1, pan: 0.32 }); +} + +// ── TIDE 3 (bars 12–18): THE SPRING TIDE — the big wave. The wow wobble and +// the slinky baba bap ride the crest, fully EXPANDED (extended + stretched +// wide) and at the loudest swell of the piece, the rosewood blazing on top +// while foam-spray rains overhead. This is the apex of the breathing. +{ + // the wow swell — expanded wide, stretched long, big crest + const wow = cell("wow"); + lay(events, stretch(extend(wow, 3), 1.2), { startBar: 12, crest: 0.58, trough: 0.36, voice: LEAD, decayMul: 1.8, panSpread: 0.34 }); + // the baba slinky-bap rides the very top of the spring tide on rosewood, + // extended and at full crest — the recognizable bap on the biggest wave + const baba = cell("baba"); + lay(events, stretch(extend(baba, 3), 1.1), { startBar: 15, crest: 0.6, trough: 0.4, voice: CREST, decayMul: 1.5, panSpread: 0.34 }); + + // foam-spray cascade raining off the crest + const foam = [ + [12, 2.0, "C6", "glockenspiel", 0.34], [13, 1.0, "G5", "kalimba", -0.28], + [14, 1.6, "E6", "glockenspiel", 0.36], [15, 2.2, "D6", "glockenspiel", 0.32], + [16, 0.8, "G6", "glockenspiel", 0.4], [17, 1.4, "C6", "kalimba", -0.26], + [18, 1.0, "E6", "glockenspiel", 0.3], + ]; + for (const [b, bt, n, v, pn] of foam) spray(events, { startBar: b, beat: bt, name: n, beats: 1.5, voice: v, gain: 0.1, pan: pn }); + + bass(events, 12, "C2", 2, 0.36); bass(events, 14, "G2", 2, 0.36); bass(events, 16, "A2", 2, 0.34); bass(events, 18, "F2", 1, 0.34); + pad(events, 12, 3, [4, 6, 1], 3, 0.12); // G (dominant lift under the swell) + pad(events, 15, 4, [0, 2, 4], 3, 0.12); // C +} + +// ── TIDE 4 (bars 19–24): the tide RECEDING. The twinkle wave returns but now +// it CONTRACTS — shed of its tail, durations shrinking, dynamics ebbing, +// the undertow inversion pulling under. The sea growing calm. +{ + const tw = cell("twinkle"); + // shed two notes, slightly compressed durations, quieter + lay(events, stretch(shed(extend(tw, 1), 2), 0.95), { startBar: 19, crest: 0.46, trough: 0.3, voice: LEAD, decayMul: 1.7, panSpread: 0.26 }); + // the undertow: an inverted, shed wow fragment, quieter still, pulling down + lay(events, stretch(shed(invert(cell("wow")), 2), 0.85), { startBar: 22, crest: 0.38, trough: 0.26, voice: LEAD, decayMul: 1.8, panSpread: 0.22 }); + bass(events, 19, "F2", 2, 0.32); bass(events, 21, "G2", 2, 0.32); bass(events, 23, "C2", 2, 0.32); + pad(events, 19, 3, [3, 5, 0], 3, 0.11); // F + pad(events, 22, 3, [4, 6, 1], 3, 0.11); // G resolving home + spray(events, { startBar: 20, beat: 1.5, name: "G5", beats: 2, gain: 0.08, pan: 0.3 }); +} + +// ── TIDE 5 (bars 25–30): THE LAST LAPPING. The sleep cadence settles to C in +// the home octave — fully contracted, no expansion, the quietest swell of +// all, the last wave running thin up the sand. A final faint hush echo +// closes the frame (the recognizable thread, going to sleep). +{ + const sleep = cell("sleep"); + // play it close + low, gentle swell, soft — the sea coming to rest + lay(events, stretch(sleep, 1.05), { startBar: 25, crest: 0.42, trough: 0.28, voice: LEAD, decayMul: 1.9, panSpread: 0.18 }); + // a final, faint, shed hush echo to close — the last lap of water + lay(events, stretch(shed(cell("hush"), 1), 1.1), { startBar: 28, crest: 0.34, trough: 0.22, voice: LEAD, decayMul: 2.0, panSpread: 0.16 }); + bass(events, 25, "C2", 2, 0.34); bass(events, 27, "G2", 2, 0.32); bass(events, 29, "C2", 2, 0.3); + pad(events, 25, 6, [0, 2, 4], 3, 0.12); // C — long resolving low tide + spray(events, { startBar: 28, beat: 1.0, name: "C6", beats: 4, gain: 0.07, pan: 0.3 }); + spray(events, { startBar: 29, beat: 1.5, name: "G5", beats: 3, voice: "kalimba", gain: 0.08, pan: -0.24 }); +} + +const { mp3, durationSec } = renderLullaby(events, { + name: "tidebaba", + here: HERE, + title: "tidebaba", + reverb: { wet: 0.4, decay: 0.86, damp: 0.36 }, // wide, washed sea-room + fadeIn: 1.8, + fadeOut: 6.0, + tailSec: 6.0, + peak: 0.83, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/toybaba.mjs b/pop/marimba/lullabies/variations/toybaba.mjs new file mode 100644 index 0000000000..a1c3a41c36 --- /dev/null +++ b/pop/marimba/lullabies/variations/toybaba.mjs @@ -0,0 +1,309 @@ +// toybaba.mjs — a TOY BOX TIPPING OVER: the marimbaba nursery riff chopped +// into tiny plastic cells and scrambled with cheeky non-sequiturs. +// +// STRATEGY — FRAGMENTATION & INTERPOLATION. We keep toybaba's identity (C major +// pentatonic, glockenspiel + xylophone "toy piano" plink, soft woodblock +// tick-tock, bouncy ~76 BPM 3/4, cozy nursery reverb) but the MELODY no longer +// restates the tune faithfully. Instead TWINKLE and BABA are diced into 2–3 +// note cells and recombined: a cell answered an octave too high, a sudden +// "wrong-but-cute" interpolated note, a hiccup (repeated stutter), a cheeky +// woodblock pitch answering back, a fragment quoted backwards, a fragment in +// the bass. Playful musical non-sequiturs that always resolve home — a toy box +// tipping over, plinking everything out, then settling. +// +// FORM (development arc): +// intro bars 0-1 : hush sigh, glassy + high (the recognizable thread) +// spill bars 2-9 : TWINKLE & BABA shattered into cells, scrambled, +// octave-displaced, with wrong-note interpolations, +// hiccups, and woodblock answers (the box tips over) +// chase bars 10-15 : cells sequenced up by step + retrograde quotes + +// a stutter accelerando (toys rolling across the floor) +// reassemble bars 16-21: the fragments snap back toward the real tune — +// twinkle head re-forms, baba cadence lands +// outro bars 22-25 : sleep settle, low & calm (lullaby rest) +// +// Run: node variations/toybaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 76; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; // marimbaba 3/4 lilt + +// ── C major pentatonic: root C, scale {0,2,4,7,9} = C D E G A ──────────────── +const ROOT_PC = m("C4") % 12; // 0 +const PENTA = [0, 2, 4, 7, 9]; + +function snap(midi, rootPc = ROOT_PC, scale = PENTA) { + let best = midi, bestD = Infinity; + for (let oct = -1; oct <= 1; oct++) { + for (const deg of scale) { + const pc = (rootPc + deg) % 12; + const base = Math.round((midi - pc) / 12) * 12 + pc + oct * 12; + const d = Math.abs(base - midi); + if (d < bestD - 1e-6 || (Math.abs(d - bestD) < 1e-6 && base < best)) { + best = base; bestD = d; + } + } + } + return best; +} + +// ── cell helpers — every cell is an array of [noteName, beats] tuples ───────── +// We work in MIDI-tuples internally so transpose/invert/retrograde compose. +const cellMidi = (cell) => cell.map(([n, b]) => [snap(m(n)), b]); +const tuneMidi = (mc) => mc; // identity, for readability + +// pentatonic-degree arithmetic: move a midi note by N scale steps (not semis), +// so sequencing keeps the toy-piano in-key without half-step clashes. +function pentaStep(midi, steps) { + // find current degree index + const pc = ((midi % 12) + 12) % 12; + let idx = PENTA.indexOf(((pc - ROOT_PC) % 12 + 12) % 12); + if (idx < 0) idx = 0; + let oct = Math.floor((midi - (ROOT_PC + PENTA[idx])) / 12); + let n = idx + steps; + oct += Math.floor(n / PENTA.length); + n = ((n % PENTA.length) + PENTA.length) % PENTA.length; + return ROOT_PC + PENTA[n] + 12 * oct + 12; // +12: snap() world centers near C5 +} + +const stepCell = (mc, steps) => mc.map(([mi, b]) => [pentaStep(mi, steps), b]); +const octCell = (mc, n) => mc.map(([mi, b]) => [mi + 12 * n, b]); +const retro = (mc) => [...mc].reverse(); +// mirror intervals around the cell's first note, then re-snap to scale. +function invertCell(mc) { + if (!mc.length) return mc; + const pivot = mc[0][0]; + return mc.map(([mi, b]) => [snap(2 * pivot - mi), b]); +} +// stretch / squeeze durations +const stretchCell = (mc, k) => mc.map(([mi, b]) => [mi, b * k]); + +// ── fragment library: tiny cells diced out of TWINKLE and BABA ─────────────── +const TW = cellMidi(MOTIFS.twinkle); // F5 A5 C6 A5 G5 -> snapped to penta +const BA = cellMidi(MOTIFS.baba); // A5 G5 A5 F5 C6 Bb5 C6 A5 +const HU = cellMidi(MOTIFS.hush); +const SL = cellMidi(MOTIFS.sleep); + +// twinkle cells +const tw_head = TW.slice(0, 2); // climb: deg-pair (the head) +const tw_peak = TW.slice(2, 4); // peak + fall +const tw_tail = TW.slice(4); // resting tone +// baba cells +const ba_hop = BA.slice(0, 3); // ba-ba-ba bounce +const ba_dip = BA.slice(2, 5); // turn down then up +const ba_top = BA.slice(4, 7); // upper wobble +const ba_cad = BA.slice(6); // landing + +const events = []; + +// ── lay a cell of [midi,beats] tuples into the toy-piano voice ─────────────── +function lay(startBar, startBeat, mc, opts = {}) { + const { + preset = "glockenspiel", + altPreset = null, + octaveShift = 0, + gain = 0.4, + pan = 0, + stretch = 1.0, + bounce = 0, + decayBoost = 1.35, + } = opts; + let bar = startBar, beat = startBeat, i = 0; + for (const [mi, beats] of mc) { + const useAlt = altPreset && (i % 2 === 1); + const pr = useAlt ? altPreset : preset; + const g = gain * (1 + (i % 2 === 0 ? bounce : -bounce)); + events.push({ + preset: pr, + startSec: bar * BAR + beat * BEAT, + midi: mi + octaveShift, + durSec: beats * stretch * BEAT, + gain: g, + decayMul: (DECAY[pr] ?? 1.1) * decayBoost, + pan, + }); + beat += beats * stretch; i += 1; + while (beat >= 3) { beat -= 3; bar += 1; } + } +} + +// a single cheeky/wrong note — the interpolation surprise (always cute, in key) +function poke(bar, beat, note, opts = {}) { + const { preset = "glockenspiel", gain = 0.3, pan = 0.3, beats = 0.5, octaveShift = 0 } = opts; + events.push({ + preset, + startSec: bar * BAR + beat * BEAT, + midi: snap(m(note)) + octaveShift, + durSec: beats * BEAT, + gain, + decayMul: (DECAY[preset] ?? 1.1) * 1.3, + pan, + }); +} + +// a soft woodblock tick — the wind-up pulse +function tick(bar, beat, gain = 0.16, note = "C5", pan = -0.28, beats = 0.5) { + events.push({ + preset: "woodblock", + startSec: bar * BAR + beat * BEAT, + midi: snap(m(note)), + durSec: beats * BEAT, + gain, + decayMul: 0.7, + pan, + }); +} + +// a plucky toy bass root +function bass(bar, note = "C3", gain = 0.32, beats = 3, beat = 0) { + events.push({ + preset: "bass", + startSec: bar * BAR + beat * BEAT, + midi: snap(m(note)), + durSec: beats * BEAT, + gain, + decayMul: 1.5, + pan: 0, + }); +} + +// ════════════════════════════════════════════════════════════════════════════ +// INTRO (bars 0-1) — the recognizable thread: hush sigh, glassy + high. +// ════════════════════════════════════════════════════════════════════════════ +lay(0, 0, octCell(HU, 1), { preset: "glockenspiel", gain: 0.3, pan: 0.1, stretch: 0.9 }); +bass(0, "C2", 0.26); bass(1, "C2", 0.24); +// one early wrong-but-cute poke that foreshadows the spill +poke(1, 2, "D6", { gain: 0.2, pan: -0.3 }); + +// ════════════════════════════════════════════════════════════════════════════ +// SPILL (bars 2-9) — TWINKLE & BABA shattered: cells scrambled, octave-jumped, +// wrong-note interpolations, hiccups, and woodblock answers. The box tips over. +// ════════════════════════════════════════════════════════════════════════════ + +// bar 2: twinkle head states normally (so we still hear the tune start)... +lay(2, 0, tw_head, { preset: "xylophone", altPreset: "glockenspiel", gain: 0.46, pan: -0.12, bounce: 0.12 }); +// ...then HICCUP: the peak cell stutters (repeat its first note) before resolving +lay(2, 1, [tw_peak[0], tw_peak[0], ...tw_peak.slice(1)].map(([mi]) => [mi, 0.4]), + { preset: "glockenspiel", altPreset: "xylophone", gain: 0.4, pan: 0.18, bounce: 0.14 }); +poke(2, 2.6, "A5", { gain: 0.24, pan: 0.34 }); // wrong-but-cute extra plink + +// bar 3: tw_tail quoted an octave TOO HIGH (non-sequitur), woodblock answers low +lay(3, 0, octCell(tw_tail, 1), { preset: "glockenspiel", gain: 0.34, pan: 0.22, stretch: 0.6 }); +lay(3, 0.7, stepCell(tw_head, 2), { preset: "xylophone", gain: 0.36, pan: -0.2, bounce: 0.1 }); // head sequenced UP +tick(3, 1.8, 0.2, "C4", 0.32); // cheeky low woodblock "answer" to the high quote + +// bar 4-5: baba hop fragment, then the SAME hop inverted (mirror) right after +lay(4, 0, ba_hop, { preset: "xylophone", altPreset: "glockenspiel", gain: 0.46, pan: 0.02, bounce: 0.14 }); +lay(4, 1.5, invertCell(ba_hop), { preset: "glockenspiel", altPreset: "xylophone", gain: 0.4, pan: -0.18, bounce: 0.12 }); +poke(4, 2.6, "G5", { preset: "xylophone", gain: 0.26, pan: 0.3 }); +lay(5, 0, ba_top, { preset: "glockenspiel", altPreset: "xylophone", octaveShift: 0, gain: 0.42, pan: 0.16, bounce: 0.12 }); +// hiccup: cad note repeated twice fast then lands +lay(5, 1.5, [ba_cad[0], ba_cad[0]].map(([mi]) => [mi, 0.4]).concat([ba_cad[0]]), + { preset: "xylophone", gain: 0.36, pan: -0.1 }); + +// bar 6-7: a fragment quoted in the WRONG octave (bass voice plinks the toy tune!) +lay(6, 0, octCell(tw_head, -1), { preset: "bass", gain: 0.3, pan: 0, decayBoost: 1.0 }); +lay(6, 1, octCell(ba_dip, 1), { preset: "glockenspiel", gain: 0.34, pan: 0.24, stretch: 0.7, bounce: 0.1 }); +tick(6, 2.5, 0.18, "E5", 0.28); +lay(7, 0, ba_dip, { preset: "xylophone", altPreset: "glockenspiel", gain: 0.44, pan: -0.06, bounce: 0.13 }); +poke(7, 2, "A5", { gain: 0.26, pan: 0.32 }); +poke(7, 2.5, "C6", { gain: 0.22, pan: -0.3 }); // little double-poke fill + +// bar 8-9: scramble — tw_peak then ba_hop retrograde, woodblock cross-talk +lay(8, 0, tw_peak, { preset: "glockenspiel", altPreset: "xylophone", gain: 0.42, pan: 0.14, bounce: 0.12 }); +lay(8, 1.5, retro(ba_hop), { preset: "xylophone", gain: 0.4, pan: -0.16, bounce: 0.12 }); +tick(8, 2.4, 0.16, "G5", 0.3); +lay(9, 0, octCell(tw_tail, 1), { preset: "glockenspiel", gain: 0.3, pan: 0.2, stretch: 0.8 }); +lay(9, 1, stepCell(ba_hop, 1), { preset: "xylophone", gain: 0.36, pan: -0.18, bounce: 0.1 }); + +// ════════════════════════════════════════════════════════════════════════════ +// CHASE (bars 10-15) — cells sequenced UP by step + retrograde quotes + a +// stutter accelerando. Toys rolling across the floor. +// ════════════════════════════════════════════════════════════════════════════ +// sequence the twinkle head up by step each bar (a rising staircase) +for (let k = 0; k < 3; k++) { + const bar = 10 + k; + lay(bar, 0, stepCell(tw_head, k), { preset: "xylophone", altPreset: "glockenspiel", gain: 0.42 - k * 0.02, pan: -0.1 + k * 0.12, bounce: 0.13 }); + lay(bar, 1, stepCell(retro(tw_head), k + 1), { preset: "glockenspiel", gain: 0.34, pan: 0.2 - k * 0.1, stretch: 0.7 }); + poke(bar, 2.5, k % 2 ? "C6" : "A5", { gain: 0.22, pan: k % 2 ? 0.3 : -0.3 }); +} + +// bar 13: a STUTTER ACCELERANDO — baba hop diminished into a fast tumble, +// notes packed tighter and tighter (the toy clatters down). +{ + const stut = ba_hop.concat(ba_top, ba_cad).map(([mi]) => mi); // 9 pitches + let beat = 0, dur = 0.4; + for (let i = 0; i < stut.length; i++) { + poke(13, beat, "C5", { preset: i % 2 ? "xylophone" : "glockenspiel", gain: 0.3, pan: (i % 2 ? 0.22 : -0.22), beats: dur }); + // overwrite that poke's pitch with the real fragment pitch: + events[events.length - 1].midi = stut[i]; + beat += dur; + dur = Math.max(0.16, dur * 0.86); // accelerate + if (beat >= 3) break; + } +} + +// bar 14-15: cells answered in call-and-response between L glock and R xylo +lay(14, 0, ba_top, { preset: "glockenspiel", gain: 0.42, pan: -0.22, bounce: 0.12 }); +lay(14, 1.5, octCell(ba_top, 1), { preset: "xylophone", gain: 0.34, pan: 0.26, stretch: 0.7 }); +lay(15, 0, invertCell(tw_peak), { preset: "xylophone", gain: 0.4, pan: -0.14, bounce: 0.12 }); +lay(15, 1.5, tw_peak, { preset: "glockenspiel", gain: 0.4, pan: 0.2, bounce: 0.1 }); +poke(15, 2.6, "D6", { gain: 0.2, pan: -0.32 }); + +// ════════════════════════════════════════════════════════════════════════════ +// REASSEMBLE (bars 16-21) — the fragments snap back toward the real tune. +// ════════════════════════════════════════════════════════════════════════════ +// twinkle head re-forms, mostly whole now, with just one cheeky poke left +lay(16, 0, [...tw_head, ...tw_peak], { preset: "xylophone", altPreset: "glockenspiel", gain: 0.46, pan: -0.1, bounce: 0.12 }); +lay(17, 0, tw_tail, { preset: "glockenspiel", gain: 0.4, pan: 0.12, stretch: 1.0 }); +poke(17, 1.5, "A5", { preset: "xylophone", gain: 0.24, pan: 0.3 }); // last little hiccup +// baba played nearly whole — the tune found again +lay(18, 0, [...ba_hop, ...ba_dip.slice(1)], { preset: "xylophone", altPreset: "glockenspiel", gain: 0.46, pan: 0, bounce: 0.12 }); +lay(19, 0, [...ba_top, ...ba_cad], { preset: "glockenspiel", altPreset: "xylophone", gain: 0.42, pan: 0.14, bounce: 0.1 }); +// twinkle head one more time, higher and tender (the box winding down) +lay(20, 0, octCell([...tw_head, ...tw_peak], 1), { preset: "glockenspiel", gain: 0.32, pan: 0.16, stretch: 1.05 }); +lay(21, 0, stretchCell(tw_tail, 1.15), { preset: "glockenspiel", gain: 0.3, pan: -0.08, stretch: 1.1 }); + +// ════════════════════════════════════════════════════════════════════════════ +// OUTRO (bars 22-26) — sleep settle, low & calm, slowing down. +// ════════════════════════════════════════════════════════════════════════════ +lay(22, 0, SL, { preset: "glockenspiel", gain: 0.3, pan: -0.06, stretch: 1.2 }); +// a last, slow, glassy echo of the twinkle head — winding fully down +lay(25, 0, stretchCell(tw_head, 1.5), { preset: "glockenspiel", gain: 0.26, pan: 0.12, stretch: 1.2 }); +lay(26, 0, stretchCell(tw_tail, 1.4), { preset: "glockenspiel", gain: 0.22, pan: -0.04, stretch: 1.3 }); +poke(26, 1.6, "C4", { gain: 0.16, pan: 0, beats: 2 }); // one soft low bell to close + +// ── the wind-up woodblock pulse under the active middle (soft tick-tock) ───── +for (let bar = 2; bar <= 19; bar++) { + tick(bar, 0, 0.16, "G5", -0.3); + tick(bar, 1, 0.1, "C5", 0.26); + tick(bar, 2, 0.12, "E5", -0.22); +} + +// ── plucky toy bass walking gently under the tune (C-pentatonic roots) ─────── +const bassPlan = [ + [2, "C2"], [3, "C3"], [4, "A2"], [5, "C2"], [6, "G2"], [7, "C3"], + [8, "C2"], [9, "A2"], [10, "G2"], [11, "C2"], [12, "A2"], [13, "G2"], + [14, "C2"], [15, "C3"], [16, "A2"], [17, "C2"], [18, "G2"], [19, "C3"], + [20, "A2"], [21, "G2"], [22, "C2"], [23, "C2"], [25, "A2"], [26, "C2"], +]; +for (const [bar, note] of bassPlan) bass(bar, note, 0.3); + +const { mp3, durationSec } = renderLullaby(events, { + name: "toybaba", + here: HERE, + title: "toybaba", + reverb: { wet: 0.32, decay: 0.8, damp: 0.42 }, + fadeIn: 0.5, + fadeOut: 3.5, + tailSec: 4.0, + peak: 0.84, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/tuckbaba.mjs b/pop/marimba/lullabies/variations/tuckbaba.mjs new file mode 100644 index 0000000000..3ad2d675aa --- /dev/null +++ b/pop/marimba/lullabies/variations/tuckbaba.mjs @@ -0,0 +1,218 @@ +// tuckbaba.mjs — the last lullaby, by LIQUIDATION (reverse-evolution). +// +// Every other variation winds down; tuckbaba IS the wind-down — but this time +// it earns the dark. It OPENS at full strength: the whole marimbaba tune, +// elaborated as densely as the seed ever gets — hush, twinkle, the wow wobble, +// the slinky ba-ba-ba-bap, the sleep settle — all stated at once with grace +// flurries, a vibraphone pad and kalimba sparkles, like a child wide awake and +// telling the whole story. Then it LIQUIDATES. Each pass keeps less: the +// ornaments go first, then whole phrases, then the contour itself flattens. +// The melody loses its top, widens its gaps, lengthens its rings. The slinky +// folds into the sleep descent; the sleep descent erodes to a falling third; +// the falling third erodes to a single low F; and the F dissolves into silence. +// One kalimba "goodnight" rings far away as the last waking thought. +// +// We stay home in F major (the seed's own key) and, as the liquidation deepens, +// fold the melody to F-major pentatonic {0,2,4,7,9} so the descent loses every +// edge — no leading tone to keep you awake. So the arc is also a smoothing: +// chromatic-rich at the top, pure-pentatonic at the bottom. The bass F2 +// heartbeat slows and softens with each pass, the breaths spacing out as +// breathing does in sleep. +// +// Riffs the seed by: stating the ENTIRE marimbaba tune up front (not just its +// closing phrase) and then reverse-developing it — progressive liquidation — +// until only the seed's last gesture, the falling third to F, remains, and then +// nothing. The kalimba sparkle is the lone surviving ornament from "wow." +// +// Run: node variations/tuckbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 50; // a touch over the old 46 — the open is awake, then it slows +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; + +// ── F major pentatonic: root pitch-class F (5), scale {0,2,4,7,9} ──────────── +const ROOT_PC = m("F4") % 12; // 5 +const PENTA = [0, 2, 4, 7, 9]; + +// Fold a midi note to nearest F-major-pentatonic pitch; ties resolve downward. +function snap(midi, rootPc = ROOT_PC, scale = PENTA) { + let best = midi, bestD = Infinity; + for (let oct = -1; oct <= 1; oct++) { + for (const deg of scale) { + const pc = (rootPc + deg) % 12; + const base = Math.round((midi - pc) / 12) * 12 + pc + oct * 12; + const d = Math.abs(base - midi); + if (d < bestD - 1e-6 || (Math.abs(d - bestD) < 1e-6 && base < best)) { + best = base; bestD = d; + } + } + } + return best; +} + +// ── little developmental helpers (arrays of [note, beats]) ─────────────────── +const tn = (cell, semis) => cell.map(([n, b]) => [m(n) + semis, b]); // transpose (->midi) +const aug = (cell, k) => cell.map(([n, b]) => [n, b * k]); // augment durations +const dim = (cell, k) => cell.map(([n, b]) => [n, b / k]); // diminish (faster) +const retro = (cell) => cell.slice().reverse(); // retrograde +// drop every note past `keep` (keeps the head), and lengthen what survives. +const liquidate = (cell, keep, stretch = 1) => + cell.slice(0, keep).map(([n, b]) => [n, b * stretch]); + +const events = []; + +// ── place a [note,beats] cell as rosewood, optionally pentatonic-folded ────── +function place(cell, startBar, startBeat, gain, pan, { + preset = "rosewood", durMul = 1, octave = 0, fold = false, decayK = 1.4, + grace = 0, // beats: tiny grace note a step above, before each strong note +} = {}) { + let bar = startBar, beat = startBeat; + for (const [note, beats] of cell) { + const raw = (typeof note === "number" ? note : m(note)); + const midi = (fold ? snap(raw) : raw) + octave; + const t = bar * BAR + beat * BEAT; + if (grace > 0) { + events.push({ + preset, startSec: t - grace * BEAT, + midi: midi + 2, durSec: grace * 1.2 * BEAT, + gain: gain * 0.5, decayMul: (DECAY[preset] ?? 1.6) * 1.1, pan, + }); + } + events.push({ + preset, startSec: t, + midi, durSec: beats * durMul * BEAT, + gain, decayMul: (DECAY[preset] ?? 1.8) * decayK, pan, + }); + beat += beats; + while (beat >= 3) { beat -= 3; bar += 1; } + } +} + +// add a vibraphone pad chord (held), pentatonic-safe F major triad-ish. +function pad(notes, startBar, beats, gain, pan = -0.16) { + for (const n of notes) { + events.push({ + preset: "vibraphone_off", startSec: startBar * BAR, + midi: m(n), durSec: beats * BEAT, gain, + decayMul: (DECAY.vibraphone_off ?? 1.4) * 1.2, pan, + }); + } +} + +// add a kalimba sparkle (single high note, rings far). +function sparkle(note, startBar, startBeat, gain, durBeats, pan = 0.34) { + events.push({ + preset: "kalimba", startSec: startBar * BAR + startBeat * BEAT, + midi: m(note), durSec: durBeats * BEAT, gain, + decayMul: (DECAY.kalimba ?? 1.75) * 1.6, pan, + }); +} + +// ════════════════════════════════════════════════════════════════════════════ +// PASS 0 — FULLEST STATEMENT (bars 0–11): the whole tune, wide awake. +// hush → twinkle → wow → ba-ba-ba-bap, ornamented, padded, sparkling. +// ════════════════════════════════════════════════════════════════════════════ + +// hush (descending sigh) with grace flurries — the most elaborate it gets. +place(MOTIFS.hush, 0, 0, 0.5, -0.05, { grace: 0.18, decayK: 1.2 }); +// answer the hush a third up (sequence), softer, other side — call/response. +place(tn(MOTIFS.hush, 4), 2, 0, 0.4, 0.12, { grace: 0.14, decayK: 1.25 }); + +// twinkle — the climbing wave, full and bright, with a kalimba on the peak. +place(MOTIFS.twinkle, 4, 0, 0.46, -0.04, { grace: 0.16, decayK: 1.2 }); +sparkle("C6", 4, 2, 0.26, 3, 0.4); +pad(["F4", "A4", "C5"], 4, 9, 0.16); + +// wow wobble — held vibraphone under a rosewood A-G-A and a staccato tumble. +events.push({ preset: "vibraphone", startSec: 7 * BAR, midi: m("G5"), durSec: 6 * BEAT, gain: 0.34, decayMul: (DECAY.vibraphone ?? 1.4) * 1.2, pan: 0.16 }); +events.push({ preset: "vibraphone", startSec: 7 * BAR, midi: m("Bb5"), durSec: 6 * BEAT, gain: 0.3, decayMul: (DECAY.vibraphone ?? 1.4) * 1.2, pan: 0.16 }); +place(MOTIFS.wow, 8, 0, 0.42, -0.02, { grace: 0.12, decayK: 1.15 }); +// a quick staccato ratatata diminution of the wow tail — the busiest moment. +place(dim([["A5", 1], ["G5", 1], ["A5", 1], ["G5", 1], ["F5", 1], ["G5", 1], ["A5", 1], ["C6", 1]], 4), + 9, 0, 0.26, -0.24, { preset: "staccato", decayK: 1.0 }); +sparkle("F6", 8, 1, 0.22, 2.5); + +// ba-ba-ba-bap — slinky-dog wobble, the last fully-elaborate phrase. +place(MOTIFS.baba, 10, 0, 0.44, 0.06, { grace: 0.12, decayK: 1.25 }); +sparkle("A5", 11, 2, 0.2, 2.5, 0.4); + +// ════════════════════════════════════════════════════════════════════════════ +// PASS 1 — FIRST LIQUIDATION (bars 12–18): ornaments gone, top trimmed. +// The tune restates but plainer: no grace notes, slinky folds toward the +// sleep settle, gaps widen, rings lengthen. Drop the staccato + pad entirely. +// ════════════════════════════════════════════════════════════════════════════ + +// hush + a fragment of twinkle, augmented (stretched), no ornaments. +place(aug(MOTIFS.hush, 1.3), 12, 0, 0.4, -0.05, { decayK: 1.5, durMul: 1.1 }); +// only the head of twinkle survives, folded, slower — the climb is half gone. +place(liquidate(MOTIFS.twinkle, 3, 1.4), 14, 0, 0.36, 0.05, { fold: true, decayK: 1.55 }); +// slinky liquidated to its first gesture, dropping into the sleep contour. +place(liquidate(MOTIFS.baba, 4, 1.3), 16, 0, 0.34, 0.04, { fold: true, decayK: 1.6 }); +// one far sparkle — the last ornament — and a thinning bass already below. +sparkle("C6", 15, 1.5, 0.18, 4, 0.36); + +// ════════════════════════════════════════════════════════════════════════════ +// PASS 2 — SECOND LIQUIDATION (bars 19–24): only the sleep settle, pentatonic. +// The contour flattens to a pure stepwise sink to F, stated once, then its +// echo an octave lower. Long rings, almost no top. This is the old tuckbaba. +// ════════════════════════════════════════════════════════════════════════════ + +place(aug(MOTIFS.sleep, 1.25), 19, 0, 0.34, -0.06, { fold: true, decayK: 1.7 }); +place(aug(MOTIFS.sleep, 1.4), 22, 0, 0.26, 0.07, { fold: true, decayK: 1.8, octave: -12 }); + +// ════════════════════════════════════════════════════════════════════════════ +// PASS 3 — THIRD LIQUIDATION (bars 26–28): the falling third, alone. +// The sleep descent erodes to its bare cadence: a high note, a third below, +// and home on F. Almost nothing left of the contour. +// ════════════════════════════════════════════════════════════════════════════ + +place([["A4", 3], ["F4", 4]], 26, 0, 0.28, 0, { fold: true, decayK: 1.9 }); + +// ════════════════════════════════════════════════════════════════════════════ +// PASS 4 — FINAL LIQUIDATION (bars 30+): a single low F, then silence. +// ════════════════════════════════════════════════════════════════════════════ + +events.push({ + preset: "rosewood", startSec: 30 * BAR, midi: snap(m("F3")), + durSec: 5 * BEAT, gain: 0.24, decayMul: (DECAY.rosewood ?? 1.8) * 2.0, pan: 0, +}); + +// the lone "goodnight" sparkle — high, far, the last waking thought. +sparkle("C6", 31, 1.5, 0.18, 7, 0.32); + +// ── the slow heartbeat: dense at the top, spacing out + softening as it sleeps +const breaths = [ + [0, 0.36], [1, 0.34], [2, 0.34], [3, 0.32], // PASS 0 — full, every bar + [4, 0.32], [6, 0.3], [8, 0.3], [10, 0.28], // still steady + [12, 0.28], [14, 0.26], [16, 0.24], // PASS 1 — thinning + [19, 0.24], [22, 0.2], // PASS 2 — sparse + [26, 0.18], // PASS 3 + [30, 0.16], // PASS 4 — last breath +]; +for (const [bar, gain] of breaths) { + events.push({ + preset: "bass", startSec: bar * BAR, + midi: m(bar >= 19 ? "F2" : (bar % 2 ? "F3" : "F2")), + durSec: 3 * BEAT, gain, + decayMul: (DECAY.bass ?? 1.8) * (bar >= 16 ? 1.7 : 1.3), pan: 0, + }); +} + +const { mp3, durationSec } = renderLullaby(events, { + name: "tuckbaba", + here: HERE, + title: "tuckbaba", + reverb: { wet: 0.38, decay: 0.86, damp: 0.36 }, + fadeIn: 1.2, + fadeOut: 6.0, + tailSec: 6.5, + peak: 0.8, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/velvetbaba.mjs b/pop/marimba/lullabies/variations/velvetbaba.mjs new file mode 100644 index 0000000000..b7e13bb5ad --- /dev/null +++ b/pop/marimba/lullabies/variations/velvetbaba.mjs @@ -0,0 +1,322 @@ +// velvetbaba.mjs — a plush, lush riff on the marimbaba lullaby, scored for +// vibraphone in Db major at ~50 BPM. +// +// Direction: PLUSH & LUSH. The whistlegraph tune (hush → twinkle → wow → +// baba → sleep) is kept as the melodic DNA, but it is developed by +// REHARMONIZATION + a COUNTERMELODY in parallel thirds/sixths that weaves +// underneath the lead. The harmony shifts richly beneath a slowly +// elaborating tune: each return of a phrase sits over a new chord, so the +// same notes keep recoloring (a Db pedal heard as Db, then Bbm7, then Gbmaj7, +// then Absus, etc.). The countermelody shadows the lead a 3rd/6th below, +// so the line always travels in velvet doubles. +// +// DEVELOPMENT STRATEGY — REHARMONIZATION + PARALLEL-THIRDS COUNTERMELODY: +// - PASS structure walks the tune through a lush descending-fifths-ish +// progression in Db, revoicing the SAME melodic cells over fresh chords. +// - A countermelody (harmonize() snaps each lead note down a diatonic 3rd +// or 6th) doubles the lead through a softer voice (vibraphone_off / kelon), +// so the tune is always plush, never bare. +// - The tune slowly ELABORATES: bare statement → thirds-doubled → ornamented +// (passing tones) → widening to sixths at the apex → settling back to a +// close thirds-doubled cadence for sleep. +// - Recognizable thread: the descending hush sigh opens it, the contour of +// twinkle/wow/baba is intact, and it cadences home to Db. +// +// Run: node variations/velvetbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 50; +const BEAT = 60 / BPM; +const BAR = 3 * BEAT; // 3/4 + +// ── Db major scale-fold (root pc = 1). Snap melodic voices to the mode. ── +const ROOT = 1; // Db +const MAJOR = [0, 2, 4, 5, 7, 9, 11]; +function snap(midi) { + const pc = ((midi % 12) + 12) % 12; + const rel = ((pc - ROOT) % 12 + 12) % 12; + let best = MAJOR[0], bestD = 99; + for (const s of MAJOR) { + const d = Math.min(Math.abs(s - rel), 12 - Math.abs(s - rel)); + if (d < bestD) { bestD = d; best = s; } + } + const base = midi - rel; + let cand = base + best; + if (cand - midi > 6) cand -= 12; + if (midi - cand > 6) cand += 12; + return cand; +} + +const LEAD = "vibraphone"; +const SHADOW = "vibraphone_off"; // the velvet countermelody voice +const ORN = "kelon"; // softer ornament voice + +// Db scale degrees as MIDI roots for chords/pads/bass. +// names spelled flat-friendly: Db Eb F Gb Ab Bb C +const DEGNAME = ["Db", "Eb", "F", "Gb", "Ab", "Bb", "C"]; +const deg = (d, oct) => snap(m(DEGNAME[((d % 7) + 7) % 7] + oct)); + +// ── motif → Db-folded MIDI cells. The marimbaba MOTIFS are written in F; +// transpose -4 toward Db then snap into the mode. Each cell is a list of +// [midi, beats] pairs we can revoice / harmonize / ornament freely. ──────── +function cell(motif) { + return MOTIFS[motif].map(([name, beats]) => [snap(m(name) - 4), beats]); +} + +// transpose a whole cell by an interval, keeping it diatonic. +function tpose(c, semis) { return c.map(([midi, beats]) => [snap(midi + semis), beats]); } + +// ── DIATONIC HARMONIZER — the heart of the velvet sound. ─────────────────── +// harmonize(cell, steps) shifts each note DOWN by `steps` scale-degrees +// (2 → a 3rd below, 5 → a 6th below), staying in Db major. This builds the +// parallel-thirds / -sixths countermelody that shadows the lead. +function scaleIndex(midi) { + // nearest diatonic degree index (0..6) + octave so we can step by degree. + const pc = ((midi % 12) + 12) % 12; + const rel = ((pc - ROOT) % 12 + 12) % 12; + let bestI = 0, bestD = 99; + for (let i = 0; i < MAJOR.length; i++) { + const d = Math.min(Math.abs(MAJOR[i] - rel), 12 - Math.abs(MAJOR[i] - rel)); + if (d < bestD) { bestD = d; bestI = i; } + } + const oct = Math.floor((midi - ROOT - MAJOR[bestI]) / 12); + return { i: bestI, oct }; +} +function degToMidi(i, oct) { + let ii = i, o = oct; + while (ii < 0) { ii += 7; o -= 1; } + while (ii > 6) { ii -= 7; o += 1; } + return ROOT + MAJOR[ii] + 12 * o; +} +function harmonize(c, steps) { + return c.map(([midi, beats]) => { + const { i, oct } = scaleIndex(midi); + return [degToMidi(i - steps, oct), beats]; + }); +} + +// ── ORNAMENT — insert a passing tone between notes a 3rd apart, so the tune +// "elaborates" without losing its shape. Returns a denser cell. ────────── +function ornament(c, depth = 0.5) { + const out = []; + for (let i = 0; i < c.length; i++) { + const [midi, beats] = c[i]; + const next = c[i + 1]; + if (next && beats >= 1) { + const gap = next[0] - midi; + if (Math.abs(gap) >= 3 && Math.abs(gap) <= 5) { + // split this note: keep most of it, then slip a stepwise passing tone + const { i: di, oct } = scaleIndex(midi); + const passing = degToMidi(di + (gap > 0 ? 1 : -1), oct); + out.push([midi, beats - depth]); + out.push([passing, depth]); + continue; + } + } + out.push([midi, beats]); + } + return out; +} + +// ── lay a cell into events at a bar, voice, with register-following pan. ───── +function lay(out, c, { startBar, voice = LEAD, gain = 0.5, pan = 0, decayMul = 1.7, beat0 = 0 } = {}) { + let beat = beat0; + for (let i = 0; i < c.length; i++) { + const [midi, beats] = c[i]; + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi, + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * decayMul, + pan, + }); + beat += beats; + } + return beat; +} + +// ── lay the lead AND its parallel countermelody together. This is the velvet: +// the tune always travels doubled in thirds (or sixths at the apex). The +// shadow is panned opposite + softer so it weaves UNDER the lead. ───────── +function layVelvet(out, c, { startBar, steps = 2, leadGain = 0.5, shadGain = 0.3, + leadPan = 0.14, shadPan = -0.2, shadVoice = SHADOW, decayMul = 1.7 } = {}) { + lay(out, c, { startBar, voice: LEAD, gain: leadGain, pan: leadPan, decayMul }); + lay(out, harmonize(c, steps), { startBar, voice: shadVoice, gain: shadGain, pan: shadPan, decayMul: decayMul + 0.2 }); +} + +// ── a lush chord pad (vibraphone_off) breathing under a span of bars. The +// REHARMONIZATION lives here: `chord` is a list of scale-degree+octave +// pairs, so the same melody recolors as the chord beneath it changes. ───── +function pad(out, startBar, lenBars, chord, gain = 0.12) { + for (let i = 0; i < chord.length; i++) { + const [d, oct] = chord[i]; + out.push({ + preset: "vibraphone_off", + startSec: startBar * BAR, + midi: deg(d, oct), + durSec: lenBars * BAR + BEAT, + gain, + decayMul: 2.1, + pan: (i - (chord.length - 1) / 2) * 0.16, + }); + } +} + +// soft bass root under a span (kept low + clear). +function bass(out, startBar, d, oct, lenBars = 2, gain = 0.4) { + out.push({ + preset: "bass", + startSec: startBar * BAR, + midi: deg(d, oct), + durSec: lenBars * BAR, + gain, + decayMul: DECAY.bass * 1.4, + pan: 0, + }); +} + +// a single high sparkle (glockenspiel/kalimba) — velvet glints. +function star(out, { startBar, beat, name, beats, voice = "glockenspiel", gain = 0.11, pan = 0.3 }) { + out.push({ + preset: voice, + startSec: startBar * BAR + beat * BEAT, + midi: snap(m(name)), + durSec: beats * BEAT, + gain, + decayMul: (DECAY[voice] ?? 1) * 1.6, + pan, + }); +} + +const events = []; + +// ════════════════════════════════════════════════════════════════════════ +// ARC: bare hush → thirds-doubled twinkle (reharmonized) → ornamented wow → +// sixths-widened baba apex → close thirds cadence to sleep, home Db. +// The chord bed walks: Db → Bbm7 → Gbmaj7 → Ab → Db ... re-coloring the +// recurring tune so it keeps transforming under the velvet. +// ════════════════════════════════════════════════════════════════════════ + +// ── PASS 0 (bars 0–3): the hush sigh, almost BARE — the tune as first heard, +// over a plush Db(add9) bed. A faint shadow a 3rd below enters only on the +// held note, so the recognizable descending sigh stays legible. ────────── +{ + const hush = cell("hush"); + lay(events, hush, { startBar: 0, voice: LEAD, gain: 0.5, pan: 0.1, decayMul: 1.8 }); + // shadow just the final long note (a 6th below) — first hint of velvet + lay(events, harmonize([hush[hush.length - 1]], 5), { startBar: 0, voice: SHADOW, gain: 0.26, pan: -0.18, decayMul: 2.0, beat0: 6 }); + bass(events, 0, 0, 2, 2, 0.42); // Db + bass(events, 2, 0, 2, 2, 0.42); + pad(events, 0, 4, [[0, 4], [2, 4], [4, 4], [1, 5]], 0.12); // Db add9 (Db F Ab Eb) + star(events, { startBar: 2, beat: 2.2, name: "Ab5", beats: 1.5, gain: 0.08, pan: 0.32 }); +} + +// ── PASS 1 (bars 4–9): twinkle, now DOUBLED IN THIRDS — the velvet arrives. +// REHARMONIZED: the climbing wave is heard first over Bbm7 (vi), then the +// same wave sequenced up over Gbmaj7 (IV) — same contour, new color. ────── +{ + const tw = cell("twinkle"); + layVelvet(events, tw, { startBar: 4, steps: 2, leadGain: 0.48, shadGain: 0.3, leadPan: 0.16, shadPan: -0.22 }); + // answer: the wave sequenced up a 3rd, still doubled — the tune climbing + layVelvet(events, tpose(tw, 3), { startBar: 6, steps: 2, leadGain: 0.44, shadGain: 0.28, leadPan: 0.18, shadPan: -0.24 }); + // bars 8–9: a flyHigh lift, doubled in thirds, the velvet reaching up + layVelvet(events, cell("flyHigh"), { startBar: 8, steps: 2, leadGain: 0.46, shadGain: 0.28, leadPan: 0.2, shadPan: -0.26 }); + + bass(events, 4, 5, 1, 2, 0.36); // Bb (vi) + bass(events, 6, 3, 1, 2, 0.36); // Gb (IV) + bass(events, 8, 4, 1, 2, 0.36); // Ab (V) + pad(events, 4, 2, [[5, 3], [0, 4], [2, 4], [4, 4]], 0.12); // Bbm7 (Bb Db F Ab) + pad(events, 6, 2, [[3, 3], [5, 3], [2, 4], [4, 4]], 0.12); // Gbmaj7 (Gb Bb F Ab... -> Gb Bb F) + pad(events, 8, 2, [[4, 3], [6, 3], [2, 4]], 0.12); // Ab (Ab C F) + star(events, { startBar: 5, beat: 1.0, name: "Db6", beats: 1.5, voice: "kalimba", gain: 0.12, pan: -0.26 }); + star(events, { startBar: 9, beat: 0.5, name: "Gb6", beats: 1.5, voice: "glockenspiel", gain: 0.1, pan: 0.34 }); +} + +// ── PASS 2 (bars 10–15): the "wow" wobble, now ORNAMENTED (passing tones) +// and doubled in thirds — the tune slowly elaborating. The baba slinky-bap +// follows, ornamented too. REHARMONIZED over a warm IV → ii → V swell. ──── +{ + const wow = ornament(cell("wow"), 0.5); + layVelvet(events, wow, { startBar: 10, steps: 2, leadGain: 0.44, shadGain: 0.28, leadPan: 0.16, shadPan: -0.22, decayMul: 1.8 }); + const baba = ornament(cell("baba"), 0.25); + layVelvet(events, baba, { startBar: 13, steps: 2, leadGain: 0.42, shadGain: 0.26, leadPan: 0.18, shadPan: -0.24, decayMul: 1.7 }); + + bass(events, 10, 3, 1, 2, 0.34); // Gb (IV) + bass(events, 12, 1, 1, 2, 0.34); // Eb (ii) + bass(events, 14, 4, 1, 2, 0.34); // Ab (V) + pad(events, 10, 2, [[3, 3], [5, 3], [2, 4]], 0.12); // Gbmaj + pad(events, 12, 2, [[1, 3], [3, 4], [5, 4]], 0.12); // Ebm7 (Eb Gb Bb) + pad(events, 14, 2, [[4, 3], [6, 3], [2, 4]], 0.12); // Ab + star(events, { startBar: 11, beat: 1.6, name: "Bb5", beats: 1.5, voice: "glockenspiel", gain: 0.1, pan: 0.32 }); + star(events, { startBar: 13, beat: 2.0, name: "F6", beats: 1.5, voice: "glockenspiel", gain: 0.1, pan: 0.36 }); +} + +// ── PASS 3 (bars 16–21): THE APEX — the velvet WIDENS to SIXTHS. The twinkle +// wave returns up an octave, doubled a SIXTH below (plusher, more open), +// over the richest reharmonization (Gbmaj7 → Db/F → Ab9). A second, inner +// countermelody (a 3rd) fills the chord so the line travels in full thirds- +// and-sixths velvet. ────────────────────────────────────────────────────── +{ + const tw = tpose(cell("twinkle"), 0); + // lead up an octave, shadow a SIXTH below, plus an inner 3rd voice — triple velvet + lay(events, tpose(tw, 12), { startBar: 16, voice: LEAD, gain: 0.46, pan: 0.18, decayMul: 1.8 }); + lay(events, harmonize(tpose(tw, 12), 5), { startBar: 16, voice: SHADOW, gain: 0.28, pan: -0.24, decayMul: 2.0 }); // a 6th below + lay(events, harmonize(tpose(tw, 12), 2), { startBar: 16, voice: ORN, gain: 0.2, pan: -0.06, decayMul: 1.5 }); // inner 3rd + + const fly = cell("flyHigh"); + lay(events, tpose(fly, 12), { startBar: 18, voice: LEAD, gain: 0.44, pan: 0.2, decayMul: 1.8 }); + lay(events, harmonize(tpose(fly, 12), 5), { startBar: 18, voice: SHADOW, gain: 0.26, pan: -0.26, decayMul: 2.0 }); + + // a final wide wow at the very top, sixths-doubled, before the descent + const wow = cell("wow"); + layVelvet(events, tpose(wow, 12), { startBar: 20, steps: 5, leadGain: 0.42, shadGain: 0.26, leadPan: 0.16, shadPan: -0.26, decayMul: 1.8 }); + + bass(events, 16, 3, 1, 2, 0.34); // Gb + bass(events, 18, 0, 1, 2, 0.34); // Db (over F-ish bass color via pad) + bass(events, 20, 4, 1, 2, 0.34); // Ab9 + pad(events, 16, 2, [[3, 3], [5, 3], [2, 4], [4, 4]], 0.12); // Gbmaj7 + pad(events, 18, 2, [[2, 3], [0, 4], [4, 4], [2, 5]], 0.12); // Db/F (F Db Ab F) + pad(events, 20, 2, [[4, 3], [6, 3], [2, 4], [1, 4]], 0.12); // Ab9 (Ab C F Eb) + star(events, { startBar: 17, beat: 1.5, name: "Db7", beats: 2, gain: 0.09, pan: 0.34 }); + star(events, { startBar: 21, beat: 0.8, name: "Ab6", beats: 2, voice: "kalimba", gain: 0.1, pan: -0.24 }); +} + +// ── PASS 4 (bars 22–27): SLEEP. The velvet settles back to a close thirds- +// doubled cadence in the home register, harmony resolving V → I (Ab → Db). +// The tune comes home, recognizable and plush. A last hush echo closes +// the frame. ────────────────────────────────────────────────────────────── +{ + const sleep = cell("sleep"); + layVelvet(events, sleep, { startBar: 22, steps: 2, leadGain: 0.46, shadGain: 0.3, leadPan: 0.12, shadPan: -0.18, decayMul: 1.9 }); + // a final, faint, close hush echo — doubled a 3rd below (recognizable thread) + layVelvet(events, cell("hush"), { startBar: 25, steps: 2, leadGain: 0.4, shadGain: 0.26, leadPan: 0.1, shadPan: -0.16, decayMul: 2.1 }); + + bass(events, 22, 4, 1, 2, 0.36); // Ab (V) + bass(events, 24, 0, 2, 2, 0.34); // Db (I) + bass(events, 26, 0, 2, 2, 0.32); // Db + pad(events, 22, 2, [[4, 3], [6, 3], [2, 4]], 0.12); // Ab (V) + pad(events, 24, 4, [[0, 4], [2, 4], [4, 4], [1, 5]], 0.13); // Db add9 — long resolving home + star(events, { startBar: 25, beat: 1.0, name: "Db6", beats: 4, gain: 0.08, pan: 0.3 }); + star(events, { startBar: 26, beat: 1.5, name: "Ab5", beats: 3, voice: "kalimba", gain: 0.08, pan: -0.22 }); +} + +const { mp3, durationSec } = renderLullaby(events, { + name: "velvetbaba", + here: HERE, + title: "velvetbaba", + reverb: { wet: 0.4, decay: 0.86, damp: 0.36 }, // plush velvet room + fadeIn: 1.4, + fadeOut: 6.0, + tailSec: 6.0, + peak: 0.83, + healingHz: 639, // Solfeggio FA — "connection/relationships", sits well in Db +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`); diff --git a/pop/marimba/lullabies/variations/waltzbaba.mjs b/pop/marimba/lullabies/variations/waltzbaba.mjs new file mode 100644 index 0000000000..010df5f348 --- /dev/null +++ b/pop/marimba/lullabies/variations/waltzbaba.mjs @@ -0,0 +1,359 @@ +// waltzbaba.mjs — a waltz that trips over itself. +// +// marimbaba is in 3/4, sleepily. waltzbaba keeps the F-major key, the rosewood +// lead, the ~66 BPM spin and the warm marimba mood — but it DRAMATICALLY develops +// the melody through HEMIOLA & METRIC MODULATION. The dance keeps slipping its +// footing: 3-against-2 and 2-against-3 cross-rhythms superimpose, accents migrate +// so the bar-line seems to walk, the tune briefly *feels* in duple (a stomping +// 2/4) then snaps back into 3/4, dizzy and reeling. The recognizable threads — +// the hush descent, the slinky baba wobble, the F-major cadence — survive, but +// they travel through a meter that won't hold still. +// +// ARC: +// A (0-5) plain 3/4 statement — establish the waltz & the hush thread. +// B (6-11) hemiola I: lead phrases in groups of 2 over the 3/4 bass (3:2). +// C (12-17) metric modulation: the dotted-quarter pulse becomes the new beat; +// the dance briefly stomps in 2/4, baba diminished into fast runs. +// D (18-23) hemiola II: 2-against-3 the other way — bass implies duple, lead +// keeps spinning in 3 — accents collide, then resolve. +// E (24-29) recombination: fragments of every meter overlap (stretto) and +// the bar-line dissolves before the F-major cadence pulls it home. +// +// Run: node variations/waltzbaba.mjs (from pop/marimba/lullabies) + +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderLullaby, m } from "../lib/core.mjs"; +import { MOTIFS, DECAY } from "../lib/marimbaba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +const BPM = 66; +const BEAT = 60 / BPM; // quarter-note in 3/4 +const BAR = 3 * BEAT; // a 3/4 bar +const SWING = 0.10; + +// ── F major ── +const ROOT_PC = m("F4") % 12; +const MAJOR = [0, 2, 4, 5, 7, 9, 11]; +function snap(midi, rootPc = ROOT_PC, scale = MAJOR) { + let best = midi, bestD = Infinity; + for (let oct = -1; oct <= 1; oct++) { + for (const deg of scale) { + const pc = (rootPc + deg) % 12; + const base = Math.round((midi - pc) / 12) * 12 + pc + oct * 12; + const d = Math.abs(base - midi); + if (d < bestD - 1e-6 || (Math.abs(d - bestD) < 1e-6 && base < best)) { + best = base; bestD = d; + } + } + } + return best; +} + +// ── little melodic-development helpers (arrays of [note, beats]) ────────────── +const toMidi = (cell) => cell.map(([n, b]) => [m(n), b]); +const fromMidi = (cell) => cell; // we work in midi after toMidi + +function transpose(cell, semis) { return cell.map(([n, b]) => [n + semis, b]); } +function invert(cell, axisMidi) { + return cell.map(([n, b]) => [axisMidi + (axisMidi - n), b]); +} +function retrograde(cell) { return [...cell].reverse(); } +function diminish(cell, factor) { return cell.map(([n, b]) => [n, b / factor]); } +function augment(cell, factor) { return cell.map(([n, b]) => [n, b * factor]); } +function sequence(cell, steps) { + // steps: array of semitone offsets; concatenate transposed copies. + return steps.flatMap((s) => transpose(cell, s)); +} + +// emit a melodic cell (midi pitches, beats in BEAT units) into events, starting +// at absolute time t0, using `beatUnit` seconds per beat (lets us metrically +// modulate — pass a different unit to redefine the pulse). Returns end time. +function emit(events, cell, t0, { + preset = "rosewood", + beatUnit = BEAT, + gain = 0.5, + octave = 0, + pan = 0, + decayMul = 1.2, + swing = 0, + legato = 1.0, + accentEvery = 0, // accent index period (0 = none) + accentBoost = 1.35, +} = {}) { + let t = t0, i = 0; + for (const [midi, beats] of cell) { + const dur = beats * beatUnit; + const frac = ((t - t0) / beatUnit) % 1; + const sw = (frac > 0.4 && frac < 0.6) ? swing * beatUnit : 0; + const accent = accentEvery && (i % accentEvery === 0) ? accentBoost : 1; + events.push({ + preset, + startSec: t + sw, + midi: snap(midi) + octave * 12, + durSec: dur * legato, + gain: gain * accent, + decayMul: (DECAY[preset] ?? 1.6) * decayMul, + pan, + }); + t += dur; i += 1; + } + return t; +} + +const events = []; + +// motif source cells in midi. +const HUSH = toMidi(MOTIFS.hush); // C5 A4 F4 / F4 +const TWINKLE = toMidi(MOTIFS.twinkle); +const BABA = toMidi(MOTIFS.baba); // slinky wobble +const WOW = toMidi(MOTIFS.wow); +const SLEEP = toMidi(MOTIFS.sleep); + +// ════════════════════════════════════════════════════════════════════════════ +// THE WALTZ ENGINE — oom-pah-pah, but with shiftable accent so the bar can walk. +// chordMode: "waltz" (root-stab-stab), "hemiola" (accent every 2 beats), +// "duple" (modulated 2/4 stomp), "implied2" (bass groups of 2 over 3). +// ════════════════════════════════════════════════════════════════════════════ +const CH = { + F: ["F2", ["A4", "C5"]], + C: ["C3", ["E4", "G4"]], + Bb: ["Bb2", ["D4", "F4"]], + Dm: ["D3", ["F4", "A4"]], + Gm: ["G2", ["Bb4", "D5"]], + Eb: ["Eb2", ["G4", "Bb4"]], // borrowed colour around the baba +}; + +function oom(t, bassNote, gain = 0.42, dur = 1.0 * BEAT) { + events.push({ + preset: "bass", startSec: t, midi: m(bassNote), durSec: dur, + gain, decayMul: (DECAY.bass ?? 1.8), pan: 0, + }); +} +function stab(t, note, gain = 0.16, pan = 0, dur = 0.5 * BEAT) { + events.push({ + preset: "staccato", startSec: t, midi: snap(m(note)), durSec: dur, + gain, decayMul: 0.9, pan, + }); +} + +// plain waltz bar: oom (b1) pah (b2) pah (b3) +function barWaltz(bar, chord, g = 1) { + const [bn, [a, b]] = CH[chord]; + const t = bar * BAR; + oom(t + 0 * BEAT, bn, 0.42 * g); + stab(t + 1 * BEAT, a, 0.15 * g, -0.16); + stab(t + 1 * BEAT, b, 0.15 * g, -0.16); + stab(t + 2 * BEAT, a, 0.15 * g, 0.16); + stab(t + 2 * BEAT, b, 0.15 * g, 0.16); +} + +// hemiola bar (3:2): accent the *halves* of the bar — booms at beats 0 and 1.5, +// stabs filling — so the ear hears two strong pulses across a 3/4 bar. +function barHemiola2over3(bar, chord, g = 1) { + const [bn, [a, b]] = CH[chord]; + const t = bar * BAR; + oom(t + 0 * BEAT, bn, 0.42 * g); + stab(t + 0.75 * BEAT, a, 0.12 * g, -0.2); + oom(t + 1.5 * BEAT, bn, 0.38 * g, 0.8 * BEAT); // second "downbeat" mid-bar + stab(t + 2.25 * BEAT, b, 0.12 * g, 0.2); +} + +// 3-against-2 bar: bass keeps 3, but a counter-stab line lands every two beats +// across pairs of bars — handled at the span level (see spanHemiola3over2). + +// duple stomp bar (post metric-modulation): the new beat = dotted-quarter of the +// old. We just place two heavy booms per old-bar, splitting it into 2. +function barDuple(bar, chord, g = 1) { + const [bn, [a, b]] = CH[chord]; + const t = bar * BAR; + const half = 1.5 * BEAT; + oom(t + 0, bn, 0.44 * g, half * 0.9); + stab(t + 0.5 * BEAT, a, 0.13 * g, -0.18); + oom(t + half, bn, 0.40 * g, half * 0.9); + stab(t + half + 0.5 * BEAT, b, 0.13 * g, 0.18); +} + +// implied-2 bass over 3/4: booms every 2 beats, drifting against the bar line +// (resets every 3 bars = 9 beats vs 2-beat groups: classic walking accent). +function spanImplied2(startBar, nBars, chordSeq, g = 1) { + const t0 = startBar * BAR; + const totalBeats = nBars * 3; + let chordIdx = 0; + for (let bt = 0; bt < totalBeats; bt += 2) { + const ch = chordSeq[chordIdx % chordSeq.length]; chordIdx++; + const [bn] = CH[ch]; + oom(t0 + bt * BEAT, bn, 0.4 * g, 1.4 * BEAT); + } + // soft 3/4 ghost stabs underneath to keep the friction audible + for (let bar = 0; bar < nBars; bar++) { + const ch = chordSeq[bar % chordSeq.length]; + const [, [a, b]] = CH[ch]; + const t = t0 + bar * BAR; + stab(t + 1 * BEAT, a, 0.09 * g, -0.24); + stab(t + 2 * BEAT, b, 0.09 * g, 0.24); + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// SECTION A (bars 0-5): plain 3/4 — establish the waltz and the hush thread. +// ════════════════════════════════════════════════════════════════════════════ +{ + const prog = ["F", "F", "Dm", "Bb", "C", "F"]; + prog.forEach((c, i) => barWaltz(i, c)); + // lead: the hush sigh (recognizable), answered by a spinning curtsy of baba. + let t = 0 * BAR; + t = emit(events, HUSH, t, { gain: 0.52, decayMul: 1.25, swing: SWING }); + // a calm baba statement, plain, as the "theme" we'll deform. + emit(events, BABA, 2 * BAR, { gain: 0.5, pan: -0.05, decayMul: 1.2, swing: SWING }); + // a high kalimba shimmer answering at the cadence + emit(events, transpose(HUSH, 12), 4 * BAR, { + preset: "kalimba", gain: 0.2, pan: 0.32, decayMul: 1.3, + }); +} + +// ════════════════════════════════════════════════════════════════════════════ +// SECTION B (bars 6-11): HEMIOLA I — lead phrases in groups of TWO over the 3/4. +// The bass stays oom-pah-pah; the melody is re-rhythmed so every note is a +// half-note triplet-feel of 2 beats, walking across the bar line. Accent every +// 2 lead-notes to make the duple grouping pop against the triple bass. +// ════════════════════════════════════════════════════════════════════════════ +{ + const prog = ["F", "C", "Dm", "Bb", "C", "F"]; + prog.forEach((c, i) => barWaltz(6 + i, c, 0.85)); + // also lay hemiola booms on bars 8-9 so the bass itself starts to slip + barHemiola2over3(8, "Dm", 1.0); + barHemiola2over3(9, "Bb", 1.0); + + // Lead: take TWINKLE, re-rhythm so each pitch lasts 2 beats (a 3:2 hemiola: + // 3 melody notes span 2 bars). Sequence it up a step then resolve. + const twHemiola = TWINKLE.map(([n]) => [n, 2]); // all duple + let t = 6 * BAR; + t = emit(events, twHemiola, t, { + gain: 0.5, decayMul: 1.2, accentEvery: 1, accentBoost: 1.25, + }); + // a fragmented baba in duple grouping, sequenced down (it spins off-axis) + const babaFrag = BABA.slice(0, 4).map(([n]) => [n, 1.5]); // dotted-feel + emit(events, sequence(babaFrag, [0, -2]), t, { + gain: 0.46, pan: 0.1, decayMul: 1.2, accentEvery: 2, accentBoost: 1.3, + }); + // glassy counter-line a 3rd above, in the bass's 3/4 — the two meters rub. + emit(events, transpose(TWINKLE, -5), 9 * BAR, { + preset: "kalimba", gain: 0.18, pan: -0.3, octave: 0, decayMul: 1.3, + }); +} + +// ════════════════════════════════════════════════════════════════════════════ +// SECTION C (bars 12-17): METRIC MODULATION → the dance stomps in DUPLE. +// The dotted-quarter of the 3/4 becomes the new beat; we feel a 2/4 stomp. +// The baba motif is DIMINISHED into a fast run (the dizzy spin tightening), +// then inverted, riding the new pulse. +// ════════════════════════════════════════════════════════════════════════════ +{ + const prog = ["F", "Bb", "C", "Dm", "Bb", "C"]; + prog.forEach((c, i) => barDuple(12 + i, c)); + + const newBeat = 1.5 * BEAT; // the modulated pulse = dotted quarter + + // baba diminished into a rolling run, on the NEW beat, stated then sequenced. + const babaRun = diminish(BABA, 2); // twice as fast (sixteenth-ish spin) + let t = 12 * BAR; + t = emit(events, babaRun, t, { + beatUnit: newBeat, gain: 0.46, decayMul: 1.1, accentEvery: 4, + accentBoost: 1.35, pan: -0.08, + }); + t = emit(events, sequence(babaRun, [3]), t, { // sequence up a 4th + beatUnit: newBeat, gain: 0.46, decayMul: 1.1, accentEvery: 4, + accentBoost: 1.35, pan: 0.08, + }); + // inverted baba answer (mirror the contour) high & far — the spin reversing. + const axis = m("A5"); + emit(events, invert(BABA, axis), 15 * BAR, { + preset: "kalimba", beatUnit: newBeat, gain: 0.22, octave: 0, pan: 0.34, + decayMul: 1.25, + }); + // a duple xylophone tick marking the new strong beats (makes the 2/4 felt) + for (let bar = 12; bar < 18; bar++) { + const t2 = bar * BAR; + events.push({ preset: "woodblock", startSec: t2, midi: m("F5"), durSec: 0.12, gain: 0.12, pan: 0 }); + events.push({ preset: "woodblock", startSec: t2 + newBeat, midi: m("C5"), durSec: 0.12, gain: 0.12, pan: 0 }); + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// SECTION D (bars 18-23): HEMIOLA II — the OTHER way. Bass implies duple +// (booms every 2 beats), while the lead snaps back into spinning 3/4 phrases. +// Accents collide; we let WOW and HUSH fragments overlap in canon (stretto), +// then a sequence pulls toward the cadence. +// ════════════════════════════════════════════════════════════════════════════ +{ + spanImplied2(18, 6, ["F", "C", "Bb", "Gm", "C", "F"], 1.0); + + // lead back in 3 — WOW wobble, augmented slightly so it floats over the duple. + let t = 18 * BAR; + t = emit(events, augment(WOW, 1.0), t, { + gain: 0.5, decayMul: 1.25, swing: SWING, accentEvery: 3, accentBoost: 1.2, + }); + // canon: a second voice (kalimba) enters one bar later with the SAME WOW, + // a 5th up — stretto, two spinning lines crossing the duple bass. + emit(events, transpose(WOW, 7), 19 * BAR, { + preset: "kalimba", gain: 0.2, pan: 0.3, decayMul: 1.3, swing: SWING, + }); + // retrograde baba sequenced down toward the cadence — the tune unwinding. + const babaRetro = retrograde(BABA); + emit(events, sequence(babaRetro.slice(0, 4), [0, -2, -4]), 21 * BAR, { + gain: 0.46, pan: -0.1, decayMul: 1.2, accentEvery: 4, accentBoost: 1.25, + }); +} + +// ════════════════════════════════════════════════════════════════════════════ +// SECTION E (bars 24-29): RECOMBINATION + cadence. Fragments of every meter +// overlap (stretto) — a 3/4 hush, a duple stomp, a hemiola boom — then the +// bar-line re-coheres and the original F-major cadence brings it home to rest. +// ════════════════════════════════════════════════════════════════════════════ +{ + // bar 24-25: collide all three feels at once + barWaltz(24, "F", 0.9); + barDuple(25, "C", 0.9); + barHemiola2over3(26, "Bb", 0.95); + barWaltz(27, "Dm", 0.9); + barWaltz(28, "C", 0.95); + barWaltz(29, "F", 1.0); + + // overlapping fragments (stretto): hush (3/4), baba diminished (duple), and + // an inverted twinkle — all entering close together, the dizziness peaking. + emit(events, HUSH, 24 * BAR, { gain: 0.5, decayMul: 1.25, swing: SWING }); + emit(events, diminish(BABA, 2), 24 * BAR + 1.5 * BEAT, { + beatUnit: 1.5 * BEAT, preset: "kalimba", gain: 0.2, pan: 0.32, decayMul: 1.2, + }); + emit(events, invert(TWINKLE, m("A5")), 25 * BAR, { + preset: "staccato", gain: 0.16, pan: -0.28, decayMul: 1.0, + }); + + // the bar-line re-coheres: a clean plain baba (the theme returns, recognizable) + emit(events, BABA, 26 * BAR, { gain: 0.5, decayMul: 1.25, swing: SWING, pan: -0.04 }); + + // the cadence: SLEEP descent, augmented, settling to F — home at last. + let t = emit(events, augment(SLEEP, 1.0), 28 * BAR, { + gain: 0.5, decayMul: 1.35, swing: SWING, + }); + // a final low F bloom under the rest + oom(29 * BAR, "F2", 0.4, 3 * BEAT); + // a far high F kalimba — the last spin coming to rest + events.push({ + preset: "kalimba", startSec: 29 * BAR + 0.5 * BEAT, midi: m("F6"), + durSec: 2.5 * BEAT, gain: 0.16, decayMul: 1.4, pan: 0.36, + }); +} + +const { mp3, durationSec } = renderLullaby(events, { + name: "waltzbaba", + here: HERE, + title: "waltzbaba", + reverb: { wet: 0.34, decay: 0.85, damp: 0.36 }, + fadeIn: 0.9, + fadeOut: 4.5, + tailSec: 5.0, +}); +console.log(`✓ ${mp3} · ${durationSec.toFixed(1)}s`);