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.
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/dist/index.d.ts +1000 -0
- package/dist/index.js +2716 -0
- package/dist/index.js.map +1 -0
- package/dist/sonarium.iife.js +236 -0
- package/dist/sonarium.iife.js.map +1 -0
- package/package.json +63 -0
- package/src/core/engine.ts +468 -0
- package/src/core/listener.ts +61 -0
- package/src/core/matter-voice.ts +240 -0
- package/src/core/profile.ts +313 -0
- package/src/core/room.ts +135 -0
- package/src/core/scanner.ts +176 -0
- package/src/core/voices.ts +222 -0
- package/src/index.ts +74 -0
- package/src/interact/activate.ts +56 -0
- package/src/interact/drag.ts +52 -0
- package/src/interact/keyboard.ts +48 -0
- package/src/interact/midi.ts +40 -0
- package/src/interact/motion.ts +61 -0
- package/src/interact/pointer.ts +65 -0
- package/src/interact/scroll.ts +43 -0
- package/src/math/chroma.ts +111 -0
- package/src/math/mapping.ts +100 -0
- package/src/math/matter.ts +201 -0
- package/src/math/modular.ts +99 -0
- package/src/math/pulse.ts +64 -0
- package/src/math/scales.ts +90 -0
- package/src/math/util.ts +16 -0
- package/src/spatial/backend.ts +166 -0
- package/src/spatial/bus.ts +114 -0
- package/src/spatial/decoder.ts +63 -0
- package/src/spatial/encoder.ts +54 -0
- package/src/spatial/field.ts +75 -0
- package/src/spatial/perceptual.ts +43 -0
- package/src/spatial/room-foa.ts +81 -0
- package/src/spatial/rotation.ts +73 -0
- package/src/spatial/sh.ts +38 -0
- package/src/spatial/sphere.ts +36 -0
- package/src/themes/index.ts +60 -0
- package/src/types.ts +157 -0
- 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
|
+
}
|