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
|
@@ -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
|
+
}
|
package/src/core/room.ts
ADDED
|
@@ -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
|
+
}
|