sonarium 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +271 -0
  3. package/dist/index.d.ts +1000 -0
  4. package/dist/index.js +2716 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/sonarium.iife.js +236 -0
  7. package/dist/sonarium.iife.js.map +1 -0
  8. package/package.json +63 -0
  9. package/src/core/engine.ts +468 -0
  10. package/src/core/listener.ts +61 -0
  11. package/src/core/matter-voice.ts +240 -0
  12. package/src/core/profile.ts +313 -0
  13. package/src/core/room.ts +135 -0
  14. package/src/core/scanner.ts +176 -0
  15. package/src/core/voices.ts +222 -0
  16. package/src/index.ts +74 -0
  17. package/src/interact/activate.ts +56 -0
  18. package/src/interact/drag.ts +52 -0
  19. package/src/interact/keyboard.ts +48 -0
  20. package/src/interact/midi.ts +40 -0
  21. package/src/interact/motion.ts +61 -0
  22. package/src/interact/pointer.ts +65 -0
  23. package/src/interact/scroll.ts +43 -0
  24. package/src/math/chroma.ts +111 -0
  25. package/src/math/mapping.ts +100 -0
  26. package/src/math/matter.ts +201 -0
  27. package/src/math/modular.ts +99 -0
  28. package/src/math/pulse.ts +64 -0
  29. package/src/math/scales.ts +90 -0
  30. package/src/math/util.ts +16 -0
  31. package/src/spatial/backend.ts +166 -0
  32. package/src/spatial/bus.ts +114 -0
  33. package/src/spatial/decoder.ts +63 -0
  34. package/src/spatial/encoder.ts +54 -0
  35. package/src/spatial/field.ts +75 -0
  36. package/src/spatial/perceptual.ts +43 -0
  37. package/src/spatial/room-foa.ts +81 -0
  38. package/src/spatial/rotation.ts +73 -0
  39. package/src/spatial/sh.ts +38 -0
  40. package/src/spatial/sphere.ts +36 -0
  41. package/src/themes/index.ts +60 -0
  42. package/src/types.ts +157 -0
  43. package/src/ui/gate.ts +65 -0
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "sonarium",
3
+ "version": "0.6.0",
4
+ "description": "Drop-in acoustic UX — one script tag turns any webpage into a spatial sound field. Layout becomes a stereo stage, geometry becomes timbre (Kiki/Bouba), the DOM tree becomes depth and harmony, the viewport becomes a room.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsup",
22
+ "dev": "tsup --watch",
23
+ "test": "vitest run",
24
+ "typecheck": "tsc --noEmit",
25
+ "serve": "vite",
26
+ "prepublishOnly": "npm test && npm run build"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "keywords": [
32
+ "audio",
33
+ "web-audio",
34
+ "tone.js",
35
+ "sonification",
36
+ "spatial-audio",
37
+ "acoustic-ux",
38
+ "sound-design",
39
+ "accessibility",
40
+ "kiki-bouba",
41
+ "ui-sound",
42
+ "creative-coding"
43
+ ],
44
+ "author": "Che-Yu Wu <frank890417@gmail.com> (https://cheyuwu.com)",
45
+ "license": "MIT",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/frank890417/sonarium.git"
49
+ },
50
+ "homepage": "https://frank890417.github.io/sonarium/",
51
+ "bugs": {
52
+ "url": "https://github.com/frank890417/sonarium/issues"
53
+ },
54
+ "dependencies": {
55
+ "tone": "^15.1.22"
56
+ },
57
+ "devDependencies": {
58
+ "tsup": "^8.5.0",
59
+ "typescript": "^5.8.0",
60
+ "vite": "^6.3.0",
61
+ "vitest": "^3.2.0"
62
+ }
63
+ }
@@ -0,0 +1,468 @@
1
+ /**
2
+ * The orchestrator — the only file allowed to know every layer (ARCHITECTURE.md §1).
3
+ * Lifecycle: idle → armed → running ⇄ muted → disposed (§3).
4
+ */
5
+ import * as Tone from 'tone'
6
+ import { SCALES, midiToFreq, parseKey, siteKey, stepInScale, type SiteKey } from '../math/scales'
7
+ import { ribbonSteps } from '../math/modular'
8
+ import { clamp } from '../math/util'
9
+ import {
10
+ chromaOf, modeFromPalette, pagePalette, parseCssColor, roomToneScaleFromWarmth,
11
+ tempoScaleFromWarmth, type Chroma,
12
+ } from '../math/chroma'
13
+ import {
14
+ ECHO_MIN_AHEAD_S, ECHO_TRANSPOSE, ECHO_VELOCITY_SCALE, PHRASE_MAX_NOTES, PHRASE_MIN_NOTES,
15
+ PHRASE_PROBABILITY, decayCount, duckFactor, echoGridS, nextGridOffset, phraseWindow,
16
+ readingOrderKey, secondsPerBeat, strumStepS, tempoFromPage,
17
+ } from '../math/pulse'
18
+ import type { Articulation, PerceptualFactors, SonariumOptions, SonicProfile, SonariumEvent, Theme, TriggerDetail } from '../types'
19
+ import { resolveTheme } from '../themes/index'
20
+ import { mountGate, isMutedPersisted, persistMuted, type GateHandle } from '../ui/gate'
21
+ import { AmbisonicBackend, PannerBackend, type SpatialBackend } from '../spatial/backend'
22
+ import { FieldRig } from '../spatial/field'
23
+ import { resolveFactors } from '../spatial/perceptual'
24
+ import { ListenerRig } from './listener'
25
+ import type { ProfileEnv } from './profile'
26
+ import { Room } from './room'
27
+ import { Scanner } from './scanner'
28
+ import { VoicePool } from './voices'
29
+ import { attachPointer } from '../interact/pointer'
30
+ import { attachActivate } from '../interact/activate'
31
+ import { attachKeyboard } from '../interact/keyboard'
32
+ import { attachScroll } from '../interact/scroll'
33
+ import { attachMotion } from '../interact/motion'
34
+ import { attachDrag } from '../interact/drag'
35
+ import { attachMidi } from '../interact/midi'
36
+
37
+ interface ResolvedOptions {
38
+ root: Element
39
+ theme: Theme
40
+ key: SiteKey
41
+ listener: 'pointer' | 'center'
42
+ ambient: number
43
+ motion: boolean
44
+ gate: 'chip' | 'none'
45
+ volume: number
46
+ maxVoices: number
47
+ panning: 'HRTF' | 'equalpower'
48
+ spatial: 'ambisonic' | 'panner'
49
+ reverb: 'auto' | number
50
+ velocityFactor: number
51
+ midi: boolean
52
+ }
53
+
54
+ type State = 'idle' | 'armed' | 'running' | 'disposed'
55
+
56
+ export class Engine {
57
+ readonly opts: ResolvedOptions
58
+ state: State = 'idle'
59
+ muted = false
60
+
61
+ scanner!: Scanner
62
+ pool: VoicePool | null = null
63
+ room: Room | null = null
64
+ rig: ListenerRig | FieldRig | null = null
65
+ backend: SpatialBackend | null = null
66
+ factors: PerceptualFactors
67
+ /** The page's mood (CHROMA.md §3) and pulse (PULSE.md §1), fixed at create(). */
68
+ readonly palette: Chroma
69
+ readonly tempo: number
70
+ private phraseLoop: Tone.Loop | null = null
71
+ private activity = new WeakMap<Element, { c: number; t: number }>()
72
+
73
+ private gate: GateHandle | null = null
74
+ private env: ProfileEnv
75
+ private detachers: Array<() => void> = []
76
+ private listeners = new Map<SonariumEvent, Set<(detail?: unknown) => void>>()
77
+ private unlockHandler: ((e: Event) => void) | null = null
78
+ private appearBucket = 6
79
+ private bucketTimer: ReturnType<typeof setInterval> | null = null
80
+
81
+ constructor(userOpts: SonariumOptions = {}) {
82
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
83
+ throw new Error('[sonarium] requires a browser environment (create() in the client only)')
84
+ }
85
+ const reduced = (userOpts.respectReducedMotion ?? true)
86
+ && typeof matchMedia === 'function'
87
+ && matchMedia('(prefers-reduced-motion: reduce)').matches
88
+
89
+ // CHROMA.md §3 — the palette chooses the mode; the hostname keeps the root (identity).
90
+ const rootStyle = getComputedStyle(document.documentElement)
91
+ const bodyStyle = getComputedStyle(document.body)
92
+ this.palette = pagePalette(
93
+ chromaOf(parseCssColor(bodyStyle.backgroundColor)),
94
+ chromaOf(parseCssColor(bodyStyle.color)),
95
+ )
96
+ const cssKey = rootStyle.getPropertyValue('--sonic-key').trim()
97
+ let key = (userOpts.key && userOpts.key !== 'auto' ? parseKey(userOpts.key) : null)
98
+ ?? (cssKey ? parseKey(cssKey) : null)
99
+ if (!key) {
100
+ const hashed = siteKey(location.hostname)
101
+ const mode = modeFromPalette(this.palette)
102
+ key = mode
103
+ ? { ...hashed, scaleName: mode, scale: SCALES[mode] as readonly number[], label: `${hashed.label.split(' ')[0]} ${mode}` }
104
+ : hashed
105
+ }
106
+
107
+ this.opts = {
108
+ root: userOpts.root ?? document.body,
109
+ theme: resolveTheme(userOpts.theme),
110
+ key,
111
+ listener: userOpts.listener ?? 'pointer',
112
+ ambient: reduced ? 0 : clamp(userOpts.ambient ?? 0.12, 0, 1),
113
+ motion: userOpts.motion ?? true,
114
+ gate: userOpts.gate ?? 'chip',
115
+ volume: userOpts.volume ?? -10,
116
+ maxVoices: clamp(userOpts.maxVoices ?? 18, 4, 24),
117
+ panning: userOpts.panning === 'equalpower' ? 'equalpower' : 'HRTF',
118
+ spatial: userOpts.spatial === 'panner' ? 'panner' : 'ambisonic',
119
+ reverb: userOpts.reverb ?? 'auto',
120
+ velocityFactor: reduced ? 0.7 : 1,
121
+ midi: userOpts.midi === true,
122
+ }
123
+ this.factors = resolveFactors(userOpts.perceptual)
124
+
125
+ this.env = {
126
+ root: this.opts.root,
127
+ key: this.opts.key,
128
+ theme: this.opts.theme,
129
+ vw: window.innerWidth,
130
+ vh: window.innerHeight,
131
+ }
132
+
133
+ this.muted = isMutedPersisted()
134
+
135
+ // L1 perception is always on: the page is readable (describe()) before it is audible.
136
+ this.scanner = new Scanner(this.env, { onAppear: (el) => this.whisper(el) })
137
+ this.scanner.scan()
138
+
139
+ // PULSE.md §1 — the layout sets the pace (overridable via --sonic-tempo).
140
+ const cssTempo = parseFloat(rootStyle.getPropertyValue('--sonic-tempo'))
141
+ this.tempo = cssTempo > 0
142
+ ? clamp(Math.round(cssTempo), 30, 200)
143
+ : tempoFromPage(this.scanner.registry.size, tempoScaleFromWarmth(this.palette.warmth))
144
+
145
+ this.arm()
146
+ }
147
+
148
+ // ---------------------------------------------------------------- lifecycle
149
+
150
+ private arm(): void {
151
+ this.state = 'armed'
152
+ if (this.opts.gate === 'chip') {
153
+ this.gate = mountGate(() => this.toggleMute())
154
+ this.gate.setState(this.muted ? 'muted' : 'armed')
155
+ }
156
+ if (!this.muted) {
157
+ // Any gesture may unlock (standard autoplay pattern); the chip is the explicit path.
158
+ // 'click' included for assistive tech and synthetic activation, which may skip pointerdown.
159
+ // Listeners stay armed until start() actually succeeds — a gesture that fails to resume
160
+ // the context (no user activation) must not consume the only unlock chance.
161
+ this.unlockHandler = () => { void this.start() }
162
+ window.addEventListener('pointerdown', this.unlockHandler, { capture: true })
163
+ window.addEventListener('keydown', this.unlockHandler, { capture: true })
164
+ window.addEventListener('click', this.unlockHandler, { capture: true })
165
+ }
166
+ }
167
+
168
+ private removeUnlockListeners(): void {
169
+ if (!this.unlockHandler) return
170
+ window.removeEventListener('pointerdown', this.unlockHandler, { capture: true })
171
+ window.removeEventListener('keydown', this.unlockHandler, { capture: true })
172
+ window.removeEventListener('click', this.unlockHandler, { capture: true })
173
+ this.unlockHandler = null
174
+ }
175
+
176
+ private starting = false
177
+
178
+ async start(): Promise<void> {
179
+ if (this.state === 'running' || this.state === 'disposed' || this.starting) return
180
+ this.starting = true
181
+ try {
182
+ // Tone.start() can stay pending forever without user activation; don't wedge on it —
183
+ // stay 'armed' and let the next (real) gesture try again.
184
+ await Promise.race([Tone.start(), new Promise((r) => setTimeout(r, 1500))])
185
+ } catch (err) {
186
+ console.warn('[sonarium] audio context could not start yet', err)
187
+ } finally {
188
+ this.starting = false
189
+ }
190
+ if (Tone.getContext().state !== 'running' || (this.state as State) === 'disposed') return
191
+ this.state = 'running'
192
+ this.removeUnlockListeners()
193
+ this.gate?.setState(this.muted ? 'muted' : 'on')
194
+
195
+ this.buildAudioGraph()
196
+ if (this.muted) this.room?.setMuted(true)
197
+
198
+ this.detachers.push(
199
+ attachPointer(this),
200
+ attachActivate(this),
201
+ attachKeyboard(this),
202
+ attachScroll(this),
203
+ attachDrag(this),
204
+ )
205
+ if (this.opts.motion) this.detachers.push(attachMotion(this))
206
+ if (this.opts.midi) this.detachers.push(attachMidi(this))
207
+
208
+ const onVis = () => this.room?.setHidden(document.hidden)
209
+ document.addEventListener('visibilitychange', onVis)
210
+ this.detachers.push(() => document.removeEventListener('visibilitychange', onVis))
211
+
212
+ this.bucketTimer = setInterval(() => { this.appearBucket = Math.min(6, this.appearBucket + 6) }, 1000)
213
+
214
+ // PULSE.md — the page's clock drives ambience, echoes and phrases.
215
+ Tone.getTransport().bpm.value = this.tempo
216
+ Tone.getTransport().start()
217
+ this.room?.startAmbience(this.env.vw, this.opts.ambient, roomToneScaleFromWarmth(this.palette.warmth))
218
+ if (this.opts.ambient > 0) {
219
+ this.phraseLoop = new Tone.Loop((time) => this.playPhrase(time), '1m')
220
+ this.phraseLoop.start('1m')
221
+ }
222
+ this.playIntroMotif()
223
+ this.emit('start')
224
+ }
225
+
226
+ /** PULSE.md §3 — the ambience reads the layout as a score; scroll moves the playhead. */
227
+ private playPhrase(time?: number): void {
228
+ if (this.state !== 'running' || this.muted || document.hidden) return
229
+ if (time === undefined || !Number.isFinite(time)) time = Tone.now()
230
+ if (Math.random() > PHRASE_PROBABILITY) return
231
+ const pool = this.scanner.visibleElements()
232
+ .map((el) => ({ el, p: this.scanner.profileFor(el) }))
233
+ .filter((x): x is { el: Element; p: SonicProfile } => !!x.p && x.p.role !== 'container' && x.p.velocityScale > 0.01)
234
+ .sort((a, b) => readingOrderKey(a.p.rect.y, a.p.rect.x) - readingOrderKey(b.p.rect.y, b.p.rect.x))
235
+ if (pool.length < PHRASE_MIN_NOTES) return
236
+ const len = Math.min(pool.length, PHRASE_MIN_NOTES + Math.floor(Math.random() * (PHRASE_MAX_NOTES - PHRASE_MIN_NOTES + 1)))
237
+ const scrollable = Math.max(1, document.documentElement.scrollHeight - window.innerHeight)
238
+ const start = phraseWindow(pool.length, len, window.scrollY / scrollable)
239
+ const step = secondsPerBeat(this.tempo) / 2
240
+ const level = 0.07 * (this.opts.ambient / 0.12)
241
+ pool.slice(start, start + len).forEach(({ el }, i) => this.excite(el, level, 'phrase', time + i * step))
242
+ }
243
+
244
+ /**
245
+ * SPATIAL.md §6 — ambisonic field by default; if its construction throws on an exotic
246
+ * browser, fall back to the v0.1 per-voice panner world rather than staying silent.
247
+ */
248
+ private buildAudioGraph(): void {
249
+ const roomOpts = { volumeDb: this.opts.volume, reverb: this.opts.reverb, ambient: this.opts.ambient }
250
+ if (this.opts.spatial === 'ambisonic') {
251
+ try {
252
+ this.room = new Room({ ...roomOpts, mode: 'ambisonic' }, this.env.vw, this.factors)
253
+ const decoderKind = this.opts.panning === 'equalpower' ? 'stereo' : 'binaural'
254
+ this.backend = new AmbisonicBackend(this.room, decoderKind, this.env.vw, this.factors)
255
+ this.rig = new FieldRig(this.backend, this.opts.listener)
256
+ this.rig.start()
257
+ this.pool = new VoicePool(this.backend, this.opts.maxVoices)
258
+ return
259
+ } catch (err) {
260
+ console.warn('[sonarium] ambisonic backend unavailable, falling back to panner', err)
261
+ this.room?.dispose()
262
+ this.room = null
263
+ }
264
+ }
265
+ this.room = new Room({ ...roomOpts, mode: 'panner' }, this.env.vw, this.factors)
266
+ this.backend = new PannerBackend(this.room, this.opts.panning)
267
+ this.rig = new ListenerRig(this.opts.listener)
268
+ this.rig.start()
269
+ this.pool = new VoicePool(this.backend, this.opts.maxVoices)
270
+ }
271
+
272
+ /**
273
+ * MODULAR.md §3 — the ribbon controller: press-and-drag turns an element into a sustained
274
+ * voice swept across scale degrees (quantized — the glide between steps is the glissando).
275
+ */
276
+ ribbon(el: Element): { move(dxPx: number): void; release(): void } | null {
277
+ if (this.state !== 'running' || this.muted || !this.pool) return null
278
+ const target = this.scanner.resolve(el) ?? el
279
+ const profile = this.scanner.profileFor(target)
280
+ if (!profile) return null
281
+ const handle = this.pool.sustain(profile, 0.6)
282
+ if (!handle) return null
283
+ this.emit('trigger', { el: target, profile, velocity: 0.6, articulation: 'ribbon' } satisfies TriggerDetail)
284
+ const key = this.opts.key
285
+ const glide = 0.03 + profile.voice.patch.portamentoS
286
+ let lastMidi = profile.midi
287
+ return {
288
+ move: (dxPx: number) => {
289
+ const midi = stepInScale(profile.midi, key, ribbonSteps(dxPx, this.env.vw, key.scale.length))
290
+ if (midi !== lastMidi) {
291
+ lastMidi = midi
292
+ handle.setFreq(midiToFreq(midi), glide)
293
+ }
294
+ },
295
+ release: () => handle.release(),
296
+ }
297
+ }
298
+
299
+ /** MATTER.md §2.2 — scroll drivers report air movement; rides the room-tone noise. */
300
+ airRush(level: number): void {
301
+ if (this.state !== 'running' || this.muted || level <= 0.005) return
302
+ this.room?.rush(level)
303
+ }
304
+
305
+ /** Live spat5.oper surface: adjust presence/roomPresence/envelopment/warmth/brilliance. */
306
+ setPerceptual(partial: Partial<PerceptualFactors>): void {
307
+ this.factors = resolveFactors({ ...this.factors, ...partial })
308
+ this.backend?.setFactors(this.factors)
309
+ }
310
+
311
+ toggleMute(): void {
312
+ if (this.state !== 'running') {
313
+ // First tap on the chip both unlocks and (if persisted-muted) unmutes.
314
+ this.muted = false
315
+ persistMuted(false)
316
+ void this.start()
317
+ return
318
+ }
319
+ this.muted = !this.muted
320
+ persistMuted(this.muted)
321
+ this.room?.setMuted(this.muted)
322
+ this.gate?.setState(this.muted ? 'muted' : 'on')
323
+ this.emit('mute', this.muted)
324
+ }
325
+
326
+ dispose(): void {
327
+ if (this.state === 'disposed') return
328
+ this.state = 'disposed'
329
+ this.removeUnlockListeners()
330
+ if (this.bucketTimer) clearInterval(this.bucketTimer)
331
+ this.phraseLoop?.dispose()
332
+ for (const detach of this.detachers.splice(0)) {
333
+ try { detach() } catch { /* already gone */ }
334
+ }
335
+ this.scanner?.dispose()
336
+ this.rig?.dispose()
337
+ this.pool?.dispose()
338
+ this.backend?.dispose()
339
+ this.room?.dispose()
340
+ this.gate?.dispose()
341
+ this.emit('dispose')
342
+ this.listeners.clear()
343
+ }
344
+
345
+ // ---------------------------------------------------------------- sounding
346
+
347
+ /**
348
+ * The single entry point for anything that wants to sound an element (Invariant: L2 drivers
349
+ * never touch Tone). Resolves the element to its profile and rents a voice.
350
+ * `transpose` shifts in semitones relative to the quantized pitch — callers must pass
351
+ * consonant intervals only (e.g. keyboard.ts FILL_INTERVALS).
352
+ */
353
+ excite(el: Element, velocity: number, articulation: Articulation, when?: number, transpose = 0): void {
354
+ if (this.state !== 'running' || this.muted || !this.pool) return
355
+ if (when !== undefined && !Number.isFinite(when)) when = undefined // NaN must never reach a ramp
356
+ const target = this.scanner.resolve(el) ?? el
357
+ let profile = this.scanner.profileFor(target)
358
+ if (!profile) return
359
+
360
+ // PULSE.md §4 — the calm system: repeated sounds recede (motifs/echoes/phrases exempt).
361
+ if (articulation !== 'motif' && articulation !== 'echo' && articulation !== 'phrase' && articulation !== 'whisper') {
362
+ const now = performance.now()
363
+ const a = this.activity.get(target) ?? { c: 0, t: now }
364
+ a.c = decayCount(a.c, now - a.t) + 1
365
+ a.t = now
366
+ this.activity.set(target, a)
367
+ velocity *= duckFactor(a.c - 1)
368
+ }
369
+ if (transpose !== 0) {
370
+ profile = { ...profile, midi: profile.midi + transpose, freqHz: profile.freqHz * Math.pow(2, transpose / 12) }
371
+ }
372
+ if (articulation === 'tick') {
373
+ profile = { ...profile, durationS: Math.min(profile.durationS, 0.07), release: 0.05 }
374
+ }
375
+ if (articulation === 'toggle-on' || articulation === 'toggle-off') {
376
+ const dir = articulation === 'toggle-on' ? 1 : -1
377
+ const base = { ...profile, durationS: 0.12 }
378
+ this.pool.trigger(base, velocity * this.opts.velocityFactor, when)
379
+ // perfect-5th answer note (I6): 7 semitones up/down from the quantized pitch
380
+ const second = { ...profile, midi: profile.midi + dir * 7, freqHz: profile.freqHz * Math.pow(2, (dir * 7) / 12), durationS: 0.16 }
381
+ this.pool.trigger(second, velocity * this.opts.velocityFactor, (when ?? Tone.now()) + 0.09)
382
+ } else {
383
+ this.pool.trigger(profile, velocity * this.opts.velocityFactor, when)
384
+ }
385
+
386
+ // PULSE.md §2 — the room answers strong hits on the next 8th, one octave up, quiet.
387
+ // The primary hit above was NOT delayed (the latency invariant, §0).
388
+ if (articulation === 'hit' && velocity >= 0.55) {
389
+ const offset = nextGridOffset(Tone.getTransport().seconds, echoGridS(this.tempo), ECHO_MIN_AHEAD_S)
390
+ this.excite(target, velocity * ECHO_VELOCITY_SCALE, 'echo', Tone.now() + offset, ECHO_TRANSPOSE)
391
+ }
392
+
393
+ this.emit('trigger', { el: target, profile, velocity, articulation } satisfies TriggerDetail)
394
+ }
395
+
396
+ /** I3/I11 — strum a set of elements left→right (or right→left for a reverse flick). */
397
+ strum(els: Element[], velocity: number, articulation: Articulation = 'strum', reverse = false): void {
398
+ if (this.state !== 'running' || !this.pool) return
399
+ const sorted = els
400
+ .map((el) => ({ el, p: this.scanner.profileFor(el) }))
401
+ .filter((x): x is { el: Element; p: SonicProfile } => !!x.p)
402
+ .sort((a, b) => (reverse ? b.p.rect.x - a.p.rect.x : a.p.rect.x - b.p.rect.x))
403
+ .slice(0, 6)
404
+ // metered: 32nd-note spacing at the page's tempo (PULSE.md §3)
405
+ const t0 = Tone.now()
406
+ const step = strumStepS(this.tempo)
407
+ sorted.forEach(({ el }, i) => this.excite(el, velocity, articulation, t0 + i * step))
408
+ }
409
+
410
+ private whisper(el: Element): void {
411
+ if (this.appearBucket <= 0) return
412
+ this.appearBucket--
413
+ this.excite(el, 0.12, 'whisper')
414
+ }
415
+
416
+ /** I12 — the page introduces itself: its largest landmarks, in DOM order, in the site key. */
417
+ private playIntroMotif(): void {
418
+ const candidates = Array.from(
419
+ this.opts.root.querySelectorAll('h1, h2, nav, main, [role=banner], header, button, [role=button]'),
420
+ ).filter((el) => {
421
+ const r = el.getBoundingClientRect()
422
+ return r.width > 0 && r.height > 0 && r.top < this.env.vh
423
+ })
424
+ const byArea = candidates
425
+ .map((el) => ({ el, area: el.getBoundingClientRect().width * el.getBoundingClientRect().height }))
426
+ .sort((a, b) => b.area - a.area)
427
+ .slice(0, 5)
428
+ .map((x) => x.el)
429
+ const inDomOrder = candidates.filter((el) => byArea.includes(el))
430
+ const t0 = Tone.now() + 0.1
431
+ const step = secondsPerBeat(this.tempo) / 4 // the motif walks 16ths at the page's tempo
432
+ inDomOrder.forEach((el, i) => this.excite(el, 0.3, 'motif', t0 + i * step))
433
+ }
434
+
435
+ // ---------------------------------------------------------------- introspection
436
+
437
+ /** Invariant #6 — explain why an element sounds the way it does. Works before start(). */
438
+ describe(el: Element): SonicProfile | null {
439
+ return this.scanner.profileFor(el)
440
+ }
441
+
442
+ geometryChanged(): void {
443
+ this.env.vw = window.innerWidth
444
+ this.env.vh = window.innerHeight
445
+ this.scanner?.updateEnv(this.env.vw, this.env.vh)
446
+ this.scanner?.invalidateRects()
447
+ }
448
+
449
+ roomResized(): void {
450
+ this.geometryChanged()
451
+ this.room?.resize(this.env.vw)
452
+ this.backend?.onViewport(this.env.vw)
453
+ }
454
+
455
+ // ---------------------------------------------------------------- events
456
+
457
+ on(event: SonariumEvent, fn: (detail?: unknown) => void): () => void {
458
+ if (!this.listeners.has(event)) this.listeners.set(event, new Set())
459
+ this.listeners.get(event)!.add(fn)
460
+ return () => this.listeners.get(event)?.delete(fn)
461
+ }
462
+
463
+ private emit(event: SonariumEvent, detail?: unknown): void {
464
+ for (const fn of this.listeners.get(event) ?? []) {
465
+ try { fn(detail) } catch (err) { console.warn('[sonarium] listener error', err) }
466
+ }
467
+ }
468
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * L0/L2 bridge — the ListenerRig: where the ears are (ARCHITECTURE.md §2).
3
+ * Pointer/center modes + device-tilt offset, lerped each frame to avoid zipper artifacts.
4
+ */
5
+ import * as Tone from 'tone'
6
+ import { ROOM_HALF_H, ROOM_HALF_W } from '../math/mapping'
7
+ import { clamp, lerp } from '../math/util'
8
+
9
+ export class ListenerRig {
10
+ private target = { x: 0, y: 0 }
11
+ private tilt = { x: 0, y: 0 }
12
+ private pos = { x: 0, y: 0 }
13
+ private raf = 0
14
+ private running = false
15
+
16
+ constructor(private mode: 'pointer' | 'center') {}
17
+
18
+ start(): void {
19
+ if (this.running) return
20
+ this.running = true
21
+ const listener = Tone.getListener()
22
+ // Listener faces -z (WebAudio default); voices live at negative z (MAPPING.md S1).
23
+ listener.forwardX.value = 0
24
+ listener.forwardY.value = 0
25
+ listener.forwardZ.value = -1
26
+ listener.upY.value = 1
27
+ const step = () => {
28
+ if (!this.running) return
29
+ const gx = clamp(this.target.x + this.tilt.x, -ROOM_HALF_W, ROOM_HALF_W)
30
+ const gy = clamp(this.target.y + this.tilt.y, -ROOM_HALF_H, ROOM_HALF_H)
31
+ this.pos.x = lerp(this.pos.x, gx, 0.12)
32
+ this.pos.y = lerp(this.pos.y, gy, 0.12)
33
+ try {
34
+ listener.positionX.value = this.pos.x
35
+ listener.positionY.value = this.pos.y
36
+ listener.positionZ.value = 0
37
+ } catch { /* context may be closing */ }
38
+ this.raf = requestAnimationFrame(step)
39
+ }
40
+ this.raf = requestAnimationFrame(step)
41
+ }
42
+
43
+ /** I9 — pointer position (viewport-normalized 0..1) targets the ears. */
44
+ pointTo(tx: number, ty: number): void {
45
+ if (this.mode !== 'pointer') return
46
+ // ×0.8: ears track the cursor but stay slightly behind it, keeping the field stable.
47
+ this.target.x = (2 * clamp(tx, 0, 1) - 1) * ROOM_HALF_W * 0.8
48
+ this.target.y = (1 - 2 * clamp(ty, 0, 1)) * ROOM_HALF_H * 0.8
49
+ }
50
+
51
+ /** I10 — device tilt offsets the ears (γ → x ±4 m, β → y ±2 m). */
52
+ tiltTo(gamma: number, beta: number): void {
53
+ this.tilt.x = clamp(gamma / 45, -1, 1) * 4
54
+ this.tilt.y = clamp((beta - 40) / 45, -1, 1) * -2
55
+ }
56
+
57
+ dispose(): void {
58
+ this.running = false
59
+ cancelAnimationFrame(this.raf)
60
+ }
61
+ }