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/dist/index.js
ADDED
|
@@ -0,0 +1,2716 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __export = (target, all) => {
|
|
3
|
+
for (var name in all)
|
|
4
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
// src/core/engine.ts
|
|
8
|
+
import * as Tone9 from "tone";
|
|
9
|
+
|
|
10
|
+
// src/math/util.ts
|
|
11
|
+
var clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
|
12
|
+
var lerp = (a, b, t) => a + (b - a) * t;
|
|
13
|
+
var norm = (v, a, b) => clamp((v - a) / (b - a), 0, 1);
|
|
14
|
+
function fnv1a(str) {
|
|
15
|
+
let h = 2166136261;
|
|
16
|
+
for (let i = 0; i < str.length; i++) {
|
|
17
|
+
h ^= str.charCodeAt(i);
|
|
18
|
+
h = Math.imul(h, 16777619);
|
|
19
|
+
}
|
|
20
|
+
return h >>> 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/math/scales.ts
|
|
24
|
+
var SCALES = {
|
|
25
|
+
pentMajor: [0, 2, 4, 7, 9],
|
|
26
|
+
pentMinor: [0, 3, 5, 7, 10],
|
|
27
|
+
dorian: [0, 2, 3, 5, 7, 9, 10],
|
|
28
|
+
lydian: [0, 2, 4, 6, 7, 9, 11],
|
|
29
|
+
mixolydian: [0, 2, 4, 5, 7, 9, 10]
|
|
30
|
+
};
|
|
31
|
+
var SCALE_NAMES = Object.keys(SCALES);
|
|
32
|
+
var OCTAVES = 3;
|
|
33
|
+
var BASE_MIDI = 48;
|
|
34
|
+
var NOTE_TO_PC = {
|
|
35
|
+
C: 0,
|
|
36
|
+
"C#": 1,
|
|
37
|
+
Db: 1,
|
|
38
|
+
D: 2,
|
|
39
|
+
"D#": 3,
|
|
40
|
+
Eb: 3,
|
|
41
|
+
E: 4,
|
|
42
|
+
F: 5,
|
|
43
|
+
"F#": 6,
|
|
44
|
+
Gb: 6,
|
|
45
|
+
G: 7,
|
|
46
|
+
"G#": 8,
|
|
47
|
+
Ab: 8,
|
|
48
|
+
A: 9,
|
|
49
|
+
"A#": 10,
|
|
50
|
+
Bb: 10,
|
|
51
|
+
B: 11
|
|
52
|
+
};
|
|
53
|
+
var PC_TO_NOTE = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"];
|
|
54
|
+
function siteKey(hostname) {
|
|
55
|
+
const host = hostname || "localhost";
|
|
56
|
+
const h = fnv1a(host);
|
|
57
|
+
const root = h % 12;
|
|
58
|
+
const scaleName = SCALE_NAMES[(h >>> 4) % SCALE_NAMES.length];
|
|
59
|
+
const scale = SCALES[scaleName];
|
|
60
|
+
return { root, scaleName, scale, label: `${PC_TO_NOTE[root]} ${scaleName}` };
|
|
61
|
+
}
|
|
62
|
+
function parseKey(spec) {
|
|
63
|
+
const m = spec.trim().match(/^([A-Ga-g][#b]?)\s+(\w+)$/);
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
const note = m[1].charAt(0).toUpperCase() + m[1].slice(1);
|
|
66
|
+
const root = NOTE_TO_PC[note];
|
|
67
|
+
const scaleName = m[2];
|
|
68
|
+
const scale = SCALES[scaleName];
|
|
69
|
+
if (root === void 0 || !scale) return null;
|
|
70
|
+
return { root, scaleName, scale, label: `${PC_TO_NOTE[root]} ${scaleName}` };
|
|
71
|
+
}
|
|
72
|
+
function degreeToMidi(degree, key, stepOffset = 0) {
|
|
73
|
+
const steps = key.scale.length * OCTAVES;
|
|
74
|
+
let idx = Math.round(clamp(degree, 0, 1) * (steps - 1)) + Math.round(stepOffset);
|
|
75
|
+
idx = clamp(idx, 0, steps - 1);
|
|
76
|
+
const oct = Math.floor(idx / key.scale.length);
|
|
77
|
+
const pc = key.scale[idx % key.scale.length];
|
|
78
|
+
return BASE_MIDI + key.root + oct * 12 + pc;
|
|
79
|
+
}
|
|
80
|
+
var midiToFreq = (m) => 440 * Math.pow(2, (m - 69) / 12);
|
|
81
|
+
function stepInScale(midi, key, steps) {
|
|
82
|
+
if (steps === 0) return midi;
|
|
83
|
+
const pcs = key.scale.map((s) => (s + key.root) % 12);
|
|
84
|
+
const dir = steps > 0 ? 1 : -1;
|
|
85
|
+
let m = midi;
|
|
86
|
+
for (let i = 0; i < Math.abs(steps); i++) {
|
|
87
|
+
do {
|
|
88
|
+
m += dir;
|
|
89
|
+
} while (!pcs.includes((m % 12 + 12) % 12) && m > 12 && m < 120);
|
|
90
|
+
}
|
|
91
|
+
return clamp(m, 12, 120);
|
|
92
|
+
}
|
|
93
|
+
function midiToNoteName(m) {
|
|
94
|
+
const pc = (m % 12 + 12) % 12;
|
|
95
|
+
return `${PC_TO_NOTE[pc]}${Math.floor(m / 12) - 1}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/math/modular.ts
|
|
99
|
+
var modular_exports = {};
|
|
100
|
+
__export(modular_exports, {
|
|
101
|
+
fmIndex: () => fmIndex,
|
|
102
|
+
foldCurve: () => foldCurve,
|
|
103
|
+
foldFromBorder: () => foldFromBorder,
|
|
104
|
+
lfoFromCss: () => lfoFromCss,
|
|
105
|
+
massBonusFromFontWeight: () => massBonusFromFontWeight,
|
|
106
|
+
patchFrom: () => patchFrom,
|
|
107
|
+
portamentoFromTransition: () => portamentoFromTransition,
|
|
108
|
+
ribbonSteps: () => ribbonSteps,
|
|
109
|
+
unisonFromMass: () => unisonFromMass
|
|
110
|
+
});
|
|
111
|
+
function foldFromBorder(borderWidthPx, edge) {
|
|
112
|
+
const drive = clamp(borderWidthPx / 6, 0, 1) * (0.25 + 0.75 * clamp(edge, 0, 1));
|
|
113
|
+
return { drive, mix: drive > 0.01 ? 0.25 + 0.55 * drive : 0 };
|
|
114
|
+
}
|
|
115
|
+
var fmIndex = (texture, edge) => 1.5 * clamp(texture, 0, 1) * clamp(edge, 0, 1);
|
|
116
|
+
function unisonFromMass(mass) {
|
|
117
|
+
const m = clamp(mass, 0, 1);
|
|
118
|
+
return m >= 0.45 ? { detuneCents: 4 + 10 * m, mix: 0.4 } : { detuneCents: 0, mix: 0 };
|
|
119
|
+
}
|
|
120
|
+
function lfoFromCss(animationS, borderStyle) {
|
|
121
|
+
if (animationS > 0.05) {
|
|
122
|
+
return {
|
|
123
|
+
rateHz: clamp(1 / animationS, 0.08, 8),
|
|
124
|
+
shape: borderStyle === "dashed" || borderStyle === "dotted" ? "square" : "sine",
|
|
125
|
+
vibratoCents: 6,
|
|
126
|
+
tremolo: 0.18,
|
|
127
|
+
filterDepth: 0.25
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (borderStyle === "dashed" || borderStyle === "dotted") {
|
|
131
|
+
return {
|
|
132
|
+
rateHz: borderStyle === "dotted" ? 7 : 3.5,
|
|
133
|
+
shape: "square",
|
|
134
|
+
vibratoCents: 0,
|
|
135
|
+
tremolo: 0.3,
|
|
136
|
+
filterDepth: 0.35
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return { rateHz: 0, shape: "sine", vibratoCents: 0, tremolo: 0, filterDepth: 0 };
|
|
140
|
+
}
|
|
141
|
+
var portamentoFromTransition = (transitionS) => clamp(transitionS / 2, 0, 0.25);
|
|
142
|
+
var massBonusFromFontWeight = (weight) => clamp((weight - 400) / 300, 0, 1) * 0.12;
|
|
143
|
+
function patchFrom(matter, visuals) {
|
|
144
|
+
return {
|
|
145
|
+
fold: foldFromBorder(visuals.borderWidthPx, matter.edge),
|
|
146
|
+
fm: { index: fmIndex(matter.texture, matter.edge) },
|
|
147
|
+
unison: unisonFromMass(matter.mass),
|
|
148
|
+
lfo: lfoFromCss(visuals.animationS, visuals.borderStyle),
|
|
149
|
+
portamentoS: portamentoFromTransition(visuals.transitionS)
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function foldCurve(samples = 2049) {
|
|
153
|
+
const out = new Float32Array(samples);
|
|
154
|
+
for (let i = 0; i < samples; i++) {
|
|
155
|
+
const x = i / (samples - 1) * 2 - 1;
|
|
156
|
+
out[i] = Math.sin(2.5 * (Math.PI / 2) * x);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
function ribbonSteps(dxPx, vw, scaleLen) {
|
|
161
|
+
const t = clamp(dxPx / (vw * 0.6), -1, 1);
|
|
162
|
+
return Math.round(t * scaleLen);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/math/chroma.ts
|
|
166
|
+
var chroma_exports = {};
|
|
167
|
+
__export(chroma_exports, {
|
|
168
|
+
attackScaleFromWarmth: () => attackScaleFromWarmth,
|
|
169
|
+
brightnessFromLuminance: () => brightnessFromLuminance,
|
|
170
|
+
chromaOf: () => chromaOf,
|
|
171
|
+
modeFromPalette: () => modeFromPalette,
|
|
172
|
+
pagePalette: () => pagePalette,
|
|
173
|
+
parseCssColor: () => parseCssColor,
|
|
174
|
+
rgbToHsl: () => rgbToHsl,
|
|
175
|
+
richnessFromSaturation: () => richnessFromSaturation,
|
|
176
|
+
roomToneScaleFromWarmth: () => roomToneScaleFromWarmth,
|
|
177
|
+
subBonusFromWarmth: () => subBonusFromWarmth,
|
|
178
|
+
tempoScaleFromWarmth: () => tempoScaleFromWarmth,
|
|
179
|
+
velocityFromLuminance: () => velocityFromLuminance,
|
|
180
|
+
warmthFromHue: () => warmthFromHue
|
|
181
|
+
});
|
|
182
|
+
function parseCssColor(css) {
|
|
183
|
+
const m = css.trim().match(/^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*(?:,\s*(\d*(?:\.\d+)?)\s*)?\)$/);
|
|
184
|
+
if (!m) return null;
|
|
185
|
+
return {
|
|
186
|
+
r: clamp(parseFloat(m[1]) / 255, 0, 1),
|
|
187
|
+
g: clamp(parseFloat(m[2]) / 255, 0, 1),
|
|
188
|
+
b: clamp(parseFloat(m[3]) / 255, 0, 1),
|
|
189
|
+
a: m[4] === void 0 ? 1 : clamp(parseFloat(m[4]), 0, 1)
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function rgbToHsl({ r, g, b }) {
|
|
193
|
+
const max = Math.max(r, g, b);
|
|
194
|
+
const min = Math.min(r, g, b);
|
|
195
|
+
const l = (max + min) / 2;
|
|
196
|
+
const d = max - min;
|
|
197
|
+
if (d < 1e-6) return { h: 0, s: 0, l };
|
|
198
|
+
const s = d / (1 - Math.abs(2 * l - 1));
|
|
199
|
+
let h;
|
|
200
|
+
if (max === r) h = (g - b) / d % 6;
|
|
201
|
+
else if (max === g) h = (b - r) / d + 2;
|
|
202
|
+
else h = (r - g) / d + 4;
|
|
203
|
+
h *= 60;
|
|
204
|
+
if (h < 0) h += 360;
|
|
205
|
+
return { h, s: clamp(s, 0, 1), l };
|
|
206
|
+
}
|
|
207
|
+
function warmthFromHue(h, s) {
|
|
208
|
+
const raw = 0.5 + 0.5 * Math.cos((h - 30) * Math.PI / 180);
|
|
209
|
+
return lerp(0.5, raw, clamp(s * 2, 0, 1));
|
|
210
|
+
}
|
|
211
|
+
function chromaOf(rgb) {
|
|
212
|
+
if (!rgb || rgb.a < 0.05) return { warmth: 0.5, saturation: 0, luminance: 0.5 };
|
|
213
|
+
const { h, s, l } = rgbToHsl(rgb);
|
|
214
|
+
return { warmth: warmthFromHue(h, s), saturation: s, luminance: l };
|
|
215
|
+
}
|
|
216
|
+
var brightnessFromLuminance = (l) => lerp(0.78, 1.22, clamp(l, 0, 1));
|
|
217
|
+
var velocityFromLuminance = (l) => lerp(0.92, 1.06, clamp(l, 0, 1));
|
|
218
|
+
var attackScaleFromWarmth = (w) => lerp(1.18, 0.82, clamp(w, 0, 1));
|
|
219
|
+
var subBonusFromWarmth = (w) => 0.08 * clamp(w, 0, 1);
|
|
220
|
+
var richnessFromSaturation = (s) => 0.35 * clamp(s, 0, 1);
|
|
221
|
+
function modeFromPalette(pal) {
|
|
222
|
+
if (pal.warmth >= 0.55) return pal.luminance >= 0.5 ? "lydian" : "mixolydian";
|
|
223
|
+
if (pal.warmth <= 0.45) return pal.luminance >= 0.5 ? "dorian" : "pentMinor";
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
function pagePalette(bg, text) {
|
|
227
|
+
return {
|
|
228
|
+
warmth: bg.warmth * 0.7 + text.warmth * 0.3,
|
|
229
|
+
saturation: bg.saturation * 0.7 + text.saturation * 0.3,
|
|
230
|
+
luminance: bg.luminance * 0.7 + text.luminance * 0.3
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
var roomToneScaleFromWarmth = (w) => lerp(0.85, 1.25, clamp(w, 0, 1));
|
|
234
|
+
var tempoScaleFromWarmth = (w) => lerp(0.94, 1.06, clamp(w, 0, 1));
|
|
235
|
+
|
|
236
|
+
// src/math/pulse.ts
|
|
237
|
+
var pulse_exports = {};
|
|
238
|
+
__export(pulse_exports, {
|
|
239
|
+
DUCK_RECOVERY_MS: () => DUCK_RECOVERY_MS,
|
|
240
|
+
ECHO_MIN_AHEAD_S: () => ECHO_MIN_AHEAD_S,
|
|
241
|
+
ECHO_TRANSPOSE: () => ECHO_TRANSPOSE,
|
|
242
|
+
ECHO_VELOCITY_SCALE: () => ECHO_VELOCITY_SCALE,
|
|
243
|
+
PHRASE_MAX_NOTES: () => PHRASE_MAX_NOTES,
|
|
244
|
+
PHRASE_MIN_NOTES: () => PHRASE_MIN_NOTES,
|
|
245
|
+
PHRASE_PROBABILITY: () => PHRASE_PROBABILITY,
|
|
246
|
+
decayCount: () => decayCount,
|
|
247
|
+
duckFactor: () => duckFactor,
|
|
248
|
+
echoGridS: () => echoGridS,
|
|
249
|
+
nextGridOffset: () => nextGridOffset,
|
|
250
|
+
phraseWindow: () => phraseWindow,
|
|
251
|
+
readingOrderKey: () => readingOrderKey,
|
|
252
|
+
secondsPerBeat: () => secondsPerBeat,
|
|
253
|
+
strumStepS: () => strumStepS,
|
|
254
|
+
tempoFromPage: () => tempoFromPage
|
|
255
|
+
});
|
|
256
|
+
function tempoFromPage(elementCount, warmthScale) {
|
|
257
|
+
const base = lerp(66, 104, clamp(elementCount / 120, 0, 1));
|
|
258
|
+
return Math.round(clamp(base * warmthScale, 56, 116));
|
|
259
|
+
}
|
|
260
|
+
var secondsPerBeat = (bpm) => 60 / Math.max(1, bpm);
|
|
261
|
+
function nextGridOffset(phaseS, gridS, minAheadS) {
|
|
262
|
+
if (gridS <= 0) return minAheadS;
|
|
263
|
+
let offset = gridS - (phaseS % gridS + gridS) % gridS;
|
|
264
|
+
while (offset < minAheadS) offset += gridS;
|
|
265
|
+
return offset;
|
|
266
|
+
}
|
|
267
|
+
var strumStepS = (bpm) => secondsPerBeat(bpm) / 8;
|
|
268
|
+
var ECHO_TRANSPOSE = 12;
|
|
269
|
+
var ECHO_VELOCITY_SCALE = 0.22;
|
|
270
|
+
var ECHO_MIN_AHEAD_S = 0.08;
|
|
271
|
+
var echoGridS = (bpm) => secondsPerBeat(bpm) / 2;
|
|
272
|
+
var DUCK_RECOVERY_MS = 2e3;
|
|
273
|
+
var decayCount = (count, dtMs) => Math.max(0, count - dtMs / DUCK_RECOVERY_MS);
|
|
274
|
+
var duckFactor = (count) => Math.max(0.4, Math.pow(0.85, Math.max(0, count)));
|
|
275
|
+
var PHRASE_PROBABILITY = 0.55;
|
|
276
|
+
var PHRASE_MIN_NOTES = 2;
|
|
277
|
+
var PHRASE_MAX_NOTES = 4;
|
|
278
|
+
var readingOrderKey = (top, left) => Math.round(top / 80) * 1e5 + clamp(left, 0, 99999);
|
|
279
|
+
function phraseWindow(n, len, progress) {
|
|
280
|
+
return Math.round(clamp(progress, 0, 1) * Math.max(0, n - len));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/themes/index.ts
|
|
284
|
+
var aurora = {
|
|
285
|
+
name: "aurora",
|
|
286
|
+
defaults: { synthKind: "matter", octaveShift: 0, baseVelocity: 0.8, releaseScale: 1 },
|
|
287
|
+
roles: {
|
|
288
|
+
toggle: { synthKind: "matter", baseVelocity: 0.7, releaseScale: 0.8 },
|
|
289
|
+
button: { synthKind: "matter", baseVelocity: 0.9 },
|
|
290
|
+
link: { synthKind: "matter", octaveShift: 1, baseVelocity: 0.6, releaseScale: 0.7 },
|
|
291
|
+
input: { synthKind: "matter", baseVelocity: 0.55, releaseScale: 1.6 },
|
|
292
|
+
heading: { synthKind: "fm", octaveShift: 0, baseVelocity: 0.75, releaseScale: 2.2 },
|
|
293
|
+
media: { synthKind: "membrane", octaveShift: -1, baseVelocity: 0.8 },
|
|
294
|
+
item: { synthKind: "matter", baseVelocity: 0.6, releaseScale: 0.8 },
|
|
295
|
+
text: { synthKind: "matter", baseVelocity: 0.35, releaseScale: 1.8 }
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
var mono = {
|
|
299
|
+
name: "mono",
|
|
300
|
+
defaults: { synthKind: "synth", octaveShift: 0, baseVelocity: 0.7, releaseScale: 0.5 },
|
|
301
|
+
roles: {
|
|
302
|
+
toggle: { synthKind: "noise", baseVelocity: 0.6 },
|
|
303
|
+
button: { synthKind: "synth", pinWave: "square", baseVelocity: 0.7, releaseScale: 0.4 },
|
|
304
|
+
link: { synthKind: "noise", baseVelocity: 0.45, releaseScale: 0.3 },
|
|
305
|
+
input: { synthKind: "synth", pinWave: "sine", octaveShift: -1, baseVelocity: 0.5 },
|
|
306
|
+
heading: { synthKind: "synth", pinWave: "sine", octaveShift: 1, baseVelocity: 0.6 },
|
|
307
|
+
media: { synthKind: "noise", baseVelocity: 0.55 },
|
|
308
|
+
item: { synthKind: "noise", baseVelocity: 0.4, releaseScale: 0.3 },
|
|
309
|
+
text: { synthKind: "synth", pinWave: "sine", baseVelocity: 0 }
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
var paper = {
|
|
313
|
+
name: "paper",
|
|
314
|
+
defaults: { synthKind: "pluck", octaveShift: 0, baseVelocity: 0.8, releaseScale: 1 },
|
|
315
|
+
roles: {
|
|
316
|
+
toggle: { synthKind: "pluck", baseVelocity: 0.7 },
|
|
317
|
+
button: { synthKind: "pluck", baseVelocity: 0.9 },
|
|
318
|
+
link: { synthKind: "pluck", octaveShift: 1, baseVelocity: 0.65 },
|
|
319
|
+
input: { synthKind: "synth", pinWave: "triangle", baseVelocity: 0.5, releaseScale: 1.4 },
|
|
320
|
+
heading: { synthKind: "pluck", octaveShift: -1, baseVelocity: 0.85, releaseScale: 1.6 },
|
|
321
|
+
media: { synthKind: "membrane", octaveShift: -1, baseVelocity: 0.7 },
|
|
322
|
+
item: { synthKind: "pluck", baseVelocity: 0.6 },
|
|
323
|
+
text: { synthKind: "synth", pinWave: "sine", baseVelocity: 0.3, releaseScale: 1.6 }
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
var THEMES = { aurora, mono, paper };
|
|
327
|
+
function resolveTheme(theme) {
|
|
328
|
+
if (!theme) return aurora;
|
|
329
|
+
if (typeof theme === "string") return THEMES[theme] ?? aurora;
|
|
330
|
+
return theme;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// src/ui/gate.ts
|
|
334
|
+
var STORAGE_KEY = "sonarium:muted";
|
|
335
|
+
var isMutedPersisted = () => {
|
|
336
|
+
try {
|
|
337
|
+
return localStorage.getItem(STORAGE_KEY) === "1";
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
var persistMuted = (muted) => {
|
|
343
|
+
try {
|
|
344
|
+
localStorage.setItem(STORAGE_KEY, muted ? "1" : "0");
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
function mountGate(onToggle) {
|
|
349
|
+
const host = document.createElement("div");
|
|
350
|
+
host.setAttribute("data-sonic", "off");
|
|
351
|
+
const shadow = host.attachShadow({ mode: "open" });
|
|
352
|
+
shadow.innerHTML = `
|
|
353
|
+
<style>
|
|
354
|
+
button {
|
|
355
|
+
position: fixed; right: 16px; bottom: 16px; z-index: 2147483646;
|
|
356
|
+
width: 44px; height: 44px; border-radius: 50%; border: 1px solid rgba(255,255,255,.25);
|
|
357
|
+
background: rgba(20, 22, 30, .82); color: #fff; font-size: 18px; line-height: 1;
|
|
358
|
+
cursor: pointer; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
|
359
|
+
box-shadow: 0 4px 16px rgba(0,0,0,.35); transition: transform .15s ease, opacity .3s ease;
|
|
360
|
+
}
|
|
361
|
+
button:hover { transform: scale(1.08); }
|
|
362
|
+
button:focus-visible { outline: 2px solid #7cd4fd; outline-offset: 2px; }
|
|
363
|
+
button.armed { animation: pulse 1.6s ease-in-out infinite; }
|
|
364
|
+
@keyframes pulse { 0%,100% { box-shadow: 0 4px 16px rgba(0,0,0,.35); } 50% { box-shadow: 0 0 0 10px rgba(124,212,253,.18); } }
|
|
365
|
+
</style>
|
|
366
|
+
<button type="button" class="armed" aria-label="Enable sound" title="Sonarium \u2014 enable sound">\u{1F507}</button>
|
|
367
|
+
`;
|
|
368
|
+
const btn = shadow.querySelector("button");
|
|
369
|
+
btn.addEventListener("click", (e) => {
|
|
370
|
+
e.stopPropagation();
|
|
371
|
+
onToggle();
|
|
372
|
+
});
|
|
373
|
+
document.body.appendChild(host);
|
|
374
|
+
return {
|
|
375
|
+
setState(state) {
|
|
376
|
+
btn.classList.toggle("armed", state === "armed");
|
|
377
|
+
if (state === "on") {
|
|
378
|
+
btn.textContent = "\u{1F50A}";
|
|
379
|
+
btn.setAttribute("aria-label", "Mute Sonarium");
|
|
380
|
+
} else if (state === "muted") {
|
|
381
|
+
btn.textContent = "\u{1F507}";
|
|
382
|
+
btn.setAttribute("aria-label", "Unmute Sonarium");
|
|
383
|
+
} else {
|
|
384
|
+
btn.textContent = "\u{1F507}";
|
|
385
|
+
btn.setAttribute("aria-label", "Enable sound");
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
dispose() {
|
|
389
|
+
host.remove();
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/spatial/backend.ts
|
|
395
|
+
import * as Tone4 from "tone";
|
|
396
|
+
|
|
397
|
+
// src/spatial/bus.ts
|
|
398
|
+
import * as Tone from "tone";
|
|
399
|
+
|
|
400
|
+
// src/spatial/sh.ts
|
|
401
|
+
var DEG = Math.PI / 180;
|
|
402
|
+
function foaGains(azimuth, elevation, extent = 0) {
|
|
403
|
+
const s = Math.min(1, Math.max(0, extent));
|
|
404
|
+
const cosEl = Math.cos(elevation);
|
|
405
|
+
const dir = 1 - s;
|
|
406
|
+
return {
|
|
407
|
+
w: 1 + 0.41 * s,
|
|
408
|
+
y: dir * Math.sin(azimuth) * cosEl,
|
|
409
|
+
z: dir * Math.sin(elevation),
|
|
410
|
+
x: dir * Math.cos(azimuth) * cosEl
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function unitVector(azimuth, elevation) {
|
|
414
|
+
const cosEl = Math.cos(elevation);
|
|
415
|
+
return [Math.cos(azimuth) * cosEl, Math.sin(azimuth) * cosEl, Math.sin(elevation)];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// src/spatial/decoder.ts
|
|
419
|
+
var C = 1 / Math.sqrt(3);
|
|
420
|
+
var CUBE_LAYOUT = [
|
|
421
|
+
{ dir: [C, C, C], label: "front-left-up" },
|
|
422
|
+
{ dir: [C, -C, C], label: "front-right-up" },
|
|
423
|
+
{ dir: [C, C, -C], label: "front-left-down" },
|
|
424
|
+
{ dir: [C, -C, -C], label: "front-right-down" },
|
|
425
|
+
{ dir: [-C, C, C], label: "back-left-up" },
|
|
426
|
+
{ dir: [-C, -C, C], label: "back-right-up" },
|
|
427
|
+
{ dir: [-C, C, -C], label: "back-left-down" },
|
|
428
|
+
{ dir: [-C, -C, -C], label: "back-right-down" }
|
|
429
|
+
];
|
|
430
|
+
var MAXRE_G0 = 1;
|
|
431
|
+
var MAXRE_G1 = 1 / Math.sqrt(3);
|
|
432
|
+
function decodeMatrix(layout) {
|
|
433
|
+
const M = layout.length;
|
|
434
|
+
return layout.map(({ dir }) => ({
|
|
435
|
+
w: 1 / M * MAXRE_G0,
|
|
436
|
+
x: 3 / M * MAXRE_G1 * dir[0],
|
|
437
|
+
y: 3 / M * MAXRE_G1 * dir[1],
|
|
438
|
+
z: 3 / M * MAXRE_G1 * dir[2]
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
function decodeGains(rows, g) {
|
|
442
|
+
return rows.map((r) => r.w * g.w + r.y * g.y + r.z * g.z + r.x * g.x);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// src/spatial/rotation.ts
|
|
446
|
+
var IDENTITY = [
|
|
447
|
+
[1, 0, 0],
|
|
448
|
+
[0, 1, 0],
|
|
449
|
+
[0, 0, 1]
|
|
450
|
+
];
|
|
451
|
+
function mul(a, b) {
|
|
452
|
+
const out = [
|
|
453
|
+
[0, 0, 0],
|
|
454
|
+
[0, 0, 0],
|
|
455
|
+
[0, 0, 0]
|
|
456
|
+
];
|
|
457
|
+
for (let r = 0; r < 3; r++)
|
|
458
|
+
for (let c = 0; c < 3; c++)
|
|
459
|
+
out[r][c] = a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c];
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
function rotationMatrix(yaw, pitch, roll = 0) {
|
|
463
|
+
const cy = Math.cos(yaw), sy = Math.sin(yaw);
|
|
464
|
+
const cp = Math.cos(pitch), sp = Math.sin(pitch);
|
|
465
|
+
const cr = Math.cos(roll), sr = Math.sin(roll);
|
|
466
|
+
const Rz = [
|
|
467
|
+
[cy, -sy, 0],
|
|
468
|
+
[sy, cy, 0],
|
|
469
|
+
[0, 0, 1]
|
|
470
|
+
];
|
|
471
|
+
const Ry = [
|
|
472
|
+
[cp, 0, sp],
|
|
473
|
+
[0, 1, 0],
|
|
474
|
+
[-sp, 0, cp]
|
|
475
|
+
];
|
|
476
|
+
const Rx = [
|
|
477
|
+
[1, 0, 0],
|
|
478
|
+
[0, cr, -sr],
|
|
479
|
+
[0, sr, cr]
|
|
480
|
+
];
|
|
481
|
+
return mul(mul(Rz, Ry), Rx);
|
|
482
|
+
}
|
|
483
|
+
function applyMat3(m, v) {
|
|
484
|
+
return [
|
|
485
|
+
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
|
|
486
|
+
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
|
|
487
|
+
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2]
|
|
488
|
+
];
|
|
489
|
+
}
|
|
490
|
+
function lookMatrix(yawRight, pitchUp) {
|
|
491
|
+
const pitch = rotationMatrix(0, pitchUp, 0);
|
|
492
|
+
const yaw = rotationMatrix(yawRight, 0, 0);
|
|
493
|
+
return mul(pitch, yaw);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/spatial/bus.ts
|
|
497
|
+
var SPEAKER_RADIUS = 2.5;
|
|
498
|
+
var AmbisonicBus = class {
|
|
499
|
+
constructor(destination, decoderKind) {
|
|
500
|
+
this.nodes = [];
|
|
501
|
+
this.inputs = { w: new Tone.Gain(1), y: new Tone.Gain(1), z: new Tone.Gain(1), x: new Tone.Gain(1) };
|
|
502
|
+
this.outW = new Tone.Gain(1);
|
|
503
|
+
this.outXYZ = [new Tone.Gain(1), new Tone.Gain(1), new Tone.Gain(1)];
|
|
504
|
+
this.inputs.w.connect(this.outW);
|
|
505
|
+
const inXYZ = [this.inputs.x, this.inputs.y, this.inputs.z];
|
|
506
|
+
this.rot = [];
|
|
507
|
+
for (let r = 0; r < 3; r++) {
|
|
508
|
+
const row = [];
|
|
509
|
+
for (let c = 0; c < 3; c++) {
|
|
510
|
+
const g = new Tone.Gain(IDENTITY[r][c]);
|
|
511
|
+
inXYZ[c].connect(g);
|
|
512
|
+
g.connect(this.outXYZ[r]);
|
|
513
|
+
row.push(g);
|
|
514
|
+
}
|
|
515
|
+
this.rot.push(row);
|
|
516
|
+
}
|
|
517
|
+
if (decoderKind === "stereo") this.buildStereoDecode(destination);
|
|
518
|
+
else this.buildVirtualSpeakerDecode(destination);
|
|
519
|
+
this.nodes.push(this.inputs.w, this.inputs.y, this.inputs.z, this.inputs.x, this.outW, ...this.outXYZ, ...this.rot.flat());
|
|
520
|
+
}
|
|
521
|
+
/** SPATIAL.md §3.2 — 8 cube speakers, each a static mix of (W, X', Y', Z') into a fixed HRTF panner. */
|
|
522
|
+
buildVirtualSpeakerDecode(destination) {
|
|
523
|
+
const rows = decodeMatrix(CUBE_LAYOUT);
|
|
524
|
+
rows.forEach((row, i) => {
|
|
525
|
+
const dir = CUBE_LAYOUT[i].dir;
|
|
526
|
+
const sum = new Tone.Gain(1);
|
|
527
|
+
const mw = new Tone.Gain(row.w);
|
|
528
|
+
const mx = new Tone.Gain(row.x);
|
|
529
|
+
const my = new Tone.Gain(row.y);
|
|
530
|
+
const mz = new Tone.Gain(row.z);
|
|
531
|
+
this.outW.connect(mw);
|
|
532
|
+
this.outXYZ[0].connect(mx);
|
|
533
|
+
this.outXYZ[1].connect(my);
|
|
534
|
+
this.outXYZ[2].connect(mz);
|
|
535
|
+
mw.connect(sum);
|
|
536
|
+
mx.connect(sum);
|
|
537
|
+
my.connect(sum);
|
|
538
|
+
mz.connect(sum);
|
|
539
|
+
const panner = new Tone.Panner3D({
|
|
540
|
+
panningModel: "HRTF",
|
|
541
|
+
distanceModel: "inverse",
|
|
542
|
+
refDistance: SPEAKER_RADIUS,
|
|
543
|
+
rolloffFactor: 0,
|
|
544
|
+
// fixed-radius speakers: direction only, no distance shading
|
|
545
|
+
positionX: -dir[1] * SPEAKER_RADIUS,
|
|
546
|
+
positionY: dir[2] * SPEAKER_RADIUS,
|
|
547
|
+
positionZ: -dir[0] * SPEAKER_RADIUS
|
|
548
|
+
});
|
|
549
|
+
sum.connect(panner);
|
|
550
|
+
panner.connect(destination);
|
|
551
|
+
this.nodes.push(sum, mw, mx, my, mz, panner);
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
/** Fallback decode without HRTF: two virtual cardioids at ±90° (SPATIAL.md §3.2). */
|
|
555
|
+
buildStereoDecode(destination) {
|
|
556
|
+
const L = new Tone.Gain(1);
|
|
557
|
+
const R = new Tone.Gain(1);
|
|
558
|
+
const wL = new Tone.Gain(0.5);
|
|
559
|
+
const wR = new Tone.Gain(0.5);
|
|
560
|
+
const yL = new Tone.Gain(0.5);
|
|
561
|
+
const yR = new Tone.Gain(-0.5);
|
|
562
|
+
this.outW.connect(wL);
|
|
563
|
+
this.outW.connect(wR);
|
|
564
|
+
this.outXYZ[1].connect(yL);
|
|
565
|
+
this.outXYZ[1].connect(yR);
|
|
566
|
+
wL.connect(L);
|
|
567
|
+
yL.connect(L);
|
|
568
|
+
wR.connect(R);
|
|
569
|
+
yR.connect(R);
|
|
570
|
+
const merge = new Tone.Merge();
|
|
571
|
+
L.connect(merge, 0, 0);
|
|
572
|
+
R.connect(merge, 0, 1);
|
|
573
|
+
merge.connect(destination);
|
|
574
|
+
this.nodes.push(L, R, wL, wR, yL, yR, merge);
|
|
575
|
+
}
|
|
576
|
+
/** Rotate the whole field (FieldRig calls this with lookMatrix output). Ramped, zipper-free. */
|
|
577
|
+
setRotation(m, rampS = 0.04) {
|
|
578
|
+
for (let r = 0; r < 3; r++)
|
|
579
|
+
for (let c = 0; c < 3; c++)
|
|
580
|
+
this.rot[r][c].gain.rampTo(m[r][c], rampS);
|
|
581
|
+
}
|
|
582
|
+
dispose() {
|
|
583
|
+
for (const n of this.nodes) n.dispose();
|
|
584
|
+
this.nodes = [];
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// src/spatial/encoder.ts
|
|
589
|
+
import * as Tone2 from "tone";
|
|
590
|
+
var SourceEncoder = class {
|
|
591
|
+
constructor(bus) {
|
|
592
|
+
this.input = new Tone2.Gain(1);
|
|
593
|
+
this.gw = new Tone2.Gain(1);
|
|
594
|
+
this.gy = new Tone2.Gain(0);
|
|
595
|
+
this.gz = new Tone2.Gain(0);
|
|
596
|
+
this.gx = new Tone2.Gain(1);
|
|
597
|
+
this.input.connect(this.gw);
|
|
598
|
+
this.input.connect(this.gy);
|
|
599
|
+
this.input.connect(this.gz);
|
|
600
|
+
this.input.connect(this.gx);
|
|
601
|
+
this.gw.connect(bus.w);
|
|
602
|
+
this.gy.connect(bus.y);
|
|
603
|
+
this.gz.connect(bus.z);
|
|
604
|
+
this.gx.connect(bus.x);
|
|
605
|
+
}
|
|
606
|
+
/** Apply SH gains × an overall direct-path gain, ramped to avoid zipper noise. */
|
|
607
|
+
set(g, directGain2, when, rampS = 0.015) {
|
|
608
|
+
const t = when ?? Tone2.now();
|
|
609
|
+
this.gw.gain.rampTo(g.w * directGain2, rampS, t);
|
|
610
|
+
this.gy.gain.rampTo(g.y * directGain2, rampS, t);
|
|
611
|
+
this.gz.gain.rampTo(g.z * directGain2, rampS, t);
|
|
612
|
+
this.gx.gain.rampTo(g.x * directGain2, rampS, t);
|
|
613
|
+
}
|
|
614
|
+
dispose() {
|
|
615
|
+
this.input.dispose();
|
|
616
|
+
this.gw.dispose();
|
|
617
|
+
this.gy.dispose();
|
|
618
|
+
this.gz.dispose();
|
|
619
|
+
this.gx.dispose();
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
// src/spatial/room-foa.ts
|
|
624
|
+
import * as Tone3 from "tone";
|
|
625
|
+
|
|
626
|
+
// src/spatial/sphere.ts
|
|
627
|
+
var sphere_exports = {};
|
|
628
|
+
__export(sphere_exports, {
|
|
629
|
+
AZ_MAX: () => AZ_MAX,
|
|
630
|
+
EL_MAX: () => EL_MAX,
|
|
631
|
+
directivityFromRoundness: () => directivityFromRoundness,
|
|
632
|
+
extentFromSize: () => extentFromSize,
|
|
633
|
+
reflectionScaleFromViewport: () => reflectionScaleFromViewport,
|
|
634
|
+
sphereFromRect: () => sphereFromRect
|
|
635
|
+
});
|
|
636
|
+
var AZ_MAX = 70 * DEG;
|
|
637
|
+
var EL_MAX = 45 * DEG;
|
|
638
|
+
function sphereFromRect(rect, vw, vh) {
|
|
639
|
+
const tx = norm(rect.x + rect.w / 2, 0, vw);
|
|
640
|
+
const ty = norm(rect.y + rect.h / 2, 0, vh);
|
|
641
|
+
return {
|
|
642
|
+
azimuth: -(2 * tx - 1) * AZ_MAX,
|
|
643
|
+
elevation: (1 - 2 * ty) * EL_MAX
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
function extentFromSize(sizeT2) {
|
|
647
|
+
return clamp(0.05 + 0.75 * Math.pow(clamp(sizeT2, 0, 1), 1.2), 0, 0.95);
|
|
648
|
+
}
|
|
649
|
+
function directivityFromRoundness(roundness2) {
|
|
650
|
+
return clamp(1 - roundness2, 0, 1);
|
|
651
|
+
}
|
|
652
|
+
function reflectionScaleFromViewport(vw) {
|
|
653
|
+
return 0.6 + norm(vw, 360, 2200) * 1;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// src/spatial/perceptual.ts
|
|
657
|
+
var DEFAULT_FACTORS = {
|
|
658
|
+
presence: 0.7,
|
|
659
|
+
roomPresence: 0.5,
|
|
660
|
+
envelopment: 0.55,
|
|
661
|
+
warmth: 0.5,
|
|
662
|
+
brilliance: 0.5
|
|
663
|
+
};
|
|
664
|
+
function resolveFactors(partial) {
|
|
665
|
+
const f = { ...DEFAULT_FACTORS, ...partial ?? {} };
|
|
666
|
+
for (const k of Object.keys(f)) f[k] = clamp(f[k], 0, 1);
|
|
667
|
+
return f;
|
|
668
|
+
}
|
|
669
|
+
var directGain = (presence) => lerp(0.5, 1.2, clamp(presence, 0, 1));
|
|
670
|
+
var roomGain = (roomPresence) => lerp(0, 1.6, clamp(roomPresence, 0, 1));
|
|
671
|
+
var tailExtent = (envelopment) => clamp(envelopment, 0, 1);
|
|
672
|
+
var tailLevel = (envelopment) => lerp(0.7, 1.25, clamp(envelopment, 0, 1));
|
|
673
|
+
var warmthDb = (warmth) => lerp(-3, 3, clamp(warmth, 0, 1));
|
|
674
|
+
var brillianceDb = (brilliance) => lerp(-4, 3, clamp(brilliance, 0, 1));
|
|
675
|
+
var directivityDirectGain = (d) => lerp(0.75, 1.1, clamp(d, 0, 1));
|
|
676
|
+
var directivitySendScale = (d) => lerp(1.35, 0.8, clamp(d, 0, 1));
|
|
677
|
+
var directivityFilterScale = (d) => lerp(0.85, 1.15, clamp(d, 0, 1));
|
|
678
|
+
|
|
679
|
+
// src/spatial/room-foa.ts
|
|
680
|
+
var ER_BASE_TIMES = [0.013, 0.019, 0.027, 0.034];
|
|
681
|
+
var ER_TAP_LEVELS = [1, 0.85, 0.7, 0.6];
|
|
682
|
+
var ER_DIRECTIONS = [
|
|
683
|
+
[110 * DEG, 30 * DEG],
|
|
684
|
+
[-110 * DEG, 30 * DEG],
|
|
685
|
+
[110 * DEG, -30 * DEG],
|
|
686
|
+
[-110 * DEG, -30 * DEG]
|
|
687
|
+
];
|
|
688
|
+
var FoaRoom = class {
|
|
689
|
+
constructor(bus, reverb, vw, factors) {
|
|
690
|
+
this.delays = [];
|
|
691
|
+
this.nodes = [];
|
|
692
|
+
this.erIn = new Tone3.Gain(1);
|
|
693
|
+
this.erMaster = new Tone3.Gain(0.22 * roomGain(factors.roomPresence));
|
|
694
|
+
this.erIn.connect(this.erMaster);
|
|
695
|
+
const scale = reflectionScaleFromViewport(vw);
|
|
696
|
+
ER_BASE_TIMES.forEach((t, i) => {
|
|
697
|
+
const delay = new Tone3.Delay({ delayTime: t * scale, maxDelay: 0.12 });
|
|
698
|
+
const tap = new Tone3.Gain(ER_TAP_LEVELS[i]);
|
|
699
|
+
const enc = new SourceEncoder(bus);
|
|
700
|
+
const [az, el] = ER_DIRECTIONS[i];
|
|
701
|
+
enc.set(foaGains(az, el, 0.35), 1);
|
|
702
|
+
this.erMaster.connect(delay);
|
|
703
|
+
delay.connect(tap);
|
|
704
|
+
tap.connect(enc.input);
|
|
705
|
+
this.delays.push(delay);
|
|
706
|
+
this.nodes.push(delay, tap, enc);
|
|
707
|
+
});
|
|
708
|
+
this.split = new Tone3.Split();
|
|
709
|
+
reverb.connect(this.split);
|
|
710
|
+
this.tailL = new SourceEncoder(bus);
|
|
711
|
+
this.tailR = new SourceEncoder(bus);
|
|
712
|
+
this.split.connect(this.tailL.input, 0);
|
|
713
|
+
this.split.connect(this.tailR.input, 1);
|
|
714
|
+
this.applyTail(factors);
|
|
715
|
+
this.nodes.push(this.erIn, this.erMaster, this.split, this.tailL, this.tailR);
|
|
716
|
+
}
|
|
717
|
+
applyTail(f) {
|
|
718
|
+
const ext = tailExtent(f.envelopment);
|
|
719
|
+
const level = tailLevel(f.envelopment);
|
|
720
|
+
this.tailL.set(foaGains(120 * DEG, 0, ext), level);
|
|
721
|
+
this.tailR.set(foaGains(-120 * DEG, 0, ext), level);
|
|
722
|
+
}
|
|
723
|
+
setFactors(f) {
|
|
724
|
+
this.erMaster.gain.rampTo(0.22 * roomGain(f.roomPresence), 0.1);
|
|
725
|
+
this.applyTail(f);
|
|
726
|
+
}
|
|
727
|
+
setViewport(vw) {
|
|
728
|
+
const scale = reflectionScaleFromViewport(vw);
|
|
729
|
+
this.delays.forEach((d, i) => d.delayTime.rampTo(ER_BASE_TIMES[i] * scale, 0.3));
|
|
730
|
+
}
|
|
731
|
+
dispose() {
|
|
732
|
+
for (const n of this.nodes) n.dispose();
|
|
733
|
+
this.nodes = [];
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
// src/spatial/backend.ts
|
|
738
|
+
var AmbisonicBackend = class {
|
|
739
|
+
constructor(room, decoderKind, vw, factors) {
|
|
740
|
+
this.room = room;
|
|
741
|
+
this.kind = "ambisonic";
|
|
742
|
+
this.factors = resolveFactors(factors);
|
|
743
|
+
this.bus = new AmbisonicBus(room.spatialIn, decoderKind);
|
|
744
|
+
this.foaRoom = new FoaRoom(this.bus.inputs, room.reverb, vw, this.factors);
|
|
745
|
+
}
|
|
746
|
+
createOutput() {
|
|
747
|
+
const enc = new SourceEncoder(this.bus.inputs);
|
|
748
|
+
const send = new Tone4.Gain(0.15);
|
|
749
|
+
const sendColor = new Tone4.Filter({ type: "lowpass", frequency: 5e3, rolloff: -12 });
|
|
750
|
+
enc.input.connect(send);
|
|
751
|
+
send.connect(sendColor);
|
|
752
|
+
sendColor.connect(this.room.reverb);
|
|
753
|
+
enc.input.connect(this.foaRoom.erIn);
|
|
754
|
+
const backend = this;
|
|
755
|
+
return {
|
|
756
|
+
input: enc.input,
|
|
757
|
+
setPlacement(profile, when) {
|
|
758
|
+
const t = when ?? Tone4.now();
|
|
759
|
+
const s = profile.sphere;
|
|
760
|
+
const w = profile.voice.reverb;
|
|
761
|
+
const extent = clamp(s.extent + w.extentBonus, 0, 0.95);
|
|
762
|
+
const g = foaGains(s.azimuth, s.elevation, extent);
|
|
763
|
+
const direct = directGain(backend.factors.presence) * directivityDirectGain(s.directivity);
|
|
764
|
+
enc.set(g, direct, when);
|
|
765
|
+
const wetSend = clamp(
|
|
766
|
+
profile.reverbSend * directivitySendScale(s.directivity) * roomGain(backend.factors.roomPresence),
|
|
767
|
+
0,
|
|
768
|
+
1.5
|
|
769
|
+
);
|
|
770
|
+
sendColor.frequency.rampTo(w.sendCutoffHz, 0.02, t);
|
|
771
|
+
send.gain.cancelScheduledValues(t);
|
|
772
|
+
if (w.bloom > 0.35) {
|
|
773
|
+
send.gain.setValueAtTime(wetSend * 0.35, t);
|
|
774
|
+
send.gain.rampTo(wetSend, Math.max(0.08, profile.durationS), t);
|
|
775
|
+
} else {
|
|
776
|
+
send.gain.rampTo(wetSend, 0.02, t);
|
|
777
|
+
}
|
|
778
|
+
},
|
|
779
|
+
dispose() {
|
|
780
|
+
enc.dispose();
|
|
781
|
+
send.dispose();
|
|
782
|
+
sendColor.dispose();
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
setFactors(factors) {
|
|
787
|
+
this.factors = factors;
|
|
788
|
+
this.foaRoom.setFactors(factors);
|
|
789
|
+
this.room.setFactors(factors);
|
|
790
|
+
}
|
|
791
|
+
setRotation(m) {
|
|
792
|
+
this.bus.setRotation(m);
|
|
793
|
+
}
|
|
794
|
+
onViewport(vw) {
|
|
795
|
+
this.foaRoom.setViewport(vw);
|
|
796
|
+
}
|
|
797
|
+
dispose() {
|
|
798
|
+
this.foaRoom.dispose();
|
|
799
|
+
this.bus.dispose();
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
var PannerBackend = class {
|
|
803
|
+
constructor(room, panningModel) {
|
|
804
|
+
this.room = room;
|
|
805
|
+
this.panningModel = panningModel;
|
|
806
|
+
this.kind = "panner";
|
|
807
|
+
}
|
|
808
|
+
createOutput() {
|
|
809
|
+
const panner = new Tone4.Panner3D({
|
|
810
|
+
panningModel: this.panningModel,
|
|
811
|
+
distanceModel: "inverse",
|
|
812
|
+
refDistance: 1,
|
|
813
|
+
rolloffFactor: 0.4,
|
|
814
|
+
positionX: 0,
|
|
815
|
+
positionY: 0,
|
|
816
|
+
positionZ: -2
|
|
817
|
+
});
|
|
818
|
+
const dry = new Tone4.Gain(1);
|
|
819
|
+
const send = new Tone4.Gain(0.18);
|
|
820
|
+
const sendColor = new Tone4.Filter({ type: "lowpass", frequency: 5e3, rolloff: -12 });
|
|
821
|
+
panner.connect(dry);
|
|
822
|
+
panner.connect(send);
|
|
823
|
+
send.connect(sendColor);
|
|
824
|
+
dry.connect(this.room.buses.dryIn);
|
|
825
|
+
sendColor.connect(this.room.buses.wetIn);
|
|
826
|
+
return {
|
|
827
|
+
input: panner,
|
|
828
|
+
setPlacement(profile, when) {
|
|
829
|
+
const t = when ?? Tone4.now();
|
|
830
|
+
panner.positionX.rampTo(profile.pan.x, 0.02, t);
|
|
831
|
+
panner.positionY.rampTo(profile.pan.y, 0.02, t);
|
|
832
|
+
panner.positionZ.rampTo(profile.pan.z, 0.02, t);
|
|
833
|
+
sendColor.frequency.rampTo(profile.voice.reverb.sendCutoffHz, 0.02, t);
|
|
834
|
+
send.gain.rampTo(clamp(profile.reverbSend, 0, 1), 0.02, t);
|
|
835
|
+
},
|
|
836
|
+
dispose() {
|
|
837
|
+
panner.dispose();
|
|
838
|
+
dry.dispose();
|
|
839
|
+
send.dispose();
|
|
840
|
+
sendColor.dispose();
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
setFactors(factors) {
|
|
845
|
+
this.room.setFactors(factors);
|
|
846
|
+
}
|
|
847
|
+
setRotation() {
|
|
848
|
+
}
|
|
849
|
+
onViewport() {
|
|
850
|
+
}
|
|
851
|
+
dispose() {
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
// src/spatial/field.ts
|
|
856
|
+
var POINTER_YAW_MAX = 40 * DEG;
|
|
857
|
+
var POINTER_PITCH_MAX = 20 * DEG;
|
|
858
|
+
var TILT_YAW_MAX = 35 * DEG;
|
|
859
|
+
var TILT_PITCH_MAX = 25 * DEG;
|
|
860
|
+
var FieldRig = class {
|
|
861
|
+
constructor(backend, mode) {
|
|
862
|
+
this.backend = backend;
|
|
863
|
+
this.mode = mode;
|
|
864
|
+
this.target = { yaw: 0, pitch: 0 };
|
|
865
|
+
this.tilt = { yaw: 0, pitch: 0 };
|
|
866
|
+
this.look = { yaw: 0, pitch: 0 };
|
|
867
|
+
this.raf = 0;
|
|
868
|
+
this.running = false;
|
|
869
|
+
}
|
|
870
|
+
start() {
|
|
871
|
+
if (this.running) return;
|
|
872
|
+
this.running = true;
|
|
873
|
+
const step = () => {
|
|
874
|
+
if (!this.running) return;
|
|
875
|
+
const gy = clamp(this.target.yaw + this.tilt.yaw, -Math.PI / 2, Math.PI / 2);
|
|
876
|
+
const gp = clamp(this.target.pitch + this.tilt.pitch, -Math.PI / 3, Math.PI / 3);
|
|
877
|
+
const ny = lerp(this.look.yaw, gy, 0.1);
|
|
878
|
+
const np = lerp(this.look.pitch, gp, 0.1);
|
|
879
|
+
if (Math.abs(ny - this.look.yaw) > 1e-4 || Math.abs(np - this.look.pitch) > 1e-4) {
|
|
880
|
+
this.look.yaw = ny;
|
|
881
|
+
this.look.pitch = np;
|
|
882
|
+
this.backend.setRotation(lookMatrix(this.look.yaw, this.look.pitch));
|
|
883
|
+
}
|
|
884
|
+
this.raf = requestAnimationFrame(step);
|
|
885
|
+
};
|
|
886
|
+
this.raf = requestAnimationFrame(step);
|
|
887
|
+
}
|
|
888
|
+
/** I9 — cursor (viewport-normalized 0..1) becomes look direction: right edge = look right. */
|
|
889
|
+
pointTo(tx, ty) {
|
|
890
|
+
if (this.mode !== "pointer") return;
|
|
891
|
+
this.target.yaw = (2 * clamp(tx, 0, 1) - 1) * POINTER_YAW_MAX;
|
|
892
|
+
this.target.pitch = (1 - 2 * clamp(ty, 0, 1)) * POINTER_PITCH_MAX;
|
|
893
|
+
}
|
|
894
|
+
/** I10 — device attitude: γ right-tilt = look right, β beyond ~40° = look up/down. */
|
|
895
|
+
tiltTo(gamma, beta) {
|
|
896
|
+
this.tilt.yaw = clamp(gamma / 45, -1, 1) * TILT_YAW_MAX;
|
|
897
|
+
this.tilt.pitch = clamp((beta - 40) / 45, -1, 1) * -TILT_PITCH_MAX;
|
|
898
|
+
}
|
|
899
|
+
/** Public look API (head tracking / WebXR later plugs in here). Radians, right/up positive. */
|
|
900
|
+
lookAt(yawRight, pitchUp) {
|
|
901
|
+
this.target.yaw = yawRight;
|
|
902
|
+
this.target.pitch = pitchUp;
|
|
903
|
+
}
|
|
904
|
+
get state() {
|
|
905
|
+
return { yaw: this.look.yaw, pitch: this.look.pitch };
|
|
906
|
+
}
|
|
907
|
+
dispose() {
|
|
908
|
+
this.running = false;
|
|
909
|
+
cancelAnimationFrame(this.raf);
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
// src/core/listener.ts
|
|
914
|
+
import * as Tone5 from "tone";
|
|
915
|
+
|
|
916
|
+
// src/math/mapping.ts
|
|
917
|
+
var mapping_exports = {};
|
|
918
|
+
__export(mapping_exports, {
|
|
919
|
+
ROOM_HALF_H: () => ROOM_HALF_H,
|
|
920
|
+
ROOM_HALF_W: () => ROOM_HALF_W,
|
|
921
|
+
ambienceCutoffFromViewport: () => ambienceCutoffFromViewport,
|
|
922
|
+
attackFromRoundness: () => attackFromRoundness,
|
|
923
|
+
brightnessTilt: () => brightnessTilt,
|
|
924
|
+
cutoffFromDepth: () => cutoffFromDepth,
|
|
925
|
+
degreeFromSize: () => degreeFromSize,
|
|
926
|
+
durationFromElongation: () => durationFromElongation,
|
|
927
|
+
panX: () => panX,
|
|
928
|
+
panY: () => panY,
|
|
929
|
+
pitchNudgeFromRoundness: () => pitchNudgeFromRoundness,
|
|
930
|
+
qFromRoundness: () => qFromRoundness,
|
|
931
|
+
reverbFromViewport: () => reverbFromViewport,
|
|
932
|
+
roundness: () => roundness,
|
|
933
|
+
sendFromShadowBlur: () => sendFromShadowBlur,
|
|
934
|
+
sizeT: () => sizeT,
|
|
935
|
+
stepsFromHeadingLevel: () => stepsFromHeadingLevel,
|
|
936
|
+
stepsFromSiblingIndex: () => stepsFromSiblingIndex,
|
|
937
|
+
velocityFromDepth: () => velocityFromDepth,
|
|
938
|
+
velocityFromSize: () => velocityFromSize,
|
|
939
|
+
waveFromRoundness: () => waveFromRoundness,
|
|
940
|
+
zBonusFromZIndex: () => zBonusFromZIndex,
|
|
941
|
+
zFromDepth: () => zFromDepth
|
|
942
|
+
});
|
|
943
|
+
var ROOM_HALF_W = 8;
|
|
944
|
+
var ROOM_HALF_H = 4;
|
|
945
|
+
function panX(rect, vw) {
|
|
946
|
+
return (2 * norm(rect.x + rect.w / 2, 0, vw) - 1) * ROOM_HALF_W;
|
|
947
|
+
}
|
|
948
|
+
function panY(rect, vh) {
|
|
949
|
+
return (1 - 2 * norm(rect.y + rect.h / 2, 0, vh)) * ROOM_HALF_H;
|
|
950
|
+
}
|
|
951
|
+
function brightnessTilt(rect, vh) {
|
|
952
|
+
return lerp(1.45, 0.7, norm(rect.y + rect.h / 2, 0, vh));
|
|
953
|
+
}
|
|
954
|
+
function sizeT(rect, vw, vh) {
|
|
955
|
+
const a = Math.max(1, rect.w * rect.h);
|
|
956
|
+
const A = Math.max(1, vw * vh);
|
|
957
|
+
return norm(Math.log(1 + 100 * a / A), 0, Math.log(101));
|
|
958
|
+
}
|
|
959
|
+
var degreeFromSize = (t) => 0.1 + 0.75 * (1 - t);
|
|
960
|
+
var velocityFromSize = (t) => lerp(0.85, 1.25, t);
|
|
961
|
+
function roundness(radiusPx, rect) {
|
|
962
|
+
const half = Math.min(rect.w, rect.h) / 2;
|
|
963
|
+
return half <= 0 ? 0 : clamp(radiusPx / half, 0, 1);
|
|
964
|
+
}
|
|
965
|
+
function waveFromRoundness(r) {
|
|
966
|
+
if (r < 0.15) return "square";
|
|
967
|
+
if (r < 0.45) return "sawtooth";
|
|
968
|
+
if (r < 0.8) return "triangle";
|
|
969
|
+
return "sine";
|
|
970
|
+
}
|
|
971
|
+
var attackFromRoundness = (r) => lerp(2e-3, 0.045, r);
|
|
972
|
+
var qFromRoundness = (r) => lerp(2.4, 0.5, r);
|
|
973
|
+
var pitchNudgeFromRoundness = (r) => (1 - r) * 1;
|
|
974
|
+
function durationFromElongation(rect) {
|
|
975
|
+
const e = Math.max(rect.w, rect.h) / Math.max(1, Math.min(rect.w, rect.h));
|
|
976
|
+
return clamp(0.18 * Math.sqrt(e), 0.12, 1.6);
|
|
977
|
+
}
|
|
978
|
+
var sendFromShadowBlur = (blurPx) => clamp(blurPx / 40, 0, 0.5);
|
|
979
|
+
var zFromDepth = (depth) => -(1 + 0.7 * Math.min(depth, 10));
|
|
980
|
+
var cutoffFromDepth = (depth) => Math.max(700, 9e3 * Math.pow(0.82, depth));
|
|
981
|
+
var velocityFromDepth = (depth) => Math.max(0.55, Math.pow(0.97, depth));
|
|
982
|
+
var zBonusFromZIndex = (z) => clamp(z / 50, 0, 1) * 0.8;
|
|
983
|
+
var stepsFromSiblingIndex = (i, scaleLen = 7) => i % Math.max(1, scaleLen);
|
|
984
|
+
var stepsFromHeadingLevel = (level) => -(7 - clamp(level, 1, 6)) * 2;
|
|
985
|
+
function reverbFromViewport(vw) {
|
|
986
|
+
const t = norm(vw, 360, 2200);
|
|
987
|
+
return { decay: lerp(0.6, 4.5, t), wet: lerp(0.08, 0.32, t) };
|
|
988
|
+
}
|
|
989
|
+
var ambienceCutoffFromViewport = (vw) => lerp(400, 1400, norm(vw, 360, 2200));
|
|
990
|
+
|
|
991
|
+
// src/core/listener.ts
|
|
992
|
+
var ListenerRig = class {
|
|
993
|
+
constructor(mode) {
|
|
994
|
+
this.mode = mode;
|
|
995
|
+
this.target = { x: 0, y: 0 };
|
|
996
|
+
this.tilt = { x: 0, y: 0 };
|
|
997
|
+
this.pos = { x: 0, y: 0 };
|
|
998
|
+
this.raf = 0;
|
|
999
|
+
this.running = false;
|
|
1000
|
+
}
|
|
1001
|
+
start() {
|
|
1002
|
+
if (this.running) return;
|
|
1003
|
+
this.running = true;
|
|
1004
|
+
const listener = Tone5.getListener();
|
|
1005
|
+
listener.forwardX.value = 0;
|
|
1006
|
+
listener.forwardY.value = 0;
|
|
1007
|
+
listener.forwardZ.value = -1;
|
|
1008
|
+
listener.upY.value = 1;
|
|
1009
|
+
const step = () => {
|
|
1010
|
+
if (!this.running) return;
|
|
1011
|
+
const gx = clamp(this.target.x + this.tilt.x, -ROOM_HALF_W, ROOM_HALF_W);
|
|
1012
|
+
const gy = clamp(this.target.y + this.tilt.y, -ROOM_HALF_H, ROOM_HALF_H);
|
|
1013
|
+
this.pos.x = lerp(this.pos.x, gx, 0.12);
|
|
1014
|
+
this.pos.y = lerp(this.pos.y, gy, 0.12);
|
|
1015
|
+
try {
|
|
1016
|
+
listener.positionX.value = this.pos.x;
|
|
1017
|
+
listener.positionY.value = this.pos.y;
|
|
1018
|
+
listener.positionZ.value = 0;
|
|
1019
|
+
} catch {
|
|
1020
|
+
}
|
|
1021
|
+
this.raf = requestAnimationFrame(step);
|
|
1022
|
+
};
|
|
1023
|
+
this.raf = requestAnimationFrame(step);
|
|
1024
|
+
}
|
|
1025
|
+
/** I9 — pointer position (viewport-normalized 0..1) targets the ears. */
|
|
1026
|
+
pointTo(tx, ty) {
|
|
1027
|
+
if (this.mode !== "pointer") return;
|
|
1028
|
+
this.target.x = (2 * clamp(tx, 0, 1) - 1) * ROOM_HALF_W * 0.8;
|
|
1029
|
+
this.target.y = (1 - 2 * clamp(ty, 0, 1)) * ROOM_HALF_H * 0.8;
|
|
1030
|
+
}
|
|
1031
|
+
/** I10 — device tilt offsets the ears (γ → x ±4 m, β → y ±2 m). */
|
|
1032
|
+
tiltTo(gamma, beta) {
|
|
1033
|
+
this.tilt.x = clamp(gamma / 45, -1, 1) * 4;
|
|
1034
|
+
this.tilt.y = clamp((beta - 40) / 45, -1, 1) * -2;
|
|
1035
|
+
}
|
|
1036
|
+
dispose() {
|
|
1037
|
+
this.running = false;
|
|
1038
|
+
cancelAnimationFrame(this.raf);
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
// src/core/room.ts
|
|
1043
|
+
import * as Tone6 from "tone";
|
|
1044
|
+
var Room = class {
|
|
1045
|
+
constructor(opts, vw, factors) {
|
|
1046
|
+
this.opts = opts;
|
|
1047
|
+
this.wetGain = null;
|
|
1048
|
+
this.noise = null;
|
|
1049
|
+
this.noiseFilter = null;
|
|
1050
|
+
this.noiseGain = null;
|
|
1051
|
+
this.rushGain = null;
|
|
1052
|
+
this.resizeTimer = null;
|
|
1053
|
+
this.mutedNow = false;
|
|
1054
|
+
this.toneScale = 1;
|
|
1055
|
+
this.limiter = new Tone6.Limiter(-1).toDestination();
|
|
1056
|
+
this.master = new Tone6.Volume(opts.volumeDb).connect(this.limiter);
|
|
1057
|
+
this.highShelf = new Tone6.Filter({ type: "highshelf", frequency: 4e3, gain: brillianceDb(factors.brilliance) }).connect(this.master);
|
|
1058
|
+
this.lowShelf = new Tone6.Filter({ type: "lowshelf", frequency: 250, gain: warmthDb(factors.warmth) }).connect(this.highShelf);
|
|
1059
|
+
this.spatialIn = new Tone6.Gain(1).connect(this.lowShelf);
|
|
1060
|
+
const { decay, wet } = this.roomParams(vw);
|
|
1061
|
+
this.reverb = new Tone6.Reverb({ decay, preDelay: 0.02, wet: 1 });
|
|
1062
|
+
if (opts.mode === "panner") {
|
|
1063
|
+
this.wetGain = new Tone6.Gain(wet).connect(this.spatialIn);
|
|
1064
|
+
this.reverb.connect(this.wetGain);
|
|
1065
|
+
}
|
|
1066
|
+
this.buses = { dryIn: this.spatialIn, wetIn: this.reverb };
|
|
1067
|
+
}
|
|
1068
|
+
setFactors(f) {
|
|
1069
|
+
this.lowShelf.gain.rampTo(warmthDb(f.warmth), 0.1);
|
|
1070
|
+
this.highShelf.gain.rampTo(brillianceDb(f.brilliance), 0.1);
|
|
1071
|
+
}
|
|
1072
|
+
roomParams(vw) {
|
|
1073
|
+
if (this.opts.reverb !== "auto") {
|
|
1074
|
+
const decay = clamp(Number(this.opts.reverb) || 1.5, 0.1, 12);
|
|
1075
|
+
return { decay, wet: 0.25 };
|
|
1076
|
+
}
|
|
1077
|
+
return reverbFromViewport(vw);
|
|
1078
|
+
}
|
|
1079
|
+
/** Debounced: Tone.Reverb regenerates its impulse response when decay changes. */
|
|
1080
|
+
resize(vw) {
|
|
1081
|
+
if (this.resizeTimer) clearTimeout(this.resizeTimer);
|
|
1082
|
+
this.resizeTimer = setTimeout(() => {
|
|
1083
|
+
const { decay, wet } = this.roomParams(vw);
|
|
1084
|
+
try {
|
|
1085
|
+
this.reverb.decay = decay;
|
|
1086
|
+
this.wetGain?.gain.rampTo(wet, 0.3);
|
|
1087
|
+
this.noiseFilter?.frequency.rampTo(ambienceCutoffFromViewport(vw) * this.toneScale, 0.5);
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
console.warn("[sonarium] room resize failed", err);
|
|
1090
|
+
}
|
|
1091
|
+
}, 400);
|
|
1092
|
+
}
|
|
1093
|
+
/** I13 — room tone (sparkles became the phrase engine, PULSE.md §3). toneScale = CH7 warmth. */
|
|
1094
|
+
startAmbience(vw, level, toneScale = 1) {
|
|
1095
|
+
if (level <= 0) return;
|
|
1096
|
+
this.toneScale = toneScale;
|
|
1097
|
+
this.noise = new Tone6.Noise("brown");
|
|
1098
|
+
this.noiseFilter = new Tone6.Filter({ frequency: ambienceCutoffFromViewport(vw) * toneScale, type: "lowpass" });
|
|
1099
|
+
this.noiseGain = new Tone6.Gain(Tone6.dbToGain(-46) * clamp(level / 0.12, 0, 3));
|
|
1100
|
+
this.noise.connect(this.noiseFilter);
|
|
1101
|
+
this.noiseFilter.connect(this.noiseGain);
|
|
1102
|
+
this.noiseGain.connect(this.spatialIn);
|
|
1103
|
+
this.rushGain = new Tone6.Gain(0);
|
|
1104
|
+
this.noiseFilter.connect(this.rushGain);
|
|
1105
|
+
this.rushGain.connect(this.spatialIn);
|
|
1106
|
+
this.noise.start();
|
|
1107
|
+
}
|
|
1108
|
+
/** MATTER.md §2.2 — moving through the page moves air. Swells fast, decays in ~450 ms. */
|
|
1109
|
+
rush(level) {
|
|
1110
|
+
if (!this.rushGain || this.mutedNow) return;
|
|
1111
|
+
const now7 = Tone6.now();
|
|
1112
|
+
this.rushGain.gain.cancelScheduledValues(now7);
|
|
1113
|
+
this.rushGain.gain.rampTo(level, 0.05, now7);
|
|
1114
|
+
this.rushGain.gain.rampTo(0, 0.45, now7 + 0.07);
|
|
1115
|
+
}
|
|
1116
|
+
/** I14 — never sound in a background tab. */
|
|
1117
|
+
setHidden(hidden) {
|
|
1118
|
+
if (this.mutedNow) return;
|
|
1119
|
+
this.master.volume.rampTo(hidden ? -Infinity : this.opts.volumeDb, 0.3);
|
|
1120
|
+
}
|
|
1121
|
+
setMuted(muted) {
|
|
1122
|
+
this.mutedNow = muted;
|
|
1123
|
+
this.master.volume.rampTo(muted ? -Infinity : this.opts.volumeDb, 0.15);
|
|
1124
|
+
}
|
|
1125
|
+
dispose() {
|
|
1126
|
+
if (this.resizeTimer) clearTimeout(this.resizeTimer);
|
|
1127
|
+
this.noise?.dispose();
|
|
1128
|
+
this.noiseFilter?.dispose();
|
|
1129
|
+
this.noiseGain?.dispose();
|
|
1130
|
+
this.rushGain?.dispose();
|
|
1131
|
+
this.reverb.dispose();
|
|
1132
|
+
this.wetGain?.dispose();
|
|
1133
|
+
this.spatialIn.dispose();
|
|
1134
|
+
this.lowShelf.dispose();
|
|
1135
|
+
this.highShelf.dispose();
|
|
1136
|
+
this.master.dispose();
|
|
1137
|
+
this.limiter.dispose();
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
// src/math/matter.ts
|
|
1142
|
+
var matter_exports = {};
|
|
1143
|
+
__export(matter_exports, {
|
|
1144
|
+
PARTIAL_COUNT: () => PARTIAL_COUNT,
|
|
1145
|
+
airRushGain: () => airRushGain,
|
|
1146
|
+
breath: () => breath,
|
|
1147
|
+
deriveMatter: () => deriveMatter,
|
|
1148
|
+
detuneJitterCents: () => detuneJitterCents,
|
|
1149
|
+
envelopeWeave: () => envelopeWeave,
|
|
1150
|
+
filterWeave: () => filterWeave,
|
|
1151
|
+
genPartials: () => genPartials,
|
|
1152
|
+
glideS: () => glideS,
|
|
1153
|
+
reverbWeave: () => reverbWeave,
|
|
1154
|
+
subShimmer: () => subShimmer,
|
|
1155
|
+
transient: () => transient
|
|
1156
|
+
});
|
|
1157
|
+
function deriveMatter(v) {
|
|
1158
|
+
const texture = clamp(
|
|
1159
|
+
clamp(v.shadowBlurPx / 40, 0, 0.4) + (1 - clamp(v.opacity, 0, 1)) * 0.6 + (v.dashedBorder ? 0.15 : 0) + (v.isMedia ? 0.35 : 0) + clamp(v.backdropBlurPx / 40, 0, 0.2),
|
|
1160
|
+
0,
|
|
1161
|
+
1
|
|
1162
|
+
);
|
|
1163
|
+
return {
|
|
1164
|
+
edge: clamp(1 - v.roundness, 0, 1),
|
|
1165
|
+
mass: clamp(v.sizeT + (v.massBonus ?? 0), 0, 1),
|
|
1166
|
+
texture,
|
|
1167
|
+
air: clamp(Math.min(v.depth, 10) / 10, 0, 1)
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
var PARTIAL_COUNT = 24;
|
|
1171
|
+
function genPartials(edge, elongation, richness = 0) {
|
|
1172
|
+
const p = Math.max(0.8, 1 + 2.6 * (1 - clamp(edge, 0, 1)) - clamp(richness, 0, 0.5));
|
|
1173
|
+
const evenness = lerp(1, 0.12, clamp((elongation - 1) / 4, 0, 1));
|
|
1174
|
+
const a = new Float32Array(PARTIAL_COUNT);
|
|
1175
|
+
let energy = 0;
|
|
1176
|
+
for (let k = 1; k <= PARTIAL_COUNT; k++) {
|
|
1177
|
+
const amp = (k % 2 === 1 ? 1 : evenness) / Math.pow(k, p);
|
|
1178
|
+
a[k - 1] = amp;
|
|
1179
|
+
energy += amp * amp;
|
|
1180
|
+
}
|
|
1181
|
+
const norm2 = 1 / Math.sqrt(energy || 1);
|
|
1182
|
+
for (let i = 0; i < PARTIAL_COUNT; i++) a[i] = a[i] * norm2;
|
|
1183
|
+
return a;
|
|
1184
|
+
}
|
|
1185
|
+
function transient(edge) {
|
|
1186
|
+
const e = clamp(edge, 0, 1);
|
|
1187
|
+
return {
|
|
1188
|
+
lengthS: lerp(5e-3, 0.025, e),
|
|
1189
|
+
hpHz: 2e3 + 4e3 * e,
|
|
1190
|
+
level: 0.7 * e
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
function breath(texture) {
|
|
1194
|
+
const t = clamp(texture, 0, 1);
|
|
1195
|
+
return { level: 0.22 * t, bpRatio: lerp(2.5, 1.2, t) };
|
|
1196
|
+
}
|
|
1197
|
+
var detuneJitterCents = (texture) => 6 * clamp(texture, 0, 1);
|
|
1198
|
+
function subShimmer(mass) {
|
|
1199
|
+
const m = clamp(mass, 0, 1);
|
|
1200
|
+
return {
|
|
1201
|
+
interval: m >= 0.45 ? -12 : 12,
|
|
1202
|
+
level: lerp(0.1, 0.38, clamp(Math.abs(m - 0.45) * 2, 0, 1))
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
var glideS = (edge) => lerp(0.028, 0, clamp(edge, 0, 1));
|
|
1206
|
+
function envelopeWeave(edge, mass) {
|
|
1207
|
+
const e = clamp(edge, 0, 1);
|
|
1208
|
+
const m = clamp(mass, 0, 1);
|
|
1209
|
+
return {
|
|
1210
|
+
attackS: lerp(0.045, 2e-3, e) + 8e-3 * m,
|
|
1211
|
+
decayS: lerp(0.4, 0.12, e),
|
|
1212
|
+
sustain: lerp(0.35, 0.15, e),
|
|
1213
|
+
releaseScale: lerp(0.8, 1.5, m)
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
function filterWeave(edge) {
|
|
1217
|
+
const e = clamp(edge, 0, 1);
|
|
1218
|
+
return {
|
|
1219
|
+
q: lerp(0.5, 2.4, e),
|
|
1220
|
+
biteAmount: 1 + 3 * e,
|
|
1221
|
+
biteDecayS: lerp(0.15, 0.06, e)
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
function reverbWeave(edge, mass, texture) {
|
|
1225
|
+
const e = clamp(edge, 0, 1);
|
|
1226
|
+
const m = clamp(mass, 0, 1);
|
|
1227
|
+
const t = clamp(texture, 0, 1);
|
|
1228
|
+
return {
|
|
1229
|
+
sendScale: lerp(1.3, 0.7, e) * lerp(0.85, 1.15, m),
|
|
1230
|
+
sendCutoffHz: lerp(1200, 7e3, e),
|
|
1231
|
+
bloom: 1 - e,
|
|
1232
|
+
extentBonus: 0.15 * t
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
var airRushGain = (pxPerMs) => clamp(pxPerMs * 0.06, 0, 0.18);
|
|
1236
|
+
|
|
1237
|
+
// src/core/profile.ts
|
|
1238
|
+
var HEADING = /^H[1-6]$/;
|
|
1239
|
+
function roleOf(el) {
|
|
1240
|
+
const aria = (el.getAttribute("role") || "").toLowerCase();
|
|
1241
|
+
const override = el.dataset?.sonicRole;
|
|
1242
|
+
if (override) return override;
|
|
1243
|
+
const tag = el.tagName;
|
|
1244
|
+
const type = (el.getAttribute("type") || "").toLowerCase();
|
|
1245
|
+
if (tag === "INPUT" && (type === "checkbox" || type === "radio")) return "toggle";
|
|
1246
|
+
if (aria === "switch" || aria === "checkbox" || tag === "SUMMARY") return "toggle";
|
|
1247
|
+
if (tag === "BUTTON" || aria === "button" || tag === "INPUT" && (type === "button" || type === "submit" || type === "reset")) return "button";
|
|
1248
|
+
if (tag === "A" && el.hasAttribute("href") || aria === "link") return "link";
|
|
1249
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable) return "input";
|
|
1250
|
+
if (HEADING.test(tag) || aria === "heading") return "heading";
|
|
1251
|
+
if (tag === "IMG" || tag === "VIDEO" || tag === "SVG" || tag === "CANVAS" || tag === "PICTURE" || tag === "AUDIO") return "media";
|
|
1252
|
+
if (tag === "LI" || tag === "TR" || ["listitem", "option", "menuitem", "tab"].includes(aria)) return "item";
|
|
1253
|
+
if (["NAV", "SECTION", "ARTICLE", "ASIDE", "FORM", "FIELDSET", "HEADER", "FOOTER", "MAIN", "DIALOG"].includes(tag)) return "container";
|
|
1254
|
+
if (["navigation", "region", "group", "list", "menu", "tablist", "dialog"].includes(aria)) return "container";
|
|
1255
|
+
if (tag === "P" || tag === "BLOCKQUOTE" || tag === "LABEL" || tag === "SPAN") return "text";
|
|
1256
|
+
return "container";
|
|
1257
|
+
}
|
|
1258
|
+
function domDepth(el, root) {
|
|
1259
|
+
let d = 0;
|
|
1260
|
+
let n = el;
|
|
1261
|
+
while (n && n !== root && d < 32) {
|
|
1262
|
+
n = n.parentElement;
|
|
1263
|
+
d++;
|
|
1264
|
+
}
|
|
1265
|
+
return d;
|
|
1266
|
+
}
|
|
1267
|
+
function eligibleSiblingIndex(el) {
|
|
1268
|
+
const parent = el.parentElement;
|
|
1269
|
+
if (!parent) return 0;
|
|
1270
|
+
let i = 0;
|
|
1271
|
+
for (const sib of Array.from(parent.children)) {
|
|
1272
|
+
if (sib === el) return i;
|
|
1273
|
+
if (sib.tagName === el.tagName || roleOf(sib) === roleOf(el)) i++;
|
|
1274
|
+
}
|
|
1275
|
+
return 0;
|
|
1276
|
+
}
|
|
1277
|
+
function recipeFor(role, theme) {
|
|
1278
|
+
return { ...theme.defaults, ...theme.roles[role] ?? {} };
|
|
1279
|
+
}
|
|
1280
|
+
function isQuiet(el) {
|
|
1281
|
+
let n = el;
|
|
1282
|
+
while (n) {
|
|
1283
|
+
if (n.dataset?.sonic === "quiet") return true;
|
|
1284
|
+
n = n.parentElement;
|
|
1285
|
+
}
|
|
1286
|
+
return false;
|
|
1287
|
+
}
|
|
1288
|
+
var NOTE_RE = /^([A-Ga-g][#b]?)(-?\d)$/;
|
|
1289
|
+
function pinnedMidi(spec) {
|
|
1290
|
+
if (!spec) return null;
|
|
1291
|
+
const m = spec.trim().match(NOTE_RE);
|
|
1292
|
+
if (!m) return null;
|
|
1293
|
+
const pcs = { 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 };
|
|
1294
|
+
const name = m[1].charAt(0).toUpperCase() + m[1].slice(1);
|
|
1295
|
+
const pc = pcs[name];
|
|
1296
|
+
if (pc === void 0) return null;
|
|
1297
|
+
return (parseInt(m[2], 10) + 1) * 12 + pc;
|
|
1298
|
+
}
|
|
1299
|
+
function sonicVar(cs, name) {
|
|
1300
|
+
return cs.getPropertyValue(name).trim();
|
|
1301
|
+
}
|
|
1302
|
+
function profileOf(el, env) {
|
|
1303
|
+
const { key, theme, vw, vh } = env;
|
|
1304
|
+
const html = el;
|
|
1305
|
+
const reasons = {};
|
|
1306
|
+
const cs = getComputedStyle(el);
|
|
1307
|
+
const r = el.getBoundingClientRect();
|
|
1308
|
+
const rect = { x: r.x, y: r.y, w: Math.max(1, r.width), h: Math.max(1, r.height) };
|
|
1309
|
+
const role = html.dataset?.sonicRole || sonicVar(cs, "--sonic-role") || roleOf(el);
|
|
1310
|
+
const recipe = recipeFor(role, theme);
|
|
1311
|
+
reasons.role = `<${el.tagName.toLowerCase()}> reads as "${role}" \u2192 ${recipe.synthKind} voice (theme ${theme.name})`;
|
|
1312
|
+
const radiusRaw = cs.borderTopLeftRadius;
|
|
1313
|
+
const radiusPx = radiusRaw.endsWith("%") ? (parseFloat(radiusRaw) || 0) / 100 * Math.min(rect.w, rect.h) : parseFloat(radiusRaw) || 0;
|
|
1314
|
+
const opacity = parseFloat(cs.opacity);
|
|
1315
|
+
const shadowBlur = parseShadowBlur(cs.boxShadow);
|
|
1316
|
+
const zIndex = cs.position !== "static" ? parseInt(cs.zIndex, 10) || 0 : 0;
|
|
1317
|
+
const depth = domDepth(el, env.root);
|
|
1318
|
+
const pan = {
|
|
1319
|
+
x: panX(rect, vw),
|
|
1320
|
+
y: panY(rect, vh),
|
|
1321
|
+
z: zFromDepth(depth) + zBonusFromZIndex(zIndex)
|
|
1322
|
+
};
|
|
1323
|
+
reasons.position = `center (${Math.round(rect.x + rect.w / 2)}, ${Math.round(rect.y + rect.h / 2)}) px \u2192 (${pan.x.toFixed(1)}, ${pan.y.toFixed(1)}) m; depth ${depth} \u2192 ${pan.z.toFixed(1)} m away`;
|
|
1324
|
+
const st = sizeT(rect, vw, vh);
|
|
1325
|
+
const degree = degreeFromSize(st);
|
|
1326
|
+
const round = roundness(radiusPx, rect);
|
|
1327
|
+
let steps = stepsFromSiblingIndex(eligibleSiblingIndex(el), key.scale.length) + pitchNudgeFromRoundness(round);
|
|
1328
|
+
if (role === "heading") {
|
|
1329
|
+
const level = HEADING.test(el.tagName) ? parseInt(el.tagName[1], 10) : 2;
|
|
1330
|
+
steps += stepsFromHeadingLevel(level);
|
|
1331
|
+
}
|
|
1332
|
+
steps += recipe.octaveShift * key.scale.length;
|
|
1333
|
+
const pinned = pinnedMidi(html.dataset?.sonicNote ?? sonicVar(cs, "--sonic-note") ?? void 0);
|
|
1334
|
+
const midi = pinned ?? degreeToMidi(degree, key, steps);
|
|
1335
|
+
reasons.pitch = pinned !== null ? `pinned by data-sonic-note \u2192 ${midiToNoteName(midi)}` : `area ${(rect.w * rect.h / 1e3).toFixed(1)}k px\xB2 (size ${st.toFixed(2)}) + sibling/heading offsets \u2192 ${midiToNoteName(midi)} in ${key.label}`;
|
|
1336
|
+
const wave = html.dataset?.sonicWave || sonicVar(cs, "--sonic-wave") || recipe.pinWave || waveFromRoundness(round);
|
|
1337
|
+
const attack = attackFromRoundness(round);
|
|
1338
|
+
reasons.timbre = `roundness ${round.toFixed(2)} (radius ${radiusPx}px) \u2192 ${wave} wave, ${(attack * 1e3).toFixed(0)} ms attack`;
|
|
1339
|
+
const durationS = durationFromElongation(rect);
|
|
1340
|
+
reasons.duration = `aspect ${(Math.max(rect.w, rect.h) / Math.min(rect.w, rect.h)).toFixed(1)}:1 \u2192 ${durationS.toFixed(2)} s`;
|
|
1341
|
+
const dir = sphereFromRect(rect, vw, vh);
|
|
1342
|
+
const extentOverride = parseFloat(html.dataset?.sonicExtent ?? sonicVar(cs, "--sonic-extent"));
|
|
1343
|
+
const sphere = {
|
|
1344
|
+
azimuth: dir.azimuth,
|
|
1345
|
+
elevation: dir.elevation,
|
|
1346
|
+
extent: isNaN(extentOverride) ? extentFromSize(st) : clamp(extentOverride, 0, 1),
|
|
1347
|
+
directivity: directivityFromRoundness(round)
|
|
1348
|
+
};
|
|
1349
|
+
reasons.sphere = `az ${(sphere.azimuth / DEG).toFixed(0)}\xB0, el ${(sphere.elevation / DEG).toFixed(0)}\xB0, extent ${sphere.extent.toFixed(2)} (size wraps the listener), directivity ${sphere.directivity.toFixed(2)} (sharp beams, round radiates)`;
|
|
1350
|
+
const chroma = elementChroma(el, cs, role);
|
|
1351
|
+
const filterHz = cutoffFromDepth(depth) * brightnessTilt(rect, vh) * directivityFilterScale(sphere.directivity) * brightnessFromLuminance(chroma.luminance);
|
|
1352
|
+
reasons.filter = `depth ${depth} + vertical position + directivity + luminance \u2192 low-pass ${Math.round(filterHz)} Hz`;
|
|
1353
|
+
let velocityScale = recipe.baseVelocity * velocityFromSize(st) * velocityFromDepth(depth) * (isNaN(opacity) ? 1 : opacity) * velocityFromLuminance(chroma.luminance);
|
|
1354
|
+
const sonicMode = sonicVar(cs, "--sonic");
|
|
1355
|
+
if (isQuiet(el) || sonicMode === "quiet") velocityScale *= 0.4;
|
|
1356
|
+
if (sonicMode === "off") {
|
|
1357
|
+
velocityScale = 0;
|
|
1358
|
+
reasons.silenced = "--sonic: off (aural stylesheet)";
|
|
1359
|
+
}
|
|
1360
|
+
velocityScale = clamp(velocityScale, 0, 1.5);
|
|
1361
|
+
const elongation = Math.max(rect.w, rect.h) / Math.max(1, Math.min(rect.w, rect.h));
|
|
1362
|
+
const textish = role === "text" || role === "heading" || role === "link" || role === "button";
|
|
1363
|
+
const fontWeight = textish ? parseInt(cs.fontWeight, 10) || 400 : 400;
|
|
1364
|
+
const matter = deriveMatter({
|
|
1365
|
+
roundness: round,
|
|
1366
|
+
sizeT: st,
|
|
1367
|
+
depth,
|
|
1368
|
+
shadowBlurPx: shadowBlur,
|
|
1369
|
+
opacity: isNaN(opacity) ? 1 : opacity,
|
|
1370
|
+
dashedBorder: cs.borderTopStyle === "dashed" || cs.borderTopStyle === "dotted",
|
|
1371
|
+
isMedia: role === "media",
|
|
1372
|
+
backdropBlurPx: parseBackdropBlur(cs),
|
|
1373
|
+
massBonus: massBonusFromFontWeight(fontWeight)
|
|
1374
|
+
});
|
|
1375
|
+
const modVisuals = {
|
|
1376
|
+
animationS: cs.animationName && cs.animationName !== "none" ? firstSeconds(cs.animationDuration) : 0,
|
|
1377
|
+
borderStyle: ["solid", "dashed", "dotted"].includes(cs.borderTopStyle) ? cs.borderTopStyle : "none",
|
|
1378
|
+
borderWidthPx: parseFloat(cs.borderTopWidth) || 0,
|
|
1379
|
+
transitionS: firstSeconds(cs.transitionDuration)
|
|
1380
|
+
};
|
|
1381
|
+
const patch = patchFrom(matter, modVisuals);
|
|
1382
|
+
const env0 = envelopeWeave(matter.edge, matter.mass);
|
|
1383
|
+
const sub0 = subShimmer(matter.mass);
|
|
1384
|
+
const voice = {
|
|
1385
|
+
matter,
|
|
1386
|
+
partials: Array.from(genPartials(matter.edge, elongation, richnessFromSaturation(chroma.saturation))),
|
|
1387
|
+
transient: transient(matter.edge),
|
|
1388
|
+
breath: breath(matter.texture),
|
|
1389
|
+
subShimmer: {
|
|
1390
|
+
interval: sub0.interval,
|
|
1391
|
+
level: sub0.level + (sub0.interval < 0 ? subBonusFromWarmth(chroma.warmth) : 0)
|
|
1392
|
+
},
|
|
1393
|
+
glideS: glideS(matter.edge),
|
|
1394
|
+
jitterCents: detuneJitterCents(matter.texture),
|
|
1395
|
+
envelope: { ...env0, attackS: env0.attackS * attackScaleFromWarmth(chroma.warmth) },
|
|
1396
|
+
filter: filterWeave(matter.edge),
|
|
1397
|
+
reverb: reverbWeave(matter.edge, matter.mass, matter.texture),
|
|
1398
|
+
patch
|
|
1399
|
+
};
|
|
1400
|
+
if (patch.fold.mix > 0.05 || patch.fm.index > 0.05 || patch.lfo.rateHz > 0.01 || patch.unison.mix > 0) {
|
|
1401
|
+
reasons.patch = [
|
|
1402
|
+
patch.fold.mix > 0.05 ? `border drives the wavefolder \xD7${patch.fold.drive.toFixed(2)}` : "",
|
|
1403
|
+
patch.fm.index > 0.05 ? `FM ring ${patch.fm.index.toFixed(2)}` : "",
|
|
1404
|
+
patch.unison.mix > 0 ? `unison +${patch.unison.detuneCents.toFixed(0)}\xA2` : "",
|
|
1405
|
+
patch.lfo.rateHz > 0.01 ? `${patch.lfo.shape} LFO ${patch.lfo.rateHz.toFixed(2)} Hz (${modVisuals.animationS > 0 ? "css animation" : "dashed border"})` : ""
|
|
1406
|
+
].filter(Boolean).join(" \xB7 ");
|
|
1407
|
+
}
|
|
1408
|
+
reasons.chroma = `warmth ${chroma.warmth.toFixed(2)} \xB7 sat ${chroma.saturation.toFixed(2)} \xB7 lum ${chroma.luminance.toFixed(2)} \u2192 ${chroma.warmth > 0.6 ? "eager onset, full body" : chroma.warmth < 0.4 ? "cool, unhurried onset" : "neutral temperament"}${chroma.saturation > 0.5 ? ", vivid spectrum" : ""}`;
|
|
1409
|
+
reasons.matter = `edge ${matter.edge.toFixed(2)} \xB7 mass ${matter.mass.toFixed(2)} \xB7 texture ${matter.texture.toFixed(2)} \xB7 air ${matter.air.toFixed(2)} \u2192 ${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"}`;
|
|
1410
|
+
const reverbSend = clamp(
|
|
1411
|
+
(0.18 + sendFromShadowBlur(shadowBlur) + 0.05 * Math.min(depth, 10) * 0.5) * voice.reverb.sendScale,
|
|
1412
|
+
0,
|
|
1413
|
+
1.2
|
|
1414
|
+
);
|
|
1415
|
+
return {
|
|
1416
|
+
role,
|
|
1417
|
+
rect,
|
|
1418
|
+
pan,
|
|
1419
|
+
sphere,
|
|
1420
|
+
midi,
|
|
1421
|
+
freqHz: midiToFreq(midi),
|
|
1422
|
+
degree,
|
|
1423
|
+
wave,
|
|
1424
|
+
attack,
|
|
1425
|
+
release: 0.3 * recipe.releaseScale,
|
|
1426
|
+
durationS,
|
|
1427
|
+
filterHz,
|
|
1428
|
+
filterQ: qFromRoundness(round),
|
|
1429
|
+
velocityScale,
|
|
1430
|
+
reverbSend,
|
|
1431
|
+
synthKind: recipe.synthKind,
|
|
1432
|
+
octaveShift: recipe.octaveShift,
|
|
1433
|
+
voice,
|
|
1434
|
+
chroma,
|
|
1435
|
+
reasons
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
function elementChroma(el, cs, role) {
|
|
1439
|
+
if (role === "text" || role === "heading" || role === "link") {
|
|
1440
|
+
return chromaOf(parseCssColor(cs.color));
|
|
1441
|
+
}
|
|
1442
|
+
let probe = el;
|
|
1443
|
+
let style = cs;
|
|
1444
|
+
for (let hops = 0; probe && hops < 6; hops++) {
|
|
1445
|
+
const rgb = parseCssColor((style ?? getComputedStyle(probe)).backgroundColor);
|
|
1446
|
+
if (rgb && rgb.a >= 0.05) return chromaOf(rgb);
|
|
1447
|
+
probe = probe.parentElement;
|
|
1448
|
+
style = void 0;
|
|
1449
|
+
}
|
|
1450
|
+
return chromaOf(parseCssColor(getComputedStyle(document.documentElement).backgroundColor));
|
|
1451
|
+
}
|
|
1452
|
+
function firstSeconds(list) {
|
|
1453
|
+
const first = (list || "").split(",")[0]?.trim() ?? "";
|
|
1454
|
+
if (!first) return 0;
|
|
1455
|
+
const v = parseFloat(first);
|
|
1456
|
+
if (isNaN(v)) return 0;
|
|
1457
|
+
return first.endsWith("ms") ? v / 1e3 : v;
|
|
1458
|
+
}
|
|
1459
|
+
function parseBackdropBlur(cs) {
|
|
1460
|
+
const bf = cs.backdropFilter || cs.webkitBackdropFilter || "";
|
|
1461
|
+
const m = bf.match(/blur\((\d+(\.\d+)?)px\)/);
|
|
1462
|
+
return m ? parseFloat(m[1]) : 0;
|
|
1463
|
+
}
|
|
1464
|
+
function parseShadowBlur(boxShadow) {
|
|
1465
|
+
if (!boxShadow || boxShadow === "none") return 0;
|
|
1466
|
+
let max = 0;
|
|
1467
|
+
for (const part of boxShadow.split(/,(?![^(]*\))/)) {
|
|
1468
|
+
const lengths = part.match(/-?\d+(\.\d+)?px/g);
|
|
1469
|
+
if (lengths && lengths.length >= 3) max = Math.max(max, parseFloat(lengths[2]));
|
|
1470
|
+
}
|
|
1471
|
+
return max;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// src/core/scanner.ts
|
|
1475
|
+
var ELIGIBLE_SELECTOR = [
|
|
1476
|
+
"button",
|
|
1477
|
+
"[role=button]",
|
|
1478
|
+
"input",
|
|
1479
|
+
"textarea",
|
|
1480
|
+
"select",
|
|
1481
|
+
"[contenteditable]",
|
|
1482
|
+
"a[href]",
|
|
1483
|
+
"[role=link]",
|
|
1484
|
+
"summary",
|
|
1485
|
+
"[role=switch]",
|
|
1486
|
+
"[role=checkbox]",
|
|
1487
|
+
"h1",
|
|
1488
|
+
"h2",
|
|
1489
|
+
"h3",
|
|
1490
|
+
"h4",
|
|
1491
|
+
"h5",
|
|
1492
|
+
"h6",
|
|
1493
|
+
"[role=heading]",
|
|
1494
|
+
"img",
|
|
1495
|
+
"video",
|
|
1496
|
+
"canvas",
|
|
1497
|
+
"svg",
|
|
1498
|
+
"picture",
|
|
1499
|
+
"li",
|
|
1500
|
+
"tr",
|
|
1501
|
+
"[role=listitem]",
|
|
1502
|
+
"[role=option]",
|
|
1503
|
+
"[role=menuitem]",
|
|
1504
|
+
"[role=tab]",
|
|
1505
|
+
"nav",
|
|
1506
|
+
"section",
|
|
1507
|
+
"article",
|
|
1508
|
+
"aside",
|
|
1509
|
+
"form",
|
|
1510
|
+
"fieldset",
|
|
1511
|
+
"dialog",
|
|
1512
|
+
"[data-sonic-role]",
|
|
1513
|
+
"[data-sonic-note]"
|
|
1514
|
+
].join(",");
|
|
1515
|
+
var MAX_TRACKED = 400;
|
|
1516
|
+
var Scanner = class {
|
|
1517
|
+
constructor(env, cb) {
|
|
1518
|
+
this.env = env;
|
|
1519
|
+
this.cb = cb;
|
|
1520
|
+
this.registry = /* @__PURE__ */ new Map();
|
|
1521
|
+
this.io = null;
|
|
1522
|
+
this.mo = null;
|
|
1523
|
+
this.capWarned = false;
|
|
1524
|
+
this.initialScanDone = false;
|
|
1525
|
+
this.mutationTimer = null;
|
|
1526
|
+
}
|
|
1527
|
+
scan() {
|
|
1528
|
+
if (typeof IntersectionObserver !== "undefined") {
|
|
1529
|
+
this.io = new IntersectionObserver((entries) => {
|
|
1530
|
+
for (const e of entries) {
|
|
1531
|
+
const entry = this.registry.get(e.target);
|
|
1532
|
+
if (!entry) continue;
|
|
1533
|
+
const was = entry.visible;
|
|
1534
|
+
entry.visible = e.isIntersecting;
|
|
1535
|
+
if (e.isIntersecting) entry.profile = null;
|
|
1536
|
+
if (e.isIntersecting && !was && this.initialScanDone) this.cb.onAppear(e.target);
|
|
1537
|
+
}
|
|
1538
|
+
}, { threshold: 0.15 });
|
|
1539
|
+
}
|
|
1540
|
+
this.register(this.env.root);
|
|
1541
|
+
for (const el of Array.from(this.env.root.querySelectorAll(ELIGIBLE_SELECTOR))) this.register(el);
|
|
1542
|
+
if (typeof MutationObserver !== "undefined") {
|
|
1543
|
+
this.mo = new MutationObserver((muts) => this.queueMutations(muts));
|
|
1544
|
+
this.mo.observe(this.env.root, { subtree: true, childList: true, attributes: true, attributeFilter: ["class", "style", "data-sonic", "data-sonic-note", "data-sonic-wave", "data-sonic-role"] });
|
|
1545
|
+
}
|
|
1546
|
+
setTimeout(() => {
|
|
1547
|
+
this.initialScanDone = true;
|
|
1548
|
+
}, 300);
|
|
1549
|
+
}
|
|
1550
|
+
register(el) {
|
|
1551
|
+
if (this.registry.has(el) || this.isOff(el)) return;
|
|
1552
|
+
if (this.registry.size >= MAX_TRACKED) {
|
|
1553
|
+
if (!this.capWarned) {
|
|
1554
|
+
this.capWarned = true;
|
|
1555
|
+
console.warn(`[sonarium] page exceeds ${MAX_TRACKED} tracked elements; extra elements stay silent`);
|
|
1556
|
+
}
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
this.registry.set(el, { profile: null, visible: false });
|
|
1560
|
+
this.io?.observe(el);
|
|
1561
|
+
}
|
|
1562
|
+
unregister(el) {
|
|
1563
|
+
if (!this.registry.has(el)) return;
|
|
1564
|
+
this.registry.delete(el);
|
|
1565
|
+
this.io?.unobserve(el);
|
|
1566
|
+
}
|
|
1567
|
+
isOff(el) {
|
|
1568
|
+
let n = el;
|
|
1569
|
+
while (n) {
|
|
1570
|
+
if (n.dataset?.sonic === "off") return true;
|
|
1571
|
+
n = n.parentElement;
|
|
1572
|
+
}
|
|
1573
|
+
return false;
|
|
1574
|
+
}
|
|
1575
|
+
queueMutations(muts) {
|
|
1576
|
+
if (this.mutationTimer) clearTimeout(this.mutationTimer);
|
|
1577
|
+
const records = muts;
|
|
1578
|
+
this.mutationTimer = setTimeout(() => {
|
|
1579
|
+
for (const m of records) {
|
|
1580
|
+
if (m.type === "attributes" && m.target instanceof Element) {
|
|
1581
|
+
const entry = this.registry.get(m.target);
|
|
1582
|
+
if (entry) entry.profile = null;
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
for (const node of Array.from(m.addedNodes)) {
|
|
1586
|
+
if (!(node instanceof Element)) continue;
|
|
1587
|
+
if (node.matches?.(ELIGIBLE_SELECTOR)) this.register(node);
|
|
1588
|
+
for (const el of Array.from(node.querySelectorAll?.(ELIGIBLE_SELECTOR) ?? [])) this.register(el);
|
|
1589
|
+
}
|
|
1590
|
+
for (const node of Array.from(m.removedNodes)) {
|
|
1591
|
+
if (!(node instanceof Element)) continue;
|
|
1592
|
+
this.unregister(node);
|
|
1593
|
+
for (const el of Array.from(node.querySelectorAll?.(ELIGIBLE_SELECTOR) ?? [])) this.unregister(el);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}, 150);
|
|
1597
|
+
}
|
|
1598
|
+
/** Profile with lazy compute + cache (cheap path: geometry invalidation only nulls it). */
|
|
1599
|
+
profileFor(el) {
|
|
1600
|
+
if (this.isOff(el)) return null;
|
|
1601
|
+
let entry = this.registry.get(el);
|
|
1602
|
+
if (!entry) {
|
|
1603
|
+
return safeProfile(el, this.env);
|
|
1604
|
+
}
|
|
1605
|
+
if (!entry.profile) entry.profile = safeProfile(el, this.env);
|
|
1606
|
+
return entry.profile;
|
|
1607
|
+
}
|
|
1608
|
+
/** Geometry changed globally (scroll/resize): rects are stale, voices params survive. */
|
|
1609
|
+
invalidateRects() {
|
|
1610
|
+
for (const entry of this.registry.values()) entry.profile = null;
|
|
1611
|
+
}
|
|
1612
|
+
visibleElements() {
|
|
1613
|
+
const out = [];
|
|
1614
|
+
for (const [el, entry] of this.registry) if (entry.visible) out.push(el);
|
|
1615
|
+
return out;
|
|
1616
|
+
}
|
|
1617
|
+
/** The element (or nearest registered ancestor) the scanner knows about. */
|
|
1618
|
+
resolve(target) {
|
|
1619
|
+
let n = target;
|
|
1620
|
+
while (n) {
|
|
1621
|
+
if (this.registry.has(n)) return n;
|
|
1622
|
+
n = n.parentElement;
|
|
1623
|
+
}
|
|
1624
|
+
return null;
|
|
1625
|
+
}
|
|
1626
|
+
updateEnv(vw, vh) {
|
|
1627
|
+
this.env.vw = vw;
|
|
1628
|
+
this.env.vh = vh;
|
|
1629
|
+
}
|
|
1630
|
+
dispose() {
|
|
1631
|
+
if (this.mutationTimer) clearTimeout(this.mutationTimer);
|
|
1632
|
+
this.io?.disconnect();
|
|
1633
|
+
this.mo?.disconnect();
|
|
1634
|
+
this.registry.clear();
|
|
1635
|
+
}
|
|
1636
|
+
};
|
|
1637
|
+
function safeProfile(el, env) {
|
|
1638
|
+
try {
|
|
1639
|
+
return profileOf(el, env);
|
|
1640
|
+
} catch (err) {
|
|
1641
|
+
console.warn("[sonarium] profile failed", err);
|
|
1642
|
+
return null;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// src/core/voices.ts
|
|
1647
|
+
import * as Tone8 from "tone";
|
|
1648
|
+
|
|
1649
|
+
// src/core/matter-voice.ts
|
|
1650
|
+
import * as Tone7 from "tone";
|
|
1651
|
+
var FOLD_CURVE = foldCurve();
|
|
1652
|
+
var MatterVoice = class {
|
|
1653
|
+
constructor() {
|
|
1654
|
+
this.startedSources = false;
|
|
1655
|
+
this.held = false;
|
|
1656
|
+
this.out = new Tone7.Gain(1);
|
|
1657
|
+
this.mix = new Tone7.Gain(0.9);
|
|
1658
|
+
this.oscA = new Tone7.Oscillator({ frequency: 220 });
|
|
1659
|
+
this.oscA2 = new Tone7.Oscillator({ frequency: 220 });
|
|
1660
|
+
this.oscB = new Tone7.Oscillator({ frequency: 110, type: "sine" });
|
|
1661
|
+
this.oscBGain = new Tone7.Gain(0.2);
|
|
1662
|
+
this.foldPre = new Tone7.Gain(0.3);
|
|
1663
|
+
this.foldShaper = new Tone7.WaveShaper(FOLD_CURVE);
|
|
1664
|
+
this.foldWet = new Tone7.Gain(0);
|
|
1665
|
+
this.foldDry = new Tone7.Gain(1);
|
|
1666
|
+
this.oscA.connect(this.foldPre);
|
|
1667
|
+
this.foldPre.connect(this.foldShaper);
|
|
1668
|
+
this.foldShaper.connect(this.foldWet);
|
|
1669
|
+
this.foldWet.connect(this.mix);
|
|
1670
|
+
this.oscA.connect(this.foldDry);
|
|
1671
|
+
this.foldDry.connect(this.mix);
|
|
1672
|
+
this.unisonGain = new Tone7.Gain(0);
|
|
1673
|
+
this.oscA2.connect(this.unisonGain);
|
|
1674
|
+
this.unisonGain.connect(this.mix);
|
|
1675
|
+
this.fmGain = new Tone7.Gain(0);
|
|
1676
|
+
this.oscB.connect(this.fmGain);
|
|
1677
|
+
this.fmGain.connect(this.oscA.frequency);
|
|
1678
|
+
this.oscB.connect(this.oscBGain);
|
|
1679
|
+
this.oscBGain.connect(this.mix);
|
|
1680
|
+
this.breathNoise = new Tone7.Noise("pink");
|
|
1681
|
+
this.breathFilter = new Tone7.Filter({ type: "bandpass", frequency: 600, Q: 1.1 });
|
|
1682
|
+
this.breathGain = new Tone7.Gain(0);
|
|
1683
|
+
this.breathNoise.connect(this.breathFilter);
|
|
1684
|
+
this.breathFilter.connect(this.breathGain);
|
|
1685
|
+
this.breathGain.connect(this.mix);
|
|
1686
|
+
this.ampEnv = new Tone7.AmplitudeEnvelope({ attack: 0.01, decay: 0.2, sustain: 0.25, release: 0.3 });
|
|
1687
|
+
this.mix.connect(this.ampEnv);
|
|
1688
|
+
this.ampEnv.connect(this.out);
|
|
1689
|
+
this.burstNoise = new Tone7.Noise("white");
|
|
1690
|
+
this.burstFilter = new Tone7.Filter({ type: "highpass", frequency: 3e3 });
|
|
1691
|
+
this.burstEnv = new Tone7.AmplitudeEnvelope({ attack: 1e-3, decay: 0.02, sustain: 0, release: 0.02 });
|
|
1692
|
+
this.burstNoise.connect(this.burstFilter);
|
|
1693
|
+
this.burstFilter.connect(this.burstEnv);
|
|
1694
|
+
this.burstEnv.connect(this.out);
|
|
1695
|
+
this.lfo = new Tone7.LFO({ frequency: 0.5, min: -1, max: 1, type: "sine" });
|
|
1696
|
+
this.lfoVib = new Tone7.Gain(0);
|
|
1697
|
+
this.lfoTrem = new Tone7.Gain(0);
|
|
1698
|
+
this.modFilterOut = new Tone7.Gain(0);
|
|
1699
|
+
this.lfo.connect(this.lfoVib);
|
|
1700
|
+
this.lfo.connect(this.lfoTrem);
|
|
1701
|
+
this.lfo.connect(this.modFilterOut);
|
|
1702
|
+
this.lfoVib.connect(this.oscA.detune);
|
|
1703
|
+
this.lfoVib.connect(this.oscA2.detune);
|
|
1704
|
+
this.lfoTrem.connect(this.mix.gain);
|
|
1705
|
+
}
|
|
1706
|
+
connect(dest) {
|
|
1707
|
+
this.out.connect(dest);
|
|
1708
|
+
return this;
|
|
1709
|
+
}
|
|
1710
|
+
ensureRunning(when) {
|
|
1711
|
+
if (this.startedSources) return;
|
|
1712
|
+
this.startedSources = true;
|
|
1713
|
+
this.oscA.start(when);
|
|
1714
|
+
this.oscA2.start(when);
|
|
1715
|
+
this.oscB.start(when);
|
|
1716
|
+
this.breathNoise.start(when);
|
|
1717
|
+
this.burstNoise.start(when);
|
|
1718
|
+
this.lfo.start(when);
|
|
1719
|
+
}
|
|
1720
|
+
/** MODULAR.md §4 — idle lanes power down completely; the next trigger restarts in place. */
|
|
1721
|
+
sleep() {
|
|
1722
|
+
if (!this.startedSources || this.held) return;
|
|
1723
|
+
this.startedSources = false;
|
|
1724
|
+
try {
|
|
1725
|
+
this.oscA.stop();
|
|
1726
|
+
this.oscA2.stop();
|
|
1727
|
+
this.oscB.stop();
|
|
1728
|
+
this.breathNoise.stop();
|
|
1729
|
+
this.burstNoise.stop();
|
|
1730
|
+
this.lfo.stop();
|
|
1731
|
+
} catch {
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
/** Everything both trigger() and gateOn() share: spectrum, patch, pitch, modulation. */
|
|
1735
|
+
applyVoice(t, when, baseCutoffHz) {
|
|
1736
|
+
const v = t.voice;
|
|
1737
|
+
this.ensureRunning(when);
|
|
1738
|
+
this.oscA.partials = v.partials;
|
|
1739
|
+
this.oscA2.partials = v.partials;
|
|
1740
|
+
const jitter = (Math.random() * 2 - 1) * v.jitterCents;
|
|
1741
|
+
this.oscA.detune.setValueAtTime(jitter, when);
|
|
1742
|
+
const glide = v.glideS + v.patch.portamentoS;
|
|
1743
|
+
if (glide > 2e-3 && !this.held) {
|
|
1744
|
+
this.oscA.frequency.setValueAtTime(t.freqHz * 0.917, when);
|
|
1745
|
+
this.oscA.frequency.rampTo(t.freqHz, glide, when);
|
|
1746
|
+
} else {
|
|
1747
|
+
this.oscA.frequency.setValueAtTime(t.freqHz, when);
|
|
1748
|
+
}
|
|
1749
|
+
this.oscA2.frequency.setValueAtTime(t.freqHz, when);
|
|
1750
|
+
this.oscA2.detune.setValueAtTime(jitter + v.patch.unison.detuneCents, when);
|
|
1751
|
+
this.unisonGain.gain.rampTo(v.patch.unison.mix, 0.02, when);
|
|
1752
|
+
this.oscB.frequency.setValueAtTime(t.freqHz * Math.pow(2, v.subShimmer.interval / 12), when);
|
|
1753
|
+
this.oscBGain.gain.rampTo(v.subShimmer.level, 0.02, when);
|
|
1754
|
+
this.foldPre.gain.rampTo(0.3 + v.patch.fold.drive * 1.2, 0.02, when);
|
|
1755
|
+
this.foldWet.gain.rampTo(v.patch.fold.mix, 0.02, when);
|
|
1756
|
+
this.foldDry.gain.rampTo(1 - v.patch.fold.mix * 0.7, 0.02, when);
|
|
1757
|
+
this.fmGain.gain.rampTo(v.patch.fm.index * t.freqHz, 0.02, when);
|
|
1758
|
+
const lfoOn = v.patch.lfo.rateHz > 0.01;
|
|
1759
|
+
this.lfo.frequency.value = Math.max(0.01, v.patch.lfo.rateHz);
|
|
1760
|
+
this.lfo.type = v.patch.lfo.shape;
|
|
1761
|
+
this.lfoVib.gain.rampTo(lfoOn ? v.patch.lfo.vibratoCents : 0, 0.05, when);
|
|
1762
|
+
this.lfoTrem.gain.rampTo(lfoOn ? 0.9 * v.patch.lfo.tremolo : 0, 0.05, when);
|
|
1763
|
+
this.modFilterOut.gain.rampTo(lfoOn ? v.patch.lfo.filterDepth * baseCutoffHz : 0, 0.05, when);
|
|
1764
|
+
this.breathFilter.frequency.rampTo(Math.min(8e3, t.freqHz * v.breath.bpRatio), 0.02, when);
|
|
1765
|
+
this.breathGain.gain.rampTo(v.breath.level, 0.02, when);
|
|
1766
|
+
this.ampEnv.attack = v.envelope.attackS;
|
|
1767
|
+
this.ampEnv.decay = v.envelope.decayS;
|
|
1768
|
+
this.ampEnv.sustain = v.envelope.sustain;
|
|
1769
|
+
this.ampEnv.release = 0.3 * t.releaseScaleBase * v.envelope.releaseScale;
|
|
1770
|
+
}
|
|
1771
|
+
trigger(t, when, baseCutoffHz = 4e3) {
|
|
1772
|
+
this.applyVoice(t, when, baseCutoffHz);
|
|
1773
|
+
this.ampEnv.triggerAttackRelease(t.durationS, when, t.velocity);
|
|
1774
|
+
if (t.voice.transient.level > 0.02) {
|
|
1775
|
+
this.burstFilter.frequency.setValueAtTime(t.voice.transient.hpHz, when);
|
|
1776
|
+
this.burstEnv.decay = t.voice.transient.lengthS;
|
|
1777
|
+
this.burstEnv.triggerAttackRelease(t.voice.transient.lengthS, when, t.velocity * t.voice.transient.level);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
// ----------------------------------------------------------- ribbon (MODULAR.md §3)
|
|
1781
|
+
gateOn(t, when, baseCutoffHz = 4e3) {
|
|
1782
|
+
this.applyVoice(t, when, baseCutoffHz);
|
|
1783
|
+
this.held = true;
|
|
1784
|
+
this.ampEnv.sustain = Math.max(0.4, t.voice.envelope.sustain);
|
|
1785
|
+
this.ampEnv.triggerAttack(when, t.velocity);
|
|
1786
|
+
}
|
|
1787
|
+
/** Ribbon pitch move — quantized upstream; the portamento IS the glissando feel. */
|
|
1788
|
+
setFreq(freqHz, glideS2, subInterval, fmIndex2) {
|
|
1789
|
+
const g = Math.max(0.015, glideS2);
|
|
1790
|
+
this.oscA.frequency.rampTo(freqHz, g);
|
|
1791
|
+
this.oscA2.frequency.rampTo(freqHz, g);
|
|
1792
|
+
this.oscB.frequency.rampTo(freqHz * Math.pow(2, subInterval / 12), g);
|
|
1793
|
+
this.fmGain.gain.rampTo(fmIndex2 * freqHz, g);
|
|
1794
|
+
}
|
|
1795
|
+
gateOff(when) {
|
|
1796
|
+
this.held = false;
|
|
1797
|
+
this.ampEnv.triggerRelease(when ?? Tone7.now());
|
|
1798
|
+
return this.ampEnv.release;
|
|
1799
|
+
}
|
|
1800
|
+
releaseTail() {
|
|
1801
|
+
return this.ampEnv.release;
|
|
1802
|
+
}
|
|
1803
|
+
dispose() {
|
|
1804
|
+
for (const n of [
|
|
1805
|
+
this.oscA,
|
|
1806
|
+
this.oscA2,
|
|
1807
|
+
this.oscB,
|
|
1808
|
+
this.oscBGain,
|
|
1809
|
+
this.fmGain,
|
|
1810
|
+
this.foldPre,
|
|
1811
|
+
this.foldShaper,
|
|
1812
|
+
this.foldWet,
|
|
1813
|
+
this.foldDry,
|
|
1814
|
+
this.unisonGain,
|
|
1815
|
+
this.breathNoise,
|
|
1816
|
+
this.breathFilter,
|
|
1817
|
+
this.breathGain,
|
|
1818
|
+
this.burstNoise,
|
|
1819
|
+
this.burstFilter,
|
|
1820
|
+
this.burstEnv,
|
|
1821
|
+
this.lfo,
|
|
1822
|
+
this.lfoVib,
|
|
1823
|
+
this.lfoTrem,
|
|
1824
|
+
this.modFilterOut,
|
|
1825
|
+
this.ampEnv,
|
|
1826
|
+
this.mix,
|
|
1827
|
+
this.out
|
|
1828
|
+
]) n.dispose();
|
|
1829
|
+
}
|
|
1830
|
+
};
|
|
1831
|
+
|
|
1832
|
+
// src/core/voices.ts
|
|
1833
|
+
var VoicePool = class {
|
|
1834
|
+
constructor(backend, maxVoices) {
|
|
1835
|
+
this.backend = backend;
|
|
1836
|
+
this.maxVoices = maxVoices;
|
|
1837
|
+
this.lanes = [];
|
|
1838
|
+
this.maxVoices = clamp(maxVoices, 4, 24);
|
|
1839
|
+
this.sleepTimer = setInterval(() => {
|
|
1840
|
+
const now7 = Tone8.now();
|
|
1841
|
+
for (const lane of this.lanes) {
|
|
1842
|
+
if (lane.synth instanceof MatterVoice && lane.busyUntil < now7 - 30) lane.synth.sleep();
|
|
1843
|
+
}
|
|
1844
|
+
}, 1e4);
|
|
1845
|
+
}
|
|
1846
|
+
createSynth(kind) {
|
|
1847
|
+
switch (kind) {
|
|
1848
|
+
case "matter":
|
|
1849
|
+
return new MatterVoice();
|
|
1850
|
+
case "fm":
|
|
1851
|
+
return new Tone8.FMSynth({ harmonicity: 3, modulationIndex: 8, envelope: { attack: 0.01, decay: 0.3, sustain: 0.1, release: 1.4 }, modulationEnvelope: { attack: 0.01, decay: 0.4, sustain: 0.2, release: 1 } });
|
|
1852
|
+
case "pluck":
|
|
1853
|
+
return new Tone8.PluckSynth({ attackNoise: 1, dampening: 3e3, resonance: 0.92 });
|
|
1854
|
+
case "membrane":
|
|
1855
|
+
return new Tone8.MembraneSynth({ pitchDecay: 0.04, octaves: 5, envelope: { attack: 1e-3, decay: 0.35, sustain: 0.01, release: 0.6 } });
|
|
1856
|
+
case "noise":
|
|
1857
|
+
return new Tone8.NoiseSynth({ noise: { type: "white" }, envelope: { attack: 1e-3, decay: 0.06, sustain: 0, release: 0.05 } });
|
|
1858
|
+
default:
|
|
1859
|
+
return new Tone8.Synth({ oscillator: { type: "triangle" }, envelope: { attack: 0.01, decay: 0.1, sustain: 0.25, release: 0.3 } });
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
createLane(kind) {
|
|
1863
|
+
const synth = this.createSynth(kind);
|
|
1864
|
+
const filter = new Tone8.Filter({ frequency: 4e3, type: "lowpass", rolloff: -12, Q: 1 });
|
|
1865
|
+
const out = this.backend.createOutput();
|
|
1866
|
+
synth.connect(filter);
|
|
1867
|
+
filter.connect(out.input);
|
|
1868
|
+
if (synth instanceof MatterVoice) synth.modFilterOut.connect(filter.frequency);
|
|
1869
|
+
const lane = { kind, synth, filter, out, busyUntil: 0 };
|
|
1870
|
+
this.lanes.push(lane);
|
|
1871
|
+
return lane;
|
|
1872
|
+
}
|
|
1873
|
+
acquire(kind) {
|
|
1874
|
+
const now7 = Tone8.now();
|
|
1875
|
+
let candidate = null;
|
|
1876
|
+
let oldestSameKind = null;
|
|
1877
|
+
let oldestAny = null;
|
|
1878
|
+
for (const lane of this.lanes) {
|
|
1879
|
+
if (lane.kind === kind) {
|
|
1880
|
+
if (lane.busyUntil <= now7) {
|
|
1881
|
+
candidate = lane;
|
|
1882
|
+
break;
|
|
1883
|
+
}
|
|
1884
|
+
if (!oldestSameKind || lane.busyUntil < oldestSameKind.busyUntil) oldestSameKind = lane;
|
|
1885
|
+
}
|
|
1886
|
+
if (!oldestAny || lane.busyUntil < oldestAny.busyUntil) oldestAny = lane;
|
|
1887
|
+
}
|
|
1888
|
+
if (candidate) return candidate;
|
|
1889
|
+
if (this.lanes.length < this.maxVoices) return this.createLane(kind);
|
|
1890
|
+
if (oldestSameKind) return oldestSameKind;
|
|
1891
|
+
const victim = oldestAny ?? this.lanes[0];
|
|
1892
|
+
victim.synth.dispose();
|
|
1893
|
+
victim.synth = this.createSynth(kind);
|
|
1894
|
+
victim.synth.connect(victim.filter);
|
|
1895
|
+
victim.kind = kind;
|
|
1896
|
+
return victim;
|
|
1897
|
+
}
|
|
1898
|
+
/** Configure a lane from the profile, then sound it. `when` lets strums schedule ahead. */
|
|
1899
|
+
trigger(profile, velocity, when) {
|
|
1900
|
+
const raw = velocity * profile.velocityScale;
|
|
1901
|
+
if (raw <= 0.01) return;
|
|
1902
|
+
const t = when ?? Tone8.now();
|
|
1903
|
+
const vel = clamp(raw, 0.03, 1);
|
|
1904
|
+
const lane = this.acquire(profile.synthKind);
|
|
1905
|
+
const baseCutoff = Math.max(200, profile.filterHz);
|
|
1906
|
+
if (lane.kind === "matter") {
|
|
1907
|
+
const w = profile.voice;
|
|
1908
|
+
lane.filter.Q.rampTo(w.filter.q, 0.02, t);
|
|
1909
|
+
lane.filter.frequency.cancelScheduledValues(t);
|
|
1910
|
+
lane.filter.frequency.setValueAtTime(Math.min(12e3, baseCutoff * w.filter.biteAmount), t);
|
|
1911
|
+
lane.filter.frequency.exponentialRampTo(baseCutoff, w.filter.biteDecayS, t);
|
|
1912
|
+
} else {
|
|
1913
|
+
lane.filter.frequency.rampTo(baseCutoff, 0.02, t);
|
|
1914
|
+
lane.filter.Q.rampTo(profile.filterQ, 0.02, t);
|
|
1915
|
+
}
|
|
1916
|
+
lane.out.setPlacement(profile, t);
|
|
1917
|
+
const dur = profile.durationS;
|
|
1918
|
+
try {
|
|
1919
|
+
switch (lane.kind) {
|
|
1920
|
+
case "matter": {
|
|
1921
|
+
const mv = lane.synth;
|
|
1922
|
+
mv.trigger({
|
|
1923
|
+
freqHz: profile.freqHz,
|
|
1924
|
+
velocity: vel,
|
|
1925
|
+
durationS: dur,
|
|
1926
|
+
releaseScaleBase: profile.release / 0.3,
|
|
1927
|
+
voice: profile.voice
|
|
1928
|
+
}, t, baseCutoff);
|
|
1929
|
+
lane.busyUntil = t + dur + mv.releaseTail();
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
case "noise": {
|
|
1933
|
+
;
|
|
1934
|
+
lane.synth.triggerAttackRelease(Math.min(dur, 0.2), t, vel);
|
|
1935
|
+
break;
|
|
1936
|
+
}
|
|
1937
|
+
case "pluck": {
|
|
1938
|
+
const pluck = lane.synth;
|
|
1939
|
+
pluck.set({ dampening: clamp(profile.filterHz, 400, 7e3) });
|
|
1940
|
+
pluck.triggerAttackRelease(profile.freqHz, dur, t, vel);
|
|
1941
|
+
break;
|
|
1942
|
+
}
|
|
1943
|
+
case "membrane": {
|
|
1944
|
+
const mem = lane.synth;
|
|
1945
|
+
mem.set({ envelope: { attack: Math.max(1e-3, profile.attack / 4), release: profile.release } });
|
|
1946
|
+
mem.triggerAttackRelease(Math.max(30, profile.freqHz / 4), dur, t, vel);
|
|
1947
|
+
break;
|
|
1948
|
+
}
|
|
1949
|
+
case "fm": {
|
|
1950
|
+
const fm = lane.synth;
|
|
1951
|
+
fm.set({ envelope: { attack: profile.attack, release: profile.release * 2 } });
|
|
1952
|
+
fm.triggerAttackRelease(profile.freqHz, dur, t, vel);
|
|
1953
|
+
break;
|
|
1954
|
+
}
|
|
1955
|
+
default: {
|
|
1956
|
+
const syn = lane.synth;
|
|
1957
|
+
syn.set({
|
|
1958
|
+
oscillator: { type: profile.wave },
|
|
1959
|
+
envelope: { attack: profile.attack, decay: 0.08, sustain: 0.25, release: profile.release }
|
|
1960
|
+
});
|
|
1961
|
+
syn.triggerAttackRelease(profile.freqHz, dur, t, vel);
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
lane.busyUntil = t + dur + profile.release;
|
|
1965
|
+
} catch (err) {
|
|
1966
|
+
console.warn("[sonarium] trigger failed", err);
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
get activeCount() {
|
|
1970
|
+
const now7 = Tone8.now();
|
|
1971
|
+
return this.lanes.filter((l) => l.busyUntil > now7).length;
|
|
1972
|
+
}
|
|
1973
|
+
/** MODULAR.md §3 — a sustained ribbon voice. Returns null when no matter lane can gate. */
|
|
1974
|
+
sustain(profile, velocity) {
|
|
1975
|
+
const raw = velocity * profile.velocityScale;
|
|
1976
|
+
if (raw <= 0.01) return null;
|
|
1977
|
+
const lane = this.acquire("matter");
|
|
1978
|
+
if (!(lane.synth instanceof MatterVoice)) return null;
|
|
1979
|
+
const mv = lane.synth;
|
|
1980
|
+
const t = Tone8.now();
|
|
1981
|
+
const baseCutoff = Math.max(200, profile.filterHz);
|
|
1982
|
+
lane.filter.Q.rampTo(profile.voice.filter.q, 0.02, t);
|
|
1983
|
+
lane.filter.frequency.cancelScheduledValues(t);
|
|
1984
|
+
lane.filter.frequency.setValueAtTime(Math.min(12e3, baseCutoff * profile.voice.filter.biteAmount), t);
|
|
1985
|
+
lane.filter.frequency.exponentialRampTo(baseCutoff, profile.voice.filter.biteDecayS, t);
|
|
1986
|
+
lane.out.setPlacement(profile, t);
|
|
1987
|
+
mv.gateOn({
|
|
1988
|
+
freqHz: profile.freqHz,
|
|
1989
|
+
velocity: clamp(raw, 0.03, 1),
|
|
1990
|
+
durationS: 9999,
|
|
1991
|
+
releaseScaleBase: profile.release / 0.3,
|
|
1992
|
+
voice: profile.voice
|
|
1993
|
+
}, t, baseCutoff);
|
|
1994
|
+
lane.busyUntil = t + 9999;
|
|
1995
|
+
const voice = profile.voice;
|
|
1996
|
+
return {
|
|
1997
|
+
setFreq(hz, glideS2) {
|
|
1998
|
+
mv.setFreq(hz, glideS2, voice.subShimmer.interval, voice.patch.fm.index);
|
|
1999
|
+
},
|
|
2000
|
+
release() {
|
|
2001
|
+
const tail = mv.gateOff();
|
|
2002
|
+
lane.busyUntil = Tone8.now() + tail;
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
dispose() {
|
|
2007
|
+
clearInterval(this.sleepTimer);
|
|
2008
|
+
for (const lane of this.lanes) {
|
|
2009
|
+
lane.synth.dispose();
|
|
2010
|
+
lane.filter.dispose();
|
|
2011
|
+
lane.out.dispose();
|
|
2012
|
+
}
|
|
2013
|
+
this.lanes = [];
|
|
2014
|
+
}
|
|
2015
|
+
};
|
|
2016
|
+
|
|
2017
|
+
// src/interact/pointer.ts
|
|
2018
|
+
var PREVIEW_ROLES = /* @__PURE__ */ new Set(["button", "link", "toggle", "input", "item", "heading", "media", "text"]);
|
|
2019
|
+
var HOVER_THROTTLE_MS = 80;
|
|
2020
|
+
var SCROLL_SUPPRESS_MS = 250;
|
|
2021
|
+
var MOVE_FRESHNESS_MS = 400;
|
|
2022
|
+
var MAX_RESOLVE_HOPS = 4;
|
|
2023
|
+
function attachPointer(engine) {
|
|
2024
|
+
const lastHover = /* @__PURE__ */ new WeakMap();
|
|
2025
|
+
let lastScrollT = -Infinity;
|
|
2026
|
+
let lastMoveT = -Infinity;
|
|
2027
|
+
const onMove = (e) => {
|
|
2028
|
+
lastMoveT = performance.now();
|
|
2029
|
+
engine.rig?.pointTo(e.clientX / window.innerWidth, e.clientY / window.innerHeight);
|
|
2030
|
+
};
|
|
2031
|
+
const onScroll = () => {
|
|
2032
|
+
lastScrollT = performance.now();
|
|
2033
|
+
};
|
|
2034
|
+
const withinHops = (target, resolved) => {
|
|
2035
|
+
let n = target;
|
|
2036
|
+
for (let d = 0; n && d <= MAX_RESOLVE_HOPS; d++) {
|
|
2037
|
+
if (n === resolved) return true;
|
|
2038
|
+
n = n.parentElement;
|
|
2039
|
+
}
|
|
2040
|
+
return false;
|
|
2041
|
+
};
|
|
2042
|
+
const onOver = (e) => {
|
|
2043
|
+
const now7 = performance.now();
|
|
2044
|
+
if (now7 - lastScrollT < SCROLL_SUPPRESS_MS) return;
|
|
2045
|
+
if (now7 - lastMoveT > MOVE_FRESHNESS_MS) return;
|
|
2046
|
+
const target = e.target;
|
|
2047
|
+
const el = engine.scanner?.resolve(target);
|
|
2048
|
+
if (!el || !target || !withinHops(target, el)) return;
|
|
2049
|
+
const profile = engine.scanner.profileFor(el);
|
|
2050
|
+
if (!profile || !PREVIEW_ROLES.has(profile.role)) return;
|
|
2051
|
+
if (now7 - (lastHover.get(el) ?? -Infinity) < HOVER_THROTTLE_MS) return;
|
|
2052
|
+
lastHover.set(el, now7);
|
|
2053
|
+
engine.excite(el, 0.25, "preview");
|
|
2054
|
+
};
|
|
2055
|
+
window.addEventListener("pointermove", onMove, { passive: true });
|
|
2056
|
+
window.addEventListener("pointerover", onOver, { passive: true });
|
|
2057
|
+
window.addEventListener("scroll", onScroll, { passive: true, capture: true });
|
|
2058
|
+
return () => {
|
|
2059
|
+
window.removeEventListener("pointermove", onMove);
|
|
2060
|
+
window.removeEventListener("pointerover", onOver);
|
|
2061
|
+
window.removeEventListener("scroll", onScroll, { capture: true });
|
|
2062
|
+
};
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// src/interact/activate.ts
|
|
2066
|
+
var DEDUPE_MS = 80;
|
|
2067
|
+
function attachActivate(engine) {
|
|
2068
|
+
const lastHit = /* @__PURE__ */ new WeakMap();
|
|
2069
|
+
const activate = (target) => {
|
|
2070
|
+
const el = engine.scanner?.resolve(target);
|
|
2071
|
+
if (!el) return;
|
|
2072
|
+
const now7 = performance.now();
|
|
2073
|
+
if (now7 - (lastHit.get(el) ?? -Infinity) < DEDUPE_MS) return;
|
|
2074
|
+
lastHit.set(el, now7);
|
|
2075
|
+
const profile = engine.scanner.profileFor(el);
|
|
2076
|
+
if (!profile) return;
|
|
2077
|
+
if (profile.role === "toggle") return;
|
|
2078
|
+
if (profile.role === "container") {
|
|
2079
|
+
const children = Array.from(el.querySelectorAll(ELIGIBLE_SELECTOR)).filter((c) => c.parentElement && engine.scanner.resolve(c) === c).filter((c) => {
|
|
2080
|
+
const r = c.getBoundingClientRect();
|
|
2081
|
+
return r.width > 0 && r.bottom > 0 && r.top < window.innerHeight;
|
|
2082
|
+
});
|
|
2083
|
+
if (children.length >= 2) {
|
|
2084
|
+
engine.strum(children, 0.5);
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
engine.excite(el, 0.75, "hit");
|
|
2089
|
+
};
|
|
2090
|
+
const onPointerDown = (e) => activate(e.target);
|
|
2091
|
+
const onClick = (e) => activate(e.target);
|
|
2092
|
+
const onChange = (e) => {
|
|
2093
|
+
const t = e.target;
|
|
2094
|
+
if (!(t instanceof HTMLInputElement) || t.type !== "checkbox" && t.type !== "radio") return;
|
|
2095
|
+
const on = t.checked;
|
|
2096
|
+
engine.excite(t, 0.5, on ? "toggle-on" : "toggle-off");
|
|
2097
|
+
};
|
|
2098
|
+
window.addEventListener("pointerdown", onPointerDown, { passive: true });
|
|
2099
|
+
window.addEventListener("click", onClick, { passive: true });
|
|
2100
|
+
window.addEventListener("change", onChange, { passive: true });
|
|
2101
|
+
return () => {
|
|
2102
|
+
window.removeEventListener("pointerdown", onPointerDown);
|
|
2103
|
+
window.removeEventListener("click", onClick);
|
|
2104
|
+
window.removeEventListener("change", onChange);
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// src/interact/keyboard.ts
|
|
2109
|
+
var TICK_THROTTLE_MS = 30;
|
|
2110
|
+
var FILL_INTERVALS = [0, 2, 4, 7, 9, 12, 14, 16];
|
|
2111
|
+
function attachKeyboard(engine) {
|
|
2112
|
+
let lastTick = 0;
|
|
2113
|
+
let lastPointerDownT = -Infinity;
|
|
2114
|
+
const onPointerDown = () => {
|
|
2115
|
+
lastPointerDownT = performance.now();
|
|
2116
|
+
};
|
|
2117
|
+
const onFocus = (e) => {
|
|
2118
|
+
if (performance.now() - lastPointerDownT < 500) return;
|
|
2119
|
+
const el = engine.scanner?.resolve(e.target);
|
|
2120
|
+
if (el) engine.excite(el, 0.35, "preview");
|
|
2121
|
+
};
|
|
2122
|
+
const onKeydown = (e) => {
|
|
2123
|
+
const t = e.target;
|
|
2124
|
+
const editable = t instanceof HTMLElement && (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || t.isContentEditable);
|
|
2125
|
+
if (!editable) return;
|
|
2126
|
+
const now7 = performance.now();
|
|
2127
|
+
if (now7 - lastTick < TICK_THROTTLE_MS) return;
|
|
2128
|
+
lastTick = now7;
|
|
2129
|
+
const len = t.value?.length ?? t.textContent?.length ?? 0;
|
|
2130
|
+
const interval = FILL_INTERVALS[Math.min(FILL_INTERVALS.length - 1, Math.floor(len / 4))];
|
|
2131
|
+
engine.excite(t, 0.15, "tick", void 0, interval);
|
|
2132
|
+
};
|
|
2133
|
+
window.addEventListener("pointerdown", onPointerDown, { passive: true, capture: true });
|
|
2134
|
+
window.addEventListener("focusin", onFocus, { passive: true });
|
|
2135
|
+
window.addEventListener("keydown", onKeydown, { passive: true });
|
|
2136
|
+
return () => {
|
|
2137
|
+
window.removeEventListener("pointerdown", onPointerDown, { capture: true });
|
|
2138
|
+
window.removeEventListener("focusin", onFocus);
|
|
2139
|
+
window.removeEventListener("keydown", onKeydown);
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// src/interact/scroll.ts
|
|
2144
|
+
function attachScroll(engine) {
|
|
2145
|
+
let scrollQueued = false;
|
|
2146
|
+
let lastY = window.scrollY;
|
|
2147
|
+
let lastT = performance.now();
|
|
2148
|
+
const onScroll = () => {
|
|
2149
|
+
if (scrollQueued) return;
|
|
2150
|
+
scrollQueued = true;
|
|
2151
|
+
requestAnimationFrame(() => {
|
|
2152
|
+
scrollQueued = false;
|
|
2153
|
+
engine.geometryChanged();
|
|
2154
|
+
const now7 = performance.now();
|
|
2155
|
+
const dt = Math.max(1, now7 - lastT);
|
|
2156
|
+
const v = Math.abs(window.scrollY - lastY) / dt;
|
|
2157
|
+
lastY = window.scrollY;
|
|
2158
|
+
lastT = now7;
|
|
2159
|
+
engine.airRush(airRushGain(v));
|
|
2160
|
+
});
|
|
2161
|
+
};
|
|
2162
|
+
let resizeQueued = false;
|
|
2163
|
+
const onResize = () => {
|
|
2164
|
+
if (resizeQueued) return;
|
|
2165
|
+
resizeQueued = true;
|
|
2166
|
+
requestAnimationFrame(() => {
|
|
2167
|
+
resizeQueued = false;
|
|
2168
|
+
engine.roomResized();
|
|
2169
|
+
});
|
|
2170
|
+
};
|
|
2171
|
+
window.addEventListener("scroll", onScroll, { passive: true, capture: true });
|
|
2172
|
+
window.addEventListener("resize", onResize, { passive: true });
|
|
2173
|
+
return () => {
|
|
2174
|
+
window.removeEventListener("scroll", onScroll, { capture: true });
|
|
2175
|
+
window.removeEventListener("resize", onResize);
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// src/interact/motion.ts
|
|
2180
|
+
var SHAKE_THRESHOLD = 18;
|
|
2181
|
+
var SHAKE_REFRACTORY_MS = 600;
|
|
2182
|
+
function attachMotion(engine) {
|
|
2183
|
+
if (typeof DeviceOrientationEvent === "undefined") return () => {
|
|
2184
|
+
};
|
|
2185
|
+
let lastShake = 0;
|
|
2186
|
+
let attached = false;
|
|
2187
|
+
const onOrientation = (e) => {
|
|
2188
|
+
if (e.gamma == null || e.beta == null) return;
|
|
2189
|
+
engine.rig?.tiltTo(e.gamma, e.beta);
|
|
2190
|
+
};
|
|
2191
|
+
const onMotion = (e) => {
|
|
2192
|
+
const a = e.accelerationIncludingGravity;
|
|
2193
|
+
if (!a || a.x == null || a.y == null || a.z == null) return;
|
|
2194
|
+
const magnitude = Math.abs(Math.sqrt(a.x * a.x + a.y * a.y + a.z * a.z) - 9.81);
|
|
2195
|
+
const now7 = performance.now();
|
|
2196
|
+
if (now7 - lastShake <= SHAKE_REFRACTORY_MS) return;
|
|
2197
|
+
if (magnitude > SHAKE_THRESHOLD) {
|
|
2198
|
+
lastShake = now7;
|
|
2199
|
+
engine.strum(engine.scanner.visibleElements(), 0.5);
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
if (Math.abs(a.x) > 12 && Math.abs(a.x) > 2 * Math.abs(a.y)) {
|
|
2203
|
+
lastShake = now7;
|
|
2204
|
+
engine.strum(engine.scanner.visibleElements(), 0.4, "strum", a.x > 0);
|
|
2205
|
+
}
|
|
2206
|
+
};
|
|
2207
|
+
const listen = () => {
|
|
2208
|
+
if (attached) return;
|
|
2209
|
+
attached = true;
|
|
2210
|
+
window.addEventListener("deviceorientation", onOrientation, { passive: true });
|
|
2211
|
+
window.addEventListener("devicemotion", onMotion, { passive: true });
|
|
2212
|
+
};
|
|
2213
|
+
const request = DeviceOrientationEvent.requestPermission;
|
|
2214
|
+
if (typeof request === "function") {
|
|
2215
|
+
request.call(DeviceOrientationEvent).then((state) => {
|
|
2216
|
+
if (state === "granted") listen();
|
|
2217
|
+
}).catch(() => {
|
|
2218
|
+
});
|
|
2219
|
+
} else {
|
|
2220
|
+
listen();
|
|
2221
|
+
}
|
|
2222
|
+
return () => {
|
|
2223
|
+
if (!attached) return;
|
|
2224
|
+
window.removeEventListener("deviceorientation", onOrientation);
|
|
2225
|
+
window.removeEventListener("devicemotion", onMotion);
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// src/interact/drag.ts
|
|
2230
|
+
var START_PX = 14;
|
|
2231
|
+
function attachDrag(engine) {
|
|
2232
|
+
let session = null;
|
|
2233
|
+
const onDown = (e) => {
|
|
2234
|
+
if (!e.isPrimary) return;
|
|
2235
|
+
const el = engine.scanner?.resolve(e.target);
|
|
2236
|
+
if (!el) return;
|
|
2237
|
+
const p = engine.scanner.profileFor(el);
|
|
2238
|
+
if (!p || p.role === "container" || p.role === "text") return;
|
|
2239
|
+
session = { x0: e.clientX, el, handle: null };
|
|
2240
|
+
};
|
|
2241
|
+
const onMove = (e) => {
|
|
2242
|
+
if (!session) return;
|
|
2243
|
+
const dx = e.clientX - session.x0;
|
|
2244
|
+
if (!session.handle) {
|
|
2245
|
+
if (Math.abs(dx) < START_PX) return;
|
|
2246
|
+
session.handle = engine.ribbon(session.el);
|
|
2247
|
+
if (!session.handle) {
|
|
2248
|
+
session = null;
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
session.handle.move(dx);
|
|
2253
|
+
};
|
|
2254
|
+
const end = () => {
|
|
2255
|
+
session?.handle?.release();
|
|
2256
|
+
session = null;
|
|
2257
|
+
};
|
|
2258
|
+
window.addEventListener("pointerdown", onDown, { passive: true });
|
|
2259
|
+
window.addEventListener("pointermove", onMove, { passive: true });
|
|
2260
|
+
window.addEventListener("pointerup", end, { passive: true });
|
|
2261
|
+
window.addEventListener("pointercancel", end, { passive: true });
|
|
2262
|
+
return () => {
|
|
2263
|
+
end();
|
|
2264
|
+
window.removeEventListener("pointerdown", onDown);
|
|
2265
|
+
window.removeEventListener("pointermove", onMove);
|
|
2266
|
+
window.removeEventListener("pointerup", end);
|
|
2267
|
+
window.removeEventListener("pointercancel", end);
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// src/interact/midi.ts
|
|
2272
|
+
var ROLE_CHANNEL = {
|
|
2273
|
+
button: 0,
|
|
2274
|
+
link: 1,
|
|
2275
|
+
toggle: 2,
|
|
2276
|
+
input: 3,
|
|
2277
|
+
heading: 4,
|
|
2278
|
+
media: 5,
|
|
2279
|
+
item: 6,
|
|
2280
|
+
text: 7,
|
|
2281
|
+
container: 8
|
|
2282
|
+
};
|
|
2283
|
+
function attachMidi(engine) {
|
|
2284
|
+
const nav = navigator;
|
|
2285
|
+
if (typeof nav.requestMIDIAccess !== "function") return () => {
|
|
2286
|
+
};
|
|
2287
|
+
let out = null;
|
|
2288
|
+
let off = null;
|
|
2289
|
+
nav.requestMIDIAccess().then((access) => {
|
|
2290
|
+
const a = access;
|
|
2291
|
+
out = a.outputs.values().next().value ?? null;
|
|
2292
|
+
if (!out) return;
|
|
2293
|
+
off = engine.on("trigger", (detail) => {
|
|
2294
|
+
const d = detail;
|
|
2295
|
+
const note = Math.max(0, Math.min(127, Math.round(d.profile.midi)));
|
|
2296
|
+
const vel = Math.max(1, Math.min(127, Math.round(d.velocity * d.profile.velocityScale * 127)));
|
|
2297
|
+
const ch = ROLE_CHANNEL[d.profile.role] ?? 9;
|
|
2298
|
+
try {
|
|
2299
|
+
out.send([144 | ch, note, vel]);
|
|
2300
|
+
setTimeout(() => {
|
|
2301
|
+
try {
|
|
2302
|
+
out?.send([128 | ch, note, 0]);
|
|
2303
|
+
} catch {
|
|
2304
|
+
}
|
|
2305
|
+
}, Math.min(4e3, d.profile.durationS * 1e3 + 60));
|
|
2306
|
+
} catch {
|
|
2307
|
+
}
|
|
2308
|
+
});
|
|
2309
|
+
}).catch(() => {
|
|
2310
|
+
});
|
|
2311
|
+
return () => off?.();
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// src/core/engine.ts
|
|
2315
|
+
var Engine = class {
|
|
2316
|
+
constructor(userOpts = {}) {
|
|
2317
|
+
this.state = "idle";
|
|
2318
|
+
this.muted = false;
|
|
2319
|
+
this.pool = null;
|
|
2320
|
+
this.room = null;
|
|
2321
|
+
this.rig = null;
|
|
2322
|
+
this.backend = null;
|
|
2323
|
+
this.phraseLoop = null;
|
|
2324
|
+
this.activity = /* @__PURE__ */ new WeakMap();
|
|
2325
|
+
this.gate = null;
|
|
2326
|
+
this.detachers = [];
|
|
2327
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
2328
|
+
this.unlockHandler = null;
|
|
2329
|
+
this.appearBucket = 6;
|
|
2330
|
+
this.bucketTimer = null;
|
|
2331
|
+
this.starting = false;
|
|
2332
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
2333
|
+
throw new Error("[sonarium] requires a browser environment (create() in the client only)");
|
|
2334
|
+
}
|
|
2335
|
+
const reduced = (userOpts.respectReducedMotion ?? true) && typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
2336
|
+
const rootStyle = getComputedStyle(document.documentElement);
|
|
2337
|
+
const bodyStyle = getComputedStyle(document.body);
|
|
2338
|
+
this.palette = pagePalette(
|
|
2339
|
+
chromaOf(parseCssColor(bodyStyle.backgroundColor)),
|
|
2340
|
+
chromaOf(parseCssColor(bodyStyle.color))
|
|
2341
|
+
);
|
|
2342
|
+
const cssKey = rootStyle.getPropertyValue("--sonic-key").trim();
|
|
2343
|
+
let key = (userOpts.key && userOpts.key !== "auto" ? parseKey(userOpts.key) : null) ?? (cssKey ? parseKey(cssKey) : null);
|
|
2344
|
+
if (!key) {
|
|
2345
|
+
const hashed = siteKey(location.hostname);
|
|
2346
|
+
const mode = modeFromPalette(this.palette);
|
|
2347
|
+
key = mode ? { ...hashed, scaleName: mode, scale: SCALES[mode], label: `${hashed.label.split(" ")[0]} ${mode}` } : hashed;
|
|
2348
|
+
}
|
|
2349
|
+
this.opts = {
|
|
2350
|
+
root: userOpts.root ?? document.body,
|
|
2351
|
+
theme: resolveTheme(userOpts.theme),
|
|
2352
|
+
key,
|
|
2353
|
+
listener: userOpts.listener ?? "pointer",
|
|
2354
|
+
ambient: reduced ? 0 : clamp(userOpts.ambient ?? 0.12, 0, 1),
|
|
2355
|
+
motion: userOpts.motion ?? true,
|
|
2356
|
+
gate: userOpts.gate ?? "chip",
|
|
2357
|
+
volume: userOpts.volume ?? -10,
|
|
2358
|
+
maxVoices: clamp(userOpts.maxVoices ?? 18, 4, 24),
|
|
2359
|
+
panning: userOpts.panning === "equalpower" ? "equalpower" : "HRTF",
|
|
2360
|
+
spatial: userOpts.spatial === "panner" ? "panner" : "ambisonic",
|
|
2361
|
+
reverb: userOpts.reverb ?? "auto",
|
|
2362
|
+
velocityFactor: reduced ? 0.7 : 1,
|
|
2363
|
+
midi: userOpts.midi === true
|
|
2364
|
+
};
|
|
2365
|
+
this.factors = resolveFactors(userOpts.perceptual);
|
|
2366
|
+
this.env = {
|
|
2367
|
+
root: this.opts.root,
|
|
2368
|
+
key: this.opts.key,
|
|
2369
|
+
theme: this.opts.theme,
|
|
2370
|
+
vw: window.innerWidth,
|
|
2371
|
+
vh: window.innerHeight
|
|
2372
|
+
};
|
|
2373
|
+
this.muted = isMutedPersisted();
|
|
2374
|
+
this.scanner = new Scanner(this.env, { onAppear: (el) => this.whisper(el) });
|
|
2375
|
+
this.scanner.scan();
|
|
2376
|
+
const cssTempo = parseFloat(rootStyle.getPropertyValue("--sonic-tempo"));
|
|
2377
|
+
this.tempo = cssTempo > 0 ? clamp(Math.round(cssTempo), 30, 200) : tempoFromPage(this.scanner.registry.size, tempoScaleFromWarmth(this.palette.warmth));
|
|
2378
|
+
this.arm();
|
|
2379
|
+
}
|
|
2380
|
+
// ---------------------------------------------------------------- lifecycle
|
|
2381
|
+
arm() {
|
|
2382
|
+
this.state = "armed";
|
|
2383
|
+
if (this.opts.gate === "chip") {
|
|
2384
|
+
this.gate = mountGate(() => this.toggleMute());
|
|
2385
|
+
this.gate.setState(this.muted ? "muted" : "armed");
|
|
2386
|
+
}
|
|
2387
|
+
if (!this.muted) {
|
|
2388
|
+
this.unlockHandler = () => {
|
|
2389
|
+
void this.start();
|
|
2390
|
+
};
|
|
2391
|
+
window.addEventListener("pointerdown", this.unlockHandler, { capture: true });
|
|
2392
|
+
window.addEventListener("keydown", this.unlockHandler, { capture: true });
|
|
2393
|
+
window.addEventListener("click", this.unlockHandler, { capture: true });
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
removeUnlockListeners() {
|
|
2397
|
+
if (!this.unlockHandler) return;
|
|
2398
|
+
window.removeEventListener("pointerdown", this.unlockHandler, { capture: true });
|
|
2399
|
+
window.removeEventListener("keydown", this.unlockHandler, { capture: true });
|
|
2400
|
+
window.removeEventListener("click", this.unlockHandler, { capture: true });
|
|
2401
|
+
this.unlockHandler = null;
|
|
2402
|
+
}
|
|
2403
|
+
async start() {
|
|
2404
|
+
if (this.state === "running" || this.state === "disposed" || this.starting) return;
|
|
2405
|
+
this.starting = true;
|
|
2406
|
+
try {
|
|
2407
|
+
await Promise.race([Tone9.start(), new Promise((r) => setTimeout(r, 1500))]);
|
|
2408
|
+
} catch (err) {
|
|
2409
|
+
console.warn("[sonarium] audio context could not start yet", err);
|
|
2410
|
+
} finally {
|
|
2411
|
+
this.starting = false;
|
|
2412
|
+
}
|
|
2413
|
+
if (Tone9.getContext().state !== "running" || this.state === "disposed") return;
|
|
2414
|
+
this.state = "running";
|
|
2415
|
+
this.removeUnlockListeners();
|
|
2416
|
+
this.gate?.setState(this.muted ? "muted" : "on");
|
|
2417
|
+
this.buildAudioGraph();
|
|
2418
|
+
if (this.muted) this.room?.setMuted(true);
|
|
2419
|
+
this.detachers.push(
|
|
2420
|
+
attachPointer(this),
|
|
2421
|
+
attachActivate(this),
|
|
2422
|
+
attachKeyboard(this),
|
|
2423
|
+
attachScroll(this),
|
|
2424
|
+
attachDrag(this)
|
|
2425
|
+
);
|
|
2426
|
+
if (this.opts.motion) this.detachers.push(attachMotion(this));
|
|
2427
|
+
if (this.opts.midi) this.detachers.push(attachMidi(this));
|
|
2428
|
+
const onVis = () => this.room?.setHidden(document.hidden);
|
|
2429
|
+
document.addEventListener("visibilitychange", onVis);
|
|
2430
|
+
this.detachers.push(() => document.removeEventListener("visibilitychange", onVis));
|
|
2431
|
+
this.bucketTimer = setInterval(() => {
|
|
2432
|
+
this.appearBucket = Math.min(6, this.appearBucket + 6);
|
|
2433
|
+
}, 1e3);
|
|
2434
|
+
Tone9.getTransport().bpm.value = this.tempo;
|
|
2435
|
+
Tone9.getTransport().start();
|
|
2436
|
+
this.room?.startAmbience(this.env.vw, this.opts.ambient, roomToneScaleFromWarmth(this.palette.warmth));
|
|
2437
|
+
if (this.opts.ambient > 0) {
|
|
2438
|
+
this.phraseLoop = new Tone9.Loop((time) => this.playPhrase(time), "1m");
|
|
2439
|
+
this.phraseLoop.start("1m");
|
|
2440
|
+
}
|
|
2441
|
+
this.playIntroMotif();
|
|
2442
|
+
this.emit("start");
|
|
2443
|
+
}
|
|
2444
|
+
/** PULSE.md §3 — the ambience reads the layout as a score; scroll moves the playhead. */
|
|
2445
|
+
playPhrase(time) {
|
|
2446
|
+
if (this.state !== "running" || this.muted || document.hidden) return;
|
|
2447
|
+
if (time === void 0 || !Number.isFinite(time)) time = Tone9.now();
|
|
2448
|
+
if (Math.random() > PHRASE_PROBABILITY) return;
|
|
2449
|
+
const pool = this.scanner.visibleElements().map((el) => ({ el, p: this.scanner.profileFor(el) })).filter((x) => !!x.p && x.p.role !== "container" && x.p.velocityScale > 0.01).sort((a, b) => readingOrderKey(a.p.rect.y, a.p.rect.x) - readingOrderKey(b.p.rect.y, b.p.rect.x));
|
|
2450
|
+
if (pool.length < PHRASE_MIN_NOTES) return;
|
|
2451
|
+
const len = Math.min(pool.length, PHRASE_MIN_NOTES + Math.floor(Math.random() * (PHRASE_MAX_NOTES - PHRASE_MIN_NOTES + 1)));
|
|
2452
|
+
const scrollable = Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
|
|
2453
|
+
const start2 = phraseWindow(pool.length, len, window.scrollY / scrollable);
|
|
2454
|
+
const step = secondsPerBeat(this.tempo) / 2;
|
|
2455
|
+
const level = 0.07 * (this.opts.ambient / 0.12);
|
|
2456
|
+
pool.slice(start2, start2 + len).forEach(({ el }, i) => this.excite(el, level, "phrase", time + i * step));
|
|
2457
|
+
}
|
|
2458
|
+
/**
|
|
2459
|
+
* SPATIAL.md §6 — ambisonic field by default; if its construction throws on an exotic
|
|
2460
|
+
* browser, fall back to the v0.1 per-voice panner world rather than staying silent.
|
|
2461
|
+
*/
|
|
2462
|
+
buildAudioGraph() {
|
|
2463
|
+
const roomOpts = { volumeDb: this.opts.volume, reverb: this.opts.reverb, ambient: this.opts.ambient };
|
|
2464
|
+
if (this.opts.spatial === "ambisonic") {
|
|
2465
|
+
try {
|
|
2466
|
+
this.room = new Room({ ...roomOpts, mode: "ambisonic" }, this.env.vw, this.factors);
|
|
2467
|
+
const decoderKind = this.opts.panning === "equalpower" ? "stereo" : "binaural";
|
|
2468
|
+
this.backend = new AmbisonicBackend(this.room, decoderKind, this.env.vw, this.factors);
|
|
2469
|
+
this.rig = new FieldRig(this.backend, this.opts.listener);
|
|
2470
|
+
this.rig.start();
|
|
2471
|
+
this.pool = new VoicePool(this.backend, this.opts.maxVoices);
|
|
2472
|
+
return;
|
|
2473
|
+
} catch (err) {
|
|
2474
|
+
console.warn("[sonarium] ambisonic backend unavailable, falling back to panner", err);
|
|
2475
|
+
this.room?.dispose();
|
|
2476
|
+
this.room = null;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
this.room = new Room({ ...roomOpts, mode: "panner" }, this.env.vw, this.factors);
|
|
2480
|
+
this.backend = new PannerBackend(this.room, this.opts.panning);
|
|
2481
|
+
this.rig = new ListenerRig(this.opts.listener);
|
|
2482
|
+
this.rig.start();
|
|
2483
|
+
this.pool = new VoicePool(this.backend, this.opts.maxVoices);
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* MODULAR.md §3 — the ribbon controller: press-and-drag turns an element into a sustained
|
|
2487
|
+
* voice swept across scale degrees (quantized — the glide between steps is the glissando).
|
|
2488
|
+
*/
|
|
2489
|
+
ribbon(el) {
|
|
2490
|
+
if (this.state !== "running" || this.muted || !this.pool) return null;
|
|
2491
|
+
const target = this.scanner.resolve(el) ?? el;
|
|
2492
|
+
const profile = this.scanner.profileFor(target);
|
|
2493
|
+
if (!profile) return null;
|
|
2494
|
+
const handle = this.pool.sustain(profile, 0.6);
|
|
2495
|
+
if (!handle) return null;
|
|
2496
|
+
this.emit("trigger", { el: target, profile, velocity: 0.6, articulation: "ribbon" });
|
|
2497
|
+
const key = this.opts.key;
|
|
2498
|
+
const glide = 0.03 + profile.voice.patch.portamentoS;
|
|
2499
|
+
let lastMidi = profile.midi;
|
|
2500
|
+
return {
|
|
2501
|
+
move: (dxPx) => {
|
|
2502
|
+
const midi = stepInScale(profile.midi, key, ribbonSteps(dxPx, this.env.vw, key.scale.length));
|
|
2503
|
+
if (midi !== lastMidi) {
|
|
2504
|
+
lastMidi = midi;
|
|
2505
|
+
handle.setFreq(midiToFreq(midi), glide);
|
|
2506
|
+
}
|
|
2507
|
+
},
|
|
2508
|
+
release: () => handle.release()
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
/** MATTER.md §2.2 — scroll drivers report air movement; rides the room-tone noise. */
|
|
2512
|
+
airRush(level) {
|
|
2513
|
+
if (this.state !== "running" || this.muted || level <= 5e-3) return;
|
|
2514
|
+
this.room?.rush(level);
|
|
2515
|
+
}
|
|
2516
|
+
/** Live spat5.oper surface: adjust presence/roomPresence/envelopment/warmth/brilliance. */
|
|
2517
|
+
setPerceptual(partial) {
|
|
2518
|
+
this.factors = resolveFactors({ ...this.factors, ...partial });
|
|
2519
|
+
this.backend?.setFactors(this.factors);
|
|
2520
|
+
}
|
|
2521
|
+
toggleMute() {
|
|
2522
|
+
if (this.state !== "running") {
|
|
2523
|
+
this.muted = false;
|
|
2524
|
+
persistMuted(false);
|
|
2525
|
+
void this.start();
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
this.muted = !this.muted;
|
|
2529
|
+
persistMuted(this.muted);
|
|
2530
|
+
this.room?.setMuted(this.muted);
|
|
2531
|
+
this.gate?.setState(this.muted ? "muted" : "on");
|
|
2532
|
+
this.emit("mute", this.muted);
|
|
2533
|
+
}
|
|
2534
|
+
dispose() {
|
|
2535
|
+
if (this.state === "disposed") return;
|
|
2536
|
+
this.state = "disposed";
|
|
2537
|
+
this.removeUnlockListeners();
|
|
2538
|
+
if (this.bucketTimer) clearInterval(this.bucketTimer);
|
|
2539
|
+
this.phraseLoop?.dispose();
|
|
2540
|
+
for (const detach of this.detachers.splice(0)) {
|
|
2541
|
+
try {
|
|
2542
|
+
detach();
|
|
2543
|
+
} catch {
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
this.scanner?.dispose();
|
|
2547
|
+
this.rig?.dispose();
|
|
2548
|
+
this.pool?.dispose();
|
|
2549
|
+
this.backend?.dispose();
|
|
2550
|
+
this.room?.dispose();
|
|
2551
|
+
this.gate?.dispose();
|
|
2552
|
+
this.emit("dispose");
|
|
2553
|
+
this.listeners.clear();
|
|
2554
|
+
}
|
|
2555
|
+
// ---------------------------------------------------------------- sounding
|
|
2556
|
+
/**
|
|
2557
|
+
* The single entry point for anything that wants to sound an element (Invariant: L2 drivers
|
|
2558
|
+
* never touch Tone). Resolves the element to its profile and rents a voice.
|
|
2559
|
+
* `transpose` shifts in semitones relative to the quantized pitch — callers must pass
|
|
2560
|
+
* consonant intervals only (e.g. keyboard.ts FILL_INTERVALS).
|
|
2561
|
+
*/
|
|
2562
|
+
excite(el, velocity, articulation, when, transpose = 0) {
|
|
2563
|
+
if (this.state !== "running" || this.muted || !this.pool) return;
|
|
2564
|
+
if (when !== void 0 && !Number.isFinite(when)) when = void 0;
|
|
2565
|
+
const target = this.scanner.resolve(el) ?? el;
|
|
2566
|
+
let profile = this.scanner.profileFor(target);
|
|
2567
|
+
if (!profile) return;
|
|
2568
|
+
if (articulation !== "motif" && articulation !== "echo" && articulation !== "phrase" && articulation !== "whisper") {
|
|
2569
|
+
const now7 = performance.now();
|
|
2570
|
+
const a = this.activity.get(target) ?? { c: 0, t: now7 };
|
|
2571
|
+
a.c = decayCount(a.c, now7 - a.t) + 1;
|
|
2572
|
+
a.t = now7;
|
|
2573
|
+
this.activity.set(target, a);
|
|
2574
|
+
velocity *= duckFactor(a.c - 1);
|
|
2575
|
+
}
|
|
2576
|
+
if (transpose !== 0) {
|
|
2577
|
+
profile = { ...profile, midi: profile.midi + transpose, freqHz: profile.freqHz * Math.pow(2, transpose / 12) };
|
|
2578
|
+
}
|
|
2579
|
+
if (articulation === "tick") {
|
|
2580
|
+
profile = { ...profile, durationS: Math.min(profile.durationS, 0.07), release: 0.05 };
|
|
2581
|
+
}
|
|
2582
|
+
if (articulation === "toggle-on" || articulation === "toggle-off") {
|
|
2583
|
+
const dir = articulation === "toggle-on" ? 1 : -1;
|
|
2584
|
+
const base = { ...profile, durationS: 0.12 };
|
|
2585
|
+
this.pool.trigger(base, velocity * this.opts.velocityFactor, when);
|
|
2586
|
+
const second = { ...profile, midi: profile.midi + dir * 7, freqHz: profile.freqHz * Math.pow(2, dir * 7 / 12), durationS: 0.16 };
|
|
2587
|
+
this.pool.trigger(second, velocity * this.opts.velocityFactor, (when ?? Tone9.now()) + 0.09);
|
|
2588
|
+
} else {
|
|
2589
|
+
this.pool.trigger(profile, velocity * this.opts.velocityFactor, when);
|
|
2590
|
+
}
|
|
2591
|
+
if (articulation === "hit" && velocity >= 0.55) {
|
|
2592
|
+
const offset = nextGridOffset(Tone9.getTransport().seconds, echoGridS(this.tempo), ECHO_MIN_AHEAD_S);
|
|
2593
|
+
this.excite(target, velocity * ECHO_VELOCITY_SCALE, "echo", Tone9.now() + offset, ECHO_TRANSPOSE);
|
|
2594
|
+
}
|
|
2595
|
+
this.emit("trigger", { el: target, profile, velocity, articulation });
|
|
2596
|
+
}
|
|
2597
|
+
/** I3/I11 — strum a set of elements left→right (or right→left for a reverse flick). */
|
|
2598
|
+
strum(els, velocity, articulation = "strum", reverse = false) {
|
|
2599
|
+
if (this.state !== "running" || !this.pool) return;
|
|
2600
|
+
const sorted = els.map((el) => ({ el, p: this.scanner.profileFor(el) })).filter((x) => !!x.p).sort((a, b) => reverse ? b.p.rect.x - a.p.rect.x : a.p.rect.x - b.p.rect.x).slice(0, 6);
|
|
2601
|
+
const t0 = Tone9.now();
|
|
2602
|
+
const step = strumStepS(this.tempo);
|
|
2603
|
+
sorted.forEach(({ el }, i) => this.excite(el, velocity, articulation, t0 + i * step));
|
|
2604
|
+
}
|
|
2605
|
+
whisper(el) {
|
|
2606
|
+
if (this.appearBucket <= 0) return;
|
|
2607
|
+
this.appearBucket--;
|
|
2608
|
+
this.excite(el, 0.12, "whisper");
|
|
2609
|
+
}
|
|
2610
|
+
/** I12 — the page introduces itself: its largest landmarks, in DOM order, in the site key. */
|
|
2611
|
+
playIntroMotif() {
|
|
2612
|
+
const candidates = Array.from(
|
|
2613
|
+
this.opts.root.querySelectorAll("h1, h2, nav, main, [role=banner], header, button, [role=button]")
|
|
2614
|
+
).filter((el) => {
|
|
2615
|
+
const r = el.getBoundingClientRect();
|
|
2616
|
+
return r.width > 0 && r.height > 0 && r.top < this.env.vh;
|
|
2617
|
+
});
|
|
2618
|
+
const byArea = candidates.map((el) => ({ el, area: el.getBoundingClientRect().width * el.getBoundingClientRect().height })).sort((a, b) => b.area - a.area).slice(0, 5).map((x) => x.el);
|
|
2619
|
+
const inDomOrder = candidates.filter((el) => byArea.includes(el));
|
|
2620
|
+
const t0 = Tone9.now() + 0.1;
|
|
2621
|
+
const step = secondsPerBeat(this.tempo) / 4;
|
|
2622
|
+
inDomOrder.forEach((el, i) => this.excite(el, 0.3, "motif", t0 + i * step));
|
|
2623
|
+
}
|
|
2624
|
+
// ---------------------------------------------------------------- introspection
|
|
2625
|
+
/** Invariant #6 — explain why an element sounds the way it does. Works before start(). */
|
|
2626
|
+
describe(el) {
|
|
2627
|
+
return this.scanner.profileFor(el);
|
|
2628
|
+
}
|
|
2629
|
+
geometryChanged() {
|
|
2630
|
+
this.env.vw = window.innerWidth;
|
|
2631
|
+
this.env.vh = window.innerHeight;
|
|
2632
|
+
this.scanner?.updateEnv(this.env.vw, this.env.vh);
|
|
2633
|
+
this.scanner?.invalidateRects();
|
|
2634
|
+
}
|
|
2635
|
+
roomResized() {
|
|
2636
|
+
this.geometryChanged();
|
|
2637
|
+
this.room?.resize(this.env.vw);
|
|
2638
|
+
this.backend?.onViewport(this.env.vw);
|
|
2639
|
+
}
|
|
2640
|
+
// ---------------------------------------------------------------- events
|
|
2641
|
+
on(event, fn) {
|
|
2642
|
+
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
2643
|
+
this.listeners.get(event).add(fn);
|
|
2644
|
+
return () => this.listeners.get(event)?.delete(fn);
|
|
2645
|
+
}
|
|
2646
|
+
emit(event, detail) {
|
|
2647
|
+
for (const fn of this.listeners.get(event) ?? []) {
|
|
2648
|
+
try {
|
|
2649
|
+
fn(detail);
|
|
2650
|
+
} catch (err) {
|
|
2651
|
+
console.warn("[sonarium] listener error", err);
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
};
|
|
2656
|
+
|
|
2657
|
+
// src/index.ts
|
|
2658
|
+
var version = "0.6.0";
|
|
2659
|
+
function create(options = {}) {
|
|
2660
|
+
return new Engine(options);
|
|
2661
|
+
}
|
|
2662
|
+
function autoInit() {
|
|
2663
|
+
if (typeof document === "undefined") return;
|
|
2664
|
+
const script = document.currentScript;
|
|
2665
|
+
if (!script || script.dataset.auto === void 0) return;
|
|
2666
|
+
const opts = {};
|
|
2667
|
+
if (script.dataset.theme) opts.theme = script.dataset.theme;
|
|
2668
|
+
if (script.dataset.key) opts.key = script.dataset.key;
|
|
2669
|
+
if (script.dataset.ambient) opts.ambient = parseFloat(script.dataset.ambient);
|
|
2670
|
+
if (script.dataset.listener) opts.listener = script.dataset.listener;
|
|
2671
|
+
if (script.dataset.volume) opts.volume = parseFloat(script.dataset.volume);
|
|
2672
|
+
if (script.dataset.panning) opts.panning = script.dataset.panning;
|
|
2673
|
+
const boot = () => {
|
|
2674
|
+
try {
|
|
2675
|
+
const engine = create(opts);
|
|
2676
|
+
window.sonarium = engine;
|
|
2677
|
+
} catch (err) {
|
|
2678
|
+
console.warn("[sonarium] auto-init failed", err);
|
|
2679
|
+
}
|
|
2680
|
+
};
|
|
2681
|
+
if (document.readyState === "loading") {
|
|
2682
|
+
document.addEventListener("DOMContentLoaded", boot, { once: true });
|
|
2683
|
+
} else {
|
|
2684
|
+
boot();
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
autoInit();
|
|
2688
|
+
export {
|
|
2689
|
+
CUBE_LAYOUT,
|
|
2690
|
+
DEFAULT_FACTORS,
|
|
2691
|
+
DEG,
|
|
2692
|
+
SCALES,
|
|
2693
|
+
THEMES,
|
|
2694
|
+
applyMat3,
|
|
2695
|
+
chroma_exports as chroma,
|
|
2696
|
+
create,
|
|
2697
|
+
decodeGains,
|
|
2698
|
+
decodeMatrix,
|
|
2699
|
+
degreeToMidi,
|
|
2700
|
+
foaGains,
|
|
2701
|
+
lookMatrix,
|
|
2702
|
+
mapping_exports as mapping,
|
|
2703
|
+
matter_exports as matter,
|
|
2704
|
+
midiToFreq,
|
|
2705
|
+
midiToNoteName,
|
|
2706
|
+
modular_exports as modular,
|
|
2707
|
+
parseKey,
|
|
2708
|
+
pulse_exports as pulse,
|
|
2709
|
+
rotationMatrix,
|
|
2710
|
+
siteKey,
|
|
2711
|
+
sphere_exports as sphereMapping,
|
|
2712
|
+
stepInScale,
|
|
2713
|
+
unitVector,
|
|
2714
|
+
version
|
|
2715
|
+
};
|
|
2716
|
+
//# sourceMappingURL=index.js.map
|