From 920628d63c723ea8c6e29b0c85bd99c9a0b11224 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 23 Jul 2026 23:50:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20show=20polish=20=E2=80=94=20?= =?UTF-8?q?readable=20lines,=20gliding=20arcs,=20a=20real=20hall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lines get a layered soft glow plus a radial pool of darkness under the text zone, so they hold up over the burning border map without a hard box. - Arc heads now interpolate fractionally along the curve with ease-out growth (the whole-segment quantization read as stutter), in the show and the beacon teaser alike. - The score gains a convolution reverb with a procedurally generated impulse response; the fear act trades raw saws for an organic bed (sine sub, drifting fifth, breathing noise rumble, a semitone grind in the dread act), the shimmer runs through a feedback echo, impact tails bloom in the hall, the heartbeat stays close and dry. --- .../src/components/FourZero/NoFearBeacon.tsx | 15 +- client/src/components/FourZero/fourzero.css | 8 +- client/src/components/FourZero/noFearAudio.ts | 200 +++++++++++++----- client/src/components/FourZero/noFearScene.ts | 15 +- 4 files changed, 172 insertions(+), 66 deletions(-) diff --git a/client/src/components/FourZero/NoFearBeacon.tsx b/client/src/components/FourZero/NoFearBeacon.tsx index 684192312..9eb55f5c5 100644 --- a/client/src/components/FourZero/NoFearBeacon.tsx +++ b/client/src/components/FourZero/NoFearBeacon.tsx @@ -73,14 +73,21 @@ function TeaserCanvas() { const my = (y1 + y2) / 2 - Math.min(Math.abs(x2 - x1) * 0.35, h * 0.34) c.beginPath() const steps = 22 - const upto = Math.max(2, Math.round(steps * grow)) + const eased = 1 - (1 - grow) ** 3 + const upto = Math.floor(steps * eased) + const pt = (q: number): [number, number] => [ + (1 - q) * (1 - q) * x1 + 2 * (1 - q) * q * mx + q * q * x2, + (1 - q) * (1 - q) * y1 + 2 * (1 - q) * q * my + q * q * y2, + ] for (let s = 0; s <= upto; s++) { - const q = s / steps - const ix = (1 - q) * (1 - q) * x1 + 2 * (1 - q) * q * mx + q * q * x2 - const iy = (1 - q) * (1 - q) * y1 + 2 * (1 - q) * q * my + q * q * y2 + const [ix, iy] = pt(s / steps) if (s === 0) c.moveTo(ix, iy) else c.lineTo(ix, iy) } + if (eased < 1) { + const [tx, ty] = pt(eased) + c.lineTo(tx, ty) + } c.strokeStyle = `rgba(255, 180, 95, ${0.12 * fade})` c.lineWidth = 3.4 c.stroke() diff --git a/client/src/components/FourZero/fourzero.css b/client/src/components/FourZero/fourzero.css index 4b1ff8f0d..f1509cf82 100644 --- a/client/src/components/FourZero/fourzero.css +++ b/client/src/components/FourZero/fourzero.css @@ -41,6 +41,9 @@ justify-content: center; padding: 0 12vw; pointer-events: none; + /* A soft radial pool of darkness under the words, so they never sink into + the burning map — depth, not a hard box. */ + background: radial-gradient(52% 34% at 50% 50%, rgba(5, 6, 10, 0.55), rgba(5, 6, 10, 0) 72%); } .fz-line { @@ -50,7 +53,10 @@ font-weight: 300; line-height: 1.45; letter-spacing: 0.015em; - text-shadow: 0 2px 24px rgba(0, 0, 0, 0.85); + text-shadow: + 0 1px 2px rgba(5, 6, 10, 0.9), + 0 4px 18px rgba(5, 6, 10, 0.8), + 0 10px 46px rgba(5, 6, 10, 0.65); animation: fzLineIn 1.6s cubic-bezier(0.19, 1, 0.22, 1) both; } diff --git a/client/src/components/FourZero/noFearAudio.ts b/client/src/components/FourZero/noFearAudio.ts index aeaf69bfd..906b34cb2 100644 --- a/client/src/components/FourZero/noFearAudio.ts +++ b/client/src/components/FourZero/noFearAudio.ts @@ -1,8 +1,10 @@ // Procedural soundtrack for the 4.0.0 "KEINE ANGST" show. Everything is -// synthesized with the Web Audio API at runtime — heartbeat with a real click -// transient, wind-noise textures, a trailer-style noise riser, cue-driven -// sub-bass impacts and a moving chord progression with glided voice-leading — -// so the show ships zero audio assets and touches no copyrighted material. +// synthesized with the Web Audio API at runtime — no audio assets, no +// copyrighted material. The cinema comes from three things: a convolution +// reverb fed by a procedurally generated impulse response (dry synthesis +// always sounds like a buzzer; a hall makes it a room), an organic low bed of +// sub sine + filtered rumble instead of raw sawtooths, and cue-driven sub +// impacts whose tails bloom in that same hall. export type NoFearAct = 'fear' | 'dread' | 'silence' | 'hope' | 'anthem' | 'end' @@ -24,6 +26,7 @@ const BASS_ROOTS = [36.71, 41.2, 46.25, 41.2] // D1 E1 F#1 E1 export class NoFearAudio { private ctx: AudioContext | null = null private master: GainNode | null = null + private reverbIn: GainNode | null = null private noiseBuf: AudioBuffer | null = null private muted = false private act: NoFearAct | null = null @@ -46,15 +49,26 @@ export class NoFearAudio { // A lazily-loaded chunk can construct us a beat after the click gesture — // resume() re-arms the context if the browser started it suspended. if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => {}) - this.master = this.ctx.createGain() - this.master.gain.value = this.muted ? 0 : 0.9 const limiter = this.ctx.createDynamicsCompressor() limiter.threshold.value = -12 limiter.knee.value = 22 limiter.ratio.value = 12 - this.master.connect(limiter) limiter.connect(this.ctx.destination) - // 2s of looped white noise feeds every texture (wind, risers, ticks). + this.master = this.ctx.createGain() + this.master.gain.value = this.muted ? 0 : 0.9 + this.master.connect(limiter) + // The hall: a convolver fed by 3.4s of exponentially decaying stereo noise. + // Everything sends into it in different amounts; the wet return rides the + // same master gain so mute/fade cover it too. + const convolver = this.ctx.createConvolver() + convolver.buffer = this.makeImpulseResponse(3.4, 2.7) + const wet = this.ctx.createGain() + wet.gain.value = 0.5 + this.reverbIn = this.ctx.createGain() + this.reverbIn.connect(convolver) + convolver.connect(wet) + wet.connect(this.master) + // 2s of looped white noise feeds every texture (wind, rumble, risers, ticks). const len = this.ctx.sampleRate * 2 this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate) const data = this.noiseBuf.getChannelData(0) @@ -63,6 +77,32 @@ export class NoFearAudio { this.heartbeatTimer = window.setInterval(() => this.scheduleBeats(), 100) } + private makeImpulseResponse(seconds: number, decay: number): AudioBuffer { + const ctx = this.ctx as AudioContext + const rate = ctx.sampleRate + const len = Math.floor(rate * seconds) + const ir = ctx.createBuffer(2, len, rate) + for (let ch = 0; ch < 2; ch++) { + const d = ir.getChannelData(ch) + for (let i = 0; i < len; i++) { + d[i] = (Math.random() * 2 - 1) * (1 - i / len) ** decay + } + } + return ir + } + + /** Routes a node to the dry bus plus a measured send into the hall. */ + private out(node: AudioNode, send: number): void { + if (!this.master || !this.reverbIn) return + node.connect(this.master) + if (send > 0) { + const g = (this.ctx as AudioContext).createGain() + g.gain.value = send + node.connect(g) + g.connect(this.reverbIn) + } + } + setMuted(muted: boolean): void { this.muted = muted if (this.master && this.ctx) { @@ -85,7 +125,7 @@ export class NoFearAudio { else void this.ctx.resume().catch(() => {}) } - /** A cue-driven cinema hit: sub boom + filtered noise burst. strength 0..1. */ + /** A cue-driven cinema hit: sub boom + filtered noise burst, tail blooming in the hall. */ impact(strength = 1): void { if (!this.ctx || !this.master) return const t = this.ctx.currentTime + 0.02 @@ -97,7 +137,8 @@ export class NoFearAudio { g.gain.setValueAtTime(0.0001, t) g.gain.exponentialRampToValueAtTime(0.9 * strength, t + 0.018) g.gain.exponentialRampToValueAtTime(0.0001, t + 1.6) - o.connect(g); g.connect(this.master) + o.connect(g) + this.out(g, 0.5) o.start(t); o.stop(t + 1.8) if (this.noiseBuf) { const n = this.ctx.createBufferSource() @@ -108,9 +149,10 @@ export class NoFearAudio { lp.frequency.exponentialRampToValueAtTime(120, t + 0.5) const ng = this.ctx.createGain() ng.gain.setValueAtTime(0.0001, t) - ng.gain.exponentialRampToValueAtTime(0.32 * strength, t + 0.012) + ng.gain.exponentialRampToValueAtTime(0.3 * strength, t + 0.012) ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.6) - n.connect(lp); lp.connect(ng); ng.connect(this.master) + n.connect(lp); lp.connect(ng) + this.out(ng, 0.6) n.start(t); n.stop(t + 0.8) } } @@ -125,14 +167,14 @@ export class NoFearAudio { case 'fear': this.bpm = 74 this.beatGain = 0.55 - this.drone(55, 82.4, 0.15, 230, 3) + this.darkBed(0.16, false) this.wind(0.05, 420) break case 'dread': this.bpm = 116 this.beatGain = 0.7 - this.drone(55, 77.8, 0.19, 300, 1) // flattened fifth — it beats uneasily - this.wind(0.085, 700) + this.darkBed(0.21, true) // + a semitone rub that grinds + this.wind(0.09, 700) this.riser(t) break case 'silence': @@ -163,6 +205,7 @@ export class NoFearAudio { const ctx = this.ctx this.ctx = null this.master = null + this.reverbIn = null if (ctx) window.setTimeout(() => { void ctx.close().catch(() => {}) }, 300) } @@ -185,41 +228,70 @@ export class NoFearAudio { }) } - /** Two slightly detuned low saws through a lowpass — the fear act's room tone. */ - private drone(f1: number, f2: number, level: number, cutoff: number, attack: number): void { - if (!this.ctx || !this.master) return + /** + * The fear act's floor: a sine sub, a quiet detune-drifting fifth and a + * low rumble of filtered noise that breathes — a room, not a synthesizer. + * `grind` adds a close semitone against the sub that beats uneasily. + */ + private darkBed(level: number, grind: boolean): void { + if (!this.ctx || !this.noiseBuf) return const t = this.ctx.currentTime const g = this.ctx.createGain() g.gain.setValueAtTime(0.0001, t) - g.gain.exponentialRampToValueAtTime(level, t + attack) - const lp = this.ctx.createBiquadFilter() - lp.type = 'lowpass' - lp.frequency.value = cutoff - // A slow LFO breathes the filter so the drone never sits still. - const lfo = this.ctx.createOscillator() - lfo.frequency.value = 0.09 - const lfoGain = this.ctx.createGain() - lfoGain.gain.value = cutoff * 0.35 - lfo.connect(lfoGain); lfoGain.connect(lp.frequency) - lfo.start(t) - const oscs: (OscillatorNode | AudioBufferSourceNode)[] = [f1, f2].map(f => { - const o = this.ctx!.createOscillator() - o.type = 'sawtooth' - o.frequency.value = f - o.detune.value = Math.random() * 7 - 3.5 - o.connect(lp) - o.start(t) - return o - }) - oscs.push(lfo) - lp.connect(g) - g.connect(this.master) + g.gain.exponentialRampToValueAtTime(level, t + 3) + const sub = this.ctx.createOscillator() + sub.type = 'sine' + sub.frequency.value = 55 + const subG = this.ctx.createGain() + subG.gain.value = 0.85 + sub.connect(subG); subG.connect(g) + const fifth = this.ctx.createOscillator() + fifth.type = 'triangle' + fifth.frequency.value = 82.4 + const fifthG = this.ctx.createGain() + fifthG.gain.value = 0.3 + fifth.connect(fifthG); fifthG.connect(g) + // Slow detune drift keeps the interval from ever sitting still. + const drift = this.ctx.createOscillator() + drift.frequency.value = 0.07 + const driftG = this.ctx.createGain() + driftG.gain.value = 6 + drift.connect(driftG); driftG.connect(fifth.detune) + const oscs: (OscillatorNode | AudioBufferSourceNode)[] = [sub, fifth, drift] + if (grind) { + const rub = this.ctx.createOscillator() + rub.type = 'sine' + rub.frequency.value = 58.27 // a semitone against the sub — it grinds + const rubG = this.ctx.createGain() + rubG.gain.value = 0.4 + rub.connect(rubG); rubG.connect(g) + rub.start(t) + oscs.push(rub) + } + // The rumble: looped noise through a low lowpass, its gain breathing slowly. + const rumble = this.ctx.createBufferSource() + rumble.buffer = this.noiseBuf + rumble.loop = true + const rlp = this.ctx.createBiquadFilter() + rlp.type = 'lowpass' + rlp.frequency.value = 120 + const rg = this.ctx.createGain() + rg.gain.value = 0.5 + const breathe = this.ctx.createOscillator() + breathe.frequency.value = 0.06 + const breatheG = this.ctx.createGain() + breatheG.gain.value = 0.25 + breathe.connect(breatheG); breatheG.connect(rg.gain) + rumble.connect(rlp); rlp.connect(rg); rg.connect(g) + sub.start(t); fifth.start(t); drift.start(t); rumble.start(t); breathe.start(t) + oscs.push(rumble, breathe) + this.out(g, 0.3) this.hold(g, oscs) } /** Bandpassed noise that slowly swells and recedes — wind through the scene. */ private wind(level: number, freq: number): void { - if (!this.ctx || !this.master || !this.noiseBuf) return + if (!this.ctx || !this.noiseBuf) return const t = this.ctx.currentTime const src = this.ctx.createBufferSource() src.buffer = this.noiseBuf @@ -237,14 +309,15 @@ export class NoFearAudio { lfoGain.gain.value = level * 0.55 lfo.connect(lfoGain); lfoGain.connect(g.gain) lfo.start(t) - src.connect(bp); bp.connect(g); g.connect(this.master) + src.connect(bp); bp.connect(g) + this.out(g, 0.55) src.start(t) this.hold(g, [src, lfo]) } /** Trailer riser: a saw sweep doubled by a noise sweep, 14s of climb. */ private riser(t: number): void { - if (!this.ctx || !this.master) return + if (!this.ctx) return const o = this.ctx.createOscillator() o.type = 'sawtooth' o.frequency.setValueAtTime(180, t) @@ -256,8 +329,9 @@ export class NoFearAudio { bp.Q.value = 8 const g = this.ctx.createGain() g.gain.setValueAtTime(0.0001, t) - g.gain.exponentialRampToValueAtTime(0.055, t + 7) - o.connect(bp); bp.connect(g); g.connect(this.master) + g.gain.exponentialRampToValueAtTime(0.05, t + 7) + o.connect(bp); bp.connect(g) + this.out(g, 0.35) o.start(t) const nodes: (OscillatorNode | AudioBufferSourceNode)[] = [o] if (this.noiseBuf) { @@ -271,8 +345,9 @@ export class NoFearAudio { nbp.Q.value = 1.4 const ng = this.ctx.createGain() ng.gain.setValueAtTime(0.0001, t) - ng.gain.exponentialRampToValueAtTime(0.09, t + 12) - n.connect(nbp); nbp.connect(ng); ng.connect(this.master) + ng.gain.exponentialRampToValueAtTime(0.08, t + 12) + n.connect(nbp); nbp.connect(ng) + this.out(ng, 0.35) n.start(t) nodes.push(n) this.hold(ng, [n]) @@ -284,7 +359,7 @@ export class NoFearAudio { /** Five gliding triangle voices + a sub root, stepping through D–A–f#m–E. */ private startChords(cutoff: number, level: number, withSub: boolean): void { - if (!this.ctx || !this.master) return + if (!this.ctx) return const t = this.ctx.currentTime const lp = this.ctx.createBiquadFilter() lp.type = 'lowpass' @@ -309,7 +384,7 @@ export class NoFearAudio { } } lp.connect(g) - g.connect(this.master) + this.out(g, 0.55) // the pad lives IN the hall — that's what makes it big this.hold(g, oscs) if (withSub) { const sub = this.ctx.createOscillator() @@ -318,7 +393,8 @@ export class NoFearAudio { const sg = this.ctx.createGain() sg.gain.setValueAtTime(0.0001, t) sg.gain.exponentialRampToValueAtTime(0.16, t + 2.5) - sub.connect(sg); sg.connect(this.master) + sub.connect(sg) + this.out(sg, 0) // sub stays dry — low end in the hall turns to mud sub.start(t) this.bassOsc = sub this.hold(sg, [sub]) @@ -332,7 +408,6 @@ export class NoFearAudio { this.chordIdx = (this.chordIdx + 1) % PROGRESSION.length const chord = PROGRESSION[this.chordIdx] const t = this.ctx.currentTime - // Each pair of detuned oscillators shares a chord voice; glide legato. for (let i = 0; i < this.padVoices.length; i++) { const target = chord[Math.floor(i / 2) % chord.length] this.padVoices[i].frequency.setTargetAtTime(target, t, 0.55) @@ -347,13 +422,21 @@ export class NoFearAudio { this.bassOsc = null } - /** Sparse high pings on the A-major pentatonic — tiny lights going on. */ + /** Sparse pentatonic pings through a feedback echo, drowned in the hall. */ private shimmer(t: number): void { - if (!this.ctx || !this.master) return + if (!this.ctx) return const notes = [880, 1108.7, 1318.5, 1479.98, 1760] const g = this.ctx.createGain() - g.gain.value = 0.055 - g.connect(this.master) + g.gain.value = 0.045 + // A 0.38s feedback delay turns each ping into a trailing echo. + const delay = this.ctx.createDelay(1) + delay.delayTime.value = 0.38 + const fb = this.ctx.createGain() + fb.gain.value = 0.35 + delay.connect(fb); fb.connect(delay) + g.connect(delay) + this.out(g, 0.8) + this.out(delay, 0.8) const oscs: OscillatorNode[] = [] for (let i = 0; i < 14; i++) { const o = this.ctx.createOscillator() @@ -387,7 +470,8 @@ export class NoFearAudio { } } - /** A heartbeat with body AND transient: low sine thump + tiny filtered click. */ + /** A heartbeat with body AND transient: low sine thump + tiny filtered click. + Stays nearly dry — a heart is close, not in a hall. */ private thump(at: number, level: number): void { if (!this.ctx || !this.master) return const o = this.ctx.createOscillator() @@ -399,7 +483,7 @@ export class NoFearAudio { g.gain.exponentialRampToValueAtTime(level, at + 0.01) g.gain.exponentialRampToValueAtTime(0.0001, at + 0.24) o.connect(g) - g.connect(this.master) + this.out(g, 0.1) o.start(at) o.stop(at + 0.32) if (this.noiseBuf) { diff --git a/client/src/components/FourZero/noFearScene.ts b/client/src/components/FourZero/noFearScene.ts index 8c70219cb..fb7679f97 100644 --- a/client/src/components/FourZero/noFearScene.ts +++ b/client/src/components/FourZero/noFearScene.ts @@ -307,14 +307,20 @@ export class NoFearScene { } private strokeArc(c: CanvasRenderingContext2D, arc: Arc, upTo: number, alpha: number): void { - const steps = 26 - const n = Math.max(2, Math.round(steps * upTo)) + // The tip interpolates FRACTIONALLY along the curve — quantizing growth to + // whole segments made the heads visibly step forward instead of glide. + const steps = 48 + const n = Math.floor(steps * upTo) c.beginPath() for (let i = 0; i <= n; i++) { const [ix, iy] = this.arcPoint(arc, (i / steps)) if (i === 0) c.moveTo(ix, iy) else c.lineTo(ix, iy) } + if (upTo < 1) { + const [tx, ty] = this.arcPoint(arc, upTo) + c.lineTo(tx, ty) + } c.strokeStyle = `rgba(255, 176, 90, ${0.10 * alpha})` c.lineWidth = 4.2 c.stroke() @@ -324,7 +330,10 @@ export class NoFearScene { } private arcLocal(arc: Arc, web: number): number { - return Math.min(Math.max((web - arc.delay * 0.72) / 0.28, 0), 1) + // A tighter growth window + ease-out: each arc shoots off and settles, + // rather than crawling for a third of the act. + const q = Math.min(Math.max((web - arc.delay * 0.84) / 0.16, 0), 1) + return 1 - (1 - q) ** 3 } draw(c: CanvasRenderingContext2D, s: SceneState, t: number): void {