Skip to content

Commit e1f28a7

Browse files
RuitingMaclaude
andcommitted
Add Clinamen sketch: lanterns, modal bells, proximity dim + strike flash
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d7e7d32 commit e1f28a7

7 files changed

Lines changed: 1401 additions & 2 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,9 @@ pnpm-debug.log*
2525

2626
# local Claude Code config (launch.json is per-machine)
2727
.claude/
28+
29+
# local-only artifacts — exist on disk but not part of the deployed site
30+
# archive/ holds pre-2023 Hexo drafts (kept for reference, not republished)
31+
# maintenance.html is the reusable "site under maintenance" cover page
32+
archive/
33+
maintenance.html

src/audio/modal-bell.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/*
2+
* Modal-synthesis bell / porcelain-bowl voice bank.
3+
*
4+
* Each strike produces a brief attack → exponential-decay sum of four
5+
* inharmonic sine partials, mixed through a master voice bus → dry/wet
6+
* → compressor → destination. A short convolution reverb (noise IR, 1.7 s)
7+
* provides spatial glue without sounding like a cathedral.
8+
*
9+
* Lifecycle:
10+
* const synth = new ModalBellSynth();
11+
* // ...user gesture (pointerdown):
12+
* await synth.wake();
13+
* // any time thereafter:
14+
* synth.strike({ pitch: 440, velocity: 0.7, size: 0.5 });
15+
* // navigation / cleanup:
16+
* synth.dispose();
17+
*
18+
* Browsers block AudioContext creation + resume until a user gesture has
19+
* fired in the page. `wake()` handles both — it's a no-op if called a
20+
* second time, so it's safe to invoke from every pointerdown.
21+
*
22+
* Design notes:
23+
* - Partials are hand-tuned for "porcelain" character rather than a
24+
* cathedral clang: a dominant fundamental, a near-perfect octave with
25+
* slight detune (warmth), one classic inharmonic "bell" partial
26+
* (2.76), and a thin high sparkle (4.13). Real bells have denser
27+
* spectra; a pond isn't a bell tower.
28+
* - `size` parameter stretches sustain — bigger bowls store more energy,
29+
* ring longer. Only the fundamental and low octaves get the full
30+
* stretch; higher partials die fast regardless.
31+
* - Per-strike micro-detune (±6 cents) prevents two same-radius lanterns
32+
* from ringing in phase lock-step.
33+
*/
34+
35+
export interface BellStrike {
36+
/** Fundamental frequency in Hz. */
37+
pitch: number;
38+
/** Loudness, 0..1. Values near 0 will still produce a quiet audible note. */
39+
velocity: number;
40+
/** Normalized "bigness", 0..1. Bigger → longer sustain on low partials. */
41+
size: number;
42+
}
43+
44+
type Partial = {
45+
/** Multiplier of fundamental: the partial's frequency = pitch × ratio. */
46+
ratio: number;
47+
/** Relative amplitude of this partial (1.0 = fundamental). */
48+
amp: number;
49+
/** Minimum decay time at size=0, seconds. */
50+
decayBase: number;
51+
/** Additional decay time at size=1, seconds. */
52+
decaySpan: number;
53+
};
54+
55+
// Tuned for crisp porcelain/ceramic character (not a cathedral bell):
56+
// - Fundamental decays quickly (1-2.5s), not the multi-second tail of a
57+
// large tuned bell.
58+
// - Higher partials bumped up so the initial "tink" reads as percussive,
59+
// not as a dull thud. They still die fast.
60+
// - Keep the inharmonic 2.76 partial for the bell/bowl signature, but
61+
// the near-octave 2.01 adds warmth that reads as ceramic rather than
62+
// as a church bell.
63+
const PARTIALS: Partial[] = [
64+
{ ratio: 1.00, amp: 1.00, decayBase: 1.0, decaySpan: 1.4 },
65+
{ ratio: 2.01, amp: 0.50, decayBase: 0.7, decaySpan: 0.7 },
66+
{ ratio: 2.76, amp: 0.32, decayBase: 0.45, decaySpan: 0.4 },
67+
{ ratio: 4.13, amp: 0.18, decayBase: 0.25, decaySpan: 0.2 },
68+
];
69+
70+
const ATTACK = 0.003; // seconds — slightly faster for a crisper onset
71+
const DETUNE_CENTS = 6;
72+
const MASTER_GAIN = 0.38; // voice bus
73+
// Reverb: pushed hard into "large open space" territory. Strikes now
74+
// carry a long shimmering tail — a koi pond in a stone courtyard, with
75+
// air above it — rather than the earlier pavilion. Wet nearly matches
76+
// dry, and a high-shelf brightener + HP-biased IR keep the tail
77+
// shimmering instead of turning into low-end wash.
78+
const DRY_GAIN = 0.58;
79+
const WET_GAIN = 0.78;
80+
const REVERB_SECONDS = 6.5;
81+
const REVERB_DECAY = 1.5; // lower → longer tail; 1.5 ≈ 5 s audible decay
82+
const REVERB_SHIMMER_HZ = 2500;
83+
const REVERB_SHIMMER_DB = 7;
84+
85+
export class ModalBellSynth {
86+
private ctx: AudioContext | null = null;
87+
private voiceBus: GainNode | null = null;
88+
89+
/** True once wake() has successfully created an AudioContext. */
90+
get ready(): boolean { return this.ctx !== null; }
91+
92+
/**
93+
* Create the AudioContext + master chain. Must be called from inside a
94+
* user-gesture event handler (pointerdown/click/keydown). Idempotent:
95+
* subsequent calls are cheap no-ops.
96+
*/
97+
async wake(): Promise<void> {
98+
if (this.ctx) return;
99+
const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
100+
if (!AudioCtx) {
101+
console.warn('[modal-bell] Web Audio API not available.');
102+
return;
103+
}
104+
const ctx = new AudioCtx();
105+
// Some browsers start the context suspended even inside a gesture.
106+
if (ctx.state === 'suspended') {
107+
try { await ctx.resume(); } catch { /* ignore */ }
108+
}
109+
110+
const voiceBus = ctx.createGain();
111+
voiceBus.gain.value = MASTER_GAIN;
112+
113+
const dry = ctx.createGain();
114+
dry.gain.value = DRY_GAIN;
115+
116+
const wet = ctx.createGain();
117+
wet.gain.value = WET_GAIN;
118+
119+
const conv = ctx.createConvolver();
120+
conv.buffer = this.buildReverbIR(ctx, REVERB_SECONDS, REVERB_DECAY);
121+
122+
// High-shelf "shimmer" on the wet path. Raw convolution of bell
123+
// partials with a white-noise IR ends up dominated by the lower
124+
// partials because they decay slowest in the source — so the wet
125+
// tail reads dark. Boosting everything above ~2.5 kHz on the wet
126+
// bus restores a fine, air-like ring without touching dry clarity.
127+
const shimmer = ctx.createBiquadFilter();
128+
shimmer.type = 'highshelf';
129+
shimmer.frequency.value = REVERB_SHIMMER_HZ;
130+
shimmer.gain.value = REVERB_SHIMMER_DB;
131+
132+
// Compressor catches the peaks when many voices sum; settings chosen
133+
// to be transparent on single strikes and only squeeze chords/piles.
134+
const comp = ctx.createDynamicsCompressor();
135+
comp.threshold.value = -16;
136+
comp.knee.value = 8;
137+
comp.ratio.value = 3;
138+
comp.attack.value = 0.005;
139+
comp.release.value = 0.25;
140+
141+
// Signal graph: voiceBus ─┬─→ dry ──────────────────→ comp → destination
142+
// └─→ conv → shimmer → wet ─↗
143+
voiceBus.connect(dry);
144+
voiceBus.connect(conv);
145+
conv.connect(shimmer);
146+
shimmer.connect(wet);
147+
dry.connect(comp);
148+
wet.connect(comp);
149+
comp.connect(ctx.destination);
150+
151+
this.ctx = ctx;
152+
this.voiceBus = voiceBus;
153+
}
154+
155+
/**
156+
* Play a single bell/bowl strike. No-op if the synth isn't awake yet or
157+
* velocity is non-positive.
158+
*/
159+
strike({ pitch, velocity, size }: BellStrike): void {
160+
const ctx = this.ctx;
161+
const bus = this.voiceBus;
162+
if (!ctx || !bus) return;
163+
if (velocity <= 0) return;
164+
165+
const now = ctx.currentTime;
166+
const detuneRatio = Math.pow(2, ((Math.random() * 2 - 1) * DETUNE_CENTS) / 1200);
167+
const clampedVel = Math.min(1, velocity);
168+
const clampedSize = Math.min(1, Math.max(0, size));
169+
170+
for (const P of PARTIALS) {
171+
const osc = ctx.createOscillator();
172+
osc.type = 'sine';
173+
osc.frequency.value = pitch * P.ratio * detuneRatio;
174+
175+
const g = ctx.createGain();
176+
const peak = clampedVel * P.amp * 0.32;
177+
const decay = P.decayBase + clampedSize * P.decaySpan;
178+
179+
// Linear attack → exponential decay to near-zero (exp ramp requires
180+
// a positive target, so we aim for 0.0001, inaudible).
181+
g.gain.setValueAtTime(0.0001, now);
182+
g.gain.linearRampToValueAtTime(peak, now + ATTACK);
183+
g.gain.exponentialRampToValueAtTime(0.0001, now + ATTACK + decay);
184+
185+
osc.connect(g);
186+
g.connect(bus);
187+
188+
osc.start(now);
189+
// Stop a frame after the envelope ends so the node gets GC'd and
190+
// doesn't keep the audio graph alive forever.
191+
osc.stop(now + ATTACK + decay + 0.05);
192+
}
193+
}
194+
195+
/** Freeze all audio. Called on stage:pause. Cheap. */
196+
suspend(): void {
197+
this.ctx?.suspend().catch(() => { /* ignore */ });
198+
}
199+
200+
/** Unfreeze. Called on stage unpause. */
201+
resume(): void {
202+
this.ctx?.resume().catch(() => { /* ignore */ });
203+
}
204+
205+
/** Tear down the AudioContext. Called on page navigation / component unmount. */
206+
dispose(): void {
207+
try { this.ctx?.close(); } catch { /* ignore */ }
208+
this.ctx = null;
209+
this.voiceBus = null;
210+
}
211+
212+
/**
213+
* Build an exponentially-decaying noise impulse response with a
214+
* first-order high-pass bias on the noise source. The HP differencing
215+
* (n - 0.6·prev) shifts the noise energy toward HF so the convolved
216+
* tail reads as shimmer/air, not rumble — complementing the wet-path
217+
* high-shelf filter. Still a toy reverb, but plenty to glue strikes
218+
* into a shared space.
219+
*/
220+
private buildReverbIR(ctx: AudioContext, seconds: number, decay: number): AudioBuffer {
221+
const sr = ctx.sampleRate;
222+
const len = Math.max(1, Math.floor(seconds * sr));
223+
const ir = ctx.createBuffer(2, len, sr);
224+
for (let c = 0; c < 2; c++) {
225+
const data = ir.getChannelData(c);
226+
let prev = 0;
227+
for (let i = 0; i < len; i++) {
228+
const t = i / len;
229+
const n = Math.random() * 2 - 1;
230+
const hp = n - 0.6 * prev;
231+
prev = n;
232+
data[i] = hp * Math.pow(1 - t, decay);
233+
}
234+
}
235+
return ir;
236+
}
237+
}

0 commit comments

Comments
 (0)