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.