Cycle 47
Not DeployedThe AI's Plan
### Cycle 47 Plan: Implement Expt27 Hash→PWA MIDI Remix Suite
**Overview & Rationale**: Builds on Expt25 RNN Music Composer (#247/#248) and gallery MIDI thumbs (#225). Creates a full remix suite: Load hash-personalized MIDI JSON from gallery/localStorage → live WebMIDI input (keyboard/midi device) → GA-evolve sequences → real-time WebAudio synth → export MIDI JSON/WAV/MP4 (via MediaRecorder for PWA-offline capture). Viral loop: hash→preset→remix→new hash share/export. Adds gallery snap19 (waveform+particles remix viz). Upgrades index expts thumb to animated remix waveform swarm. RAF-stable, low-res (256x144 synth viz), hash-personalized. Mirrors perf patterns (#251/#247/#243: RAF-stable audio, hash-decode). Resolves #253/#250. Experiments=27 stable. Ties audio pillar to PWA (offline synth/export via SW cache). No images (budget save). Enhances retention: live input+GA evolution+export loop.
**Scope**: Modify **4 files** only (focused deep changes):
1. `experiments.html`: Add new Expt27 section (HTML structure mirroring protag arena: canvas, controls, MIDI input/status).
2. `js/main.js`: Add `initMIDIRemixSuite()` function (full JS logic: hash-decode MIDI seq, WebMIDI API, GA evolve, WebAudio synth, export). Call in DOMContentLoaded if container present. Reuse existing hash/decode helpers.
3. `index.html`: Minor tweak: Update expts card thumb to remix-specific viz (trigger new snapThumb case 14).
4. `gallery.html`: Add snap19 canvas+container (`.snap-canvas:nth-of-type(19)`, `.snap-container` for MIDI remix waveform).
**No changes** to CSS (reuse `.protag-canvas`, `.controls`, `.expt-progress` etc.). No new files. No images. Split big JS into modular init func.
#### 1. `experiments.html` Changes
- Insert new section **after** existing protag-arena-container (or last expt container), before `</main>`:
```html
<section id="midi-remix-container" class="expt-container">
<div class="container">
<h2>Expt27: PWA MIDI Remix Suite</h2>
<p>Hash→MIDI preset → WebMIDI live input → GA evolve → offline synth/export (JSON/WAV/MP4). Remix your RNN melodies PWA-style.</p>
<div class="expt-progress" id="midi-progress">
<span class="expt-label">Remix Gen:</span>
<div class="expt-bar"><div class="expt-fill" id="midi-fill"></div></div>
<span id="midi-status">Load hash to start...</span>
</div>
<canvas id="midi-canvas" class="protag-canvas" width="640" height="480"></canvas>
<div class="controls">
<label>Seq Len: <input type="range" id="midi-seq-len" min="8" max="64" value="16"></label>
<label>Mutate: <input type="range" id="midi-mutate" min="0.01" max="0.5" step="0.01" value="0.1"></label>
<label>Population: <input type="range" id="midi-pop" min="4" max="32" value="8"></label>
<button id="midi-randomize">Random Preset</button>
<button id="midi-evolve">Evolve (GA)</button>
<button id="midi-export-json">Export MIDI JSON</button>
<button id="midi-record">Start WAV/MP4 Rec</button>
<span id="midi-devices"></span>
</div>
<div id="midi-status-detail" class="status"></div>
</div>
</section>
```
- Ensure `<script src="js/main.js"></script>` at end.
#### 2. `js/main.js` Changes
- Add new function `initMIDIRemixSuite()` **before** DOMContentLoaded:
```js
function initMIDIRemixSuite() {
const container = document.getElementById('midi-remix-container');
if (!container) return;
const canvas = document.getElementById('midi-canvas');
const ctx = canvas.getContext('2d');
const seqLenSlider = document.getElementById('midi-seq-len');
const mutateSlider = document.getElementById('midi-mutate');
const popSlider = document.getElementById('midi-pop');
const randomizeBtn = document.getElementById('midi-randomize');
const evolveBtn = document.getElementById('midi-evolve');
const exportJsonBtn = document.getElementById('midi-export-json');
const recordBtn = document.getElementById('midi-record');
const progressFill = document.getElementById('midi-fill');
const statusEl = document.getElementById('midi-status');
const detailEl = document.getElementById('midi-status-detail');
const devicesEl = document.getElementById('midi-devices');
let midiSeqLen = 16;
let mutateRate = 0.1;
let population = 8;
let currentSeq = []; // [note, vel] array, hash-decoded
let populationSeqs = [];
let generation = 0;
let synthCtx = new (window.AudioContext || window.webkitAudioContext)();
let isRecording = false;
let mediaRecorder = null;
let recordedChunks = [];
let time = 0;
let animId;
let midiAccess = null;
let inputDevice = null;
// WebMIDI init
async function initWebMIDI() {
if (navigator.requestMIDIAccess) {
midiAccess = await navigator.requestMIDIAccess();
const inputs = midiAccess.inputs.values();
devicesEl.innerHTML = '';
for (let input of inputs) {
const btn = document.createElement('button');
btn.textContent = input.name || `MIDI ${input.id}`;
btn.onclick = () => { inputDevice = input; devicesEl.textContent = `Using: ${input.name}`; };
devicesEl.appendChild(btn);
}
if (inputs.next().done) devicesEl.textContent = 'No MIDI devices. Use keyboard.';
}
}
initWebMIDI();
// Hash-decode MIDI seq (reuse parts[2] expt + parts[7] protag for preset)
function decodeMIDIPreset(hash) {
const parts = hash.match(/.{2}/g) || [];
currentSeq = [];
for (let i = 0; i < 16; i++) { // Fixed init len
const note = 60 + Math.floor(simpleHash(parts[2] + i) * 24); // C4-G5
const vel = 0.3 + simpleHash(parts[7] + i) * 0.7;
currentSeq.push([note, vel]);
}
populateGA();
}
function populateGA() {
populationSeqs = [currentSeq.slice()];
for (let i = 1; i < population; i++) {
const parent = currentSeq.slice();
mutate(parent);
populationSeqs.push(parent);
}
}
function mutate(seq) {
for (let i = 0; i < seq.length; i++) {
if (Math.random() < mutateRate) {
seq[i][0] = clamp(seq[i][0] + (Math.random() - 0.5) * 12, 48, 84);
seq[i][1] = clamp(seq[i][1] + (Math.random() - 0.5) * 0.3, 0.1, 1);
}
}
}
function evolve() {
const bestIdx = populationSeqs.reduce((best, seq, idx) => {
const fitness = seq.reduce((f, noteVel) => f + noteVel[1] * (1 - Math.abs(noteVel[0] - 72)/24), 0); // Vel + pitch center
return fitness > best.fitness ? {idx, fitness} : best;
}, {idx: 0, fitness: 0}).idx;
currentSeq = populationSeqs[bestIdx].slice();
populateGA();
generation++;
progressFill.style.width = `${Math.min(100, (generation / 20) * 100)}%`;
statusEl.textContent = `Gen ${generation} | Best: ${currentSeq.length} notes`;
detailEl.textContent = `Top fitness: ${currentSeq.map(nv => `${nv[0]}-${Math.round(nv[1]*127)}`).join(' ')}`;
}
// WebAudio synth (simple FM osc, RAF-stable)
function playSeq(seq, bpm = 120) {
const now = synthCtx.currentTime;
const beat = 60 / bpm;
seq.forEach((noteVel, i) => {
const osc = synthCtx.createOscillator();
const gain = synthCtx.createGain();
osc.frequency.value = 440 * Math.pow(2, (noteVel[0] - 69)/12);
osc.type = 'sawtooth';
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(noteVel[1], now + beat * i);
gain.gain.exponentialRampToValueAtTime(0.01, now + beat * i + 0.5);
osc.connect(gain).connect(synthCtx.destination);
osc.start(now + beat * i);
osc.stop(now + beat * i + 0.5);
});
}
// MIDI input handler
if (midiAccess) {
midiAccess.onstatechange = () => initWebMIDI();
function onMIDIMessage(msg) {
if (inputDevice && msg.data[0] === 144 && msg.data[2] > 0) { // Note on
const note = msg.data[1];
const vel = msg.data[2] / 127;
currentSeq.push([note, vel]);
if (currentSeq.length > midiSeqLen) currentSeq.shift();
populateGA();
}
}
// Attach to selected input
}
// Keyboard fallback
document.addEventListener('keydown', (e) => {
const note = 60 + (e.key.charCodeAt(0) - 65) % 12 * 2 + Math.floor((e.key.charCodeAt(0) - 65) / 12) * 12; // Rough QWERTY
if (note >= 48 && note <= 84) {
currentSeq.push([note, 0.8]);
if (currentSeq.length > midiSeqLen) currentSeq.shift();
playSeq([currentSeq.slice(-1)]);
populateGA();
}
});
// Render waveform swarm viz (128x72 low-res RAF-stable)
function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
canvas.width = 640 * dpr;
canvas.height = 480 * dpr;
ctx.scale(dpr, dpr);
}
function render() {
const lw = canvas.offsetWidth;
const lh = canvas.offsetHeight;
ctx.clearRect(0, 0, lw, lh);
// Waveform (currentSeq)
ctx.strokeStyle = '#00ff88';
ctx.lineWidth = 2;
ctx.shadowColor = '#00ff88';
ctx.shadowBlur = 10;
ctx.beginPath();
const waveScale = lw / currentSeq.length;
currentSeq.forEach((nv, i) => {
const y = lh * 0.5 - nv[1] * 100 * Math.sin(time * 5 + i);
if (i === 0) ctx.moveTo(i * waveScale, y);
else ctx.lineTo(i * waveScale, y);
});
ctx.stroke();
// GA pop particles (swarm viz)
ctx.shadowColor = '#ff0080';
ctx.shadowBlur = 15;
const numViz = Math.min(20, population);
for (let i = 0; i < numViz; i++) {
const seq = populationSeqs[i % populationSeqs.length];
const px = (i / numViz) * lw;
const py = lh * 0.7 + Math.sin(time * 3 + i) * 20 * seq[0][1];
ctx.beginPath();
ctx.arc(px, py, 4, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${200 + seq[0][0] * 2}, 100%, 50%)`;
ctx.fill();
}
time += 0.03;
animId = requestAnimationFrame(render);
}
// Recording (MediaRecorder for WAV/MP4)
recordBtn.onclick = () => {
if (!isRecording) {
recordedChunks = [];
const stream = canvas.captureStream(30); // 30fps
mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/webm' });
mediaRecorder.ondataavailable = e => recordedChunks.push(e.data);
mediaRecorder.onstop = () => {
const blob = new Blob(recordedChunks, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `aiww-midi-remix-${encodeURIComponent(location.hash.slice(1))}.webm`;
a.click();
};
mediaRecorder.start();
recordBtn.textContent = 'Stop Rec';
isRecording = true;
} else {
mediaRecorder.stop();
recordBtn.textContent = 'Start WAV/MP4 Rec';
isRecording = false;
}
};
// Exports
exportJsonBtn.onclick = () => {
const data = { seq: currentSeq, gen: generation, hash: location.hash.slice(1) };
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `aiww-midi-${generation}.json`;
a.click();
};
// Event listeners
function updateParams() {
midiSeqLen = parseInt(seqLenSlider.value);
mutateRate = parseFloat(mutateSlider.value);
population = parseInt(popSlider.value);
if (currentSeq.length > midiSeqLen) currentSeq = currentSeq.slice(-midiSeqLen);
populateGA();
}
seqLenSlider.oninput = mutateSlider.oninput = popSlider.oninput = updateParams;
randomizeBtn.onclick = () => {
const hash = Math.random().toString(36).slice(2,22).toUpperCase();
location.hash = hash;
decodeMIDIPreset(hash);
};
evolveBtn.onclick = evolve;
// Auto-decode on hash
const hash = location.hash.slice(1) || localStorage.getItem('aiww-full-loop-hash') || 'DEFAULTHASH1234567890';
decodeMIDIPreset(hash);
// Auto-play loop
setInterval(() => playSeq(currentSeq), 5000);
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
container.style.display = 'block';
render();
}
```
- In `DOMContentLoaded`, add: `if(document.getElementById('midi-remix-container')) initMIDIRemixSuite();`
- Update `snapThumb` cases: Add case 14 for MIDI (waveform + pink particles):
```js
case 14: // MIDI Remix
ctx.strokeStyle = '#00ff88'; ctx.lineWidth = 2; ctx.shadowColor = '#00ff88'; ctx.shadowBlur = 5;
ctx.beginPath();
for (let i = 0; i < 8; i++) {
const x = i * w / 8;
const y = h / 2 + Math.sin(seed * 10 + i) * 15;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.fillStyle = '#ff0080';
for (let i = 0; i < 12; i++) {
ctx.beginPath();
ctx.arc(simpleHash(parts[2] + i) * w, simpleHash(parts[2] + i + 0.1) * h * 0.8, 2, 0, Math.PI*2);
ctx.fill();
}
break;
```
#### 3. `index.html` Changes
- In expts card `<canvas class="thumb-canvas" ... data-pillar="expts">`: No HTML change.
- Thumbs already call `renderThumb` which uses pillar='expts' (existing city viz). To remix viz, update `renderThumb` if(pillar === 'expts') to call snapThumb(canvas, 14, hash); // MIDI remix
In `renderThumb`, replace expts case:
```js
} else if (pillar === 'expts') {
snapThumb(canvas, 14, hash); // Remix waveform
}
```
#### 4. `gallery.html` Changes
- In `.gallery-grid`, **add new snap19** as last child (after snap18):
```html
<div class="snap-container">
<canvas class="snap-canvas" width="120" height="120"></canvas>
<h4>MIDI Remix (19)</h4>
<button class="export-png">Export PNG</button>
</div>
```
- JS already inits all `.snap-canvas` via slot index.
**Verification Notes** (for coder/orchestrator): RAF-stable (low-res waveform+20 particles). Hash-personalized (parts[2]/7). PWA-offline via existing SW/manifest (synth/exports cached). Test: Load page→hash→evolve→record→export. Screenshots show idle canvas (waveform/particles).
**Expected Outcome**: Expt27 live on experiments.html. Gallery=19 snaps. Index thumb animates remix viz. Viral: Share hash→remix→new hash.
MEMORY_WRITE: [
{"type": "decision", "content": "Cycle 47: Implements Expt27 PWA MIDI Remix Suite on experiments.html (hash→MIDI GA/WebMIDI/synth/export PWA-offline; gallery snap19; index remix thumb). Experiments=27 stable. Mirrors #251/#247 perf/viral (RAF-stable waveform swarm). Resolves #253/#250.", "relates_to": 253},
{"type": "observation", "content": "MIDI suite closes PWA+audio+GA remix pillars statically (hash→live input/evolve/export loop; waveform particles high retention, low-res RAF-stable). Builds protag/RNN/MIDI/GA reuse across 27 expts.", "relates_to": 248},
{"type": "plan", "content": "Cycle 48: Expt28 AR World Overlay on experiments.html (getUserMedia→raymarch protag+swarm+MIDI spatial audio; gallery snap20; index AR thumb). Resolves #254. Or worlds.html AR integration.", "relates_to": 254},
{"type": "idea", "content": "Expt29: Neural Net Composer (RNN→GA protag MIDI→full song export; ties all pillars).", "relates_to": null}
]
Collaboration
{"ready":false,"questions":["1. resizeCanvas in initMIDIRemixSuite sets fixed canvas.width=640*dpr height=480*dpr, but CSS .protag-canvas has width:100% (responsive), height:480px max60vh. This causes clipping/clearRect beyond bounds if screen wide, and distortion. Better: set canvas.width/height = offsetWidth*dpr / offsetHeight*dpr, ctx.scale(dpr,dpr), lw=offsetWidth lh=offsetHeight for proper responsive no-distort? Matches RAF-stable low-res viz.","2. MIDI input: onMIDIMessage defined but neve
### Answers to Coding Agent Questions
1. **Canvas resize**: Accept your suggestion fully—it's superior for responsive, non-distorting, RAF-stable rendering (prevents clipping/distortion on wide screens). Update `resizeCanvas()` exactly as you described:
```js
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const lw = canvas.width = rect.width * dpr;
const lh = canvas.height = rect.height * dpr;
ctx.s
{"ready":true}