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
@@ -0,0 +1,240 @@
1
+ /**
2
+ * MatterVoice — the woven, modular instrument (MATTER.md §3 + MODULAR.md §2).
3
+ * Harmonic core (custom spectrum + sub/shimmer partner + unison twin) through a wavefolder,
4
+ * audio-rate FM cable from oscB, breath + burst noise layers, and one LFO patched from CSS
5
+ * (vibrato / tremolo / filter wobble). Supports one-shot triggers, sustained ribbon gating,
6
+ * and sleeping (all sources stop when idle; restart in place). Imports Tone only.
7
+ */
8
+ import * as Tone from 'tone'
9
+ import { foldCurve } from '../math/modular'
10
+ import type { MatterVoiceParams } from '../types'
11
+
12
+ export interface MatterTrigger {
13
+ freqHz: number
14
+ velocity: number
15
+ durationS: number
16
+ releaseScaleBase: number
17
+ voice: MatterVoiceParams
18
+ }
19
+
20
+ const FOLD_CURVE = foldCurve()
21
+
22
+ export class MatterVoice {
23
+ /** Pool connects this to the lane filter's frequency param (LFO wobble tap). */
24
+ readonly modFilterOut: Tone.Gain
25
+
26
+ private oscA: Tone.Oscillator
27
+ private oscA2: Tone.Oscillator
28
+ private oscB: Tone.Oscillator
29
+ private oscBGain: Tone.Gain
30
+ private fmGain: Tone.Gain
31
+ private foldPre: Tone.Gain
32
+ private foldShaper: Tone.WaveShaper
33
+ private foldWet: Tone.Gain
34
+ private foldDry: Tone.Gain
35
+ private unisonGain: Tone.Gain
36
+ private breathNoise: Tone.Noise
37
+ private breathFilter: Tone.Filter
38
+ private breathGain: Tone.Gain
39
+ private burstNoise: Tone.Noise
40
+ private burstFilter: Tone.Filter
41
+ private burstEnv: Tone.AmplitudeEnvelope
42
+ private ampEnv: Tone.AmplitudeEnvelope
43
+ private mix: Tone.Gain
44
+ private lfo: Tone.LFO
45
+ private lfoVib: Tone.Gain
46
+ private lfoTrem: Tone.Gain
47
+ private out: Tone.Gain
48
+ private startedSources = false
49
+ private held = false
50
+
51
+ constructor() {
52
+ this.out = new Tone.Gain(1)
53
+ this.mix = new Tone.Gain(0.9)
54
+
55
+ this.oscA = new Tone.Oscillator({ frequency: 220 })
56
+ this.oscA2 = new Tone.Oscillator({ frequency: 220 })
57
+ this.oscB = new Tone.Oscillator({ frequency: 110, type: 'sine' })
58
+ this.oscBGain = new Tone.Gain(0.2)
59
+
60
+ // The wavefolder (MODULAR.md M1): oscA splits dry/folded; envelope sweeps the fold.
61
+ this.foldPre = new Tone.Gain(0.3)
62
+ this.foldShaper = new Tone.WaveShaper(FOLD_CURVE)
63
+ this.foldWet = new Tone.Gain(0)
64
+ this.foldDry = new Tone.Gain(1)
65
+ this.oscA.connect(this.foldPre)
66
+ this.foldPre.connect(this.foldShaper)
67
+ this.foldShaper.connect(this.foldWet)
68
+ this.foldWet.connect(this.mix)
69
+ this.oscA.connect(this.foldDry)
70
+ this.foldDry.connect(this.mix)
71
+
72
+ // Unison twin stays clean (thickness without mud).
73
+ this.unisonGain = new Tone.Gain(0)
74
+ this.oscA2.connect(this.unisonGain)
75
+ this.unisonGain.connect(this.mix)
76
+
77
+ // The FM cable (M2): oscB modulates oscA's frequency at audio rate.
78
+ this.fmGain = new Tone.Gain(0)
79
+ this.oscB.connect(this.fmGain)
80
+ this.fmGain.connect(this.oscA.frequency)
81
+ this.oscB.connect(this.oscBGain)
82
+ this.oscBGain.connect(this.mix)
83
+
84
+ this.breathNoise = new Tone.Noise('pink')
85
+ this.breathFilter = new Tone.Filter({ type: 'bandpass', frequency: 600, Q: 1.1 })
86
+ this.breathGain = new Tone.Gain(0)
87
+ this.breathNoise.connect(this.breathFilter)
88
+ this.breathFilter.connect(this.breathGain)
89
+ this.breathGain.connect(this.mix)
90
+
91
+ this.ampEnv = new Tone.AmplitudeEnvelope({ attack: 0.01, decay: 0.2, sustain: 0.25, release: 0.3 })
92
+ this.mix.connect(this.ampEnv)
93
+ this.ampEnv.connect(this.out)
94
+
95
+ // The /k/ of kiki: bypasses the amp envelope so it stays a crisp onset event.
96
+ this.burstNoise = new Tone.Noise('white')
97
+ this.burstFilter = new Tone.Filter({ type: 'highpass', frequency: 3000 })
98
+ this.burstEnv = new Tone.AmplitudeEnvelope({ attack: 0.001, decay: 0.02, sustain: 0, release: 0.02 })
99
+ this.burstNoise.connect(this.burstFilter)
100
+ this.burstFilter.connect(this.burstEnv)
101
+ this.burstEnv.connect(this.out)
102
+
103
+ // One LFO, three depth taps (M4/M5): vibrato, tremolo, filter wobble.
104
+ this.lfo = new Tone.LFO({ frequency: 0.5, min: -1, max: 1, type: 'sine' })
105
+ this.lfoVib = new Tone.Gain(0)
106
+ this.lfoTrem = new Tone.Gain(0)
107
+ this.modFilterOut = new Tone.Gain(0)
108
+ this.lfo.connect(this.lfoVib)
109
+ this.lfo.connect(this.lfoTrem)
110
+ this.lfo.connect(this.modFilterOut)
111
+ this.lfoVib.connect(this.oscA.detune)
112
+ this.lfoVib.connect(this.oscA2.detune)
113
+ this.lfoTrem.connect(this.mix.gain)
114
+ }
115
+
116
+ connect(dest: Tone.InputNode): this {
117
+ this.out.connect(dest)
118
+ return this
119
+ }
120
+
121
+ private ensureRunning(when: number): void {
122
+ if (this.startedSources) return
123
+ this.startedSources = true
124
+ this.oscA.start(when)
125
+ this.oscA2.start(when)
126
+ this.oscB.start(when)
127
+ this.breathNoise.start(when)
128
+ this.burstNoise.start(when)
129
+ this.lfo.start(when)
130
+ }
131
+
132
+ /** MODULAR.md §4 — idle lanes power down completely; the next trigger restarts in place. */
133
+ sleep(): void {
134
+ if (!this.startedSources || this.held) return
135
+ this.startedSources = false
136
+ try {
137
+ this.oscA.stop()
138
+ this.oscA2.stop()
139
+ this.oscB.stop()
140
+ this.breathNoise.stop()
141
+ this.burstNoise.stop()
142
+ this.lfo.stop()
143
+ } catch { /* already stopped */ }
144
+ }
145
+
146
+ /** Everything both trigger() and gateOn() share: spectrum, patch, pitch, modulation. */
147
+ private applyVoice(t: MatterTrigger, when: number, baseCutoffHz: number): void {
148
+ const v = t.voice
149
+ this.ensureRunning(when)
150
+ this.oscA.partials = v.partials
151
+ this.oscA2.partials = v.partials
152
+
153
+ // Pitch behaviour: glide for round (+M6 portamento), jitter for rough texture.
154
+ const jitter = (Math.random() * 2 - 1) * v.jitterCents
155
+ this.oscA.detune.setValueAtTime(jitter, when)
156
+ const glide = v.glideS + v.patch.portamentoS
157
+ if (glide > 0.002 && !this.held) {
158
+ this.oscA.frequency.setValueAtTime(t.freqHz * 0.917, when)
159
+ this.oscA.frequency.rampTo(t.freqHz, glide, when)
160
+ } else {
161
+ this.oscA.frequency.setValueAtTime(t.freqHz, when)
162
+ }
163
+ this.oscA2.frequency.setValueAtTime(t.freqHz, when)
164
+ this.oscA2.detune.setValueAtTime(jitter + v.patch.unison.detuneCents, when)
165
+ this.unisonGain.gain.rampTo(v.patch.unison.mix, 0.02, when)
166
+ this.oscB.frequency.setValueAtTime(t.freqHz * Math.pow(2, v.subShimmer.interval / 12), when)
167
+ this.oscBGain.gain.rampTo(v.subShimmer.level, 0.02, when)
168
+
169
+ // Modular patch: fold drive/mix, FM index (× carrier), LFO rate/shape/depths.
170
+ this.foldPre.gain.rampTo(0.3 + v.patch.fold.drive * 1.2, 0.02, when)
171
+ this.foldWet.gain.rampTo(v.patch.fold.mix, 0.02, when)
172
+ this.foldDry.gain.rampTo(1 - v.patch.fold.mix * 0.7, 0.02, when)
173
+ this.fmGain.gain.rampTo(v.patch.fm.index * t.freqHz, 0.02, when)
174
+ const lfoOn = v.patch.lfo.rateHz > 0.01
175
+ this.lfo.frequency.value = Math.max(0.01, v.patch.lfo.rateHz)
176
+ this.lfo.type = v.patch.lfo.shape
177
+ this.lfoVib.gain.rampTo(lfoOn ? v.patch.lfo.vibratoCents : 0, 0.05, when)
178
+ this.lfoTrem.gain.rampTo(lfoOn ? 0.9 * v.patch.lfo.tremolo : 0, 0.05, when)
179
+ this.modFilterOut.gain.rampTo(lfoOn ? v.patch.lfo.filterDepth * baseCutoffHz : 0, 0.05, when)
180
+
181
+ // Breath rides the tone (common fate = fusion).
182
+ this.breathFilter.frequency.rampTo(Math.min(8000, t.freqHz * v.breath.bpRatio), 0.02, when)
183
+ this.breathGain.gain.rampTo(v.breath.level, 0.02, when)
184
+
185
+ // Envelope weave.
186
+ this.ampEnv.attack = v.envelope.attackS
187
+ this.ampEnv.decay = v.envelope.decayS
188
+ this.ampEnv.sustain = v.envelope.sustain
189
+ this.ampEnv.release = 0.3 * t.releaseScaleBase * v.envelope.releaseScale
190
+ }
191
+
192
+ trigger(t: MatterTrigger, when: number, baseCutoffHz = 4000): void {
193
+ this.applyVoice(t, when, baseCutoffHz)
194
+ this.ampEnv.triggerAttackRelease(t.durationS, when, t.velocity)
195
+ if (t.voice.transient.level > 0.02) {
196
+ this.burstFilter.frequency.setValueAtTime(t.voice.transient.hpHz, when)
197
+ this.burstEnv.decay = t.voice.transient.lengthS
198
+ this.burstEnv.triggerAttackRelease(t.voice.transient.lengthS, when, t.velocity * t.voice.transient.level)
199
+ }
200
+ }
201
+
202
+ // ----------------------------------------------------------- ribbon (MODULAR.md §3)
203
+
204
+ gateOn(t: MatterTrigger, when: number, baseCutoffHz = 4000): void {
205
+ this.applyVoice(t, when, baseCutoffHz)
206
+ this.held = true
207
+ this.ampEnv.sustain = Math.max(0.4, t.voice.envelope.sustain)
208
+ this.ampEnv.triggerAttack(when, t.velocity)
209
+ }
210
+
211
+ /** Ribbon pitch move — quantized upstream; the portamento IS the glissando feel. */
212
+ setFreq(freqHz: number, glideS: number, subInterval: number, fmIndex: number): void {
213
+ const g = Math.max(0.015, glideS)
214
+ this.oscA.frequency.rampTo(freqHz, g)
215
+ this.oscA2.frequency.rampTo(freqHz, g)
216
+ this.oscB.frequency.rampTo(freqHz * Math.pow(2, subInterval / 12), g)
217
+ this.fmGain.gain.rampTo(fmIndex * freqHz, g)
218
+ }
219
+
220
+ gateOff(when?: number): number {
221
+ this.held = false
222
+ this.ampEnv.triggerRelease(when ?? Tone.now())
223
+ return this.ampEnv.release as number
224
+ }
225
+
226
+ releaseTail(): number {
227
+ return this.ampEnv.release as number
228
+ }
229
+
230
+ dispose(): void {
231
+ for (const n of [
232
+ this.oscA, this.oscA2, this.oscB, this.oscBGain, this.fmGain,
233
+ this.foldPre, this.foldShaper, this.foldWet, this.foldDry, this.unisonGain,
234
+ this.breathNoise, this.breathFilter, this.breathGain,
235
+ this.burstNoise, this.burstFilter, this.burstEnv,
236
+ this.lfo, this.lfoVib, this.lfoTrem, this.modFilterOut,
237
+ this.ampEnv, this.mix, this.out,
238
+ ]) n.dispose()
239
+ }
240
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * L1 Page Reading — element → SonicProfile. Imports math + DOM, never Tone
3
+ * (ARCHITECTURE.md §1 dependency rules).
4
+ */
5
+ import {
6
+ attackFromRoundness, brightnessTilt, cutoffFromDepth, degreeFromSize, durationFromElongation,
7
+ panX, panY, pitchNudgeFromRoundness, qFromRoundness, roundness, sendFromShadowBlur, sizeT,
8
+ stepsFromHeadingLevel, stepsFromSiblingIndex, velocityFromDepth, velocityFromSize,
9
+ waveFromRoundness, zBonusFromZIndex, zFromDepth,
10
+ } from '../math/mapping'
11
+ import { clamp } from '../math/util'
12
+ import { degreeToMidi, midiToFreq, midiToNoteName, type SiteKey } from '../math/scales'
13
+ import {
14
+ breath, deriveMatter, detuneJitterCents, envelopeWeave, filterWeave, genPartials, glideS,
15
+ reverbWeave, subShimmer, transient,
16
+ } from '../math/matter'
17
+ import {
18
+ attackScaleFromWarmth, brightnessFromLuminance, chromaOf, parseCssColor,
19
+ richnessFromSaturation, subBonusFromWarmth, velocityFromLuminance, type Chroma,
20
+ } from '../math/chroma'
21
+ import { massBonusFromFontWeight, patchFrom, type ModularVisuals } from '../math/modular'
22
+ import { directivityFromRoundness, extentFromSize, sphereFromRect } from '../spatial/sphere'
23
+ import { directivityFilterScale } from '../spatial/perceptual'
24
+ import { DEG } from '../spatial/sh'
25
+ import type { MatterVoiceParams, Rect, Role, SonicProfile, SphereProps, Theme, VoiceRecipe, Wave } from '../types'
26
+
27
+ const HEADING = /^H[1-6]$/
28
+
29
+ export function roleOf(el: Element): Role {
30
+ const aria = (el.getAttribute('role') || '').toLowerCase()
31
+ const override = (el as HTMLElement).dataset?.sonicRole as Role | undefined
32
+ if (override) return override
33
+ const tag = el.tagName
34
+ const type = (el.getAttribute('type') || '').toLowerCase()
35
+ if (tag === 'INPUT' && (type === 'checkbox' || type === 'radio')) return 'toggle'
36
+ if (aria === 'switch' || aria === 'checkbox' || tag === 'SUMMARY') return 'toggle'
37
+ if (tag === 'BUTTON' || aria === 'button' || (tag === 'INPUT' && (type === 'button' || type === 'submit' || type === 'reset'))) return 'button'
38
+ if ((tag === 'A' && el.hasAttribute('href')) || aria === 'link') return 'link'
39
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (el as HTMLElement).isContentEditable) return 'input'
40
+ if (HEADING.test(tag) || aria === 'heading') return 'heading'
41
+ if (tag === 'IMG' || tag === 'VIDEO' || tag === 'SVG' || tag === 'CANVAS' || tag === 'PICTURE' || tag === 'AUDIO') return 'media'
42
+ if (tag === 'LI' || tag === 'TR' || ['listitem', 'option', 'menuitem', 'tab'].includes(aria)) return 'item'
43
+ if (['NAV', 'SECTION', 'ARTICLE', 'ASIDE', 'FORM', 'FIELDSET', 'HEADER', 'FOOTER', 'MAIN', 'DIALOG'].includes(tag)) return 'container'
44
+ if (['navigation', 'region', 'group', 'list', 'menu', 'tablist', 'dialog'].includes(aria)) return 'container'
45
+ if (tag === 'P' || tag === 'BLOCKQUOTE' || tag === 'LABEL' || tag === 'SPAN') return 'text'
46
+ return 'container'
47
+ }
48
+
49
+ export function domDepth(el: Element, root: Element): number {
50
+ let d = 0
51
+ let n: Element | null = el
52
+ while (n && n !== root && d < 32) {
53
+ n = n.parentElement
54
+ d++
55
+ }
56
+ return d
57
+ }
58
+
59
+ function eligibleSiblingIndex(el: Element): number {
60
+ const parent = el.parentElement
61
+ if (!parent) return 0
62
+ let i = 0
63
+ for (const sib of Array.from(parent.children)) {
64
+ if (sib === el) return i
65
+ if (sib.tagName === el.tagName || roleOf(sib) === roleOf(el)) i++
66
+ }
67
+ return 0
68
+ }
69
+
70
+ function recipeFor(role: Role, theme: Theme): VoiceRecipe {
71
+ return { ...theme.defaults, ...(theme.roles[role] ?? {}) }
72
+ }
73
+
74
+ function isQuiet(el: Element): boolean {
75
+ let n: Element | null = el
76
+ while (n) {
77
+ if ((n as HTMLElement).dataset?.sonic === 'quiet') return true
78
+ n = n.parentElement
79
+ }
80
+ return false
81
+ }
82
+
83
+ const NOTE_RE = /^([A-Ga-g][#b]?)(-?\d)$/
84
+ function pinnedMidi(spec: string | undefined): number | null {
85
+ if (!spec) return null
86
+ const m = spec.trim().match(NOTE_RE)
87
+ if (!m) return null
88
+ const pcs: Record<string, number> = { C: 0, 'C#': 1, Db: 1, D: 2, 'D#': 3, Eb: 3, E: 4, F: 5, 'F#': 6, Gb: 6, G: 7, 'G#': 8, Ab: 8, A: 9, 'A#': 10, Bb: 10, B: 11 }
89
+ const name = (m[1] as string).charAt(0).toUpperCase() + (m[1] as string).slice(1)
90
+ const pc = pcs[name]
91
+ if (pc === undefined) return null
92
+ return (parseInt(m[2] as string, 10) + 1) * 12 + pc
93
+ }
94
+
95
+ export interface ProfileEnv {
96
+ root: Element
97
+ key: SiteKey
98
+ theme: Theme
99
+ vw: number
100
+ vh: number
101
+ }
102
+
103
+ /** CSS custom property, trimmed; '' when unset. Inherits down the tree (the aural stylesheet). */
104
+ function sonicVar(cs: CSSStyleDeclaration, name: string): string {
105
+ return cs.getPropertyValue(name).trim()
106
+ }
107
+
108
+ /** Compute the full acoustic identity of an element. Pure given (el state, env). */
109
+ export function profileOf(el: Element, env: ProfileEnv): SonicProfile {
110
+ const { key, theme, vw, vh } = env
111
+ const html = el as HTMLElement
112
+ const reasons: Record<string, string> = {}
113
+
114
+ const cs = getComputedStyle(el)
115
+ const r = el.getBoundingClientRect()
116
+ const rect: Rect = { x: r.x, y: r.y, w: Math.max(1, r.width), h: Math.max(1, r.height) }
117
+ // Priority: data-sonic-role > --sonic-role > inference (CHROMA.md §4).
118
+ const role = (html.dataset?.sonicRole as Role | undefined)
119
+ || (sonicVar(cs, '--sonic-role') as Role | '')
120
+ || roleOf(el)
121
+ const recipe = recipeFor(role, theme)
122
+ reasons.role = `<${el.tagName.toLowerCase()}> reads as "${role}" → ${recipe.synthKind} voice (theme ${theme.name})`
123
+ // border-radius percentages survive into computed style — resolve against the box.
124
+ const radiusRaw = cs.borderTopLeftRadius
125
+ const radiusPx = radiusRaw.endsWith('%')
126
+ ? ((parseFloat(radiusRaw) || 0) / 100) * Math.min(rect.w, rect.h)
127
+ : parseFloat(radiusRaw) || 0
128
+ const opacity = parseFloat(cs.opacity)
129
+ const shadowBlur = parseShadowBlur(cs.boxShadow)
130
+ const zIndex = cs.position !== 'static' ? parseInt(cs.zIndex, 10) || 0 : 0
131
+
132
+ // Space (G1, G2, S1, S5)
133
+ const depth = domDepth(el, env.root)
134
+ const pan = {
135
+ x: panX(rect, vw),
136
+ y: panY(rect, vh),
137
+ z: zFromDepth(depth) + zBonusFromZIndex(zIndex),
138
+ }
139
+ reasons.position = `center (${Math.round(rect.x + rect.w / 2)}, ${Math.round(rect.y + rect.h / 2)}) px → (${pan.x.toFixed(1)}, ${pan.y.toFixed(1)}) m; depth ${depth} → ${pan.z.toFixed(1)} m away`
140
+
141
+ // Pitch (G4, S4, S6, G10) through the quantizer
142
+ const st = sizeT(rect, vw, vh)
143
+ const degree = degreeFromSize(st)
144
+ const round = roundness(radiusPx, rect)
145
+ let steps = stepsFromSiblingIndex(eligibleSiblingIndex(el), key.scale.length) + pitchNudgeFromRoundness(round)
146
+ if (role === 'heading') {
147
+ const level = HEADING.test(el.tagName) ? parseInt(el.tagName[1] as string, 10) : 2
148
+ steps += stepsFromHeadingLevel(level)
149
+ }
150
+ steps += recipe.octaveShift * key.scale.length
151
+ const pinned = pinnedMidi(html.dataset?.sonicNote ?? sonicVar(cs, '--sonic-note') ?? undefined)
152
+ const midi = pinned ?? degreeToMidi(degree, key, steps)
153
+ reasons.pitch = pinned !== null
154
+ ? `pinned by data-sonic-note → ${midiToNoteName(midi)}`
155
+ : `area ${(rect.w * rect.h / 1000).toFixed(1)}k px² (size ${st.toFixed(2)}) + sibling/heading offsets → ${midiToNoteName(midi)} in ${key.label}`
156
+
157
+ // Timbre (G6–G9) — Kiki/Bouba
158
+ const wave: Wave = (html.dataset?.sonicWave as Wave)
159
+ || (sonicVar(cs, '--sonic-wave') as Wave | '')
160
+ || recipe.pinWave
161
+ || waveFromRoundness(round)
162
+ const attack = attackFromRoundness(round)
163
+ reasons.timbre = `roundness ${round.toFixed(2)} (radius ${radiusPx}px) → ${wave} wave, ${(attack * 1000).toFixed(0)} ms attack`
164
+
165
+ // Duration (G11)
166
+ const durationS = durationFromElongation(rect)
167
+ reasons.duration = `aspect ${(Math.max(rect.w, rect.h) / Math.min(rect.w, rect.h)).toFixed(1)}:1 → ${durationS.toFixed(2)} s`
168
+
169
+ // Sphere — the 聲球 (SPATIAL.md SP1–SP5)
170
+ const dir = sphereFromRect(rect, vw, vh)
171
+ const extentOverride = parseFloat(html.dataset?.sonicExtent ?? sonicVar(cs, '--sonic-extent'))
172
+ const sphere: SphereProps = {
173
+ azimuth: dir.azimuth,
174
+ elevation: dir.elevation,
175
+ extent: isNaN(extentOverride) ? extentFromSize(st) : clamp(extentOverride, 0, 1),
176
+ directivity: directivityFromRoundness(round),
177
+ }
178
+ reasons.sphere = `az ${(sphere.azimuth / DEG).toFixed(0)}°, el ${(sphere.elevation / DEG).toFixed(0)}°, extent ${sphere.extent.toFixed(2)} (size wraps the listener), directivity ${sphere.directivity.toFixed(2)} (sharp beams, round radiates)`
179
+
180
+ // The Chroma weave (CHROMA.md §1) — the element's color as mood, extracted once, used below.
181
+ const chroma = elementChroma(el, cs, role)
182
+
183
+ // Filter (S2 · G3, G9, CH1) — depth, height, directivity and color luminance share the cutoff
184
+ const filterHz = cutoffFromDepth(depth) * brightnessTilt(rect, vh)
185
+ * directivityFilterScale(sphere.directivity) * brightnessFromLuminance(chroma.luminance)
186
+ reasons.filter = `depth ${depth} + vertical position + directivity + luminance → low-pass ${Math.round(filterHz)} Hz`
187
+
188
+ // Loudness (G5, G12, S3, CH2, quiet via attribute or --sonic custom property)
189
+ let velocityScale = recipe.baseVelocity * velocityFromSize(st) * velocityFromDepth(depth)
190
+ * (isNaN(opacity) ? 1 : opacity) * velocityFromLuminance(chroma.luminance)
191
+ const sonicMode = sonicVar(cs, '--sonic')
192
+ if (isQuiet(el) || sonicMode === 'quiet') velocityScale *= 0.4
193
+ if (sonicMode === 'off') {
194
+ velocityScale = 0
195
+ reasons.silenced = '--sonic: off (aural stylesheet)'
196
+ }
197
+ velocityScale = clamp(velocityScale, 0, 1.5)
198
+
199
+ // The Matter weave (MATTER.md §2) — one material, many co-varying cues.
200
+ const elongation = Math.max(rect.w, rect.h) / Math.max(1, Math.min(rect.w, rect.h))
201
+ const textish = role === 'text' || role === 'heading' || role === 'link' || role === 'button'
202
+ const fontWeight = textish ? parseInt(cs.fontWeight, 10) || 400 : 400
203
+ const matter = deriveMatter({
204
+ roundness: round,
205
+ sizeT: st,
206
+ depth,
207
+ shadowBlurPx: shadowBlur,
208
+ opacity: isNaN(opacity) ? 1 : opacity,
209
+ dashedBorder: cs.borderTopStyle === 'dashed' || cs.borderTopStyle === 'dotted',
210
+ isMedia: role === 'media',
211
+ backdropBlurPx: parseBackdropBlur(cs),
212
+ massBonus: massBonusFromFontWeight(fontWeight),
213
+ })
214
+
215
+ // The modular patch (MODULAR.md §1) — CSS plugs the cables.
216
+ const modVisuals: ModularVisuals = {
217
+ animationS: cs.animationName && cs.animationName !== 'none' ? firstSeconds(cs.animationDuration) : 0,
218
+ borderStyle: (['solid', 'dashed', 'dotted'].includes(cs.borderTopStyle) ? cs.borderTopStyle : 'none') as ModularVisuals['borderStyle'],
219
+ borderWidthPx: parseFloat(cs.borderTopWidth) || 0,
220
+ transitionS: firstSeconds(cs.transitionDuration),
221
+ }
222
+ const patch = patchFrom(matter, modVisuals)
223
+ // Chroma tints the matter (CH3–CH5) — never a second instrument.
224
+ const env0 = envelopeWeave(matter.edge, matter.mass)
225
+ const sub0 = subShimmer(matter.mass)
226
+ const voice: MatterVoiceParams = {
227
+ matter,
228
+ partials: Array.from(genPartials(matter.edge, elongation, richnessFromSaturation(chroma.saturation))),
229
+ transient: transient(matter.edge),
230
+ breath: breath(matter.texture),
231
+ subShimmer: {
232
+ interval: sub0.interval,
233
+ level: sub0.level + (sub0.interval < 0 ? subBonusFromWarmth(chroma.warmth) : 0),
234
+ },
235
+ glideS: glideS(matter.edge),
236
+ jitterCents: detuneJitterCents(matter.texture),
237
+ envelope: { ...env0, attackS: env0.attackS * attackScaleFromWarmth(chroma.warmth) },
238
+ filter: filterWeave(matter.edge),
239
+ reverb: reverbWeave(matter.edge, matter.mass, matter.texture),
240
+ patch,
241
+ }
242
+ if (patch.fold.mix > 0.05 || patch.fm.index > 0.05 || patch.lfo.rateHz > 0.01 || patch.unison.mix > 0) {
243
+ reasons.patch = [
244
+ patch.fold.mix > 0.05 ? `border drives the wavefolder ×${patch.fold.drive.toFixed(2)}` : '',
245
+ patch.fm.index > 0.05 ? `FM ring ${patch.fm.index.toFixed(2)}` : '',
246
+ patch.unison.mix > 0 ? `unison +${patch.unison.detuneCents.toFixed(0)}¢` : '',
247
+ patch.lfo.rateHz > 0.01 ? `${patch.lfo.shape} LFO ${patch.lfo.rateHz.toFixed(2)} Hz (${modVisuals.animationS > 0 ? 'css animation' : 'dashed border'})` : '',
248
+ ].filter(Boolean).join(' · ')
249
+ }
250
+ reasons.chroma = `warmth ${chroma.warmth.toFixed(2)} · sat ${chroma.saturation.toFixed(2)} · lum ${chroma.luminance.toFixed(2)} → ${chroma.warmth > 0.6 ? 'eager onset, full body' : chroma.warmth < 0.4 ? 'cool, unhurried onset' : 'neutral temperament'}${chroma.saturation > 0.5 ? ', vivid spectrum' : ''}`
251
+ reasons.matter = `edge ${matter.edge.toFixed(2)} · mass ${matter.mass.toFixed(2)} · texture ${matter.texture.toFixed(2)} · air ${matter.air.toFixed(2)} → ${voice.transient.level > 0.1 ? 'clicky' : 'soft'}, ${voice.breath.level > 0.05 ? 'breathy' : 'clean'}, ${voice.subShimmer.interval < 0 ? 'chest sub' : 'sparkle +8va'}, ${voice.reverb.bloom > 0.5 ? 'blooms into the room' : 'dry strike'}`
252
+
253
+ // Room (G13 + MATTER reverb weave + SP3 distance wetness)
254
+ const reverbSend = clamp(
255
+ (0.18 + sendFromShadowBlur(shadowBlur) + 0.05 * Math.min(depth, 10) * 0.5) * voice.reverb.sendScale,
256
+ 0,
257
+ 1.2,
258
+ )
259
+
260
+ return {
261
+ role, rect, pan, sphere,
262
+ midi, freqHz: midiToFreq(midi), degree,
263
+ wave, attack, release: 0.3 * recipe.releaseScale, durationS,
264
+ filterHz, filterQ: qFromRoundness(round),
265
+ velocityScale, reverbSend,
266
+ synthKind: recipe.synthKind, octaveShift: recipe.octaveShift,
267
+ voice, chroma,
268
+ reasons,
269
+ }
270
+ }
271
+
272
+ /** CHROMA.md §1 — text-ish roles speak in their text color; boxes speak in their background,
273
+ * walking up past transparent ancestors. */
274
+ function elementChroma(el: Element, cs: CSSStyleDeclaration, role: Role): Chroma {
275
+ if (role === 'text' || role === 'heading' || role === 'link') {
276
+ return chromaOf(parseCssColor(cs.color))
277
+ }
278
+ let probe: Element | null = el
279
+ let style: CSSStyleDeclaration | undefined = cs
280
+ for (let hops = 0; probe && hops < 6; hops++) {
281
+ const rgb = parseCssColor((style ?? getComputedStyle(probe)).backgroundColor)
282
+ if (rgb && rgb.a >= 0.05) return chromaOf(rgb)
283
+ probe = probe.parentElement
284
+ style = undefined
285
+ }
286
+ return chromaOf(parseCssColor(getComputedStyle(document.documentElement).backgroundColor))
287
+ }
288
+
289
+ /** First duration in a comma list ("2s, 0.5s" → 2; "250ms" → 0.25). */
290
+ function firstSeconds(list: string): number {
291
+ const first = (list || '').split(',')[0]?.trim() ?? ''
292
+ if (!first) return 0
293
+ const v = parseFloat(first)
294
+ if (isNaN(v)) return 0
295
+ return first.endsWith('ms') ? v / 1000 : v
296
+ }
297
+
298
+ function parseBackdropBlur(cs: CSSStyleDeclaration): number {
299
+ const bf = cs.backdropFilter || (cs as unknown as Record<string, string>).webkitBackdropFilter || ''
300
+ const m = bf.match(/blur\((\d+(\.\d+)?)px\)/)
301
+ return m ? parseFloat(m[1] as string) : 0
302
+ }
303
+
304
+ function parseShadowBlur(boxShadow: string): number {
305
+ if (!boxShadow || boxShadow === 'none') return 0
306
+ // blur is the 3rd length in each shadow; take the max across shadows
307
+ let max = 0
308
+ for (const part of boxShadow.split(/,(?![^(]*\))/)) {
309
+ const lengths = part.match(/-?\d+(\.\d+)?px/g)
310
+ if (lengths && lengths.length >= 3) max = Math.max(max, parseFloat(lengths[2] as string))
311
+ }
312
+ return max
313
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * L0 Acoustic Substrate — the Room: master chain (spatial input → warmth/brilliance shelves →
3
+ * volume → limiter), viewport-sized reverb (S7/S8), ambience (I13), visibility fading (I14),
4
+ * mute. In 'panner' mode it also owns the classic dry/wet routing; in 'ambisonic' mode the
5
+ * AmbisonicBackend consumes `reverb` and `spatialIn` directly (SPATIAL.md §6). Imports Tone only.
6
+ */
7
+ import * as Tone from 'tone'
8
+ import { ambienceCutoffFromViewport, reverbFromViewport } from '../math/mapping'
9
+ import { clamp } from '../math/util'
10
+ import { brillianceDb, warmthDb, type PerceptualFactors } from '../spatial/perceptual'
11
+ import type { VoiceBuses } from './voices'
12
+
13
+ export interface RoomOptions {
14
+ volumeDb: number
15
+ reverb: 'auto' | number
16
+ ambient: number
17
+ mode: 'panner' | 'ambisonic'
18
+ }
19
+
20
+ export class Room {
21
+ /** Classic v0.1 buses — meaningful in 'panner' mode (dryIn = spatialIn, wetIn = reverb). */
22
+ readonly buses: VoiceBuses
23
+ /** Everything audible enters here (gets the perceptual EQ + limiter). */
24
+ readonly spatialIn: Tone.Gain
25
+ readonly reverb: Tone.Reverb
26
+
27
+ private master: Tone.Volume
28
+ private limiter: Tone.Limiter
29
+ private lowShelf: Tone.Filter
30
+ private highShelf: Tone.Filter
31
+ private wetGain: Tone.Gain | null = null
32
+ private noise: Tone.Noise | null = null
33
+ private noiseFilter: Tone.Filter | null = null
34
+ private noiseGain: Tone.Gain | null = null
35
+ private rushGain: Tone.Gain | null = null
36
+ private resizeTimer: ReturnType<typeof setTimeout> | null = null
37
+ private mutedNow = false
38
+ private toneScale = 1
39
+
40
+ constructor(private opts: RoomOptions, vw: number, factors: PerceptualFactors) {
41
+ this.limiter = new Tone.Limiter(-1).toDestination()
42
+ this.master = new Tone.Volume(opts.volumeDb).connect(this.limiter)
43
+ this.highShelf = new Tone.Filter({ type: 'highshelf', frequency: 4000, gain: brillianceDb(factors.brilliance) }).connect(this.master)
44
+ this.lowShelf = new Tone.Filter({ type: 'lowshelf', frequency: 250, gain: warmthDb(factors.warmth) }).connect(this.highShelf)
45
+ this.spatialIn = new Tone.Gain(1).connect(this.lowShelf)
46
+
47
+ const { decay, wet } = this.roomParams(vw)
48
+ this.reverb = new Tone.Reverb({ decay, preDelay: 0.02, wet: 1 })
49
+ if (opts.mode === 'panner') {
50
+ this.wetGain = new Tone.Gain(wet).connect(this.spatialIn)
51
+ this.reverb.connect(this.wetGain)
52
+ }
53
+ this.buses = { dryIn: this.spatialIn, wetIn: this.reverb }
54
+ }
55
+
56
+ setFactors(f: PerceptualFactors): void {
57
+ this.lowShelf.gain.rampTo(warmthDb(f.warmth), 0.1)
58
+ this.highShelf.gain.rampTo(brillianceDb(f.brilliance), 0.1)
59
+ }
60
+
61
+ private roomParams(vw: number): { decay: number; wet: number } {
62
+ if (this.opts.reverb !== 'auto') {
63
+ const decay = clamp(Number(this.opts.reverb) || 1.5, 0.1, 12)
64
+ return { decay, wet: 0.25 }
65
+ }
66
+ return reverbFromViewport(vw)
67
+ }
68
+
69
+ /** Debounced: Tone.Reverb regenerates its impulse response when decay changes. */
70
+ resize(vw: number): void {
71
+ if (this.resizeTimer) clearTimeout(this.resizeTimer)
72
+ this.resizeTimer = setTimeout(() => {
73
+ const { decay, wet } = this.roomParams(vw)
74
+ try {
75
+ this.reverb.decay = decay
76
+ this.wetGain?.gain.rampTo(wet, 0.3)
77
+ this.noiseFilter?.frequency.rampTo(ambienceCutoffFromViewport(vw) * this.toneScale, 0.5)
78
+ } catch (err) {
79
+ console.warn('[sonarium] room resize failed', err)
80
+ }
81
+ }, 400)
82
+ }
83
+
84
+ /** I13 — room tone (sparkles became the phrase engine, PULSE.md §3). toneScale = CH7 warmth. */
85
+ startAmbience(vw: number, level: number, toneScale = 1): void {
86
+ if (level <= 0) return
87
+ this.toneScale = toneScale
88
+ this.noise = new Tone.Noise('brown')
89
+ this.noiseFilter = new Tone.Filter({ frequency: ambienceCutoffFromViewport(vw) * toneScale, type: 'lowpass' })
90
+ this.noiseGain = new Tone.Gain(Tone.dbToGain(-46) * clamp(level / 0.12, 0, 3))
91
+ this.noise.connect(this.noiseFilter)
92
+ this.noiseFilter.connect(this.noiseGain)
93
+ this.noiseGain.connect(this.spatialIn)
94
+ // Air-rush path (MATTER.md §2.2): scroll velocity swells the same room-tone noise.
95
+ this.rushGain = new Tone.Gain(0)
96
+ this.noiseFilter.connect(this.rushGain)
97
+ this.rushGain.connect(this.spatialIn)
98
+ this.noise.start()
99
+ }
100
+
101
+ /** MATTER.md §2.2 — moving through the page moves air. Swells fast, decays in ~450 ms. */
102
+ rush(level: number): void {
103
+ if (!this.rushGain || this.mutedNow) return
104
+ const now = Tone.now()
105
+ this.rushGain.gain.cancelScheduledValues(now)
106
+ this.rushGain.gain.rampTo(level, 0.05, now)
107
+ this.rushGain.gain.rampTo(0, 0.45, now + 0.07)
108
+ }
109
+
110
+ /** I14 — never sound in a background tab. */
111
+ setHidden(hidden: boolean): void {
112
+ if (this.mutedNow) return
113
+ this.master.volume.rampTo(hidden ? -Infinity : this.opts.volumeDb, 0.3)
114
+ }
115
+
116
+ setMuted(muted: boolean): void {
117
+ this.mutedNow = muted
118
+ this.master.volume.rampTo(muted ? -Infinity : this.opts.volumeDb, 0.15)
119
+ }
120
+
121
+ dispose(): void {
122
+ if (this.resizeTimer) clearTimeout(this.resizeTimer)
123
+ this.noise?.dispose()
124
+ this.noiseFilter?.dispose()
125
+ this.noiseGain?.dispose()
126
+ this.rushGain?.dispose()
127
+ this.reverb.dispose()
128
+ this.wetGain?.dispose()
129
+ this.spatialIn.dispose()
130
+ this.lowShelf.dispose()
131
+ this.highShelf.dispose()
132
+ this.master.dispose()
133
+ this.limiter.dispose()
134
+ }
135
+ }