parent
c49b69d9a7
commit
42d9505a6b
@ -1,21 +1,25 @@
|
|||||||
Conway's Game of Life - Frontend only
|
# Conway's Game of Life - Frontend only
|
||||||
|
|
||||||
Files:
|
Files:
|
||||||
|
|
||||||
- index.html — main page
|
- index.html — main page
|
||||||
- style.css — styles
|
- style.css — styles
|
||||||
- script.js — game logic and UI
|
- script.js — game logic and UI
|
||||||
|
|
||||||
How to run:
|
How to run:
|
||||||
|
|
||||||
1. Open `index.html` in your browser (double-click or use a simple static file server).
|
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.
|
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.
|
3. Click or drag on the canvas to toggle cells. Adjust speed and cell size with sliders.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- No backend required.
|
- 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.
|
- 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:
|
Features:
|
||||||
|
|
||||||
- Presets: select common patterns (Glider, Lightweight spaceship, Pulsar, Gosper glider gun) from the Presets dropdown.
|
- 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).
|
- 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.
|
- 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.
|
- Speed slider: slider direction matches natural expectation: left = slower, right = faster.
|
||||||
|
|||||||
@ -1,496 +0,0 @@
|
|||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
@ -0,0 +1,244 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0f172a;
|
||||||
|
--canvas-bg: #071020;
|
||||||
|
--muted: rgba(255, 255, 255, 0.06);
|
||||||
|
--text: #e6eef8;
|
||||||
|
--accent: #7cb7ff;
|
||||||
|
--panel: rgba(255, 255, 255, 0.02);
|
||||||
|
--radius: 12px;
|
||||||
|
--gap: 14px;
|
||||||
|
--max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--max-width);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px 1fr;
|
||||||
|
gap: 20px
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
@media(min-width:980px) {
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 22px;
|
||||||
|
height: calc(100vh - 44px);
|
||||||
|
overflow: auto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#runState::before, .recording-indicator::before {
|
||||||
|
content: "● ";
|
||||||
|
}
|
||||||
|
|
||||||
|
#runState.running {
|
||||||
|
color: #7cb7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.primary {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.secondary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--muted);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px
|
||||||
|
}
|
||||||
|
|
||||||
|
label.inline {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px
|
||||||
|
}
|
||||||
|
|
||||||
|
#lifeCanvas {
|
||||||
|
width: 100%;
|
||||||
|
height: min(72vh, 900px);
|
||||||
|
background: var(--canvas-bg);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: .8
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media(max-width:980px) {
|
||||||
|
.app-grid {
|
||||||
|
grid-template-columns: 1fr
|
||||||
|
}
|
||||||
|
|
||||||
|
#lifeCanvas {
|
||||||
|
height: 60vh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Collapsible panels ---------- */
|
||||||
|
|
||||||
|
.collapsible .collapse-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible .collapsible-content {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible.collapsed .collapsible-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-toggle::before {
|
||||||
|
content: "↑ ";
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible.collapsed .collapse-toggle::before {
|
||||||
|
content: "↓ ";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Full canvas mode ---------- */
|
||||||
|
|
||||||
|
.fullscreen-toggle {
|
||||||
|
align-self: flex-end;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.canvas-only .sidebar,
|
||||||
|
body.canvas-only header,
|
||||||
|
body.canvas-only footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.canvas-only .canvas-area {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.canvas-only #lifeCanvas {
|
||||||
|
height: calc(100vh - 80px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.canvas-only .app-grid {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider alignment */
|
||||||
|
.slider-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 6em 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-control {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 3ch;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent output from resizing */
|
||||||
|
.slider-control output {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2ch;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
@ -1,179 +0,0 @@
|
|||||||
: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