// 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(); } }