Working version with music recording

main
František Špaček 2 weeks ago
parent 349074de30
commit c49b69d9a7

@ -1,3 +1,21 @@
# music-of-life
Conway's Game of Life - Frontend only
Music synthesis from Conways Game of Life
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.

@ -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);

@ -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);

@ -0,0 +1,108 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Music of Life</title>
<link rel="icon" href="musical_note.png">
<link rel="stylesheet" href="style.css">
</head>
<body>
<main>
<header>
<h1>Music of Life</h1>
<div id="status">
<span id="runState">stopped</span>
<span id="generation">Generation: 0</span>
</div>
</header>
<section class="controls">
<div class="panel primary">
<button id="startBtn">Start</button>
<button id="stepBtn">Step</button>
<button id="randomBtn">Random</button>
<button id="clearBtn">Clear</button>
<select id="presetSelect">
<option value="">Presets</option>
<option value="glider">Glider</option>
<option value="lwss">Lightweight spaceship</option>
<option value="gosper">Gosper glider gun</option>
</select>
<button id="saveBtn">Save</button>
<button id="loadBtn">Load</button>
<div class="record-controls">
<button id="recordBtn">Start Recording</button>
<button id="stopRecordBtn">Stop Recording</button>
</div>
</div>
<div class="panel secondary">
<label>
Speed
<input id="speedRange" type="range" min="50" max="2000" value="800">
<output id="speedValue"></output>
</label>
<label>
Cell size
<input id="cellSizeRange" type="range" min="4" max="64" value="24">
<output id="cellSizeValue"></output>
</label>
<label>
Base frequency
<input id="baseFrequencyRange" type="range" min="20" max="600" value="100">
<output id="baseFrequencyValue"></output>
</label>
<label>
Volume
<input id="volumeRange" type="range" min="0" max="100" value="50">
<output id="volumeValue"></output>
</label>
<label class="inline">
<input type="checkbox" id="wrapCheckbox" checked>
Wrap edges
</label>
<label>
Music mode
<select id="musicModeSelect">
<option value="additive">Additive</option>
<option value="frequencyGrid">Frequency Grid</option>
<option value="granular">Granular</option>
<option value="polyrhythm">Polyrhythm</option>
</select>
</label>
<small id="modeHint"></small>
</div>
</section>
<canvas id="lifeCanvas"></canvas>
<footer>
<small>
Click or drag to draw · Right click to erase · Space = start/stop
</small>
</footer>
</main>
<!-- order matters -->
<script src="recording.js"></script>
<script src="music.js"></script>
<script src="script.js"></script>
<script src="ui.js"></script>
</body>
</html>

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

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

@ -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();

@ -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 }
}

103
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 doesnt 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();
});
Loading…
Cancel
Save

Powered by TurnKey Linux.