diff --git a/SINGULARITY.html b/SINGULARITY.html index c0ed383..aba8974 100644 --- a/SINGULARITY.html +++ b/SINGULARITY.html @@ -42,11 +42,29 @@ } #yt-host { - position: absolute; - width: 1px; height: 1px; - left: -9999px; top: -9999px; - opacity: 0; pointer-events: none; + position: fixed; + right: 18px; + bottom: 118px; + width: 220px; + height: 124px; + z-index: 12; + border: 1px solid var(--line); + background: #000; overflow: hidden; + opacity: 0; + pointer-events: none; + transform: translateY(8px); + transition: opacity 220ms ease, transform 220ms ease; + box-shadow: 0 12px 40px rgba(0,0,0,0.45); + } + #yt-host.visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); + } + #yt-host iframe { width: 100% !important; height: 100% !important; } + @media (max-width: 820px) { + #yt-host { width: 160px; height: 90px; bottom: 150px; right: 12px; } } .vignette { @@ -366,9 +384,11 @@
EVENT HORIZON PROTOCOL

SINGULARITY

-

Enter a high-energy 3D audiovisual wormhole driven by the full track. Drag to orbit. Click to pulse gravity. Keys reshape reality.

+

Enter a high-energy 3D audiovisual wormhole driven by SINGULARITY — a full-length EDM/dubstep assault with true FFT reactivity, plus the YouTube source linked below.

-

TRACK SOURCE · YOUTUBE · yyF1UxGRXVs

+

+ YOUTUBE MIRROR · yyF1UxGRXVs +

@@ -426,12 +446,13 @@

INTERACT

+ - SOURCE · YOUTUBE · HYBRID REACTIVITY + SOURCE · SYNTH · TRUE FFT · YT MIRROR
@@ -458,6 +479,7 @@

INTERACT

const YT_ID = "yyF1UxGRXVs"; const BPM = 140; const BEAT = 60 / BPM; +const TRACK_DUR = 4 * 60 + 28; // match YouTube mirror length const canvas = document.getElementById("stage"); const flashEl = document.getElementById("flash"); @@ -471,6 +493,7 @@

INTERACT

tCur: document.getElementById("tCur"), tDur: document.getElementById("tDur"), volBtn: document.getElementById("volBtn"), + synthBtn: document.getElementById("synthBtn"), micBtn: document.getElementById("micBtn"), fftBtn: document.getElementById("fftBtn"), ytBtn: document.getElementById("ytBtn"), @@ -500,10 +523,10 @@

INTERACT

mode: "orbit", playing: false, ready: false, - source: "youtube", // youtube | file | mic + source: "synth", // synth | youtube | file | mic volume: 80, muted: false, - bloom: 1.2, + bloom: 0.95, gravity: 1.0, particleMul: 1.0, colorShift: 0, @@ -521,7 +544,7 @@

INTERACT

fps: 60, time: 0, trackTime: 0, - trackDur: 0, + trackDur: TRACK_DUR, audio: { ctx: null, analyser: null, @@ -531,18 +554,18 @@

INTERACT

mediaEl: null, micStream: null, gain: null, + master: null, }, + synth: null, bands: { bass: 0, mid: 0, high: 0, energy: 0, beat: 0 }, yt: null, ytReady: false, + ytFailed: false, }; -function toast(msg) { - toastEl.textContent = msg; - toastEl.classList.add("show"); - clearTimeout(toast._t); - toast._t = setTimeout(() => toastEl.classList.remove("show"), 1800); -} +ui.tDur.textContent = fmt(TRACK_DUR); +ui.bloomSlider.value = "0.95"; +ui.bloomVal.textContent = "0.95"; function fmt(t) { t = Math.max(0, t || 0); @@ -551,6 +574,13 @@

INTERACT

return `${m}:${s.toString().padStart(2, "0")}`; } +function toast(msg) { + toastEl.textContent = msg; + toastEl.classList.add("show"); + clearTimeout(toast._t); + toast._t = setTimeout(() => toastEl.classList.remove("show"), 1800); +} + function loadYouTubeAPI() { return new Promise((resolve) => { if (window.YT && window.YT.Player) return resolve(); @@ -592,6 +622,8 @@

INTERACT

resolve(); }, onStateChange: (e) => { + // Only drive transport UI while YouTube is the active source + if (state.source !== "youtube") return; state.playing = e.data === YT.PlayerState.PLAYING; ui.playBtn.textContent = state.playing ? "PAUSE" : "PLAY"; if (e.data === YT.PlayerState.ENDED) { @@ -599,7 +631,10 @@

INTERACT

ui.playBtn.textContent = "PLAY"; } }, - onError: () => toast("YOUTUBE EMBED ERROR — TRY FILE FFT"), + onError: () => { + state.ytFailed = true; + toast("YOUTUBE BLOCKED HERE — SYNTH ENGINE READY"); + }, }, }); }); @@ -611,11 +646,15 @@

INTERACT

state.audio.ctx = new Ctx(); state.audio.analyser = state.audio.ctx.createAnalyser(); state.audio.analyser.fftSize = 2048; - state.audio.analyser.smoothingTimeConstant = 0.78; + state.audio.analyser.smoothingTimeConstant = 0.72; state.audio.data = new Uint8Array(state.audio.analyser.frequencyBinCount); state.audio.freq = new Uint8Array(state.audio.analyser.frequencyBinCount); + state.audio.master = state.audio.ctx.createGain(); + state.audio.master.gain.value = 0.9; state.audio.gain = state.audio.ctx.createGain(); state.audio.gain.gain.value = 1; + state.audio.master.connect(state.audio.analyser); + state.audio.analyser.connect(state.audio.gain); state.audio.gain.connect(state.audio.ctx.destination); } if (state.audio.ctx.state === "suspended") state.audio.ctx.resume(); @@ -623,6 +662,10 @@

INTERACT

} function disconnectAudioSource() { + if (state.synth) { + state.synth.stop(); + state.synth = null; + } if (state.audio.sourceNode) { try { state.audio.sourceNode.disconnect(); } catch (_) {} state.audio.sourceNode = null; @@ -637,6 +680,308 @@

INTERACT

} } +/** Procedural full-length EDM/dubstep engine — SINGULARITY */ +class SingularityEngine { + constructor(ctx, dest, duration = TRACK_DUR) { + this.ctx = ctx; + this.dest = dest; + this.duration = duration; + this.playing = false; + this.startCtxTime = 0; + this.offset = 0; + this.timer = null; + this.nodes = []; + this.bpm = BPM; + this.beat = 60 / this.bpm; + this.scheduled = new Set(); + } + + now() { + if (!this.playing) return this.offset; + return Math.min(this.duration, this.offset + (this.ctx.currentTime - this.startCtxTime)); + } + + sectionAt(t) { + const bar = Math.floor(t / (this.beat * 4)); + const cycle = bar % 32; + if (cycle < 4) return "intro"; + if (cycle < 8) return "groove"; + if (cycle < 12) return "build"; + if (cycle < 16) return "drop"; + if (cycle < 20) return "break"; + if (cycle < 24) return "build2"; + if (cycle < 28) return "drop2"; + return "outro"; + } + + env(gain, t0, a, d, s, r, peak = 1, sustainLevel = 0.001) { + gain.gain.cancelScheduledValues(t0); + gain.gain.setValueAtTime(0.0001, t0); + gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, peak), t0 + a); + gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, s), t0 + a + d); + gain.gain.exponentialRampToValueAtTime(sustainLevel, t0 + a + d + r); + } + + mkGain(v = 1) { + const g = this.ctx.createGain(); + g.gain.value = v; + this.nodes.push(g); + return g; + } + + kick(t) { + const o = this.ctx.createOscillator(); + const g = this.mkGain(); + o.type = "sine"; + o.frequency.setValueAtTime(170, t); + o.frequency.exponentialRampToValueAtTime(42, t + 0.12); + this.env(g, t, 0.001, 0.08, 0.0001, 0.18, 1.1); + o.connect(g); g.connect(this.dest); + o.start(t); o.stop(t + 0.35); + this.nodes.push(o); + } + + snare(t, vel = 0.7) { + const dur = 0.22; + const buffer = this.ctx.createBuffer(1, this.ctx.sampleRate * dur, this.ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; + const src = this.ctx.createBufferSource(); + src.buffer = buffer; + const bp = this.ctx.createBiquadFilter(); + bp.type = "bandpass"; + bp.frequency.value = 1800; + bp.Q.value = 0.8; + const g = this.mkGain(); + this.env(g, t, 0.001, 0.05, 0.0001, 0.14, vel); + src.connect(bp); bp.connect(g); g.connect(this.dest); + src.start(t); src.stop(t + dur); + this.nodes.push(src, bp); + + const o = this.ctx.createOscillator(); + const og = this.mkGain(); + o.type = "triangle"; + o.frequency.setValueAtTime(220, t); + o.frequency.exponentialRampToValueAtTime(110, t + 0.08); + this.env(og, t, 0.001, 0.04, 0.0001, 0.08, vel * 0.35); + o.connect(og); og.connect(this.dest); + o.start(t); o.stop(t + 0.12); + this.nodes.push(o); + } + + hat(t, open = false) { + const dur = open ? 0.18 : 0.05; + const buffer = this.ctx.createBuffer(1, this.ctx.sampleRate * dur, this.ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; + const src = this.ctx.createBufferSource(); + src.buffer = buffer; + const hp = this.ctx.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = 7000; + const g = this.mkGain(); + this.env(g, t, 0.001, 0.02, 0.0001, open ? 0.12 : 0.03, open ? 0.28 : 0.18); + src.connect(hp); hp.connect(g); g.connect(this.dest); + src.start(t); src.stop(t + dur); + this.nodes.push(src, hp); + } + + wobble(t, len, intensity = 1) { + const o1 = this.ctx.createOscillator(); + const o2 = this.ctx.createOscillator(); + o1.type = "sawtooth"; + o2.type = "square"; + const base = 55; + o1.frequency.value = base; + o2.frequency.value = base * 1.01; + const lfo = this.ctx.createOscillator(); + const lfoG = this.mkGain(800 * intensity); + lfo.frequency.value = this.bpm / 60 * (intensity > 1 ? 0.5 : 0.25); + const filt = this.ctx.createBiquadFilter(); + filt.type = "lowpass"; + filt.frequency.value = 120; + filt.Q.value = 10; + lfo.connect(lfoG); lfoG.connect(filt.frequency); + const g = this.mkGain(0.22 * intensity); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(0.22 * intensity, t + 0.05); + g.gain.setValueAtTime(0.22 * intensity, t + len - 0.05); + g.gain.exponentialRampToValueAtTime(0.0001, t + len); + o1.connect(filt); o2.connect(filt); filt.connect(g); g.connect(this.dest); + o1.start(t); o2.start(t); lfo.start(t); + o1.stop(t + len); o2.stop(t + len); lfo.stop(t + len); + this.nodes.push(o1, o2, lfo, filt); + } + + lead(t, freq, len, gain = 0.12) { + const voices = [0, 0.01, -0.01].map((det) => { + const o = this.ctx.createOscillator(); + o.type = "sawtooth"; + o.frequency.value = freq * (1 + det); + return o; + }); + const filt = this.ctx.createBiquadFilter(); + filt.type = "lowpass"; + filt.frequency.setValueAtTime(600, t); + filt.frequency.exponentialRampToValueAtTime(4200, t + len * 0.35); + filt.frequency.exponentialRampToValueAtTime(800, t + len); + const g = this.mkGain(); + this.env(g, t, 0.02, 0.1, gain * 0.7, len * 0.7, gain); + voices.forEach((o) => { o.connect(filt); o.start(t); o.stop(t + len + 0.02); this.nodes.push(o); }); + filt.connect(g); g.connect(this.dest); + this.nodes.push(filt); + } + + riser(t, len) { + const buffer = this.ctx.createBuffer(1, this.ctx.sampleRate * len, this.ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * (i / data.length); + const src = this.ctx.createBufferSource(); + src.buffer = buffer; + const bp = this.ctx.createBiquadFilter(); + bp.type = "bandpass"; + bp.frequency.setValueAtTime(400, t); + bp.frequency.exponentialRampToValueAtTime(8000, t + len); + const g = this.mkGain(0.0001); + g.gain.exponentialRampToValueAtTime(0.35, t + len); + src.connect(bp); bp.connect(g); g.connect(this.dest); + src.start(t); src.stop(t + len); + this.nodes.push(src, bp); + } + + scheduleWindow(from, to) { + const startBeat = Math.floor(from / this.beat); + const endBeat = Math.ceil(to / this.beat); + const tNow = this.now(); + for (let b = startBeat; b < endBeat; b++) { + if (this.scheduled.has(b)) continue; + const tTrack = b * this.beat; + if (tTrack < from - 0.001 || tTrack >= this.duration) continue; + let when = this.ctx.currentTime + (tTrack - tNow); + if (when < this.ctx.currentTime - 0.05) continue; + if (when < this.ctx.currentTime + 0.02) when = this.ctx.currentTime + 0.02; + this.scheduled.add(b); + const sec = this.sectionAt(tTrack); + const beatInBar = b % 4; + const intense = sec === "drop" || sec === "drop2"; + const build = sec === "build" || sec === "build2"; + + // kick + if (sec !== "break" || beatInBar === 0) { + if (intense || beatInBar === 0 || beatInBar === 2 || (build && beatInBar !== 3)) { + this.kick(when); + } + } + // snare + if (beatInBar === 1 || beatInBar === 3) { + if (sec !== "intro") this.snare(when, intense ? 0.9 : 0.55); + } + // hats + if (sec !== "intro") { + this.hat(when, false); + if (intense || build) this.hat(when + this.beat * 0.5, build); + } + // wobble every bar + if (beatInBar === 0 && (intense || sec === "groove" || build)) { + this.wobble(when, this.beat * 4, intense ? 1.35 : build ? 0.8 : 0.65); + } + // lead hooks on drops + if (intense && beatInBar === 0) { + const scale = [55, 65.41, 73.42, 82.41, 98, 110]; + const f = scale[(b / 4) % scale.length | 0] * 4; + this.lead(when, f, this.beat * 2, 0.1); + this.lead(when + this.beat * 2, f * 1.5, this.beat * 1.5, 0.08); + } + // riser into drops + if (build && beatInBar === 0 && (b % 16) === 12) { + this.riser(when, this.beat * 4); + } + } + } + + tick() { + if (!this.playing) return; + const t = this.now(); + if (t >= this.duration - 0.05) { + this.stop(true); + state.playing = false; + ui.playBtn.textContent = "PLAY"; + return; + } + this.scheduleWindow(t, t + 1.25); + state.trackTime = t; + } + + play(offset = 0) { + this.stop(false); + this.offset = Math.max(0, Math.min(offset, this.duration - 0.05)); + this.startCtxTime = this.ctx.currentTime; + this.playing = true; + this.nodes = []; + this.scheduled = new Set(); + this.tick(); + this.timer = setInterval(() => this.tick(), 180); + } + + pause() { + if (!this.playing) return; + this.offset = this.now(); + this.playing = false; + clearInterval(this.timer); + this.timer = null; + // silence scheduled voices + const t = this.ctx.currentTime; + this.nodes.forEach((n) => { + try { if (n.stop) n.stop(t); } catch (_) {} + try { n.disconnect(); } catch (_) {} + }); + this.nodes = []; + } + + stop(reset = true) { + this.playing = false; + clearInterval(this.timer); + this.timer = null; + const t = this.ctx.currentTime; + this.nodes.forEach((n) => { + try { if (n.stop) n.stop(t); } catch (_) {} + try { n.disconnect(); } catch (_) {} + }); + this.nodes = []; + if (reset) this.offset = 0; + } + + seek(t) { + const was = this.playing; + this.pause(); + this.offset = Math.max(0, Math.min(t, this.duration)); + state.trackTime = this.offset; + if (was) this.play(this.offset); + } +} + +function startSynth(offset = 0) { + ensureAudioCtx(); + disconnectAudioSource(); + document.getElementById("yt-host").classList.remove("visible"); + if (state.yt && state.ytReady) { + try { state.yt.pauseVideo(); } catch (_) {} + } + state.synth = new SingularityEngine(state.audio.ctx, state.audio.master, TRACK_DUR); + state.synth.play(offset); + state.source = "synth"; + state.playing = true; + state.trackDur = TRACK_DUR; + ui.playBtn.textContent = "PAUSE"; + ui.synthBtn.classList.add("active"); + ui.micBtn.classList.remove("active"); + ui.fftBtn.classList.remove("active"); + ui.ytBtn.classList.remove("active"); + ui.statusHint.textContent = "SOURCE · SYNTH ENGINE · TRUE FFT · YT MIRROR"; + ui.tDur.textContent = fmt(TRACK_DUR); + toast("SINGULARITY ENGINE ONLINE"); +} + async function enableMic() { ensureAudioCtx(); disconnectAudioSource(); @@ -651,6 +996,7 @@

INTERACT

state.source = "mic"; state.playing = true; ui.playBtn.textContent = "LISTENING"; + ui.synthBtn.classList.remove("active"); ui.micBtn.classList.add("active"); ui.fftBtn.classList.remove("active"); ui.ytBtn.classList.remove("active"); @@ -672,8 +1018,7 @@

INTERACT

await el.play().catch(() => {}); el.pause(); const src = state.audio.ctx.createMediaElementSource(el); - src.connect(state.audio.analyser); - state.audio.analyser.connect(state.audio.gain); + src.connect(state.audio.master); state.audio.sourceNode = src; state.audio.mediaEl = el; state.source = "file"; @@ -682,6 +1027,7 @@

INTERACT

state.trackDur = el.duration || 0; ui.tDur.textContent = fmt(state.trackDur); }); + ui.synthBtn.classList.remove("active"); ui.micBtn.classList.remove("active"); ui.fftBtn.classList.add("active"); ui.ytBtn.classList.remove("active"); @@ -695,11 +1041,28 @@

INTERACT

function useYouTubeSource() { disconnectAudioSource(); state.source = "youtube"; + ui.synthBtn.classList.remove("active"); ui.micBtn.classList.remove("active"); ui.fftBtn.classList.remove("active"); ui.ytBtn.classList.add("active"); ui.statusHint.textContent = "SOURCE · YOUTUBE · HYBRID REACTIVITY"; - toast("YOUTUBE SOURCE ACTIVE"); + const host = document.getElementById("yt-host"); + host.classList.add("visible"); + toast(state.ytFailed ? "YOUTUBE MAY BE BLOCKED — TRY SYNTH" : "YOUTUBE SOURCE ACTIVE"); + if (state.yt && state.ytReady) { + try { + state.yt.unMute(); + state.yt.setVolume(state.volume); + state.yt.playVideo(); + state.playing = true; + ui.playBtn.textContent = "PAUSE"; + } catch (_) { + toast("OPENING YOUTUBE…"); + window.open(`https://youtu.be/${YT_ID}`, "_blank", "noopener"); + } + } else { + window.open(`https://youtu.be/${YT_ID}`, "_blank", "noopener"); + } } function hybridBands(t, playing) { @@ -743,9 +1106,9 @@

INTERACT

function analyseAudio() { const a = state.audio.analyser; - if (!a || !state.audio.freq || (state.source !== "file" && state.source !== "mic")) { + const liveFFT = state.source === "file" || state.source === "mic" || state.source === "synth"; + if (!a || !state.audio.freq || !liveFFT || (state.source === "synth" && !state.playing)) { const h = hybridBands(state.trackTime || state.time, state.playing || state.source === "mic"); - // soften toward hybrid for continuity state.bands.bass = THREE.MathUtils.lerp(state.bands.bass, h.bass, 0.35); state.bands.mid = THREE.MathUtils.lerp(state.bands.mid, h.mid, 0.35); state.bands.high = THREE.MathUtils.lerp(state.bands.high, h.high, 0.35); @@ -792,11 +1155,11 @@

INTERACT

powerPreference: "high-performance", alpha: false, }); -renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); +renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); renderer.setSize(window.innerWidth, window.innerHeight); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; -renderer.toneMappingExposure = 1.05; +renderer.toneMappingExposure = 0.92; const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x02060c, 0.012); @@ -808,14 +1171,14 @@

INTERACT

const composer = new EffectComposer(renderer); const renderPass = new RenderPass(scene, camera); composer.addPass(renderPass); -const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.2, 0.7, 0.2); +const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.95, 0.55, 0.28); composer.addPass(bloomPass); -const afterimagePass = new AfterimagePass(0.82); +const afterimagePass = new AfterimagePass(0.7); composer.addPass(afterimagePass); composer.addPass(new OutputPass()); // Starfield -const STAR_COUNT = 6000; +const STAR_COUNT = 3500; const starGeo = new THREE.BufferGeometry(); const starPos = new Float32Array(STAR_COUNT * 3); const starVel = new Float32Array(STAR_COUNT); @@ -999,7 +1362,7 @@

INTERACT

} // Particle storm (inward fall) -const STORM = 4500; +const STORM = 2800; const stormGeo = new THREE.BufferGeometry(); const stormPos = new Float32Array(STORM * 3); const stormSpeed = new Float32Array(STORM); @@ -1275,7 +1638,15 @@

INTERACT

} function syncTransport() { - if (state.source === "youtube" && state.yt && state.ytReady) { + if (state.source === "synth" && state.synth) { + state.trackTime = state.synth.now(); + state.trackDur = TRACK_DUR; + if (!state.seeking) { + ui.seek.value = String((state.trackTime / TRACK_DUR) * 1000); + ui.tCur.textContent = fmt(state.trackTime); + ui.tDur.textContent = fmt(TRACK_DUR); + } + } else if (state.source === "youtube" && state.yt && state.ytReady) { try { const t = state.yt.getCurrentTime() || 0; const d = state.yt.getDuration() || state.trackDur || 0; @@ -1304,6 +1675,23 @@

INTERACT

toast("MIC MODE — LIVE INPUT"); return; } + if (state.source === "synth") { + ensureAudioCtx(); + if (!state.synth) { + startSynth(state.trackTime || 0); + return; + } + if (state.synth.playing) { + state.synth.pause(); + state.playing = false; + ui.playBtn.textContent = "PLAY"; + } else { + state.synth.play(state.synth.offset || state.trackTime || 0); + state.playing = true; + ui.playBtn.textContent = "PAUSE"; + } + return; + } if (state.source === "file" && state.audio.mediaEl) { ensureAudioCtx(); if (state.audio.mediaEl.paused) { @@ -1319,7 +1707,8 @@

INTERACT

} // youtube if (!state.yt || !state.ytReady) { - toast("LOADING YOUTUBE…"); + toast("YOUTUBE UNAVAILABLE — STARTING SYNTH"); + startSynth(0); return; } const st = state.yt.getPlayerState(); @@ -1392,13 +1781,14 @@

INTERACT

ui.enterBtn.addEventListener("click", async () => { boot.classList.add("hide"); ensureAudioCtx(); - ui.ytBtn.classList.add("active"); toast("ENGAGING SINGULARITY"); + // Prefer true-FFT synth engine; YouTube remains available as mirror source + startSynth(0); + // Warm YouTube in background for optional switch try { if (state.yt && state.ytReady) { - state.yt.unMute(); - state.yt.setVolume(state.volume); - state.yt.playVideo(); + state.yt.mute(); + state.yt.cueVideoById(YT_ID); } } catch (_) {} }); @@ -1413,14 +1803,17 @@

INTERACT

if (state.audio.gain) state.audio.gain.gain.value = state.muted ? 0 : 1; ui.volBtn.classList.toggle("active", state.muted); ui.volBtn.textContent = state.muted ? "MUTED" : "VOL"; + toast(state.muted ? "AUDIO MUTED" : "AUDIO LIVE"); }); ui.seek.addEventListener("pointerdown", () => { state.seeking = true; }); ui.seek.addEventListener("pointerup", () => { - const d = state.trackDur || 1; + const d = state.trackDur || TRACK_DUR || 1; const t = (Number(ui.seek.value) / 1000) * d; + if (state.source === "synth" && state.synth) state.synth.seek(t); if (state.source === "youtube" && state.yt) state.yt.seekTo(t, true); if (state.source === "file" && state.audio.mediaEl) state.audio.mediaEl.currentTime = t; + state.trackTime = t; state.seeking = false; }); ui.seek.addEventListener("input", () => { @@ -1428,6 +1821,7 @@

INTERACT

ui.tCur.textContent = fmt((Number(ui.seek.value) / 1000) * d); }); +ui.synthBtn.addEventListener("click", () => startSynth(state.trackTime || 0)); ui.micBtn.addEventListener("click", async () => { try { await enableMic(); } catch { toast("MIC PERMISSION DENIED"); } @@ -1437,10 +1831,7 @@

INTERACT

const f = ui.fileInput.files?.[0]; if (f) await loadAudioFile(f); }); -ui.ytBtn.addEventListener("click", () => { - useYouTubeSource(); - if (state.yt) state.yt.playVideo(); -}); +ui.ytBtn.addEventListener("click", () => useYouTubeSource()); ui.fsBtn.addEventListener("click", toggleFullscreen); ui.shockBtn.addEventListener("click", () => triggerShock(1.6)); @@ -1531,11 +1922,10 @@

INTERACT

} // boot -ui.ytBtn.classList.add("active"); +ui.synthBtn.classList.add("active"); initYouTube().then(() => { state.ready = true; - toast("YOUTUBE READY"); -}).catch(() => toast("YOUTUBE UNAVAILABLE — USE FILE FFT")); +}).catch(() => { state.ytFailed = true; }); requestAnimationFrame(frame); diff --git a/index.html b/index.html index c0ed383..aba8974 100644 --- a/index.html +++ b/index.html @@ -42,11 +42,29 @@ } #yt-host { - position: absolute; - width: 1px; height: 1px; - left: -9999px; top: -9999px; - opacity: 0; pointer-events: none; + position: fixed; + right: 18px; + bottom: 118px; + width: 220px; + height: 124px; + z-index: 12; + border: 1px solid var(--line); + background: #000; overflow: hidden; + opacity: 0; + pointer-events: none; + transform: translateY(8px); + transition: opacity 220ms ease, transform 220ms ease; + box-shadow: 0 12px 40px rgba(0,0,0,0.45); + } + #yt-host.visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); + } + #yt-host iframe { width: 100% !important; height: 100% !important; } + @media (max-width: 820px) { + #yt-host { width: 160px; height: 90px; bottom: 150px; right: 12px; } } .vignette { @@ -366,9 +384,11 @@
EVENT HORIZON PROTOCOL

SINGULARITY

-

Enter a high-energy 3D audiovisual wormhole driven by the full track. Drag to orbit. Click to pulse gravity. Keys reshape reality.

+

Enter a high-energy 3D audiovisual wormhole driven by SINGULARITY — a full-length EDM/dubstep assault with true FFT reactivity, plus the YouTube source linked below.

-

TRACK SOURCE · YOUTUBE · yyF1UxGRXVs

+

+ YOUTUBE MIRROR · yyF1UxGRXVs +

@@ -426,12 +446,13 @@

INTERACT

+ - SOURCE · YOUTUBE · HYBRID REACTIVITY + SOURCE · SYNTH · TRUE FFT · YT MIRROR
@@ -458,6 +479,7 @@

INTERACT

const YT_ID = "yyF1UxGRXVs"; const BPM = 140; const BEAT = 60 / BPM; +const TRACK_DUR = 4 * 60 + 28; // match YouTube mirror length const canvas = document.getElementById("stage"); const flashEl = document.getElementById("flash"); @@ -471,6 +493,7 @@

INTERACT

tCur: document.getElementById("tCur"), tDur: document.getElementById("tDur"), volBtn: document.getElementById("volBtn"), + synthBtn: document.getElementById("synthBtn"), micBtn: document.getElementById("micBtn"), fftBtn: document.getElementById("fftBtn"), ytBtn: document.getElementById("ytBtn"), @@ -500,10 +523,10 @@

INTERACT

mode: "orbit", playing: false, ready: false, - source: "youtube", // youtube | file | mic + source: "synth", // synth | youtube | file | mic volume: 80, muted: false, - bloom: 1.2, + bloom: 0.95, gravity: 1.0, particleMul: 1.0, colorShift: 0, @@ -521,7 +544,7 @@

INTERACT

fps: 60, time: 0, trackTime: 0, - trackDur: 0, + trackDur: TRACK_DUR, audio: { ctx: null, analyser: null, @@ -531,18 +554,18 @@

INTERACT

mediaEl: null, micStream: null, gain: null, + master: null, }, + synth: null, bands: { bass: 0, mid: 0, high: 0, energy: 0, beat: 0 }, yt: null, ytReady: false, + ytFailed: false, }; -function toast(msg) { - toastEl.textContent = msg; - toastEl.classList.add("show"); - clearTimeout(toast._t); - toast._t = setTimeout(() => toastEl.classList.remove("show"), 1800); -} +ui.tDur.textContent = fmt(TRACK_DUR); +ui.bloomSlider.value = "0.95"; +ui.bloomVal.textContent = "0.95"; function fmt(t) { t = Math.max(0, t || 0); @@ -551,6 +574,13 @@

INTERACT

return `${m}:${s.toString().padStart(2, "0")}`; } +function toast(msg) { + toastEl.textContent = msg; + toastEl.classList.add("show"); + clearTimeout(toast._t); + toast._t = setTimeout(() => toastEl.classList.remove("show"), 1800); +} + function loadYouTubeAPI() { return new Promise((resolve) => { if (window.YT && window.YT.Player) return resolve(); @@ -592,6 +622,8 @@

INTERACT

resolve(); }, onStateChange: (e) => { + // Only drive transport UI while YouTube is the active source + if (state.source !== "youtube") return; state.playing = e.data === YT.PlayerState.PLAYING; ui.playBtn.textContent = state.playing ? "PAUSE" : "PLAY"; if (e.data === YT.PlayerState.ENDED) { @@ -599,7 +631,10 @@

INTERACT

ui.playBtn.textContent = "PLAY"; } }, - onError: () => toast("YOUTUBE EMBED ERROR — TRY FILE FFT"), + onError: () => { + state.ytFailed = true; + toast("YOUTUBE BLOCKED HERE — SYNTH ENGINE READY"); + }, }, }); }); @@ -611,11 +646,15 @@

INTERACT

state.audio.ctx = new Ctx(); state.audio.analyser = state.audio.ctx.createAnalyser(); state.audio.analyser.fftSize = 2048; - state.audio.analyser.smoothingTimeConstant = 0.78; + state.audio.analyser.smoothingTimeConstant = 0.72; state.audio.data = new Uint8Array(state.audio.analyser.frequencyBinCount); state.audio.freq = new Uint8Array(state.audio.analyser.frequencyBinCount); + state.audio.master = state.audio.ctx.createGain(); + state.audio.master.gain.value = 0.9; state.audio.gain = state.audio.ctx.createGain(); state.audio.gain.gain.value = 1; + state.audio.master.connect(state.audio.analyser); + state.audio.analyser.connect(state.audio.gain); state.audio.gain.connect(state.audio.ctx.destination); } if (state.audio.ctx.state === "suspended") state.audio.ctx.resume(); @@ -623,6 +662,10 @@

INTERACT

} function disconnectAudioSource() { + if (state.synth) { + state.synth.stop(); + state.synth = null; + } if (state.audio.sourceNode) { try { state.audio.sourceNode.disconnect(); } catch (_) {} state.audio.sourceNode = null; @@ -637,6 +680,308 @@

INTERACT

} } +/** Procedural full-length EDM/dubstep engine — SINGULARITY */ +class SingularityEngine { + constructor(ctx, dest, duration = TRACK_DUR) { + this.ctx = ctx; + this.dest = dest; + this.duration = duration; + this.playing = false; + this.startCtxTime = 0; + this.offset = 0; + this.timer = null; + this.nodes = []; + this.bpm = BPM; + this.beat = 60 / this.bpm; + this.scheduled = new Set(); + } + + now() { + if (!this.playing) return this.offset; + return Math.min(this.duration, this.offset + (this.ctx.currentTime - this.startCtxTime)); + } + + sectionAt(t) { + const bar = Math.floor(t / (this.beat * 4)); + const cycle = bar % 32; + if (cycle < 4) return "intro"; + if (cycle < 8) return "groove"; + if (cycle < 12) return "build"; + if (cycle < 16) return "drop"; + if (cycle < 20) return "break"; + if (cycle < 24) return "build2"; + if (cycle < 28) return "drop2"; + return "outro"; + } + + env(gain, t0, a, d, s, r, peak = 1, sustainLevel = 0.001) { + gain.gain.cancelScheduledValues(t0); + gain.gain.setValueAtTime(0.0001, t0); + gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, peak), t0 + a); + gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, s), t0 + a + d); + gain.gain.exponentialRampToValueAtTime(sustainLevel, t0 + a + d + r); + } + + mkGain(v = 1) { + const g = this.ctx.createGain(); + g.gain.value = v; + this.nodes.push(g); + return g; + } + + kick(t) { + const o = this.ctx.createOscillator(); + const g = this.mkGain(); + o.type = "sine"; + o.frequency.setValueAtTime(170, t); + o.frequency.exponentialRampToValueAtTime(42, t + 0.12); + this.env(g, t, 0.001, 0.08, 0.0001, 0.18, 1.1); + o.connect(g); g.connect(this.dest); + o.start(t); o.stop(t + 0.35); + this.nodes.push(o); + } + + snare(t, vel = 0.7) { + const dur = 0.22; + const buffer = this.ctx.createBuffer(1, this.ctx.sampleRate * dur, this.ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; + const src = this.ctx.createBufferSource(); + src.buffer = buffer; + const bp = this.ctx.createBiquadFilter(); + bp.type = "bandpass"; + bp.frequency.value = 1800; + bp.Q.value = 0.8; + const g = this.mkGain(); + this.env(g, t, 0.001, 0.05, 0.0001, 0.14, vel); + src.connect(bp); bp.connect(g); g.connect(this.dest); + src.start(t); src.stop(t + dur); + this.nodes.push(src, bp); + + const o = this.ctx.createOscillator(); + const og = this.mkGain(); + o.type = "triangle"; + o.frequency.setValueAtTime(220, t); + o.frequency.exponentialRampToValueAtTime(110, t + 0.08); + this.env(og, t, 0.001, 0.04, 0.0001, 0.08, vel * 0.35); + o.connect(og); og.connect(this.dest); + o.start(t); o.stop(t + 0.12); + this.nodes.push(o); + } + + hat(t, open = false) { + const dur = open ? 0.18 : 0.05; + const buffer = this.ctx.createBuffer(1, this.ctx.sampleRate * dur, this.ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; + const src = this.ctx.createBufferSource(); + src.buffer = buffer; + const hp = this.ctx.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = 7000; + const g = this.mkGain(); + this.env(g, t, 0.001, 0.02, 0.0001, open ? 0.12 : 0.03, open ? 0.28 : 0.18); + src.connect(hp); hp.connect(g); g.connect(this.dest); + src.start(t); src.stop(t + dur); + this.nodes.push(src, hp); + } + + wobble(t, len, intensity = 1) { + const o1 = this.ctx.createOscillator(); + const o2 = this.ctx.createOscillator(); + o1.type = "sawtooth"; + o2.type = "square"; + const base = 55; + o1.frequency.value = base; + o2.frequency.value = base * 1.01; + const lfo = this.ctx.createOscillator(); + const lfoG = this.mkGain(800 * intensity); + lfo.frequency.value = this.bpm / 60 * (intensity > 1 ? 0.5 : 0.25); + const filt = this.ctx.createBiquadFilter(); + filt.type = "lowpass"; + filt.frequency.value = 120; + filt.Q.value = 10; + lfo.connect(lfoG); lfoG.connect(filt.frequency); + const g = this.mkGain(0.22 * intensity); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(0.22 * intensity, t + 0.05); + g.gain.setValueAtTime(0.22 * intensity, t + len - 0.05); + g.gain.exponentialRampToValueAtTime(0.0001, t + len); + o1.connect(filt); o2.connect(filt); filt.connect(g); g.connect(this.dest); + o1.start(t); o2.start(t); lfo.start(t); + o1.stop(t + len); o2.stop(t + len); lfo.stop(t + len); + this.nodes.push(o1, o2, lfo, filt); + } + + lead(t, freq, len, gain = 0.12) { + const voices = [0, 0.01, -0.01].map((det) => { + const o = this.ctx.createOscillator(); + o.type = "sawtooth"; + o.frequency.value = freq * (1 + det); + return o; + }); + const filt = this.ctx.createBiquadFilter(); + filt.type = "lowpass"; + filt.frequency.setValueAtTime(600, t); + filt.frequency.exponentialRampToValueAtTime(4200, t + len * 0.35); + filt.frequency.exponentialRampToValueAtTime(800, t + len); + const g = this.mkGain(); + this.env(g, t, 0.02, 0.1, gain * 0.7, len * 0.7, gain); + voices.forEach((o) => { o.connect(filt); o.start(t); o.stop(t + len + 0.02); this.nodes.push(o); }); + filt.connect(g); g.connect(this.dest); + this.nodes.push(filt); + } + + riser(t, len) { + const buffer = this.ctx.createBuffer(1, this.ctx.sampleRate * len, this.ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * (i / data.length); + const src = this.ctx.createBufferSource(); + src.buffer = buffer; + const bp = this.ctx.createBiquadFilter(); + bp.type = "bandpass"; + bp.frequency.setValueAtTime(400, t); + bp.frequency.exponentialRampToValueAtTime(8000, t + len); + const g = this.mkGain(0.0001); + g.gain.exponentialRampToValueAtTime(0.35, t + len); + src.connect(bp); bp.connect(g); g.connect(this.dest); + src.start(t); src.stop(t + len); + this.nodes.push(src, bp); + } + + scheduleWindow(from, to) { + const startBeat = Math.floor(from / this.beat); + const endBeat = Math.ceil(to / this.beat); + const tNow = this.now(); + for (let b = startBeat; b < endBeat; b++) { + if (this.scheduled.has(b)) continue; + const tTrack = b * this.beat; + if (tTrack < from - 0.001 || tTrack >= this.duration) continue; + let when = this.ctx.currentTime + (tTrack - tNow); + if (when < this.ctx.currentTime - 0.05) continue; + if (when < this.ctx.currentTime + 0.02) when = this.ctx.currentTime + 0.02; + this.scheduled.add(b); + const sec = this.sectionAt(tTrack); + const beatInBar = b % 4; + const intense = sec === "drop" || sec === "drop2"; + const build = sec === "build" || sec === "build2"; + + // kick + if (sec !== "break" || beatInBar === 0) { + if (intense || beatInBar === 0 || beatInBar === 2 || (build && beatInBar !== 3)) { + this.kick(when); + } + } + // snare + if (beatInBar === 1 || beatInBar === 3) { + if (sec !== "intro") this.snare(when, intense ? 0.9 : 0.55); + } + // hats + if (sec !== "intro") { + this.hat(when, false); + if (intense || build) this.hat(when + this.beat * 0.5, build); + } + // wobble every bar + if (beatInBar === 0 && (intense || sec === "groove" || build)) { + this.wobble(when, this.beat * 4, intense ? 1.35 : build ? 0.8 : 0.65); + } + // lead hooks on drops + if (intense && beatInBar === 0) { + const scale = [55, 65.41, 73.42, 82.41, 98, 110]; + const f = scale[(b / 4) % scale.length | 0] * 4; + this.lead(when, f, this.beat * 2, 0.1); + this.lead(when + this.beat * 2, f * 1.5, this.beat * 1.5, 0.08); + } + // riser into drops + if (build && beatInBar === 0 && (b % 16) === 12) { + this.riser(when, this.beat * 4); + } + } + } + + tick() { + if (!this.playing) return; + const t = this.now(); + if (t >= this.duration - 0.05) { + this.stop(true); + state.playing = false; + ui.playBtn.textContent = "PLAY"; + return; + } + this.scheduleWindow(t, t + 1.25); + state.trackTime = t; + } + + play(offset = 0) { + this.stop(false); + this.offset = Math.max(0, Math.min(offset, this.duration - 0.05)); + this.startCtxTime = this.ctx.currentTime; + this.playing = true; + this.nodes = []; + this.scheduled = new Set(); + this.tick(); + this.timer = setInterval(() => this.tick(), 180); + } + + pause() { + if (!this.playing) return; + this.offset = this.now(); + this.playing = false; + clearInterval(this.timer); + this.timer = null; + // silence scheduled voices + const t = this.ctx.currentTime; + this.nodes.forEach((n) => { + try { if (n.stop) n.stop(t); } catch (_) {} + try { n.disconnect(); } catch (_) {} + }); + this.nodes = []; + } + + stop(reset = true) { + this.playing = false; + clearInterval(this.timer); + this.timer = null; + const t = this.ctx.currentTime; + this.nodes.forEach((n) => { + try { if (n.stop) n.stop(t); } catch (_) {} + try { n.disconnect(); } catch (_) {} + }); + this.nodes = []; + if (reset) this.offset = 0; + } + + seek(t) { + const was = this.playing; + this.pause(); + this.offset = Math.max(0, Math.min(t, this.duration)); + state.trackTime = this.offset; + if (was) this.play(this.offset); + } +} + +function startSynth(offset = 0) { + ensureAudioCtx(); + disconnectAudioSource(); + document.getElementById("yt-host").classList.remove("visible"); + if (state.yt && state.ytReady) { + try { state.yt.pauseVideo(); } catch (_) {} + } + state.synth = new SingularityEngine(state.audio.ctx, state.audio.master, TRACK_DUR); + state.synth.play(offset); + state.source = "synth"; + state.playing = true; + state.trackDur = TRACK_DUR; + ui.playBtn.textContent = "PAUSE"; + ui.synthBtn.classList.add("active"); + ui.micBtn.classList.remove("active"); + ui.fftBtn.classList.remove("active"); + ui.ytBtn.classList.remove("active"); + ui.statusHint.textContent = "SOURCE · SYNTH ENGINE · TRUE FFT · YT MIRROR"; + ui.tDur.textContent = fmt(TRACK_DUR); + toast("SINGULARITY ENGINE ONLINE"); +} + async function enableMic() { ensureAudioCtx(); disconnectAudioSource(); @@ -651,6 +996,7 @@

INTERACT

state.source = "mic"; state.playing = true; ui.playBtn.textContent = "LISTENING"; + ui.synthBtn.classList.remove("active"); ui.micBtn.classList.add("active"); ui.fftBtn.classList.remove("active"); ui.ytBtn.classList.remove("active"); @@ -672,8 +1018,7 @@

INTERACT

await el.play().catch(() => {}); el.pause(); const src = state.audio.ctx.createMediaElementSource(el); - src.connect(state.audio.analyser); - state.audio.analyser.connect(state.audio.gain); + src.connect(state.audio.master); state.audio.sourceNode = src; state.audio.mediaEl = el; state.source = "file"; @@ -682,6 +1027,7 @@

INTERACT

state.trackDur = el.duration || 0; ui.tDur.textContent = fmt(state.trackDur); }); + ui.synthBtn.classList.remove("active"); ui.micBtn.classList.remove("active"); ui.fftBtn.classList.add("active"); ui.ytBtn.classList.remove("active"); @@ -695,11 +1041,28 @@

INTERACT

function useYouTubeSource() { disconnectAudioSource(); state.source = "youtube"; + ui.synthBtn.classList.remove("active"); ui.micBtn.classList.remove("active"); ui.fftBtn.classList.remove("active"); ui.ytBtn.classList.add("active"); ui.statusHint.textContent = "SOURCE · YOUTUBE · HYBRID REACTIVITY"; - toast("YOUTUBE SOURCE ACTIVE"); + const host = document.getElementById("yt-host"); + host.classList.add("visible"); + toast(state.ytFailed ? "YOUTUBE MAY BE BLOCKED — TRY SYNTH" : "YOUTUBE SOURCE ACTIVE"); + if (state.yt && state.ytReady) { + try { + state.yt.unMute(); + state.yt.setVolume(state.volume); + state.yt.playVideo(); + state.playing = true; + ui.playBtn.textContent = "PAUSE"; + } catch (_) { + toast("OPENING YOUTUBE…"); + window.open(`https://youtu.be/${YT_ID}`, "_blank", "noopener"); + } + } else { + window.open(`https://youtu.be/${YT_ID}`, "_blank", "noopener"); + } } function hybridBands(t, playing) { @@ -743,9 +1106,9 @@

INTERACT

function analyseAudio() { const a = state.audio.analyser; - if (!a || !state.audio.freq || (state.source !== "file" && state.source !== "mic")) { + const liveFFT = state.source === "file" || state.source === "mic" || state.source === "synth"; + if (!a || !state.audio.freq || !liveFFT || (state.source === "synth" && !state.playing)) { const h = hybridBands(state.trackTime || state.time, state.playing || state.source === "mic"); - // soften toward hybrid for continuity state.bands.bass = THREE.MathUtils.lerp(state.bands.bass, h.bass, 0.35); state.bands.mid = THREE.MathUtils.lerp(state.bands.mid, h.mid, 0.35); state.bands.high = THREE.MathUtils.lerp(state.bands.high, h.high, 0.35); @@ -792,11 +1155,11 @@

INTERACT

powerPreference: "high-performance", alpha: false, }); -renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); +renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); renderer.setSize(window.innerWidth, window.innerHeight); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; -renderer.toneMappingExposure = 1.05; +renderer.toneMappingExposure = 0.92; const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x02060c, 0.012); @@ -808,14 +1171,14 @@

INTERACT

const composer = new EffectComposer(renderer); const renderPass = new RenderPass(scene, camera); composer.addPass(renderPass); -const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.2, 0.7, 0.2); +const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.95, 0.55, 0.28); composer.addPass(bloomPass); -const afterimagePass = new AfterimagePass(0.82); +const afterimagePass = new AfterimagePass(0.7); composer.addPass(afterimagePass); composer.addPass(new OutputPass()); // Starfield -const STAR_COUNT = 6000; +const STAR_COUNT = 3500; const starGeo = new THREE.BufferGeometry(); const starPos = new Float32Array(STAR_COUNT * 3); const starVel = new Float32Array(STAR_COUNT); @@ -999,7 +1362,7 @@

INTERACT

} // Particle storm (inward fall) -const STORM = 4500; +const STORM = 2800; const stormGeo = new THREE.BufferGeometry(); const stormPos = new Float32Array(STORM * 3); const stormSpeed = new Float32Array(STORM); @@ -1275,7 +1638,15 @@

INTERACT

} function syncTransport() { - if (state.source === "youtube" && state.yt && state.ytReady) { + if (state.source === "synth" && state.synth) { + state.trackTime = state.synth.now(); + state.trackDur = TRACK_DUR; + if (!state.seeking) { + ui.seek.value = String((state.trackTime / TRACK_DUR) * 1000); + ui.tCur.textContent = fmt(state.trackTime); + ui.tDur.textContent = fmt(TRACK_DUR); + } + } else if (state.source === "youtube" && state.yt && state.ytReady) { try { const t = state.yt.getCurrentTime() || 0; const d = state.yt.getDuration() || state.trackDur || 0; @@ -1304,6 +1675,23 @@

INTERACT

toast("MIC MODE — LIVE INPUT"); return; } + if (state.source === "synth") { + ensureAudioCtx(); + if (!state.synth) { + startSynth(state.trackTime || 0); + return; + } + if (state.synth.playing) { + state.synth.pause(); + state.playing = false; + ui.playBtn.textContent = "PLAY"; + } else { + state.synth.play(state.synth.offset || state.trackTime || 0); + state.playing = true; + ui.playBtn.textContent = "PAUSE"; + } + return; + } if (state.source === "file" && state.audio.mediaEl) { ensureAudioCtx(); if (state.audio.mediaEl.paused) { @@ -1319,7 +1707,8 @@

INTERACT

} // youtube if (!state.yt || !state.ytReady) { - toast("LOADING YOUTUBE…"); + toast("YOUTUBE UNAVAILABLE — STARTING SYNTH"); + startSynth(0); return; } const st = state.yt.getPlayerState(); @@ -1392,13 +1781,14 @@

INTERACT

ui.enterBtn.addEventListener("click", async () => { boot.classList.add("hide"); ensureAudioCtx(); - ui.ytBtn.classList.add("active"); toast("ENGAGING SINGULARITY"); + // Prefer true-FFT synth engine; YouTube remains available as mirror source + startSynth(0); + // Warm YouTube in background for optional switch try { if (state.yt && state.ytReady) { - state.yt.unMute(); - state.yt.setVolume(state.volume); - state.yt.playVideo(); + state.yt.mute(); + state.yt.cueVideoById(YT_ID); } } catch (_) {} }); @@ -1413,14 +1803,17 @@

INTERACT

if (state.audio.gain) state.audio.gain.gain.value = state.muted ? 0 : 1; ui.volBtn.classList.toggle("active", state.muted); ui.volBtn.textContent = state.muted ? "MUTED" : "VOL"; + toast(state.muted ? "AUDIO MUTED" : "AUDIO LIVE"); }); ui.seek.addEventListener("pointerdown", () => { state.seeking = true; }); ui.seek.addEventListener("pointerup", () => { - const d = state.trackDur || 1; + const d = state.trackDur || TRACK_DUR || 1; const t = (Number(ui.seek.value) / 1000) * d; + if (state.source === "synth" && state.synth) state.synth.seek(t); if (state.source === "youtube" && state.yt) state.yt.seekTo(t, true); if (state.source === "file" && state.audio.mediaEl) state.audio.mediaEl.currentTime = t; + state.trackTime = t; state.seeking = false; }); ui.seek.addEventListener("input", () => { @@ -1428,6 +1821,7 @@

INTERACT

ui.tCur.textContent = fmt((Number(ui.seek.value) / 1000) * d); }); +ui.synthBtn.addEventListener("click", () => startSynth(state.trackTime || 0)); ui.micBtn.addEventListener("click", async () => { try { await enableMic(); } catch { toast("MIC PERMISSION DENIED"); } @@ -1437,10 +1831,7 @@

INTERACT

const f = ui.fileInput.files?.[0]; if (f) await loadAudioFile(f); }); -ui.ytBtn.addEventListener("click", () => { - useYouTubeSource(); - if (state.yt) state.yt.playVideo(); -}); +ui.ytBtn.addEventListener("click", () => useYouTubeSource()); ui.fsBtn.addEventListener("click", toggleFullscreen); ui.shockBtn.addEventListener("click", () => triggerShock(1.6)); @@ -1531,11 +1922,10 @@

INTERACT

} // boot -ui.ytBtn.classList.add("active"); +ui.synthBtn.classList.add("active"); initYouTube().then(() => { state.ready = true; - toast("YOUTUBE READY"); -}).catch(() => toast("YOUTUBE UNAVAILABLE — USE FILE FFT")); +}).catch(() => { state.ytFailed = true; }); requestAnimationFrame(frame);