From d59dd9d5dd2a0a9019d369c3abb00cce4b8c29ab Mon Sep 17 00:00:00 2001 From: Dejvino Date: Mon, 17 Aug 2026 07:34:51 +0200 Subject: [PATCH] Give the song bank notes instead of a test tone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tonal content was a sustained sine stack at a fixed root, and the stage multiplier scaled that root directly — so a drop arrived at roughly 1.2kHz with ten harmonics stacked on top of it and held there for thirty seconds. Every statistic it was built to control came out correct. It was also unlistenable, and a bank nobody can stand to play is a bank nobody audits. There is a scale, a chord progression, a bass on the chord roots and a seeded motif over the loud stages. Key, mode and progression come off the song's seed so two entries are not the same four chords at different tempos. This is the plainest thing that qualifies as music — not trying to be good, trying to be playable enough that a person will listen to the bank and notice what is wrong with it. Register is the real fix. `stage.root` used to multiply the fundamental and now adds voices upward instead: bass and chord stay where they belong, a lead octave arrives when the arrangement opens up. Brightness still moves the harmonic content and the key's register, but across an octave and a half rather than a piercing sweep. Harmonics above Nyquist are dropped rather than left to alias, since folded energy would corrupt the centroid and flatness the bank exists to control. Two measured consequences. Tempo detection on the beatless entries got better, not worse — the bass gives a pulse where the drone gave nothing, and `drone` now reads 62bpm instead of 88. And enveloped notes put a floor of 0.27 under the measured dynamic range, because the gaps between notes are gaps a limitered master does not have; note decay flattens toward a hold as dynamics falls, which takes the compressed end back to 0.18. Coverage still passes on all six axes and all six section kinds. Co-Authored-By: Claude Opus 5 --- flow-state/src/audio/synth.js | 152 +++++++++++++++++++++++++++++++--- 1 file changed, 141 insertions(+), 11 deletions(-) diff --git a/flow-state/src/audio/synth.js b/flow-state/src/audio/synth.js index 71e9f0a..eca55e6 100644 --- a/flow-state/src/audio/synth.js +++ b/flow-state/src/audio/synth.js @@ -154,17 +154,126 @@ function addNoise(data, sampleRate, from, to, gain = 0.05, tilt = 0.5, seed = 12 } } -/** A pad with controllable harmonic content — the other half of brightness. */ -function addRichPad(data, sampleRate, from, to, gain, root, harmonics = 2) { - const start = Math.max(0, Math.round(from * sampleRate)); - const end = Math.min(data.length, Math.round(to * sampleRate)); +// --- notes ---------------------------------------------------------------- +// The bank started out as sustained sine stacks at a fixed root, and a stage +// multiplier of 4 put that root above 1kHz with ten harmonics on top of it. +// Measurably it was fine. To listen to it was a test tone, and nobody can +// review a bank they cannot bear to play — a song you skip is a song you never +// notice is wrong. +// +// So the tonal content is notes now: a scale, a chord progression, a bass +// following the roots, and a seeded motif over the loud stages. This is +// deliberately the plainest music that qualifies as music. It is not trying to +// be good; it is trying to be listenable enough that a human will actually +// audit the bank, while keeping every measured statistic under the same control +// as before. + +/** Semitone offsets. Minor and its neighbours — nothing here should sound jolly. */ +const SCALES = { + minor: [0, 2, 3, 5, 7, 8, 10], + dorian: [0, 2, 3, 5, 7, 9, 10], + pentatonic: [0, 3, 5, 7, 10], +}; +const SCALE_NAMES = Object.keys(SCALES); + +/** Degree sequences, one chord per two bars. All resolve back to the tonic. */ +const PROGRESSIONS = [ + [0, 5, 3, 4], + [0, 3, 4, 3], + [0, 6, 5, 4], + [0, 2, 5, 4], +]; + +/** Equal temperament from a root frequency. */ +const semitone = (root, n) => root * 2 ** (n / 12); + +/** + * One note: a harmonic stack under an envelope. + * + * Harmonics above Nyquist are skipped rather than allowed to alias — aliasing + * would fold energy back down into the spectrum and quietly corrupt both the + * centroid and the flatness the bank exists to control. + */ +function addNote(data, sampleRate, at, duration, freq, gain, harmonics = 3, attack = 0.012, sustain = 0) { + const start = Math.max(0, Math.round(at * sampleRate)); + const end = Math.min(data.length, Math.round((at + duration) * sampleRate)); + if (end <= start || freq <= 0) return; + + const usable = []; + for (let h = 1; h <= harmonics; h++) { + if (freq * h < sampleRate * 0.4) usable.push(h); + } + if (!usable.length) return; + + const norm = gain / Math.log2(usable.length + 1); + const attackSamples = Math.max(1, attack * sampleRate); + // Long enough to sustain through the note, short enough that consecutive + // notes articulate instead of smearing into the drone this replaced. + // + // `sustain` flattens that decay toward a hold. A limitered master has no + // gaps in it, and once the tonal bed became enveloped notes the gaps between + // them put a floor of 0.27 under the measured dynamic range — the compressed + // end of the bank simply stopped being reachable. + const decay = (1.6 / Math.max(0.08, duration)) * (1 - sustain * 0.94); + for (let s = start; s < end; s++) { - const t = s / sampleRate; + const i = s - start; + const t = i / sampleRate; + const env = Math.min(1, i / attackSamples) * Math.exp(-t * decay); let v = 0; - for (let h = 1; h <= harmonics; h++) { - v += Math.sin(2 * Math.PI * root * h * t) / h; + for (const h of usable) v += Math.sin(2 * Math.PI * freq * h * t) / h; + data[s] += v * env * norm; + } +} + +/** + * The tonal bed for one stage: bass, chords, and a motif over the loud stages. + * + * `register` is the old stage `root` multiplier, reinterpreted. It used to + * multiply the fundamental — which is how a drop ended up at 1.2kHz — and now + * it adds VOICES upward instead: the bass and chord stay where they belong and + * a lead octave arrives when the arrangement gets loud. Same shape in the + * measurements, an octave of headroom instead of a shriek. + */ +function addTonalBed(data, sampleRate, from, to, { + gain, root, scale, progression, harmonics, beat, register, rng, sustain = 0, +}) { + const bar = beat * 4; + const degrees = SCALES[scale]; + const chordAt = (index) => { + const degree = progression[index % progression.length]; + return [0, 2, 4].map((step) => degrees[(degree + step) % degrees.length] + + 12 * Math.floor((degree + step) / degrees.length)); + }; + + let chordIndex = 0; + for (let t = from; t < to; t += bar * 2, chordIndex++) { + const chord = chordAt(chordIndex); + const span = Math.min(bar * 2, to - t); + + // Bass: the chord root, one per bar, low and simple. + for (let b = 0; b < 2 && t + b * bar < to; b++) { + addNote(data, sampleRate, t + b * bar, Math.min(bar, to - t - b * bar), + semitone(root, chord[0]) / 2, gain * 1.1, Math.max(2, harmonics - 2), 0.02, sustain); + } + + // Chord: held across the two bars, mid register. + for (const note of chord) { + addNote(data, sampleRate, t, span, semitone(root, note), gain * 0.5, harmonics, 0.25, sustain); + } + + // Motif: only once the arrangement has opened up, so quiet stages stay + // quiet and the loud ones have something on top that moves. + if (register >= 1.5) { + const step = beat / (register >= 3 ? 2 : 1); + const octave = register >= 3 ? 4 : 2; + for (let n = 0; t + n * step < to && n * step < span; n++) { + if (rng() < 0.3) continue; // rests, so it phrases + const pick = degrees[Math.floor(rng() * degrees.length)]; + addNote(data, sampleRate, t + n * step, step * 0.9, + semitone(root, pick) * octave, gain * 0.30, Math.max(2, harmonics - 1), 0.008); + } } - data[s] += v * gain / Math.log2(harmonics + 1); } } @@ -287,10 +396,26 @@ export function synthesizeSong({ // Brightness has to reach genuinely dark, and a hat is broadband: leaving // any hat in at brightness 0 held the measured centroid above 0.65 no matter // what the pad did. Below a quarter brightness the hats go entirely. - const baseRoot = 55 + brightness * 260; + // + // Brightness moves the HARMONIC content and the key's register, but only + // within an octave and a half. It used to set the fundamental outright, + // across 55Hz to 315Hz and then multiplied by the stage — which is how a + // drop arrived at 1.2kHz with ten harmonics stacked on it. + const baseRoot = 55 * 2 ** (brightness * 1.5); const harmonics = Math.max(1, Math.round(1 + brightness ** 1.5 * 9)); const hatPresence = Math.max(0, (brightness - 0.22) / 0.78); + // Key and progression come off the seed, so two songs in the bank are not + // the same four chords at different tempos. + let rngState = (seed * 2654435761 + 12345) >>> 0; + const rng = () => { + rngState = (Math.imul(rngState ^ (rngState >>> 15), rngState | 1) + 0x6d2b79f5) >>> 0; + return ((rngState ^ (rngState >>> 14)) >>> 0) / 4294967296; + }; + const scale = SCALE_NAMES[Math.floor(rng() * SCALE_NAMES.length)]; + const progression = PROGRESSIONS[Math.floor(rng() * PROGRESSIONS.length)]; + const root = semitone(baseRoot, Math.floor(rng() * 12)); + // The noise bed's COLOUR is its own axis, defaulting to the track's // brightness but separable from it. Tying the two together made spectral // flatness a function of centroid — measured across the bank they came out @@ -320,8 +445,13 @@ export function synthesizeSong({ } } - addRichPad(left, sampleRate, from, to, - level(stage.pad, 'pad') * sustainBoost, baseRoot * stage.root, harmonics); + addTonalBed(left, sampleRate, from, to, { + gain: level(stage.pad, 'pad') * sustainBoost, + root, scale, progression, harmonics, beat, + register: stage.root, + sustain: 1 - dynamics, + rng, + }); if (noise > 0.01) { // The bed follows the arrangement so it cannot flatten the dynamics // it is layered over.