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