Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207#!/usr/bin/env node// compose.mjs — `gradus`, a /pop track. A classical two-voice piece// composed entirely from the new pop compositional tools://// • lib/meter.mjs — Fibonacci numbers set the FORM: phrase lengths// are consecutive Fibonacci values laid out as an arch, and// fibPartition places an internal rhythmic stress inside each phrase.// • lib/counterpoint.mjs — generateFirstSpecies writes the upper voice// above an algorithmically-grown cantus firmus, in strict Fux// first species; checkFirstSpecies verifies every phrase.// • dance/synths/sinepower.mjs — both voices are rendered with the// stacked-sine "pad" voice, an organ-ish sustain.//// Key: D dorian (Fux's home key). Output: gradus.mp3 + the two voices// as .np scores. Deterministic — fixed RNG seed, same track every run.//// Usage: node pop/gradus/bin/compose.mjs
import { writeFileSync, mkdirSync, unlinkSync } from "node:fs";import { resolve, dirname } from "node:path";import { fileURLToPath } from "node:url";import { spawnSync } from "node:child_process";import { fibSequence, fibPartition } from "../../lib/meter.mjs";import { generateFirstSpecies, checkFirstSpecies } from "../../lib/counterpoint.mjs";import { mixEventSinePower } from "../../dance/synths/sinepower.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));const LANE = resolve(HERE, "..");const SR = 48_000;const PRESET = "pad";const TARGET_SEC = 84; // aim for ~1:24const TAIL = 2.6; // decay tail past the last notelet BPM, BEAT; // tempo is solved to the target length
// ── D dorian ──────────────────────────────────────────────────────────const TONIC = 2; // pitch class Dconst DORIAN = [0, 2, 3, 5, 7, 9, 10]; // dorian degreesconst inKey = (m) => DORIAN.includes(((m - TONIC) % 12 + 12) % 12);const NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];const midiName = (m) => NAMES[((m % 12) + 12) % 12] + (Math.floor(m / 12) - 1);
// scale pool of D-dorian notes spanning the cantus-firmus registerfunction poolBetween(lo, hi) { const p = []; for (let m = lo; m <= hi; m++) if (inKey(m)) p.push(m); return p;}
// deterministic RNG (mulberry32)function rng(seed) { return () => { seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
// ── cantus firmus ─────────────────────────────────────────────────────// A stepwise walk through the scale pool: starts and ends on the tonic,// mostly steps with the odd third, one shaped climax. Lower voice.function makeCantus(len, pool, rand) { const tonicIdxs = pool.map((m, i) => (inKey(m + 0) && ((m - TONIC) % 12 + 12) % 12 === 0 ? i : -1)).filter((i) => i >= 0); const startIdx = tonicIdxs[0]; // lowest tonic const cf = [startIdx]; for (let i = 1; i < len - 1; i++) { const prev = cf[i - 1]; const climax = i / len < 0.62; // climb early, fall late let step; const r = rand(); if (r < 0.62) step = climax ? 1 : -1; // a scale step else if (r < 0.82) step = climax ? -1 : 1; // a step the other way else step = (climax ? 1 : -1) * 2; // a third let idx = prev + step; idx = Math.max(0, Math.min(pool.length - 1, idx)); if (idx === prev) idx = prev + (climax ? 1 : -1); idx = Math.max(0, Math.min(pool.length - 1, idx)); cf.push(idx); } // resolve to the tonic by step const finalIdx = tonicIdxs[0]; cf[len - 1] = finalIdx; if (Math.abs(cf[len - 2] - finalIdx) !== 1) cf[len - 2] = finalIdx + 1; // penultimate steps in return cf.map((i) => pool[Math.max(0, Math.min(pool.length - 1, i))]);}
// ── form: Fibonacci arch of phrases ───────────────────────────────────// Phrase lengths are consecutive Fibonacci numbers mirrored into an arch.const fib = fibSequence(6); // 1,2,3,5,8,13const climb = [fib[3], fib[4], fib[5]]; // 5, 8, 13const PHRASES = [...climb, ...climb.slice().reverse()]; // 5,8,13,13,8,5 = 52 colsconst rand = rng(0x6772 /* "gr" */);
// the two highest (8-note) phrases sit an octave up — registral climaxconst phrases = [];let totalCols = 0;for (let p = 0; p < PHRASES.length; p++) { const len = PHRASES[p]; const high = len === 13; // climax phrases const pool = high ? poolBetween(57, 79) : poolBetween(50, 69);
let cantus = null, counter = null; for (let tries = 0; tries < 80 && !counter; tries++) { cantus = makeCantus(len, pool, rand); counter = generateFirstSpecies(cantus, { tonic: TONIC, scale: DORIAN }); } if (!counter) { console.error(`✗ phrase ${p + 1}: no counterpoint found`); process.exit(1); }
const check = checkFirstSpecies(cantus, counter); phrases.push({ len, high, cantus, counter, check }); totalCols += len;}
// ── rhythm: fibPartition stresses + cadence holds ─────────────────────// Most columns are 2 beats. fibPartition(len) marks an internal stress// (3 beats). The last column of each phrase is a 4-beat cadence; the// very last column of the piece is held 8 beats.const columns = []; // { cantus, counter, beats }for (let p = 0; p < phrases.length; p++) { const ph = phrases[p]; const stressAt = fibPartition(ph.len)[0]; // first golden-split point for (let i = 0; i < ph.len; i++) { let beats = 2; if (i === stressAt && i !== ph.len - 1) beats = 3; if (i === ph.len - 1) beats = (p === phrases.length - 1) ? 8 : 4; columns.push({ cantus: ph.cantus[i], counter: ph.counter[i], beats }); }}
// ── tempo solved to the target length ─────────────────────────────────const totalBeats = columns.reduce((s, c) => s + c.beats, 0);BEAT = (TARGET_SEC - TAIL) / totalBeats;BPM = Math.round(60 / BEAT);
// ── render — two voices, sinepower pad ────────────────────────────────const totalSec = totalBeats * BEAT + TAIL;const low = new Float32Array(Math.ceil(totalSec * SR));const high = new Float32Array(low.length);
let t = 0;for (const col of columns) { const dur = col.beats * BEAT; mixEventSinePower({ startSec: t, midi: col.cantus, durSec: dur, gain: 0.80 }, low, { sampleRate: SR, preset: PRESET }); mixEventSinePower({ startSec: t, midi: col.counter, durSec: dur, gain: 0.72 }, high, { sampleRate: SR, preset: PRESET }); t += dur;}
// gentle stereo spread — cantus left-leaning, counterpoint right-leaningconst stereo = new Float32Array(low.length * 2);let peak = 0;for (let i = 0; i < low.length; i++) { const L = low[i] * 0.92 + high[i] * 0.32; const R = high[i] * 0.92 + low[i] * 0.32; stereo[i * 2] = L; stereo[i * 2 + 1] = R; peak = Math.max(peak, Math.abs(L), Math.abs(R));}if (peak > 0) { const n = 0.92 / peak; for (let i = 0; i < stereo.length; i++) stereo[i] *= n; }
// ── write .np scores + mp3 ────────────────────────────────────────────function toNp(title, pick) { let s = `# ${title}\n# gradus — D dorian · ${BPM} bpm · Fibonacci-arch first-species counterpoint\n\n`; let col = 0; phrases.forEach((ph, p) => { s += `# phrase ${p + 1} — ${ph.len} notes${ph.high ? " (octave climax)" : ""}\n`; const cells = []; for (let i = 0; i < ph.len; i++) { const c = columns[col++]; cells.push(`${midiName(pick(c))}:_*${c.beats}`); } s += cells.join(" ") + "\n\n"; }); return s;}mkdirSync(LANE, { recursive: true });writeFileSync(resolve(LANE, "gradus.cantus.np"), toNp("cantus voice (lower)", (c) => c.cantus));writeFileSync(resolve(LANE, "gradus.counter.np"), toNp("counterpoint voice (upper)", (c) => c.counter));
const mp3 = resolve(LANE, "gradus.mp3");const raw = resolve(LANE, ".gradus.f32.raw");const b = Buffer.alloc(stereo.length * 4);for (let i = 0; i < stereo.length; i++) b.writeFloatLE(stereo[i], i * 4);writeFileSync(raw, b);const ff = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", raw, "-c:a", "libmp3lame", "-q:a", "2", "-metadata", "title=gradus", "-metadata", "album=pixsies", "-metadata", "genre=classical", mp3], { stdio: "inherit" });try { unlinkSync(raw); } catch {}if (ff.status !== 0) { console.error("✗ ffmpeg failed"); process.exit(1); }
// ── report ────────────────────────────────────────────────────────────const mm = Math.floor(totalSec / 60), ss = Math.round(totalSec % 60);console.log(`\ngradus — ${phrases.length} phrases, ${totalCols} columns, ${totalBeats} beats`);phrases.forEach((ph, p) => { const flags = [...ph.check.violations.map((v) => "✗" + v.rule), ...ph.check.warnings.map((w) => "·" + w.rule)]; console.log(` phrase ${p + 1} ${String(ph.len).padStart(2)} notes ` + `${ph.check.ok ? "valid first species" : "RULE BREAK"}` + (flags.length ? ` [${flags.join(" ")}]` : ""));});console.log(`\n✓ ${mp3}`);console.log(`✓ gradus.cantus.np · gradus.counter.np`);console.log(` duration ≈ ${mm}:${String(ss).padStart(2, "0")}`);