Synthwave Surfer Phase 1 — Foundations 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: Implement the .swmd codec with multi-phase support, the Phase Engine state machine, the external window.synthwaveSurfer.* API, six curated multi-phase factory presets, and fix the WAV export. Stays within synthwave_surfer.html (single-file, no build step).
Architecture: All code lives in synthwave_surfer.html. New JS code is inserted in clearly-defined regions (codec helpers after line 489 (before the line 491 Form Builders block), Phase Engine + API after FACTORY_PRESETS, UI hookups after existing event handlers). The codec is a set of pure functions (parsers + serializers) easy to unit-test with an inline test harness. The Phase Engine is a tiny state machine that hooks into the existing generate(). The external API is a frozen window.synthwaveSurfer object exposed at app init.
Tech Stack: Vanilla JS (ES2020), Tone.js 14 (already loaded via CDN), no build step, single HTML file. Tests are inline JS functions executed via a dev-only ?test=1 URL parameter that prints to console.
Spec reference: docs/superpowers/specs/2026-05-14-synthwave-foundations-design.md
File Structure¶
Only one file is modified: synthwave_surfer.html.
New code is inserted in these regions (in order):
| Region | Insertion point | What goes there |
|---|---|---|
| Codec helpers | after line 489 (after snapDegree, before line 491 Form Builders block) |
Frontmatter parser, section splitter, table parser, piano-roll parser/serializer, drum parser/serializer, pad parser/serializer, FX parser, instrument-settings parser, full SWMD parser/serializer |
| Phase Engine | after FACTORY_PRESETS (~line 1999) | State variables, setPhase(), getCurrentPhaseObj(), listener machinery |
| buildFormFromSwmd | right after Phase Engine | Builds a form object from a parsed .swmd for the current phase |
| External API | after Phase Engine | Frozen window.synthwaveSurfer object |
Modified generate() |
line 2037 | Branches on patternMode === 'fixed' && currentSwmd |
Modified applyFactoryPreset() |
line 2000 | Loads fixedSwmd, calls swmdParse, sets phase mode |
Modified exportWav() |
line 1724 | Pass transport from offline context destructured arg |
| HTML — Tracks panel | line 235 (after harmonic-toggle) |
Pattern-mode toggle + Phase Selector container |
| HTML — Export panel | line 302 (after export-wav) |
SWMD ⬇/⬆ buttons + State JSON button |
| CSS | line 104 (after .harmonic-toggle.strict) |
.pattern-toggle, .phase-pill |
| Test harness | end of <script> (~line 1912) |
runCodecTests() + ?test=1 URL trigger |
State variables added (~line 1774, near let currentState = null;):
let patternMode = 'generative'; // 'generative' | 'fixed'
let currentSwmd = null; // result of swmdParse() or null
let currentPhaseIndex = 0; // index into currentSwmd.phases
const phaseChangeListeners = []; // (phaseName: string) => void callbacks
Test Strategy¶
This is a single-file HTML app with no test framework. We use a lightweight inline test harness: a runCodecTests() function with console output, triggered automatically when the URL contains ?test=1. Each codec task adds a test_*() helper, which runCodecTests() calls in order. Tests use a tiny assert(condition, message) helper that throws on failure and logs success.
This gives us TDD-style red/green/refactor for the pure functions (parsers, serializers, phase engine). UI and audio behavior is verified manually per the checklist in §10 of the spec.
Run tests: open synthwave_surfer.html?test=1 in a browser → check the console.
Task 1: Test harness setup¶
Files:
- Modify: synthwave_surfer.html — append at end of <script> block (~line 1912)
- [x] Step 1.1: Add
assert()helper and emptyrunCodecTests()
Append to the end of <script> (just before </script>):
/* ═══════════════════════════════════════════════════════════════════
Codec test harness (run with ?test=1)
═══════════════════════════════════════════════════════════════════ */
function assert(cond, msg) {
if (cond) { console.log(' ✓ ' + msg); return; }
console.error(' ✗ FAIL: ' + msg);
throw new Error('Test failed: ' + msg);
}
function runCodecTests() {
console.log('━━━ Synthwave Surfer Codec Tests ━━━');
let passed = 0, failed = 0;
const tests = [
// Tests are added by subsequent tasks
];
for (const [name, fn] of tests) {
console.log('▸ ' + name);
try { fn(); passed++; } catch (e) { failed++; console.error(e); }
}
console.log(`━━━ Done: ${passed} passed, ${failed} failed ━━━`);
}
if (new URLSearchParams(location.search).get('test') === '1') {
window.addEventListener('load', () => setTimeout(runCodecTests, 100));
}
- [x] Step 1.2: Verify harness runs
Open synthwave_surfer.html?test=1 in browser. Open DevTools console.
Expected output:
- [x] Step 1.3: Commit
git add synthwave_surfer.html
git commit -m "feat: add inline codec test harness with ?test=1 trigger"
Task 2: YAML frontmatter parser¶
Files:
- Modify: synthwave_surfer.html — insert after line 489 (after snapDegree function, before the line 491 Form Builders block)
- [x] Step 2.1: Add failing test
In runCodecTests(), add to the tests array:
['parseYamlFrontmatter — extracts flat fields', () => {
const text = '---\nname: Test\nbpm: 120\nmood: brooding\n---\n\n## Body';
const m = parseYamlFrontmatter(text);
assert(m.name === 'Test', 'name parsed');
assert(m.bpm === '120', 'bpm parsed (string, not coerced)');
assert(m.mood === 'brooding', 'mood parsed');
}],
['parseYamlFrontmatter — empty for no frontmatter', () => {
const m = parseYamlFrontmatter('## Just a heading');
assert(Object.keys(m).length === 0, 'empty object');
}],
['parseYamlFrontmatter — strips quoted strings', () => {
const text = '---\nsynthwave-surfer: "1.0"\n---';
const m = parseYamlFrontmatter(text);
assert(m['synthwave-surfer'] === '1.0', 'quotes stripped');
}],
- [x] Step 2.2: Run tests, verify they fail
Open synthwave_surfer.html?test=1 → console should show:
▸ parseYamlFrontmatter — extracts flat fields
✗ FAIL: ... ReferenceError: parseYamlFrontmatter is not defined
- [x] Step 2.3: Implement
parseYamlFrontmatter
Insert after line 489 (after snapDegree, before the line 491 Form Builders block):
/* ═══════════════════════════════════════════════════════════════════
.swmd Codec — Markdown-based pattern format
═══════════════════════════════════════════════════════════════════ */
function parseYamlFrontmatter(text) {
const match = text.match(/^---\n([\s\S]+?)\n---/);
if (!match) return {};
const result = {};
for (const line of match[1].split('\n')) {
const m = line.match(/^([\w-]+):\s*"?([^"]*)"?\s*$/);
if (m) result[m[1]] = m[2].trim();
}
return result;
}
- [x] Step 2.4: Run tests, verify they pass
Reload synthwave_surfer.html?test=1. Console should show:
▸ parseYamlFrontmatter — extracts flat fields
✓ name parsed
✓ bpm parsed (string, not coerced)
✓ mood parsed
[etc., all green]
- [x] Step 2.5: Commit
Task 3: Markdown table parser (helper)¶
Files:
- Modify: synthwave_surfer.html — append after parseYamlFrontmatter
- [x] Step 3.1: Add failing test
Add to tests in runCodecTests():
['parseMarkdownTable — basic 2-row table', () => {
const text = '| Param | Value |\n|-------|-------|\n| volume | 0.7 |\n| model | stratos |';
const rows = parseMarkdownTable(text);
assert(rows.length === 2, 'two rows');
assert(rows[0].Param === 'volume', 'col 1 of row 0');
assert(rows[0].Value === '0.7', 'col 2 of row 0');
assert(rows[1].Param === 'model', 'col 1 of row 1');
}],
['parseMarkdownTable — returns [] for no table', () => {
assert(parseMarkdownTable('just text').length === 0, 'empty array');
}],
-
[x] Step 3.2: Run tests, verify they fail (parseMarkdownTable undefined)
-
[x] Step 3.3: Implement
parseMarkdownTable
Append after parseYamlFrontmatter:
function parseMarkdownTable(text) {
const lines = text.split('\n').filter(l => l.trim().startsWith('|'));
if (lines.length < 2) return [];
const headers = lines[0].split('|').slice(1, -1).map(h => h.trim());
const rows = [];
for (let i = 2; i < lines.length; i++) {
const cells = lines[i].split('|').slice(1, -1).map(c => c.trim());
const row = {};
headers.forEach((h, j) => { row[h] = cells[j] ?? ''; });
rows.push(row);
}
return rows;
}
-
[x] Step 3.4: Run tests, verify they pass
-
[x] Step 3.5: Commit
Task 4: SWMD section splitter (with multi-phase awareness)¶
Files:
- Modify: synthwave_surfer.html — append after parseMarkdownTable
The splitter is the routing layer. It splits a .swmd document by ## Phase: name headings (or top-level ## Bass / ## Lead / etc. for single-phase backwards-compat).
- [x] Step 4.1: Add failing tests
['splitSwmdSections — single phase (no "Phase:" prefix)', () => {
const text = '---\nx: y\n---\n\n## Bass · Stratos\nstuff\n\n## Lead · Crystal\nmore stuff\n\n## FX Bus\nfx';
const sections = splitSwmdSections(text);
assert(Array.isArray(sections.phases), 'phases is an array');
assert(sections.phases.length === 1, 'one implicit phase');
assert(sections.phases[0].name === 'default', 'implicit phase name');
assert('Bass' in sections.phases[0].sections, 'phase has Bass');
assert('Lead' in sections.phases[0].sections, 'phase has Lead');
assert('FX Bus' in sections.fileSections, 'FX Bus is file-level');
}],
['splitSwmdSections — multi-phase', () => {
const text = '---\nx: y\n---\n\n## Phase: calm\n\n### Bass · Stratos\n\n[bass]\n\n### Lead · Crystal\n\n[lead]\n\n## Phase: intense\n\n### Bass · Stratos\n\n[bass2]\n\n## FX Bus\n[fx]';
const sections = splitSwmdSections(text);
assert(sections.phases.length === 2, 'two phases');
assert(sections.phases[0].name === 'calm', 'first phase named');
assert(sections.phases[1].name === 'intense', 'second phase named');
assert('FX Bus' in sections.fileSections, 'FX Bus is file-level');
}],
-
[x] Step 4.2: Run tests, verify they fail (splitSwmdSections undefined)
-
[x] Step 4.3: Implement
splitSwmdSections
Append after parseMarkdownTable:
function splitSwmdSections(text) {
// Strip frontmatter if present so it doesn't pollute section splitting
const body = text.replace(/^---\n[\s\S]+?\n---\n?/, '');
// First-level split: ## headings
const parts = body.split(/\n## /);
// parts[0] is anything before first ## — ignored
const phases = [];
const fileSections = {};
let currentPhase = null;
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const nlIdx = part.indexOf('\n');
const header = (nlIdx >= 0 ? part.slice(0, nlIdx) : part).trim();
const body = nlIdx >= 0 ? part.slice(nlIdx + 1) : '';
if (header.startsWith('Phase:')) {
const name = header.slice('Phase:'.length).trim();
currentPhase = { name, sections: {} };
phases.push(currentPhase);
// Body of a "## Phase: X" heading contains the ### Bass / ### Lead / ### Pad / ### Drums sections
const phaseSubparts = body.split(/\n### /);
// phaseSubparts[0] is whitespace before first ###
for (let j = 1; j < phaseSubparts.length; j++) {
const subPart = phaseSubparts[j];
const subNl = subPart.indexOf('\n');
const subHeader = (subNl >= 0 ? subPart.slice(0, subNl) : subPart).trim();
const subBody = subNl >= 0 ? subPart.slice(subNl + 1) : '';
const key = subHeader.split(' ·')[0].trim();
currentPhase.sections[key] = subBody;
}
} else {
// File-level section (e.g. "FX Bus", "Routing") OR backwards-compat single-phase top-level (Bass, Lead, etc.)
const key = header.split(' ·')[0].trim();
const isPhaseLevelKey = ['Bass', 'Lead', 'Pad', 'Drums'].includes(key);
if (isPhaseLevelKey) {
// Backwards-compat: no explicit phase, use implicit "default"
if (!currentPhase || currentPhase.name !== 'default') {
currentPhase = { name: 'default', sections: {} };
phases.push(currentPhase);
}
currentPhase.sections[key] = body;
} else {
fileSections[key] = body;
}
}
}
return { phases, fileSections };
}
-
[x] Step 4.4: Run tests, verify they pass
-
[x] Step 4.5: Commit
git add synthwave_surfer.html
git commit -m "feat: add SWMD section splitter with multi-phase support"
Task 5: Piano roll parser¶
Files:
- Modify: synthwave_surfer.html — append after splitSwmdSections
- [x] Step 5.1: Add failing tests
['parsePianoRoll — basic single note', () => {
const text = '### Pattern\n| Grad | 1 | 2 | 3 | 4 |\n|------|----|----|----|----|\n| 0 | ● | ─ | | |';
const notes = parsePianoRoll(text);
assert(notes.length === 2, 'two segments (note + rest)');
assert(notes[0].degree === 0, 'degree 0');
assert(notes[0].duration === 2, 'duration 2 (● + ─)');
assert(notes[1].rest === true, 'rest segment');
assert(notes[1].duration === 2, 'rest duration 2');
}],
['parsePianoRoll — multiple degrees in 16 steps', () => {
const text = '### Pattern\n' +
'| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |\n' +
'|------|----|----|----|----|----|----|----|----|\n' +
'| 4 | | | | | ● | ─ | | |\n' +
'| 0 | ● | ─ | | | | | ● | ─ |';
const notes = parsePianoRoll(text);
assert(notes.length === 4, 'four segments: note rest note note');
assert(notes[0].degree === 0 && notes[0].duration === 2, 'note 1: deg 0 dur 2');
assert(notes[1].rest === true && notes[1].duration === 2, 'rest of dur 2');
assert(notes[2].degree === 4 && notes[2].duration === 2, 'note 2: deg 4 dur 2');
assert(notes[3].degree === 0 && notes[3].duration === 2, 'note 3: deg 0 dur 2');
}],
['parsePianoRoll — empty when no Pattern subsection', () => {
assert(parsePianoRoll('### Settings\n| x | y |').length === 0, 'empty');
}],
-
[x] Step 5.2: Run tests, verify they fail (parsePianoRoll undefined)
-
[x] Step 5.3: Implement
parsePianoRoll
Append after splitSwmdSections:
function parsePianoRoll(sectionText) {
const patternMatch = sectionText.match(/### Pattern\n([\s\S]+?)(?=\n###|\n##|$)/);
if (!patternMatch) return [];
const tableText = patternMatch[1];
const lines = tableText.split('\n').filter(l => l.trim().startsWith('|'));
if (lines.length < 3) return [];
const headerCells = lines[0].split('|').slice(1, -1);
const stepCount = headerCells.length - 1; // first column is "Grad"
const stepGrid = new Array(stepCount).fill(null);
for (let r = 2; r < lines.length; r++) {
const cells = lines[r].split('|').slice(1, -1);
const degree = parseInt(cells[0].trim());
if (Number.isNaN(degree)) continue;
for (let s = 0; s < stepCount; s++) {
const cell = (cells[s + 1] || '').trim();
if (cell === '●') stepGrid[s] = { degree, starting: true };
else if (cell === '─' && stepGrid[s] === null) stepGrid[s] = { degree, starting: false };
}
}
const notes = [];
let i = 0;
while (i < stepGrid.length) {
if (!stepGrid[i]) {
let dur = 1;
while (i + dur < stepGrid.length && !stepGrid[i + dur]) dur++;
notes.push({ rest: true, duration: dur });
i += dur; continue;
}
const degree = stepGrid[i].degree;
let dur = 1;
while (i + dur < stepGrid.length
&& stepGrid[i + dur]
&& !stepGrid[i + dur].starting
&& stepGrid[i + dur].degree === degree) dur++;
notes.push({ degree, duration: dur });
i += dur;
}
return notes;
}
-
[x] Step 5.4: Run tests, verify they pass
-
[x] Step 5.5: Commit
Task 6: Piano roll serializer¶
Files:
- Modify: synthwave_surfer.html — append after parsePianoRoll
- [x] Step 6.1: Add failing tests, including a round-trip
['serializePianoRoll — single note round-trip', () => {
const notes = [{ degree: 0, duration: 2 }, { rest: true, duration: 2 }];
const text = serializePianoRoll(notes);
assert(text.includes('|'), 'output is a markdown table');
assert(text.includes(' ● '), 'has note marker');
assert(text.includes(' ─ '), 'has hold marker');
// Round-trip via parser
const reparsed = parsePianoRoll('### Pattern\n' + text);
assert(reparsed.length === 2, 'round-trip preserves segment count');
assert(reparsed[0].degree === 0 && reparsed[0].duration === 2, 'note preserved');
assert(reparsed[1].rest === true && reparsed[1].duration === 2, 'rest preserved');
}],
['serializePianoRoll — multi-degree round-trip', () => {
const notes = [
{ degree: 0, duration: 2 },
{ degree: 4, duration: 2 },
{ degree: 0, duration: 2 },
{ rest: true, duration: 2 },
];
const text = serializePianoRoll(notes);
const reparsed = parsePianoRoll('### Pattern\n' + text);
assert(reparsed.length === 4, 'four segments preserved');
assert(reparsed[0].degree === 0 && reparsed[2].degree === 0, 'low degree preserved');
assert(reparsed[1].degree === 4, 'high degree preserved');
}],
-
[x] Step 6.2: Run tests, verify they fail (serializePianoRoll undefined)
-
[x] Step 6.3: Implement
serializePianoRoll
Append after parsePianoRoll:
function serializePianoRoll(notes) {
// Expand notes back into a step grid
const steps = [];
for (const n of notes) {
if (n.rest) { for (let i = 0; i < n.duration; i++) steps.push(null); continue; }
steps.push({ degree: n.degree, start: true });
for (let i = 1; i < n.duration; i++) steps.push({ degree: n.degree, start: false });
}
const S = steps.length;
if (S === 0) return '';
// Find degrees to render (used degrees only; sort high → low)
const degreesUsed = [...new Set(steps.filter(Boolean).map(s => s.degree))].sort((a, b) => b - a);
if (degreesUsed.length === 0) return '';
const gradW = 6;
const header = `| ${'Grad'.padEnd(gradW)} |` +
Array.from({ length: S }, (_, i) => ` ${String(i + 1).padStart(2)} `).join('|') + '|';
const sep = `|${'-'.repeat(gradW + 2)}|` +
Array.from({ length: S }, () => '----').join('|') + '|';
const rows = degreesUsed.map(d => {
const cells = steps.map(s => {
if (!s || s.degree !== d) return ' ';
return s.start ? ' ● ' : ' ─ ';
});
return `| ${String(d).padStart(gradW)} |` + cells.join('|') + '|';
});
return [header, sep, ...rows].join('\n');
}
-
[x] Step 6.4: Run tests, verify they pass
-
[x] Step 6.5: Commit
git add synthwave_surfer.html
git commit -m "feat: add piano-roll serializer with round-trip test coverage"
Task 7: Drum parser + serializer¶
Files:
- Modify: synthwave_surfer.html — append after serializePianoRoll
- [x] Step 7.1: Add failing tests
['parseDrums — basic 16-step', () => {
const text = '### Pattern\n```swdrum\nkick: x . . . x . . . x . . . x . . .\nsnare: . . . . x . . . . . . . x . . .\nhihat: x . x . x . x . x . x . x . x .\nopen: . . . . . . . x . . . . . . . x\n```';
const drums = parseDrums(text);
assert(drums !== null, 'drums parsed');
assert(drums.kick.length === 16, 'kick has 16 steps');
assert(drums.kick[0] === true && drums.kick[1] === false, 'kick step 0 on, 1 off');
assert(drums.snare[4] === true, 'snare on step 4');
assert(drums.open[15] === true, 'open hat on step 15');
}],
['parseDrums — null for no swdrum block', () => {
assert(parseDrums('### Pattern\nno block here') === null, 'null when no swdrum');
}],
['serializeDrums — round-trip', () => {
const grid = {
kick: [true,false,false,false, true,false,false,false, true,false,false,false, true,false,false,false],
snare: [false,false,false,false, true,false,false,false, false,false,false,false, true,false,false,false],
hihat: [true,false,true,false, true,false,true,false, true,false,true,false, true,false,true,false],
open: [false,false,false,false, false,false,false,true, false,false,false,false, false,false,false,true],
};
const text = serializeDrums(grid);
assert(text.startsWith('```swdrum'), 'starts with code fence');
const reparsed = parseDrums('### Pattern\n' + text);
assert(reparsed.kick[0] === true, 'kick preserved');
assert(reparsed.snare[4] === true, 'snare preserved');
assert(reparsed.open[15] === true, 'open preserved');
}],
-
[x] Step 7.2: Run tests, verify they fail
-
[x] Step 7.3: Implement
parseDrumsandserializeDrums
Append after serializePianoRoll:
function parseDrums(sectionText) {
const blockMatch = sectionText.match(/```swdrum\n([\s\S]+?)```/);
if (!blockMatch) return null;
const result = { kick: [], snare: [], hihat: [], open: [] };
for (const line of blockMatch[1].split('\n')) {
const m = line.match(/^(\w+):\s*(.+)$/);
if (!m) continue;
const key = m[1].toLowerCase();
if (!(key in result)) continue;
result[key] = m[2].trim().split(/\s+/).map(c => c === 'x');
}
for (const k of Object.keys(result)) {
while (result[k].length < 16) result[k].push(false);
result[k] = result[k].slice(0, 16);
}
return result;
}
function serializeDrums(grid) {
const fmt = a => a.slice(0, 16).map(b => b ? 'x' : '.').join(' ');
return '```swdrum\nkick: ' + fmt(grid.kick || [])
+ '\nsnare: ' + fmt(grid.snare || [])
+ '\nhihat: ' + fmt(grid.hihat || [])
+ '\nopen: ' + fmt(grid.open || [])
+ '\n```';
}
-
[x] Step 7.4: Run tests, verify they pass
-
[x] Step 7.5: Commit
git add synthwave_surfer.html
git commit -m "feat: add swdrum parser/serializer with round-trip test"
Task 8: Pad progression parser + serializer¶
Files:
- Modify: synthwave_surfer.html — append after serializeDrums
- [x] Step 8.1: Add failing tests
['parsePadProgression — 4-bar progression', () => {
const text = '### Progression\n| Bar | 1 | 2 | 3 | 4 |\n|---------|------|------|------|------|\n| Degrees | 0,2,4 | -2,0,2 | 2,4,6 | -1,1,3 |';
const prog = parsePadProgression(text);
assert(prog !== null, 'progression parsed');
assert(prog.length === 4, 'four bars');
assert(prog[0].length === 3 && prog[0][0] === 0 && prog[0][2] === 4, 'bar 1: [0,2,4]');
assert(prog[1][0] === -2, 'bar 2: negative degree');
}],
['parsePadProgression — null for no Degrees row', () => {
assert(parsePadProgression('### Progression\n| Bar | 1 |\n|-----|---|\n| Other | x |') === null, 'null without Degrees');
}],
['serializePadProgression — round-trip', () => {
const prog = [[0, 2, 4], [-2, 0, 2], [2, 4, 6], [-1, 1, 3]];
const text = serializePadProgression(prog);
const reparsed = parsePadProgression('### Progression\n' + text);
assert(reparsed.length === 4, 'four bars round-tripped');
assert(reparsed[0][2] === 4 && reparsed[1][0] === -2, 'values preserved');
}],
-
[x] Step 8.2: Run tests, verify they fail
-
[x] Step 8.3: Implement parser and serializer
Append after serializeDrums:
function parsePadProgression(sectionText) {
const progMatch = sectionText.match(/### Progression\n([\s\S]+?)(?=\n###|\n##|$)/);
const tableText = progMatch ? progMatch[1] : sectionText;
const rows = parseMarkdownTable(tableText);
const degreeRow = rows.find(r => r['Bar'] === 'Degrees' || r['Bar'] === 'Degree');
if (!degreeRow) return null;
const chords = [];
for (let i = 1; i <= 8; i++) {
const val = degreeRow[String(i)];
if (val === undefined || val === '') break;
chords.push(val.split(',').map(s => parseInt(s.trim(), 10)));
}
return chords.length > 0 ? chords : null;
}
function serializePadProgression(prog) {
const N = prog.length;
const header = `| Bar |` + prog.map((_, i) => ` ${i + 1} |`).join('');
const sep = `|---------|` + prog.map(() => '------|').join('');
const degrees = `| Degrees |` + prog.map(c => ` ${c.join(',')} |`).join('');
return `${header}\n${sep}\n${degrees}`;
}
-
[x] Step 8.4: Run tests, verify they pass
-
[x] Step 8.5: Commit
git add synthwave_surfer.html
git commit -m "feat: add pad progression parser/serializer for .swmd codec"
Task 9: FX Bus parser + Instrument settings parser¶
Files:
- Modify: synthwave_surfer.html — append after serializePadProgression
- [x] Step 9.1: Add failing tests
['parseFx — extracts master state shape', () => {
const text = '| Effect | Param | Value |\n|--------|-------|-------|\n| reverb | size | 0.6 |\n| reverb | wet | 0.5 |\n| delay | time | 0.55 |\n| sidechain | depth | 0.7 |\n| master | gain | 0.8 |';
const fx = parseFx(text);
assert(fx.reverbSize === 0.6, 'reverbSize mapped');
assert(fx.reverbWet === 0.5, 'reverbWet mapped');
assert(fx.delayTime === 0.55, 'delayTime mapped');
assert(fx.sidechain === 0.7, 'sidechain depth mapped');
assert(fx.masterGain === 0.8, 'master gain mapped');
}],
['parseInstrumentSettings — coerces numerics', () => {
const text = '### Settings\n| Param | Value |\n|-------|-------|\n| model | stratos |\n| volume | 0.7 |\n| character | 0.5 |\n| lfoTarget | pwm |';
const s = parseInstrumentSettings(text);
assert(s.model === 'stratos', 'string preserved');
assert(s.volume === 0.7, 'volume coerced to number');
assert(s.character === 0.5, 'character coerced');
assert(s.lfoTarget === 'pwm', 'lfoTarget string preserved');
}],
-
[x] Step 9.2: Run tests, verify they fail
-
[x] Step 9.3: Implement both parsers
Append after serializePadProgression:
function parseFx(sectionText) {
const rows = parseMarkdownTable(sectionText);
const ms = {};
const map = {
reverb: { size: 'reverbSize', wet: 'reverbWet' },
delay: { time: 'delayTime', feedback: 'delayFB', wet: 'delayWet' },
sidechain: { depth: 'sidechain' },
tapesat: { drive: 'tapeSat' },
master: { gain: 'masterGain' },
};
for (const row of rows) {
const effect = (row['Effect'] || '').toLowerCase();
const param = (row['Param'] || '').toLowerCase();
const value = parseFloat(row['Value']);
if (Number.isNaN(value)) continue;
if (map[effect] && map[effect][param]) ms[map[effect][param]] = value;
}
return ms;
}
const NUMERIC_INSTRUMENT_KEYS = new Set([
'volume','detune','attack','decay','sustain','release',
'cutoff','resonance','fenvAmount','fenvDecay','character',
'lfoRate','lfoDepth'
]);
const BOOL_INSTRUMENT_KEYS = new Set(['send']);
function parseInstrumentSettings(sectionText) {
const settingsMatch = sectionText.match(/### Settings\n([\s\S]+?)(?=\n###|\n##|$)/);
const tableText = settingsMatch ? settingsMatch[1] : sectionText;
const rows = parseMarkdownTable(tableText);
const result = {};
for (const row of rows) {
const key = row['Param']; const val = row['Value'];
if (!key || val === undefined) continue;
if (NUMERIC_INSTRUMENT_KEYS.has(key)) result[key] = parseFloat(val);
else if (BOOL_INSTRUMENT_KEYS.has(key)) result[key] = (val === 'true');
else result[key] = val;
}
return result;
}
-
[x] Step 9.4: Run tests, verify they pass
-
[x] Step 9.5: Commit
git add synthwave_surfer.html
git commit -m "feat: add FX and instrument settings parsers for .swmd codec"
Task 10: Full SWMD parser (multi-phase aware)¶
Files:
- Modify: synthwave_surfer.html — append after parseInstrumentSettings
- [x] Step 10.1: Add failing tests
['swmdParse — minimal single-phase document', () => {
const text = `---
synthwave-surfer: "1.0"
name: Test
algo: outrun
bpm: 120
mode: aeolian
pattern-mode: fixed
bass-root: 48
lead-root: 60
mood: nostalgic
energy: medium
---
## Bass · Stratos
### Settings
| Param | Value |
|-------|-------|
| model | stratos |
| volume | 0.7 |
### Pattern
| Grad | 1 | 2 | 3 | 4 |
|------|----|----|----|----|
| 0 | ● | ─ | | |
## FX Bus
| Effect | Param | Value |
|--------|-------|-------|
| master | gain | 0.8 |
`;
const parsed = swmdParse(text);
assert(parsed.meta.name === 'Test', 'meta name');
assert(parsed.meta.mood === 'nostalgic', 'meta mood');
assert(parsed.phases.length === 1, 'one implicit phase');
assert(parsed.phases[0].name === 'default', 'default phase name');
assert(parsed.phases[0].bassPattern.length === 2, 'bass pattern parsed');
assert(parsed.phases[0].bassSettings.model === 'stratos', 'bass settings');
assert(parsed.fxSettings.masterGain === 0.8, 'fx parsed');
}],
['swmdParse — multi-phase document', () => {
const text = `---
synthwave-surfer: "1.0"
name: MultiTest
algo: noir
bpm: 80
mode: aeolian
pattern-mode: fixed
default-phase: calm
---
## Phase: calm
### Bass · Stratos
#### Pattern (replaced inline below since #### is not parsed; pattern lives under ### Pattern in real spec)
### Pattern
| Grad | 1 | 2 |
|------|----|----|
| 0 | ● | ─ |
## Phase: intense
### Bass · Stratos
### Pattern
| Grad | 1 | 2 |
|------|----|----|
| 4 | ● | ─ |
## FX Bus
| Effect | Param | Value |
|--------|-------|-------|
| reverb | wet | 0.5 |
`;
const parsed = swmdParse(text);
assert(parsed.phases.length === 2, 'two phases parsed');
assert(parsed.phases[0].name === 'calm', 'first phase name');
assert(parsed.phases[1].name === 'intense', 'second phase name');
assert(parsed.meta['default-phase'] === 'calm', 'default-phase meta');
}],
-
[x] Step 10.2: Run tests, verify they fail
-
[x] Step 10.3: Implement
swmdParse
Append after parseInstrumentSettings:
function swmdParse(text) {
const meta = parseYamlFrontmatter(text);
const split = splitSwmdSections(text);
const phases = split.phases.map(phase => {
const bassSection = phase.sections['Bass'] || '';
const leadSection = phase.sections['Lead'] || '';
const padSection = phase.sections['Pad'] || '';
const drumSection = phase.sections['Drums'] || '';
return {
name: phase.name,
bassPattern: parsePianoRoll(bassSection),
leadPattern: parsePianoRoll(leadSection),
bassSettings: parseInstrumentSettings(bassSection),
leadSettings: parseInstrumentSettings(leadSection),
padSettings: parseInstrumentSettings(padSection),
padChords: parsePadProgression(padSection),
drumGrid: parseDrums(drumSection),
};
});
const fxSection = split.fileSections['FX Bus'] || '';
return {
meta,
phases,
fxSettings: parseFx(fxSection),
};
}
-
[x] Step 10.4: Run tests, verify they pass
-
[x] Step 10.5: Commit
git add synthwave_surfer.html
git commit -m "feat: add full SWMD parser combining all sub-parsers (multi-phase)"
Task 11: Full SWMD serializer (multi-phase output)¶
Files:
- Modify: synthwave_surfer.html — append after swmdParse
- [x] Step 11.1: Add round-trip test
['swmdSerialize — single-phase round-trip', () => {
const original = {
meta: { name: 'RoundTrip', algo: 'outrun', bpm: '120', mode: 'aeolian',
'pattern-mode': 'fixed', 'bass-root': '48', 'lead-root': '60',
mood: 'test', energy: 'medium', 'default-phase': 'default',
'synthwave-surfer': '1.0' },
phases: [{
name: 'default',
bassPattern: [{ degree: 0, duration: 2 }, { rest: true, duration: 2 }],
leadPattern: [{ degree: 4, duration: 4 }],
bassSettings: { model: 'stratos', volume: 0.7 },
leadSettings: { model: 'crystal', volume: 0.45 },
padSettings: { model: 'vapor', volume: 0.4 },
padChords: [[0, 2, 4]],
drumGrid: {
kick: Array(16).fill(false).map((_, i) => i % 4 === 0),
snare: Array(16).fill(false).map((_, i) => i === 4 || i === 12),
hihat: Array(16).fill(false).map((_, i) => i % 2 === 0),
open: Array(16).fill(false),
},
}],
fxSettings: { reverbSize: 0.6, reverbWet: 0.5, masterGain: 0.8 },
};
const text = swmdSerialize(original);
const reparsed = swmdParse(text);
assert(reparsed.meta.name === 'RoundTrip', 'meta round-tripped');
assert(reparsed.phases.length === 1, 'one phase');
assert(reparsed.phases[0].bassPattern.length === 2, 'bass pattern length');
assert(reparsed.phases[0].bassPattern[0].degree === 0, 'bass first note');
assert(reparsed.phases[0].bassSettings.volume === 0.7, 'bass volume');
assert(reparsed.fxSettings.masterGain === 0.8, 'master gain');
}],
['swmdSerialize — multi-phase round-trip', () => {
const original = {
meta: { name: 'TwoPhases', algo: 'noir', bpm: '80', mode: 'aeolian',
'pattern-mode': 'fixed', 'bass-root': '48', 'lead-root': '60',
mood: 'cinematic', energy: 'low', 'default-phase': 'calm',
'synthwave-surfer': '1.0' },
phases: [
{ name: 'calm',
bassPattern: [{ degree: 0, duration: 16 }],
leadPattern: [], bassSettings: { model: 'stratos', volume: 0.4 },
leadSettings: { model: 'crystal', volume: 0.3 },
padSettings: { model: 'vapor', volume: 0.5 },
padChords: [[0, 2, 4]],
drumGrid: { kick: Array(16).fill(false), snare: Array(16).fill(false),
hihat: Array(16).fill(false), open: Array(16).fill(false) }},
{ name: 'rising',
bassPattern: [{ degree: 0, duration: 4 }, { degree: 2, duration: 4 },
{ degree: 4, duration: 4 }, { degree: 5, duration: 4 }],
leadPattern: [{ degree: 6, duration: 8 }, { degree: 4, duration: 8 }],
bassSettings: { model: 'stratos', volume: 0.6 },
leadSettings: { model: 'crystal', volume: 0.5 },
padSettings: { model: 'vapor', volume: 0.5 },
padChords: [[0, 2, 4]],
drumGrid: { kick: Array(16).fill(false).map((_, i) => i % 4 === 0),
snare: Array(16).fill(false), hihat: Array(16).fill(false),
open: Array(16).fill(false) }},
],
fxSettings: { masterGain: 0.75 },
};
const text = swmdSerialize(original);
const reparsed = swmdParse(text);
assert(reparsed.phases.length === 2, 'two phases round-tripped');
assert(reparsed.phases[0].name === 'calm', 'phase 1 name');
assert(reparsed.phases[1].name === 'rising', 'phase 2 name');
assert(reparsed.phases[1].bassPattern.length === 4, 'rising bass count');
}],
-
[x] Step 11.2: Run tests, verify they fail (swmdSerialize undefined)
-
[x] Step 11.3: Implement
swmdSerialize
Append after swmdParse:
function swmdSerialize(parsed) {
const m = parsed.meta || {};
const fmFields = [
'synthwave-surfer','name','algo','bpm','mode','pattern-mode',
'bass-root','lead-root','mood','energy','default-phase',
];
const fmLines = ['---'];
for (const k of fmFields) {
if (m[k] === undefined || m[k] === '') continue;
const needsQuote = k === 'synthwave-surfer';
fmLines.push(`${k}: ${needsQuote ? '"' + m[k] + '"' : m[k]}`);
}
fmLines.push('---');
const frontmatter = fmLines.join('\n');
const phaseSections = parsed.phases.map(phase => {
const isDefault = phase.name === 'default';
const phaseHeader = isDefault ? '' : `## Phase: ${phase.name}\n\n`;
const headingPrefix = isDefault ? '## ' : '### ';
const bassSettings = serializeInstrumentSettingsTable(phase.bassSettings);
const bassPattern = serializePianoRoll(phase.bassPattern);
const bassBlock = `${headingPrefix}Bass · ${phase.bassSettings.model || 'stratos'}\n\n` +
`${isDefault ? '###' : '####'} Settings\n${bassSettings}\n\n` +
`${isDefault ? '###' : '####'} Pattern\n${bassPattern}`;
const leadSettings = serializeInstrumentSettingsTable(phase.leadSettings);
const leadPattern = serializePianoRoll(phase.leadPattern);
const leadBlock = `${headingPrefix}Lead · ${phase.leadSettings.model || 'crystal'}\n\n` +
`${isDefault ? '###' : '####'} Settings\n${leadSettings}\n\n` +
`${isDefault ? '###' : '####'} Pattern\n${leadPattern}`;
const padSettings = serializeInstrumentSettingsTable(phase.padSettings);
const padProg = phase.padChords ? serializePadProgression(phase.padChords) : '';
const padBlock = `${headingPrefix}Pad · ${phase.padSettings.model || 'vapor'}\n\n` +
`${isDefault ? '###' : '####'} Settings\n${padSettings}\n\n` +
`${isDefault ? '###' : '####'} Progression\n${padProg}`;
const drumBlock = `${headingPrefix}Drums\n\n` +
`${isDefault ? '###' : '####'} Pattern\n${serializeDrums(phase.drumGrid || {})}`;
return phaseHeader + [bassBlock, leadBlock, padBlock, drumBlock].join('\n\n');
});
const fx = parsed.fxSettings || {};
const fxRows = [
['reverb','size', fx.reverbSize],
['reverb','wet', fx.reverbWet],
['delay','time', fx.delayTime],
['delay','feedback', fx.delayFB],
['delay','wet', fx.delayWet],
['sidechain','depth', fx.sidechain],
['tapeSat','drive', fx.tapeSat],
['master','gain', fx.masterGain],
].filter(r => r[2] !== undefined)
.map(r => `| ${r[0]} | ${r[1]} | ${typeof r[2] === 'number' ? r[2].toFixed(2) : r[2]} |`)
.join('\n');
const fxBlock = `## FX Bus\n| Effect | Param | Value |\n|--------|-------|-------|\n${fxRows}`;
return [frontmatter, ...phaseSections, fxBlock].join('\n\n') + '\n';
}
function serializeInstrumentSettingsTable(settings) {
const rows = Object.entries(settings)
.map(([k, v]) => {
const valStr = typeof v === 'number' ? (Number.isInteger(v) ? String(v) : v.toFixed(2))
: typeof v === 'boolean' ? String(v)
: v;
return `| ${k} | ${valStr} |`;
})
.join('\n');
return `| Param | Value |\n|-------|-------|\n${rows}`;
}
Note for plan reader: The serializer above uses ### headings inside multi-phase mode. The parser in Task 4 uses ### Bass · ... for multi-phase (under ## Phase:). The #### levels here in some sub-headings (Settings, Pattern) are deliberately one level deeper to fit under ### Bass. Verify by reading the round-trip test pass — if it fails, adjust heading depth on either parser or serializer for consistency. The parser only matches on ### Pattern, ### Settings, ### Progression regexes (pattern: /### Pattern\n/), so adjust the serializer to use ### everywhere if needed.
Resolution for ambiguity: Use ### Pattern, ### Settings, ### Progression consistently (level 3) regardless of phase context. Multi-phase uses ## Phase: name (level 2) with ### Bass · ... (level 3) as the instrument header. This means the original (single-phase) format must use ## Bass · ... (level 2) — but for serializer simplicity, always emit multi-phase form: every output has at least one ## Phase: default heading. Modify the serializer accordingly:
Replace the implementation above with this simpler always-multi-phase form:
function swmdSerialize(parsed) {
const m = parsed.meta || {};
const fmFields = [
'synthwave-surfer','name','algo','bpm','mode','pattern-mode',
'bass-root','lead-root','mood','energy','default-phase',
];
const fmLines = ['---'];
for (const k of fmFields) {
if (m[k] === undefined || m[k] === '') continue;
const needsQuote = k === 'synthwave-surfer';
fmLines.push(`${k}: ${needsQuote ? '"' + m[k] + '"' : m[k]}`);
}
fmLines.push('---');
const frontmatter = fmLines.join('\n');
const phaseSections = parsed.phases.map(phase => {
const bassModel = phase.bassSettings?.model || 'stratos';
const leadModel = phase.leadSettings?.model || 'crystal';
const padModel = phase.padSettings?.model || 'vapor';
const bassBlock = `### Bass · ${bassModel}\n\n` +
`### Settings\n${serializeInstrumentSettingsTable(phase.bassSettings)}\n\n` +
`### Pattern\n${serializePianoRoll(phase.bassPattern)}`;
const leadBlock = `### Lead · ${leadModel}\n\n` +
`### Settings\n${serializeInstrumentSettingsTable(phase.leadSettings)}\n\n` +
`### Pattern\n${serializePianoRoll(phase.leadPattern)}`;
const padProg = phase.padChords ? serializePadProgression(phase.padChords) : '';
const padBlock = `### Pad · ${padModel}\n\n` +
`### Settings\n${serializeInstrumentSettingsTable(phase.padSettings)}\n\n` +
`### Progression\n${padProg}`;
const drumBlock = `### Drums\n\n` +
`### Pattern\n${serializeDrums(phase.drumGrid || {})}`;
return `## Phase: ${phase.name}\n\n${bassBlock}\n\n${leadBlock}\n\n${padBlock}\n\n${drumBlock}`;
});
const fx = parsed.fxSettings || {};
const fxRows = [
['reverb','size', fx.reverbSize],
['reverb','wet', fx.reverbWet],
['delay','time', fx.delayTime],
['delay','feedback', fx.delayFB],
['delay','wet', fx.delayWet],
['sidechain','depth', fx.sidechain],
['tapeSat','drive', fx.tapeSat],
['master','gain', fx.masterGain],
].filter(r => r[2] !== undefined)
.map(r => `| ${r[0]} | ${r[1]} | ${typeof r[2] === 'number' ? r[2].toFixed(2) : r[2]} |`)
.join('\n');
const fxBlock = `## FX Bus\n| Effect | Param | Value |\n|--------|-------|-------|\n${fxRows}`;
return [frontmatter, ...phaseSections, fxBlock].join('\n\n') + '\n';
}
function serializeInstrumentSettingsTable(settings) {
const rows = Object.entries(settings || {})
.map(([k, v]) => {
const valStr = typeof v === 'number' ? (Number.isInteger(v) ? String(v) : v.toFixed(2))
: typeof v === 'boolean' ? String(v)
: v;
return `| ${k} | ${valStr} |`;
})
.join('\n');
return `| Param | Value |\n|-------|-------|\n${rows}`;
}
Important: The parser in Task 4 must accept the splitter's ## Phase: form. We chose to always emit multi-phase form (each file always has at least ## Phase: default). This is what the round-trip tests in Step 11.1 already assume — single-phase serialization produces a ## Phase: default block which the parser splits correctly.
-
[x] Step 11.4: Run tests, verify they pass
-
[x] Step 11.5: Commit
git add synthwave_surfer.html
git commit -m "feat: add SWMD serializer always emitting multi-phase form"
Task 12: Phase Engine state + setPhase¶
Files:
- Modify: synthwave_surfer.html
- Add state vars near line 1774
- Add Phase Engine functions after FACTORY_PRESETS (~line 1999)
- [x] Step 12.1: Add failing tests
['Phase Engine — getPhases / getCurrentPhase with no swmd', () => {
currentSwmd = null;
patternMode = 'generative';
assert(getPhases().length === 0, 'no phases when no swmd');
assert(getCurrentPhase() === null, 'no current phase');
assert(setPhase('foo') === false, 'setPhase fails when generative');
}],
['Phase Engine — setPhase switches index', () => {
currentSwmd = {
meta: {},
phases: [{ name: 'a' }, { name: 'b' }, { name: 'c' }],
fxSettings: {},
};
patternMode = 'fixed';
currentPhaseIndex = 0;
assert(getPhases().join(',') === 'a,b,c', 'phases listed');
assert(getCurrentPhase() === 'a', 'starts at a');
let notified = null;
const unsub = onPhaseChange(name => { notified = name; });
assert(setPhase('b') === true, 'setPhase succeeds');
assert(currentPhaseIndex === 1, 'index updated');
assert(getCurrentPhase() === 'b', 'current phase is b');
assert(notified === 'b', 'listener fired');
unsub();
assert(setPhase('nonexistent') === false, 'invalid phase rejected');
// cleanup
currentSwmd = null;
currentPhaseIndex = 0;
}],
-
[x] Step 12.2: Run tests, verify they fail (functions undefined)
-
[x] Step 12.3: Add state variables near line 1774 (next to
let currentState = null;)
let patternMode = 'generative';
let currentSwmd = null;
let currentPhaseIndex = 0;
const phaseChangeListeners = [];
- [x] Step 12.4: Add Phase Engine functions right after
FACTORY_PRESETS(~line 1999)
/* ═══════════════════════════════════════════════════════════════════
Phase Engine — multi-phase pattern playback state machine
═══════════════════════════════════════════════════════════════════ */
function getPhases() {
if (patternMode !== 'fixed' || !currentSwmd) return [];
return currentSwmd.phases.map(p => p.name);
}
function getCurrentPhase() {
if (patternMode !== 'fixed' || !currentSwmd) return null;
return currentSwmd.phases[currentPhaseIndex]?.name || null;
}
function getCurrentPhaseObj() {
if (patternMode !== 'fixed' || !currentSwmd) return null;
return currentSwmd.phases[currentPhaseIndex] || null;
}
function setPhase(name) {
if (patternMode !== 'fixed' || !currentSwmd) {
console.warn('[synthwaveSurfer] setPhase ignored: not in fixed mode');
return false;
}
const idx = currentSwmd.phases.findIndex(p => p.name === name);
if (idx === -1) {
console.warn('[synthwaveSurfer] setPhase ignored: unknown phase ' + name);
return false;
}
if (idx === currentPhaseIndex) return true;
currentPhaseIndex = idx;
// Re-generate the form with the new phase's pattern
if (currentState) generate();
// Notify listeners
for (const cb of phaseChangeListeners) {
try { cb(name); } catch (e) { console.error('[synthwaveSurfer] listener error', e); }
}
return true;
}
function onPhaseChange(cb) {
phaseChangeListeners.push(cb);
return () => {
const i = phaseChangeListeners.indexOf(cb);
if (i >= 0) phaseChangeListeners.splice(i, 1);
};
}
- [x] Step 12.5: Run tests, verify they pass
The test uses currentSwmd and patternMode as global; the test sets them directly and resets after. This is intentional — these are file-level let declarations, accessible from anywhere in the script.
- [x] Step 12.6: Commit
git add synthwave_surfer.html
git commit -m "feat: add Phase Engine state machine (setPhase, listeners)"
Task 13: External API surface (window.synthwaveSurfer)¶
Files:
- Modify: synthwave_surfer.html — append after Phase Engine functions
- [x] Step 13.1: Add test
['External API — exposed and frozen', () => {
assert(typeof window.synthwaveSurfer === 'object', 'API exposed');
assert(typeof window.synthwaveSurfer.setPhase === 'function', 'setPhase fn');
assert(typeof window.synthwaveSurfer.getPhases === 'function', 'getPhases fn');
assert(typeof window.synthwaveSurfer.getCurrentPhase === 'function', 'getCurrentPhase fn');
assert(typeof window.synthwaveSurfer.onPhaseChange === 'function', 'onPhaseChange fn');
assert(typeof window.synthwaveSurfer.getMeta === 'function', 'getMeta fn');
assert(Object.isFrozen(window.synthwaveSurfer), 'API is frozen');
}],
-
[x] Step 13.2: Run test, verify it fails
-
[x] Step 13.3: Implement API surface
Append right after the Phase Engine functions:
window.synthwaveSurfer = Object.freeze({
setPhase,
getPhases,
getCurrentPhase,
onPhaseChange,
getMeta: () => (patternMode === 'fixed' && currentSwmd) ? { ...currentSwmd.meta } : null,
});
-
[x] Step 13.4: Run test, verify it passes
-
[x] Step 13.5: Commit
git add synthwave_surfer.html
git commit -m "feat: expose frozen window.synthwaveSurfer API surface"
Task 14: buildFormFromSwmd — produce form from current phase¶
Files:
- Modify: synthwave_surfer.html — append after Phase Engine + API (around line 1700ish, before generate())
This function takes the parsed .swmd + algo metadata and produces the form object that scheduleAll() consumes.
- [x] Step 14.1: Add failing test
['buildFormFromSwmd — produces form with bass/lead parts', () => {
const swmd = {
meta: {},
phases: [{
name: 'default',
bassPattern: [{ degree: 0, duration: 4 }, { degree: 2, duration: 4 },
{ degree: 4, duration: 4 }, { degree: 5, duration: 4 }],
leadPattern: [{ degree: 6, duration: 8 }, { degree: 4, duration: 8 }],
bassSettings: { model: 'stratos' },
leadSettings: { model: 'crystal' },
padSettings: { model: 'vapor' },
padChords: [[0, 2, 4]],
drumGrid: { kick: Array(16).fill(false).map((_,i)=>i%4===0),
snare: Array(16).fill(false), hihat: Array(16).fill(false),
open: Array(16).fill(false) },
}],
fxSettings: {},
};
const algo = ALGORITHMS.outrun;
const phase = swmd.phases[0];
const form = buildFormFromSwmd(swmd, phase, algo);
assert(Array.isArray(form.parts), 'form.parts is array');
assert(form.parts.some(p => p.voice === 'bass'), 'has bass parts');
assert(form.parts.some(p => p.voice === 'lead'), 'has lead parts');
assert(Array.isArray(form.padProg), 'padProg is array');
assert(Array.isArray(form.drumGrid && form.drumGrid.kick), 'drumGrid.kick array');
}],
-
[x] Step 14.2: Run test, verify it fails
-
[x] Step 14.3: Implement
buildFormFromSwmd
Append right before function generate() (line 2037):
function buildFormFromSwmd(swmd, phase, algo) {
const padProg = phase.padChords || algo.padProg;
const chordMap = buildChordMap(padProg, algo.totalBars);
const bassLine = phase.bassPattern.length > 0
? phase.bassPattern
: algo.riffGen(mulberry32(1), SCALES.aeolian.length);
const leadLine = phase.leadPattern.length > 0
? phase.leadPattern
: [];
const parts = [];
// Bass: loop bassLine every bar, transposed to chord root
for (let b = 0; b < algo.totalBars; b++) {
const rootOffset = chordMap[b]?.root ?? 0;
parts.push({ voice: 'bass', line: transpose(bassLine, rootOffset), startBar: b });
}
// Lead: loop in 4-bar blocks starting at bar 4
if (leadLine.length > 0) {
for (let b = 4; b < algo.totalBars; b += 6) {
parts.push({ voice: 'lead', line: leadLine, startBar: b });
}
}
return {
parts,
riff: bassLine,
melodyA: leadLine,
melodyB: leadLine,
padProg,
drumGrid: phase.drumGrid,
};
}
-
[x] Step 14.4: Run test, verify it passes
-
[x] Step 14.5: Commit
git add synthwave_surfer.html
git commit -m "feat: add buildFormFromSwmd to convert parsed phase into form"
Task 15: Modify generate() to branch on patternMode¶
Files:
- Modify: synthwave_surfer.html — replace existing generate() body (lines 1695-1717)
- [x] Step 15.1: Read current
generate()body to confirm shape
Open synthwave_surfer.html, view lines 1695-1717 to confirm the current implementation matches the snippet below. Adjust the line numbers in Step 15.2 if it has changed.
- [x] Step 15.2: Replace
generate()body
Find:
function generate() {
const seed = parseInt($('seed').value) || 1;
const mode = $('mode').value;
const bpm = Math.max(60, Math.min(200, parseInt($('bpm').value) || 110));
const algoName = document.querySelector('input[name="algo"]:checked').value;
const algo = ALGORITHMS[algoName];
$('bpm').value = bpm;
const rng = mulberry32(seed);
const scale = SCALES[mode];
const chordMap = buildChordMap(algo.padProg, algo.totalBars);
const ctx = { chordMap, harmonicMode };
const material = {
riff: algo.riffGen(rng, scale.length),
melodyA: algo.leadGenA(rng, scale.length, ctx),
melodyB: algo.leadGenB(rng, scale.length, ctx),
};
const form = algo.buildForm(rng, material, { modeName: mode, chordMap });
currentState = { seed, mode, bpm, material, form, algoName };
$('log').innerHTML = buildLog(currentState);
drawRoll(form, scale, algo, 0, muteState);
$('play').disabled = false; $('export-midi').disabled = false; $('export-wav').disabled = false;
setStatus('✓ generated · ' + algoName + ' · seed ' + seed);
}
Replace with:
function generate() {
const seed = parseInt($('seed').value) || 1;
const mode = $('mode').value;
const bpm = Math.max(60, Math.min(200, parseInt($('bpm').value) || 110));
const algoName = document.querySelector('input[name="algo"]:checked').value;
const algo = ALGORITHMS[algoName];
$('bpm').value = bpm;
const scale = SCALES[mode];
let form, material;
if (patternMode === 'fixed' && currentSwmd) {
const phase = getCurrentPhaseObj();
form = buildFormFromSwmd(currentSwmd, phase, algo);
material = form; // form already has riff / melodyA / melodyB shape
} else {
const rng = mulberry32(seed);
const chordMap = buildChordMap(algo.padProg, algo.totalBars);
const ctx = { chordMap, harmonicMode };
material = {
riff: algo.riffGen(rng, scale.length),
melodyA: algo.leadGenA(rng, scale.length, ctx),
melodyB: algo.leadGenB(rng, scale.length, ctx),
};
form = algo.buildForm(rng, material, { modeName: mode, chordMap });
}
currentState = { seed, mode, bpm, material, form, algoName };
$('log').innerHTML = buildLog(currentState);
drawRoll(form, scale, algo, 0, muteState);
$('play').disabled = false; $('export-midi').disabled = false; $('export-wav').disabled = false;
$('export-swmd').disabled = false;
$('state-export').disabled = false;
syncPhaseSelector();
setStatus('✓ generated · ' + algoName + ' · ' + (patternMode === 'fixed' ? 'fixed: ' + (getCurrentPhase() || 'default') : 'seed ' + seed));
}
The syncPhaseSelector function and the export-swmd / state-export buttons are added in later tasks. The reference here is forward-looking — Tasks 16, 18, 19, 22 add them. Until those land, opening the page will throw ReferenceError: syncPhaseSelector is not defined. Implement Tasks 16-22 in sequence to keep the app loadable.
Recommendation: Define a no-op stub now so the app stays loadable while later tasks land:
After replacing generate(), add this stub immediately above it:
And ensure $('export-swmd') and $('state-export') won't crash when called against missing elements — wrap with guards:
const exSwmd = $('export-swmd'); if (exSwmd) exSwmd.disabled = false;
const stExp = $('state-export'); if (stExp) stExp.disabled = false;
Use those guarded forms in generate() instead of the direct ones. Final body:
function syncPhaseSelector() { /* implemented in Task 19 */ }
function generate() {
const seed = parseInt($('seed').value) || 1;
const mode = $('mode').value;
const bpm = Math.max(60, Math.min(200, parseInt($('bpm').value) || 110));
const algoName = document.querySelector('input[name="algo"]:checked').value;
const algo = ALGORITHMS[algoName];
$('bpm').value = bpm;
const scale = SCALES[mode];
let form, material;
if (patternMode === 'fixed' && currentSwmd) {
const phase = getCurrentPhaseObj();
form = buildFormFromSwmd(currentSwmd, phase, algo);
material = form;
} else {
const rng = mulberry32(seed);
const chordMap = buildChordMap(algo.padProg, algo.totalBars);
const ctx = { chordMap, harmonicMode };
material = {
riff: algo.riffGen(rng, scale.length),
melodyA: algo.leadGenA(rng, scale.length, ctx),
melodyB: algo.leadGenB(rng, scale.length, ctx),
};
form = algo.buildForm(rng, material, { modeName: mode, chordMap });
}
currentState = { seed, mode, bpm, material, form, algoName };
$('log').innerHTML = buildLog(currentState);
drawRoll(form, scale, algo, 0, muteState);
$('play').disabled = false; $('export-midi').disabled = false; $('export-wav').disabled = false;
const exSwmd = $('export-swmd'); if (exSwmd) exSwmd.disabled = false;
const stExp = $('state-export'); if (stExp) stExp.disabled = false;
syncPhaseSelector();
setStatus('✓ generated · ' + algoName + ' · ' + (patternMode === 'fixed' ? 'fixed: ' + (getCurrentPhase() || 'default') : 'seed ' + seed));
}
- [x] Step 15.3: Verify in browser
Open synthwave_surfer.html (no ?test=1). Click Generate. Audio should play normally. No console errors.
- [x] Step 15.4: Verify with
?test=1
Open synthwave_surfer.html?test=1. Tests should still pass.
- [x] Step 15.5: Commit
git add synthwave_surfer.html
git commit -m "feat: branch generate() on patternMode (generative vs fixed-phase)"
Task 16: Add Pattern Mode toggle UI¶
Files:
- Modify: synthwave_surfer.html
- HTML: insert after harmonic-toggle div (~line 235)
- CSS: insert after .harmonic-toggle.strict style (~line 104)
- JS: append handler near other toggle handlers (~line 1747)
- [x] Step 16.1: Add CSS (after line 104, the
.harmonic-toggle.strictline)
.pattern-toggle { padding: 10px 16px; border: 1px solid var(--rule); background: rgba(10, 6, 18, 0.4); cursor: pointer; font-family: inherit; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--steel); display: flex; align-items: center; gap: 10px; user-select: none; transition: all 0.15s ease; }
.pattern-toggle:hover { border-color: var(--purple); }
.pattern-toggle.generative { border-color: var(--steel); color: var(--steel); }
.pattern-toggle.fixed { border-color: var(--purple); color: var(--purple); background: rgba(160,106,255,0.08); }
If --purple is not defined, replace with an existing accent var (--green works as fallback). Verify by inspecting the CSS variables block at the top of <style>.
- [x] Step 16.2: Add HTML — find the line containing
harmonic-toggle natural(line 235) and add immediately after:
<div class="track-divider"></div>
<div class="pattern-toggle generative" id="pattern-toggle">⚙ Generativ</div>
- [x] Step 16.3: Add handler — find the
harmonic-toggleclick handler (~line 1747) and add this immediately after:
$('pattern-toggle').addEventListener('click', () => {
patternMode = patternMode === 'generative' ? 'fixed' : 'generative';
const btn = $('pattern-toggle');
btn.className = 'pattern-toggle ' + patternMode;
btn.textContent = patternMode === 'generative' ? '⚙ Generativ' : '⚙ Fixed';
if (patternMode === 'fixed' && !currentSwmd) {
setStatus('⚠ No fixed pattern loaded — pick a Factory Preset or import .swmd', true);
}
if (currentState) generate();
});
- [x] Step 16.4: Verify in browser
Open synthwave_surfer.html. Click the new toggle (it should switch text between "⚙ Generativ" and "⚙ Fixed"). With currentSwmd === null, switching to Fixed should show the warning status. Audio still plays in generative mode.
- [x] Step 16.5: Commit
git add synthwave_surfer.html
git commit -m "feat: add Pattern Mode toggle (Generativ / Fixed) to Tracks panel"
Task 17: Add SWMD Export/Import buttons + handlers¶
Files:
- Modify: synthwave_surfer.html
- HTML: insert in Export panel (~line 302, after export-wav button)
- JS: append handlers near other export wiring (~line 1798)
- [x] Step 17.1: Add HTML — find the line
<button id="export-wav" class="export" disabled>⬇ WAV</button>(line 302) and add immediately after:
<button id="export-swmd" class="export" disabled>⬇ SWMD</button>
<label class="export" style="cursor:pointer; padding: 8px 14px; border: 1px solid var(--rule); display: inline-flex; align-items: center;">⬆ SWMD<input id="import-swmd-file" type="file" accept=".swmd,.md" style="display:none;"></label>
- [x] Step 17.2: Add
exportSwmd()andimportSwmd()and the input/click handlers
Append after the line $('export-wav').onclick = exportWav; (~line 1798):
function exportSwmd() {
if (!currentState) { setStatus('⚠ Generate first', true); return; }
let parsed = currentSwmd;
if (!parsed) {
// Build a one-phase swmd from current state for export
parsed = buildSwmdFromCurrentState();
}
const text = swmdSerialize(parsed);
const fname = `${currentState.algoName}_${parsed.meta.name || currentState.seed}.md`;
download(new TextEncoder().encode(text), fname, 'text/markdown');
setStatus('✓ SWMD exported');
}
function buildSwmdFromCurrentState() {
const algo = ALGORITHMS[currentState.algoName];
const bassPart = currentState.form.parts.find(p => p.voice === 'bass');
const leadPart = currentState.form.parts.find(p => p.voice === 'lead');
return {
meta: {
'synthwave-surfer': '1.0',
name: currentState.algoName + '_' + currentState.seed,
algo: currentState.algoName,
bpm: String(currentState.bpm),
mode: currentState.mode,
'pattern-mode': 'fixed',
'bass-root': '48',
'lead-root': '60',
'default-phase': 'default',
},
phases: [{
name: 'default',
bassPattern: bassPart ? bassPart.line : (currentState.material.riff || []),
leadPattern: leadPart ? leadPart.line : (currentState.material.melodyA || []),
bassSettings: { ...trackParams.bass },
leadSettings: { ...trackParams.lead },
padSettings: { ...trackParams.pad },
padChords: algo.padProg,
drumGrid: defaultDrumGridForAlgo(algo),
}],
fxSettings: { ...masterState },
};
}
function defaultDrumGridForAlgo(algo) {
const K = Array(16).fill(false), S = Array(16).fill(false),
H = Array(16).fill(false), O = Array(16).fill(false);
if (algo.drumPattern === 'driving') {
[0,4,8,12].forEach(i => { K[i] = true; });
[4,12].forEach(i => { S[i] = true; });
[0,2,4,6,8,10,12,14].forEach(i => { H[i] = true; });
} else if (algo.drumPattern === 'sparse') {
[0].forEach(i => { K[i] = true; });
[8].forEach(i => { S[i] = true; });
[0,4,8,12].forEach(i => { H[i] = true; });
} else { // shuffle / dreamy / other
[0,8].forEach(i => { K[i] = true; });
[8].forEach(i => { S[i] = true; });
[0,2,4,6,8,10,12,14].forEach(i => { H[i] = true; });
}
return { kick: K, snare: S, hihat: H, open: O };
}
function importSwmd(file) {
const reader = new FileReader();
reader.onload = ev => {
try {
const parsed = swmdParse(ev.target.result);
currentSwmd = parsed;
currentPhaseIndex = 0;
// Apply meta
if (parsed.meta.bpm) $('bpm').value = parsed.meta.bpm;
if (parsed.meta.mode) $('mode').value = parsed.meta.mode;
if (parsed.meta.algo) {
document.querySelectorAll('input[name="algo"]').forEach(r => r.checked = (r.value === parsed.meta.algo));
document.querySelectorAll('.algo-card').forEach(c => c.classList.toggle('active', c.dataset.algo === parsed.meta.algo));
}
// Apply default-phase if set
if (parsed.meta['default-phase']) {
const dp = parsed.phases.findIndex(p => p.name === parsed.meta['default-phase']);
if (dp >= 0) currentPhaseIndex = dp;
}
// Apply track + fx settings from current phase
const phase = parsed.phases[currentPhaseIndex];
if (phase.bassSettings) Object.assign(trackParams.bass, phase.bassSettings);
if (phase.leadSettings) Object.assign(trackParams.lead, phase.leadSettings);
if (phase.padSettings) Object.assign(trackParams.pad, phase.padSettings);
if (parsed.fxSettings) Object.assign(masterState, parsed.fxSettings);
for (const k of ['bass','lead','pad']) syncTrackUI(k);
syncMasterUI();
patternMode = 'fixed';
$('pattern-toggle').className = 'pattern-toggle fixed';
$('pattern-toggle').textContent = '⚙ Fixed';
generate();
setStatus('✓ SWMD imported: ' + (parsed.meta.name || file.name));
} catch (e) {
console.error(e);
setStatus('⚠ SWMD parse error: ' + e.message, true);
}
};
reader.readAsText(file);
}
$('export-swmd').onclick = exportSwmd;
$('import-swmd-file').addEventListener('change', e => {
if (e.target.files[0]) { importSwmd(e.target.files[0]); e.target.value = ''; }
});
-
[x] Step 17.3: Verify in browser
-
Open
synthwave_surfer.html. Click Generate. - Click ⬇ SWMD. A
.mdfile downloads. Open it in any text editor to confirm it has frontmatter,## Phase: default, instrument sections, and## FX Bus. -
Click ⬆ SWMD and pick the file you just exported. The toggle should switch to Fixed and audio should play (potentially with a different sound if track params changed).
-
[x] Step 17.4: Commit
Task 18: Add State JSON export button¶
Files:
- Modify: synthwave_surfer.html
- HTML: insert in User Presets panel near preset-export (~line 278)
- JS: append handler after preset-export wiring
- [x] Step 18.1: Add HTML — find
<button id="preset-export" class="small">⬇ Export</button>(line 278) and add immediately after:
- [x] Step 18.2: Add handler
Find the existing preset-export handler (or just append near other UI wiring) and add:
$('state-export').onclick = () => {
if (!currentState) { setStatus('⚠ Generate first', true); return; }
const state = {
timestamp: new Date().toISOString(),
seed: currentState.seed,
mode: currentState.mode,
bpm: currentState.bpm,
algoName: currentState.algoName,
patternMode,
currentPhase: getCurrentPhase(),
trackParams: { bass: { ...trackParams.bass }, lead: { ...trackParams.lead }, pad: { ...trackParams.pad } },
masterState: { ...masterState },
muteState: { ...muteState },
swmd: currentSwmd ? swmdSerialize(currentSwmd) : null,
};
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url;
a.download = `synthwave_state_${currentState.algoName}_${currentState.seed}.json`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
setStatus('✓ State exported');
};
- [x] Step 18.3: Verify in browser
Click Generate then ⬇ State JSON. Open the downloaded file. It should be valid JSON with all keys above.
- [x] Step 18.4: Commit
Task 19: Phase Selector UI¶
Files:
- Modify: synthwave_surfer.html
- HTML: insert phase-selector container in Tracks panel (~line 235, after pattern-toggle)
- CSS: add .phase-pill styles (after Task 16's CSS additions)
- JS: replace the function syncPhaseSelector() stub from Task 15
- [x] Step 19.1: Add HTML — find the line
<div class="pattern-toggle generative" id="pattern-toggle">⚙ Generativ</div>(added in Task 16) and add immediately after:
- [x] Step 19.2: Add CSS (right after
.pattern-toggle.fixedrule from Task 16):
.phase-selector { display: flex; gap: 6px; align-items: center; padding-left: 12px; border-left: 1px solid var(--rule); margin-left: 6px; }
.phase-pill { padding: 6px 12px; border: 1px solid var(--steel); border-radius: 12px; cursor: pointer; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--steel); user-select: none; transition: all 0.15s ease; }
.phase-pill:hover { border-color: var(--purple); }
.phase-pill.active { border-color: var(--purple); color: var(--purple); background: rgba(160,106,255,0.12); }
- [x] Step 19.3: Replace the
syncPhaseSelectorstub from Task 15 with full implementation
Find the line function syncPhaseSelector() { /* implemented in Task 19 */ } and replace with:
function syncPhaseSelector() {
const container = $('phase-selector');
if (!container) return;
const phases = getPhases();
if (phases.length <= 1) {
container.style.display = 'none';
container.innerHTML = '';
return;
}
container.style.display = 'flex';
container.innerHTML = '';
const current = getCurrentPhase();
for (const name of phases) {
const pill = document.createElement('div');
pill.className = 'phase-pill' + (name === current ? ' active' : '');
pill.textContent = name;
pill.addEventListener('click', () => { setPhase(name); });
container.appendChild(pill);
}
}
- [x] Step 19.4: Verify in browser
Right now we don't have a multi-phase preset to load (those come in Tasks 20-25). To verify, paste this in the console after generating:
currentSwmd = {
meta: { name: 'Manual Test' },
phases: [
{ name: 'a', bassPattern: [{degree:0,duration:16}], leadPattern: [],
bassSettings:{model:'stratos'}, leadSettings:{model:'crystal'},
padSettings:{model:'vapor'}, padChords:[[0,2,4]],
drumGrid:{kick:Array(16).fill(false),snare:Array(16).fill(false),hihat:Array(16).fill(false),open:Array(16).fill(false)} },
{ name: 'b', bassPattern: [{degree:4,duration:16}], leadPattern: [],
bassSettings:{model:'stratos'}, leadSettings:{model:'crystal'},
padSettings:{model:'vapor'}, padChords:[[0,2,4]],
drumGrid:{kick:Array(16).fill(false),snare:Array(16).fill(false),hihat:Array(16).fill(false),open:Array(16).fill(false)} },
],
fxSettings: {},
};
patternMode = 'fixed';
generate();
Pills "a" and "b" should appear. Clicking them switches phase (audio re-renders if playing).
- [x] Step 19.5: Commit
git add synthwave_surfer.html
git commit -m "feat: add Phase Selector UI (pill row, hidden when single phase)"
Task 20: Modify applyFactoryPreset to handle fixedSwmd¶
Files:
- Modify: synthwave_surfer.html — extend applyFactoryPreset() (lines 1658-1680)
- [x] Step 20.1: Replace
applyFactoryPreset()body
Find:
function applyFactoryPreset(name) {
const p = FACTORY_PRESETS[name];
if (!p) return;
$('seed').value = p.seed;
$('mode').value = p.mode;
$('bpm').value = p.bpm;
document.querySelectorAll('input[name="algo"]').forEach(r => r.checked = (r.value === p.algoName));
document.querySelectorAll('.algo-card').forEach(c => c.classList.toggle('active', c.dataset.algo === p.algoName));
Object.assign(trackParams.bass, p.bass);
Object.assign(trackParams.lead, p.lead);
Object.assign(trackParams.pad, p.pad);
Object.assign(masterState, p.master);
for (const k of ['bass','lead','pad']) syncTrackUI(k);
syncMasterUI();
generate();
if (liveSynths) {
setTrackModel(liveSynths, 'bass', trackParams.bass.model, trackParams.bass);
setTrackModel(liveSynths, 'lead', trackParams.lead.model, trackParams.lead);
setTrackModel(liveSynths, 'pad', trackParams.pad.model, trackParams.pad);
applyAllToGraph(liveSynths);
}
setStatus('✓ Factory preset loaded: ' + name);
}
Replace with:
function applyFactoryPreset(name) {
const p = FACTORY_PRESETS[name];
if (!p) return;
$('seed').value = p.seed;
$('mode').value = p.mode;
$('bpm').value = p.bpm;
document.querySelectorAll('input[name="algo"]').forEach(r => r.checked = (r.value === p.algoName));
document.querySelectorAll('.algo-card').forEach(c => c.classList.toggle('active', c.dataset.algo === p.algoName));
Object.assign(trackParams.bass, p.bass);
Object.assign(trackParams.lead, p.lead);
Object.assign(trackParams.pad, p.pad);
Object.assign(masterState, p.master);
for (const k of ['bass','lead','pad']) syncTrackUI(k);
syncMasterUI();
if (p.fixedSwmd) {
try {
currentSwmd = swmdParse(p.fixedSwmd);
currentPhaseIndex = 0;
if (currentSwmd.meta['default-phase']) {
const dp = currentSwmd.phases.findIndex(ph => ph.name === currentSwmd.meta['default-phase']);
if (dp >= 0) currentPhaseIndex = dp;
}
patternMode = 'fixed';
const btn = $('pattern-toggle');
if (btn) { btn.className = 'pattern-toggle fixed'; btn.textContent = '⚙ Fixed'; }
} catch (e) {
console.error('[applyFactoryPreset] swmd parse error', e);
currentSwmd = null;
patternMode = 'generative';
}
} else {
currentSwmd = null;
patternMode = 'generative';
const btn = $('pattern-toggle');
if (btn) { btn.className = 'pattern-toggle generative'; btn.textContent = '⚙ Generativ'; }
}
generate();
if (liveSynths) {
setTrackModel(liveSynths, 'bass', trackParams.bass.model, trackParams.bass);
setTrackModel(liveSynths, 'lead', trackParams.lead.model, trackParams.lead);
setTrackModel(liveSynths, 'pad', trackParams.pad.model, trackParams.pad);
applyAllToGraph(liveSynths);
}
setStatus('✓ Factory preset loaded: ' + name + (p.fixedSwmd ? ' (fixed)' : ' (generative)'));
}
- [x] Step 20.2: Verify in browser
Open the app. Click any of the existing 6 factory presets. They should still play (fall through the else branch — generative mode, no fixedSwmd yet).
- [x] Step 20.3: Commit
git add synthwave_surfer.html
git commit -m "feat: extend applyFactoryPreset to load .swmd template literal when present"
Task 21: Curated preset — Miami Nights (multi-phase)¶
Files:
- Modify: synthwave_surfer.html — extend 'Miami Nights' entry in FACTORY_PRESETS (line 1987)
- [x] Step 21.1: Add
fixedSwmdfield to Miami Nights
Find the closing }, of the 'Miami Nights' entry (the line that ends with master: { ... } }) and add a fixedSwmd: \...`` field before it. Replace:
'Miami Nights': {
tag: 'Outrun · classic', algoName: 'outrun', bpm: 120, mode: 'pentatonic_minor', seed: 1986,
bass: { ... },
lead: { ... },
pad: { ... },
master: { reverbSize: 0.6, reverbWet: 0.5, delayTime: 0.55, delayFB: 0.45, delayWet: 0.45, sidechain: 0.75, tapeSat: 0.35, masterGain: 0.8 }
},
with (keeping the existing bass/lead/pad/master, adding mode: 'aeolian' and fixedSwmd):
'Miami Nights': {
tag: 'Outrun · classic', algoName: 'outrun', bpm: 120, mode: 'aeolian', seed: 1986,
bass: { model: 'stratos', volume: 0.7, detune: 0, attack: 0.005, decay: 0.25, sustain: 0.7, release: 0.3, cutoff: 0.5, resonance: 0.3, fenvAmount: 0.4, fenvDecay: 0.3, character: 0.5, lfoTarget: 'pwm', lfoRate: 0.15, lfoDepth: 0.4, arp: 'off', send: false },
lead: { model: 'crystal', volume: 0.45, detune: 0, attack: 0.01, decay: 0.4, sustain: 0.25, release: 1.0, cutoff: 0.7, resonance: 0.2, fenvAmount: 0.3, fenvDecay: 0.5, character: 0.6, lfoTarget: 'off', lfoRate: 0.3, lfoDepth: 0.2, arp: 'off', send: true },
pad: { model: 'vapor', volume: 0.4, detune: 0, attack: 1.2, decay: 0.5, sustain: 0.85, release: 2.5, cutoff: 0.45, resonance: 0.15, fenvAmount: 0.3, fenvDecay: 0.6, character: 0.8, lfoTarget: 'filter', lfoRate: 0.08, lfoDepth: 0.3, arp: 'off', send: true },
master: { reverbSize: 0.6, reverbWet: 0.5, delayTime: 0.55, delayFB: 0.45, delayWet: 0.45, sidechain: 0.75, tapeSat: 0.35, masterGain: 0.8 },
fixedSwmd: `---
synthwave-surfer: "1.0"
name: Miami Nights
algo: outrun
bpm: 120
mode: aeolian
pattern-mode: fixed
bass-root: 48
lead-root: 60
mood: nostalgic
energy: high
default-phase: pumping
---
## Phase: calm
### Bass · stratos
### Settings
| Param | Value |
|-------|-------|
| volume | 0.50 |
| character | 0.40 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 0 | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ |
### Lead · crystal
### Settings
| Param | Value |
|-------|-------|
| volume | 0.30 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 4 | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ | | | | | | | | |
| 2 | | | | | | | | | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.40 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | 5,0,2 | -2,2,4 | -3,2,4 |
### Drums
### Pattern
\`\`\`swdrum
kick: x . . . . . . . x . . . . . . .
snare: . . . . x . . . . . . . x . . .
hihat: x . x . x . x . x . x . x . x .
open: . . . . . . . . . . . . . . . .
\`\`\`
## Phase: pumping
### Bass · stratos
### Settings
| Param | Value |
|-------|-------|
| volume | 0.70 |
| character | 0.50 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 4 | | | | | | | | | | | | | | | ● | |
| 2 | | | | | ● | | | | | | ● | | | | | |
| 0 | ● | ─ | | | | | ● | ─ | ● | ─ | | | ● | ─ | | ─ |
### Lead · crystal
### Settings
| Param | Value |
|-------|-------|
| volume | 0.45 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 6 | | | | | | | ● | ─ | | | | | | | | |
| 4 | ● | ─ | ─ | ─ | | | | | ● | ─ | ─ | | | | | |
| 2 | | | | | ● | ─ | | | | | | | ● | ─ | ─ | ─ |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.40 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | 5,0,2 | -2,2,4 | -3,2,4 |
### Drums
### Pattern
\`\`\`swdrum
kick: x . . . x . . . x . . . x . . .
snare: . . . . x . . . . . . . x . . .
hihat: x . x . x . x . x . x . x . x .
open: . . . . . . . x . . . . . . . x
\`\`\`
## FX Bus
| Effect | Param | Value |
|--------|-------|-------|
| reverb | size | 0.60 |
| reverb | wet | 0.50 |
| delay | time | 0.55 |
| delay | feedback | 0.45 |
| delay | wet | 0.45 |
| sidechain | depth | 0.75 |
| tapeSat | drive | 0.35 |
| master | gain | 0.80 |
`
},
Note about template literal escaping: The triple-backticks inside the JS template literal must be escaped as \``(each backtick prefixed with`). The example above shows that. JavaScript template literals require this escaping for literal backticks within them.
- [ ] Step 21.2: Verify in browser
Open synthwave_surfer.html. Click Miami Nights in the factory row. The Pattern toggle should switch to "⚙ Fixed", the Phase Selector should show pills calm and pumping, and audio should play the pumping phase (the default). Click calm — audio should switch.
- [x] Step 21.3: Commit
git add synthwave_surfer.html
git commit -m "feat: add multi-phase fixed pattern for Miami Nights preset"
Task 22: Curated preset — Highway Cruise (multi-phase)¶
Files:
- Modify: synthwave_surfer.html — add fixedSwmd to 'Highway Cruise' (line 1994)
- [x] Step 22.1: Add
fixedSwmdfield to Highway Cruise
Find the 'Highway Cruise' entry. After the master: {...} line, add a comma and the fixedSwmd field. Use this template literal:
fixedSwmd: `---
synthwave-surfer: "1.0"
name: Highway Cruise
algo: outrun
bpm: 108
mode: aeolian
pattern-mode: fixed
bass-root: 48
lead-root: 60
mood: nostalgic
energy: medium
default-phase: cruising
---
## Phase: cruising
### Bass · stratos
### Settings
| Param | Value |
|-------|-------|
| volume | 0.65 |
| character | 0.30 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 4 | | | | | | | | | | | | | | ● | | |
| 2 | | | | | ● | ─ | | | | | ● | | | | | |
| 0 | ● | ─ | ─ | ─ | | | | | ● | ─ | | | ● | | ─ | ─ |
### Lead · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.45 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 6 | | | | | | | | | ● | ─ | ─ | ─ | | | | |
| 4 | ● | ─ | ─ | ─ | ─ | ─ | ─ | | | | | | ● | ─ | ─ | ─ |
| 2 | | | | | | | | ● | | | | | | | | |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.42 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | -3,0,2 | -2,2,4 | 0,4,5 |
### Drums
### Pattern
\`\`\`swdrum
kick: x . . . . . . . x . . . . . . .
snare: . . . . x . . . . . . . x . . .
hihat: x . . . x . . . x . . . x . . .
open: . . . . . . . . . . . . . . . .
\`\`\`
## Phase: breakdown
### Bass · stratos
### Settings
| Param | Value |
|-------|-------|
| volume | 0.45 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 0 | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ |
### Lead · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.30 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 4 | | | | | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ | | | | |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.50 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | -3,0,2 | -2,2,4 | 0,4,5 |
### Drums
### Pattern
\`\`\`swdrum
kick: x . . . . . . . . . . . . . . .
snare: . . . . . . . . . . . . . . . .
hihat: . . . . . . . . . . . . . . . .
open: . . . . . . . . . . . . . . . .
\`\`\`
## Phase: lift
### Bass · stratos
### Settings
| Param | Value |
|-------|-------|
| volume | 0.70 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 4 | | | | | | | | | | | | | | | ● | |
| 2 | | | | | ● | ─ | | | | | ● | ─ | | | | |
| 0 | ● | ─ | | | | | ● | ─ | ● | ─ | | | ● | ─ | | ─ |
### Lead · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.55 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 7 | | | | | | | ● | ─ | | | | | | | | |
| 6 | | | | | ● | ─ | | | | | | ● | ─ | ─ | | |
| 4 | ● | ─ | ─ | ─ | | | | | ● | ─ | ─ | | | | | |
| 2 | | | | | | | | | | | | | | | ● | ─ |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.42 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | -3,0,2 | -2,2,4 | 0,4,5 |
### Drums
### Pattern
\`\`\`swdrum
kick: x . . . x . . . x . . . x . . .
snare: . . . . x . . . . . . . x . . .
hihat: x . x . x . x . x . x . x . x .
open: . . . . . . . x . . . . . . . x
\`\`\`
## FX Bus
| Effect | Param | Value |
|--------|-------|-------|
| reverb | size | 0.65 |
| reverb | wet | 0.55 |
| delay | time | 0.50 |
| delay | feedback | 0.40 |
| delay | wet | 0.50 |
| sidechain | depth | 0.50 |
| tapeSat | drive | 0.40 |
| master | gain | 0.78 |
`
Also change mode: 'aeolian' (was 'aeolian' already — fine).
- [ ] Step 22.2: Verify in browser
Click Highway Cruise. Phase pills cruising, breakdown, lift should appear. Click each — audio switches.
- [x] Step 22.3: Commit
git add synthwave_surfer.html
git commit -m "feat: add multi-phase fixed pattern for Highway Cruise preset"
Task 23: Curated preset — Blade Runner Rain (multi-phase)¶
Files:
- Modify: synthwave_surfer.html — add fixedSwmd to 'Blade Runner Rain' (line 2001)
- [x] Step 23.1: Add
fixedSwmdfield
Append to the 'Blade Runner Rain' entry, after master: {...},:
fixedSwmd: `---
synthwave-surfer: "1.0"
name: Blade Runner Rain
algo: noir
bpm: 80
mode: aeolian
pattern-mode: fixed
bass-root: 48
lead-root: 60
mood: cinematic
energy: low
default-phase: calm
---
## Phase: calm
### Bass · stratos
### Settings
| Param | Value |
|-------|-------|
| volume | 0.55 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 0 | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ |
### Lead · crystal
### Settings
| Param | Value |
|-------|-------|
| volume | 0.30 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 4 | | | | | | | | | | | | | | | | |
| 2 | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ | | | | | | | | |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.55 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | 0,2,4 | -2,2,5 | -2,2,5 |
### Drums
### Pattern
\`\`\`swdrum
kick: x . . . . . . . . . . . . . . .
snare: . . . . . . . . . . . . . . . .
hihat: . . . . . . . . . . . . . . . .
open: . . . . . . . . . . . . . . . .
\`\`\`
## Phase: rising
### Bass · stratos
### Settings
| Param | Value |
|-------|-------|
| volume | 0.55 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 2 | | | | | | | | | | | | | ● | ─ | ─ | ─ |
| 0 | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | | | | |
### Lead · crystal
### Settings
| Param | Value |
|-------|-------|
| volume | 0.40 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 6 | | | | | ● | ─ | ─ | ─ | | | | | | | | |
| 4 | | | ● | ─ | | | | | | | ● | ─ | ─ | ─ | ─ | ─ |
| 2 | ● | ─ | | | | | | | ● | ─ | | | | | | |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.55 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | 0,2,4 | -2,2,5 | -2,2,5 |
### Drums
### Pattern
\`\`\`swdrum
kick: x . . . . . . . x . . . . . . .
snare: . . . . . . . . . . . . . . . .
hihat: x . . . x . . . x . . . x . . .
open: . . . . . . . . . . . . . . . .
\`\`\`
## Phase: outro
### Bass · stratos
### Settings
| Param | Value |
|-------|-------|
| volume | 0.40 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 0 | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ |
### Lead · crystal
### Settings
| Param | Value |
|-------|-------|
| volume | 0.20 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 2 | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.40 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | 0,2,4 | 0,2,4 | 0,2,4 |
### Drums
### Pattern
\`\`\`swdrum
kick: . . . . . . . . . . . . . . . .
snare: . . . . . . . . . . . . . . . .
hihat: . . . . . . . . . . . . . . . .
open: . . . . . . . . . . . . . . . .
\`\`\`
## FX Bus
| Effect | Param | Value |
|--------|-------|-------|
| reverb | size | 0.85 |
| reverb | wet | 0.75 |
| delay | time | 0.70 |
| delay | feedback | 0.55 |
| delay | wet | 0.50 |
| tapeSat | drive | 0.45 |
| master | gain | 0.75 |
`
- [ ] Step 23.2: Verify in browser
Click Blade Runner Rain. Phases calm, rising, outro should appear and switch correctly.
- [x] Step 23.3: Commit
git add synthwave_surfer.html
git commit -m "feat: add multi-phase fixed pattern for Blade Runner Rain preset"
Task 24: Curated preset — Carpenter Synth (multi-phase)¶
Files:
- Modify: synthwave_surfer.html — add fixedSwmd to 'Carpenter Synth' (line 2008). Change mode: 'phrygian' to mode: 'aeolian' for harmonic consistency with the spec.
- [x] Step 24.1: Add
fixedSwmdfield and change mode to aeolian
Find mode: 'phrygian' in the Carpenter Synth entry and change to mode: 'aeolian'. Then append fixedSwmd:
fixedSwmd: `---
synthwave-surfer: "1.0"
name: Carpenter Synth
algo: noir
bpm: 90
mode: aeolian
pattern-mode: fixed
bass-root: 48
lead-root: 60
mood: brooding
energy: medium
default-phase: tension
---
## Phase: tension
### Bass · alkali
### Settings
| Param | Value |
|-------|-------|
| volume | 0.70 |
| character | 0.60 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 0 | ● | ─ | ─ | ─ | ● | ─ | ─ | ─ | ● | ─ | ─ | ─ | ● | ─ | ─ | ─ |
### Lead · alkali
### Settings
| Param | Value |
|-------|-------|
| volume | 0.50 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 3 | | | | | ● | ─ | ─ | ─ | | | | | | | | |
| 1 | | ● | ─ | ─ | | | | | ● | ─ | ─ | ─ | | | | |
| 0 | ● | | | | | | | | | | | | ● | ─ | ─ | ─ |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.40 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | 0,2,4 | 0,2,4 | 0,2,4 |
### Drums
### Pattern
\`\`\`swdrum
kick: x . . . x . . . x . . . x . . .
snare: . . . . . . . . . . . . . . . .
hihat: . . . . . . . . . . . . . . . .
open: . . . . . . . . . . . . . . . .
\`\`\`
## Phase: release
### Bass · alkali
### Settings
| Param | Value |
|-------|-------|
| volume | 0.55 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 0 | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ |
### Lead · alkali
### Settings
| Param | Value |
|-------|-------|
| volume | 0.45 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 4 | | | | | | | | | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ |
| 2 | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ | | | | | | | | |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.45 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | -3,0,2 | -2,2,4 | 0,2,4 |
### Drums
### Pattern
\`\`\`swdrum
kick: x . . . . . . . x . . . . . . .
snare: . . . . x . . . . . . . x . . .
hihat: x . . . x . . . x . . . x . . .
open: . . . . . . . . . . . . . . . .
\`\`\`
## FX Bus
| Effect | Param | Value |
|--------|-------|-------|
| reverb | size | 0.75 |
| reverb | wet | 0.65 |
| delay | time | 0.45 |
| delay | feedback | 0.40 |
| delay | wet | 0.30 |
| tapeSat | drive | 0.50 |
| master | gain | 0.78 |
`
- [ ] Step 24.2: Verify in browser
Click Carpenter Synth. Phases tension, release should appear and switch.
- [x] Step 24.3: Commit
git add synthwave_surfer.html
git commit -m "feat: add multi-phase fixed pattern for Carpenter Synth preset"
Task 25: Curated preset — Mall Bliss (multi-phase)¶
Files:
- Modify: synthwave_surfer.html — add fixedSwmd to 'Mall Bliss' (line 2015)
- [x] Step 25.1: Add
fixedSwmdfield
Append to Mall Bliss after master: {...},:
fixedSwmd: `---
synthwave-surfer: "1.0"
name: Mall Bliss
algo: dreamwave
bpm: 100
mode: aeolian
pattern-mode: fixed
bass-root: 48
lead-root: 60
mood: dreamy
energy: medium
default-phase: floating
---
## Phase: floating
### Bass · stratos
### Settings
| Param | Value |
|-------|-------|
| volume | 0.60 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 4 | | | | ● | | | | ● | | | | ● | | | | ● |
| 2 | | ● | ─ | | | ● | ─ | | | ● | ─ | | | ● | ─ | |
| 0 | ● | | | | ● | | | | ● | | | | ● | | | |
### Lead · crystal
### Settings
| Param | Value |
|-------|-------|
| volume | 0.45 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 6 | | | | ● | ─ | ─ | ─ | | | ● | ─ | | | | | |
| 4 | ● | ─ | ─ | | | | | ● | | | | ● | ─ | ─ | ─ | |
| 2 | | | | | | | | | | | | | | | | ● |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.45 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | -3,0,2 | -2,2,5 | 0,4,5 |
### Drums
### Pattern
\`\`\`swdrum
kick: x . . . . . . . x . . . . . . .
snare: . . . . x . . . . . . . x . . .
hihat: x . x . x . x . x . x . x . x .
open: . . . . . . . . . . . . . . . .
\`\`\`
## Phase: arrival
### Bass · stratos
### Settings
| Param | Value |
|-------|-------|
| volume | 0.65 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 4 | | | | ● | | | | ● | | | | ● | | | | ● |
| 2 | | ● | ─ | | | ● | ─ | | | ● | ─ | | | ● | ─ | |
| 0 | ● | | | | ● | | | | ● | | | | ● | | | |
### Lead · crystal
### Settings
| Param | Value |
|-------|-------|
| volume | 0.55 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 7 | | | | | | | | | ● | ─ | ─ | ─ | | | | |
| 6 | | | | ● | ─ | ─ | ─ | | | | | | ● | ─ | ─ | |
| 4 | ● | ─ | ─ | | | | | | | | | | | | | ● |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.45 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | -3,0,2 | -2,2,5 | 0,4,5 |
### Drums
### Pattern
\`\`\`swdrum
kick: x . . . x . . . x . . . x . . .
snare: . . . . x . . . . . . . x . . .
hihat: x . x . x . x . x . x . x . x .
open: . . . . . . . x . . . . . . . x
\`\`\`
## FX Bus
| Effect | Param | Value |
|--------|-------|-------|
| reverb | size | 0.70 |
| reverb | wet | 0.60 |
| delay | time | 0.60 |
| delay | feedback | 0.50 |
| delay | wet | 0.50 |
| sidechain | depth | 0.55 |
| tapeSat | drive | 0.40 |
| master | gain | 0.78 |
`
- [ ] Step 25.2: Verify in browser
Click Mall Bliss. Phases floating, arrival should appear and switch correctly.
- [x] Step 25.3: Commit
git add synthwave_surfer.html
git commit -m "feat: add multi-phase fixed pattern for Mall Bliss preset"
Task 26: Curated preset — VHS Sunset (multi-phase)¶
Files:
- Modify: synthwave_surfer.html — add fixedSwmd to 'VHS Sunset' (line 2022). Change mode: 'pentatonic_minor' to mode: 'aeolian'.
- [x] Step 26.1: Add
fixedSwmdfield and change mode
Find mode: 'pentatonic_minor' and change to mode: 'aeolian'. Append fixedSwmd:
fixedSwmd: `---
synthwave-surfer: "1.0"
name: VHS Sunset
algo: dreamwave
bpm: 96
mode: aeolian
pattern-mode: fixed
bass-root: 48
lead-root: 60
mood: warm
energy: medium
default-phase: glow
---
## Phase: glow
### Bass · stratos
### Settings
| Param | Value |
|-------|-------|
| volume | 0.65 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 4 | | | | | | | ● | | | | | | | | ● | |
| 2 | | ● | | ● | | | | ● | | ● | | ● | | | | ─ |
| 0 | ● | | ─ | | ● | ─ | | | ● | | ─ | | ● | ─ | | |
### Lead · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.50 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 5 | | | | | | | ● | ─ | ─ | ─ | | | | | | |
| 4 | | | ● | ─ | ─ | ─ | | | | | ● | ─ | ─ | | | |
| 2 | ● | ─ | | | | | | | | | | | | ● | ─ | ─ |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.45 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | -3,0,2 | 5,0,2 | -2,2,4 |
### Drums
### Pattern
\`\`\`swdrum
kick: x . . . x . . . x . . . x . . .
snare: . . . . x . . . . . . . x . . .
hihat: x . x . x . x . x . x . x . x .
open: . . . . . . . . . . . . . . . .
\`\`\`
## Phase: fade
### Bass · stratos
### Settings
| Param | Value |
|-------|-------|
| volume | 0.40 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 0 | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ |
### Lead · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.30 |
### Pattern
| Grad | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|------|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|----|
| 4 | ● | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ | ─ |
### Pad · vapor
### Settings
| Param | Value |
|-------|-------|
| volume | 0.50 |
### Progression
| Bar | 1 | 2 | 3 | 4 |
|---------|------|------|------|------|
| Degrees | 0,2,4 | 0,2,4 | -3,0,2 | -3,0,2 |
### Drums
### Pattern
\`\`\`swdrum
kick: x . . . . . . . . . . . . . . .
snare: . . . . . . . . . . . . . . . .
hihat: . . . . . . . . . . . . . . . .
open: . . . . . . . . . . . . . . . .
\`\`\`
## FX Bus
| Effect | Param | Value |
|--------|-------|-------|
| reverb | size | 0.70 |
| reverb | wet | 0.60 |
| delay | time | 0.55 |
| delay | feedback | 0.50 |
| delay | wet | 0.55 |
| sidechain | depth | 0.60 |
| tapeSat | drive | 0.55 |
| master | gain | 0.76 |
`
- [ ] Step 26.2: Verify in browser
Click VHS Sunset. Phases glow, fade should appear.
- [x] Step 26.3: Commit
git add synthwave_surfer.html
git commit -m "feat: add multi-phase fixed pattern for VHS Sunset preset"
Task 27: Fix WAV export (offline transport)¶
Files:
- Modify: synthwave_surfer.html — replace exportWav() body (lines 1724-1752)
- [x] Step 27.1: Replace
exportWav()to use destructuredtransportfrom offline context
Find the existing async function exportWav() { ... } (line 1724) and replace its body:
async function exportWav() {
if (!currentState) return;
setStatus('⌛ rendering WAV...');
$('export-wav').disabled = true;
const bpm = currentState.bpm;
const algo = ALGORITHMS[currentState.algoName];
const totalSec = (algo.totalBars * 4 * 60 / bpm) + 3;
try {
const buffer = await Tone.Offline(({ transport }) => {
const offGraph = buildAudioGraph();
setTrackModel(offGraph, 'bass', trackParams.bass.model, trackParams.bass);
setTrackModel(offGraph, 'lead', trackParams.lead.model, trackParams.lead);
setTrackModel(offGraph, 'pad', trackParams.pad.model, trackParams.pad);
applyAllToGraph(offGraph);
const wasLoop = loopMode; loopMode = false;
scheduleAll(currentState.form, SCALES[currentState.mode], bpm, offGraph, algo);
loopMode = wasLoop;
transport.start(0);
}, totalSec);
const audioBuf = buffer.get ? buffer.get() : buffer;
const wavBytes = audioBufferToWav(audioBuf);
download(wavBytes, `synthwave_${currentState.algoName}_${currentState.seed}_${currentState.mode}.wav`, 'audio/wav');
setStatus('✓ WAV exported');
} catch (e) {
console.error(e); setStatus('⚠ Render error: ' + e.message, true);
} finally {
$('export-wav').disabled = false;
}
}
The change is on the Tone.Offline(...) call: ({ transport }) => { ... transport.start(0); } instead of async () => { ... Tone.Transport.start(0); }. This uses the offline transport (the one bound to Tone.Offline's context) rather than the global live transport, which is the bug.
-
[ ] Step 27.2: Verify in browser
-
Open
synthwave_surfer.html. Click Generate, then ▶ Play (verify audio works). - Click ■ Stop.
- Click ⬇ WAV. After ~5-10 seconds, a
.wavfile downloads. - Open the WAV in a media player or audio editor. It should contain the audio you heard, not silence.
-
Repeat for a Factory Preset with
fixedSwmd(e.g. Miami Nights). The exported WAV should match the fixed-pattern audio of the current phase. -
[x] Step 27.3: Commit
git add synthwave_surfer.html
git commit -m "fix: WAV export uses offline transport from Tone.Offline destructured arg"
Task 28: Final verification checklist¶
This task adds no code — it walks through the full Phase 1 acceptance criteria from the spec (§12) and verifies every success criterion.
Files: none modified.
- [ ] Step 28.1: Codec round-trip tests
Open synthwave_surfer.html?test=1. Console should show all tests passing (0 failures). If any fail, fix them before proceeding.
- [ ] Step 28.2: Each preset plays multi-phase
For each of: Miami Nights, Highway Cruise, Blade Runner Rain, Carpenter Synth, Mall Bliss, VHS Sunset:
- Click the preset chip
- Pattern toggle switches to ⚙ Fixed
- Phase selector pills appear (2-3 pills depending on preset)
- Audio plays the default phase
- Click each phase pill — audio switches to that phase
-
All notes are in C minor (no obvious wrong notes / clashes between voices)
-
[ ] Step 28.3: SWMD round-trip
-
Load a preset (e.g. Miami Nights).
- Click ⬇ SWMD — file
Miami Nights.mddownloads. - Open the file in a text editor — it should be readable Markdown with frontmatter + 2 phases + FX bus.
- Click ⬆ SWMD and pick the file you just downloaded.
-
Audio should play the same as before. Phase pills should match.
-
[ ] Step 28.4: External API smoke test
In browser console, with Miami Nights loaded and playing:
window.synthwaveSurfer.getPhases(); // → ["calm", "pumping"]
window.synthwaveSurfer.getCurrentPhase(); // → "pumping" (or "calm" if you switched)
window.synthwaveSurfer.setPhase("calm"); // → true; audio switches at next bar
const meta = window.synthwaveSurfer.getMeta();
console.log(meta.mood, meta.energy); // → "nostalgic" "high"
const unsub = window.synthwaveSurfer.onPhaseChange(p => console.log('phase →', p));
window.synthwaveSurfer.setPhase("pumping"); // logs "phase → pumping"
unsub();
window.synthwaveSurfer.setPhase("nope"); // → false; console.warn
- [ ] Step 28.5: WAV export works
Generate any state (preset or generative). Click ⬇ WAV. Open the file — must contain audio matching what you heard.
- [ ] Step 28.6: State JSON export
Generate any state. Click ⬇ State JSON. Open file — must be valid JSON with seed, patternMode, currentPhase, swmd (if fixed-mode) keys.
-
[ ] Step 28.7: Generative mode still works
-
Click ⚙ Generativ (or pick no preset).
- Click ⚙ Generate.
- Audio plays. Phase pills are hidden.
-
WAV export still works.
-
[x] Step 28.8: Update memory file
Edit claude/memory/project_synthwave_surfer.md to reflect Phase 1 complete and Phase 2 (plugin migration) as the next step. Replace any reference to the old plan filename with the new one.
- [x] Step 28.9: Final commit + tag
git add claude/memory/project_synthwave_surfer.md
git commit -m "docs: update memory — Phase 1 complete, Phase 2 plugin migration next"
git tag phase-1-foundations
Self-Review¶
1. Spec coverage: Each spec section maps to plan tasks:
| Spec section | Tasks |
|---|---|
| §3.2 Modules | 2-13 (codec), 12-13 (engine + API), 14 (form builder) |
| §3.3 Data flow | 14-15 (buildFormFromSwmd + generate branch) |
| §3.4 Generative mode preserved | 12 (engine returns null in generative), 15 (generate branch) |
| §4 .swmd format spec | 2 (frontmatter), 4 (multi-phase splitter), 5-9 (sub-parsers), 10-11 (full parse + serialize) |
| §5 Phase Engine | 12 (state + setPhase) |
| §6 External API | 13 |
| §7 Curated presets | 21-26 (one per preset) |
| §8 UI changes | 16 (toggle), 17 (SWMD buttons), 18 (state JSON), 19 (phase selector), 20 (preset hookup) |
| §10 Testing | 1 (harness), tests embedded in 2-13, 28 (manual checklist) |
| §11.1 Risks (WAV bug) | 27 |
| §12 Success criteria | 28 (verification checklist) |
All covered.
2. Placeholder scan: No "TBD", "TODO", or "implement later" present. All code blocks are complete. The only forward-reference is in Task 15 which adds a function syncPhaseSelector() { /* implemented in Task 19 */ } stub — Task 19 replaces the stub. The comment is accurate; this is not a placeholder.
3. Type consistency:
- Phase object shape { name, bassPattern, leadPattern, bassSettings, leadSettings, padSettings, padChords, drumGrid } is consistent across Tasks 10 (parser output), 11 (serializer input), 12 (engine consumes), 14 (buildFormFromSwmd consumes), 21-26 (presets use same shape via parser).
- currentSwmd shape { meta, phases: [...], fxSettings } is consistent across 10, 11, 12, 13, 17, 20.
- parsePianoRoll returns [{degree, duration} | {rest:true, duration}] — consumed by serializePianoRoll (6), buildFormFromSwmd (14, via existing transpose which works on both shapes thanks to existing code).
- Drum grid shape {kick, snare, hihat, open} each boolean[16] — consistent across 7 (parser/serializer), 17 (defaultDrumGridForAlgo).
4. Ambiguity check:
- generate() in Task 15 references syncPhaseSelector and $('export-swmd')/$('state-export') which don't exist until Tasks 17, 18, 19. Resolved by adding stub function syncPhaseSelector(){} and guarded if (exSwmd) ... in Task 15.
- The serializer in Task 11 went through one revision because the section heading depth was inconsistent between phase context and file context. Resolved by always emitting multi-phase form (## Phase: name always present), with ### Bass / ### Settings / ### Pattern consistently at level 3.
- Task 17 defaultDrumGridForAlgo is used inside buildSwmdFromCurrentState for generative-state SWMD export; multi-phase fixed presets supply their own drum grid in the embedded .swmd.
No remaining ambiguities.
5. Missing tasks: Spec §11.2 calls out three open questions all marked "Decision: …". No implementation work needed for those.
Self-review complete. Ready for execution.