Created
January 26, 2026 05:22
-
-
Save rndmcnlly/d43f70050c66da0acc47149652e7fd35 to your computer and use it in GitHub Desktop.
a generated generative audiovisual composition
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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