diff --git a/README.md b/README.md
index 4c2ed2c..4793c1d 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,21 @@
-# music-of-life
+Conway's Game of Life - Frontend only
-Music synthesis from Conway’s Game of Life
\ No newline at end of file
+Files:
+- index.html — main page
+- style.css — styles
+- script.js — game logic and UI
+
+How to run:
+1. Open `index.html` in your browser (double-click or use a simple static file server).
+2. Use the controls to Start/Stop, Step, Randomize, or Clear.
+3. Click or drag on the canvas to toggle cells. Adjust speed and cell size with sliders.
+
+Notes:
+- No backend required.
+- For best results, open the page via a local server (e.g., `python3 -m http.server`) if your browser blocks local file scripts.
+
+New features:
+- Presets: select common patterns (Glider, Lightweight spaceship, Pulsar, Gosper glider gun) from the Presets dropdown.
+- Wrap edges: enable "Wrap edges" to use toroidal neighbor rules (cells at edges consider opposite edges as neighbors).
+- Save / Load: Save the current grid to your browser's localStorage (client-side only) and load it back later. The save is stored per-browser and per-machine.
+- Speed slider: slider direction matches natural expectation: left = slower, right = faster.
\ No newline at end of file
diff --git a/additive-processor.js b/additive-processor.js
new file mode 100644
index 0000000..8ea03f0
--- /dev/null
+++ b/additive-processor.js
@@ -0,0 +1,109 @@
+class AdditiveProcessor extends AudioWorkletProcessor {
+ constructor() {
+ super();
+
+ this.MAX_PARTIALS = 500;
+
+ // Sine lookup table
+ this.SINE_TABLE_SIZE = 4096;
+ this.sineTable = new Float32Array(this.SINE_TABLE_SIZE);
+ for (let i = 0; i < this.SINE_TABLE_SIZE; i++) {
+ this.sineTable[i] = Math.sin((i / this.SINE_TABLE_SIZE) * 2 * Math.PI);
+ }
+
+ // Partial state arrays
+ this.freq = new Float32Array(this.MAX_PARTIALS);
+ this.amp = new Float32Array(this.MAX_PARTIALS);
+ this.phase = new Float32Array(this.MAX_PARTIALS);
+
+ this.targetFreq = new Float32Array(this.MAX_PARTIALS);
+ this.targetAmp = new Float32Array(this.MAX_PARTIALS);
+
+ this.activeCount = 0;
+ this.targetCount = 0;
+
+ // LPF state
+ this.lastSample = 0;
+
+ this.port.onmessage = e => {
+ const { freqs, amps } = e.data;
+ let count = freqs.length;
+
+ if (count > this.MAX_PARTIALS) {
+ // Supercell aggregation
+ const factor = count / this.MAX_PARTIALS;
+ for (let i = 0; i < this.MAX_PARTIALS; i++) {
+ let sumFreq = 0, sumAmp = 0;
+ const start = Math.floor(i * factor);
+ const end = Math.floor((i + 1) * factor);
+ for (let j = start; j < end; j++) {
+ sumFreq += freqs[j];
+ sumAmp += amps[j];
+ }
+ this.targetFreq[i] = sumFreq / (end - start);
+ this.targetAmp[i] = sumAmp / (end - start);
+
+ if (this.freq[i] === 0) this.phase[i] = Math.random();
+ }
+ this.targetCount = this.MAX_PARTIALS;
+ } else {
+ // Direct assignment
+ this.targetFreq.set(freqs);
+ this.targetAmp.set(amps);
+ for (let i = 0; i < count; i++) {
+ if (this.freq[i] === 0) this.phase[i] = Math.random();
+ }
+ this.targetCount = count;
+ }
+ };
+ }
+
+ process(_, outputs) {
+ const out = outputs[0][0];
+
+ const COUNT_SMOOTH = 0.02;
+ this.activeCount += (this.targetCount - this.activeCount) * COUNT_SMOOTH;
+ const count = Math.floor(this.activeCount);
+
+ if (count === 0) {
+ out.fill(0);
+ return true;
+ }
+
+ // Dynamic smoothing
+ const AMP_SMOOTH = 0.01 * Math.min(count / 50, 1);
+ const FREQ_SMOOTH = 0.01 * Math.min(count / 50, 1);
+
+ const srInv = 1 / sampleRate;
+ const tableSize = this.SINE_TABLE_SIZE;
+
+ for (let i = 0; i < out.length; i++) {
+ let sample = 0;
+
+ for (let c = 0; c < count; c++) {
+ this.freq[c] += (this.targetFreq[c] - this.freq[c]) * FREQ_SMOOTH;
+ this.amp[c] += (this.targetAmp[c] - this.amp[c]) * AMP_SMOOTH;
+
+ // Continuous phase
+ this.phase[c] += this.freq[c] * srInv;
+ if (this.phase[c] >= 1) this.phase[c] -= 1;
+
+ const idx = Math.floor(this.phase[c] * tableSize) % tableSize;
+ sample += this.sineTable[idx] * this.amp[c];
+ }
+
+ // Only apply soft LPF if we have more than a few partials
+ const useLPF = count >= 4;
+ if (useLPF) {
+ out[i] = this.lastSample * 0.95 + sample * 0.05;
+ this.lastSample = out[i];
+ } else {
+ out[i] = sample;
+ }
+ }
+
+ return true;
+ }
+}
+
+registerProcessor('additive-processor', AdditiveProcessor);
diff --git a/granular-processor.js b/granular-processor.js
new file mode 100644
index 0000000..43c1ca7
--- /dev/null
+++ b/granular-processor.js
@@ -0,0 +1,102 @@
+class GranularProcessor extends AudioWorkletProcessor {
+ constructor() {
+ super();
+
+ this.MAX_PARTIALS = 500;
+
+ // Sine lookup table
+ this.SINE_TABLE_SIZE = 4096;
+ this.sineTable = new Float32Array(this.SINE_TABLE_SIZE);
+ for (let i = 0; i < this.SINE_TABLE_SIZE; i++) {
+ this.sineTable[i] = Math.sin((i / this.SINE_TABLE_SIZE) * 2 * Math.PI);
+ }
+
+ // Partial state
+ this.freq = new Float32Array(this.MAX_PARTIALS);
+ this.phase = new Float32Array(this.MAX_PARTIALS);
+ this.amp = new Float32Array(this.MAX_PARTIALS);
+ this.envPhase = new Float32Array(this.MAX_PARTIALS);
+ this.envDuration = new Float32Array(this.MAX_PARTIALS);
+
+ this.targetCount = 0;
+
+ this.port.onmessage = e => {
+ const { freqs, amps, durations } = e.data;
+ const count = freqs.length;
+ let factor = 1;
+ if (count > this.MAX_PARTIALS) factor = count / this.MAX_PARTIALS;
+
+ for (let i = 0; i < this.MAX_PARTIALS && i * factor < count; i++) {
+ const start = Math.floor(i * factor);
+ const end = Math.floor((i + 1) * factor);
+
+ let sumFreq = 0;
+ let sumAmp = 0;
+ let sumDur = 0;
+
+ for (let j = start; j < end; j++) {
+ sumFreq += freqs[j];
+ sumAmp += amps[j];
+ sumDur += durations[j];
+ }
+
+ const n = end - start;
+ this.freq[i] = sumFreq / n;
+ this.amp[i] = (sumAmp / n) * 1.5; // louder
+ this.envDuration[i] = sumDur / n;
+ this.envPhase[i] = 0;
+ }
+
+ this.targetCount = Math.min(count, this.MAX_PARTIALS);
+ };
+ }
+
+ process(_, outputs) {
+ const out = outputs[0][0];
+ const srInv = 1 / sampleRate;
+
+ const attackTime = 0.005; // 5ms attack
+ const decayTimeFactor = 1.2; // longer decay
+ const expFactor = 5; // controls exponential decay speed
+
+ for (let i = 0; i < out.length; i++) {
+ let sample = 0;
+
+ for (let c = 0; c < this.targetCount; c++) {
+ let env = 0;
+
+ if (this.envPhase[c] < 1) {
+ const t = this.envPhase[c] * this.envDuration[c];
+
+ if (t < attackTime) {
+ // linear attack
+ env = t / attackTime;
+ } else {
+ // exponential decay
+ const decayTime = this.envDuration[c] * decayTimeFactor;
+ const decayProgress = (t - attackTime) / Math.max(decayTime, 0.001);
+ env = Math.exp(-expFactor * decayProgress);
+ }
+
+ this.envPhase[c] += srInv / Math.max(this.envDuration[c], 0.001);
+ }
+
+ this.phase[c] += this.freq[c] * srInv;
+ if (this.phase[c] >= 1) this.phase[c] -= 1;
+
+ const idx = Math.floor(this.phase[c] * this.SINE_TABLE_SIZE) % this.SINE_TABLE_SIZE;
+ sample += this.sineTable[idx] * this.amp[c] * env;
+ }
+
+ // soft limiting
+ if (sample > 1) sample = 1;
+ else if (sample < -1) sample = -1;
+
+ out[i] = sample;
+ }
+
+ return true;
+ }
+}
+
+registerProcessor('granular-processor', GranularProcessor);
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..1b71126
--- /dev/null
+++ b/index.html
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+ Music of Life
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/music.js b/music.js
new file mode 100644
index 0000000..d5cff4c
--- /dev/null
+++ b/music.js
@@ -0,0 +1,714 @@
+// Music synthesis from Conway's Game of Life
+// Base class and multiple synthesis approaches
+
+// Create global instances
+let lifeMusic;
+//let globalAudioContext;
+
+/**
+ * Base class for all music synthesis approaches
+ */
+class MusicSynthesizer {
+ constructor() {
+ this.audioContext = new (window.AudioContext || window.webkitAudioContext)();;
+ this.isPlaying = false;
+ this.masterGain = this.audioContext.createGain();
+ this.masterGain.connect(this.audioContext.destination);
+ //this.masterGain.gain.value = 0.3; // Prevent clipping
+
+ this.baseFrequency = 100; // Hz
+
+ this.initialized = false; // track whether init was run
+
+ // recording tap (safe even if not recording)
+ if (recordingInputNode) {
+ const recorderSend = this.audioContext.createGain();
+ recorderSend.gain.value = 1;
+
+ this.masterGain.connect(recorderSend);
+ recorderSend.connect(recordingInputNode);
+ }
+ }
+
+ /**
+ * Generate music based on grid state - override in subclasses
+ */
+ generateFromGrid(grid, cols, rows) {
+ // To be implemented by subclasses
+ }
+
+ async init() {
+ // default: do nothing
+ this.initialized = true;
+ }
+
+ play() {
+ if (this.isPlaying) return;
+ this.isPlaying = true;
+ this.masterGain.gain.setValueAtTime(0.3, this.audioContext.currentTime);
+ }
+
+ stop() {
+ this.isPlaying = false;
+ this.masterGain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
+ this.cleanup();
+ }
+
+ setBaseFrequency(frequency) {
+ this.baseFrequency = frequency;
+ }
+
+ async cleanup() {
+ // Default: disconnect master gain
+ if (this.masterGain) {
+ this.masterGain.disconnect();
+ }
+ this.isPlaying = false;
+ this.initialized = false;
+
+ try {
+ this.masterGain.disconnect();
+ this.recordSource?.disconnect();
+ this.recordGain?.disconnect();
+ //this.audioContext.close();
+ } catch { }
+
+ try {
+ await this.audioContext.suspend();
+ try { await this.audioContext.close(); } catch (e) { }
+ } catch (e) { }
+ }
+
+}
+
+/**
+ * Additive Synthesis: Each live cell generates a sine oscillator
+ * Frequency based on cell position, creating harmonic textures
+ */
+class AdditiveSynthesizer extends MusicSynthesizer {
+ constructor(maxCells = 20000) {
+ super();
+
+ this.maxCells = maxCells;
+ this.lastUpdate = 0;
+
+ this.freq = new Float32Array(maxCells);
+ this.amp = new Float32Array(maxCells);
+
+ this.node = null;
+ }
+
+ async init() {
+ if (this.node) return; // already initialized
+
+ await this.audioContext.audioWorklet.addModule('additive-processor.js');
+
+ this.node = new AudioWorkletNode(
+ this.audioContext,
+ 'additive-processor',
+ {
+ numberOfInputs: 0,
+ numberOfOutputs: 1,
+ outputChannelCount: [1]
+ }
+ );
+
+ this.node.connect(this.masterGain);
+ }
+
+ calculateFrequency(x, y, cols, rows) {
+ const nx = 1 - x / (cols - 1 || 1);
+ const ny = 1 - y / (rows - 1 || 1);
+
+ const octaveOffset = ny * 3;
+ const noteOffset = nx * 12;
+
+ return this.baseFrequency * Math.pow(2, octaveOffset + noteOffset / 12);
+ }
+
+ generateFromGrid(grid, cols, rows) {
+ if (!this.isPlaying || !this.node) return;
+
+ const now = performance.now();
+ //if (now - this.lastUpdate < 25) return;
+ this.lastUpdate = now;
+
+ // Collect live cells
+ const liveCells = [];
+ for (let y = 0; y < rows; y++) {
+ for (let x = 0; x < cols; x++) {
+ if (grid[y][x]) liveCells.push({ x, y });
+ }
+ }
+
+ const count = liveCells.length;
+ //if (count === 0) return;
+
+ // --- New dynamic amplitude scaling ---
+ const MAX_TOTAL_AMP = 0.3;
+ let perCellAmp;
+
+ // Calculate density relative to grid size
+ const density = count / (cols * rows);
+
+ // Soft compression using density, preserves volume on small/medium grids
+ perCellAmp = 0.25 * Math.pow(density + 0.01, 0.25) + 0.01; // small offset to avoid 0
+ // Limit per-cell amplitude to prevent worklet clipping
+ perCellAmp = Math.min(perCellAmp, 0.03);
+
+ const freqs = [];
+ const amps = [];
+
+ for (const cell of liveCells) {
+ freqs.push(this.calculateFrequency(cell.x, cell.y, cols, rows));
+ amps.push(perCellAmp);
+ }
+
+ this.node.port.postMessage({ freqs, amps });
+ }
+
+ async play() {
+ if (this.isPlaying) return;
+
+ this.isPlaying = true;
+
+ // Ensure node exists
+ if (!this.node) {
+ await this.init();
+ }
+
+ // Restore master gain
+ this.masterGain.gain.setValueAtTime(0.3, this.audioContext.currentTime);
+ }
+
+
+ cleanup() {
+ super.cleanup();
+
+ if (this.node) {
+ this.node.disconnect();
+ this.node = null;
+ }
+ }
+}
+
+
+
+/**
+ * Frequency Grid: Map rows to frequencies, columns to amplitude modulation
+ * Creates a more melodic, wavetable-like sound
+ */
+class FrequencyGridSynthesizer extends MusicSynthesizer {
+ constructor() {
+ super();
+ this.oscillators = new Map(); // Map of row index -> { osc, gain }
+ }
+
+ generateFromGrid(grid, cols, rows) {
+ if (!this.isPlaying) return;
+
+ const activeCells = new Set();
+
+ // Find which rows have active cells
+ for (let y = 0; y < rows; y++) {
+ let rowHasLife = false;
+ for (let x = 0; x < cols; x++) {
+ if (grid[y][x]) {
+ rowHasLife = true;
+ activeCells.add(y);
+ break;
+ }
+ }
+ }
+
+ // Stop oscillators for rows that are now empty
+ for (const [rowKey, { osc, gain }] of this.oscillators) {
+ if (!activeCells.has(parseInt(rowKey))) {
+ gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
+ setTimeout(() => {
+ osc.stop();
+ }, 100);
+ this.oscillators.delete(rowKey);
+ }
+ }
+
+ // global grid density
+ const density = activeCells.size / (rows * cols);
+
+ // compensation: louder when overall density is low, neutral when medium
+ const globalBoost = Math.min(1 / Math.sqrt(density), 10);
+
+ // Create or update oscillators for rows with life
+ for (const row of activeCells) {
+ const frequency = this.baseFrequency * Math.pow(2, (1 - row / (rows - 1 || 1)) * 3);
+
+ // Count density in this row for amplitude
+ let cellCount = 0;
+ for (let x = 0; x < cols; x++) {
+ if (grid[row][x]) cellCount++;
+ }
+ const amplitude = Math.min((cellCount / cols) * globalBoost, 0.1) * 0.5;
+
+ if (!this.oscillators.has(row)) {
+ const osc = this.audioContext.createOscillator();
+ const gain = this.audioContext.createGain();
+
+ osc.type = 'triangle';
+ osc.frequency.value = frequency;
+
+ gain.gain.setValueAtTime(0, this.audioContext.currentTime);
+ gain.gain.setTargetAtTime(amplitude, this.audioContext.currentTime, 0.1);
+
+ osc.connect(gain);
+ gain.connect(this.masterGain);
+ osc.start();
+
+ this.oscillators.set(row, { osc, gain });
+ } else {
+ const { osc, gain } = this.oscillators.get(row);
+ osc.frequency.setTargetAtTime(frequency, this.audioContext.currentTime, 0.1);
+ gain.gain.setTargetAtTime(amplitude, this.audioContext.currentTime, 0.1);
+ }
+ }
+ }
+
+ cleanup() {
+ super.cleanup();
+
+ for (const [, { osc, gain }] of this.oscillators) {
+ gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
+ setTimeout(() => {
+ osc.stop();
+ }, 150);
+ }
+ this.oscillators.clear();
+ }
+}
+
+/**
+ * Granular Synthesis: Each cell generates a brief burst/grain
+ * Creates glitchy, textured, evolving sounds
+ */
+class GranularSynthesizer extends MusicSynthesizer {
+ constructor(maxCells = 20000) {
+ super();
+ this.maxCells = maxCells;
+
+ this.node = null;
+ this.lastUpdate = 0;
+ this.previousLiveCells = new Set();
+ }
+
+ async init() {
+ if (this.node) return;
+
+ await this.audioContext.audioWorklet.addModule('granular-processor.js');
+
+ this.node = new AudioWorkletNode(this.audioContext, 'granular-processor', {
+ numberOfInputs: 0,
+ numberOfOutputs: 1,
+ outputChannelCount: [1]
+ });
+
+ this.node.connect(this.masterGain);
+ }
+
+ async generateFromGrid(grid, cols, rows) {
+ if (!this.isPlaying) return;
+ if (!this.node) await this.init();
+
+ const now = performance.now();
+ const dt = this.lastUpdate ? (now - this.lastUpdate) / 1000 : 0.05;
+ this.lastUpdate = now;
+
+ const newLiveCells = new Set();
+ const triggeredCells = [];
+
+ for (let y = 0; y < rows; y++) {
+ for (let x = 0; x < cols; x++) {
+ const key = `${x},${y}`;
+ const alive = grid[y][x] === 1;
+ if (alive) newLiveCells.add(key);
+
+ if (alive && !this.previousLiveCells.has(key)) triggeredCells.push({ x, y });
+ }
+ }
+
+ //if (triggeredCells.length === 0) return;
+
+ const MAX_TOTAL_AMP = 0.5;
+ const count = triggeredCells.length;
+ const gainPerGrain = Math.min(MAX_TOTAL_AMP / Math.sqrt(count), 0.05);
+
+ const freqs = [];
+ const amps = [];
+ const durations = [];
+
+ let factor = 1;
+ if (count > this.maxCells) factor = count / this.maxCells;
+
+ for (let i = 0; i < this.maxCells && i * factor < count; i++) {
+ const start = Math.floor(i * factor);
+ const end = Math.floor((i + 1) * factor);
+
+ let sumFreq = 0;
+ for (let j = start; j < end; j++) {
+ const cell = triggeredCells[j];
+ const nx = 1 - cell.x / (cols - 1 || 1);
+ const ny = 1 - cell.y / (rows - 1 || 1);
+ sumFreq += this.baseFrequency * Math.pow(2, ny * 3 + nx);
+ }
+ const n = end - start;
+ freqs.push(sumFreq / n);
+ amps.push(gainPerGrain);
+ durations.push(Math.min(Math.max(dt, 0.01), 0.1));
+ }
+
+ this.node.port.postMessage({ freqs, amps, durations });
+ this.previousLiveCells = newLiveCells;
+ }
+
+ async play() {
+ if (this.isPlaying) return;
+ this.isPlaying = true;
+ if (!this.node) await this.init();
+ this.masterGain.gain.setValueAtTime(0.3, this.audioContext.currentTime);
+ }
+
+ stop() {
+ this.isPlaying = false;
+ this.masterGain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
+ }
+
+ cleanup() {
+ super.cleanup();
+
+ if (this.node) {
+ this.node.disconnect();
+ this.node = null;
+ }
+ this.previousLiveCells.clear();
+ }
+}
+
+
+
+/*class GridHarmonicsSynthesizer extends MusicSynthesizer {
+ constructor(maxOscillators = 50) {
+ super();
+
+ this.maxOscillators = maxOscillators;
+ this.oscillatorPool = [];
+ this.activeNotes = new Map(); // rowKey -> oscillator index
+ this.lastUpdate = 0;
+
+ // Initialize oscillators
+ this._initOscillatorPool();
+
+ // Global delay for echo
+ this.delayNode = this.audioContext.createDelay();
+ this.delayNode.delayTime.value = 0.2; // 200ms
+ this.delayGain = this.audioContext.createGain();
+ this.delayGain.gain.value = 0.2; // safe volume
+ this.delayNode.connect(this.delayGain);
+ this.delayGain.connect(this.masterGain);
+ }
+
+ _initOscillatorPool() {
+ for (let i = 0; i < this.maxOscillators; i++) {
+ const osc = this.audioContext.createOscillator();
+ const gain = this.audioContext.createGain();
+ osc.type = 'sine';
+ gain.gain.setValueAtTime(0, this.audioContext.currentTime);
+ osc.connect(gain);
+ gain.connect(this.masterGain);
+ osc.start();
+ this.oscillatorPool.push({ osc, gain, inUse: false });
+ }
+ }
+
+ generateFromGrid(grid, cols, rows) {
+ if (!this.isPlaying) return;
+
+ const now = performance.now();
+ if (now - this.lastUpdate < 25) return; // throttle ~40Hz
+ this.lastUpdate = now;
+
+ // Track active rows
+ const activeRows = new Map();
+
+ for (let y = 0; y < rows; y++) {
+ const liveX = [];
+ for (let x = 0; x < cols; x++) if (grid[y][x]) liveX.push(x);
+ if (!liveX.length) continue;
+ activeRows.set(y, liveX.length);
+ }
+
+ const totalActiveRows = activeRows.size || 1;
+
+ // Scale amplitude based on total active rows to avoid clipping
+ const MAX_TOTAL_AMP = 0.3;
+ const perRowGain = MAX_TOTAL_AMP / Math.sqrt(totalActiveRows);
+
+ // Assign or update oscillators
+ const usedOscillators = new Set();
+
+ for (const [row, count] of activeRows) {
+ let oscIndex;
+
+ if (this.activeNotes.has(row)) {
+ oscIndex = this.activeNotes.get(row);
+ } else {
+ // find free oscillator
+ const free = this.oscillatorPool.findIndex(o => !o.inUse);
+ if (free === -1) continue;
+ oscIndex = free;
+ this.oscillatorPool[oscIndex].inUse = true;
+ this.activeNotes.set(row, oscIndex);
+ }
+
+ usedOscillators.add(oscIndex);
+
+ const oscObj = this.oscillatorPool[oscIndex];
+ const freq = this.baseFrequency * Math.pow(2, (1 - row / rows) * 4);
+
+ // Smooth frequency change
+ oscObj.osc.frequency.setTargetAtTime(freq, this.audioContext.currentTime, 0.05);
+
+ // Smooth amplitude change
+ oscObj.gain.gain.setTargetAtTime(perRowGain, this.audioContext.currentTime, 0.05);
+ }
+
+ // Release unused oscillators
+ for (let i = 0; i < this.oscillatorPool.length; i++) {
+ if (!usedOscillators.has(i) && this.oscillatorPool[i].inUse) {
+ this.oscillatorPool[i].gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
+ this.oscillatorPool[i].inUse = false;
+ // Remove from activeNotes map
+ for (const [row, idx] of this.activeNotes.entries()) {
+ if (idx === i) this.activeNotes.delete(row);
+ }
+ }
+ }
+ }
+
+ play() {
+ if (this.isPlaying) return;
+ this.isPlaying = true;
+ this.masterGain.gain.setValueAtTime(0.3, this.audioContext.currentTime);
+ }
+
+ stop() {
+ this.isPlaying = false;
+ this.masterGain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
+
+ // Reset all oscillators
+ for (const oscObj of this.oscillatorPool) {
+ oscObj.gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
+ oscObj.inUse = false;
+ }
+ this.activeNotes.clear();
+ }
+
+ cleanup() {
+ for (const oscObj of this.oscillatorPool) {
+ oscObj.gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
+ oscObj.inUse = false;
+ }
+ this.activeNotes.clear();
+ }
+}*/
+
+class PolyrhythmSynthesizer extends MusicSynthesizer {
+ constructor(maxOscillators = 100) {
+ super();
+ this.maxOscillators = maxOscillators;
+
+ this.oscillatorPool = [];
+ this.activeNotes = new Map(); // "x,y" -> oscillator index
+ this.lastUpdate = 0;
+
+ this._initOscillatorPool();
+
+ // Global delay
+ this.delayNode = this.audioContext.createDelay();
+ this.delayNode.delayTime.value = 0.2;
+ this.delayGain = this.audioContext.createGain();
+ this.delayGain.gain.value = 0.2;
+ this.delayNode.connect(this.delayGain);
+ this.delayGain.connect(this.masterGain);
+ }
+
+ _initOscillatorPool() {
+ for (let i = 0; i < this.maxOscillators; i++) {
+ const osc = this.audioContext.createOscillator();
+ const gain = this.audioContext.createGain();
+ const pan = this.audioContext.createStereoPanner();
+
+ osc.type = 'sine';
+ gain.gain.setValueAtTime(0, this.audioContext.currentTime);
+
+ osc.connect(gain);
+ gain.connect(pan);
+ pan.connect(this.masterGain);
+
+ osc.start();
+
+ this.oscillatorPool.push({ osc, gain, pan, inUse: false });
+ }
+ }
+
+ generateFromGrid(grid, cols, rows) {
+ if (!this.isPlaying) return;
+
+ const now = performance.now();
+ //if (now - this.lastUpdate < 25) return; // throttle ~40Hz
+ this.lastUpdate = now;
+
+ const activeCells = [];
+ for (let y = 0; y < rows; y++) {
+ for (let x = 0; x < cols; x++) {
+ if (grid[y][x]) activeCells.push({ x, y });
+ }
+ }
+
+ const totalActive = activeCells.length || 1;
+ const maxTotalGain = 0.2;
+ const perCellGain = maxTotalGain / Math.sqrt(totalActive);
+
+ const usedOscillators = new Set();
+
+ for (const cell of activeCells) {
+ const key = `${cell.x},${cell.y}`;
+ let oscIndex;
+
+ if (this.activeNotes.has(key)) {
+ oscIndex = this.activeNotes.get(key);
+ } else {
+ const free = this.oscillatorPool.findIndex(o => !o.inUse);
+ if (free === -1) continue;
+ oscIndex = free;
+ this.oscillatorPool[oscIndex].inUse = true;
+ this.activeNotes.set(key, oscIndex);
+ }
+
+ usedOscillators.add(oscIndex);
+ const oscObj = this.oscillatorPool[oscIndex];
+
+ // Frequency influenced by row
+ const freq = this.baseFrequency * Math.pow(2, (1 - cell.y / rows) * 4);
+ oscObj.osc.frequency.setTargetAtTime(freq, this.audioContext.currentTime, 0.05);
+
+ // Panning influenced by column
+ const panValue = (cell.x / (cols - 1)) * 2 - 1; // -1 (left) → 1 (right)
+ oscObj.pan.pan.setTargetAtTime(panValue, this.audioContext.currentTime, 0.05);
+
+ // Smooth gain
+ oscObj.gain.gain.setTargetAtTime(perCellGain, this.audioContext.currentTime, 0.05);
+ }
+
+ // Release unused oscillators
+ for (let i = 0; i < this.oscillatorPool.length; i++) {
+ if (!usedOscillators.has(i) && this.oscillatorPool[i].inUse) {
+ this.oscillatorPool[i].gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
+ this.oscillatorPool[i].inUse = false;
+
+ // Remove from activeNotes map
+ for (const [key, idx] of this.activeNotes.entries()) {
+ if (idx === i) this.activeNotes.delete(key);
+ }
+ }
+ }
+ }
+
+ play() {
+ if (this.isPlaying) return;
+ this.isPlaying = true;
+ this.masterGain.gain.setValueAtTime(0.3, this.audioContext.currentTime);
+ }
+
+ stop() {
+ this.isPlaying = false;
+ this.masterGain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
+
+ for (const oscObj of this.oscillatorPool) {
+ oscObj.gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
+ oscObj.inUse = false;
+ }
+ this.activeNotes.clear();
+ }
+
+ cleanup() {
+ super.cleanup();
+
+ for (const oscObj of this.oscillatorPool) {
+ oscObj.gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
+ oscObj.inUse = false;
+ }
+ this.activeNotes.clear();
+ }
+}
+
+
+
+// Function to switch synthesis approaches
+async function setSynthesisMode(mode) {
+
+ /*if (!lifeMusic) {
+ window.globalAudioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }*/
+
+ const wasPlaying = !!lifeMusic && lifeMusic.isPlaying;
+
+ if (wasPlaying) {
+ lifeMusic.stop();
+ lifeMusic.cleanup();
+ }
+
+ /*try {
+ await window.globalAudioContext.suspend();
+ try { await window.globalAudioContext.close(); } catch (e) {}
+ } catch (e) { } */
+
+ // recreate global context
+ window.globalAudioContext = new (window.AudioContext || window.webkitAudioContext)();
+
+
+ switch (mode) {
+ case 'additive':
+ lifeMusic = new AdditiveSynthesizer();
+ break;
+ case 'frequencyGrid':
+ lifeMusic = new FrequencyGridSynthesizer();
+ break;
+ case 'granular':
+ lifeMusic = new GranularSynthesizer();
+ break;
+ case 'polyrhythm':
+ lifeMusic = new PolyrhythmSynthesizer();
+ break;
+ //case 'gridHarmonics':
+ // lifeMusic = new GridHarmonicsSynthesizer();
+ default:
+ lifeMusic = new AdditiveSynthesizer();
+ }
+
+ if (wasPlaying) {
+ // Resume new synth
+ await lifeMusic.init?.(); // only if init exists
+ //await lifeMusic.audioContext.resume();
+ lifeMusic.play();
+ }
+
+ // Re-attach recording if it was already recording
+ /*if (window.recording.recorder) {
+ // Stop old recording and immediately start on new synth
+ //window.recording.stop();
+ window.recording.start(lifeMusic);
+ }*/
+ // after creating lifeMusic (the new synth) and starting it if needed:
+ if (window.recording._currentRecorder) {
+ // recorder is active: start a new segment on the new synth
+ window.recording.start(lifeMusic);
+ }
+}
diff --git a/music.js.old b/music.js.old
new file mode 100644
index 0000000..2f667c4
--- /dev/null
+++ b/music.js.old
@@ -0,0 +1,496 @@
+// Music synthesis from Conway's Game of Life
+// Base class and multiple synthesis approaches
+
+/**
+ * Base class for all music synthesis approaches
+ */
+class MusicSynthesizer {
+ constructor() {
+ this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ this.isPlaying = false;
+ this.masterGain = this.audioContext.createGain();
+ this.masterGain.connect(this.audioContext.destination);
+ this.masterGain.gain.value = 0.3; // Prevent clipping
+
+ this.baseFrequency = 100; // Hz
+ }
+
+ /**
+ * Generate music based on grid state - override in subclasses
+ */
+ generateFromGrid(grid, cols, rows) {
+ // To be implemented by subclasses
+ }
+
+ play() {
+ if (this.isPlaying) return;
+ this.isPlaying = true;
+ this.masterGain.gain.setValueAtTime(0.3, this.audioContext.currentTime);
+ }
+
+ stop() {
+ this.isPlaying = false;
+ this.masterGain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
+ this.cleanup();
+ }
+
+ setBaseFrequency(frequency) {
+ this.baseFrequency = frequency;
+ }
+
+ cleanup() {
+ // To be implemented by subclasses
+ }
+}
+
+/**
+ * Additive Synthesis: Each live cell generates a sine oscillator
+ * Frequency based on cell position, creating harmonic textures
+ */
+class AdditiveSynthesizer extends MusicSynthesizer {
+ constructor(superCellThreshold = 100, superCellMaxOscillators = 100) {
+ super();
+ this.oscillators = new Map(); // Map of "key" -> { osc, gain, frequency }
+ this.lastUpdate = 0;
+ this.liveCells = new Set(); // Tracks currently alive cells as "x,y"
+ this.superCellThreshold = superCellThreshold;
+ this.superCellMaxOscillators = superCellMaxOscillators;
+ }
+
+ calculateFrequency(x, y, cols, rows) {
+ const normalizedX = 1 - x / (cols - 1 || 1);
+ const normalizedY = 1 - y / (rows - 1 || 1);
+
+ const octaveOffset = normalizedY * 3;
+ const noteOffset = normalizedX * 12;
+
+ return this.baseFrequency * Math.pow(2, octaveOffset + noteOffset / 12);
+ }
+
+ generateFromGrid(grid, cols, rows) {
+ if (!this.isPlaying) return;
+
+ // Throttle updates ~40Hz
+ const now = performance.now();
+ if (now - this.lastUpdate < 25) return;
+ this.lastUpdate = now;
+
+ const newLiveCells = new Set();
+
+ // Only iterate the sparse live cells
+ for (let y = 0; y < rows; y++) {
+ for (let x = 0; x < cols; x++) {
+ if (grid[y][x]) newLiveCells.add(`${x},${y}`);
+ }
+ }
+
+ const activeCellCount = newLiveCells.size;
+ const useSuperCells = activeCellCount > this.superCellThreshold;
+
+ let superCellSize = 1;
+ const superCells = new Map();
+
+ if (useSuperCells) {
+ // Determine super-cell size to limit total oscillators
+ superCellSize = Math.ceil(Math.sqrt((cols * rows) / this.superCellMaxOscillators));
+
+ for (const cellKey of newLiveCells) {
+ const [x, y] = cellKey.split(',').map(Number);
+ const sx = Math.floor(x / superCellSize);
+ const sy = Math.floor(y / superCellSize);
+ const sKey = `${sx},${sy}`;
+ superCells.set(sKey, (superCells.get(sKey) || 0) + 1);
+ }
+ } else {
+ // In sparse mode, each live cell becomes its own "super cell"
+ for (const cellKey of newLiveCells) {
+ superCells.set(cellKey, 1);
+ }
+ }
+
+ // Stop oscillators for empty keys
+ const toDelete = [];
+ for (const [key, { osc, gain }] of this.oscillators) {
+ if (!superCells.has(key)) {
+ gain.gain.cancelScheduledValues(this.audioContext.currentTime);
+ gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
+ osc.stop(this.audioContext.currentTime + 0.05);
+ toDelete.push(key);
+ }
+ }
+ for (const key of toDelete) this.oscillators.delete(key);
+
+ // Create/update oscillators
+ for (const [key, count] of superCells) {
+ const [sx, sy] = key.split(',').map(Number);
+
+ const cx = useSuperCells
+ ? sx * superCellSize + superCellSize / 2
+ : sx;
+ const cy = useSuperCells
+ ? sy * superCellSize + superCellSize / 2
+ : sy;
+
+ const frequency = this.calculateFrequency(cx, cy, cols, rows);
+
+ const gainValue = useSuperCells
+ ? Math.min(0.3, Math.sqrt(count / (superCellSize * superCellSize)) * 0.3)
+ : 0.3 / Math.sqrt(activeCellCount);
+
+ if (!this.oscillators.has(key)) {
+ const osc = this.audioContext.createOscillator();
+ const gain = this.audioContext.createGain();
+
+ osc.type = 'sine';
+ osc.frequency.value = frequency;
+
+ gain.gain.setValueAtTime(0, this.audioContext.currentTime);
+ gain.gain.setTargetAtTime(gainValue, this.audioContext.currentTime, 0.05);
+
+ osc.connect(gain);
+ gain.connect(this.masterGain);
+ osc.start();
+
+ this.oscillators.set(key, { osc, gain, frequency });
+ } else {
+ const { osc, gain } = this.oscillators.get(key);
+
+ if (Math.abs(osc.frequency.value - frequency) > 0.1) {
+ osc.frequency.setTargetAtTime(frequency, this.audioContext.currentTime, 0.05);
+ this.oscillators.get(key).frequency = frequency;
+ }
+ if (Math.abs(gain.gain.value - gainValue) > 0.01) {
+ gain.gain.setTargetAtTime(gainValue, this.audioContext.currentTime, 0.05);
+ }
+ }
+ }
+
+ // Update liveCells set
+ this.liveCells = newLiveCells;
+ }
+
+ cleanup() {
+ for (const [, { osc, gain }] of this.oscillators) {
+ gain.gain.cancelScheduledValues(this.audioContext.currentTime);
+ gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
+ osc.stop(this.audioContext.currentTime + 0.1);
+ }
+ this.oscillators.clear();
+ this.liveCells.clear();
+ }
+}
+
+
+
+/**
+ * Frequency Grid: Map rows to frequencies, columns to amplitude modulation
+ * Creates a more melodic, wavetable-like sound
+ */
+class FrequencyGridSynthesizer extends MusicSynthesizer {
+ constructor() {
+ super();
+ this.oscillators = new Map(); // Map of row index -> { osc, gain }
+ }
+
+ generateFromGrid(grid, cols, rows) {
+ if (!this.isPlaying) return;
+
+ const activeCells = new Set();
+
+ // Find which rows have active cells
+ for (let y = 0; y < rows; y++) {
+ let rowHasLife = false;
+ for (let x = 0; x < cols; x++) {
+ if (grid[y][x]) {
+ rowHasLife = true;
+ activeCells.add(y);
+ break;
+ }
+ }
+ }
+
+ // Stop oscillators for rows that are now empty
+ for (const [rowKey, { osc, gain }] of this.oscillators) {
+ if (!activeCells.has(parseInt(rowKey))) {
+ gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
+ setTimeout(() => {
+ osc.stop();
+ }, 100);
+ this.oscillators.delete(rowKey);
+ }
+ }
+
+ // global grid density
+ const density = activeCells.size / (rows * cols);
+
+ // compensation: louder when overall density is low, neutral when medium
+ const globalBoost = Math.min(1 / Math.sqrt(density), 10);
+
+ // Create or update oscillators for rows with life
+ for (const row of activeCells) {
+ const frequency = this.baseFrequency * Math.pow(2, (1 - row / (rows - 1 || 1)) * 3);
+
+ // Count density in this row for amplitude
+ let cellCount = 0;
+ for (let x = 0; x < cols; x++) {
+ if (grid[row][x]) cellCount++;
+ }
+ const amplitude = Math.min((cellCount / cols) * globalBoost, 0.1);
+
+ if (!this.oscillators.has(row)) {
+ const osc = this.audioContext.createOscillator();
+ const gain = this.audioContext.createGain();
+
+ osc.type = 'triangle';
+ osc.frequency.value = frequency;
+
+ gain.gain.setValueAtTime(0, this.audioContext.currentTime);
+ gain.gain.setTargetAtTime(amplitude, this.audioContext.currentTime, 0.1);
+
+ osc.connect(gain);
+ gain.connect(this.masterGain);
+ osc.start();
+
+ this.oscillators.set(row, { osc, gain });
+ } else {
+ const { osc, gain } = this.oscillators.get(row);
+ osc.frequency.setTargetAtTime(frequency, this.audioContext.currentTime, 0.1);
+ gain.gain.setTargetAtTime(amplitude, this.audioContext.currentTime, 0.1);
+ }
+ }
+ }
+
+ cleanup() {
+ for (const [, { osc, gain }] of this.oscillators) {
+ gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
+ setTimeout(() => {
+ osc.stop();
+ }, 150);
+ }
+ this.oscillators.clear();
+ }
+}
+
+/**
+ * Granular Synthesis: Each cell generates a brief burst/grain
+ * Creates glitchy, textured, evolving sounds
+ */
+class GranularSynthesizer extends MusicSynthesizer {
+ constructor() {
+ super();
+ this.liveCells = new Set(); // currently alive cells
+ }
+
+ generateFromGrid(grid, cols, rows) {
+ if (!this.isPlaying) return;
+
+ const newLiveCells = new Set();
+ const triggeredCells = [];
+
+ // Only loop over the entire grid to detect transitions
+ for (let y = 0; y < rows; y++) {
+ for (let x = 0; x < cols; x++) {
+ const key = `${x},${y}`;
+ const isAlive = grid[y][x] === 1;
+
+ if (isAlive) newLiveCells.add(key);
+
+ const wasAlive = this.liveCells.has(key);
+ if (isAlive && !wasAlive) triggeredCells.push([x, y]);
+ }
+ }
+
+ // Calculate per-grain volume based on number of grains this update
+ const grainCount = triggeredCells.length || 1; // avoid division by zero
+ const gainPerGrain = Math.min(0.2, 0.5 / Math.sqrt(grainCount));
+
+ for (const [x, y] of triggeredCells) {
+ this.triggerGrain(x, y, cols, rows, gainPerGrain);
+ }
+
+ // Update live cells set
+ this.liveCells = newLiveCells;
+ }
+
+ triggerGrain(x, y, cols, rows, gainValue) {
+ const frequency = this.baseFrequency * Math.pow(
+ 2,
+ (1 - y / (rows - 1 || 1)) * 3 + (1 - x / (cols - 1 || 1))
+ );
+ const grainDuration = 0.1; // 100ms
+
+ const osc = this.audioContext.createOscillator();
+ const gain = this.audioContext.createGain();
+ const now = this.audioContext.currentTime;
+
+ osc.frequency.value = frequency;
+ osc.type = 'sine';
+
+ // Envelope: fade in, hold, fade out
+ gain.gain.setValueAtTime(0, now);
+ gain.gain.linearRampToValueAtTime(gainValue, now + 0.01);
+ gain.gain.linearRampToValueAtTime(gainValue, now + grainDuration - 0.02);
+ gain.gain.linearRampToValueAtTime(0, now + grainDuration);
+
+ osc.connect(gain);
+ gain.connect(this.masterGain);
+ osc.start(now);
+ osc.stop(now + grainDuration);
+ }
+
+ cleanup() {
+ this.liveCells.clear();
+ }
+}
+
+class PolyrhythmSynthesizer extends MusicSynthesizer {
+ constructor() {
+ super();
+ this.oscillatorPool = [];
+ this.activeOscillators = [];
+ this.maxOscillators = 50;
+ this._initOscillatorPool();
+
+ // Simple delay for echo effect
+ this.delayNode = this.audioContext.createDelay();
+ this.delayNode.delayTime.value = 0.2; // 200ms echo
+ this.delayGain = this.audioContext.createGain();
+ this.delayGain.gain.value = 0.25; // echo volume
+ this.delayNode.connect(this.delayGain);
+ this.delayGain.connect(this.masterGain);
+ }
+
+ _initOscillatorPool() {
+ for (let i = 0; i < this.maxOscillators; i++) {
+ const osc = this.audioContext.createOscillator();
+ const gain = this.audioContext.createGain();
+ osc.type = 'sine';
+ gain.gain.setValueAtTime(0, this.audioContext.currentTime);
+ osc.connect(gain);
+ gain.connect(this.masterGain);
+ osc.start();
+ this.oscillatorPool.push({ osc, gain, inUse: false });
+ }
+ }
+
+ generateFromGrid(grid, cols, rows) {
+ if (!this.isPlaying) return;
+
+ for (let y = 0; y < rows; y++) {
+ const rowCells = [];
+ for (let x = 0; x < cols; x++) if (grid[y][x]) rowCells.push(x);
+ if (!rowCells.length) continue;
+
+ const rowLength = (rows - y) + 2;
+ for (const x of rowCells) {
+ const triggerProb = Math.min(1, 0.3 * rowLength / rowCells.length);
+ if (Math.random() < triggerProb) this.triggerNote(y, rowCells.length);
+ }
+ }
+ }
+
+ triggerNote(row, activeCellsInRow) {
+ const freeOsc = this.oscillatorPool.find(o => !o.inUse);
+ if (!freeOsc) return;
+ freeOsc.inUse = true;
+
+ const now = this.audioContext.currentTime;
+ const freq = this.baseFrequency * Math.pow(2, (1 - row / 8) * 4); // wider pitch spread
+ freeOsc.osc.frequency.setValueAtTime(freq, now);
+
+ const gainValue = Math.min(0.3, 0.5 / Math.sqrt(activeCellsInRow));
+
+ // Calculate attack and release times based on frequency
+ const noteDuration = 1 + (6 - row) * 0.2; // Longer duration for lower notes
+ const attackTime = 0.05;
+ const releaseTime = noteDuration - attackTime;
+
+ // Smoother fade-in/out with dynamic times
+ freeOsc.gain.gain.cancelScheduledValues(now);
+ freeOsc.gain.gain.setValueAtTime(0, now);
+ freeOsc.gain.gain.linearRampToValueAtTime(gainValue, now + attackTime);
+ freeOsc.gain.gain.exponentialRampToValueAtTime(0.01, now + releaseTime);
+
+ // Optional echo: duplicate into delay node
+ const delayGain = this.audioContext.createGain();
+ delayGain.gain.value = 0.2;
+ freeOsc.osc.connect(delayGain);
+ delayGain.connect(this.delayNode);
+
+ // Free oscillator after envelope finishes
+ setTimeout(() => {
+ freeOsc.inUse = false;
+ delayGain.disconnect();
+ }, noteDuration * 1000); // Adjusted to match the new decay time
+ }
+ /*triggerNote(row, activeCellsInRow) {
+ const freeOsc = this.oscillatorPool.find(o => !o.inUse);
+ if (!freeOsc) return;
+ freeOsc.inUse = true;
+
+ const now = this.audioContext.currentTime;
+ const freq = this.baseFrequency * Math.pow(2, (1 - row / 8) * 4); // wider pitch spread
+ freeOsc.osc.frequency.setValueAtTime(freq, now);
+
+ const gainValue = Math.min(0.3, 0.5 / Math.sqrt(activeCellsInRow));
+
+ // Long envelope with smoother fade in/out
+ freeOsc.gain.gain.cancelScheduledValues(now);
+ freeOsc.gain.gain.setValueAtTime(0, now);
+ freeOsc.gain.gain.linearRampToValueAtTime(gainValue, now + 0.05);
+ freeOsc.gain.gain.linearRampToValueAtTime(0.01, now + 0.4); // ~400ms note duration
+
+ // Optional echo: duplicate into delay node
+ const delayGain = this.audioContext.createGain();
+ delayGain.gain.value = 0.2;
+ freeOsc.osc.connect(delayGain);
+ delayGain.connect(this.delayNode);
+
+ // Free oscillator after envelope finishes
+ setTimeout(() => {
+ freeOsc.inUse = false;
+ delayGain.disconnect();
+ }, 450);
+ }*/
+
+ cleanup() {
+ for (const oscObj of this.oscillatorPool) {
+ oscObj.gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
+ oscObj.inUse = false;
+ }
+ }
+}
+
+
+// Create global instance using Additive Synthesis by default
+let lifeMusic = new AdditiveSynthesizer();
+
+// Function to switch synthesis approaches
+function setSynthesisMode(mode) {
+ const wasPlaying = lifeMusic.isPlaying;
+
+ if (wasPlaying) {
+ lifeMusic.stop();
+ }
+
+ switch (mode) {
+ case 'additive':
+ lifeMusic = new AdditiveSynthesizer();
+ break;
+ case 'frequencyGrid':
+ lifeMusic = new FrequencyGridSynthesizer();
+ break;
+ case 'granular':
+ lifeMusic = new GranularSynthesizer();
+ break;
+ case 'polyrhythm':
+ lifeMusic = new PolyrhythmSynthesizer();
+ break;
+ default:
+ lifeMusic = new AdditiveSynthesizer();
+ }
+
+ if (wasPlaying) {
+ lifeMusic.play();
+ }
+}
diff --git a/musical_note.png b/musical_note.png
new file mode 100644
index 0000000..cc79249
Binary files /dev/null and b/musical_note.png differ
diff --git a/recording.js b/recording.js
new file mode 100644
index 0000000..17ee41a
--- /dev/null
+++ b/recording.js
@@ -0,0 +1,461 @@
+// recording.js
+// Records per-synth segments and merges them into one WAV on stop.
+// Attach to window.recording
+
+(function () {
+ window.recording = {
+ // internal state
+ _currentRecorder: null,
+ _currentDestination: null,
+ _currentChunks: null,
+ _currentSynth: null,
+ segments: [], // array of Blob segments in order
+
+ // Start recording for the given synth.
+ // If a recorder is already running, it will stop that segment first.
+ async start(synth) {
+ if (!synth || !synth.audioContext || !synth.masterGain) {
+ console.warn("recording.start: invalid synth");
+ return;
+ }
+
+ // If currently recording on another synth, stop that segment first
+ if (this._currentRecorder) {
+ await this._stopCurrentSegment(); // pushes to segments
+ }
+
+ // create a destination inside this synth's audioContext
+ const dest = synth.audioContext.createMediaStreamDestination();
+ try {
+ // connect synth audio into this destination
+ synth.masterGain.connect(dest);
+ } catch (e) {
+ console.error("recording.start: connect failed", e);
+ return;
+ }
+
+ const mime = this._chooseMimeType();
+ const recorder = new MediaRecorder(dest.stream, mime ? { mimeType: mime } : undefined);
+
+ this._currentRecorder = recorder;
+ this._currentDestination = dest;
+ this._currentChunks = [];
+ this._currentSynth = synth;
+
+ recorder.ondataavailable = (ev) => {
+ if (ev.data && ev.data.size) this._currentChunks.push(ev.data);
+ };
+
+ recorder.onstart = () => {
+ console.log("Recording segment started (synth):", synth.constructor ? synth.constructor.name : synth);
+ };
+
+ recorder.onerror = (e) => {
+ console.error("MediaRecorder error:", e);
+ };
+
+ recorder.start();
+
+ window.recording.visualize.switch(synth.masterGain, synth.audioContext);
+ },
+
+ // Switch recording to a new synth (stop previous segment, start a new one)
+ async switchTo(synth) {
+ await this.start(synth);
+
+ },
+
+ // Stop current recorder and keep the segment, but do not merge/download yet.
+ async stopSegment() {
+ if (!this._currentRecorder) {
+ console.warn("recording.stopSegment: nothing to stop");
+ return;
+ }
+ await this._stopCurrentSegment(); // pushes to segments
+ },
+
+ // Stop all recording and merge segments into a single WAV, then trigger download.
+ // filename default: music-of-life.wav
+ async stopAndMerge(filename = `music-of-life-${Date.now()}.wav`) {
+ // stop current active segment if any
+ if (this._currentRecorder) {
+ await this._stopCurrentSegment();
+ }
+
+ if (!this.segments || this.segments.length === 0) {
+ console.warn("recording.stopAndMerge: no segments recorded");
+ return;
+ }
+
+ try {
+ // 1) decode all blobs to AudioBuffers
+ const decodedBuffers = await this._decodeAllSegments(this.segments);
+
+ // 2) choose a target sampleRate (use max to avoid upsampling many)
+ const targetSampleRate = decodedBuffers.reduce((m, b) => Math.max(m, b.sampleRate), 44100);
+
+ // 3) resample buffers that need it
+ const resampled = await Promise.all(
+ decodedBuffers.map((buf) =>
+ buf.sampleRate === targetSampleRate ? Promise.resolve(buf) : this._resampleBuffer(buf, targetSampleRate)
+ )
+ );
+
+ // 4) compute total length and channel count
+ const maxChannels = resampled.reduce((m, b) => Math.max(m, b.numberOfChannels), 1);
+ const totalLength = resampled.reduce((sum, b) => sum + b.length, 0);
+
+ // 5) create an OfflineAudioContext to render the concatenated audio
+ const offline = new OfflineAudioContext(maxChannels, totalLength, targetSampleRate);
+
+ // 6) schedule each buffer sequentially
+ let writeOffset = 0; // in sample frames
+ for (const buf of resampled) {
+ const src = offline.createBufferSource();
+ // make sure channel count equals offline destination: if buffer has fewer channels, that's OK
+ src.buffer = buf;
+ src.connect(offline.destination);
+ src.start(writeOffset / targetSampleRate);
+ writeOffset += buf.length;
+ }
+
+ // 7) render final buffer
+ const finalBuffer = await offline.startRendering();
+
+ // 8) encode to WAV (PCM16) and download
+ const wavBlob = this._audioBufferToWavBlob(finalBuffer);
+ this._downloadBlob(wavBlob, filename);
+ } catch (e) {
+ console.error("recording.stopAndMerge error:", e);
+ } finally {
+ // clear stored segments (we consumed them)
+ this.segments.length = 0;
+ }
+ },
+
+ // Internal: stop current recorder, push blob to segments
+ _stopCurrentSegment() {
+ const self = this;
+ return new Promise((resolve) => {
+ if (!self._currentRecorder) return resolve();
+
+ const currentRecorder = self._currentRecorder;
+ const chunks = self._currentChunks || [];
+ const dest = self._currentDestination;
+ const synth = self._currentSynth;
+
+ currentRecorder.onstop = async () => {
+ try {
+ const blob = new Blob(chunks, { type: currentRecorder.mimeType || "audio/webm" });
+ self.segments.push(blob);
+ console.log("Recording segment saved. segments:", self.segments.length);
+ // disconnect the synth from the destination
+ if (synth && synth.masterGain && dest) {
+ try { synth.masterGain.disconnect(dest); } catch (e) { /* ignore */ }
+ }
+ } catch (err) {
+ console.error("Error finishing segment:", err);
+ } finally {
+ // reset current recorder state
+ self._currentRecorder = null;
+ self._currentChunks = null;
+ self._currentDestination = null;
+ self._currentSynth = null;
+ resolve();
+ }
+ };
+
+ try {
+ currentRecorder.stop();
+ } catch (e) {
+ console.warn("Error stopping recorder:", e);
+ // fall back: still try to clean up
+ try {
+ if (synth && synth.masterGain && dest) synth.masterGain.disconnect(dest);
+ } catch (_) { }
+ self._currentRecorder = null;
+ self._currentChunks = null;
+ self._currentDestination = null;
+ self._currentSynth = null;
+ resolve();
+ }
+ });
+ },
+
+ // decode blobs -> AudioBuffer[] using a temporary AudioContext
+ async _decodeAllSegments(blobs) {
+ const ac = new (window.AudioContext || window.webkitAudioContext)();
+ try {
+ const buffers = [];
+ for (const b of blobs) {
+ const ab = await b.arrayBuffer();
+ // decodeAudioData returns a promise in modern browsers
+ const decoded = await ac.decodeAudioData(ab.slice(0));
+ buffers.push(decoded);
+ }
+ return buffers;
+ } finally {
+ // close decode context
+ try { ac.close(); } catch (e) { }
+ }
+ },
+
+ // resample an AudioBuffer to targetSampleRate using OfflineAudioContext
+ _resampleBuffer(buffer, targetSampleRate) {
+ return new Promise(async (resolve, reject) => {
+ try {
+ const channels = buffer.numberOfChannels;
+ const duration = buffer.duration;
+ const frames = Math.ceil(duration * targetSampleRate);
+ const offline = new OfflineAudioContext(channels, frames, targetSampleRate);
+ const src = offline.createBufferSource();
+ src.buffer = buffer;
+ src.connect(offline.destination);
+ src.start(0);
+ const rendered = await offline.startRendering();
+ resolve(rendered);
+ } catch (e) {
+ reject(e);
+ }
+ });
+ },
+
+ // convert AudioBuffer to WAV Blob (16-bit PCM)
+ _audioBufferToWavBlob(buffer) {
+ const numChannels = buffer.numberOfChannels;
+ const sampleRate = buffer.sampleRate;
+ const format = 1; // PCM
+ const bitsPerSample = 16;
+
+ // interleave channels
+ const length = buffer.length * numChannels * (bitsPerSample / 8);
+ const headerLength = 44;
+ const totalLength = headerLength + length;
+ const arrayBuffer = new ArrayBuffer(totalLength);
+ const view = new DataView(arrayBuffer);
+
+ let offset = 0;
+
+ function writeString(s) {
+ for (let i = 0; i < s.length; i++) {
+ view.setUint8(offset + i, s.charCodeAt(i));
+ }
+ offset += s.length;
+ }
+
+ // write RIFF header
+ writeString("RIFF");
+ view.setUint32(offset, totalLength - 8, true); offset += 4; // file length - 8
+ writeString("WAVE");
+ writeString("fmt ");
+ view.setUint32(offset, 16, true); offset += 4; // fmt chunk length
+ view.setUint16(offset, format, true); offset += 2; // audio format (1 = PCM)
+ view.setUint16(offset, numChannels, true); offset += 2;
+ view.setUint32(offset, sampleRate, true); offset += 4;
+ view.setUint32(offset, sampleRate * numChannels * bitsPerSample / 8, true); offset += 4; // byte rate
+ view.setUint16(offset, numChannels * bitsPerSample / 8, true); offset += 2; // block align
+ view.setUint16(offset, bitsPerSample, true); offset += 2;
+ writeString("data");
+ view.setUint32(offset, totalLength - headerLength, true); offset += 4;
+
+ // write PCM samples
+ const interleaved = new Float32Array(buffer.length * numChannels);
+ // read per channel and interleave
+ for (let ch = 0; ch < numChannels; ch++) {
+ const channelData = buffer.getChannelData(ch);
+ for (let i = 0; i < channelData.length; i++) {
+ interleaved[i * numChannels + ch] = channelData[i];
+ }
+ }
+
+ // write samples as 16-bit PCM
+ let index = 0;
+ for (let i = 0; i < interleaved.length; i++, index += 2) {
+ let s = Math.max(-1, Math.min(1, interleaved[i]));
+ s = s < 0 ? s * 0x8000 : s * 0x7fff;
+ view.setInt16(offset + index, s, true);
+ }
+
+ const wavBlob = new Blob([view], { type: "audio/wav" });
+ return wavBlob;
+ },
+
+ _downloadBlob(blob, filename) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.style.display = "none";
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ setTimeout(() => {
+ URL.revokeObjectURL(url);
+ a.remove();
+ }, 1000);
+ },
+
+ _chooseMimeType() {
+ // prefer webm/opus if available
+ if (MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported("audio/webm;codecs=opus")) {
+ return "audio/webm;codecs=opus";
+ }
+ if (MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported("audio/ogg")) {
+ return "audio/ogg";
+ }
+ return null;
+ }
+ };
+})();
+
+
+// --- Timer & Waveform visualization ---
+(function () {
+ let analyser = null;
+ let dataArray = null;
+ let animationId = null;
+ let timerInterval = null;
+ let elapsedTime = 0; // total elapsed seconds
+ let lastUpdate = 0; // for pausing/resuming
+
+ const waveformCanvas = document.createElement('canvas');
+ waveformCanvas.width = 400;
+ waveformCanvas.height = 80;
+ waveformCanvas.style.border = '1px solid #ccc';
+ waveformCanvas.style.display = 'block';
+ waveformCanvas.style.marginTop = '6px';
+ document.body.appendChild(waveformCanvas);
+ const ctx = waveformCanvas.getContext('2d');
+
+ const timerDisplay = document.createElement('div');
+ timerDisplay.style.color = '#cfe7ff';
+ timerDisplay.style.margin = '4px 0';
+ timerDisplay.textContent = '00:00';
+ document.body.appendChild(timerDisplay);
+
+ function ensureAnalyser(audioContext) {
+ if (analyser && analyser.source) {
+ try { analyser.source.disconnect(analyser); } catch { }
+ }
+ analyser = null;
+ if (!analyser) {
+ analyser = audioContext.createAnalyser();
+ analyser.fftSize = 2048;
+ dataArray = new Uint8Array(analyser.fftSize);
+ }
+ }
+
+ function attachSynth(synthNode, audioContext) {
+ if (!synthNode) return;
+ ensureAnalyser(audioContext);
+
+ // Disconnect old node if needed
+ if (synthNode !== analyser.source) {
+ try { synthNode.disconnect(analyser); } catch { }
+ try { synthNode.disconnect(audioContext.destination); } catch { }
+ synthNode.connect(analyser);
+ analyser.connect(audioContext.destination); // pass-through
+ analyser.source = synthNode; // track current node
+ }
+ }
+
+ function drawWaveform() {
+ if (!analyser) return;
+ analyser.getByteTimeDomainData(dataArray);
+
+ ctx.fillStyle = '#071020';
+ ctx.fillRect(0, 0, waveformCanvas.width, waveformCanvas.height);
+
+ ctx.lineWidth = 2;
+ ctx.strokeStyle = '#4fd1c5';
+ ctx.beginPath();
+
+ const sliceWidth = waveformCanvas.width / dataArray.length;
+ let x = 0;
+ const midY = waveformCanvas.height / 2;
+
+ for (let i = 0; i < dataArray.length; i++) {
+ // scale v from [0..255] to [-1..1]
+ const v = (dataArray[i] - 128) / 128;
+ // scale to canvas height, almost full height
+ const y = midY + v * midY * 0.95; // 0.95 to avoid touching edges
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ x += sliceWidth;
+ }
+ ctx.stroke();
+
+ animationId = requestAnimationFrame(drawWaveform);
+ }
+
+
+ function startTimer() {
+ lastUpdate = Date.now();
+ if (timerInterval) return; // already running
+
+ timerInterval = setInterval(() => {
+ const now = Date.now();
+ elapsedTime += (now - lastUpdate) / 1000;
+ lastUpdate = now;
+
+ const mins = String(Math.floor(elapsedTime / 60)).padStart(2, '0');
+ const secs = String(Math.floor(elapsedTime % 60)).padStart(2, '0');
+ const tenths = Math.floor((elapsedTime % 1) * 10);
+ timerDisplay.textContent = `${mins}:${secs}.${tenths}`;
+ }, 100);
+ }
+
+ function pauseTimer() {
+ if (timerInterval) {
+ clearInterval(timerInterval);
+ timerInterval = null;
+ }
+ }
+
+ function resetTimer() {
+ pauseTimer();
+ elapsedTime = 0;
+ lastUpdate = 0;
+ timerDisplay.textContent = '00:00.0';
+ }
+
+ // Expose hooks
+ window.recording.visualize = {
+ start(synthNode, audioContext) {
+ attachSynth(synthNode, audioContext);
+ if (!animationId) drawWaveform();
+ startTimer();
+ },
+ pause() {
+ pauseTimer();
+ },
+ resume() {
+ lastUpdate = Date.now();
+ startTimer();
+ },
+ stop() {
+ cancelAnimationFrame(animationId);
+ animationId = null;
+ if (analyser && analyser.source) {
+ try { analyser.source.disconnect(analyser); } catch { }
+ }
+ analyser = null;
+ dataArray = null;
+ resetTimer();
+ ctx.clearRect(0, 0, waveformCanvas.width, waveformCanvas.height);
+ },
+ attachSynth,
+ switch(synthNode, audioContext) {
+ cancelAnimationFrame(animationId);
+ animationId = null;
+ if (analyser && analyser.source) {
+ try { analyser.source.disconnect(analyser); } catch { }
+ }
+ analyser = null;
+ dataArray = null;
+ ctx.clearRect(0, 0, waveformCanvas.width, waveformCanvas.height);
+
+ attachSynth(synthNode, audioContext);
+ if (!animationId) drawWaveform();
+ }
+ };
+})();
diff --git a/script.js b/script.js
new file mode 100644
index 0000000..ee12e42
--- /dev/null
+++ b/script.js
@@ -0,0 +1,433 @@
+// Simple Conway's Game of Life - frontend only
+const canvas = document.getElementById('lifeCanvas');
+const ctx = canvas.getContext('2d');
+const startBtn = document.getElementById('startBtn');
+const stepBtn = document.getElementById('stepBtn');
+const randomBtn = document.getElementById('randomBtn');
+const clearBtn = document.getElementById('clearBtn');
+const speedRange = document.getElementById('speedRange');
+const cellSizeRange = document.getElementById('cellSizeRange');
+const generationEl = document.getElementById('generation');
+const presetSelect = document.getElementById('presetSelect');
+const wrapCheckbox = document.getElementById('wrapCheckbox');
+const saveBtn = document.getElementById('saveBtn');
+const loadBtn = document.getElementById('loadBtn');
+const baseFrequencyRange = document.getElementById('baseFrequencyRange');
+const volumeRange = document.getElementById('volumeRange');
+const musicModeSelect = document.getElementById('musicModeSelect');
+
+const { startRecording, stopRecording, recordingInputNode } = window.recording;
+
+let cellSize = parseInt(cellSizeRange.value, 10);
+let cols, rows;
+let grid;
+let running = false;
+let timer = null;
+let generation = 0;
+let isMouseDown = false;
+let drawMode = 'toggle'; // 'on' 'off' 'toggle'
+let wrap = false;
+let offsetX = 0;
+let offsetY = 0;
+let centerCellX = 0; // Track the logical center cell for stable zooming
+let centerCellY = 0;
+
+let currentCell = null;
+let previousCell = null;
+
+function resizeCanvas() {
+ // fit the canvas to its displayed size
+ const rect = canvas.getBoundingClientRect();
+ canvas.width = Math.floor(rect.width);
+ canvas.height = Math.floor(rect.height);
+
+ cols = Math.floor(canvas.width / cellSize);
+ rows = Math.floor(canvas.height / cellSize);
+
+ // Calculate offsets to center the grid
+ const gridWidth = cols * cellSize;
+ const gridHeight = rows * cellSize;
+ offsetX = (canvas.width - gridWidth) / 2;
+ offsetY = (canvas.height - gridHeight) / 2;
+}
+
+function makeGrid(empty = true) {
+ const g = new Array(rows);
+ for (let y = 0; y < rows; y++) {
+ g[y] = new Array(cols).fill(0);
+ }
+ if (!empty) {
+ for (let y = 0; y < rows; y++)
+ for (let x = 0; x < cols; x++)
+ g[y][x] = Math.random() < 0.2 ? 1 : 0;
+ }
+ return g;
+}
+
+function drawGrid() {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = '#062033';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = '#7ee0ff';
+
+ for (let y = 0; y < rows; y++) {
+ for (let x = 0; x < cols; x++) {
+ if (grid[y][x]) {
+ ctx.fillRect(offsetX + x * cellSize, offsetY + y * cellSize, cellSize - 1, cellSize - 1);
+ }
+ }
+ }
+}
+
+function countNeighbors(x, y) {
+ let count = 0;
+ for (let oy = -1; oy <= 1; oy++) {
+ for (let ox = -1; ox <= 1; ox++) {
+ if (ox === 0 && oy === 0) continue;
+ let nx = x + ox;
+ let ny = y + oy;
+ if (wrap) {
+ nx = ((nx % cols) + cols) % cols;
+ ny = ((ny % rows) + rows) % rows;
+ count += grid[ny][nx];
+ } else {
+ if (nx >= 0 && nx < cols && ny >= 0 && ny < rows) {
+ count += grid[ny][nx];
+ }
+ }
+ }
+ }
+ return count;
+}
+
+function step() {
+ const next = makeGrid(true);
+ for (let y = 0; y < rows; y++) {
+ for (let x = 0; x < cols; x++) {
+ let count = countNeighbors(x, y);
+ if (grid[y][x]) {
+ next[y][x] = (count === 2 || count === 3) ? 1 : 0;
+ } else {
+ next[y][x] = (count === 3) ? 1 : 0;
+ }
+ }
+ }
+ grid = next;
+ generation++;
+ generationEl.textContent = 'Generation: ' + generation;
+ lifeMusic.generateFromGrid(grid, cols, rows);
+ drawGrid();
+}
+
+function runInterval() {
+ if (!running) return;
+ // If a timer is already scheduled, don't clear/reset it here — let the pending tick happen,
+ // the loop reads the current speed each time so new speed will take effect on the next interval
+ if (timer) return;
+
+ const loop = () => {
+ if (!running) return;
+ step();
+ const v = parseInt(speedRange.value, 10);
+ const ms = Math.max(10, Math.round(1550 - v));
+ timer = setTimeout(loop, ms);
+ };
+
+ const v = parseInt(speedRange.value, 10);
+ const ms = Math.max(10, Math.round(1550 - v));
+ timer = setTimeout(loop, ms);
+}
+
+async function start() {
+ setSynthesisMode(musicModeSelect.value);
+
+ if (!running) {
+ running = true;
+ startBtn.textContent = 'Stop';
+ runInterval();
+
+ if (!lifeMusic.initialized) {
+ await lifeMusic.init();
+ }
+
+ // Required by browser autoplay policy
+ //await lifeMusic.audioContext.resume();
+
+ lifeMusic.play();
+ lifeMusic.generateFromGrid(grid, cols, rows);
+
+ } else {
+ running = false;
+ startBtn.textContent = 'Start';
+
+ if (timer) clearInterval(timer);
+ timer = null;
+
+ lifeMusic.stop();
+ lifeMusic.generateFromGrid(grid, cols, rows);
+ }
+
+ // GUI
+ updateRunState();
+}
+
+
+function clearGrid() {
+ grid = makeGrid(true);
+ generation = 0;
+ generationEl.textContent = 'Generation: 0';
+ lifeMusic.generateFromGrid(grid, cols, rows);
+ drawGrid();
+}
+
+function randomize() {
+ grid = makeGrid(false);
+ generation = 0;
+ generationEl.textContent = 'Generation: 0';
+ drawGrid();
+}
+
+function canvasToCell(clientX, clientY) {
+ const rect = canvas.getBoundingClientRect();
+ const cx = clientX - rect.left;
+ const cy = clientY - rect.top;
+ const cellX = Math.floor((cx - offsetX) / cellSize);
+ const cellY = Math.floor((cy - offsetY) / cellSize);
+ return { cellX, cellY };
+}
+
+function cellChanged(cellX, cellY) {
+ if (currentCell === null || currentCell.x !== cellX || currentCell.y !== cellY) {
+ previousCell = currentCell;
+ currentCell = { x: cellX, y: cellY };
+ return true;
+ }
+ return false;
+}
+
+canvas.addEventListener('mousedown', (e) => {
+ isMouseDown = true;
+ const { cellX, cellY } = canvasToCell(e.clientX, e.clientY);
+ if (cellX < 0 || cellX >= cols || cellY < 0 || cellY >= rows) return;
+
+ // Only toggle on left click (button 0), right click (button 2) will erase on drag
+ if (e.button === 0) {
+ grid[cellY][cellX] = grid[cellY][cellX] ? 0 : 1;
+ drawGrid();
+ } else if (e.button === 2) {
+ grid[cellY][cellX] = 0;
+ drawGrid();
+ }
+});
+
+canvas.addEventListener('mousemove', (e) => {
+ const { cellX, cellY } = canvasToCell(e.clientX, e.clientY);
+ if (cellX < 0 || cellX >= cols || cellY < 0 || cellY >= rows) return;
+
+ let isDifferentCell = cellChanged(cellX, cellY);
+
+ if (!isMouseDown || !isDifferentCell) return;
+
+ // paint with left button, erase with right button; prevent default on right-button drag
+ if (e.buttons === 1) {
+ grid[cellY][cellX] = 1;
+ } else if (e.buttons === 2) {
+ grid[cellY][cellX] = 0;
+ }
+ drawGrid();
+});
+
+canvas.addEventListener('contextmenu', (e) => {
+ e.preventDefault();
+});
+
+canvas.addEventListener('wheel', (e) => {
+ e.preventDefault();
+
+ const minSize = parseInt(cellSizeRange.min, 10);
+ const maxSize = parseInt(cellSizeRange.max, 10);
+
+ // Calculate new cell size based on scroll direction
+ const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Scroll down = zoom out, scroll up = zoom in
+ const newCellSize = Math.max(minSize, Math.min(maxSize, Math.round(cellSize * zoomFactor)));
+
+ if (newCellSize === cellSize) return; // No change
+
+ cellSize = newCellSize;
+ cellSizeRange.value = cellSize;
+ cellSizeRange.dispatchEvent(new Event('input')); // Trigger the input event to handle zoom
+}, { passive: false });
+
+window.addEventListener('mouseup', () => { isMouseDown = false; });
+
+startBtn.addEventListener('click', start);
+stepBtn.addEventListener('click', () => { step(); });
+clearBtn.addEventListener('click', clearGrid);
+randomBtn.addEventListener('click', randomize);
+speedRange.addEventListener('input', () => { if (running) runInterval(); });
+cellSizeRange.addEventListener('input', () => {
+ const oldCols = cols;
+ const oldRows = rows;
+ const oldGrid = grid;
+
+ cellSize = parseInt(cellSizeRange.value, 10);
+ resizeCanvas(); // This recalculates cols, rows, and offsets based on new cellSize
+
+ // Create new grid with potentially different dimensions
+ const newGrid = makeGrid(true);
+
+ // Calculate offset to keep the same logical center point
+ // Use floor consistently to prevent drift
+ const offsetX = Math.floor((cols - oldCols) / 2);
+ const offsetY = Math.floor((rows - oldRows) / 2);
+
+ // Copy old grid cells to new grid, centered
+ for (let y = 0; y < oldRows; y++) {
+ for (let x = 0; x < oldCols; x++) {
+ const newX = x + offsetX;
+ const newY = y + offsetY;
+ // Only copy if the new position is within bounds
+ if (newX >= 0 && newX < cols && newY >= 0 && newY < rows) {
+ newGrid[newY][newX] = oldGrid[y][x];
+ }
+ }
+ }
+
+ grid = newGrid;
+ drawGrid();
+});
+
+wrapCheckbox.addEventListener('change', (e) => { wrap = e.target.checked; });
+
+/*const musicPlayBtn = document.getElementById('musicPlayBtn');
+const musicStopBtn = document.getElementById('musicStopBtn');
+
+musicPlayBtn.addEventListener('click', () => {
+ lifeMusic.play();
+ lifeMusic.generateFromGrid(grid, cols, rows);
+});
+
+musicStopBtn.addEventListener('click', () => {
+ lifeMusic.stop();
+ lifeMusic.generateFromGrid(grid, cols, rows);
+});*/
+
+volumeRange.addEventListener('input', (e) => {
+ const minGain = 0.001; // avoid log(0)
+ const maxGain = 1; // maximum master gain
+ const position = e.target.value / 100; // 0..1
+
+ // logarithmic mapping
+ //const gain = minGain * Math.pow(maxGain / minGain, position);
+ const gain = position;
+
+ lifeMusic.masterGain.gain.setValueAtTime(gain, lifeMusic.audioContext.currentTime);
+});
+
+
+musicModeSelect.addEventListener('change', async (e) => {
+ setSynthesisMode(e.target.value);
+ //await start();
+});
+
+presetSelect.addEventListener('change', (e) => {
+ const v = e.target.value;
+ if (!v) return;
+ applyPreset(v);
+ e.target.value = '';
+});
+
+saveBtn.addEventListener('click', () => {
+ try {
+ const payload = { cols, rows, grid, cellSize, generation };
+ localStorage.setItem('life-save', JSON.stringify(payload));
+ alert('Saved to local storage.');
+ } catch (err) { alert('Save failed: ' + err); }
+});
+
+//window.addEventListener("DOMContentLoaded", () => {
+// window.recording.init(); // no context yet
+//});
+
+
+loadBtn.addEventListener('click', () => {
+ try {
+ const data = localStorage.getItem('life-save');
+ if (!data) { alert('No saved pattern in local storage.'); return; }
+ const obj = JSON.parse(data);
+ // if dimensions differ, create new grid and center loaded pattern
+ if (obj.cols === cols && obj.rows === rows) {
+ grid = obj.grid;
+ } else {
+ const newGrid = makeGrid(true);
+ const offY = Math.floor((rows - obj.rows) / 2);
+ const offX = Math.floor((cols - obj.cols) / 2);
+ for (let y = 0; y < Math.min(rows, obj.rows); y++)
+ for (let x = 0; x < Math.min(cols, obj.cols); x++)
+ newGrid[y + Math.max(0, offY)][x + Math.max(0, offX)] = obj.grid[y][x];
+ grid = newGrid;
+ }
+ generation = obj.generation || 0;
+ generationEl.textContent = 'Generation: ' + generation;
+ drawGrid();
+ } catch (err) { alert('Load failed: ' + err); }
+});
+
+baseFrequencyRange.addEventListener('input', (e) => {
+ const newBaseFrequency = parseInt(e.target.value, 10);
+ lifeMusic.setBaseFrequency(newBaseFrequency);
+});
+
+function applyPreset(name) {
+ clearGrid();
+ const midY = Math.floor(rows / 2);
+ const midX = Math.floor(cols / 2);
+ if (name === 'glider') {
+ const p = [[0, 1, 0], [0, 0, 1], [1, 1, 1]]; // glider
+ for (let y = 0; y < p.length; y++) for (let x = 0; x < p[y].length; x++) grid[midY + y][midX + x] = p[y][x];
+ } else if (name === 'lwss') {
+ const p = [
+ [0, 1, 1, 1, 1],
+ [1, 0, 0, 0, 1],
+ [0, 0, 0, 0, 1],
+ [1, 0, 0, 1, 0]
+ ];
+ for (let y = 0; y < p.length; y++) for (let x = 0; x < p[y].length; x++) grid[midY + y][midX + x] = p[y][x];
+ } else if (name === 'gosper') {
+ // Gosper glider gun pattern hard-coded relative points
+ const pts = [
+ [0, 4], [0, 5], [1, 4], [1, 5], [10, 4], [10, 5], [10, 6], [11, 3], [11, 7], [12, 2], [12, 8], [13, 2], [13, 8], [14, 5], [15, 3], [15, 7], [16, 4], [16, 5], [16, 6], [17, 5], [20, 2], [20, 3], [20, 4], [21, 2], [21, 3], [21, 4], [22, 1], [22, 5], [24, 0], [24, 1], [24, 5], [24, 6], [34, 2], [34, 3], [35, 2], [35, 3]
+ ];
+ for (const [dx, dy] of pts) {
+ const x = midX + dx - 10;
+ const y = midY + dy - 5;
+ if (y >= 0 && y < rows && x >= 0 && x < cols) grid[y][x] = 1;
+ }
+ }
+ drawGrid();
+}
+
+function init() {
+ resizeCanvas();
+ grid = makeGrid(true);
+ centerCellX = cols / 2; // Track the logical center
+ centerCellY = rows / 2;
+ generation = 0;
+ generationEl.textContent = 'Generation: 0';
+ drawGrid();
+}
+
+window.addEventListener('resize', () => {
+ // preserve approximate pattern when resizing by copying to a new grid
+ const oldCols = cols, oldRows = rows, old = grid;
+ resizeCanvas();
+ const newGrid = makeGrid(true);
+ for (let y = 0; y < Math.min(oldRows, rows); y++)
+ for (let x = 0; x < Math.min(oldCols, cols); x++)
+ newGrid[y][x] = old[y][x];
+ grid = newGrid;
+ drawGrid();
+});
+
+wrap = wrapCheckbox.checked;
+init();
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..c2eb692
--- /dev/null
+++ b/style.css
@@ -0,0 +1,179 @@
+:root {
+ --bg: #0f172a;
+ --canvas-bg: #071020;
+ --text: #e6eef8;
+ --label: #cfe7ff;
+ --button-bg: #0b1220;
+ --footer: #9bb8d9;
+ --border: rgba(255, 255, 255, 0.06);
+}
+
+* {
+ box-sizing: border-box;
+ font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--bg);
+ color: var(--text);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+main {
+ width: 100%;
+ max-width: 1100px;
+ padding: 22px;
+}
+
+/* ---------- Header ---------- */
+
+header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-bottom: 12px;
+}
+
+h1 {
+ margin: 0;
+ font-size: 24px;
+}
+
+#status {
+ display: flex;
+ gap: 14px;
+ font-size: 13px;
+ opacity: 0.85;
+}
+
+#runState::before {
+ content: "● ";
+}
+
+#runState.running {
+ color: #7cb7ff;
+}
+
+/* ---------- Controls ---------- */
+
+.controls {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+}
+
+.panel {
+ border-radius: 10px;
+ padding: 10px;
+ border: 1px solid var(--border);
+}
+
+.panel.primary {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ background: rgba(255,255,255,0.02);
+}
+
+.panel.secondary {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ background: rgba(255,255,255,0.015);
+ min-width: 260px;
+}
+
+/* ---------- Inputs ---------- */
+
+button {
+ background: var(--button-bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ padding: 8px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+button:hover {
+ filter: brightness(1.05);
+}
+
+button:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+select,
+input[type="range"] {
+ background: var(--button-bg);
+ color: var(--text);
+}
+
+label {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 6px;
+ font-size: 14px;
+ color: var(--label);
+}
+
+label.inline {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+output {
+ font-size: 12px;
+ opacity: 0.7;
+ align-self: center;
+}
+
+#modeHint {
+ font-size: 12px;
+ opacity: 0.7;
+ line-height: 1.3;
+}
+
+/* ---------- Canvas ---------- */
+
+#lifeCanvas {
+ width: 100%;
+ height: 70vh;
+ display: block;
+ background: var(--canvas-bg);
+ border: 1px solid var(--border);
+ cursor: crosshair;
+}
+
+/* ---------- Footer ---------- */
+
+footer {
+ margin-top: 8px;
+ text-align: center;
+ color: var(--footer);
+ font-size: 12px;
+}
+
+/* ---------- Recording -------- */
+.recording-indicator {
+ margin-left: 8px;
+ font-size: 13px;
+ color: #ff5f5f;
+ animation: pulse 1.2s infinite;
+}
+
+.hidden {
+ display: none;
+}
+
+@keyframes pulse {
+ 0% { opacity: 1 }
+ 50% { opacity: 0.4 }
+ 100% { opacity: 1 }
+}
diff --git a/ui.js b/ui.js
new file mode 100644
index 0000000..19e464d
--- /dev/null
+++ b/ui.js
@@ -0,0 +1,103 @@
+const runStateEl = document.getElementById("runState");
+
+// Call this from script.js when state changes if you want
+/*window.setRunningState = function (state) {
+ running = state;
+ updateRunState();
+};*/
+
+function updateRunState() {
+ runStateEl.textContent = running ? "running" : "stopped";
+ runStateEl.classList.toggle("running", running);
+
+ startBtn.textContent = running ? "Stop" : "Start";
+ stepBtn.disabled = running;
+}
+
+// Fallback if script.js doesn’t call setRunningState
+startBtn.addEventListener("click", () => {
+ updateRunState();
+});
+
+updateRunState();
+
+
+// ---------- Live slider values ----------
+
+function bindRange(id, suffix = "") {
+ const input = document.getElementById(id);
+ const output = document.getElementById(id.replace("Range", "Value"));
+
+ const update = () => {
+ output.textContent = input.value + suffix;
+ };
+
+ input.addEventListener("input", update);
+ update();
+}
+
+bindRange("speedRange", " ms");
+bindRange("cellSizeRange", " px");
+bindRange("baseFrequencyRange", " Hz");
+bindRange("volumeRange", "%");
+
+
+// ---------- Music mode hint ----------
+
+const modeHints = {
+ additive: "Each active cell adds a tone to the mix.",
+ frequencyGrid: "Grid rows map to pitch, density to amplitude.",
+ granular: "Short sound grains triggered by cell activity.",
+ polyrhythm: "Independent rhythmic layers from cell clusters."
+};
+
+const modeSelect = document.getElementById("musicModeSelect");
+const modeHint = document.getElementById("modeHint");
+
+function updateModeHint() {
+ modeHint.textContent = modeHints[modeSelect.value] || "";
+}
+
+modeSelect.addEventListener("change", updateModeHint);
+updateModeHint();
+
+
+// ---------- Keyboard shortcuts ----------
+
+window.addEventListener("keydown", e => {
+ if (e.target.tagName === "INPUT" || e.target.tagName === "SELECT") return;
+
+ switch (e.code) {
+ case "Space":
+ e.preventDefault();
+ startBtn.click();
+ break;
+ case "KeyR":
+ document.getElementById("randomBtn").click();
+ break;
+ case "KeyC":
+ document.getElementById("clearBtn").click();
+ break;
+ }
+});
+
+// recording button handlers
+document.getElementById("recordBtn").addEventListener("click", () => {
+ if (!lifeMusic) { console.warn("no synth"); return; }
+ window.recording.start(lifeMusic);
+ window.recording.visualize.start(lifeMusic.masterGain, lifeMusic.audioContext);
+});
+
+document.getElementById("stopRecordBtn").addEventListener("click", async () => {
+ running = false;
+ startBtn.textContent = 'Start';
+
+ if (timer) clearInterval(timer);
+ timer = null;
+
+ lifeMusic.stop();
+ lifeMusic.generateFromGrid(grid, cols, rows);
+
+ await window.recording.stopAndMerge("music-of-life.wav");
+ window.recording.visualize.stop();
+});