// Synthetic audio with known ground truth, so tempo and segmentation can be
// tested against an exact answer instead of "sounds about right". Real music
// goes through the click track and the battery; this catches regressions in CI
// speed and without a GPU.

/** Minimal stand-in for AudioBuffer — the analysis code only needs this surface. */
export class MockAudioBuffer {
    constructor(channels, length, sampleRate) {
        this.numberOfChannels = channels;
        this.length = length;
        this.sampleRate = sampleRate;
        this.duration = length / sampleRate;
        this._data = Array.from({ length: channels }, () => new Float32Array(length));
    }
    getChannelData(i) { return this._data[i]; }
}

function addKick(data, sampleRate, at, gain = 1) {
    const start = Math.round(at * sampleRate);
    const length = Math.round(0.12 * sampleRate);
    for (let i = 0; i < length; i++) {
        const s = start + i;
        if (s < 0 || s >= data.length) continue;
        const t = i / sampleRate;
        const env = Math.exp(-t * 30);
        const freq = 55 * Math.exp(-t * 20) + 40;   // pitch drop, like a real kick
        data[s] += Math.sin(2 * Math.PI * freq * t) * env * gain;
    }
}

function addHat(data, sampleRate, at, gain = 0.3, seed = 1) {
    const start = Math.round(at * sampleRate);
    const length = Math.round(0.04 * sampleRate);
    let s0 = seed >>> 0;
    const rnd = () => {
        s0 = (Math.imul(s0 ^ (s0 >>> 15), s0 | 1) + 0x6d2b79f5) >>> 0;
        return ((s0 >>> 14) & 0xffff) / 0xffff - 0.5;
    };
    for (let i = 0; i < length; i++) {
        const s = start + i;
        if (s < 0 || s >= data.length) continue;
        const env = Math.exp(-(i / sampleRate) * 90);
        data[s] += rnd() * env * gain;
    }
}

function addPad(data, sampleRate, from, to, gain = 0.15, root = 110) {
    const start = Math.round(from * sampleRate);
    const end = Math.min(data.length, Math.round(to * sampleRate));
    for (let s = start; s < end; s++) {
        const t = s / sampleRate;
        data[s] += (Math.sin(2 * Math.PI * root * t) + Math.sin(2 * Math.PI * root * 1.5 * t)) * gain * 0.5;
    }
}

/**
 * A four-to-the-floor track at a known BPM.
 * @param {object} opts
 * @returns {MockAudioBuffer}
 */
export function synthesizeBeat({
    bpm = 128,
    duration = 40,
    sampleRate = 44100,
    hats = true,
    pad = true,
    kickGain = 1,
    hatGain = 0.25,
    padRoot = 110,
    padGain = 0.12,
} = {}) {
    const length = Math.round(duration * sampleRate);
    const buffer = new MockAudioBuffer(2, length, sampleRate);
    const left = buffer.getChannelData(0);
    const right = buffer.getChannelData(1);

    const beat = 60 / bpm;
    let index = 0;
    for (let t = 0; t < duration; t += beat, index++) {
        // Accent the downbeat so the bar phase is detectable.
        addKick(left, sampleRate, t, kickGain * (index % 4 === 0 ? 1.0 : 0.8));
        if (hats) addHat(left, sampleRate, t + beat / 2, hatGain, index + 1);
    }
    if (pad) addPad(left, sampleRate, 0, duration, padGain, padRoot);

    for (let i = 0; i < length; i++) right[i] = left[i] * 0.98;
    return buffer;
}

/**
 * A track with a deliberate structural change at `changeAt` seconds: sparse and
 * dark before, dense and bright after. Segmentation must find that boundary.
 */
export function synthesizeSectioned({
    bpm = 128,
    duration = 120,
    changeAt = 60,
    sampleRate = 44100,
} = {}) {
    const length = Math.round(duration * sampleRate);
    const buffer = new MockAudioBuffer(2, length, sampleRate);
    const left = buffer.getChannelData(0);
    const right = buffer.getChannelData(1);

    const beat = 60 / bpm;
    let index = 0;
    for (let t = 0; t < duration; t += beat, index++) {
        const after = t >= changeAt;
        addKick(left, sampleRate, t, after ? 1.0 : 0.35);
        if (after) {
            addHat(left, sampleRate, t + beat / 2, 0.45, index + 1);
            addHat(left, sampleRate, t + beat / 4, 0.25, index + 7);
        }
    }
    addPad(left, sampleRate, 0, changeAt, 0.10, 110);
    addPad(left, sampleRate, changeAt, duration, 0.22, 440);   // brighter after

    for (let i = 0; i < length; i++) right[i] = left[i] * 0.98;
    return buffer;
}

/**
 * A broadband noise bed. This is the only way to move spectral FLATNESS, which
 * the generator reads to decide how noisy a track is — it picks the director
 * partly on it, and the whole grade follows. Kicks, hats and pads are all
 * tonal or transient, so without this the bank could only ever produce tonal
 * tracks and half the director table would be unreachable.
 *
 * `tilt` shapes it with a one-pole filter: 0 is dark rumble, 1 is bright hiss.
 * 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, 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;
    };
    // 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) * 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.
// 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 i = s - start;
        const t = i / sampleRate;
        const env = Math.min(1, i / attackSamples) * Math.exp(-t * decay);
        let v = 0;
        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, {
    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];
        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, 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),
                gainOf(t) * 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, gainOf(t + n * step) * 0.30,
                    Math.max(2, harmonics - 1), 0.008);
            }
        }
    }
}

/**
 * Arrangement shapes, as stage lists.
 *
 * A song's SHAPE is a feature the generator reads as directly as its tempo —
 * section kinds decide which families each director opens, so a bank that only
 * 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, 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, 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, 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, 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, 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, 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, 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'  },
    ],
};

/**
 * One song, from a spec.
 *
 * The four continuous knobs map onto exactly the four summary statistics the
 * look generator reads, so a bank built on them can be checked for coverage
 * against what the generator actually consumes rather than against what seemed
 * like a reasonable spread of audio.
 *
 *   bpm         → summary.bpm
 *   brightness  → summary.meanCentroid   (pad register, harmonics, noise tilt)
 *   noise       → summary.meanFlatness   (broadband bed)
 *   dynamics    → summary.dynamicRange   (how far quiet stages fall below loud)
 */
export function synthesizeSong({
    bpm = 128,
    duration = 120,
    sampleRate = 44100,
    arrangement = 'classic',
    brightness = 0.5,
    noise = 0.15,
    noiseColour = null,
    dynamics = 0.6,
    transition = 0.5,
    seed = 1,
} = {}) {
    const stages = ARRANGEMENTS[arrangement] || ARRANGEMENTS.classic;
    const length = Math.round(duration * sampleRate);
    const buffer = new MockAudioBuffer(2, length, sampleRate);
    const left = buffer.getChannelData(0);
    const right = buffer.getChannelData(1);

    const beat = 60 / bpm;

    // Dynamic range is measured as a crest factor — the 40th percentile of
    // frame RMS against the 95th — so moving it means changing the RATIO
    // between the quiet stages and the loud ones, not the overall level.
    //
    // A gamma on each stage's level relative to the loudest stage does that.
    // The loudest stage is pinned at its original value and everything below it
    // is pushed down (high dynamics) or pulled up (limitered), so a dynamic
    // track does not simply come out quieter — which is what a straight floor
    // offset did, and it moved the measured range by 0.2 across the whole bank.
    const gamma = 0.25 + dynamics * 3.2;
    // Percussion is transient, so a kick-driven track has a low RMS floor
    // between hits no matter how hard it is limitered — the gamma alone could
    // not get the measured range below 0.67. What actually fills the gaps is
    // SUSTAINED content, so the pad and the noise bed swell as dynamics falls.
    // That is also what a limitered master really sounds like.
    const sustainBoost = 1 + (1 - dynamics) ** 2 * 6;
    const peakOf = (field) => Math.max(...stages.map((s) => s[field] || 0), 1e-6);
    const peaks = { kick: peakOf('kick'), hat: peakOf('hat'), pad: peakOf('pad') };
    const level = (x, field) => (x <= 0 ? 0 : peaks[field] * (x / peaks[field]) ** gamma);

    // 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.
    //
    // 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
    // at r = 0.95, one axis wearing two names, and the whole dark-but-noisy
    // 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, 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 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') * 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, {
            gainAt: (u) => padLevel * shapeAt(u),
            root, scale, progression, harmonics, beat,
            register: stage.root,
            sustain: 1 - dynamics,
            rng,
        });

        if (noise > 0.01) {
            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,
                (u) => bed * shapeAt(u),
                rising ? (u) => Math.min(0.98, tilt + u * 0.5) : () => tilt,
                (seed * 7919 + stageIndex) >>> 0);
        }
    }

    for (let i = 0; i < length; i++) right[i] = left[i] * 0.98;
    return buffer;
}

/**
 * A track with a full ARRANGEMENT — intro, build, drop, breakdown, drop, outro.
 *
 * `synthesizeSectioned` has one change point, so it segments into exactly two
 * sections and both of them are quiet kinds. That is fine for testing that the
 * segmenter finds a boundary, and it was quietly useless for anything that
 * measures what the generator DOES with a song: intro, breakdown and outro are
 * restricted to the restful families for every director, so a two-section track
 * cannot reach geometric, glitch or structural scenes at all. Half the library
 * is unreachable before the seed is even drawn, and a test built on it will
 * report that as a casting failure.
 *
 * The stages here are shaped to hit the segmenter's own classifier: a build
 * needs a rising energy slope, a drop needs energy and flux together, and a
 * breakdown needs to fall well below the median.
 */
export function synthesizeArrangement({
    bpm = 128,
    duration = 120,
    sampleRate = 44100,
    brightness = 1,
    density = 1,
} = {}) {
    const length = Math.round(duration * sampleRate);
    const buffer = new MockAudioBuffer(2, length, sampleRate);
    const left = buffer.getChannelData(0);
    const right = buffer.getChannelData(1);

    // Proportions of the track, in order. Kick gain, hat gain, pad root, pad gain.
    const stages = [
        { span: 0.12, kick: 0.00, hat: 0.00, root: 110, pad: 0.09 },   // intro
        { span: 0.16, kick: 0.55, hat: 0.20, root: 165, pad: 0.14, ramp: true },  // build
        { span: 0.22, kick: 1.00, hat: 0.50, root: 440, pad: 0.24 },   // drop
        { span: 0.14, kick: 0.05, hat: 0.02, root: 110, pad: 0.07 },   // breakdown
        { span: 0.24, kick: 1.00, hat: 0.55, root: 440, pad: 0.26 },   // drop
        { span: 0.12, kick: 0.25, hat: 0.08, root: 110, pad: 0.08 },   // outro
    ];

    const beat = 60 / bpm;
    let at = 0;
    for (const stage of stages) {
        const from = at;
        const to = Math.min(duration, at + stage.span * duration);
        at = to;

        let index = Math.round(from / beat);
        for (let t = from; t < to; t += beat, index++) {
            // A build ramps across its own span so the energy slope is positive
            // enough for the classifier to call it one.
            const ramp = stage.ramp ? (t - from) / Math.max(1e-6, to - from) : 1;
            const gain = stage.kick * density * (0.25 + ramp * 0.75);
            if (gain > 0.02) addKick(left, sampleRate, t, gain * (index % 4 === 0 ? 1 : 0.8));
            if (stage.hat > 0.02) {
                const h = stage.hat * density * ramp;
                addHat(left, sampleRate, t + beat / 2, h, index + 1);
                if (h > 0.3) addHat(left, sampleRate, t + beat / 4, h * 0.6, index + 7);
            }
        }
        addPad(left, sampleRate, from, to, stage.pad, stage.root * brightness);
    }

    for (let i = 0; i < length; i++) right[i] = left[i] * 0.98;
    return buffer;
}

/** Silence, for degenerate-input checks. */
export function synthesizeSilence({ duration = 10, sampleRate = 44100 } = {}) {
    return new MockAudioBuffer(2, Math.round(duration * sampleRate), sampleRate);
}
