fix(dashboard): soften the pivot into 'but you have traveled' + audio review fixes

The transition out of the fear act was too brutal: the silence now arrives as
a fast pull-away with the hall tail ringing out instead of a 100ms kill, the
pivot line trades its impact boom for a slow warm A1 swell (the first calm
breath), and the line itself breathes in over 3.4s.

Audio review fixes: the wind's swell LFO moves to a series tremolo stage so
the release can actually silence it before the cut (it used to warble through
the silence and click on stop); the heartbeat's noise layer becomes the
intended high-passed click instead of a dull lowpassed thud; dispose() pulls
the master to zero before the deferred context close so leaving the show
cannot pop.
This commit is contained in:
Maurice
2026-07-23 23:57:22 +02:00
committed by Maurice
parent ba2582f9c8
commit d2d8b65827
3 changed files with 45 additions and 11 deletions
@@ -18,7 +18,9 @@ interface Cue {
act?: NoFearAct
line?: LineKey | null
hard?: boolean
soft?: boolean
impact?: number
swell?: boolean
}
// The script. Times in seconds; the scene ramps below share the same clock.
@@ -28,7 +30,7 @@ const CUES: Cue[] = [
{ at: 12.0, act: 'dread', line: 'fearTool' },
{ at: 18.5, line: 'hateTrade' },
{ at: 26.0, act: 'silence', line: null },
{ at: 28.5, line: 'butYouTraveled', impact: 0.5 },
{ at: 28.5, line: 'butYouTraveled', soft: true, swell: true },
{ at: 34.0, act: 'hope', line: 'tables' },
{ at: 42.0, line: 'face' },
{ at: 49.0, line: 'fences' },
@@ -188,6 +190,7 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
const cue = CUES[idx]
if (cue.act) audioRef.current?.setAct(cue.act)
if (cue.impact) audioRef.current?.impact(cue.impact)
if (cue.swell) audioRef.current?.swell()
setCueIdx(idx)
if (t >= ANTHEM_AT) setAnthem(true)
if (t >= CREDITS_AT) setCredits(true)
@@ -242,7 +245,7 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
{/* The lines. */}
{!anthem && lineText && flashIdx < 0 && (
<div className="fz-line-wrap" aria-live="polite">
<p key={lineText} className={`fz-line ${hard ? 'fz-line-hard' : ''}`}>{lineText}</p>
<p key={lineText} className={`fz-line ${hard ? 'fz-line-hard' : ''} ${cue?.soft ? 'fz-line-soft' : ''}`}>{lineText}</p>
</div>
)}
@@ -66,6 +66,11 @@
letter-spacing: 0.01em;
}
/* The pivot line out of the silence breathes in slowly instead of arriving. */
.fz-line-soft {
animation-duration: 3.4s;
}
@keyframes fzLineIn {
from { opacity: 0; transform: translateY(14px); filter: blur(6px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
+35 -9
View File
@@ -125,6 +125,22 @@ export class NoFearAudio {
else void this.ctx.resume().catch(() => {})
}
/** A soft warm swell out of the silence — the first calm breath, not a hit. */
swell(): void {
if (!this.ctx || !this.master) return
const t = this.ctx.currentTime + 0.05
const o = this.ctx.createOscillator()
o.type = 'sine'
o.frequency.value = 55 // A1 — the root the hope act will grow from
const g = this.ctx.createGain()
g.gain.setValueAtTime(0.0001, t)
g.gain.exponentialRampToValueAtTime(0.22, t + 1.6)
g.gain.exponentialRampToValueAtTime(0.0001, t + 5)
o.connect(g)
this.out(g, 0.6)
o.start(t); o.stop(t + 5.2)
}
/** 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
@@ -161,7 +177,9 @@ export class NoFearAudio {
if (!this.ctx || !this.master || this.act === act) return
this.act = act
const t = this.ctx.currentTime
this.releaseVoices(act === 'silence' ? 0.1 : 1.6)
// Into the silence: a fast pull-away rather than a hard kill — the fear's
// hall tail is left ringing out on its own, like a room falling quiet.
this.releaseVoices(act === 'silence' ? 0.6 : 1.6)
this.stopChords()
switch (act) {
case 'fear':
@@ -202,6 +220,9 @@ export class NoFearAudio {
this.heartbeatTimer = null
this.stopChords()
this.releaseVoices(0.1)
// Pull the master down fast BEFORE the deferred close — closing mid-release
// (voices still ~30% hot plus the hall tail) would pop.
if (this.master && this.ctx) this.master.gain.setTargetAtTime(0, this.ctx.currentTime, 0.05)
const ctx = this.ctx
this.ctx = null
this.master = null
@@ -303,13 +324,18 @@ export class NoFearAudio {
const g = this.ctx.createGain()
g.gain.setValueAtTime(0.0001, t)
g.gain.exponentialRampToValueAtTime(level, t + 4)
// The swell LFO modulates a SERIES tremolo stage, not g itself — an input
// on g.gain would ADD to the release automation and the wind could never
// be silenced for the hard cut into the silence act.
const trem = this.ctx.createGain()
trem.gain.value = 0.75
const lfo = this.ctx.createOscillator()
lfo.frequency.value = 0.07
const lfoGain = this.ctx.createGain()
lfoGain.gain.value = level * 0.55
lfo.connect(lfoGain); lfoGain.connect(g.gain)
lfoGain.gain.value = 0.4
lfo.connect(lfoGain); lfoGain.connect(trem.gain)
lfo.start(t)
src.connect(bp); bp.connect(g)
src.connect(bp); bp.connect(trem); trem.connect(g)
this.out(g, 0.55)
src.start(t)
this.hold(g, [src, lfo])
@@ -490,15 +516,15 @@ export class NoFearAudio {
const n = this.ctx.createBufferSource()
n.buffer = this.noiseBuf
const hp = this.ctx.createBiquadFilter()
hp.type = 'lowpass'
hp.frequency.value = 340
hp.type = 'highpass' // the CLICK of the valve — the sine above is the body
hp.frequency.value = 1700
const ng = this.ctx.createGain()
ng.gain.setValueAtTime(0.0001, at)
ng.gain.exponentialRampToValueAtTime(level * 0.5, at + 0.006)
ng.gain.exponentialRampToValueAtTime(0.0001, at + 0.05)
ng.gain.exponentialRampToValueAtTime(level * 0.3, at + 0.005)
ng.gain.exponentialRampToValueAtTime(0.0001, at + 0.04)
n.connect(hp); hp.connect(ng); ng.connect(this.master)
n.start(at)
n.stop(at + 0.08)
n.stop(at + 0.07)
}
}
}