Motivic Melody Engine Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the random-walk lead generators with a motif-development engine that produces memorable, harmony-aware, genre-distinct melodies.
Architecture: Pure, deterministic JS functions in the algo block of the single-file app. A genre profile drives a seed-motif generator, a set of development operators, a contour shaper, and a harmony-lander; a phrase assembler combines them. Output format is identical to today ({degree,duration}/{rest,duration}), so it drops into ALGORITHMS.*.leadGenA/B with no downstream changes.
Tech Stack: Vanilla JS (ES2017), Tone.js 14 (audio only — the melody engine itself uses no Tone/DOM), single-file synthwave_surfer.html, inline browser test harness (?test=1).
Conventions for every task¶
- All code lives in
synthwave_surfer.html. No new files. - Engine functions go in the algo block: insert after the
snapDegreefunction (closing}at ~line 503) and before the SWMD-codec comment banner (/* ═══ ... ═══ */at ~line 505). Append each task's function(s) to the bottom of this block, in task order. - Tests go into the
testsarray insiderunCodecTests(~lines 3393–3795). Add each new[name, fn]entry immediately before the closing];at ~line 3795. Functions under test are top-level and available by the timerunCodecTestsruns. - Running tests (no CLI runner — browser console):
- Ensure an HTTP server serves the repo root:
python3 -m http.server 8090(audio needs HTTP, but tests run fine too). - Open
http://localhost:8090/synthwave_surfer.html?test=1, open DevTools console. - Read the final line:
━━━ Done: <N> passed, <M> failed | Probes: ... ━━━. - Baseline before this plan: 28 passed, 0 failed. Each task adds tests; the passed count grows and
failedmust stay0. - These melody functions are pure, so they may alternatively be verified headlessly by extracting the algo block into Node — but the canonical gate is the browser console count above.
- Commit style: conventional commits, e.g.
feat(algo): .... End commit messages with the project'sCo-Authored-Bytrailer.
File Structure¶
Only synthwave_surfer.html changes:
- Algo block (~L360–504): new
MELODY_PROFILESdata + engine functions (weightedPick,makeMotif,opRepeat/opSequence/opInvert/opVaryRhythm/opFragment/opOrnament,applyNamedOp,pickContour,landOnHarmony,generateMelody). ALGORITHMSconfig (~L906–931):leadGenA/Brewired togenerateMelody. Oldgenerate*Leadkept as fallback.- Test harness (~L3393–3795): new
[name, fn]entries.
Task 1: Profiles + weightedPick helper¶
Files:
- Modify: synthwave_surfer.html (algo block ~L503; tests array ~L3795)
- [ ] Step 1: Write the failing tests — add to the
testsarray:
['weightedPick — respects weights deterministically', () => {
const rng = mulberry32(1);
const counts = { a: 0, b: 0 };
for (let i = 0; i < 1000; i++) counts[weightedPick(rng, { a: 0.9, b: 0.1 })]++;
assert(counts.a > counts.b * 3, 'a chosen far more than b');
assert(JSON.stringify([weightedPick(mulberry32(5), {x:1,y:1}), weightedPick(mulberry32(5), {x:1,y:1})]).split(',')[0] ===
JSON.stringify([weightedPick(mulberry32(5), {x:1,y:1})]).split(',')[0], 'same seed → same pick');
}],
['MELODY_PROFILES — three genres with valid shape', () => {
for (const g of ['outrun', 'noir', 'dreamwave']) {
const p = MELODY_PROFILES[g];
assert(Array.isArray(p.grammar) && p.grammar.length >= 1, g + ' has grammar');
assert(p.rhythmCells.every(c => c.length >= p.motifLen[0] && c.length <= p.motifLen[1]), g + ' cells within motifLen');
const wsum = Object.values(p.opWeights).reduce((s, w) => s + w, 0);
assert(Math.abs(wsum - 1) < 1e-6, g + ' opWeights sum to 1');
}
}],
- [ ] Step 2: Run tests, verify they fail
Open http://localhost:8090/synthwave_surfer.html?test=1 → console shows the two new tests failing (weightedPick is not defined, MELODY_PROFILES is not defined). failed ≥ 2.
- [ ] Step 3: Implement — append to the algo block (after
snapDegree, ~L503):
/* ═══════════════════════════════════════════════════════════════════
Motif-development melody engine
═══════════════════════════════════════════════════════════════════ */
function weightedPick(rng, weights) {
const entries = Object.entries(weights);
const total = entries.reduce((s, [, w]) => s + w, 0);
let r = rng() * total;
for (const [k, w] of entries) { if ((r -= w) < 0) return k; }
return entries[entries.length - 1][0];
}
const MELODY_PROFILES = {
outrun: {
motifLen: [3, 5],
rhythmCells: [[4,4,4,4],[4,2,2,4],[2,2,4,8],[4,4,8],[2,2,2,2,8]],
stepBias: 0.7, leapSizes: [2, 3, 4], restDensity: 0.12,
opWeights: { repeat:0.25, sequence:0.30, invert:0.10, varyRhythm:0.20, fragment:0.10, ornament:0.05 },
contours: ['arch','wave','rise'], register: [0, 9],
grammar: ['A','A2','B','A3'], startDegree: 4,
},
noir: {
motifLen: [2, 3],
rhythmCells: [[8,8],[12,4],[8,4,4]],
stepBias: 0.85, leapSizes: [2, 4], restDensity: 0.30,
opWeights: { repeat:0.45, sequence:0.20, invert:0.05, varyRhythm:0.15, fragment:0.10, ornament:0.05 },
contours: ['fall','arch'], register: [0, 7],
grammar: ['A','A','A2','B'], startDegree: 2,
},
dreamwave: {
motifLen: [3, 5],
rhythmCells: [[2,2,4,2,6],[4,2,2,4,4],[2,2,2,2,8],[4,4,4,4]],
stepBias: 0.75, leapSizes: [2, 3], restDensity: 0.15,
opWeights: { repeat:0.20, sequence:0.25, invert:0.10, varyRhythm:0.25, fragment:0.10, ornament:0.10 },
contours: ['wave','arch','rise'], register: [0, 9],
grammar: ['A','A2','B','A2'], startDegree: 2,
},
};
- [ ] Step 4: Run tests, verify they pass
Reload ?test=1 → both new tests pass. failed back to 0, passed = 30.
- [ ] Step 5: Commit
git add synthwave_surfer.html
git commit -m "feat(algo): melody-engine profiles + weightedPick helper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 2: makeMotif¶
Files:
- Modify: synthwave_surfer.html (algo block; tests array)
- [ ] Step 1: Write the failing test — add to the
testsarray:
['makeMotif — deterministic, correct length, relative format', () => {
const a = makeMotif(mulberry32(42), MELODY_PROFILES.outrun);
const b = makeMotif(mulberry32(42), MELODY_PROFILES.outrun);
assert(JSON.stringify(a) === JSON.stringify(b), 'same seed → same motif');
assert(a.length >= MELODY_PROFILES.outrun.motifLen[0] && a.length <= MELODY_PROFILES.outrun.motifLen[1], 'length within motifLen');
assert(a.every(n => typeof n.deg === 'number' && typeof n.dur === 'number'), 'each note has deg+dur');
assert(a[0].deg === 0, 'motif starts on relative root 0');
}],
- [ ] Step 2: Run test, verify it fails
Reload ?test=1 → makeMotif is not defined. failed ≥ 1.
- [ ] Step 3: Implement — append to the algo block:
function makeMotif(rng, profile) {
const cell = profile.rhythmCells[Math.floor(rng() * profile.rhythmCells.length)];
const motif = [];
let deg = 0;
for (let i = 0; i < cell.length; i++) {
motif.push({ deg, dur: cell[i] });
if (rng() < profile.stepBias) {
deg += rng() < 0.5 ? 1 : -1;
} else {
const leap = profile.leapSizes[Math.floor(rng() * profile.leapSizes.length)];
deg += rng() < 0.5 ? leap : -leap;
}
}
return motif;
}
- [ ] Step 4: Run test, verify it passes
Reload ?test=1 → passes. passed = 31, failed = 0.
- [ ] Step 5: Commit
git add synthwave_surfer.html
git commit -m "feat(algo): makeMotif — seed-motif generator
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 3: Development operators + applyNamedOp¶
Files:
- Modify: synthwave_surfer.html (algo block; tests array)
- [ ] Step 1: Write the failing tests — add to the
testsarray:
['operators — sequence/invert/fragment preserve format & transform', () => {
const m = [{deg:0,dur:4},{deg:2,dur:4},{deg:1,dur:4}];
assert(JSON.stringify(opSequence(m, 2)) === JSON.stringify([{deg:2,dur:4},{deg:4,dur:4},{deg:3,dur:4}]), 'sequence +2');
assert(JSON.stringify(opInvert(m, 0)) === JSON.stringify([{deg:0,dur:4},{deg:-2,dur:4},{deg:-1,dur:4}]), 'invert around 0');
const frag = opFragment([{deg:0,dur:4},{deg:1,dur:4},{deg:2,dur:4},{deg:3,dur:4}], mulberry32(3));
assert(frag.length >= 2 && frag.length <= 4, 'fragment 2..len notes');
assert(opRepeat(m) !== m && JSON.stringify(opRepeat(m)) === JSON.stringify(m), 'repeat is a copy');
}],
['operators — varyRhythm/ornament keep valid format', () => {
const m = [{deg:0,dur:4},{deg:2,dur:4},{deg:1,dur:4}];
const vr = opVaryRhythm(m, mulberry32(9), MELODY_PROFILES.outrun);
assert(vr.every(n => typeof n.deg === 'number' && n.dur >= 1), 'varyRhythm valid');
const orn = opOrnament(m, mulberry32(9));
assert(orn.length >= m.length && orn.every(n => typeof n.deg === 'number' && n.dur >= 1), 'ornament valid');
assert(JSON.stringify(applyNamedOp('sequence', m, mulberry32(2), MELODY_PROFILES.outrun)) ===
JSON.stringify(applyNamedOp('sequence', m, mulberry32(2), MELODY_PROFILES.outrun)), 'applyNamedOp deterministic');
}],
- [ ] Step 2: Run tests, verify they fail
Reload ?test=1 → opSequence is not defined. failed ≥ 1.
- [ ] Step 3: Implement — append to the algo block:
function opRepeat(m) { return m.map(n => ({ deg: n.deg, dur: n.dur })); }
function opSequence(m, iv) { return m.map(n => ({ deg: n.deg + iv, dur: n.dur })); }
function opInvert(m, axis) { return m.map(n => ({ deg: axis - (n.deg - axis), dur: n.dur })); }
function opVaryRhythm(m, rng, profile) {
const sameLen = profile.rhythmCells.filter(c => c.length === m.length);
if (sameLen.length) {
const cell = sameLen[Math.floor(rng() * sameLen.length)];
return m.map((n, i) => ({ deg: n.deg, dur: cell[i] }));
}
const factor = rng() < 0.5 ? 2 : 0.5;
return m.map(n => ({ deg: n.deg, dur: Math.max(1, Math.round(n.dur * factor)) }));
}
function opFragment(m, rng) {
if (m.length <= 2) return m.map(n => ({ deg: n.deg, dur: n.dur }));
const start = Math.floor(rng() * (m.length - 1));
const len = Math.max(2, Math.floor(rng() * (m.length - start)) + 1);
return m.slice(start, start + len).map(n => ({ deg: n.deg, dur: n.dur }));
}
function opOrnament(m, rng) {
const out = [];
for (let i = 0; i < m.length; i++) {
const n = m[i];
if (i < m.length - 1 && n.dur >= 4 && rng() < 0.4) {
const half = Math.floor(n.dur / 2);
const passing = m[i + 1].deg >= n.deg ? n.deg + 1 : n.deg - 1;
out.push({ deg: n.deg, dur: n.dur - half });
out.push({ deg: passing, dur: half });
} else {
out.push({ deg: n.deg, dur: n.dur });
}
}
return out;
}
function applyNamedOp(name, motif, rng, profile) {
switch (name) {
case 'repeat': return opRepeat(motif);
case 'sequence': return opSequence(motif, (rng() < 0.5 ? 1 : -1) * profile.leapSizes[Math.floor(rng() * profile.leapSizes.length)]);
case 'invert': return opInvert(motif, motif[0].deg);
case 'varyRhythm':return opVaryRhythm(motif, rng, profile);
case 'fragment': return opFragment(motif, rng);
case 'ornament': return opOrnament(motif, rng);
default: return opRepeat(motif);
}
}
- [ ] Step 4: Run tests, verify they pass
Reload ?test=1 → pass. passed = 33, failed = 0.
- [ ] Step 5: Commit
git add synthwave_surfer.html
git commit -m "feat(algo): melody development operators + applyNamedOp
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 4: pickContour¶
Files:
- Modify: synthwave_surfer.html (algo block; tests array)
- [ ] Step 1: Write the failing test — add to the
testsarray:
['pickContour — one offset per grammar slot, deterministic', () => {
const a = pickContour(mulberry32(11), MELODY_PROFILES.outrun);
const b = pickContour(mulberry32(11), MELODY_PROFILES.outrun);
assert(JSON.stringify(a) === JSON.stringify(b), 'same seed → same contour');
assert(a.length === MELODY_PROFILES.outrun.grammar.length, 'one offset per slot');
assert(a.every(v => Number.isInteger(v)), 'integer offsets');
}],
- [ ] Step 2: Run test, verify it fails
Reload ?test=1 → pickContour is not defined. failed ≥ 1.
- [ ] Step 3: Implement — append to the algo block:
function pickContour(rng, profile) {
const shape = profile.contours[Math.floor(rng() * profile.contours.length)];
const n = profile.grammar.length;
const out = [];
for (let i = 0; i < n; i++) {
const t = n > 1 ? i / (n - 1) : 0;
let v;
if (shape === 'arch') v = Math.round(3 * Math.sin(Math.PI * t));
else if (shape === 'rise') v = Math.round(3 * t);
else if (shape === 'fall') v = Math.round(3 * (1 - t));
else v = (i % 2) * 2; // wave
out.push(v);
}
return out;
}
- [ ] Step 4: Run test, verify it passes
Reload ?test=1 → pass. passed = 34, failed = 0.
- [ ] Step 5: Commit
git add synthwave_surfer.html
git commit -m "feat(algo): pickContour — phrase contour shaper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 5: landOnHarmony¶
Files:
- Modify: synthwave_surfer.html (algo block; tests array)
Depends on the existing nearestChordDegree (~L485) and SCALES (~L343).
- [ ] Step 1: Write the failing test — add to the
testsarray:
['landOnHarmony — snaps strong-beat notes to chord tones', () => {
const scaleLen = SCALES.aeolian.length; // 7
const chordMap = [{ root: 0, tones: [0, 2, 4] }]; // Am triad as scale degrees
// events: a strong-beat (pos 0) non-chord note (deg 1) followed by weak-beat notes
const events = [{ degree: 1, duration: 8 }, { degree: 3, duration: 8 }];
landOnHarmony(events, chordMap, scaleLen, 0);
assert([0, 2, 4].includes(((events[0].degree % scaleLen) + scaleLen) % scaleLen), 'strong-beat note is a chord tone');
assert(events[1].degree === 3, 'weak-beat note (pos 8 is also strong here) left or snapped consistently');
}],
Note: with pos % 8 === 0, both pos 0 and pos 8 are "strong". The second assert documents that; if you change strong-beat granularity later, update it.
- [ ] Step 2: Run test, verify it fails
Reload ?test=1 → landOnHarmony is not defined. failed ≥ 1.
- [ ] Step 3: Implement — append to the algo block:
function landOnHarmony(events, chordMap, scaleLen, startBar) {
let pos = 0;
for (const e of events) {
if (!e.rest && pos % 8 === 0) {
const bar = Math.min(startBar + Math.floor(pos / 16), chordMap.length - 1);
const tones = chordMap[bar].tones;
const pc = ((e.degree % scaleLen) + scaleLen) % scaleLen;
const chordPcs = tones.map(ct => ((ct % scaleLen) + scaleLen) % scaleLen);
if (!chordPcs.includes(pc)) {
e.degree = nearestChordDegree(e.degree, tones, scaleLen);
}
}
pos += e.duration;
}
return events;
}
The test's second event lands at pos 8 (also strong); deg 3 is not in [0,2,4] so it snaps to nearest chord tone (2 or 4). Update the second assert to:
assert([0, 2, 4].includes(((events[1].degree % scaleLen) + scaleLen) % scaleLen), 'pos-8 note also snapped to chord tone');
- [ ] Step 4: Run test, verify it passes
Reload ?test=1 → pass. passed = 35, failed = 0.
- [ ] Step 5: Commit
git add synthwave_surfer.html
git commit -m "feat(algo): landOnHarmony — strong-beat chord-tone snapping
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 6: generateMelody assembler¶
Files:
- Modify: synthwave_surfer.html (algo block; tests array)
- [ ] Step 1: Write the failing tests — add to the
testsarray:
['generateMelody — fills exactly bars*16, valid events, in register, deterministic', () => {
const sl = SCALES.aeolian.length;
const chordMap = buildChordMap([[0,2,4],[-2,0,2],[2,4,6],[-1,1,3]], 16);
const ctx = { chordMap, profile: MELODY_PROFILES.outrun, startBar: 4 };
const a = generateMelody(mulberry32(1986), sl, 4, ctx);
const b = generateMelody(mulberry32(1986), sl, 4, ctx);
assert(JSON.stringify(a) === JSON.stringify(b), 'same seed → same melody');
assert(a.reduce((s, e) => s + e.duration, 0) === 4 * 16, 'durations sum to bars*16');
assert(a.every(e => e.rest || (e.degree >= 0 && e.degree <= 9)), 'notes within outrun register');
assert(a.some(e => !e.rest), 'has at least one note');
}],
['generateMelody — single-slot grammar reproduces the motif (motif presence)', () => {
const prof = { ...MELODY_PROFILES.outrun, grammar: ['A'], contours: ['rise'], restDensity: 0 };
const motif = makeMotif(mulberry32(7), prof);
const ev = generateMelody(mulberry32(7), SCALES.aeolian.length, 4, { profile: prof });
const clamp = d => Math.max(prof.register[0], Math.min(prof.register[1], d));
assert(ev[0].degree === clamp(prof.startDegree + motif[0].deg), 'first note = motif root transposed');
assert(ev[0].duration === motif[0].dur, 'first note duration from motif');
}],
- [ ] Step 2: Run tests, verify they fail
Reload ?test=1 → generateMelody is not defined. failed ≥ 1.
- [ ] Step 3: Implement — append to the algo block:
function generateMelody(rng, scaleLen, bars, ctx = {}) {
const { chordMap = null, profile, startBar = 0 } = ctx;
const total = bars * 16;
const motif = makeMotif(rng, profile);
const contour = pickContour(rng, profile);
const events = [];
let pos = 0;
for (let slot = 0; slot < profile.grammar.length && pos < total; slot++) {
const label = profile.grammar[slot];
let inst;
if (label === 'A') {
inst = opRepeat(motif);
} else if (label === 'B') {
inst = rng() < 0.5 ? opInvert(motif, motif[0].deg) : opSequence(motif, profile.leapSizes[0]);
} else {
inst = applyNamedOp(weightedPick(rng, profile.opWeights), motif, rng, profile);
}
const root = profile.startDegree + (contour[slot] || 0);
for (const n of inst) {
if (pos >= total) break;
let dur = n.dur;
if (pos + dur > total) dur = total - pos;
let degree = root + n.deg;
degree = Math.max(profile.register[0], Math.min(profile.register[1], degree));
events.push({ degree, duration: dur });
pos += dur;
}
if (pos < total && rng() < profile.restDensity * 2) {
const r = Math.min(2, total - pos);
events.push({ rest: true, duration: r });
pos += r;
}
}
if (pos < total) events.push({ rest: true, duration: total - pos });
if (chordMap) landOnHarmony(events, chordMap, scaleLen, startBar);
return events;
}
- [ ] Step 4: Run tests, verify they pass
Reload ?test=1 → pass. passed = 37, failed = 0.
- [ ] Step 5: Commit
git add synthwave_surfer.html
git commit -m "feat(algo): generateMelody — phrase assembler (motif + development + contour + harmony)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 7: Wire engine into ALGORITHMS + ear validation¶
Files:
- Modify: synthwave_surfer.html (ALGORITHMS config ~L906–931)
The old generate{Outrun,Noir,Dreamwave}Lead functions stay in place as a fallback/reference; only the leadGenA/B wrappers change.
- [ ] Step 1: Rewire
leadGenA/B— replace the three pairs ofleadGenA/leadGenBlines:
outrun (~L906–907):
leadGenA: (rng, sl, ctx) => generateMelody(rng, sl, 4, { ...ctx, profile: MELODY_PROFILES.outrun, startBar: 4 }),
leadGenB: (rng, sl, ctx) => generateMelody(rng, sl, 4, { ...ctx, profile: MELODY_PROFILES.outrun, startBar: 10 }),
noir (~L918–919):
leadGenA: (rng, sl, ctx) => generateMelody(rng, sl, 4, { ...ctx, profile: MELODY_PROFILES.noir, startBar: 4 }),
leadGenB: (rng, sl, ctx) => generateMelody(rng, sl, 4, { ...ctx, profile: MELODY_PROFILES.noir, startBar: 8 }),
dreamwave (~L930–931):
leadGenA: (rng, sl, ctx) => generateMelody(rng, sl, 4, { ...ctx, profile: MELODY_PROFILES.dreamwave, startBar: 4 }),
leadGenB: (rng, sl, ctx) => generateMelody(rng, sl, 4, { ...ctx, profile: MELODY_PROFILES.dreamwave, startBar: 10 }),
- [ ] Step 2: Verify existing tests stay green
Reload ?test=1 → still 0 failed (all 28 original + new melody tests pass; the rewire doesn't touch tested codec paths).
-
[ ] Step 3: Ear validation in the browser (the real gate)
-
Open
http://localhost:8090/synthwave_surfer.html(no?test=1). - Switch the pattern toggle to Generativ (presets are still frozen
fixedSwmd— to hear the new engine you must be in generative mode). - For each algo (Outrun / Noir / Dreamwave): generate, press Play, listen. Confirm the lead now sounds motivic (a recognizable idea that repeats/develops) rather than a random walk, with genre-appropriate character (flowing Outrun, sparse Noir, dreamy Dreamwave).
-
Re-generate a few times (new seeds) to hear the bandwidth/variety.
-
[ ] Step 4: Commit
git add synthwave_surfer.html
git commit -m "feat(algo): wire motif engine into leadGenA/B (generative mode)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
- [ ] Step 5: Hörtest-Iteration (project mode)
Per Phase-1.5 mode (ear is ground truth): if a genre's melody feels off, tune that genre's MELODY_PROFILES values (e.g. opWeights, restDensity, grammar, rhythmCells, register), reload, listen, and commit each accepted tweak (fix(algo): tune <genre> melody profile — <change>). The inline tests guard the structural invariants while you tune freely.
Self-Review (completed by plan author)¶
- Spec coverage: profiles (T1), makeMotif (T2), operators (T3), contour (T4), harmony-lander (T5), assembler (T6), integration + ear validation + fallback retention (T7), all five spec test categories covered (determinism T2/T4/T6, format/sum T6, harmony T5, motif-presence T6, register T6). ✓
- Placeholders: none — every step has runnable code/commands. ✓
- Type/name consistency:
weightedPick,makeMotif(rng, profile),op*,applyNamedOp(name, motif, rng, profile),pickContour(rng, profile),landOnHarmony(events, chordMap, scaleLen, startBar),generateMelody(rng, scaleLen, bars, ctx)used identically across all tasks. ✓ - Note:
makeMotifuses signature(rng, profile)— a deliberate refinement of the spec's(rng, scaleLen, profile, chordTones); harmony handling moved entirely intolandOnHarmony, soscaleLen/chordTonesaren't needed inmakeMotif.