diff --git a/app/globals.css b/app/globals.css index e3133cc..daff360 100644 --- a/app/globals.css +++ b/app/globals.css @@ -32,6 +32,11 @@ transform: translateX(-50%); } } + @keyframes wave-run { + to { + transform: translateX(-11px); + } + } @keyframes marquee-rev { from { transform: translateX(-50%); @@ -197,6 +202,23 @@ button { transform: translateY(0); transition-duration: 0.15s; } + /* the sound toggle: a flat line when off, a travelling sine wave when on */ + .wave-amp { + transform-box: fill-box; + transform-origin: center; + transform: scaleY(0.04); + transition: transform 0.7s var(--ease-out-expo); + } + .sound-on .wave-amp { + transform: scaleY(1); + } + .wave-run { + animation: wave-run 1.1s linear infinite; + animation-play-state: paused; + } + .sound-on .wave-run { + animation-play-state: running; + } .gutter { padding-left: max(var(--gutter), env(safe-area-inset-left)); padding-right: max(var(--gutter), env(safe-area-inset-right)); diff --git a/components/home/Nav.jsx b/components/home/Nav.jsx index 065d894..b3f1ba8 100644 --- a/components/home/Nav.jsx +++ b/components/home/Nav.jsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react" import Image from "next/image" import { AnimatePresence, motion } from "motion/react" import { Magnetic } from "./ui" +import SoundToggle from "./SoundToggle" const LINKS = [ ["#vision", "Vision"], @@ -21,8 +22,9 @@ function goTo(href) { else el.scrollIntoView({ behavior: "smooth" }) } -/* The pill on phones: its label rolls from Menu to Close, and its two lines cross into an X. */ +/* On phones: two thin lines that cross into an X. No words, no frame. */ function MenuButton({ open, onClick, buttonRef }) { + const line = "absolute left-1/2 h-[1.5px] w-[22px] -translate-x-1/2 bg-current transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]" return ( ) } @@ -178,17 +153,20 @@ export default function Nav() { Existence - - - setOpen((v) => !v)} buttonRef={buttonRef} /> +
+ +
diff --git a/components/home/SoundToggle.jsx b/components/home/SoundToggle.jsx new file mode 100644 index 0000000..2c1cd41 --- /dev/null +++ b/components/home/SoundToggle.jsx @@ -0,0 +1,131 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { Ambient } from "@lib/ambient" + +const KEY = "existence-sound" + +const readPref = () => { + try { + return localStorage.getItem(KEY) === "on" + } catch { + return false + } +} +const writePref = (on) => { + try { + localStorage.setItem(KEY, on ? "on" : "off") + } catch {} +} + +/* + * The sound switch in the navbar. Browsers only allow sound after the visitor interacts, + * so the score starts off; turning it on is remembered, and on a return visit it resumes + * at the first tap or key press. It fades out whenever the tab is hidden. + */ +export default function SoundToggle() { + const [on, setOn] = useState(false) + const engine = useRef(null) + const wanted = useRef(false) + + const getEngine = () => { + if (!engine.current) { + engine.current = new Ambient() + // development only: lets the engine be measured from the console + if (process.env.NODE_ENV !== "production") window.__ambient = engine.current + } + return engine.current + } + + const play = () => { + // on iPhones this lets the score play even with the ringer switch on silent + try { + if (navigator.audioSession) navigator.audioSession.type = "playback" + } catch {} + const e = getEngine() + e.start() + e.setProgress(progress()) + setOn(true) + } + + const pause = () => { + engine.current?.stop() + setOn(false) + } + + const toggle = () => { + wanted.current = !on + writePref(wanted.current) + wanted.current ? play() : pause() + } + + // returning visitors who left the sound on: resume at their first interaction + useEffect(() => { + if (!readPref()) return + wanted.current = true + const resume = (e) => { + // a tap on the toggle itself is handled by the toggle + if (!e.target?.closest?.("[data-sound-toggle]") && wanted.current) play() + cleanup() + } + const cleanup = () => { + window.removeEventListener("pointerdown", resume, true) + window.removeEventListener("keydown", resume, true) + } + window.addEventListener("pointerdown", resume, true) + window.addEventListener("keydown", resume, true) + return cleanup + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // the score follows the page: harmony and brightness shift as you scroll + useEffect(() => { + const onScroll = () => engine.current?.setProgress(progress()) + window.addEventListener("scroll", onScroll, { passive: true }) + return () => window.removeEventListener("scroll", onScroll) + }, []) + + // fade out when the tab is hidden, back in when it returns + useEffect(() => { + const onVisibility = () => { + if (!engine.current || !wanted.current) return + if (document.hidden) engine.current.stop() + else engine.current.start() + } + document.addEventListener("visibilitychange", onVisibility) + return () => document.removeEventListener("visibilitychange", onVisibility) + }, []) + + useEffect(() => () => engine.current?.stop(), []) + + return ( + + ) +} + +// two and a bit periods of a sine (period 11), so it can slide one period and loop seamlessly +const WAVE = (() => { + let d = "" + for (let x = 0; x <= 33; x += 0.5) d += `${x === 0 ? "M" : "L"}${x} ${(6 - Math.sin((x / 11) * Math.PI * 2) * 4).toFixed(2)} ` + return d.trim() +})() + +function progress() { + const max = document.documentElement.scrollHeight - window.innerHeight + return max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0 +} diff --git a/lib/ambient.js b/lib/ambient.js new file mode 100644 index 0000000..b15a47e --- /dev/null +++ b/lib/ambient.js @@ -0,0 +1,326 @@ +/* + * Existence ambient score. + * + * Generated live with the Web Audio API, so there is no audio file to download or license. + * A low drone on A, a distant formant choir, desert wind, sparse bells in a minor scale and + * a slow brass swell now and then, all inside a long synthetic reverb. The harmony and the + * brightness follow how far down the page the visitor is. + */ + +const ROOT = 55 // A1 +const hz = (semitones, base = ROOT) => base * Math.pow(2, semitones / 12) + +// A minor pentatonic, two octaves: A C D E G +const BELL_STEPS = [0, 3, 5, 7, 10, 12, 15, 17, 19, 22] +// chord tone the choir sings in each part of the page: root, fifth, minor third, fourth +const SECTION_STEPS = [12, 19, 15, 17] +// vowel formants (F1, F2) the choir drifts between: ah, oh, oo, eh +const VOWELS = [ + [730, 1090], + [570, 840], + [300, 870], + [530, 1840] +] + +const rand = (a, b) => a + Math.random() * (b - a) +const pick = (list) => list[Math.floor(Math.random() * list.length)] + +function impulseResponse(ctx, seconds, decay) { + const length = Math.floor(ctx.sampleRate * seconds) + const buffer = ctx.createBuffer(2, length, ctx.sampleRate) + for (let ch = 0; ch < 2; ch++) { + const data = buffer.getChannelData(ch) + for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay) + } + return buffer +} + +function brownNoise(ctx, seconds) { + const length = Math.floor(ctx.sampleRate * seconds) + const buffer = ctx.createBuffer(1, length, ctx.sampleRate) + const data = buffer.getChannelData(0) + let last = 0 + for (let i = 0; i < length; i++) { + last = (last + 0.02 * (Math.random() * 2 - 1)) / 1.02 + data[i] = last * 3.5 + } + return buffer +} + +export class Ambient { + constructor() { + this.ctx = null + this.playing = false + this.timers = [] + this.section = -1 + this.stopToken = 0 + } + + build() { + const ctx = new (window.AudioContext || window.webkitAudioContext)() + this.ctx = ctx + + // master: fades in and out, a gentle compressor keeps the swells polite + const master = ctx.createGain() + master.gain.value = 0 + const comp = ctx.createDynamicsCompressor() + comp.threshold.value = -20 + comp.ratio.value = 3.5 + comp.attack.value = 0.05 + comp.release.value = 0.6 + master.connect(comp).connect(ctx.destination) + this.master = master + + // one long, dark reverb for the whole score + const reverb = ctx.createConvolver() + reverb.buffer = impulseResponse(ctx, 7, 2.6) + const reverbTone = ctx.createBiquadFilter() + reverbTone.type = "lowpass" + reverbTone.frequency.value = 2600 + const wet = ctx.createGain() + wet.gain.value = 0.6 + reverb.connect(reverbTone).connect(wet).connect(master) + const dry = ctx.createGain() + dry.gain.value = 0.7 + dry.connect(master) + this.send = (node, amount) => { + node.connect(dry) + const s = ctx.createGain() + s.gain.value = amount + node.connect(s).connect(reverb) + } + + this.buildDrone() + this.buildChoir() + this.buildWind() + } + + buildDrone() { + const ctx = this.ctx + const filter = ctx.createBiquadFilter() + filter.type = "lowpass" + filter.frequency.value = 280 + filter.Q.value = 0.8 + const gain = ctx.createGain() + gain.gain.value = 0.2 + + // detuned saws on the root, its octave and its fifth + for (const [freq, detune] of [ + [hz(0), -6], + [hz(0), 6], + [hz(12), -3], + [hz(7), 4] + ]) { + const o = ctx.createOscillator() + o.type = "sawtooth" + o.frequency.value = freq + o.detune.value = detune + o.connect(filter) + o.start() + } + // a clean sine underneath so it has weight on small speakers too + const sub = ctx.createOscillator() + sub.frequency.value = hz(0) + const subGain = ctx.createGain() + subGain.gain.value = 0.55 + sub.connect(subGain).connect(gain) + sub.start() + + // the filter breathes very slowly + const lfo = ctx.createOscillator() + lfo.frequency.value = 0.03 + const depth = ctx.createGain() + depth.gain.value = 80 + lfo.connect(depth).connect(filter.frequency) + lfo.start() + + filter.connect(gain) + this.send(gain, 0.35) + this.droneFilter = filter + } + + buildChoir() { + const ctx = this.ctx + const voice = ctx.createOscillator() + voice.type = "sawtooth" + voice.frequency.value = hz(SECTION_STEPS[0]) + // a slow, slight wavering so it sounds sung, not generated + const vib = ctx.createOscillator() + vib.frequency.value = 0.18 + const vibDepth = ctx.createGain() + vibDepth.gain.value = 9 + vib.connect(vibDepth).connect(voice.detune) + vib.start() + + const out = ctx.createGain() + out.gain.value = 0.08 + const formants = VOWELS[0].map((f) => { + const band = ctx.createBiquadFilter() + band.type = "bandpass" + band.frequency.value = f + band.Q.value = 9 + voice.connect(band).connect(out) + return band + }) + voice.start() + + // a swell in and out every half minute or so + const swell = ctx.createOscillator() + swell.frequency.value = 0.035 + const swellDepth = ctx.createGain() + swellDepth.gain.value = 0.035 + swell.connect(swellDepth).connect(out.gain) + swell.start() + + this.send(out, 0.9) + this.voice = voice + this.formants = formants + } + + buildWind() { + const ctx = this.ctx + const noise = ctx.createBufferSource() + noise.buffer = brownNoise(ctx, 6) + noise.loop = true + const band = ctx.createBiquadFilter() + band.type = "bandpass" + band.frequency.value = 520 + band.Q.value = 0.7 + const gain = ctx.createGain() + gain.gain.value = 0.045 + + // the wind's pitch wanders, and it gusts on a different, slower cycle + const sweep = ctx.createOscillator() + sweep.frequency.value = 0.045 + const sweepDepth = ctx.createGain() + sweepDepth.gain.value = 320 + sweep.connect(sweepDepth).connect(band.frequency) + sweep.start() + const gust = ctx.createOscillator() + gust.frequency.value = 0.07 + const gustDepth = ctx.createGain() + gustDepth.gain.value = 0.03 + gust.connect(gustDepth).connect(gain.gain) + gust.start() + + noise.connect(band).connect(gain) + noise.start() + this.send(gain, 0.5) + this.wind = gain + } + + bell() { + const ctx = this.ctx + const t = ctx.currentTime + 0.05 + const freq = hz(pick(BELL_STEPS), 220) + const out = ctx.createGain() + out.gain.setValueAtTime(0, t) + out.gain.linearRampToValueAtTime(0.045, t + 0.02) + out.gain.exponentialRampToValueAtTime(0.0001, t + 6) + for (const [mult, level] of [ + [1, 1], + [2.76, 0.3], + [5.4, 0.12] + ]) { + const o = ctx.createOscillator() + o.type = "sine" + o.frequency.value = freq * mult + const g = ctx.createGain() + g.gain.value = level + o.connect(g).connect(out) + o.start(t) + o.stop(t + 6.2) + } + this.send(out, 1.4) + } + + brass() { + const ctx = this.ctx + const t = ctx.currentTime + 0.1 + const filter = ctx.createBiquadFilter() + filter.type = "lowpass" + filter.Q.value = 1.2 + filter.frequency.setValueAtTime(110, t) + filter.frequency.exponentialRampToValueAtTime(1300, t + 2.6) + filter.frequency.exponentialRampToValueAtTime(140, t + 8) + const out = ctx.createGain() + out.gain.setValueAtTime(0.0001, t) + out.gain.exponentialRampToValueAtTime(0.13, t + 2.4) + out.gain.exponentialRampToValueAtTime(0.0001, t + 8.5) + for (const [step, detune] of [ + [0, -8], + [0, 8], + [7, 0], + [12, -4] + ]) { + const o = ctx.createOscillator() + o.type = "sawtooth" + o.frequency.value = hz(step) + o.detune.value = detune + o.connect(filter) + o.start(t) + o.stop(t + 9) + } + filter.connect(out) + this.send(out, 0.8) + } + + shiftVowel() { + const ctx = this.ctx + const [f1, f2] = pick(VOWELS) + this.formants[0].frequency.setTargetAtTime(f1, ctx.currentTime, 2.5) + this.formants[1].frequency.setTargetAtTime(f2, ctx.currentTime, 2.5) + } + + schedule(fn, min, max) { + const run = () => { + if (!this.playing) return + fn.call(this) + this.timers.push(setTimeout(run, rand(min, max))) + } + this.timers.push(setTimeout(run, rand(min, max))) + } + + /** 0 at the top of the page, 1 at the bottom */ + setProgress(p) { + if (!this.ctx) return + const now = this.ctx.currentTime + this.droneFilter.frequency.setTargetAtTime(260 + p * 460, now, 2) + const section = Math.min(SECTION_STEPS.length - 1, Math.floor(p * SECTION_STEPS.length)) + if (section !== this.section) { + this.section = section + this.voice.frequency.setTargetAtTime(hz(SECTION_STEPS[section]), now, 1.8) + } + } + + async start() { + if (!this.ctx) this.build() + this.stopToken++ + if (this.ctx.state !== "running") await this.ctx.resume() + const now = this.ctx.currentTime + this.master.gain.cancelScheduledValues(now) + this.master.gain.setValueAtTime(this.master.gain.value, now) + this.master.gain.linearRampToValueAtTime(0.55, now + 3) + if (!this.playing) { + this.playing = true + this.schedule(this.bell, 6000, 15000) + this.schedule(this.brass, 22000, 38000) + this.schedule(this.shiftVowel, 8000, 12000) + } + } + + stop() { + if (!this.ctx) return + this.playing = false + this.timers.forEach(clearTimeout) + this.timers = [] + const now = this.ctx.currentTime + this.master.gain.cancelScheduledValues(now) + this.master.gain.setValueAtTime(this.master.gain.value, now) + this.master.gain.linearRampToValueAtTime(0, now + 1.2) + const token = ++this.stopToken + setTimeout(() => { + if (token === this.stopToken && this.ctx.state === "running") this.ctx.suspend() + }, 1400) + } +}