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.

715 lines
22 KiB

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

Powered by TurnKey Linux.