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 + + + + + + +
+
+

Music of Life

+ +
+ stopped + Generation: 0 +
+
+ +
+
+ + + + + + + + + + +
+ + +
+ +
+ +
+ + + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + \ 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(); +});