diff --git a/pop/lib/fal.mjs b/pop/lib/fal.mjs index 2f9091b622..856a05e292 100644 --- a/pop/lib/fal.mjs +++ b/pop/lib/fal.mjs @@ -278,6 +278,32 @@ export async function generateAvatar({ return runQueueJob({ endpoint, input, outPath, label, log }); } +// Video UPSCALE / restore (Topaz Video AI via fal). Unlike the generators above, +// this invents no motion — it takes a real clip and rebuilds detail: denoise, +// deblur, and a learned 2×/4× upscale that actually recovers edges rather than +// interpolating them. Made for exactly the old-webcam-footage case. Takes a +// source VIDEO, returns a larger one. `factor` is 2 or 4; `targetFps` optionally +// resamples (leave null to keep the source rate). +// +// Cost is billed by fal per output-second and is NOT one of the Seedance rates +// above — confirm the current number on the model's fal page before a long run; +// this is why callers should test on a short cut first. The job's wall-clock is +// returned, not the dollar figure — read that from the fal dashboard. +export async function upscaleVideo({ + video, factor = 2, targetFps = null, model = "fal-ai/topaz/upscale/video", + outPath, label = "upscale", log = console.log, +}) { + if (!existsSync(video)) throw new Error(`upscale source not found: ${video}`); + // Topaz reads the clip from a URL, not an inline data: URI — upload first, the + // same as sync-lipsync. + const input = { + video_url: await uploadToFalStorage(video, log), + upscale_factor: factor, + ...(targetFps ? { target_fps: targetFps } : {}), + }; + return runQueueJob({ endpoint: model, input, outPath, label, log }); +} + // Text-to-video: generate a clip from a prompt alone (no source image). The // counterpart to generateShot (image+text) — together the helper covers both // the image-led and text-led paths. diff --git a/pop/lib/preview-shared.mjs b/pop/lib/preview-shared.mjs index 75cf2f78f9..f05834be17 100644 --- a/pop/lib/preview-shared.mjs +++ b/pop/lib/preview-shared.mjs @@ -125,16 +125,21 @@ export function magickMeasureWidth(text, ptSize) { } // Render `text` as a transparent PNG with YWFT-Processing-Bold. -// opts: { ptSize, fill, shadow?, shadowSpec?, stroke?, strokeWidth?, padX=0, padY=0, outPath } +// opts: { ptSize, fill, font?, shadow?, shadowSpec?, stroke?, strokeWidth?, padX=0, padY=0, outPath } // Returns a loaded Image. // // `shadowSpec` is magick's own x++. The default is a // faint 2px nudge, which is enough over a flat cover but vanishes over moving // footage — captions burned onto video want something like "100x0+6+7" (a hard, // offset drop) plus a `stroke`, so the glyph survives whatever passes behind it. +// +// `font` overrides the module's active font for this call only. Reach for it +// when one render needs two typefaces at once — burned-in captions in a plain +// system face, say, while the chrome around them stays in YWFT — since +// setPreviewFont() is global and would take both. export async function magickRenderText(text, opts) { const { - ptSize, fill, shadow, shadowSpec = "100x0+2+2", + ptSize, fill, font = null, shadow, shadowSpec = "100x0+2+2", stroke = null, strokeWidth = 0, padX = 0, padY = 0, outPath, } = opts; @@ -142,7 +147,7 @@ export async function magickRenderText(text, opts) { const args = [ "-background", "none", "-fill", fill, - "-font", activeFont, + "-font", font || activeFont, "-pointsize", String(ptSize), ]; // Stroke is drawn under the fill, so the outline thickens the glyph outward diff --git a/pop/maytrax/README.md b/pop/maytrax/README.md index 42ea05a829..ec10ae0041 100644 --- a/pop/maytrax/README.md +++ b/pop/maytrax/README.md @@ -49,6 +49,26 @@ node pop/maytrax/bin/maytrax.mjs node pop/maytrax/bin/maytrax.mjs --bpm 140 --out ~/m.mp3 ``` +### microtrax + +`microtrax` keeps `pianotrax`'s A-major ragtime motion but removes the piano +entirely. Short additive-sine bells play the syncopated figures over continuous +four-on-the-floor sine booms and tiny 16th-note clockwork. Its 72 bars at 144 +BPM land exactly at 2:00. + +```bash +node pop/maytrax/bin/render-microtrax.mjs +# → out/microtrax.mp3 +``` + +`femrag` plays the same two-minute micro-rag through a marimba-like hybrid of +bowl body modes and the FEM-derived shell modes from `pop/bell`: + +```bash +node pop/maytrax/bin/render-femrag.mjs +# → out/femrag.mp3 +``` + ## conventions - bpm: 138–142 (prodigy classic; `firestarter`=140, `breathe`=137) diff --git a/pop/maytrax/bin/render-femrag.mjs b/pop/maytrax/bin/render-femrag.mjs new file mode 100644 index 0000000000..4324c5828a --- /dev/null +++ b/pop/maytrax/bin/render-femrag.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +// femrag — microtrax's ragtime score played by short FEM-marimba bells. +if (!process.argv.includes("--fem")) process.argv.push("--fem"); +await import("./render-microtrax.mjs"); diff --git a/pop/maytrax/bin/render-microtrax.mjs b/pop/maytrax/bin/render-microtrax.mjs new file mode 100644 index 0000000000..112ad39f6f --- /dev/null +++ b/pop/maytrax/bin/render-microtrax.mjs @@ -0,0 +1,248 @@ +#!/usr/bin/env node +// microtrax — pianotrax's ragtime bones rebuilt as a two-minute microtekno toy. +// Every sound is a sine: short additive bells, bass pips, and pitch-falling +// sine-boom kicks. No piano samples, noise, filters, or distortion. +// +// 72 bars × 4 beats × 60 / 144 BPM = exactly 120 seconds. +// +// node pop/maytrax/bin/render-microtrax.mjs +// node pop/maytrax/bin/render-microtrax.mjs --out ~/microtrax.mp3 + +import { mkdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FEM = process.argv.includes("--fem"); +const TITLE = FEM ? "femrag" : "microtrax"; +const SR = 48_000; +const BPM = 144; +const BEAT = 60 / BPM; +const BAR = BEAT * 4; +const BARS = 72; +const SECONDS = BARS * BAR; +const NS = Math.ceil(SECONDS * SR); +const TAU = Math.PI * 2; +const SWING = .62; +const out = [new Float32Array(NS), new Float32Array(NS)]; +let voices = 0; + +const argv = (flag) => { + const i = process.argv.indexOf(flag); + return i < 0 ? null : process.argv[i + 1]; +}; +const expandHome = (p) => !p ? p : p === "~" ? homedir() + : p.startsWith("~/") ? resolve(homedir(), p.slice(2)) : p; +const hz = (midi) => 440 * 2 ** ((midi - 69) / 12); +const at = (bar, beat = 0) => bar * BAR + beat * BEAT; +const swung = (beat) => { + const eighth = beat * 2; + const whole = Math.floor(eighth + 1e-9); + const fraction = eighth - whole; + return Math.floor(whole / 2) * BEAT + (whole % 2 ? SWING * BEAT : 0) + + fraction * BEAT / 2; +}; + +function oscillator(t0, duration, freq, gain, { + attack = .003, release = .08, pan = 0, phase = 0, freqEnd = freq, +} = {}) { + const begin = Math.max(0, Math.floor(t0 * SR)); + const end = Math.min(NS, Math.ceil((t0 + duration) * SR)); + const angle = (pan + 1) * Math.PI / 4; + const lr = [Math.cos(angle), Math.sin(angle)]; + let p = phase; + voices++; + for (let i = begin; i < end; i++) { + const elapsed = i / SR - t0; + const remaining = duration - elapsed; + const a = Math.min(1, elapsed / attack); + const r = Math.min(1, remaining / release); + const env = Math.sin(Math.max(0, a) * Math.PI / 2) ** 2 + * Math.sin(Math.max(0, r) * Math.PI / 2) ** 2; + const f = freq * (freqEnd / freq) ** (elapsed / duration); + p += TAU * f / SR; + const sample = Math.sin(p) * gain * env; + out[0][i] += sample * lr[0]; + out[1][i] += sample * lr[1]; + } +} + +// A bell made exclusively from short sine partials. The mild inharmonicity is +// what separates it from a piano-like harmonic stack. +function bell(t0, midi, gain = .06, pan = 0, length = .22) { + const f = hz(midi); + // femrag combines bowl-like body modes with the first FEM-derived handbell + // shell modes used by pop/bell. Short decay turns the shell into a playable + // marimba bar rather than a sustained church bell. + const partials = FEM ? [ + [.461, .22, length * 1.35], [.688, .25, length * 1.18], [1, 1, length], + [3.479, .17, length * .54], [3.559, .14, length * .48], [4.81, .08, length * .34], + ] : [ + [1, 1, length], [2.01, .28, length * .62], [3.97, .12, length * .38], + ]; + partials.forEach(([ratio, level, duration], i) => oscillator(t0, duration, f * ratio, gain * level, { + attack: .0015, release: duration * .88, pan: pan + (i - 1) * .04, phase: i * 1.1, + })); +} + +function boom(t0, gain = .18) { + oscillator(t0, .34, 148, gain, { + attack: .0015, release: .31, freqEnd: 43, pan: 0, + }); + oscillator(t0, .19, 58, gain * .45, { + attack: .002, release: .17, freqEnd: 45, pan: 0, phase: .7, + }); +} + +const CHORDS = [ + { bass: 45, notes: [57, 61, 64, 69] }, // A + { bass: 38, notes: [57, 62, 66, 69] }, // D + { bass: 42, notes: [57, 61, 66, 69] }, // F#m + { bass: 40, notes: [56, 59, 64, 68] }, // E +]; +const RAG = [0, 2, 1, 3, 0, 2, 1, 3]; +const LEAD = [ + [[69, 0], [73, 1.5], [76, 2.5], [73, 3.25]], + [[78, 0], [76, .75], [73, 1.5], [71, 2.5], [73, 3.25]], + [[69, 0], [71, .75], [73, 1.5], [76, 2.25], [78, 3]], + [[76, 0], [73, 1], [71, 2], [68, 2.75], [69, 3.5]], +]; + +function kickBar(bar, weight = 1, pickup = false) { + for (let beat = 0; beat < 4; beat++) boom(at(bar, beat), .16 * weight * (beat === 0 ? 1.08 : 1)); + if (pickup) boom(at(bar, 3.75), .075 * weight); +} + +function ragBar(bar, density = 1, octave = 0, { pattern = true, oom = true } = {}) { + const chord = CHORDS[bar % 4]; + if (pattern) for (let e = 0; e < 8; e++) { + if (density < .7 && e % 2) continue; + const tt = at(bar) + swung(e * .5); + const midi = chord.notes[RAG[e]] + octave; + bell(tt, midi, (e % 2 ? .035 : .052) * density, e % 2 ? .46 : -.38, .16); + } + // Ragtime oom-pah reduced to tiny sine pips: bass on beats, chord answers. + if (oom) { + for (const beat of [0, 2]) bell(at(bar, beat), chord.bass, .072 * density, -.55, .2); + for (const beat of [1, 3]) chord.notes.slice(1, 4).forEach((m, i) => + bell(at(bar, beat) + i * .008, m + octave, .024 * density, .18 + i * .18, .13)); + } +} + +function leadBar(bar, amount = 1, octave = 0) { + LEAD[bar % 4].forEach(([midi, beat], i) => { + const tt = at(bar) + swung(beat); + bell(tt, midi + octave, .075 * amount, .18 + (i % 2) * .35, .24); + // A very short upper answer makes the melody read as microtekno, not keys. + if (i % 2 === 0) bell(tt + BEAT * .25, midi + 12 + octave, .025 * amount, -.58, .1); + }); +} + +function ticks(bar, amount = 1) { + for (let s = 0; s < 16; s++) { + if (s % 4 === 0) continue; + const note = [93, 97, 100, 97][(s + bar) % 4]; + bell(at(bar, s / 4), note, .010 * amount * (s % 2 ? 1 : .65), s % 2 ? .82 : -.82, .055); + } +} + +// 0–8: use the old ~1:22 micro-break as the opening vocabulary. The kick is +// continuous but restrained; the thin upper rag and late ticks make a clearer +// runway into the tune than the earlier miniature full-arrangement intro. +for (let bar = 0; bar < 8; bar++) { + const rise = .72 + bar * .025; + kickBar(bar, rise); + if (bar >= 1) ragBar(bar, .34 + bar * .025, 12, { pattern: bar >= 2, oom: true }); + if (bar >= 3) leadBar(bar, .24 + (bar - 3) * .035, bar < 6 ? -12 : 0); + if (bar >= 4) ticks(bar, .18 + (bar - 4) * .07); +} + +// 8–24: first full ragtime statement over an uninterrupted four-floor boom. +for (let bar = 8; bar < 24; bar++) { + kickBar(bar, .92, bar % 4 === 3); + ragBar(bar, .82); + leadBar(bar, .78); + if (bar >= 12) ticks(bar, .45); +} + +// 24–32: micro-break — not a kick break. Notes contract to little syncopations. +for (let bar = 24; bar < 32; bar++) { + kickBar(bar, .78); + ragBar(bar, .5, bar >= 28 ? 12 : 0); + if (bar % 2 === 0) leadBar(bar, .42, -12); + ticks(bar, .28); +} + +// 32–52: central machine-rag bloom, with 16th ticks and octave answers. +for (let bar = 32; bar < 52; bar++) { + kickBar(bar, 1, bar % 2 === 1); + ragBar(bar, 1); + leadBar(bar, 1, bar >= 44 ? 12 : 0); + ticks(bar, .8); + if (bar % 4 === 3) { + const chord = CHORDS[bar % 4]; + for (let s = 0; s < 8; s++) bell(at(bar, 2) + swung(s * .25), chord.notes[s % 4] + 12, .025, s % 2 ? .7 : -.7, .09); + } +} + +// 52–60: second micro-break. The continuous kick now feels like the melody. +for (let bar = 52; bar < 60; bar++) { + kickBar(bar, .84); + ragBar(bar, .42, 12); + if (bar >= 56) ticks(bar, .38); +} + +// 60–68: compact final strain, brighter and busier but still all tiny notes. +for (let bar = 60; bar < 68; bar++) { + const fade = 1 - Math.max(0, bar - 64) * .08; + kickBar(bar, .98 * fade, bar % 2 === 1); + ragBar(bar, .94 * fade, bar % 4 === 2 ? 12 : 0); + leadBar(bar, .92 * fade, 12); + ticks(bar, .72 * fade); +} + +// 68–72: strip the clock down without ever dropping the main kick. +for (let bar = 68; bar < 72; bar++) { + const fade = 1 - (bar - 68) * .2; + kickBar(bar, .82 * fade); + ragBar(bar, .5 * fade); + if (bar === 68) leadBar(bar, .55); +} +bell(at(71, 3), 45, .085, 0, .42); + +let peak = 0; +for (const channel of out) for (const sample of channel) peak = Math.max(peak, Math.abs(sample)); +const gain = peak ? .88 / peak : 1; +const fadeIn = Math.floor(.006 * SR); +const fadeOut = Math.floor(.55 * SR); +for (const channel of out) { + for (let i = 0; i < NS; i++) channel[i] *= gain; + for (let i = 0; i < fadeIn; i++) channel[i] *= i / fadeIn; + for (let i = 0; i < fadeOut; i++) channel[NS - 1 - i] *= i / fadeOut; +} + +const outPath = expandHome(argv("--out")) || resolve(HERE, "..", "out", `${TITLE}.mp3`); +mkdirSync(dirname(outPath), { recursive: true }); +const rawPath = `${outPath}.f32.raw`; +const buffer = Buffer.alloc(NS * 8); +for (let i = 0; i < NS; i++) { + buffer.writeFloatLE(out[0][i], i * 8); + buffer.writeFloatLE(out[1][i], i * 8 + 4); +} +writeFileSync(rawPath, buffer); +const ffmpeg = spawnSync("ffmpeg", [ + "-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", rawPath, + "-af", "acompressor=threshold=-18dB:ratio=1.8:attack=18:release=100:makeup=1.5:knee=6,alimiter=limit=.96:attack=4:release=70", + "-c:a", "libmp3lame", "-b:a", "320k", + "-metadata", `title=${TITLE}`, "-metadata", "artist=jeffrey", "-metadata", "album=pixsies", + outPath, +], { stdio: "inherit" }); +try { unlinkSync(rawPath); } catch {} +if (ffmpeg.status !== 0) { + console.error("✗ ffmpeg failed"); + process.exit(1); +} +console.log(`✓ ${outPath} · ${BPM} BPM · ${BARS} bars · ${SECONDS.toFixed(1)} s · ${voices} ${FEM ? "FEM-marimba" : "sine"} voices`); diff --git a/pop/nullabye/README.md b/pop/nullabye/README.md index fb7f368459..3560ab4259 100644 --- a/pop/nullabye/README.md +++ b/pop/nullabye/README.md @@ -39,6 +39,60 @@ node pop/nullabye/bin/render-nullabye.mjs --proof # flat EQ ⇒ bit-exact sil `--proof` renders with every band forced flat and asserts the output is bit-exact digital zero — the cancellation is real, not just quiet. +## sineabye — the oscillator cut + +`bin/render-sineabye.mjs` recasts the lullaby around sine voices, with a very +quiet bed of independently filtered noise hats and soft percussion. It uses no +samples, distortion, cancellation, or EQ-as-instrument technique. +It expands the original theme to exactly **2:00** (38 bars at 76 BPM), moving +through an immediate quiet opening verse, two increasingly full statements, +a consonant F-major bridge, homecoming, and a melody-only ending. Even the +heartbeat is a short downward-gliding sine; sparse modal gongs mark the form. + +```bash +node pop/nullabye/bin/render-sineabye.mjs +# → out/sineabye.mp3 + out/sineabye.struct.json +``` + +### spatial-sineabye — gravitational room cut + +`c/spatial-sineabye.c` is a small acoustic game engine: every musical voice is +a persistent body in a virtual room, and its live acoustic energy exerts +gravity plus a tangential “groove” force on a damped listener body. The moving +listener position compiles to distance, azimuth, elevation, Doppler, ear gain, +and room reflections. The same C simulation rasterizes an MP4 showing sources, +energy, attraction lines, listener position, and head direction in perspective +3D. After 1:18 its camera withdraws from the listener and eventually leaves the +whole room floating far away as a small source constellation. +Its listening law is intentionally selective: distance falls steeply after a +few room units, so wandering actively remixes the piece. Faint grey tethers in +the video show physical source distance; colored halos and lines show the +post-distance energy that actually reaches the stereo listener. +From 0:50–1:18 the complete source constellation eases through two physical +rotations. The audio and gravity use the rotating coordinates, so this passage +produces real proximity, Doppler, and stereo movement rather than a camera spin. +The echo and air sources are follower bodies: they occupy the listener's +position 2.2 and 5.5 seconds in the past, respectively. Their sound and gravity +therefore chase the player; a fading breadcrumb in the video exposes the same +six-second positional memory. + +```bash +sh pop/nullabye/c/build-spatial.sh +cd pop/nullabye/c +./spatial-sineabye --wav ../out/spatial-sineabye.wav \ + --mp3 ../out/spatial-sineabye.mp3 \ + --video ../out/spatial-sineabye.mp4 --spatial-wet 0.58 +``` + +`--spatial-wet 0..1` interpolates every voice between a restrained fixed-pan, +constant-distance studio mix and the complete moving-listener model (including +distance, azimuth, Doppler, and room motion). The pop default is 0.58; use 1 for +the fully experimental observation or 0 for the stable dry reference. The wet +bus is implemented by `c/ac_hrtf.h`, an allocation-free procedural binaural +core designed to compile unchanged to WebAssembly. It models fractional ITD, +far-ear head shadow, distance and elevation-dependent pinna notches. This is +portable directional DSP, not yet a personalized measured HRIR dataset. + ## nuellaby — the complexity-arch cut Second cut in the lane (`bin/render-nuellaby.mjs` → `out/nuellaby.mp3`, diff --git a/pop/nullabye/bin/gen-seedance-spatial.mjs b/pop/nullabye/bin/gen-seedance-spatial.mjs new file mode 100644 index 0000000000..c220f0d088 --- /dev/null +++ b/pop/nullabye/bin/gen-seedance-spatial.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node +// One-shot Seedance 2.0 reference-video pilot for spatial-sineabye. +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { generateReferenceShot } from "../../lib/fal.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LANE = resolve(HERE, ".."); +const OUT = resolve(LANE, "out/seedance"); +const reference = resolve(OUT, "spatial-ref-00-15.mp4"); +const source = resolve(LANE, "out/spatial-sineabye-hrtf.mp4"); +const raw = resolve(OUT, "spatial-sineabye-pilot-01-silent.mp4"); +const final = resolve(OUT, "spatial-sineabye-pilot-01.mp4"); +const promptPath = resolve(LANE, "seedance-spatial-prompt.txt"); +const prompt = readFileSync(promptPath, "utf8").trim(); + +if (!existsSync(reference) || !existsSync(source)) throw new Error("prepare/reference video missing"); +if (!existsSync(raw)) { + const result = await generateReferenceShot({ + videos: [reference], prompt, duration: "15", ratio: "1:1", + resolution: "720p", tier: "fast", audio: false, + outPath: raw, label: "spatial-sineabye-pilot", + }); + if (!result.ok) throw new Error(result.error); + writeFileSync(`${raw}.illy.json`, JSON.stringify({ + kind: "motion-reference", provider: "fal.ai", + model: "bytedance/seedance-2.0/fast/reference-to-video", + reference, promptPath, prompt, duration: 15, resolution: "720p", + aspectRatio: "1:1", seed: result.seed, generatedAt: new Date().toISOString(), + }, null, 2) + "\n"); +} + +const mux = spawnSync("ffmpeg", ["-hide_banner", "-y", "-loglevel", "error", + "-i", raw, "-ss", "0", "-t", "15", "-i", source, + "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", "-c:a", "aac", + "-b:a", "256k", "-shortest", final], { stdio: "inherit" }); +if (mux.status !== 0) process.exit(mux.status ?? 1); +console.log(`✓ ${final}`); diff --git a/pop/nullabye/bin/render-sineabye.mjs b/pop/nullabye/bin/render-sineabye.mjs new file mode 100644 index 0000000000..e664f3acc0 --- /dev/null +++ b/pop/nullabye/bin/render-sineabye.mjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node +// render-sineabye.mjs — nullabye reimagined as a two-minute sine lullaby. +// +// Pitched layers are sine oscillators. A very quiet filtered-noise hat and +// percussion bed adds continuity without becoming the source of a pitched voice. +// +// 38 bars × 4 beats × 60 / 76 BPM = exactly 120 seconds. +// +// Run: +// node pop/nullabye/bin/render-sineabye.mjs +// node pop/nullabye/bin/render-sineabye.mjs --out ~/sineabye.mp3 + +import { writeFileSync, mkdirSync, unlinkSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { spawnSync } from "node:child_process"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SR = 48_000; +const BPM = 76; +const BEAT = 60 / BPM; +const BAR = 4 * BEAT; +const TOTAL_BARS = 38; +const TOTAL_SEC = TOTAL_BARS * BAR; // 120.0 exactly +const NS = Math.ceil(TOTAL_SEC * SR); +const TAU = Math.PI * 2; + +const arg = (name) => { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : null; +}; +const expandHome = (p) => !p ? p + : p === "~" ? homedir() + : p.startsWith("~/") ? resolve(homedir(), p.slice(2)) + : p; +const t = (bar, beat = 0) => bar * BAR + beat * BEAT; + +const HZ = { + A1: 55.00, C2: 65.41, F2: 87.31, G2: 98.00, A2: 110.00, + C3: 130.81, D3: 146.83, E3: 164.81, F3: 174.61, G3: 196.00, A3: 220.00, B3: 246.94, + C4: 261.63, D4: 293.66, E4: 329.63, F4: 349.23, G4: 392.00, A4: 440.00, B4: 493.88, + C5: 523.25, D5: 587.33, E5: 659.26, F5: 698.46, G5: 783.99, A5: 880.00, + C6: 1046.50, D6: 1174.66, E6: 1318.51, G6: 1567.98, +}; + +const out = [new Float32Array(NS), new Float32Array(NS)]; +let voiceCount = 0; +let noiseSeed = 0x51aeab1e; +const random = () => ((noiseSeed = (noiseSeed * 1664525 + 1013904223) >>> 0) / 4294967296) * 2 - 1; + +// Equal-power stereo placement and sin² attack/release keep a bare sine +// graceful at both edges. Optional drift is slow phase modulation, not EQ. +function sine(t0, duration, freq, gain, { + attack = 0.08, release = 0.35, pan = 0, phase = 0, + freqEnd = freq, drift = 0, driftRate = 0.08, +} = {}) { + const start = Math.max(0, Math.floor(t0 * SR)); + const end = Math.min(NS, Math.ceil((t0 + duration) * SR)); + const panAngle = (pan + 1) * Math.PI / 4; + const channelGain = [Math.cos(panAngle), Math.sin(panAngle)]; + let p = phase; + voiceCount++; + + for (let i = start; i < end; i++) { + const elapsed = i / SR - t0; + const remain = duration - elapsed; + const a = attack <= 0 ? 1 : Math.min(1, elapsed / attack); + const r = release <= 0 ? 1 : Math.min(1, remain / release); + const env = Math.sin(a * Math.PI / 2) ** 2 * Math.sin(r * Math.PI / 2) ** 2; + const progress = elapsed / duration; + const f = freq * Math.pow(freqEnd / freq, progress) + * (1 + drift * Math.sin(TAU * driftRate * elapsed + phase)); + p += TAU * f / SR; + const sample = Math.sin(p) * gain * env; + out[0][i] += sample * channelGain[0]; + out[1][i] += sample * channelGain[1]; + } +} + +function noiseHit(t0, duration, gain, { low = 2500, high = 9000, pan = 0 } = {}) { + const start = Math.max(0, Math.floor(t0 * SR)); + const end = Math.min(NS, Math.ceil((t0 + duration) * SR)); + const hpA = Math.exp(-TAU * low / SR), lpA = Math.exp(-TAU * high / SR); + const angle = (pan + 1) * Math.PI / 4; + const lr = [Math.cos(angle), Math.sin(angle)]; + let lowState = 0, highState = 0; + for (let i = start; i < end; i++) { + const elapsed = i / SR - t0; + const env = Math.sin(Math.min(1, elapsed / .008) * Math.PI / 2) ** 2 + * Math.exp(-elapsed * 5.2 / duration); + const white = random(); + lowState = (1 - hpA) * white + hpA * lowState; + const hp = white - lowState; + highState = (1 - lpA) * hp + lpA * highState; + const sample = highState * gain * env; + out[0][i] += sample * lr[0]; out[1][i] += sample * lr[1]; + } +} + +// Modal gong: a struck circular plate approximated by slowly beating, +// inharmonic sine modes. Sparse placement lets each large bloom finish speaking. +function gong(t0, fundamental, gain = .045, pan = 0) { + [[1, 1, 8.5], [1.41, .52, 7.2], [1.98, .34, 6.1], [2.91, .2, 4.8], [4.07, .1, 3.5]] + .forEach(([ratio, level, duration], i) => sine(t0, duration, fundamental * ratio, gain * level, { + attack: .018 + i * .006, release: duration * .92, pan: pan + (i - 2) * .06, + phase: i * 1.13, drift: .0012 / (i + 1), driftRate: .07 + i * .013, + })); +} + +const CHORDS = [ + { tones: [HZ.C3, HZ.G3, HZ.E4, HZ.D5], root: HZ.C2 }, + { tones: [HZ.A2, HZ.E3, HZ.C4, HZ.B4], root: HZ.A1 }, + { tones: [HZ.F3, HZ.C4, HZ.A4, HZ.G4], root: HZ.F2 }, + { tones: [HZ.G3, HZ.D4, HZ.B4, HZ.A4], root: HZ.G2 }, +]; + +const THEME_A = [ + [[HZ.E5, 0, 1.5], [HZ.G5, 1.5, .5], [HZ.A5, 2, 2]], + [[HZ.G5, 0, 1], [HZ.E5, 1, 1], [HZ.D5, 2, 2]], + [[HZ.C5, 0, 1.5], [HZ.D5, 1.5, .5], [HZ.E5, 2, 1], [HZ.G5, 3, 1]], + [[HZ.D5, 0, 3]], +]; +const THEME_B = [ + [[HZ.E5, 0, 1.5], [HZ.G5, 1.5, .5], [HZ.A5, 2, 2]], + [[HZ.G5, 0, 1], [HZ.A5, 1, 1], [HZ.C6, 2, 2]], + [[HZ.A5, 0, 1.5], [HZ.G5, 1.5, .5], [HZ.E5, 2, 1], [HZ.C5, 3, 1]], + [[HZ.D5, 0, 2], [HZ.E5, 2, 2]], +]; +const BRIDGE = [ + [[HZ.A5, 0, 2], [HZ.C6, 2, 2]], + [[HZ.G5, 0, 1], [HZ.E5, 1, 1], [HZ.D5, 2, 2]], + [[HZ.F5, 0, 2], [HZ.A5, 2, 1], [HZ.G5, 3, 1]], + [[HZ.E5, 0, 4]], +]; + +function padBar(bar, amount = 1, transpose = 1) { + const chord = CHORDS[bar % 4]; + chord.tones.forEach((f, i) => sine(t(bar), BAR + .12, f * transpose, .045 * amount, { + attack: .85, release: 1.15, pan: [-.7, .55, -.25, .75][i], + phase: i * 1.7, drift: .0007, driftRate: .035 + i * .008, + })); +} + +function melody(bar0, phrase, amount = 1, transpose = 1, echo = false) { + phrase.forEach((notes, b) => notes.forEach(([f, beat, beats], i) => { + const start = t(bar0 + b, beat); + const duration = beats * BEAT + .28; + sine(start, duration, f * transpose, .1 * amount, { + attack: .045, release: .3, pan: -.12, phase: i * .8, + drift: .00035, driftRate: .11, + }); + if (echo) sine(start + .5 * BEAT, duration * .72, f * transpose * 2, .018 * amount, { + attack: .09, release: .45, pan: .68, phase: 1.2, + }); + })); +} + +function foundation(bar, amount = 1, transpose = 1) { + const root = CHORDS[bar % 4].root; + sine(t(bar), BAR + .08, root * transpose, .075 * amount, { + attack: .18, release: .7, pan: -.08, drift: .00025, driftRate: .04, + }); + // Heartbeat: still a sine, with a short downward pitch curve. + for (const beat of [0, 2]) sine(t(bar, beat), .32, 61, .11 * amount, { + attack: .006, release: .25, pan: .05, freqEnd: 43, + }); +} + +function mobile(bar, amount = 1, transpose = 1) { + const chord = CHORDS[bar % 4]; + for (let beat = .5; beat < 4; beat += 1) { + const f = chord.tones[(Math.floor(beat) + bar) % chord.tones.length] * 4; + sine(t(bar, beat), .22, f * transpose, .021 * amount, { + attack: .012, release: .18, pan: beat % 2 ? -.82 : .82, + }); + } +} + +function noiseBed(bar, amount = 1) { + for (const beat of [.5, 1.5, 2.5, 3.5]) + noiseHit(t(bar, beat), .075, .095 * amount, { low: 4200, high: 10500, pan: beat % 2 ? .62 : -.62 }); + for (const beat of [1, 3]) + noiseHit(t(bar, beat), .14, .068 * amount, { low: 620, high: 2100, pan: beat === 1 ? -.28 : .28 }); + noiseHit(t(bar), BAR, .018 * amount, { low: 1800, high: 6200 }); +} + +// The filtered-noise ensemble is a continuous member of the arrangement, not +// a section effect. It grows into the body and bows slightly under the ending. +for (let bar = 0; bar < TOTAL_BARS; bar++) { + const amount = bar < 4 ? .58 + bar * .07 : bar < 32 ? .86 : .8 - (bar - 32) * .055; + noiseBed(bar, amount); +} + +// 0–4 · immediate first verse. An earlier cut waited through four isolated +// tones here; starting the tune at bar zero keeps the opening from feeling as +// though playback has stopped before the song arrives. +for (let bar = 0; bar < 4; bar++) padBar(bar, .62); +melody(0, THEME_A, .72); + +// 4–12 · the original melody, close and sparse. +for (let bar = 4; bar < 12; bar++) padBar(bar, .8); +melody(4, THEME_A, .9); +melody(8, THEME_B, .95); +for (let bar = 8; bar < 12; bar++) foundation(bar, .55); + +// 12–20 · fuller statement: bass, heartbeat, and a high sine mobile arrive. +for (let bar = 12; bar < 20; bar++) { + padBar(bar, 1); + foundation(bar, .72); + mobile(bar, .7); +} +melody(12, THEME_A, 1, true); +melody(16, THEME_B, 1.05, true); + +gong(t(19, 3.5), HZ.C2, .038, -.2); + +// 20–28 · bridge: the whole arrangement lifts together to consonant F major. +for (let bar = 20; bar < 28; bar++) { + padBar(bar, .92, 4 / 3); + foundation(bar, .58, 4 / 3); + mobile(bar, .46, 4 / 3); + [HZ.F4, HZ.A4, HZ.C5, HZ.G5].forEach((f, i) => sine(t(bar), BAR + .08, f, .018, { + attack: .7, release: .82, pan: [-.68, .42, -.25, .7][i], phase: i * 1.3, + })); +} +melody(20, BRIDGE, .9, 4 / 3, true); +melody(24, THEME_B, .82, 4 / 3, true); +gong(t(27, 3.5), HZ.F2, .042, .22); + +// 28–34 · homecoming: the theme returns, then gradually loses its mobile. +for (let bar = 28; bar < 34; bar++) { + const fade = 1 - (bar - 28) * .07; + padBar(bar, fade); + foundation(bar, .65 * fade); + if (bar < 32) mobile(bar, .45 * fade); +} +melody(28, THEME_A, 1.02, 1, true); +melody(32, THEME_B.slice(0, 2), .85); + +// 34–38 · the melodic line gets the last word: E–D–C, then a small C–D–E–C +// answer. No low drone after it, so the composition ends where the tune ends. +for (let bar = 34; bar < 38; bar++) padBar(bar, .42 - (bar - 34) * .07); +sine(t(34), 2 * BEAT, HZ.E5, .075, { attack: .08, release: .55, pan: -.2 }); +sine(t(35), 2 * BEAT, HZ.D5, .068, { attack: .08, release: .6, pan: .2 }); +[[HZ.C5, 0, 1], [HZ.D5, 1, 1], [HZ.E5, 2, 1], [HZ.C5, 3, 1.8]].forEach(([f, beat, beats], i) => + sine(t(36, beat), beats * BEAT, f, .072 - i * .006, { attack: .07, release: i === 3 ? 1.25 : .38, pan: (i - 1.5) * .15 })); +gong(t(33, 3.5), HZ.C2, .026, 0); + +// A restrained cross-channel room: three early reflections add space without +// smearing the exposed melody or turning the noise percussion into a wash. +for (const [delaySec, amount] of [[.071, .07], [.113, .045], [.181, .028]]) { + const delay = Math.floor(delaySec * SR); + for (let i = delay; i < NS; i++) { + const l = out[0][i - delay], r = out[1][i - delay]; + out[0][i] += r * amount; + out[1][i] += l * amount; + } +} + +// Peak-normalize, then leave a short true-silence landing at exactly 2:00. +let peak = 0; +for (const channel of out) for (const sample of channel) peak = Math.max(peak, Math.abs(sample)); +const gain = peak > 0 ? .86 / peak : 1; +const fadeIn = Math.floor(.02 * SR); +const fadeOut = Math.floor(.8 * SR); +for (const channel of out) { + for (let i = 0; i < NS; i++) channel[i] *= gain; + for (let i = 0; i < fadeIn; i++) channel[i] *= i / fadeIn; + for (let i = 0; i < fadeOut; i++) channel[NS - 1 - i] *= i / fadeOut; +} + +const outPath = expandHome(arg("--out")) || resolve(HERE, "..", "out", "sineabye.mp3"); +mkdirSync(dirname(outPath), { recursive: true }); +const rawPath = `${outPath}.f32.raw`; +const buffer = Buffer.alloc(NS * 2 * 4); +for (let i = 0; i < NS; i++) { + buffer.writeFloatLE(out[0][i], i * 8); + buffer.writeFloatLE(out[1][i], i * 8 + 4); +} +writeFileSync(rawPath, buffer); + +const ffmpeg = spawnSync("ffmpeg", [ + "-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", String(SR), "-ac", "2", "-i", rawPath, + "-af", [ + "highpass=f=28", + "equalizer=f=72:t=q:w=.8:g=1.2", + "equalizer=f=310:t=q:w=1.1:g=-.8", + "equalizer=f=7200:t=q:w=.9:g=-1.1", + "lowpass=f=15800", + "acompressor=threshold=-23dB:ratio=1.8:attack=28:release=210:makeup=1.8:knee=7", + "loudnorm=I=-13:TP=-1.2:LRA=10", + "alimiter=limit=.95:attack=6:release=100", + ].join(","), + "-c:a", "libmp3lame", "-q:a", "2", outPath, +], { stdio: "inherit" }); +try { unlinkSync(rawPath); } catch {} +if (ffmpeg.status !== 0) { + console.error("✗ ffmpeg failed"); + process.exit(1); +} + +const struct = { + _comment: "Section map for sineabye: sine voices with filtered-noise percussion and modal gongs.", + meter: 4, bpm: BPM, scale: "major", rootMidi: 60, totalSec: TOTAL_SEC, + sections: [ + { name: "opening-verse", startSec: 0, endSec: t(4) }, + { name: "theme", startSec: t(4), endSec: t(12) }, + { name: "full-statement", startSec: t(12), endSec: t(20) }, + { name: "lifted-bridge", startSec: t(20), endSec: t(28) }, + { name: "homecoming", startSec: t(28), endSec: t(34) }, + { name: "dissolve", startSec: t(34), endSec: TOTAL_SEC }, + ], +}; +writeFileSync(resolve(HERE, "..", "out", "sineabye.struct.json"), JSON.stringify(struct, null, 2) + "\n"); +console.log(`✓ ${outPath} · pop-mastered sine voices + filtered percussion · ${voiceCount} voices · ${TOTAL_SEC.toFixed(1)} s`); diff --git a/pop/nullabye/c/ac_hrtf.h b/pop/nullabye/c/ac_hrtf.h new file mode 100644 index 0000000000..6e1f013154 --- /dev/null +++ b/pop/nullabye/c/ac_hrtf.h @@ -0,0 +1,35 @@ +// ac_hrtf.h — allocation-free procedural binaural spatializer for native C/WASM. +// Not a personalized measured HRTF: this models ITD, head shadow, and moving +// elevation-dependent pinna combs. State is per voice; API has no OS dependency. +#ifndef AC_HRTF_H +#define AC_HRTF_H +#include +#include +#define AC_HRTF_RING 256 +#define AC_HRTF_PI 3.14159265358979323846 +typedef struct { float ring[AC_HRTF_RING]; int at; float shadowL,shadowR; } ACHrtf; +static inline float ac_hrtf_read(const ACHrtf *h,double delay){ + double p=h->at-delay;while(p<0)p+=AC_HRTF_RING;int a=(int)p&(AC_HRTF_RING-1),b=(a+1)&(AC_HRTF_RING-1);double f=p-floor(p);return h->ring[a]*(1-f)+h->ring[b]*f; +} +static inline void ac_hrtf_process(ACHrtf *h,float input,double azimuth,double elevation,double distance,float *L,float *R){ + // Acoustic floor is deliberately tiny: proximity remains a compositional axis. + double near=.004+.996/(1+.55*distance*distance),side=sin(azimuth); + h->at=(h->at+1)&(AC_HRTF_RING-1);h->ring[h->at]=input*(float)near; + // Woodworth-scale maximum ITD ≈ 650 μs at 48 kHz. + double itd=fabs(side)*31.2,dl=side>0?itd:0,dr=side<0?itd:0; + float l=ac_hrtf_read(h,dl),r=ac_hrtf_read(h,dr); + // Pinna reflection delay moves with elevation: above is a short reflection, + // below a longer one. The subtraction produces a direction-dependent notch; + // a quieter second ridge avoids reducing elevation to one static comb. + double en=fmax(-1,fmin(1,elevation/(AC_HRTF_PI*.5))); + double tap1=15-en*8,tap2=31-en*10; + l-=ac_hrtf_read(h,dl+tap1)*(float)(.25+.05*side);l+=ac_hrtf_read(h,dl+tap2)*.11f; + r-=ac_hrtf_read(h,dr+tap1)*(float)(.25-.05*side);r+=ac_hrtf_read(h,dr+tap2)*.11f; + // Far-ear head shadow: a moving one-pole lowpass mixed with the direct ear. + double shadow=.18+.62*fabs(side),a=.72+.20*fabs(side); + h->shadowL=(float)((1-a)*l+a*h->shadowL);h->shadowR=(float)((1-a)*r+a*h->shadowR); + if(side>0)l=(float)(l*(1-shadow)+h->shadowL*shadow);else r=(float)(r*(1-shadow)+h->shadowR*shadow); + // Mild elevation loudness compensation for the subtractive pinna taps. + double eg=1+.08*fabs(en);*L=l*(float)eg;*R=r*(float)eg; +} +#endif diff --git a/pop/nullabye/c/build-spatial.sh b/pop/nullabye/c/build-spatial.sh new file mode 100644 index 0000000000..b064215520 --- /dev/null +++ b/pop/nullabye/c/build-spatial.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +HERE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +cc -O3 -std=c11 -Wall -Wextra -o "$HERE/spatial-sineabye" "$HERE/spatial-sineabye.c" -lm +echo "built $HERE/spatial-sineabye" diff --git a/pop/nullabye/c/run-c.mjs b/pop/nullabye/c/run-c.mjs index c7692912c3..a1c3b3ed4e 100644 --- a/pop/nullabye/c/run-c.mjs +++ b/pop/nullabye/c/run-c.mjs @@ -57,6 +57,17 @@ const MASTERS = { "treble=g=1.6:f=8500", "alimiter=limit=0.97:attack=3:release=50", ], + // teknull needs audible drum attack without re-brightening its noise source. + teknull: [ + "highpass=f=26", + "lowpass=f=14500", + "acompressor=threshold=-17dB:ratio=2.8:attack=18:release=105:makeup=3:knee=5", + "equalizer=f=52:t=q:w=1.1:g=3", + "bass=g=2:f=72", + "treble=g=-1.5:f=7200", + "alimiter=limit=0.96:attack=4:release=65", + "volume=0.82", // MP3-safe headroom; avoids decoded/intersample overs + ], }; const chain = MASTERS[masterPreset]; if (!chain) { console.error(`unknown master preset: ${masterPreset}`); process.exit(1); } diff --git a/pop/nullabye/c/spatial-sineabye.c b/pop/nullabye/c/spatial-sineabye.c new file mode 100644 index 0000000000..667f0d53fc --- /dev/null +++ b/pop/nullabye/c/spatial-sineabye.c @@ -0,0 +1,254 @@ +// spatial-sineabye.c — acoustic game-engine cut of sineabye. +// C owns score, oscillators, gravity simulation, stereo projection and pixels; +// ffmpeg is used only to encode/mux the C-generated WAV + PPM frame stream. +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include "ac_hrtf.h" +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif +#define TAU (2*M_PI) +#define SR 48000 +#define BPM 76.0 +#define BEAT (60.0/BPM) +#define BAR (4*BEAT) +#define DUR (38*BAR) +#define CTRL 60 +#define FPS 24 +#define W 720 +#define H 720 +#define MAXE 4096 +#define NSRC 12 +#define NFRAMES ((int)(DUR*FPS)) + +typedef struct { double x,y,z,mass; const char *name; uint32_t color; } Source; +typedef struct { double t,dur,f0,f1,g,atk,rel; int src,type; uint32_t seed; } Event; +typedef struct { double x,y,vx,vy,heading; } Listener; +static Source S[NSRC]={ + {-4.2,-2.8,1.0,1.3,"bass",0x4ecdc4},{.4,-1.2,.4,2.0,"boom",0xff6b6b}, + {-2.3,1.2,1.2,.8,"pad-l",0x63cdda},{4.8,2.4,1.5,.8,"pad-r",0x778beb}, + {-.8,2.1,2.2,1.2,"melody",0xf6c915},{5.8,-1.2,2.8,.55,"echo",0xf8a5c2}, + {-7.0,2.8,3,.35,"hat-l",0x59656a},{7.2,-3.5,3,.35,"hat-r",0x84979d}, + {-2.0,-.1,1.1,.5,"nose-l",0xa29bfe},{2.5,-4.8,1.1,.5,"nose-r",0x81ecec}, + {0,6.5,.7,2.3,"gong",0xe0a464},{-6.2,-5.2,1.6,.25,"air",0xb2bec3} +}; +static Event E[MAXE]; static int NE=0; +static Listener *L; static float *busL,*busR,*fieldGain; +// Measured post-HRTF contribution of every source in every video frame. The +// visualizer reads this telemetry instead of guessing loudness from envelopes. +static double *meterL,*meterR; +static double spatialWet=.32; +static const int TOUR[]={4,8,0,2,10,9,3,5,1,6,11,7}; +static int tour_source(double t){return TOUR[((int)fmax(0,t/9.0))%12];} +static double hz(double m){return 440*pow(2,(m-69)/12);} +// Whole-room choreography: from 0:50–1:18 the source constellation eases +// through two complete rotations. Because every physics/audio lookup uses this +// function, the spin changes gravity, distance, Doppler and stereo—not just pixels. +static Source source_at(int s,double t){Source q=S[s];if(t>=50&&t<=78){double u=(t-50)/28;u=u*u*(3-2*u);double a=TAU*2*u; + // Eight-turn centrifuge from 62–70 s. Smoothstep gives it a physical ramp; + // eight whole turns land at the original orientation with no visual/audio cut. + if(t>=62){double x=fmin(1,(t-62)/8);x=x*x*(3-2*x);a+=TAU*8*x;} + double x=q.x,y=q.y;q.x=x*cos(a)-y*sin(a);q.y=x*sin(a)+y*cos(a);q.z+=.22*sin(a+s*.7);} + // Echo and air are memory-bodies: they occupy where the listener used to be. + // During simulation these indices are always in the already-computed past. + if(L&&(s==5||s==11)){double lag=s==5?2.2:5.5;int i=(int)(fmax(0,t-lag)*CTRL),max=(int)(DUR*CTRL);if(i>max)i=max;Listener p=L[i];double side=s==5?.55:-.8;q.x=p.x-cos(p.heading)*.45-sin(p.heading)*side;q.y=p.y-sin(p.heading)*.45+cos(p.heading)*side;q.z=s==5?2.35:1.85;} + // Final movement: every voice peels away from the room and occupies a + // progressively older point on the listener's path, forming an audible snake. + if(L&&t>=82){double mix=fmin(1,(t-82)/10);mix=mix*mix*(3-2*mix);double lag=1.0+s*.72;int i=(int)(fmax(0,t-lag)*CTRL),max=(int)(DUR*CTRL);if(i>max)i=max;Listener p=L[i];double side=((s&1)?1:-1)*(.08+.018*s),tx=p.x-sin(p.heading)*side,ty=p.y+cos(p.heading)*side,tz=.55+fmod(s*1.37,2.5);q.x=q.x*(1-mix)+tx*mix;q.y=q.y*(1-mix)+ty*mix;q.z=q.z*(1-mix)+tz*mix;} + return q;} +static void ev(double t,double d,double m,double g,int src,double a,double r){ + if(NEt;if(u<0||u>=e->dur)return 0;double a=fmin(1,u/e->atk),r=fmin(1,(e->dur-u)/e->rel);return sin(a*M_PI/2)*sin(a*M_PI/2)*sin(r*M_PI/2)*sin(r*M_PI/2);} + +static const int chord[4][4]={{48,55,64,74},{45,52,60,71},{53,60,69,67},{55,62,71,69}}; +static const int root[4]={36,33,41,43}; +static const int mel[4][4]={{76,79,81,81},{79,76,74,74},{72,74,76,79},{74,76,76,72}}; +static void score(void){ + for(int b=0;b<38;b++){ + double t=b*BAR, tr=(b>=20&&b<28)?4.0/3:1; + for(int k=0;k<4;k++)ev(t,BAR+.1,69+12*log2(hz(chord[b%4][k])*tr/440),.034,k<2?2:3,.7,1.0); + // Continuous independently filtered noise voices. + for(int q=0;q<4;q++)noisev(t+(q+.5)*BEAT,.085,4200,10200,.065,q&1?7:6); + noisev(t,BAR,1700,5700,.013,11); + for(int q=1;q<4;q+=2)noisev(t+q*BEAT,.15,620,2100,.052,q==1?8:9); + if(b>=4&&b<35){ev(t,BAR,root[b%4],.055,0,.08,.5);glide(t,.34,66,40,.13,1);glide(t,.58,46,31,.105,1);glide(t+2*BEAT,.34,66,40,.12,1);glide(t+2*BEAT,.58,46,31,.095,1);} + // Immediate melodic line; small rhythmic cells stay consonant in the bridge. + for(int q=0;q<4;q++){double raw=mel[b%4][q]-12+12*log2(tr),m=fmin(69,raw);ev(t+q*BEAT,BEAT*.88,m,.075,4,.045,.34);if(b>=12&&b<32)ev(t+(q+.5)*BEAT,BEAT*.48,m+12,.014,5,.06,.28);} + if(b>=12&&b<32)for(int q=0;q<4;q++)ev(t+(q+.5)*BEAT,.18,chord[b%4][q]+24+12*log2(tr),.017,q&1?5:4,.01,.15); + } + gong(12*BAR,65.41,.035);gong(20*BAR,87.31,.042);gong(28*BAR,65.41,.035);gong(34*BAR,65.41,.024); + // Melody owns the final cadence. + ev(36*BAR,BEAT*.9,60,.08,4,.05,.35);ev(36*BAR+BEAT,BEAT*.9,62,.075,4,.05,.35); + ev(36*BAR+2*BEAT,BEAT*.9,64,.07,4,.05,.4);ev(36*BAR+3*BEAT,BEAT*1.8,60,.075,4,.06,1.2); +} + +static void simulate(void){ + int n=(int)(DUR*CTRL)+1;L=calloc(n,sizeof(*L));L[0]=(Listener){0,-.5,0,0,0};double dt=1.0/CTRL; + for(int i=1;i=104&&t<118){double back=2+(t-104)*2.35,pt=fmax(0,t-back);Listener old=L[(int)(pt*CTRL)];double dx=old.x-p.x,dy=old.y-p.y;fx+=dx*.34;fy+=dy*.34;} + // Two irrational-feeling wander periods prevent the listener settling into + // one polite orbit. Weak room gravity eventually draws every excursion back. + fx+=.27*sin(TAU*t/31)+.13*sin(TAU*t/11.7)-.018*p.x; + fy+=.24*cos(TAU*t/23)+.11*cos(TAU*t/13.1)-.018*p.y; + p.vx=(p.vx+fx*dt)*.995;p.vy=(p.vy+fy*dt)*.995;p.x+=p.vx*dt;p.y+=p.vy*dt; + if(hypot(p.vx,p.vy)>.01)p.heading=atan2(p.vy,p.vx);L[i]=p; + } + // Mutual acoustic loading. Active neighboring fields damp one another; mass + // makes boom/gong exert more pressure. This is control-rate and interpolated + // by the sample renderer, so it remains deterministic and inexpensive. + fieldGain=calloc(n*NSRC,sizeof(*fieldGain));for(int i=0;i0){Source b=source_at(o,t);double dx=a.x-b.x,dy=a.y-b.y,dz=a.z-b.z;load+=en[o]*S[o].mass/(1+dx*dx+dy*dy+.35*dz*dz);}fieldGain[i*NSRC+s]=(float)(.32+.68/(1+5.5*load));}} +} +static double field_gain(int src,double t){double u=t*CTRL;int i=(int)u,n=(int)(DUR*CTRL);if(i>=n)return fieldGain[n*NSRC+src];double f=u-i;return fieldGain[i*NSRC+src]*(1-f)+fieldGain[(i+1)*NSRC+src]*f;} +static void gains(int src,double t,double *gl,double *gr,double *dist){ + double u=t*CTRL;int i=(int)u, n=(int)(DUR*CTRL);if(i>=n)i=n-1;double f=u-i; + Listener a=L[i],b=L[i+1];double x=a.x+(b.x-a.x)*f,y=a.y+(b.y-a.y)*f,h=a.heading+(b.heading-a.heading)*f; + Source so=source_at(src,t);double dx=so.x-x,dy=so.y-y,dz=so.z-1.6;*dist=sqrt(dx*dx+dy*dy+dz*dz); + // Deliberately selective listening: beyond a few room units a source falls + // away quickly, leaving only a 1.5% diffuse floor. Wandering now changes the + // arrangement instead of merely nudging its stereo pan. + double ang=atan2(dy,dx)-h,pan=sin(ang),near=.004+.996/(1+.55*(*dist)*(*dist)); + double wetL=sqrt((1-pan)*.5)*near,wetR=sqrt((1+pan)*.5)*near; + // Dry reference = a restrained fixed studio panorama with no distance loss. + // Interpolate gains before summing so --spatial-wet is a genuine mix control. + double dryPan=fmax(-.62,fmin(.62,S[src].x/6.0)),dryL=sqrt((1-dryPan)*.5),dryR=sqrt((1+dryPan)*.5); + *gl=dryL*(1-spatialWet)+wetL*spatialWet;*gr=dryR*(1-spatialWet)+wetR*spatialWet; +} +static void spatial_params(int src,double t,double *az,double *el,double *dist){ + double u=t*CTRL;int i=(int)u,n=(int)(DUR*CTRL);if(i>=n)i=n-1;double f=u-i;Listener a=L[i],b=L[i+1]; + double x=a.x+(b.x-a.x)*f,y=a.y+(b.y-a.y)*f,h=a.heading+(b.heading-a.heading)*f;Source so=source_at(src,t); + double dx=so.x-x,dy=so.y-y,dz=so.z-1.6,horiz=hypot(dx,dy);*dist=hypot(horiz,dz);*az=atan2(dy,dx)-h;*el=atan2(dz,horiz); +} +static void propagation(int src,double t,double *wall,double *cutoff,double *wind,double *rain){double u=t*CTRL;int i=(int)u,n=(int)(DUR*CTRL);if(i>=n)i=n-1;double f=u-i;Listener a=L[i],b=L[i+1];double x=a.x+(b.x-a.x)*f,y=a.y+(b.y-a.y)*f;Source so=source_at(src,t);double dx=so.x-x,dy=so.y-y,d=hypot(dx,dy);*wall=1;*cutoff=20000; + // Brick barrier at x=1.2, y=-3..2.5, height 2.4. Only an actual ray + // intersection occludes; high sources and paths around its ends remain clear. + if((x-1.2)*(so.x-1.2)<0){double q=(1.2-x)/(so.x-x),iy=y+(so.y-y)*q,iz=1.6+(so.z-1.6)*q;if(iy>-3&&iy<2.5&&iz<2.4){*wall=.46;*cutoff=1450;}} + double wx=.7*cos(t*.11),wy=.7*sin(t*.083+.8);*wind=.0045*(wx*dx+wy*dy)/(d+1);*rain=1-.075*fmin(1,d/8.0)*fabs(sin(t*19.7+src*2.13));} +static void render(void){long n=(long)(DUR*SR);busL=calloc(n,4);busR=calloc(n,4);meterL=calloc(NFRAMES*NSRC,sizeof(*meterL));meterR=calloc(NFRAMES*NSRC,sizeof(*meterR)); + for(int j=0;jt*SR),nn=(long)(e->dur*SR);double ph=0,lp=0,hpLP=0,envLP=0;uint32_t rs=e->seed;double lastD=0;ACHrtf hs;memset(&hs,0,sizeof hs); + for(long k=0;ksrc,t,&az,&el,&d);propagation(e->src,t,&wall,&cutoff,&wind,&rain); + double v;if(e->type==0){double fullDop=(k?fmax(.985,fmin(1.015,1-(d-lastD)*SR/343)):1),dop=1+(fullDop-1)*spatialWet;double f=e->f0*pow(e->f1/e->f0,u/e->dur)*dop*(1+wind*spatialWet);ph+=TAU*f/SR;v=sin(ph);lastD=d;} + else {rs=rs*1664525u+1013904223u;double w=((rs>>8)/8388608.0)-1;double ca=exp(-TAU*e->f0/SR),cb=exp(-TAU*e->f1/SR);lp=(1-ca)*w+ca*lp;double hi=w-lp;hpLP=(1-cb)*hi+cb*hpLP;v=hpLP;} + if(cutoff<19000){double c=exp(-TAU*cutoff/SR);envLP=(1-c)*v+c*envLP;v=envLP;}v*=wall*rain*e->g*a*field_gain(e->src,t);float hl,hr;ac_hrtf_process(&hs,(float)v,az,el,d,&hl,&hr); + double dryPan=fmax(-.62,fmin(.62,S[e->src].x/6.0)),dl=sqrt((1-dryPan)*.5),dr=sqrt((1+dryPan)*.5); + double dg=cos(spatialWet*M_PI*.5),wg=sin(spatialWet*M_PI*.5),cl=v*dl*dg+hl*wg,cr=v*dr*dg+hr*wg; + // Retarded reception: radio energy is written when it reaches the listener, + // not when the emitter produced it. Distance therefore becomes real delay. + long at=s0+k+(long)(d*SR/343.0);if(atsrc)*SR);if(echo=0&&frsrc;meterL[mi]+=cl*cl;meterR[mi]+=cr*cr;}} + } + } + // Cross-room early reflections. + int ds[]={3408,5424,8688};double dg[]={.075,.048,.03};for(int q=0;q<3;q++)for(long i=ds[q];ipk)pk=a;}double g=pk?.88/pk:1; + for(long i=0;in-SR?((n-i)/(double)SR):1;busL[i]*=g*fo;busR[i]*=g*fo;} +} +static int wav(const char*p){FILE*f=fopen(p,"wb");if(!f)return 0;long n=(long)(DUR*SR);uint32_t ds=n*8,sz=36+ds,sr=SR,br=SR*8,fs=16;uint16_t fm=3,ch=2,ba=8,bi=32; + fwrite("RIFF",1,4,f);fwrite(&sz,4,1,f);fwrite("WAVEfmt ",1,8,f);fwrite(&fs,4,1,f);fwrite(&fm,2,1,f);fwrite(&ch,2,1,f);fwrite(&sr,4,1,f);fwrite(&br,4,1,f);fwrite(&ba,2,1,f);fwrite(&bi,2,1,f);fwrite("data",1,4,f);fwrite(&ds,4,1,f);for(long i=0;i=0&&X=0&&Y>16;p[o+1]=c>>8;p[o+2]=c;}}} +static void glow(unsigned char*p,int x,int y,int r,uint32_t c,double strength){int rr=c>>16,gg=(c>>8)&255,bb=c&255;for(int yy=-r;yy<=r;yy++)for(int xx=-r;xx<=r;xx++){double d=sqrt(xx*xx+yy*yy)/(double)r;if(d>1)continue;int X=x+xx,Y=y+yy;if(X<0||X>=W||Y<0||Y>=H)continue;double a=strength*(1-d)*(1-d);int o=(Y*W+X)*3;p[o]=p[o]*(1-a)+rr*a;p[o+1]=p[o+1]*(1-a)+gg*a;p[o+2]=p[o+2]*(1-a)+bb*a;}} +typedef struct{double x,y,z;} V3;typedef struct{int x,y;double z;int ok;} P2; +static V3 sub3(V3 a,V3 b){return(V3){a.x-b.x,a.y-b.y,a.z-b.z};}static double dot3(V3 a,V3 b){return a.x*b.x+a.y*b.y+a.z*b.z;} +static V3 cross3(V3 a,V3 b){return(V3){a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x};} +static V3 norm3(V3 a){double n=sqrt(dot3(a,a));return n?(V3){a.x/n,a.y/n,a.z/n}:a;} +static P2 project(V3 p,V3 cam,V3 target){V3 f=norm3(sub3(target,cam)),r=norm3(cross3(f,(V3){0,0,1})),u=cross3(r,f),q=sub3(p,cam);double z=dot3(q,f);if(z<.15)return(P2){0,0,z,0};double focal=W*.694;return(P2){(int)(W*.5+dot3(q,r)*focal/z),(int)(H*.54-dot3(q,u)*focal/z),z,1};} +static void line2(unsigned char*p,int x0,int y0,int x1,int y1,uint32_t c,double alpha){int dx=abs(x1-x0),sx=x0=0&&x0=0&&y0>16,gg=(c>>8)&255,bb=c&255;p[o]=p[o]*(1-alpha)+rr*alpha;p[o+1]=p[o+1]*(1-alpha)+gg*alpha;p[o+2]=p[o+2]*(1-alpha)+bb*alpha;}if(x0==x1&&y0==y1)break;int e2=2*er;if(e2>=dy){er+=dy;x0+=sx;}if(e2<=dx){er+=dx;y0+=sy;}}} +static void fill2(unsigned char*p,int x0,int y0,int x1,int y1,uint32_t c,double a){int rr=c>>16,gg=(c>>8)&255,bb=c&255;if(x0<0)x0=0;if(y0<0)y0=0;if(x1>W)x1=W;if(y1>H)y1=H;for(int y=y0;y>16,cg=(c>>8)&255,cb=c&255;for(int y=-r;y<=r;y++)for(int x=-r;x<=r;x++){double nx=x/(double)r,ny=y/(double)r,r2=nx*nx+ny*ny;if(r2>1)continue;double nz=sqrt(1-r2),lx=-.42,ly=-.55,lz=.72,nd=fmax(0,nx*lx+ny*ly+nz*lz),rim=pow(1-nz,2.2),hx=nx+.26,hy=ny+.34,hl=hypot(hx,hy),spec=pow(fmax(0,1-hl*2.4),18);double shade=.18+.66*nd+.34*rim+1.15*spec+fmin(.45,energy*10);int X=cx+x,Y=cy+y;if(X<0||X>=W||Y<0||Y>=H)continue;int o=(Y*W+X)*3;p[o]=(unsigned char)fmin(255,cr*shade+spec*110);p[o+1]=(unsigned char)fmin(255,cg*shade+spec*110);p[o+2]=(unsigned char)fmin(255,cb*shade+spec*110);}} +static void source_meter(int fr,int src,double *l,double *r){ + // A short symmetric window removes 24 fps sparkle without inventing energy. + double sl=0,sr=0;int count=0;for(int q=-2;q<=2;q++){int f=fr+q;if(f<0||f>=NFRAMES)continue;double w=q?1.0/(1+abs(q)):1.0;sl+=meterL[f*NSRC+src]*w;sr+=meterR[f*NSRC+src]*w;count+=(int)(SR/(double)FPS*w);} + *l=count?sqrt(sl/count):0;*r=count?sqrt(sr/count):0; +} +static void stereo_lobes(unsigned char*p,P2 sp,double l,double r,int radius,uint32_t c){ + // The two ears are visible: left/right lobe areas are the measured channel RMS. + double peak=fmax(l,r),scale=peak>0?1.0/peak:0;int gap=radius+2; + int rl=(int)fmax(1,radius*(.2+.8*sqrt(l*scale))),rr=(int)fmax(1,radius*(.2+.8*sqrt(r*scale))); + glow(p,sp.x-gap,sp.y,rl*2,c,.12+fmin(.55,l*18));glow(p,sp.x+gap,sp.y,rr*2,c,.12+fmin(.55,r*18)); + dot(p,sp.x-gap,sp.y,rl,c);dot(p,sp.x+gap,sp.y,rr,c); +} +static void video(const char*wavp,const char*outp){char cmd[2048];size_t ol=strlen(outp);int lossless=ol>=4&&!strcmp(outp+ol-4,".mov");if(lossless) + snprintf(cmd,sizeof cmd,"ffmpeg -hide_banner -y -loglevel error -f image2pipe -vcodec ppm -r %d -i - -i '%s' -c:v libx265 -preset medium -x265-params lossless=1:log-level=error -pix_fmt yuv444p -tag:v hvc1 -c:a pcm_s24le -ar 48000 -shortest '%s'",FPS,wavp,outp); + else snprintf(cmd,sizeof cmd,"ffmpeg -hide_banner -y -loglevel error -f image2pipe -vcodec ppm -r %d -i - -i '%s' -c:v libx264 -pix_fmt yuv420p -crf 17 -c:a aac -b:a 320k -shortest '%s'",FPS,wavp,outp);FILE*ff=popen(cmd,"w");if(!ff)return;unsigned char*p=malloc(W*H*3); + for(int fr=0;fr<(int)(DUR*FPS);fr++){double t=fr/(double)FPS;for(int y=0;y.00005)line3(p,lv,sv,cam,target,S[s].color,.03+fmin(.84,heard*28)); + if(sp.ok){double sc=W/360.0;int core=(int)fmax(3,fmin(11,52*sc/sp.z));int halo=(int)fmax(core*2,fmin(62,(4+heard*1500)*sc*10/sp.z));P2 sh=project((V3){so.x,so.y,.02},cam,target);if(sh.ok)glow(p,sh.x,sh.y,(int)fmax(5,halo*.8),S[s].color,.06+fmin(.4,heard*14));if(s==ts&&t<104)glow(p,sp.x,sp.y,halo+18,0xffffff,.16);glow(p,sp.x,sp.y,halo,S[s].color,.18+fmin(.7,heard*16));stereo_lobes(p,sp,ml,mr,core,S[s].color);marble(p,sp.x,sp.y,core+2,S[s].color,heard);} + } + P2 lp=project(lv,cam,target),hd=project((V3){l.x+cos(l.heading)*.55,l.y+sin(l.heading)*.55,1.6},cam,target); + if(lp.ok){int rr=(int)fmax(4,fmin(20,130/lp.z));glow(p,lp.x,lp.y,rr*3,0xf6c915,.35);dot(p,lp.x,lp.y,rr,0xffd54f);if(hd.ok)line2(p,lp.x,lp.y,hd.x,hd.y,0xff6b9d,.95);} + // Receiver cockpit. The canopy frames the view while the lower scanner maps + // contacts around the listener; vertical stalks encode above/below exactly. + fill2(p,0,H-205,W,H,0x080b0d,.72);for(int w=0;w<4;w++){line2(p,35+w,H,178+w,105,0x38454b,.88);line2(p,W-35-w,H,W-178-w,105,0x38454b,.88);line2(p,178+w,105,W-178-w,105,0x38454b,.7);} + line2(p,W/2-18,H/2,W/2-5,H/2,0xb8c9cc,.65);line2(p,W/2+5,H/2,W/2+18,H/2,0xb8c9cc,.65);line2(p,W/2,H/2-18,W/2,H/2-5,0xb8c9cc,.65);line2(p,W/2,H/2+5,W/2,H/2+18,0xb8c9cc,.65); + int rcx=W/2,rcy=H-92;ellipse2(p,rcx,rcy,122,62,0x5b6f73,.8);ellipse2(p,rcx,rcy,82,41,0x405256,.65);ellipse2(p,rcx,rcy,41,20,0x405256,.5);line2(p,rcx-122,rcy,rcx+122,rcy,0x405256,.45);line2(p,rcx,rcy-62,rcx,rcy+62,0x405256,.45); + // Receiver triangle points forward. Contact brightness is measured signal, + // weak transmissions flicker unresolved instead of being presented as fact. + line2(p,rcx,rcy-7,rcx-6,rcy+6,0xf6c915,.95);line2(p,rcx-6,rcy+6,rcx+6,rcy+6,0xf6c915,.95);line2(p,rcx+6,rcy+6,rcx,rcy-7,0xf6c915,.95); + double sumL=0,sumR=0;for(int s=0;s10.5){rx*=10.5/rr;ry*=10.5/rr;}int bx=rcx+(int)(ry*scale),base=rcy-(int)(rx*scale*.5),by=base-(int)((so.z-1.6)*10),weak=hypot(ml,mr)<.0015;double vis=weak?(.16+.24*(sin(t*31+s*7)>0)):.9;line2(p,bx,base,bx,by,S[s].color,vis*.7);if(s==ts)glow(p,bx,by,12,0xffffff,.28);dot(p,bx,by,s==ts?4:3,S[s].color);} + int lm=(int)fmin(110,sumL*850),rm=(int)fmin(110,sumR*850);fill2(p,70,H-55,70+lm,H-45,0x4ecdc4,.9);fill2(p,W-70-rm,H-55,W-70,H-45,0xf8a5c2,.9); + fprintf(ff,"P6\n%d %d\n255\n",W,H);fwrite(p,1,W*H*3,ff); + } + free(p);pclose(ff); +} +int main(int argc,char**argv){const char*w="../out/spatial-sineabye.wav",*v="../out/spatial-sineabye.mp4",*mp3=NULL;for(int i=1;i= 4) - note("kickSub", t(bar, beat + 0.025), { freq: 52, freqEnd: 40, peakDb: 26 * Math.min(1, soft + 0.3), q: 4.5, atk: 0.008, hold: 0.08, rel: 0.26 }); + note("kickSub", t(bar, beat + 0.018), { freq: 58, freqEnd: 43, peakDb: 27 * Math.min(1, soft + 0.3), q: 5.5, atk: 0.005, hold: 0.055, rel: 0.2 }); } // hats — eager (lean in early), accent cycle, open hat wanders if (bar <= 67) // ticking from bar 0 — soft at first, full by bar 8 [0.5, 1.5, 2.5, 3.5].forEach((beat, i) => note("hatC", t(bar, beat) + EAGER + jit(bar, i), { - freq: 9200, peakDb: (bar < 8 ? -3 : 0) + [22, 19, 23, 20][i], q: 2.5, + freq: 8200, peakDb: (bar < 8 ? -3 : 0) + [18, 16, 19, 17][i], q: 4.2, atk: 0.002, hold: 0.006, rel: 0.04, })); if (bar >= 4 && bar <= 67 && !inBreak) - note("hatO", t(bar, bar % 4 === 2 ? 1.5 : 3.5) + EAGER, { freq: 7400, peakDb: 20, q: 1.8, atk: 0.002, hold: 0.015, rel: 0.2 }); + note("hatO", t(bar, bar % 4 === 2 ? 1.5 : 3.5) + EAGER, { freq: 6800, peakDb: 17, q: 3.2, atk: 0.002, hold: 0.012, rel: 0.16 }); // clap — 2 and 4, dragged late against the eager hats; ghost "clap-a" // closing every 4th bar. the most honest instrument here, it IS the noise if (bar >= 6 && bar <= 67 && !inBreak) { for (const beat of [1, 3]) - note("clap", t(bar, beat) + DRAG, { freq: 2300, peakDb: 21, q: 1.3, atk: 0.002, hold: 0.03, rel: 0.15 }); + note("clap", t(bar, beat) + DRAG, { freq: 2050, peakDb: 19, q: 2.2, atk: 0.0015, hold: 0.022, rel: 0.11 }); if (bar % 4 === 3) - note("clap", t(bar, 3.375) + DRAG, { freq: 2300, peakDb: 15, q: 1.3, atk: 0.002, hold: 0.02, rel: 0.1 }); + note("clap", t(bar, 3.375) + DRAG, { freq: 2050, peakDb: 13, q: 2.2, atk: 0.0015, hold: 0.015, rel: 0.08 }); } // sub — offbeat "oontz" with a small downward settle per note; odd bars @@ -186,7 +186,7 @@ for (let bar = 0; bar < TOTAL_BARS; bar++) { if (DROP_B(bar)) for (let k = 0; k < 16; k++) { if (k % 8 === 7) continue; - note("ride16", t(bar, sw(k * 0.25)) + jit(bar * 16 + k, 41, 0.002), { freq: 12500, peakDb: k % 4 === 2 ? 12 : 9, q: 5, atk: 0.002, hold: 0.005, rel: 0.04 }); + note("ride16", t(bar, sw(k * 0.25)) + jit(bar * 16 + k, 41, 0.002), { freq: 10500, peakDb: k % 4 === 2 ? 10 : 7, q: 7, atk: 0.002, hold: 0.004, rel: 0.032 }); } // LEADS — the hook rides the WHOLE drop now: a swung 2-bar pentatonic @@ -205,15 +205,15 @@ for (let bar = 0; bar < TOTAL_BARS; bar++) { // breakdown — chord swells + rising noise-wash while the kick rests if (inBreak) { - note("noisewash", t(bar), { freq: 1200 + (bar - 40) * 350, peakDb: 9 + (bar - 40) * 0.7, q: 0.5, atk: 0.5, hold: BAR - 0.5, rel: 0.8 }); + note("noisewash", t(bar), { freq: 1000 + (bar - 40) * 250, peakDb: 7 + (bar - 40) * 0.55, q: 1.1, atk: 0.35, hold: BAR - 0.35, rel: 0.9 }); } } // one-shots: crashes, risers, drop zaps, sub booms for (const bar of [8, 16, 24, 32, 40, 48, 56, 64]) - note("crash", t(bar), { freq: 5500, peakDb: 16, q: 0.7, atk: 0.003, hold: 0.05, rel: 1.0 }); + note("crash", t(bar), { freq: 4800, peakDb: 13, q: 1.5, atk: 0.003, hold: 0.035, rel: 0.75 }); for (const bar of [14, 22, 46, 62]) - note("riser", t(bar), { freq: 600, freqEnd: 6500, peakDb: 8.5, q: 1.2, atk: 2 * BAR - 0.6, hold: 0.3, rel: 0.3 }); + note("riser", t(bar), { freq: 600, freqEnd: 4800, peakDb: 7, q: 2, atk: 2 * BAR - 0.6, hold: 0.3, rel: 0.3 }); for (const bar of [24, 48, 64]) note("fall", t(bar), { freq: 3500, freqEnd: 90, peakDb: 18, q: 2, atk: 0.004, hold: 0.05, rel: 0.45 }); for (const bar of [40, 64, 68]) @@ -267,6 +267,18 @@ if (lanes.size > MAX_POINTS) console.log(`→ teknull · ${TOTAL_BARS} bars · A minor @ ${BPM} BPM · ${lanes.size}/${MAX_POINTS} EQ points`); // ── bake the score for the C engine ─────────────────────────────────── +// Give each family its own independently generated and filtered source. +// This keeps the 32 serial EQ voices from smearing into one shared wash: +// drums are dry velvet impulses, the low end uses rounder brown noise, +// and the musical/ambient layers retain separate pink-noise identities. +const family = (name) => { + if (/^kick|^sub$|^subdrop$/.test(name)) return 2; + if (/^(hat|clap|perc|ride|crash|riser|fall)/.test(name)) return 3; + if (/^acid/.test(name)) return 4; + if (/^(veil|drone|air|noisewash)$/.test(name)) return 5; + return lanes.get(name).group ?? 1; +}; +for (const [name, lane] of lanes) lane.group = family(name); const bands = [...lanes.values()]; for (const b of bands) b.events.sort((x, y) => x.t0 - y.t0); @@ -287,10 +299,12 @@ L.push(`sr ${SR}`, `dur ${totalSec}`, `detune 1.0009 0.9991`, `seed ${0xAC1D} ${ L.push(`normpeak 0.9`, `fadein 0.004`, `fadeout 1.2`); L.push(`ridewin ${BAR} ${RIDE_BARS}`); for (let b = 0; b < RIDE_BARS; b++) L.push(String(rideTarget(b))); -for (const g of [0, 1]) { +const noiseType = ["pink", "pink", "brown", "velvet", "pink", "pink"]; +for (const g of [...new Set(bands.map((b) => b.group ?? 0))].sort()) { const groupBands = bands.filter((b) => (b.group ?? 0) === g); if (!groupBands.length) continue; - if (g > 0) L.push(`group ${0x7E77 + g} ${0x9A11 + g}`); // fresh noise pair per group + if (g > 0) L.push(`group ${0x7E77 + g} ${0x9A11 + g}`); // fresh noise pair per family + L.push(`noisetype ${noiseType[g] ?? "pink"}`); for (const band of groupBands) { L.push(`band ${band.events.length}`); for (const e of band.events) @@ -308,5 +322,5 @@ if (process.argv.includes("--bake-only")) process.exit(0); const _argi = (k) => { const i = process.argv.indexOf(k); return i >= 0 ? process.argv[i + 1] : null; }; const outPath = _argi("--out") || resolve(OUT_DIR, "teknull.mp3"); const r = spawnSync("node", [resolve(HERE, "../../nullabye/c/run-c.mjs"), - scorePath, "--out", outPath, "--master", "techno"], { stdio: "inherit" }); + scorePath, "--out", outPath, "--master", "teknull"], { stdio: "inherit" }); process.exit(r.status ?? 1);