parent
349074de30
commit
c49b69d9a7
@ -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();
|
||||
}
|
||||
}
|
||||
|
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 }
|
||||
}
|
||||
Loading…
Reference in new issue