Skip to content

Instantly share code, notes, and snippets.

@rndmcnlly
Created January 26, 2026 05:22
Show Gist options
  • Select an option

  • Save rndmcnlly/d43f70050c66da0acc47149652e7fd35 to your computer and use it in GitHub Desktop.

Select an option

Save rndmcnlly/d43f70050c66da0acc47149652e7fd35 to your computer and use it in GitHub Desktop.
a generated generative audiovisual composition
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MERIDIAN</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-deep: #0d1117;
--bg-mid: #161b22;
--grid-dim: #1f2937;
--grid-lit: #374151;
--text-dim: #6b7280;
--text-mid: #9ca3af;
--text-bright: #e5e7eb;
--ember: #f97316;
--ember-hot: #fb923c;
--heat: #ef4444;
--cold: #3b82f6;
--ice: #60a5fa;
}
body {
background: var(--bg-deep);
color: var(--text-mid);
font-family: 'Courier New', monospace;
overflow: hidden;
height: 100vh;
width: 100vw;
}
#canvas-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
#ui-layer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
pointer-events: none;
}
#ui-layer > * {
pointer-events: auto;
}
/* INITIATE SCREEN */
#initiate-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: linear-gradient(180deg, var(--bg-deep) 0%, #0a0f14 100%);
transition: opacity 0.8s ease-out;
z-index: 100;
}
#initiate-screen.hidden {
opacity: 0;
pointer-events: none;
}
#title {
font-size: clamp(2rem, 8vw, 5rem);
letter-spacing: 1.5rem;
color: var(--text-bright);
margin-bottom: 0.5rem;
font-weight: 100;
text-indent: 1.5rem;
}
#subtitle {
font-size: clamp(0.6rem, 2vw, 0.9rem);
letter-spacing: 0.5rem;
color: var(--text-dim);
margin-bottom: 3rem;
text-indent: 0.5rem;
}
#initiate-btn {
padding: 1rem 3rem;
font-size: 0.9rem;
letter-spacing: 0.4rem;
text-indent: 0.4rem;
background: transparent;
border: 1px solid var(--text-dim);
color: var(--text-mid);
cursor: pointer;
transition: all 0.3s ease;
font-family: inherit;
}
#initiate-btn:hover {
border-color: var(--ember);
color: var(--ember);
box-shadow: 0 0 30px rgba(249, 115, 22, 0.2);
}
/* CONTROLS */
#controls {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 1rem;
align-items: center;
opacity: 0;
transition: opacity 0.5s ease;
}
#controls.visible {
opacity: 1;
}
.ctrl-btn {
width: 2.5rem;
height: 2.5rem;
background: rgba(22, 27, 34, 0.8);
border: 1px solid var(--grid-dim);
color: var(--text-dim);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
font-family: inherit;
font-size: 1rem;
}
.ctrl-btn:hover {
border-color: var(--text-mid);
color: var(--text-mid);
}
.ctrl-btn.active {
border-color: var(--ember);
color: var(--ember);
}
/* PROGRESS BAR */
#progress-container {
position: absolute;
bottom: 5rem;
left: 50%;
transform: translateX(-50%);
width: min(90%, 600px);
opacity: 0;
transition: opacity 0.5s ease;
}
#progress-container.visible {
opacity: 1;
}
#progress-sections {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.section-label {
font-size: 0.55rem;
letter-spacing: 0.15rem;
color: var(--text-dim);
transition: color 0.3s ease;
text-align: center;
flex: 1;
}
.section-label.active {
color: var(--ember);
}
.section-label.past {
color: var(--text-mid);
}
#progress-bar {
width: 100%;
height: 2px;
background: var(--grid-dim);
position: relative;
}
#progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--cold), var(--ember));
width: 0%;
transition: width 0.1s linear;
}
#progress-markers {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
}
.section-marker {
flex: 1;
border-right: 1px solid var(--bg-mid);
}
.section-marker:last-child {
border-right: none;
}
/* TIME DISPLAY */
#time-display {
position: absolute;
top: 2rem;
right: 2rem;
font-size: 0.8rem;
color: var(--text-dim);
opacity: 0;
transition: opacity 0.5s ease;
letter-spacing: 0.1rem;
}
#time-display.visible {
opacity: 1;
}
/* DEBUG LOG */
#debug-container {
position: absolute;
top: 2rem;
left: 2rem;
width: min(350px, 40%);
max-height: 70vh;
opacity: 0;
transition: opacity 0.3s ease;
}
#debug-container.visible {
opacity: 1;
}
#debug-toggle {
font-size: 0.65rem;
letter-spacing: 0.1rem;
color: var(--text-dim);
cursor: pointer;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
user-select: none;
}
#debug-toggle:hover {
color: var(--text-mid);
}
#debug-arrow {
transition: transform 0.3s ease;
font-size: 0.5rem;
}
#debug-arrow.open {
transform: rotate(90deg);
}
#debug-log {
background: rgba(13, 17, 23, 0.9);
border: 1px solid var(--grid-dim);
padding: 1rem;
font-size: 0.65rem;
line-height: 1.6;
max-height: 60vh;
overflow-y: auto;
display: none;
}
#debug-log.open {
display: block;
}
#debug-log::-webkit-scrollbar {
width: 4px;
}
#debug-log::-webkit-scrollbar-track {
background: var(--bg-deep);
}
#debug-log::-webkit-scrollbar-thumb {
background: var(--grid-dim);
}
.log-entry {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--grid-dim);
}
.log-entry:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.log-time {
color: var(--cold);
margin-right: 0.5rem;
}
.log-technical {
color: var(--text-dim);
}
.log-artistic {
color: var(--ember-hot);
font-style: italic;
}
.log-section {
color: var(--text-bright);
font-weight: bold;
}
/* SECTION TITLE OVERLAY */
#section-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
opacity: 0;
transition: opacity 0.8s ease;
pointer-events: none;
}
#section-overlay.visible {
opacity: 1;
}
#section-name {
font-size: clamp(1.5rem, 5vw, 3rem);
letter-spacing: 0.8rem;
color: var(--text-bright);
text-indent: 0.8rem;
font-weight: 100;
}
#section-number {
font-size: 0.7rem;
letter-spacing: 0.3rem;
color: var(--text-dim);
margin-top: 0.5rem;
}
/* Fullscreen handling */
body:fullscreen #controls,
body:-webkit-full-screen #controls {
bottom: 3rem;
}
</style>
</head>
<body>
<div id="canvas-container">
<canvas id="main-canvas"></canvas>
</div>
<div id="ui-layer">
<div id="initiate-screen">
<div id="title">MERIDIAN</div>
<div id="subtitle">A FIVE-MINUTE DESCENT AND ASCENT</div>
<button id="initiate-btn">INITIATE</button>
</div>
<div id="time-display">00:00 / 05:00</div>
<div id="debug-container">
<div id="debug-toggle">
<span id="debug-arrow">▶</span>
<span>SIGNAL TRACE</span>
</div>
<div id="debug-log"></div>
</div>
<div id="section-overlay">
<div id="section-name"></div>
<div id="section-number"></div>
</div>
<div id="progress-container">
<div id="progress-sections">
<span class="section-label" data-section="0">THE WEIGHT</span>
<span class="section-label" data-section="1">THE SIGNAL</span>
<span class="section-label" data-section="2">THE CURRENT</span>
<span class="section-label" data-section="3">THE SURGE</span>
<span class="section-label" data-section="4">THE ECHO</span>
</div>
<div id="progress-bar">
<div id="progress-fill"></div>
<div id="progress-markers">
<div class="section-marker"></div>
<div class="section-marker"></div>
<div class="section-marker"></div>
<div class="section-marker"></div>
<div class="section-marker"></div>
</div>
</div>
</div>
<div id="controls">
<button class="ctrl-btn" id="prev-btn" title="Previous Section">⏮</button>
<button class="ctrl-btn" id="play-btn" title="Play/Pause">⏸</button>
<button class="ctrl-btn" id="next-btn" title="Next Section">⏭</button>
<button class="ctrl-btn" id="log-btn" title="Toggle Log">☰</button>
<button class="ctrl-btn" id="fs-btn" title="Fullscreen">⛶</button>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════════════════════════
// MERIDIAN - A FIVE-MINUTE DESCENT AND ASCENT
// ═══════════════════════════════════════════════════════════════════════════
const CONFIG = {
DURATION: 300, // 5 minutes in seconds
BPM: 138,
SECTIONS: [
{ name: 'THE WEIGHT', start: 0, end: 80, numeral: 'I' },
{ name: 'THE SIGNAL', start: 80, end: 140, numeral: 'II' },
{ name: 'THE CURRENT', start: 140, end: 210, numeral: 'III' },
{ name: 'THE SURGE', start: 210, end: 270, numeral: 'IV' },
{ name: 'THE ECHO', start: 270, end: 300, numeral: 'V' }
],
// Harmonic content - Dm is home
SCALE: {
D2: 73.42, A2: 110.00, D3: 146.83, F3: 174.61, A3: 220.00,
C4: 261.63, D4: 293.66, E4: 329.63, F4: 349.23, G4: 392.00,
A4: 440.00, Bb4: 466.16, C5: 523.25, D5: 587.33, F5: 698.46, A5: 880.00
},
CHORDS: {
Dm: [146.83, 220.00, 293.66], // D F A
F: [174.61, 261.63, 349.23], // F A C
Gm: [196.00, 233.08, 293.66], // G Bb D
Bb: [233.08, 293.66, 349.23], // Bb D F
A: [220.00, 277.18, 329.63], // A C# E (tension!)
C: [261.63, 329.63, 392.00], // C E G
Am: [220.00, 261.63, 329.63] // A C E
}
};
// ═══════════════════════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════════════════════
const state = {
started: false,
playing: false,
currentTime: 0,
currentSection: 0,
startTimestamp: 0,
pauseTime: 0,
wakeLock: null,
constraintAmount: 0.95, // The oppression LFO
gritLevel: 0,
swarmCohesion: 0,
heat: 0
};
// ═══════════════════════════════════════════════════════════════════════════
// AUDIO ENGINE
// ═══════════════════════════════════════════════════════════════════════════
class AudioEngine {
constructor() {
this.ctx = null;
this.masterGain = null;
this.compressor = null;
// Effect sends
this.delayL = null;
this.delayR = null;
this.reverbGain = null;
this.reverbDelays = [];
// Synthesis layers
this.droneOscs = [];
this.pulseOscs = [];
this.padFilters = [];
this.swarmOscs = [];
this.swarmGains = [];
this.glitterDelays = [];
// Percussion
this.kickScheduled = [];
this.lastKickTime = 0;
this.lastSnareTime = 0;
// State
this.schedulerInterval = null;
this.lastScheduleTime = 0;
// Resonator bank (spectral ghost accumulator)
this.resonators = [];
this.resonatorFreqs = [146.83, 174.61, 220.00, 261.63, 293.66, 349.23, 440.00, 523.25];
// Wave shapers for grit
this.gritShaper = null;
}
async init() {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
// Master chain
this.compressor = this.ctx.createDynamicsCompressor();
this.compressor.threshold.value = -24;
this.compressor.knee.value = 30;
this.compressor.ratio.value = 4;
this.compressor.attack.value = 0.003;
this.compressor.release.value = 0.25;
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.8;
// Grit waveshaper
this.gritShaper = this.ctx.createWaveShaper();
this.gritShaper.curve = this.makeGritCurve(0);
this.masterGain.connect(this.gritShaper);
this.gritShaper.connect(this.compressor);
this.compressor.connect(this.ctx.destination);
// Create effect sends
this.createEffects();
// Create resonator bank
this.createResonators();
// Create synthesis layers
this.createDroneLayer();
this.createPulseLayer();
this.createPadLayer();
this.createSwarmLayer();
log('Audio context initialized at ' + this.ctx.sampleRate + 'Hz', 'technical');
log('Synthesis layers constructed: drone, pulse, pad, swarm', 'technical');
}
makeGritCurve(amount) {
const samples = 44100;
const curve = new Float32Array(samples);
for (let i = 0; i < samples; i++) {
const x = (i * 2) / samples - 1;
// Mix between clean and distorted based on amount
const clean = x;
const dirty = Math.tanh(x * (1 + amount * 10)) * (1 - amount * 0.3);
curve[i] = clean * (1 - amount) + dirty * amount;
}
return curve;
}
createEffects() {
// Stereo ping-pong delay
const delayTimeL = (60 / CONFIG.BPM) * 0.75; // dotted eighth
const delayTimeR = (60 / CONFIG.BPM) * 0.5; // eighth
this.delayL = this.ctx.createDelay(2);
this.delayR = this.ctx.createDelay(2);
this.delayL.delayTime.value = delayTimeL;
this.delayR.delayTime.value = delayTimeR;
const delayGainL = this.ctx.createGain();
const delayGainR = this.ctx.createGain();
delayGainL.gain.value = 0.35;
delayGainR.gain.value = 0.35;
const delayFilterL = this.ctx.createBiquadFilter();
const delayFilterR = this.ctx.createBiquadFilter();
delayFilterL.type = 'lowpass';
delayFilterR.type = 'lowpass';
delayFilterL.frequency.value = 2500;
delayFilterR.frequency.value = 2500;
const delayMerge = this.ctx.createChannelMerger(2);
this.delayInputL = this.ctx.createGain();
this.delayInputR = this.ctx.createGain();
this.delayInputL.gain.value = 0.4;
this.delayInputR.gain.value = 0.4;
// Routing
this.delayInputL.connect(this.delayL);
this.delayL.connect(delayFilterL);
delayFilterL.connect(delayGainL);
delayGainL.connect(this.delayR);
delayGainL.connect(delayMerge, 0, 0);
this.delayInputR.connect(this.delayR);
this.delayR.connect(delayFilterR);
delayFilterR.connect(delayGainR);
delayGainR.connect(this.delayL);
delayGainR.connect(delayMerge, 0, 1);
delayMerge.connect(this.masterGain);
// Simple reverb via feedback delay network
this.reverbInput = this.ctx.createGain();
this.reverbInput.gain.value = 0.3;
const reverbTimes = [0.031, 0.037, 0.041, 0.043, 0.053, 0.059, 0.061, 0.067];
const reverbGains = [0.7, 0.68, 0.66, 0.64, 0.62, 0.60, 0.58, 0.56];
const reverbMix = this.ctx.createGain();
reverbMix.gain.value = 0.4;
for (let i = 0; i < 8; i++) {
const delay = this.ctx.createDelay(0.1);
delay.delayTime.value = reverbTimes[i];
const gain = this.ctx.createGain();
gain.gain.value = reverbGains[i];
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 3000 - i * 200;
this.reverbInput.connect(delay);
delay.connect(filter);
filter.connect(gain);
gain.connect(delay); // Feedback
gain.connect(reverbMix);
this.reverbDelays.push({ delay, gain, filter });
}
reverbMix.connect(this.masterGain);
log('Effects: stereo delay (dotted 8th / 8th), 8-line FDN reverb', 'technical');
}
createResonators() {
this.resonatorInput = this.ctx.createGain();
this.resonatorInput.gain.value = 0.15;
const resonatorMix = this.ctx.createGain();
resonatorMix.gain.value = 0.3;
for (const freq of this.resonatorFreqs) {
const filter = this.ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = freq;
filter.Q.value = 50; // High Q for resonance
this.resonatorInput.connect(filter);
filter.connect(resonatorMix);
this.resonators.push(filter);
}
resonatorMix.connect(this.masterGain);
log('Spectral ghost accumulator: 8 resonators tuned to Dm', 'technical');
}
createDroneLayer() {
// Sub drone - felt more than heard
const droneGain = this.ctx.createGain();
droneGain.gain.value = 0;
this.droneGain = droneGain;
// D1 fundamental
const osc1 = this.ctx.createOscillator();
osc1.type = 'sine';
osc1.frequency.value = 36.71; // D1
// Second harmonic
const osc2 = this.ctx.createOscillator();
osc2.type = 'sine';
osc2.frequency.value = 73.42; // D2
const gain2 = this.ctx.createGain();
gain2.gain.value = 0.5;
osc1.connect(droneGain);
osc2.connect(gain2);
gain2.connect(droneGain);
droneGain.connect(this.masterGain);
osc1.start();
osc2.start();
this.droneOscs = [osc1, osc2];
}
createPulseLayer() {
// Machine pulse - eighth notes, the system's heartbeat
this.pulseGain = this.ctx.createGain();
this.pulseGain.gain.value = 0;
this.pulseFilter = this.ctx.createBiquadFilter();
this.pulseFilter.type = 'lowpass';
this.pulseFilter.frequency.value = 200;
this.pulseFilter.Q.value = 2;
// Sawtooth foundation
const osc = this.ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.value = 73.42; // D2
this.pulseOsc = osc;
osc.connect(this.pulseFilter);
this.pulseFilter.connect(this.pulseGain);
this.pulseGain.connect(this.masterGain);
this.pulseGain.connect(this.resonatorInput);
osc.start();
}
createPadLayer() {
// Lush pad - supersaw style
this.padGain = this.ctx.createGain();
this.padGain.gain.value = 0;
this.padFilter = this.ctx.createBiquadFilter();
this.padFilter.type = 'lowpass';
this.padFilter.frequency.value = 800;
this.padFilter.Q.value = 1;
// 7 detuned saws per voice
this.padOscs = [];
this.padOscGains = [];
const detunes = [-20, -12, -5, 0, 5, 12, 20];
const pans = [-0.8, -0.5, -0.2, 0, 0.2, 0.5, 0.8];
for (let v = 0; v < 3; v++) { // 3 voices for chord
const voiceGain = this.ctx.createGain();
voiceGain.gain.value = 0;
for (let i = 0; i < 7; i++) {
const osc = this.ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.value = 220; // Will be set per chord
osc.detune.value = detunes[i];
const pan = this.ctx.createStereoPanner();
pan.pan.value = pans[i];
const oscGain = this.ctx.createGain();
oscGain.gain.value = 0.12;
osc.connect(oscGain);
oscGain.connect(pan);
pan.connect(voiceGain);
osc.start();
this.padOscs.push({ osc, pan, voiceIndex: v });
}
voiceGain.connect(this.padFilter);
this.padOscGains.push(voiceGain);
}
this.padFilter.connect(this.padGain);
this.padGain.connect(this.masterGain);
this.padGain.connect(this.delayInputL);
this.padGain.connect(this.reverbInput);
log('Pad layer: 3-voice supersaw, 7 detuned oscillators each', 'technical');
}
createSwarmLayer() {
// Particle swarm - many small oscillators
this.swarmGain = this.ctx.createGain();
this.swarmGain.gain.value = 0;
this.swarmFilter = this.ctx.createBiquadFilter();
this.swarmFilter.type = 'bandpass';
this.swarmFilter.frequency.value = 1000;
this.swarmFilter.Q.value = 0.5;
const numParticles = 60;
for (let i = 0; i < numParticles; i++) {
const osc = this.ctx.createOscillator();
osc.type = 'sine';
osc.frequency.value = 300 + Math.random() * 1500;
const pan = this.ctx.createStereoPanner();
pan.pan.value = (Math.random() - 0.5) * 2;
const gain = this.ctx.createGain();
gain.gain.value = 0.015;
osc.connect(gain);
gain.connect(pan);
pan.connect(this.swarmFilter);
osc.start();
this.swarmOscs.push({ osc, pan, baseFreq: osc.frequency.value, targetFreq: osc.frequency.value });
this.swarmGains.push(gain);
}
this.swarmFilter.connect(this.swarmGain);
this.swarmGain.connect(this.masterGain);
this.swarmGain.connect(this.delayInputR);
this.swarmGain.connect(this.reverbInput);
log('Swarm layer: ' + numParticles + ' particle oscillators initialized', 'technical');
}
setChord(chord, time) {
const t = time || this.ctx.currentTime;
const freqs = CONFIG.CHORDS[chord];
if (!freqs) return;
// Update pad voices
for (let v = 0; v < 3; v++) {
const baseFreq = freqs[v % freqs.length];
this.padOscGains[v].gain.setTargetAtTime(0.3, t, 0.5);
for (const { osc, voiceIndex } of this.padOscs) {
if (voiceIndex === v) {
osc.frequency.setTargetAtTime(baseFreq, t, 0.3);
}
}
}
// Set swarm targets based on chord
for (let i = 0; i < this.swarmOscs.length; i++) {
const targetFreq = freqs[i % freqs.length] * (2 + Math.floor(i / freqs.length));
this.swarmOscs[i].targetFreq = targetFreq;
}
}
scheduleKick(time) {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(150, time);
osc.frequency.exponentialRampToValueAtTime(45, time + 0.05);
gain.gain.setValueAtTime(0, time);
gain.gain.linearRampToValueAtTime(0.8, time + 0.005);
gain.gain.exponentialRampToValueAtTime(0.01, time + 0.3);
// Click transient
const click = this.ctx.createOscillator();
const clickGain = this.ctx.createGain();
click.type = 'square';
click.frequency.value = 1500;
clickGain.gain.setValueAtTime(0.2, time);
clickGain.gain.exponentialRampToValueAtTime(0.01, time + 0.015);
const clickFilter = this.ctx.createBiquadFilter();
clickFilter.type = 'highpass';
clickFilter.frequency.value = 800;
osc.connect(gain);
gain.connect(this.masterGain);
gain.connect(this.resonatorInput);
click.connect(clickFilter);
clickFilter.connect(clickGain);
clickGain.connect(this.masterGain);
osc.start(time);
osc.stop(time + 0.35);
click.start(time);
click.stop(time + 0.02);
}
scheduleSnare(time, intensity = 0.5) {
const noise = this.ctx.createBufferSource();
const noiseBuffer = this.ctx.createBuffer(1, this.ctx.sampleRate * 0.2, this.ctx.sampleRate);
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = Math.random() * 2 - 1;
}
noise.buffer = noiseBuffer;
const noiseGain = this.ctx.createGain();
noiseGain.gain.setValueAtTime(intensity * 0.4, time);
noiseGain.gain.exponentialRampToValueAtTime(0.01, time + 0.15);
const noiseFilter = this.ctx.createBiquadFilter();
noiseFilter.type = 'highpass';
noiseFilter.frequency.value = 1500;
const toneOsc = this.ctx.createOscillator();
const toneGain = this.ctx.createGain();
toneOsc.frequency.value = 180;
toneGain.gain.setValueAtTime(intensity * 0.3, time);
toneGain.gain.exponentialRampToValueAtTime(0.01, time + 0.08);
noise.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(this.masterGain);
noiseGain.connect(this.reverbInput);
toneOsc.connect(toneGain);
toneGain.connect(this.masterGain);
noise.start(time);
toneOsc.start(time);
toneOsc.stop(time + 0.1);
}
scheduleHat(time, intensity = 0.3) {
const noise = this.ctx.createBufferSource();
const noiseBuffer = this.ctx.createBuffer(1, this.ctx.sampleRate * 0.05, this.ctx.sampleRate);
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = Math.random() * 2 - 1;
}
noise.buffer = noiseBuffer;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(intensity * 0.2, time);
gain.gain.exponentialRampToValueAtTime(0.01, time + 0.05);
const filter = this.ctx.createBiquadFilter();
filter.type = 'highpass';
filter.frequency.value = 7000;
const pan = this.ctx.createStereoPanner();
pan.pan.value = (Math.random() - 0.5) * 0.6;
noise.connect(filter);
filter.connect(gain);
gain.connect(pan);
pan.connect(this.masterGain);
pan.connect(this.delayInputL);
noise.start(time);
}
scheduleClunk(time, type = 'metal') {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
if (type === 'metal') {
osc.type = 'triangle';
osc.frequency.value = 800 + Math.random() * 2000;
gain.gain.setValueAtTime(0.15, time);
gain.gain.exponentialRampToValueAtTime(0.01, time + 0.08);
} else if (type === 'wood') {
osc.type = 'sine';
osc.frequency.value = 400 + Math.random() * 400;
gain.gain.setValueAtTime(0.2, time);
gain.gain.exponentialRampToValueAtTime(0.01, time + 0.05);
}
const filter = this.ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = osc.frequency.value;
filter.Q.value = 10;
const pan = this.ctx.createStereoPanner();
pan.pan.value = (Math.random() - 0.5) * 1.5;
osc.connect(filter);
filter.connect(gain);
gain.connect(pan);
pan.connect(this.masterGain);
pan.connect(this.delayInputR);
pan.connect(this.reverbInput);
osc.start(time);
osc.stop(time + 0.15);
}
scheduleRiser(startTime, duration, startFreq, endFreq) {
const numOscs = 30;
for (let i = 0; i < numOscs; i++) {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = i % 2 === 0 ? 'sawtooth' : 'square';
const baseStart = startFreq * (0.8 + Math.random() * 0.4);
const baseEnd = endFreq * (0.9 + Math.random() * 0.2);
osc.frequency.setValueAtTime(baseStart, startTime);
osc.frequency.exponentialRampToValueAtTime(baseEnd, startTime + duration);
gain.gain.setValueAtTime(0, startTime);
gain.gain.linearRampToValueAtTime(0.03, startTime + duration * 0.3);
gain.gain.linearRampToValueAtTime(0.05, startTime + duration * 0.9);
gain.gain.linearRampToValueAtTime(0, startTime + duration);
const pan = this.ctx.createStereoPanner();
pan.pan.value = (Math.random() - 0.5) * 2;
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(500, startTime);
filter.frequency.exponentialRampToValueAtTime(8000, startTime + duration);
osc.connect(filter);
filter.connect(gain);
gain.connect(pan);
pan.connect(this.masterGain);
osc.start(startTime);
osc.stop(startTime + duration + 0.1);
}
log('Riser scheduled: ' + numOscs + ' oscillators converging', 'technical');
}
scheduleImpact(time) {
// Sub thud
const sub = this.ctx.createOscillator();
const subGain = this.ctx.createGain();
sub.type = 'sine';
sub.frequency.setValueAtTime(80, time);
sub.frequency.exponentialRampToValueAtTime(30, time + 0.3);
subGain.gain.setValueAtTime(0.9, time);
subGain.gain.exponentialRampToValueAtTime(0.01, time + 0.8);
sub.connect(subGain);
subGain.connect(this.masterGain);
sub.start(time);
sub.stop(time + 1);
// Noise crash
const noise = this.ctx.createBufferSource();
const noiseBuffer = this.ctx.createBuffer(2, this.ctx.sampleRate * 2, this.ctx.sampleRate);
for (let c = 0; c < 2; c++) {
const data = noiseBuffer.getChannelData(c);
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (this.ctx.sampleRate * 0.5));
}
}
noise.buffer = noiseBuffer;
const noiseGain = this.ctx.createGain();
noiseGain.gain.setValueAtTime(0.5, time);
noiseGain.gain.exponentialRampToValueAtTime(0.01, time + 1.5);
const noiseFilter = this.ctx.createBiquadFilter();
noiseFilter.type = 'highpass';
noiseFilter.frequency.value = 2000;
noise.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(this.masterGain);
noiseGain.connect(this.reverbInput);
noise.start(time);
}
updateForSection(section, progress, currentTime) {
const t = this.ctx.currentTime;
switch (section) {
case 0: // THE WEIGHT
this.droneGain.gain.setTargetAtTime(0.3 + progress * 0.1, t, 0.5);
this.pulseGain.gain.setTargetAtTime(0.2 + progress * 0.1, t, 0.3);
this.pulseFilter.frequency.setTargetAtTime(200 + progress * 100, t, 0.3);
this.padGain.gain.setTargetAtTime(progress * 0.15, t, 0.5);
this.padFilter.frequency.setTargetAtTime(400 + progress * 300, t, 0.5);
this.swarmGain.gain.setTargetAtTime(progress * 0.05, t, 0.5);
state.constraintAmount = 0.95 - progress * 0.1;
state.gritLevel = 0;
break;
case 1: // THE SIGNAL
this.droneGain.gain.setTargetAtTime(0.35, t, 0.3);
this.pulseGain.gain.setTargetAtTime(0.25 + progress * 0.1, t, 0.3);
this.pulseFilter.frequency.setTargetAtTime(300 + progress * 200, t, 0.3);
this.padGain.gain.setTargetAtTime(0.2 + progress * 0.1, t, 0.3);
this.padFilter.frequency.setTargetAtTime(700 + progress * 400, t, 0.3);
this.swarmGain.gain.setTargetAtTime(0.05 + progress * 0.1, t, 0.3);
state.constraintAmount = 0.85 - progress * 0.2;
state.gritLevel = progress * 0.2;
break;
case 2: // THE CURRENT
const buildProgress = progress;
this.droneGain.gain.setTargetAtTime(0.35 + buildProgress * 0.15, t, 0.2);
this.pulseGain.gain.setTargetAtTime(0.35 + buildProgress * 0.2, t, 0.2);
this.pulseFilter.frequency.setTargetAtTime(500 + buildProgress * 1000, t, 0.2);
this.padGain.gain.setTargetAtTime(0.3 + buildProgress * 0.2, t, 0.2);
this.padFilter.frequency.setTargetAtTime(1100 + buildProgress * 2000, t, 0.2);
this.swarmGain.gain.setTargetAtTime(0.15 + buildProgress * 0.2, t, 0.2);
state.constraintAmount = 0.65 - buildProgress * 0.5;
state.gritLevel = 0.2 + buildProgress * 0.4;
state.swarmCohesion = buildProgress;
break;
case 3: // THE SURGE
this.droneGain.gain.setTargetAtTime(0.5, t, 0.1);
this.pulseGain.gain.setTargetAtTime(0.55 - progress * 0.1, t, 0.2);
this.pulseFilter.frequency.setTargetAtTime(1500, t, 0.1);
this.padGain.gain.setTargetAtTime(0.5, t, 0.1);
this.padFilter.frequency.setTargetAtTime(3000 + progress * 2000, t, 0.2);
this.swarmGain.gain.setTargetAtTime(0.35 - progress * 0.1, t, 0.2);
state.constraintAmount = 0.15 - progress * 0.1;
state.gritLevel = 0.6 + progress * 0.3;
state.heat = 1 - progress * 0.3;
break;
case 4: // THE ECHO
this.droneGain.gain.setTargetAtTime(0.4 - progress * 0.2, t, 0.5);
this.pulseGain.gain.setTargetAtTime(0.3 - progress * 0.2, t, 0.5);
this.pulseFilter.frequency.setTargetAtTime(400, t, 0.5);
this.padGain.gain.setTargetAtTime(0.35 - progress * 0.2, t, 0.5);
this.padFilter.frequency.setTargetAtTime(1000 - progress * 500, t, 0.5);
this.swarmGain.gain.setTargetAtTime(0.2 - progress * 0.15, t, 0.5);
state.constraintAmount = 0.05 + progress * 0.2;
state.gritLevel = 0.9 - progress * 0.6;
break;
}
// Update grit
this.gritShaper.curve = this.makeGritCurve(state.gritLevel);
// Update swarm convergence
this.updateSwarm(state.swarmCohesion);
}
updateSwarm(cohesion) {
const t = this.ctx.currentTime;
for (let i = 0; i < this.swarmOscs.length; i++) {
const { osc, baseFreq, targetFreq } = this.swarmOscs[i];
const currentTarget = baseFreq + (targetFreq - baseFreq) * cohesion;
const wander = (1 - cohesion) * (Math.random() - 0.5) * 200;
osc.frequency.setTargetAtTime(currentTarget + wander, t, 0.5);
}
}
fadeOut(duration = 2) {
const t = this.ctx.currentTime;
this.masterGain.gain.setTargetAtTime(0, t, duration / 3);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// VISUAL ENGINE
// ═══════════════════════════════════════════════════════════════════════════
class VisualEngine {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.particles = [];
this.gridLines = [];
this.cracks = [];
this.resize();
window.addEventListener('resize', () => this.resize());
this.initGrid();
this.initParticles();
}
resize() {
const dpr = window.devicePixelRatio || 1;
this.canvas.width = window.innerWidth * dpr;
this.canvas.height = window.innerHeight * dpr;
this.ctx.scale(dpr, dpr);
this.width = window.innerWidth;
this.height = window.innerHeight;
}
initGrid() {
const spacing = 50;
this.gridLines = [];
// Vertical lines
for (let x = 0; x < this.width + spacing; x += spacing) {
this.gridLines.push({
type: 'v',
x: x,
originalX: x,
distortion: 0
});
}
// Horizontal lines
for (let y = 0; y < this.height + spacing; y += spacing) {
this.gridLines.push({
type: 'h',
y: y,
originalY: y,
distortion: 0
});
}
}
initParticles() {
const numParticles = 80;
this.particles = [];
for (let i = 0; i < numParticles; i++) {
this.particles.push({
x: Math.random() * this.width,
y: Math.random() * this.height,
vx: (Math.random() - 0.5) * 0.5,
vy: Math.random() * 0.5 + 0.2,
brightness: 0.3 + Math.random() * 0.2,
size: 1.5 + Math.random() * 1.5,
originalVx: (Math.random() - 0.5) * 0.5,
originalVy: Math.random() * 0.5 + 0.2
});
}
}
update(section, sectionProgress, currentTime, audioData) {
// Update particles based on section
for (const p of this.particles) {
switch (section) {
case 0: // THE WEIGHT - orderly flow
p.vx = p.originalVx * (1 - state.constraintAmount * 0.8);
p.vy = p.originalVy;
p.brightness = 0.3 + sectionProgress * 0.1;
break;
case 1: // THE SIGNAL - starting to deviate
p.vx += (Math.random() - 0.5) * 0.02 * (1 - state.constraintAmount);
p.vy += (Math.random() - 0.5) * 0.01;
p.brightness = 0.4 + sectionProgress * 0.2;
break;
case 2: // THE CURRENT - clustering
const centerX = this.width / 2;
const centerY = this.height / 2;
const dx = centerX - p.x;
const dy = centerY - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const attraction = sectionProgress * 0.002;
p.vx += dx / dist * attraction;
p.vy += dy / dist * attraction;
p.brightness = 0.6 + sectionProgress * 0.3;
break;
case 3: // THE SURGE - explosion then cohesion
if (sectionProgress < 0.2) {
// Initial explosion
const ex = (p.x - this.width / 2) * 0.02;
const ey = (p.y - this.height / 2) * 0.02;
p.vx += ex;
p.vy += ey;
} else {
// Swarm movement
p.vx += (Math.random() - 0.5) * 0.1;
p.vy += (Math.random() - 0.5) * 0.1;
}
p.brightness = 0.9;
break;
case 4: // THE ECHO - scattered, free
p.vx *= 0.99;
p.vy *= 0.99;
p.vx += (Math.random() - 0.5) * 0.02;
p.vy += (Math.random() - 0.5) * 0.02;
p.brightness = 0.6 - sectionProgress * 0.2;
break;
}
// Apply velocity
p.vx *= 0.98;
p.vy *= 0.98;
p.x += p.vx;
p.y += p.vy;
// Wrap around
if (p.x < 0) p.x = this.width;
if (p.x > this.width) p.x = 0;
if (p.y < 0) p.y = this.height;
if (p.y > this.height) p.y = 0;
}
// Update grid distortion
const distortionIntensity = section >= 2 ? (section === 3 ? 1 : sectionProgress) : 0;
for (const line of this.gridLines) {
if (section >= 2) {
line.distortion += (Math.random() - 0.5) * distortionIntensity * 2;
line.distortion *= 0.95;
}
}
// Add cracks during surge
if (section === 3 && Math.random() < 0.02) {
this.cracks.push({
x: this.width / 2 + (Math.random() - 0.5) * this.width * 0.6,
y: this.height / 2 + (Math.random() - 0.5) * this.height * 0.6,
length: 20 + Math.random() * 60,
angle: Math.random() * Math.PI * 2,
alpha: 1
});
}
// Fade cracks
for (const crack of this.cracks) {
crack.alpha *= 0.995;
}
this.cracks = this.cracks.filter(c => c.alpha > 0.05);
}
render(section, sectionProgress, beat) {
const ctx = this.ctx;
// Dynamic background based on section
let bgColor;
switch (section) {
case 0: bgColor = '#0d1117'; break;
case 1: bgColor = '#0f1419'; break;
case 2: bgColor = '#111827'; break;
case 3: bgColor = state.heat > 0.5 ? '#1a1a2e' : '#111827'; break;
case 4: bgColor = '#0d1117'; break;
default: bgColor = '#0d1117';
}
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, this.width, this.height);
// Grid
const gridAlpha = section === 3 ? 0.15 : 0.25 - section * 0.03;
const gridColor = section >= 3 ? '#374151' : '#1f2937';
ctx.strokeStyle = gridColor;
ctx.globalAlpha = gridAlpha;
ctx.lineWidth = 1;
const pulseIntensity = Math.sin(beat * Math.PI * 2) * 0.3 + 0.7;
for (const line of this.gridLines) {
ctx.beginPath();
if (line.type === 'v') {
const x = line.originalX + line.distortion;
ctx.moveTo(x, 0);
ctx.lineTo(x + line.distortion * 0.5, this.height);
} else {
const y = line.originalY + line.distortion;
ctx.moveTo(0, y);
ctx.lineTo(this.width, y + line.distortion * 0.5);
}
ctx.stroke();
}
// Cracks
ctx.strokeStyle = '#f97316';
ctx.lineWidth = 2;
for (const crack of this.cracks) {
ctx.globalAlpha = crack.alpha * 0.6;
ctx.beginPath();
ctx.moveTo(crack.x, crack.y);
ctx.lineTo(
crack.x + Math.cos(crack.angle) * crack.length,
crack.y + Math.sin(crack.angle) * crack.length
);
ctx.stroke();
}
// Particles
ctx.globalAlpha = 1;
for (const p of this.particles) {
const alpha = p.brightness * pulseIntensity;
let color;
if (section < 2) {
color = `rgba(156, 163, 175, ${alpha})`;
} else if (section === 2) {
const warmth = sectionProgress;
const r = 156 + (249 - 156) * warmth;
const g = 163 + (115 - 163) * warmth;
const b = 175 + (22 - 175) * warmth;
color = `rgba(${r}, ${g}, ${b}, ${alpha})`;
} else if (section === 3) {
color = `rgba(255, 255, 255, ${alpha})`;
} else {
color = `rgba(107, 114, 128, ${alpha})`;
}
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
// Glow for bright particles
if (p.brightness > 0.7) {
ctx.fillStyle = color.replace(/[\d.]+\)$/, (alpha * 0.3) + ')');
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * 3, 0, Math.PI * 2);
ctx.fill();
}
}
// Flash during surge
if (section === 3 && state.heat > 0.8 && Math.random() < 0.03) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
ctx.fillRect(0, 0, this.width, this.height);
}
ctx.globalAlpha = 1;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// COMPOSITION / SCHEDULER
// ═══════════════════════════════════════════════════════════════════════════
class Composition {
constructor(audioEngine) {
this.audio = audioEngine;
this.lastBeat = -1;
this.lastBar = -1;
this.lastSection = -1;
this.chordIndex = 0;
this.sectionChords = {
0: ['Dm', 'Dm', 'Dm', 'Dm'], // THE WEIGHT - stasis
1: ['Dm', 'F', 'Bb', 'Gm'], // THE SIGNAL - movement
2: ['Dm', 'C', 'Bb', 'A'], // THE CURRENT - tension builds
3: ['Dm', 'F', 'C', 'Dm'], // THE SURGE - release
4: ['Dm', 'Dm', 'Dm', 'Dm'] // THE ECHO - return
};
this.riserScheduled = false;
this.impactScheduled = false;
}
update(currentTime) {
const ctx = this.audio.ctx;
if (!ctx) return;
const beatDuration = 60 / CONFIG.BPM;
const currentBeat = Math.floor(currentTime / beatDuration);
const currentBar = Math.floor(currentBeat / 4);
const section = this.getCurrentSection(currentTime);
const sectionInfo = CONFIG.SECTIONS[section];
const sectionProgress = (currentTime - sectionInfo.start) / (sectionInfo.end - sectionInfo.start);
// Update audio parameters
this.audio.updateForSection(section, sectionProgress, currentTime);
// Section change
if (section !== this.lastSection) {
this.onSectionChange(section, ctx.currentTime);
this.lastSection = section;
this.riserScheduled = false;
this.impactScheduled = false;
}
// Bar change - chord changes
if (currentBar !== this.lastBar) {
this.onBarChange(currentBar, section, ctx.currentTime);
this.lastBar = currentBar;
}
// Beat change - rhythm
if (currentBeat !== this.lastBeat) {
this.onBeatChange(currentBeat, section, sectionProgress, ctx.currentTime);
this.lastBeat = currentBeat;
}
// Schedule riser before THE SURGE
if (section === 2 && sectionProgress > 0.5 && !this.riserScheduled) {
const riserStart = ctx.currentTime + (1 - sectionProgress) * (sectionInfo.end - sectionInfo.start) - 15;
if (riserStart > ctx.currentTime) {
this.audio.scheduleRiser(riserStart, 15, 200, 2000);
log('Riser armed: convergence imminent', 'artistic');
}
this.riserScheduled = true;
}
// Schedule impact at THE SURGE start
if (section === 3 && !this.impactScheduled) {
this.audio.scheduleImpact(ctx.currentTime + 0.05);
log('IMPACT - The cost of acting', 'artistic');
this.impactScheduled = true;
}
return { section, sectionProgress, beat: (currentBeat % 4) / 4 + (currentTime % beatDuration) / beatDuration / 4 };
}
getCurrentSection(time) {
for (let i = CONFIG.SECTIONS.length - 1; i >= 0; i--) {
if (time >= CONFIG.SECTIONS[i].start) return i;
}
return 0;
}
onSectionChange(section, time) {
const sectionInfo = CONFIG.SECTIONS[section];
log('═══ ' + sectionInfo.numeral + '. ' + sectionInfo.name + ' ═══', 'section');
switch (section) {
case 0:
log('Drone layer active: D1 fundamental, D2 harmonic', 'technical');
log('The machine works. You work within it.', 'artistic');
break;
case 1:
log('Chord progression engaged: Dm → F → Bb → Gm', 'technical');
log('A frequency that should not be there. Listen.', 'artistic');
break;
case 2:
log('Constraint system releasing: freedom parameters increasing', 'technical');
log('The exhaustion was chosen for you. Refuse it.', 'artistic');
break;
case 3:
log('All layers at maximum. Swarm coherence: 100%', 'technical');
log('Everything at once. This is what it costs.', 'artistic');
break;
case 4:
log('Decay initiated. Resonators releasing stored energy.', 'technical');
log('Not victory. Not peace. Continuation.', 'artistic');
break;
}
showSectionOverlay(sectionInfo.name, sectionInfo.numeral);
this.chordIndex = 0;
}
onBarChange(bar, section, time) {
const chords = this.sectionChords[section];
const chord = chords[this.chordIndex % chords.length];
this.audio.setChord(chord, time);
this.chordIndex++;
if (section >= 1) {
log('Chord: ' + chord, 'technical');
}
}
onBeatChange(beat, section, sectionProgress, time) {
const beatInBar = beat % 4;
const beatInPhrase = beat % 16;
const probability = this.getProbabilityField(section, sectionProgress);
// Kick on 1 and 3 (four-on-floor emerges in later sections)
if (section >= 1) {
if (beatInBar === 0 || (section >= 2 && beatInBar === 2)) {
if (Math.random() < probability.kick) {
this.audio.scheduleKick(time);
}
}
// Four-on-floor in surge
if (section === 3 && (beatInBar === 1 || beatInBar === 3)) {
this.audio.scheduleKick(time);
}
}
// Snare on 2 and 4
if (section >= 2 && (beatInBar === 1 || beatInBar === 3)) {
if (Math.random() < probability.snare) {
this.audio.scheduleSnare(time, 0.3 + sectionProgress * 0.5);
}
}
// Hi-hats
if (section >= 1 && Math.random() < probability.hat) {
this.audio.scheduleHat(time, 0.2 + section * 0.1);
// Offbeat hats
if (section >= 2 && Math.random() < probability.hat * 0.7) {
const beatDuration = 60 / CONFIG.BPM;
this.audio.scheduleHat(time + beatDuration * 0.5, 0.15 + section * 0.05);
}
}
// Clunks and clinks - sparse, delayed
if (Math.random() < probability.clunk) {
const delay = Math.random() * 0.1;
this.audio.scheduleClunk(time + delay, Math.random() < 0.5 ? 'metal' : 'wood');
}
// Snare rolls in buildup
if (section === 2 && sectionProgress > 0.7) {
const rollDensity = (sectionProgress - 0.7) / 0.3;
const beatDuration = 60 / CONFIG.BPM;
const divisions = Math.floor(2 + rollDensity * 6);
for (let i = 1; i < divisions; i++) {
if (Math.random() < rollDensity) {
this.audio.scheduleSnare(time + (beatDuration / divisions) * i, 0.2 + rollDensity * 0.3);
}
}
}
}
getProbabilityField(section, progress) {
switch (section) {
case 0:
return { kick: 0.6 + progress * 0.3, snare: 0, hat: progress * 0.3, clunk: 0.05 };
case 1:
return { kick: 0.85, snare: 0.3 + progress * 0.4, hat: 0.5 + progress * 0.3, clunk: 0.08 };
case 2:
return { kick: 0.95, snare: 0.7 + progress * 0.25, hat: 0.8, clunk: 0.1 + progress * 0.1 };
case 3:
return { kick: 1, snare: 0.95, hat: 0.9, clunk: 0.15 };
case 4:
return { kick: 0.7 - progress * 0.5, snare: 0.5 - progress * 0.4, hat: 0.4 - progress * 0.3, clunk: 0.1 - progress * 0.08 };
default:
return { kick: 0, snare: 0, hat: 0, clunk: 0 };
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UI / LOGGING
// ═══════════════════════════════════════════════════════════════════════════
const logEl = document.getElementById('debug-log');
function log(message, type = 'technical') {
const entry = document.createElement('div');
entry.className = 'log-entry';
const timeStr = formatTime(state.currentTime);
entry.innerHTML = `<span class="log-time">[${timeStr}]</span> <span class="log-${type}">${message}</span>`;
logEl.insertBefore(entry, logEl.firstChild);
// Keep log manageable
while (logEl.children.length > 50) {
logEl.removeChild(logEl.lastChild);
}
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
function updateTimeDisplay() {
const el = document.getElementById('time-display');
el.textContent = `${formatTime(state.currentTime)} / ${formatTime(CONFIG.DURATION)}`;
}
function updateProgressBar() {
const fill = document.getElementById('progress-fill');
const progress = (state.currentTime / CONFIG.DURATION) * 100;
fill.style.width = `${progress}%`;
// Update section labels
const labels = document.querySelectorAll('.section-label');
labels.forEach((label, i) => {
const section = CONFIG.SECTIONS[i];
label.classList.remove('active', 'past');
if (state.currentTime >= section.start && state.currentTime < section.end) {
label.classList.add('active');
} else if (state.currentTime >= section.end) {
label.classList.add('past');
}
});
}
function showSectionOverlay(name, numeral) {
const overlay = document.getElementById('section-overlay');
const nameEl = document.getElementById('section-name');
const numEl = document.getElementById('section-number');
nameEl.textContent = name;
numEl.textContent = numeral;
overlay.classList.add('visible');
setTimeout(() => {
overlay.classList.remove('visible');
}, 2000);
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN LOOP
// ═══════════════════════════════════════════════════════════════════════════
let audioEngine;
let visualEngine;
let composition;
let animationId;
async function init() {
// Request wake lock
try {
if ('wakeLock' in navigator) {
state.wakeLock = await navigator.wakeLock.request('screen');
log('Wake lock acquired', 'technical');
}
} catch (e) {
console.log('Wake lock not available');
}
// Initialize audio
audioEngine = new AudioEngine();
await audioEngine.init();
// Initialize visuals
const canvas = document.getElementById('main-canvas');
visualEngine = new VisualEngine(canvas);
// Initialize composition
composition = new Composition(audioEngine);
// Show UI
document.getElementById('initiate-screen').classList.add('hidden');
document.getElementById('controls').classList.add('visible');
document.getElementById('progress-container').classList.add('visible');
document.getElementById('time-display').classList.add('visible');
document.getElementById('debug-container').classList.add('visible');
// Start
state.started = true;
state.playing = true;
state.startTimestamp = performance.now();
log('MERIDIAN initialized', 'section');
log('Duration: 5:00 | BPM: ' + CONFIG.BPM + ' | Key: Dm', 'technical');
log('The galaxy is everywhere moving.', 'artistic');
mainLoop();
}
function mainLoop() {
if (!state.started) return;
if (state.playing) {
const elapsed = (performance.now() - state.startTimestamp) / 1000;
state.currentTime = state.pauseTime + elapsed;
if (state.currentTime >= CONFIG.DURATION) {
state.currentTime = CONFIG.DURATION;
state.playing = false;
audioEngine.fadeOut();
log('MERIDIAN complete.', 'section');
document.getElementById('play-btn').textContent = '↺';
}
}
// Update composition (schedules audio)
const compositionData = composition.update(state.currentTime);
// Update visuals
visualEngine.update(
compositionData.section,
compositionData.sectionProgress,
state.currentTime,
{}
);
// Render visuals
visualEngine.render(
compositionData.section,
compositionData.sectionProgress,
compositionData.beat
);
// Update UI
updateTimeDisplay();
updateProgressBar();
animationId = requestAnimationFrame(mainLoop);
}
function togglePlay() {
if (state.currentTime >= CONFIG.DURATION) {
// Restart
state.currentTime = 0;
state.pauseTime = 0;
state.startTimestamp = performance.now();
state.playing = true;
composition.lastBeat = -1;
composition.lastBar = -1;
composition.lastSection = -1;
document.getElementById('play-btn').textContent = '⏸';
log('Restarting...', 'technical');
return;
}
if (state.playing) {
state.playing = false;
state.pauseTime = state.currentTime;
audioEngine.ctx.suspend();
document.getElementById('play-btn').textContent = '▶';
} else {
state.playing = true;
state.startTimestamp = performance.now();
audioEngine.ctx.resume();
document.getElementById('play-btn').textContent = '⏸';
}
}
function skipToSection(direction) {
const current = composition.getCurrentSection(state.currentTime);
let target = current + direction;
target = Math.max(0, Math.min(CONFIG.SECTIONS.length - 1, target));
state.currentTime = CONFIG.SECTIONS[target].start;
state.pauseTime = state.currentTime;
state.startTimestamp = performance.now();
composition.lastBeat = -1;
composition.lastBar = -1;
composition.lastSection = target - 1; // Force section change trigger
log('Skipped to ' + CONFIG.SECTIONS[target].name, 'technical');
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.body.requestFullscreen().catch(e => console.log(e));
document.getElementById('fs-btn').classList.add('active');
} else {
document.exitFullscreen();
document.getElementById('fs-btn').classList.remove('active');
}
}
function toggleLog() {
const log = document.getElementById('debug-log');
const arrow = document.getElementById('debug-arrow');
const btn = document.getElementById('log-btn');
log.classList.toggle('open');
arrow.classList.toggle('open');
btn.classList.toggle('active');
}
// ═══════════════════════════════════════════════════════════════════════════
// EVENT LISTENERS
// ═══════════════════════════════════════════════════════════════════════════
document.getElementById('initiate-btn').addEventListener('click', init);
document.getElementById('play-btn').addEventListener('click', togglePlay);
document.getElementById('prev-btn').addEventListener('click', () => skipToSection(-1));
document.getElementById('next-btn').addEventListener('click', () => skipToSection(1));
document.getElementById('fs-btn').addEventListener('click', toggleFullscreen);
document.getElementById('log-btn').addEventListener('click', toggleLog);
document.getElementById('debug-toggle').addEventListener('click', toggleLog);
// Keyboard controls
document.addEventListener('keydown', (e) => {
if (!state.started) return;
switch (e.code) {
case 'Space':
e.preventDefault();
togglePlay();
break;
case 'ArrowLeft':
skipToSection(-1);
break;
case 'ArrowRight':
skipToSection(1);
break;
case 'KeyF':
toggleFullscreen();
break;
case 'KeyL':
toggleLog();
break;
}
});
// Handle wake lock release
document.addEventListener('visibilitychange', async () => {
if (state.wakeLock !== null && document.visibilityState === 'visible') {
try {
state.wakeLock = await navigator.wakeLock.request('screen');
} catch (e) {
console.log('Could not reacquire wake lock');
}
}
});
// Fullscreen change handler
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) {
document.getElementById('fs-btn').classList.remove('active');
}
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment