/* global React, ReactDOM, Portal, Gallery, Themes, Journal, Prints, About, Feedback, Atmosphere */ const { useState, useEffect, useRef } = React; const TWEAKS = /*EDITMODE-BEGIN*/{ "grainOpacity": 0.22, "whispersOn": true, "soundOn": false, "soundVolume": 0.35, "showAiThought": true, "palette": "violet-night" } /*EDITMODE-END*/; // Audio engine — Indian tanpura-like plucked drone + breath + occasional bowl // Plus a softer "namaste" bell exposed on window.__darshanBell, triggered on chamber enter. function useAmbientSound(on, volume) { const ctxRef = useRef(null); const masterRef = useRef(null); const reverbRef = useRef(null); const stopRef = useRef(() => {}); // ── set up / tear down on `on` flip ───────────────────────────── useEffect(() => { if (!on) { const ctx = ctxRef.current; const m = masterRef.current; if (ctx && m) { m.gain.cancelScheduledValues(ctx.currentTime); m.gain.linearRampToValueAtTime(0.0001, ctx.currentTime + 1.4); // suspend after fade so plucks stop scheduling setTimeout(() => { try { ctx.suspend(); } catch (e) {} }, 1600); } return; } // First-time setup if (!ctxRef.current) { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return; const ctx = new AC(); ctxRef.current = ctx; // ── master ── const master = ctx.createGain(); master.gain.value = 0; master.connect(ctx.destination); masterRef.current = master; // ── simple feedback-delay "reverb" send ── const reverbIn = ctx.createGain(); reverbIn.gain.value = 1; const delays = [0.087, 0.119, 0.151, 0.181].map((t, i) => { const d = ctx.createDelay(4); d.delayTime.value = t; const fb = ctx.createGain(); fb.gain.value = 0.58 - i * 0.03; const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 2200; d.connect(lp).connect(fb).connect(d); reverbIn.connect(d); d.connect(master); return d; }); const reverbWet = ctx.createGain(); reverbWet.gain.value = 0.65; delays.forEach((d) => d.connect(reverbWet)); reverbWet.connect(master); reverbRef.current = reverbIn; // ── tanpura pluck (additive sine with harmonics, soft attack, long decay) ── // Traditional tanpura cycle: Pa - Sa - Sa - Sa (5th, then root x3) // Tuned to C (Sa = C2/C3, Pa = G2) const SA_LOW = 65.41; // C2 const PA = 98.00; // G2 const SA_MID = 130.81; // C3 const cycle = [PA, SA_MID, SA_MID, SA_LOW]; // pa, sa, sa, sa-low let cIdx = 0; const pluck = (freq, when, accent = 1.0) => { const harmonics = [ { mult: 1, gain: 0.50 }, { mult: 2, gain: 0.22 }, { mult: 3, gain: 0.12 }, { mult: 4, gain: 0.06 }, { mult: 5.02, gain: 0.04 }, // slight detune for sympathetic shimmer { mult: 6, gain: 0.025 } ]; // Envelope — soft attack, long exponential decay const env = ctx.createGain(); env.gain.setValueAtTime(0.0001, when); env.gain.linearRampToValueAtTime(0.28 * accent, when + 0.06); env.gain.exponentialRampToValueAtTime(0.0001, when + 7.0); env.connect(master); env.connect(reverbIn); // Soft body filter — keeps it warm const body = ctx.createBiquadFilter(); body.type = "lowpass"; body.frequency.setValueAtTime(2400, when); body.frequency.exponentialRampToValueAtTime(900, when + 6.5); body.Q.value = 0.4; body.connect(env); harmonics.forEach((h, i) => { const o = ctx.createOscillator(); o.type = "sine"; o.frequency.value = freq * h.mult; const g = ctx.createGain(); g.gain.value = h.gain * (0.92 + Math.random() * 0.16); // gentle pitch wobble (tanpura "jhanjhar" character) const lfo = ctx.createOscillator(); lfo.frequency.value = 0.18 + Math.random() * 0.22; const lfoG = ctx.createGain(); lfoG.gain.value = 0.4 + i * 0.15; lfo.connect(lfoG).connect(o.frequency); o.connect(g).connect(body); o.start(when); o.stop(when + 7.3); lfo.start(when); lfo.stop(when + 7.3); }); }; // ── breath / distant wind ── const bufLen = 3 * ctx.sampleRate; const buf = ctx.createBuffer(1, bufLen, ctx.sampleRate); const ch = buf.getChannelData(0); for (let i = 0; i < bufLen; i++) ch[i] = (Math.random() * 2 - 1) * 0.6; const noise = ctx.createBufferSource(); noise.buffer = buf; noise.loop = true; const noiseLP = ctx.createBiquadFilter(); noiseLP.type = "lowpass"; noiseLP.frequency.value = 340; const noiseHP = ctx.createBiquadFilter(); noiseHP.type = "highpass"; noiseHP.frequency.value = 80; const noiseGain = ctx.createGain(); noiseGain.gain.value = 0.03; const windLfo = ctx.createOscillator(); windLfo.frequency.value = 0.06; const windLfoG = ctx.createGain(); windLfoG.gain.value = 140; windLfo.connect(windLfoG).connect(noiseLP.frequency); // slow breath on amplitude const breathLfo = ctx.createOscillator(); breathLfo.frequency.value = 0.09; const breathLfoG = ctx.createGain(); breathLfoG.gain.value = 0.015; breathLfo.connect(breathLfoG).connect(noiseGain.gain); noise.connect(noiseHP).connect(noiseLP).connect(noiseGain).connect(master); noise.start(); windLfo.start(); breathLfo.start(); // ── tanpura pluck scheduler ── const PLUCK_INTERVAL = 2.6; // seconds between plucks let nextTime = ctx.currentTime + 0.6; const scheduler = setInterval(() => { if (!ctxRef.current) return; const lookahead = ctx.currentTime + 2.0; while (nextTime < lookahead) { const freq = cycle[cIdx % cycle.length]; // every 4th pluck is the deep Sa — slightly louder const accent = (cIdx % cycle.length === 3) ? 1.15 : 1.0; pluck(freq, nextTime, accent); cIdx++; // tiny rubato — slight humanisation nextTime += PLUCK_INTERVAL + (Math.random() - 0.5) * 0.18; } }, 250); // ── singing bowl (rare, deep, long-decay) ── const bowl = (when) => { const root = 220 + Math.random() * 40; // wandering A3-ish // Inharmonic bell ratios (Risset bell) const partials = [ { mult: 1.00, gain: 0.55, decay: 9 }, { mult: 2.00, gain: 0.30, decay: 7 }, { mult: 2.76, gain: 0.20, decay: 6 }, { mult: 4.07, gain: 0.10, decay: 5 }, { mult: 5.42, gain: 0.05, decay: 4.5 } ]; const mainEnv = ctx.createGain(); mainEnv.gain.value = 1; mainEnv.connect(master); mainEnv.connect(reverbIn); partials.forEach((p) => { const o = ctx.createOscillator(); o.type = "sine"; o.frequency.value = root * p.mult; const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, when); g.gain.linearRampToValueAtTime(p.gain * 0.16, when + 0.005); g.gain.exponentialRampToValueAtTime(0.0001, when + p.decay); o.connect(g).connect(mainEnv); o.start(when); o.stop(when + p.decay + 0.2); }); }; const scheduleBowl = () => { if (!ctxRef.current) return; bowl(ctx.currentTime + 0.2); }; // first bowl ~30s after on, then every 60–110s const bowlT1 = setTimeout(function loop() { scheduleBowl(); const next = 60000 + Math.random() * 50000; bowlIv = setTimeout(loop, next); }, 30000); let bowlIv = bowlT1; // ── expose namaste bell for chamber-enter ── window.__darshanBell = () => { const c = ctxRef.current; const m = masterRef.current; if (!c || !m) return; if (c.state === "suspended") return; // only when sound is on const t = c.currentTime + 0.02; // brighter, soft bell — fundamental ~ E5 const root = 659.25; const partials = [ { mult: 1.00, gain: 0.40, decay: 4.5 }, { mult: 2.00, gain: 0.18, decay: 3.2 }, { mult: 2.76, gain: 0.12, decay: 2.6 }, { mult: 4.10, gain: 0.06, decay: 2.0 } ]; const send = c.createGain(); send.gain.value = 1; send.connect(m); if (reverbRef.current) send.connect(reverbRef.current); partials.forEach((p) => { const o = c.createOscillator(); o.type = "sine"; o.frequency.value = root * p.mult; const g = c.createGain(); g.gain.setValueAtTime(0.0001, t); g.gain.linearRampToValueAtTime(p.gain * 0.10, t + 0.004); g.gain.exponentialRampToValueAtTime(0.0001, t + p.decay); o.connect(g).connect(send); o.start(t); o.stop(t + p.decay + 0.2); }); }; stopRef.current = () => { clearInterval(scheduler); clearTimeout(bowlIv); }; ctx.resume(). then(() => { master.gain.cancelScheduledValues(ctx.currentTime); master.gain.linearRampToValueAtTime(volume, ctx.currentTime + 2.4); }). catch(() => {}); } else { // Already initialised — just resume + fade in const ctx = ctxRef.current; const m = masterRef.current; ctx.resume(). then(() => { m.gain.cancelScheduledValues(ctx.currentTime); m.gain.linearRampToValueAtTime(volume, ctx.currentTime + 1.6); }). catch(() => {}); } // NOTE: no teardown returned — we keep the engine alive across toggles. }, [on]); // ── separate effect for volume changes (no rebuild, no flicker) ── useEffect(() => { if (!on) return; const ctx = ctxRef.current; const m = masterRef.current; if (!ctx || !m) return; m.gain.cancelScheduledValues(ctx.currentTime); m.gain.linearRampToValueAtTime(volume, ctx.currentTime + 0.35); }, [volume, on]); // ── tear down on unmount only ── useEffect(() => { return () => { stopRef.current(); try { ctxRef.current && ctxRef.current.close(); } catch (e) {} try { delete window.__darshanBell; } catch (e) {} }; }, []); } function Veil() { const ref = useRef(null); const [done, setDone] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; let raf; const HOLD = 1600,FADE = 1600; const start = performance.now(); const tick = (now) => { const t = now - start; if (t < HOLD) { el.style.opacity = "1"; } else { const p = Math.min(1, (t - HOLD) / FADE); el.style.opacity = String(1 - p); if (p >= 1) {setDone(true);return;} } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, []); if (done) return null; return (
रा॰ Rahul Malviya
photographer · since MMXX
); } function App() { const t = window.useTweaks ? window.useTweaks(TWEAKS) : [TWEAKS, () => {}]; const tweaks = t[0];const setTweak = t[1]; const [scene, setScene] = useState("portal"); // portal | gallery | themes | prints | journal | about | feedback const [galleryStartId, setGalleryStartId] = useState(null); useAmbientSound(tweaks.soundOn, tweaks.soundVolume); // Deep-linking via URL hash: #gallery, #darshan/03, #themes, etc. useEffect(() => { const apply = () => { const h = location.hash.replace(/^#/, ""); if (!h) return; if (h.startsWith("darshan/")) { const id = h.split("/")[1]; setGalleryStartId(id); setScene("gallery"); } else if (["gallery", "themes", "prints", "journal", "about", "feedback"].includes(h)) { setScene(h); } }; apply(); window.addEventListener("hashchange", apply); return () => window.removeEventListener("hashchange", apply); }, []); const go = (key) => { setScene(key); if (key === "portal") location.hash = "";else location.hash = key; try { window.__darshanBell && window.__darshanBell(); } catch (e) {} }; const goHome = () => { setGalleryStartId(null); setScene("portal"); location.hash = ""; try { window.__darshanBell && window.__darshanBell(); } catch (e) {} }; const back = goHome; const openPhoto = (id) => {setGalleryStartId(id);setScene("gallery");location.hash = `darshan/${id}`;try { window.__darshanBell && window.__darshanBell(); } catch (e) {}}; const isGallery = scene === "gallery"; const isPortal = scene === "portal"; const SCENE_LABEL = { portal: "AT THE THRESHOLD", gallery: "DARSHAN · IN PASSAGE", themes: "CHAMBER · विषय / THEMES", prints: "CHAMBER · मुद्रण / PRINTS", journal: "CHAMBER · लेखन / JOURNAL", about: "CHAMBER · परिचय / ABOUT", feedback: "CHAMBER · प्रतिक्रिया / FEEDBACK" }; return ( <> {/* Top chrome */} {!isGallery && (
{SCENE_LABEL[scene] || "·"}
)} {/* Scenes */}
{isGallery && ( setTweak("soundOn", !tweaks.soundOn)} /> )}
{scene === "themes" && }
{scene === "journal" && }
{scene === "prints" && }
{scene === "about" && }
{scene === "feedback" && }
{/* corner hint */}
{isPortal && choose a threshold · or press G to enter the gallery} {isGallery && ← / → to walk · F hold · ? map · esc return} {!isPortal && !isGallery && esc to return to the threshold}
{/* tweaks panel */} {window.TweaksPanel && setTweak("grainOpacity", v)} /> setTweak("whispersOn", v)} /> setTweak("soundOn", v)} /> setTweak("soundVolume", v)} /> setTweak("showAiThought", v)} /> } ); } // hot-key: G to jump to gallery from anywhere; Esc returns window.addEventListener("keydown", (e) => { if (e.target && /input|textarea/i.test(e.target.tagName)) return; if (e.key === "g" || e.key === "G") { if (!location.hash.startsWith("#gallery") && !location.hash.startsWith("#darshan/")) { location.hash = "gallery"; } } }); ReactDOM.createRoot(document.getElementById("root")).render();