diff --git a/flow-state/src/audio/songbank.js b/flow-state/src/audio/songbank.js index 0aa7f7e..ff4e9bc 100644 --- a/flow-state/src/audio/songbank.js +++ b/flow-state/src/audio/songbank.js @@ -35,43 +35,43 @@ import { synthesizeSong } from './synth.js'; */ export const SONGS = [ // --- slow, quiet, spacious ------------------------------------------- - { name: 'drone', bpm: 62, arrangement: 'ambient', brightness: 0.05, noise: 0.02, dynamics: 0.95, + { name: 'drone', bpm: 62, arrangement: 'ambient', brightness: 0.05, noise: 0.02, dynamics: 0.95, transition: 0.05, covers: 'beatless · darkest · most dynamic' }, - { name: 'air', bpm: 76, arrangement: 'ambient', brightness: 0.90, noise: 0.25, noiseColour: 0.15, dynamics: 0.85, + { name: 'air', bpm: 76, arrangement: 'ambient', brightness: 0.90, noise: 0.25, noiseColour: 0.15, dynamics: 0.85, transition: 0.1, covers: 'beatless but bright — brightness without a beat' }, - { name: 'ember', bpm: 68, arrangement: 'club', brightness: 0.20, noise: 0.05, dynamics: 0.55, + { name: 'ember', bpm: 68, arrangement: 'club', brightness: 0.20, noise: 0.05, dynamics: 0.55, transition: 0.8, covers: 'slowest track with a detectable beat — anchors the low tempo end' }, - { name: 'elegy', bpm: 84, arrangement: 'ballad', brightness: 0.10, noise: 0.03, dynamics: 0.90, + { name: 'elegy', bpm: 84, arrangement: 'ballad', brightness: 0.10, noise: 0.03, dynamics: 0.90, transition: 0.12, covers: 'slow, dark, one build and one payoff' }, - { name: 'hymn', bpm: 92, arrangement: 'ballad', brightness: 0.80, noise: 0.20, dynamics: 0.70, + { name: 'hymn', bpm: 92, arrangement: 'ballad', brightness: 0.80, noise: 0.20, dynamics: 0.70, transition: 0.25, covers: 'slow and bright — separates tempo from brightness' }, - { name: 'still', bpm: 100, arrangement: 'sparse', brightness: 0.35, noise: 0.06, dynamics: 0.75, + { name: 'still', bpm: 100, arrangement: 'sparse', brightness: 0.35, noise: 0.06, dynamics: 0.75, transition: 0.2, covers: 'the degenerate two-section case, which must not break' }, // --- mid tempo, the bulk of the space --------------------------------- - { name: 'dusk', bpm: 118, arrangement: 'classic', brightness: 0.02, noise: 0.01, dynamics: 0.60, + { name: 'dusk', bpm: 118, arrangement: 'classic', brightness: 0.02, noise: 0.01, dynamics: 0.60, transition: 0.45, covers: 'darkest and most tonal with a full arrangement' }, - { name: 'centre', bpm: 124, arrangement: 'classic', brightness: 0.50, noise: 0.20, dynamics: 0.55, + { name: 'centre', bpm: 124, arrangement: 'classic', brightness: 0.50, noise: 0.20, dynamics: 0.55, transition: 0.6, covers: 'the middle of every axis — the null hypothesis' }, - { name: 'glare', bpm: 128, arrangement: 'classic', brightness: 0.95, noise: 0.55, dynamics: 0.50, + { name: 'glare', bpm: 128, arrangement: 'classic', brightness: 0.95, noise: 0.55, dynamics: 0.50, transition: 0.9, covers: 'bright and noisy together' }, - { name: 'slab', bpm: 126, arrangement: 'club', brightness: 0.45, noise: 0.25, dynamics: 0.05, + { name: 'slab', bpm: 126, arrangement: 'club', brightness: 0.45, noise: 0.25, dynamics: 0.05, transition: 0.95, covers: 'limitered — lowest dynamic range, longest sections' }, - { name: 'chrome', bpm: 132, arrangement: 'club', brightness: 0.85, noise: 0.06, noiseColour: 0.1, dynamics: 0.35, + { name: 'chrome', bpm: 132, arrangement: 'club', brightness: 0.85, noise: 0.06, noiseColour: 0.1, dynamics: 0.35, transition: 0.85, covers: 'bright and tonal at club tempo' }, - { name: 'murk', bpm: 88, arrangement: 'classic', brightness: 0.12, noise: 0.85, noiseColour: 0.95, dynamics: 0.70, + { name: 'murk', bpm: 88, arrangement: 'classic', brightness: 0.12, noise: 0.85, noiseColour: 0.95, dynamics: 0.70, transition: 0.35, covers: 'dark tonal content under bright hiss — the quadrant that breaks the brightness/noise correlation' }, // --- fast, dense, broken ---------------------------------------------- - { name: 'lattice', bpm: 140, arrangement: 'breaks', brightness: 0.60, noise: 0.35, dynamics: 0.45, + { name: 'lattice', bpm: 140, arrangement: 'breaks', brightness: 0.60, noise: 0.35, dynamics: 0.45, transition: 0.92, covers: 'most sections — makes rosters actually rotate' }, - { name: 'grit', bpm: 150, arrangement: 'breaks', brightness: 0.15, noise: 0.90, noiseColour: 0.85, dynamics: 0.40, + { name: 'grit', bpm: 150, arrangement: 'breaks', brightness: 0.15, noise: 0.90, noiseColour: 0.85, dynamics: 0.40, transition: 0.88, covers: "noisiest and dark — the corrupt director's home ground" }, - { name: 'plate', bpm: 138, arrangement: 'club', brightness: 0.25, noise: 0.70, noiseColour: 0.9, dynamics: 0.10, + { name: 'plate', bpm: 138, arrangement: 'club', brightness: 0.25, noise: 0.70, noiseColour: 0.9, dynamics: 0.10, transition: 0.95, covers: 'noisy AND compressed — two extremes at once' }, - { name: 'runner', bpm: 174, arrangement: 'breaks', brightness: 0.70, noise: 0.40, dynamics: 0.50, + { name: 'runner', bpm: 174, arrangement: 'breaks', brightness: 0.70, noise: 0.40, dynamics: 0.50, transition: 0.9, covers: 'fastest with a broken arrangement' }, - { name: 'flare', bpm: 168, arrangement: 'classic', brightness: 0.95, noise: 0.85, dynamics: 0.25, + { name: 'flare', bpm: 168, arrangement: 'classic', brightness: 0.95, noise: 0.85, dynamics: 0.25, transition: 0.7, covers: 'brightest and noisiest — the far corner' }, ]; @@ -97,6 +97,7 @@ export function song(name, { fps = 60 } = {}) { noise: spec.noise, noiseColour: spec.noiseColour ?? null, dynamics: spec.dynamics, + transition: spec.transition ?? 0.5, seed: 1 + SONGS.indexOf(spec), }), { fps }), }; diff --git a/flow-state/src/audio/synth.js b/flow-state/src/audio/synth.js index eca55e6..95d11b5 100644 --- a/flow-state/src/audio/synth.js +++ b/flow-state/src/audio/synth.js @@ -130,30 +130,63 @@ export function synthesizeSectioned({ * Brightness and noisiness have to be independently controllable or the bank * cannot tell the two apart when it reports coverage. */ -function addNoise(data, sampleRate, from, to, gain = 0.05, tilt = 0.5, seed = 12345) { +function addNoise(data, sampleRate, from, to, gainAt, tiltAt, seed = 12345) { const start = Math.max(0, Math.round(from * sampleRate)); const end = Math.min(data.length, Math.round(to * sampleRate)); + if (end <= start) return; let s0 = seed >>> 0; const rnd = () => { s0 = (Math.imul(s0 ^ (s0 >>> 15), s0 | 1) + 0x6d2b79f5) >>> 0; return ((s0 >>> 14) & 0xffff) / 0xffff - 0.5; }; - // Two cascaded one-poles rather than one. A single pole is a 6dB/octave - // slope, which leaves so much top on "dark" noise that a noisy track always - // measured bright — the dark-and-noisy corner, which is the corrupt - // director's entire home ground, was not reachable at all. - const a = 0.004 + tilt * 0.9; + // Gain and tilt are functions of position through the stage, not constants. + // Held constant, the bed was a continuous hiss over the entire track — the + // one thing every listener noticed first. As a sweep it becomes a riser into + // a drop and a wash that recedes, which is both easier to hear past and a + // better test signal: a rising filter is real spectral movement for the + // analyser to find. let lp1 = 0, lp2 = 0; + const span = end - start; for (let s = start; s < end; s++) { + const u = (s - start) / span; + const tilt = tiltAt(u); + // Two cascaded one-poles rather than one. A single pole is a 6dB/octave + // slope, which leaves so much top on "dark" noise that a noisy track + // always measured bright — the dark-and-noisy corner, which is the + // corrupt director's entire home ground, was not reachable at all. + const a = 0.004 + tilt * 0.9; const white = rnd(); lp1 += a * (white - lp1); lp2 += a * (lp1 - lp2); // Below half tilt the lowpass IS the signal; above it, what the lowpass // removed is. - data[s] += (tilt > 0.5 ? white - lp2 : lp2 * 3) * gain * 2; + data[s] += (tilt > 0.5 ? white - lp2 : lp2 * 3) * gainAt(u) * 2; } } +/** + * How a stage's energy moves across its own span. + * + * Stages were flat blocks: a drop was thirty seconds at one level, and the only + * shape available was a `ramp` flag on the build. That is both dull to listen to + * and a worse test signal than it looks, because the segmenter classifies a + * section partly on its energy SLOPE — a build that does not build is a build + * the classifier has to guess at. + * + * `u` runs 0..1 through the stage. + */ +const CONTOURS = { + flat: () => 1, + rise: (u) => 0.22 + 0.78 * u ** 1.5, + fall: (u) => 1 - 0.72 * u ** 1.2, + swell: (u) => 0.35 + 0.65 * Math.sin(Math.PI * u), + dip: (u) => 1 - 0.55 * Math.sin(Math.PI * u), + // Loud on the downbeat, easing off, then lifting back at the very end into + // whatever comes next. + surge: (u) => 0.72 + 0.28 * Math.cos(Math.PI * 2 * u), +}; +const contourFor = (shape) => CONTOURS[shape] || CONTOURS.flat; + // --- 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. @@ -236,9 +269,12 @@ function addNote(data, sampleRate, at, duration, freq, gain, harmonics = 3, atta * 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, + gainAt, root, scale, progression, harmonics, beat, register, rng, sustain = 0, }) { const bar = beat * 4; + // The contour is sampled per note rather than per stage, so a rise actually + // rises through the chords instead of stepping between two flat halves. + const gainOf = (t) => gainAt((t - from) / Math.max(1e-6, to - from)); const degrees = SCALES[scale]; const chordAt = (index) => { const degree = progression[index % progression.length]; @@ -254,12 +290,14 @@ function addTonalBed(data, sampleRate, from, to, { // 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); + semitone(root, chord[0]) / 2, gainOf(t + b * bar) * 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); + addNote(data, sampleRate, t, span, semitone(root, note), + gainOf(t) * 0.5, harmonics, 0.25, sustain); } // Motif: only once the arrangement has opened up, so quiet stages stay @@ -271,7 +309,8 @@ function addTonalBed(data, sampleRate, from, to, { 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); + semitone(root, pick) * octave, gainOf(t + n * step) * 0.30, + Math.max(2, harmonics - 1), 0.008); } } } @@ -285,59 +324,64 @@ function addTonalBed(data, sampleRate, from, to, { * contains one arrangement cannot exercise the casting logic no matter how * widely it spreads tempo and brightness. Spans are fractions of the track. * - * Stage fields: span, kick, hat, noise, pad, root multiplier, ramp. + * Stage fields: span, kick, hat, pad, noise, root (register), shape (contour). + * + * `noise` is per stage rather than following the pad level, which is what made + * the bed a constant hiss across the whole track. It is now an arrangement + * element: a riser through a build, a wash under a drop, nearly absent in a + * breakdown. */ export const ARRANGEMENTS = { // Beatless. Segments into quiet kinds only — the case that must stay // representable, since it is what an actual ambient track looks like. ambient: [ - { span: 0.30, kick: 0, hat: 0, pad: 0.10, root: 1.0 }, - { span: 0.25, kick: 0, hat: 0, pad: 0.20, root: 1.5, ramp: true }, - { span: 0.25, kick: 0, hat: 0, pad: 0.12, root: 1.0 }, - { span: 0.20, kick: 0, hat: 0, pad: 0.07, root: 0.75 }, + { span: 0.30, kick: 0, hat: 0, pad: 0.10, noise: 0.25, root: 1.0, shape: 'swell' }, + { span: 0.25, kick: 0, hat: 0, pad: 0.20, noise: 0.60, root: 1.5, shape: 'rise' }, + { span: 0.25, kick: 0, hat: 0, pad: 0.12, noise: 0.35, root: 1.0, shape: 'fall' }, + { span: 0.20, kick: 0, hat: 0, pad: 0.07, noise: 0.15, root: 0.75, shape: 'fall' }, ], // The standard shape: everything present, once. classic: [ - { span: 0.12, kick: 0.00, hat: 0.00, pad: 0.09, root: 1.0 }, - { span: 0.16, kick: 0.55, hat: 0.20, pad: 0.14, root: 1.5, ramp: true }, - { span: 0.22, kick: 1.00, hat: 0.50, pad: 0.24, root: 4.0 }, - { span: 0.14, kick: 0.05, hat: 0.02, pad: 0.07, root: 1.0 }, - { span: 0.24, kick: 1.00, hat: 0.55, pad: 0.26, root: 4.0 }, - { span: 0.12, kick: 0.25, hat: 0.08, pad: 0.08, root: 1.0 }, + { span: 0.12, kick: 0.00, hat: 0.00, pad: 0.09, noise: 0.15, root: 1.0, shape: 'swell' }, + { span: 0.16, kick: 0.55, hat: 0.20, pad: 0.14, noise: 1.00, root: 1.5, shape: 'rise' }, + { span: 0.22, kick: 1.00, hat: 0.50, pad: 0.24, noise: 0.35, root: 4.0, shape: 'surge' }, + { span: 0.14, kick: 0.05, hat: 0.02, pad: 0.07, noise: 0.10, root: 1.0, shape: 'dip' }, + { span: 0.24, kick: 1.00, hat: 0.55, pad: 0.26, noise: 0.40, root: 4.0, shape: 'surge' }, + { span: 0.12, kick: 0.25, hat: 0.08, pad: 0.08, noise: 0.20, root: 1.0, shape: 'fall' }, ], // Long and level: a club tool that states its groove and stays there. The // case that produces `sustain`, which the classic shape barely reaches. club: [ - { span: 0.10, kick: 0.45, hat: 0.15, pad: 0.10, root: 1.0 }, - { span: 0.30, kick: 0.90, hat: 0.45, pad: 0.20, root: 2.0 }, - { span: 0.12, kick: 0.35, hat: 0.15, pad: 0.12, root: 1.0 }, - { span: 0.34, kick: 1.00, hat: 0.55, pad: 0.22, root: 3.0 }, - { span: 0.14, kick: 0.50, hat: 0.20, pad: 0.10, root: 1.0 }, + { span: 0.10, kick: 0.45, hat: 0.15, pad: 0.10, noise: 0.30, root: 1.0, shape: 'rise' }, + { span: 0.30, kick: 0.90, hat: 0.45, pad: 0.20, noise: 0.30, root: 2.0, shape: 'surge' }, + { span: 0.12, kick: 0.35, hat: 0.15, pad: 0.12, noise: 0.20, root: 1.0, shape: 'dip' }, + { span: 0.34, kick: 1.00, hat: 0.55, pad: 0.22, noise: 0.40, root: 3.0, shape: 'surge' }, + { span: 0.14, kick: 0.50, hat: 0.20, pad: 0.10, noise: 0.20, root: 1.0, shape: 'fall' }, ], // Many short sections. Stresses the segmenter and produces the highest // section counts, which is what makes rosters rotate. breaks: [ - { span: 0.10, kick: 0.30, hat: 0.10, pad: 0.08, root: 1.0 }, - { span: 0.12, kick: 0.95, hat: 0.55, pad: 0.20, root: 3.0 }, - { span: 0.10, kick: 0.05, hat: 0.02, pad: 0.06, root: 1.0 }, - { span: 0.14, kick: 1.00, hat: 0.60, pad: 0.24, root: 4.0 }, - { span: 0.10, kick: 0.10, hat: 0.03, pad: 0.07, root: 0.75 }, - { span: 0.16, kick: 0.60, hat: 0.30, pad: 0.16, root: 2.0, ramp: true }, - { span: 0.16, kick: 1.00, hat: 0.65, pad: 0.26, root: 4.0 }, - { span: 0.12, kick: 0.20, hat: 0.05, pad: 0.08, root: 1.0 }, + { span: 0.10, kick: 0.30, hat: 0.10, pad: 0.08, noise: 0.20, root: 1.0, shape: 'rise' }, + { span: 0.12, kick: 0.95, hat: 0.55, pad: 0.20, noise: 0.35, root: 3.0, shape: 'surge' }, + { span: 0.10, kick: 0.05, hat: 0.02, pad: 0.06, noise: 0.10, root: 1.0, shape: 'dip' }, + { span: 0.14, kick: 1.00, hat: 0.60, pad: 0.24, noise: 0.40, root: 4.0, shape: 'surge' }, + { span: 0.10, kick: 0.10, hat: 0.03, pad: 0.07, noise: 0.15, root: 0.75, shape: 'fall' }, + { span: 0.16, kick: 0.60, hat: 0.30, pad: 0.16, noise: 1.00, root: 2.0, shape: 'rise' }, + { span: 0.16, kick: 1.00, hat: 0.65, pad: 0.26, noise: 0.40, root: 4.0, shape: 'surge' }, + { span: 0.12, kick: 0.20, hat: 0.05, pad: 0.08, noise: 0.15, root: 1.0, shape: 'fall' }, ], // One long rise into one payoff, then gone. Slow material's shape. ballad: [ - { span: 0.28, kick: 0.00, hat: 0.00, pad: 0.10, root: 1.0 }, - { span: 0.30, kick: 0.40, hat: 0.10, pad: 0.18, root: 1.5, ramp: true }, - { span: 0.24, kick: 0.85, hat: 0.35, pad: 0.26, root: 3.0 }, - { span: 0.18, kick: 0.15, hat: 0.03, pad: 0.09, root: 1.0 }, + { span: 0.28, kick: 0.00, hat: 0.00, pad: 0.10, noise: 0.20, root: 1.0, shape: 'swell' }, + { span: 0.30, kick: 0.40, hat: 0.10, pad: 0.18, noise: 0.85, root: 1.5, shape: 'rise' }, + { span: 0.24, kick: 0.85, hat: 0.35, pad: 0.26, noise: 0.35, root: 3.0, shape: 'surge' }, + { span: 0.18, kick: 0.15, hat: 0.03, pad: 0.09, noise: 0.15, root: 1.0, shape: 'fall' }, ], // Two sections, both quiet. Kept because it is the degenerate case the old // bank produced by accident, and the generator must not break on it. sparse: [ - { span: 0.5, kick: 0.10, hat: 0.02, pad: 0.08, root: 1.0 }, - { span: 0.5, kick: 0.35, hat: 0.15, pad: 0.16, root: 2.0 }, + { span: 0.5, kick: 0.10, hat: 0.02, pad: 0.08, noise: 0.20, root: 1.0, shape: 'swell' }, + { span: 0.5, kick: 0.35, hat: 0.15, pad: 0.16, noise: 0.30, root: 2.0, shape: 'rise' }, ], }; @@ -363,6 +407,7 @@ export function synthesizeSong({ noise = 0.15, noiseColour = null, dynamics = 0.6, + transition = 0.5, seed = 1, } = {}) { const stages = ARRANGEMENTS[arrangement] || ARRANGEMENTS.classic; @@ -423,41 +468,98 @@ export function synthesizeSong({ // quadrant was unreachable. Hiss over a sub-bass pad is an ordinary record. const tilt = 0.05 + (noiseColour === null ? brightness : noiseColour) * 0.9; + // How abruptly one stage becomes the next. Genres differ on this more than + // they differ on tempo: an ambient record dissolves between its sections and + // a club record cuts, and until now every song in the bank cut. + // + // It is not only a listening difference. The segmenter finds boundaries by + // looking for change, so a soft track genuinely has fewer of them — section + // count is the axis the bank covered worst, and this is the knob that moves + // it for a musical reason rather than by padding the stage list. + const bar = beat * 4; + const fade = (1 - transition) ** 1.5 * bar * 2.5; + // The hardest transitions get the pre-drop trick: everything stops for a + // moment before the loud stage lands. Nothing states "this is a cut" more + // plainly, and it is what an EDM master actually does. + const gapBeats = transition > 0.7 ? (transition - 0.7) / 0.3 * 0.9 : 0; + let at = 0; let stageIndex = 0; + const bounds = []; + { + let cursor = 0; + for (const stage of stages) { + const from = cursor; + cursor = Math.min(duration, cursor + stage.span * duration); + bounds.push([from, cursor]); + } + } + for (const stage of stages) { - const from = at; - const to = Math.min(duration, at + stage.span * duration); + const [from, to] = bounds[stageIndex]; + const next = stages[stageIndex + 1]; at = to; stageIndex++; + const span = Math.max(1e-6, to - from); + const contour = contourFor(stage.shape); + // Compression flattens the contrast BETWEEN sections, not just within + // them, so a limitered track gets shallower contours. Without this the + // contours alone put a floor of 0.50 under the measured dynamic range + // and the compressed end of the bank stopped existing — the section + // shapes were quietly undoing the axis they were layered onto. + const depth = 0.12 + dynamics * 0.88; + const fadeU = Math.min(0.45, fade / span); + // A hard cut into a LOUDER stage gets the gap; a fall into a quiet one + // does not, because silence before a breakdown is just silence. + const gapU = next && (next.kick + next.pad) > (stage.kick + stage.pad) * 1.3 + ? Math.min(0.2, (gapBeats * beat) / span) : 0; + + /** The stage's own shape, with its edges softened or cut. */ + const shapeAt = (u) => { + let v = 1 + (contour(Math.max(0, Math.min(1, u))) - 1) * depth; + if (fadeU > 1e-4) { + if (u < fadeU) v *= 0.5 - 0.5 * Math.cos(Math.PI * (u / fadeU)); + if (u > 1 - fadeU) v *= 0.5 - 0.5 * Math.cos(Math.PI * ((1 - u) / fadeU)); + } + if (gapU > 1e-4 && u > 1 - gapU) v *= 0.04; + return v; + }; + let index = Math.round(from / beat); for (let t = from; t < to; t += beat, index++) { - const ramp = stage.ramp ? (t - from) / Math.max(1e-6, to - from) : 1; - const gain = level(stage.kick, 'kick') * (0.25 + ramp * 0.75); + const u = (t - from) / span; + const shaped = shapeAt(u); + const gain = level(stage.kick, 'kick') * shaped; if (stage.kick > 0.02 && gain > 0.005) { addKick(left, sampleRate, t, gain * (index % 4 === 0 ? 1 : 0.8)); } if (stage.hat > 0.02 && hatPresence > 0) { - const h = level(stage.hat, 'hat') * ramp * hatPresence * 1.4; + const h = level(stage.hat, 'hat') * shaped * hatPresence * 1.4; addHat(left, sampleRate, t + beat / 2, h, index + seed); if (h > 0.3) addHat(left, sampleRate, t + beat / 4, h * 0.6, index + seed + 7); } } + const padLevel = level(stage.pad, 'pad') * sustainBoost; addTonalBed(left, sampleRate, from, to, { - gain: level(stage.pad, 'pad') * sustainBoost, + gainAt: (u) => padLevel * shapeAt(u), 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. + const bed = noise * (stage.noise ?? 0.3) * padLevel * 6; + // A riser sweeps its filter up as it goes; everything else holds its + // colour. This is the difference between a bed that builds tension + // and one that is just always there. + const rising = stage.shape === 'rise'; addNoise(left, sampleRate, from, to, - noise * level(stage.pad, 'pad') * 3 * sustainBoost, - tilt, (seed * 7919 + stageIndex) >>> 0); + (u) => bed * shapeAt(u), + rising ? (u) => Math.min(0.98, tilt + u * 0.5) : () => tilt, + (seed * 7919 + stageIndex) >>> 0); } } diff --git a/flow-state/tools/build-song-bank.js b/flow-state/tools/build-song-bank.js index 1fdcb0c..23ebfa3 100644 --- a/flow-state/tools/build-song-bank.js +++ b/flow-state/tools/build-song-bank.js @@ -89,6 +89,7 @@ for (const spec of SONGS) { noise: spec.noise, noiseColour: spec.noiseColour ?? null, dynamics: spec.dynamics, + transition: spec.transition ?? 0.5, seed: 1 + SONGS.indexOf(spec), }); const track = FeatureTrack.fromAudioBuffer(buffer, { fps: 60 }); @@ -118,12 +119,13 @@ for (const spec of SONGS) { const n2 = (x) => (typeof x === 'number' ? x.toFixed(2) : String(x)).padStart(7); console.log('\n MEASURED — what the generator will actually read\n'); -console.log(' song tempo bright noisy dynamic loud sections arrangement'); +console.log(' song tempo bright noisy dynamic loud sections arrangement cuts'); console.log(' ' + '-'.repeat(86)); for (const b of built) { console.log(` ${b.name.padEnd(11)}${n2(b.measured.bpm)}${n2(b.measured.centroid)}` + `${n2(b.measured.flatness)}${n2(b.measured.dynamics)}${n2(b.measured.loudness)}` + - `${String(b.measured.sections).padStart(10)} ${b.arrangement}`); + `${String(b.measured.sections).padStart(10)} ${b.arrangement.padEnd(9)}` + + ` ${(b.transition ?? 0.5) >= 0.7 ? 'hard' : (b.transition ?? 0.5) <= 0.3 ? 'soft' : 'mid '}`); } // --- coverage -------------------------------------------------------------- @@ -197,6 +199,16 @@ for (let i = 0; i < AXES.length; i++) { } } +// Transition hardness is a spec input rather than a summary statistic, so it is +// reported rather than binned: how abruptly one section becomes the next is a +// genre difference the bank has to contain — an ambient record dissolves where a +// club record cuts — and a bank where every song cut would be testing the +// generator against one kind of music. +const soft = built.filter((b) => (b.transition ?? 0.5) <= 0.3).length; +const hard = built.filter((b) => (b.transition ?? 0.5) >= 0.7).length; +console.log(`\n transitions ${soft} soft · ${built.length - soft - hard} mid · ${hard} hard`); +if (!soft || !hard) problems.push('the bank has no ' + (soft ? 'hard' : 'soft') + ' transitions'); + // Section kinds are the casting gate, so coverage of them is not optional: a // bank with no drop cannot reach the geometric, glitch or structural families // no matter how widely its tempo spreads.