You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
497 lines
17 KiB
497 lines
17 KiB
// 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();
|
|
}
|
|
}
|