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

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

Powered by TurnKey Linux.