@joewinke/jatui 0.46.0 → 0.48.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/README.md +46 -0
- package/dist/ambience/README.md +131 -0
- package/dist/ambience/breath.d.ts +22 -0
- package/dist/ambience/breath.js +43 -0
- package/dist/ambience/default.d.ts +24 -0
- package/dist/ambience/default.js +34 -0
- package/dist/ambience/engine.d.ts +21 -0
- package/dist/ambience/engine.js +313 -0
- package/dist/ambience/index.d.ts +23 -0
- package/dist/ambience/index.js +27 -0
- package/dist/ambience/master.d.ts +49 -0
- package/dist/ambience/master.js +83 -0
- package/dist/ambience/persist.d.ts +11 -0
- package/dist/ambience/persist.js +24 -0
- package/dist/ambience/registry.d.ts +11 -0
- package/dist/ambience/registry.js +16 -0
- package/dist/ambience/scapes/focus.d.ts +55 -0
- package/dist/ambience/scapes/focus.js +245 -0
- package/dist/ambience/scapes/hibiscus.d.ts +103 -0
- package/dist/ambience/scapes/hibiscus.js +345 -0
- package/dist/ambience/scapes/nature.d.ts +36 -0
- package/dist/ambience/scapes/nature.js +325 -0
- package/dist/ambience/scapes/noise.d.ts +77 -0
- package/dist/ambience/scapes/noise.js +226 -0
- package/dist/ambience/scapes/playlist.d.ts +129 -0
- package/dist/ambience/scapes/playlist.js +569 -0
- package/dist/ambience/types.d.ts +80 -0
- package/dist/ambience/types.js +9 -0
- package/dist/components/AmbienceToggle.svelte +599 -0
- package/dist/components/AmbienceToggle.svelte.d.ts +74 -0
- package/dist/components/VideoPlayer.svelte +73 -6
- package/dist/components/VideoPlayer.svelte.d.ts +9 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +9 -0
- package/dist/video/keys.d.ts +4 -3
- package/dist/video/keys.js +5 -4
- package/dist/video/settings/remembered.d.ts +9 -0
- package/dist/video/settings/remembered.js +9 -0
- package/package.json +6 -1
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/** How far ahead of `now` tick-driven events are scheduled (s). */
|
|
2
|
+
const LEAD_S = 0.75;
|
|
3
|
+
/** Seconds of white noise per channel — long enough to loop invisibly. */
|
|
4
|
+
const NOISE_SECONDS = 2;
|
|
5
|
+
const noiseCache = new WeakMap();
|
|
6
|
+
/** White noise, generated once per AudioContext, shared by bed and droplets. */
|
|
7
|
+
function noiseBuffer(audio) {
|
|
8
|
+
const cached = noiseCache.get(audio);
|
|
9
|
+
if (cached)
|
|
10
|
+
return cached;
|
|
11
|
+
const len = Math.max(1, Math.floor(audio.sampleRate * NOISE_SECONDS));
|
|
12
|
+
const buf = audio.createBuffer(2, len, audio.sampleRate);
|
|
13
|
+
for (let ch = 0; ch < 2; ch++) {
|
|
14
|
+
const data = buf.getChannelData(ch);
|
|
15
|
+
for (let i = 0; i < len; i++)
|
|
16
|
+
data[i] = Math.random() * 2 - 1;
|
|
17
|
+
}
|
|
18
|
+
noiseCache.set(audio, buf);
|
|
19
|
+
return buf;
|
|
20
|
+
}
|
|
21
|
+
/** Random loop/slice offset — Math.random, not ctx.rand: variety across
|
|
22
|
+
* plays matters here, determinism does not (the engine's rand is seeded by
|
|
23
|
+
* scape id, so it would replay the same offsets every session). */
|
|
24
|
+
function randomOffset(audio) {
|
|
25
|
+
const buf = noiseBuffer(audio);
|
|
26
|
+
return Math.random() * (buf.length / buf.sampleRate);
|
|
27
|
+
}
|
|
28
|
+
/** Rain: mean seconds between droplets at density 0 → 1. */
|
|
29
|
+
const RAIN_DENSITY = Object.freeze({
|
|
30
|
+
MIN_INTERVAL: 2.4,
|
|
31
|
+
MAX_INTERVAL: 0.12,
|
|
32
|
+
DEFAULT: 0.4
|
|
33
|
+
});
|
|
34
|
+
/** Max droplets scheduled in ONE tick — a background tab's throttled interval
|
|
35
|
+
* must never come back as a burst of missed droplets fired into the past. */
|
|
36
|
+
const DROPS_PER_TICK_CAP = 32;
|
|
37
|
+
export const rain = {
|
|
38
|
+
id: 'rain',
|
|
39
|
+
label: 'Rain',
|
|
40
|
+
group: 'nature',
|
|
41
|
+
description: 'Steady rain on leaves — a soft noise bed with sparse droplet patter.',
|
|
42
|
+
build(ctx) {
|
|
43
|
+
const { audio, input, rand } = ctx;
|
|
44
|
+
let density = RAIN_DENSITY.DEFAULT;
|
|
45
|
+
const out = audio.createGain();
|
|
46
|
+
out.gain.value = 1;
|
|
47
|
+
// Bed: looped stereo noise → highpass (rumble out) → lowpass (hiss out)
|
|
48
|
+
// → bed level → out. The master lowpass (1100 Hz) sits downstream.
|
|
49
|
+
const bed = audio.createBufferSource();
|
|
50
|
+
bed.buffer = noiseBuffer(audio);
|
|
51
|
+
bed.loop = true;
|
|
52
|
+
const bedHP = audio.createBiquadFilter();
|
|
53
|
+
bedHP.type = 'highpass';
|
|
54
|
+
bedHP.frequency.value = 220;
|
|
55
|
+
const bedLP = audio.createBiquadFilter();
|
|
56
|
+
bedLP.type = 'lowpass';
|
|
57
|
+
bedLP.frequency.value = 1000;
|
|
58
|
+
const bedGain = audio.createGain();
|
|
59
|
+
bedGain.gain.value = 0.13;
|
|
60
|
+
bed.connect(bedHP);
|
|
61
|
+
bedHP.connect(bedLP);
|
|
62
|
+
bedLP.connect(bedGain);
|
|
63
|
+
bedGain.connect(out);
|
|
64
|
+
out.connect(input);
|
|
65
|
+
const droplets = [];
|
|
66
|
+
const allSrcs = [bed];
|
|
67
|
+
let nextAt = 0;
|
|
68
|
+
let stopping = false;
|
|
69
|
+
let teardownTimer = null;
|
|
70
|
+
let unsub = null;
|
|
71
|
+
function meanInterval() {
|
|
72
|
+
return RAIN_DENSITY.MAX_INTERVAL + (RAIN_DENSITY.MIN_INTERVAL - RAIN_DENSITY.MAX_INTERVAL) * (1 - density);
|
|
73
|
+
}
|
|
74
|
+
/** One droplet: a short slice of the noise buffer through a randomized
|
|
75
|
+
* bandpass (pitch), a pluck envelope (level), and a pan (when the
|
|
76
|
+
* context has a StereoPanner — the node-side test fake may not). */
|
|
77
|
+
function scheduleDroplet(t) {
|
|
78
|
+
const buf = noiseBuffer(audio);
|
|
79
|
+
const src = audio.createBufferSource();
|
|
80
|
+
src.buffer = buf;
|
|
81
|
+
const bp = audio.createBiquadFilter();
|
|
82
|
+
bp.type = 'bandpass';
|
|
83
|
+
bp.frequency.value = 320 + rand() * 680; // 320–1000 Hz (master lowpass above)
|
|
84
|
+
bp.Q.value = 4 + rand() * 8;
|
|
85
|
+
const g = audio.createGain();
|
|
86
|
+
const peak = 0.06 + rand() * 0.24;
|
|
87
|
+
const tau = 0.05 + rand() * 0.08;
|
|
88
|
+
g.gain.setValueAtTime(0, t);
|
|
89
|
+
g.gain.linearRampToValueAtTime(peak, t + 0.004);
|
|
90
|
+
g.gain.setTargetAtTime(0, t + 0.004, tau);
|
|
91
|
+
let tail = g;
|
|
92
|
+
let panner = null;
|
|
93
|
+
if (typeof audio.createStereoPanner === 'function') {
|
|
94
|
+
panner = audio.createStereoPanner();
|
|
95
|
+
panner.pan.setValueAtTime(rand() * 1.6 - 0.8, t);
|
|
96
|
+
g.connect(panner);
|
|
97
|
+
tail = panner;
|
|
98
|
+
}
|
|
99
|
+
src.connect(bp);
|
|
100
|
+
bp.connect(g);
|
|
101
|
+
tail.connect(out);
|
|
102
|
+
const dur = 0.2 + tau * 3;
|
|
103
|
+
src.start(t, randomOffset(audio) % Math.max(buf.length / buf.sampleRate - dur, 0.01), dur);
|
|
104
|
+
allSrcs.push(src);
|
|
105
|
+
const nodes = panner ? [src, bp, g, panner] : [src, bp, g];
|
|
106
|
+
droplets.push({ until: t + dur + tau * 2, src, nodes });
|
|
107
|
+
}
|
|
108
|
+
function teardown() {
|
|
109
|
+
if (teardownTimer) {
|
|
110
|
+
clearTimeout(teardownTimer);
|
|
111
|
+
teardownTimer = null;
|
|
112
|
+
}
|
|
113
|
+
unsub?.();
|
|
114
|
+
unsub = null;
|
|
115
|
+
for (const n of [out, bed, bedHP, bedLP, bedGain]) {
|
|
116
|
+
try {
|
|
117
|
+
n.disconnect();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
/* already gone */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const d of droplets) {
|
|
124
|
+
for (const n of d.nodes) {
|
|
125
|
+
try {
|
|
126
|
+
n.disconnect();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* already gone */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
droplets.length = 0;
|
|
134
|
+
}
|
|
135
|
+
const tick = (now) => {
|
|
136
|
+
if (stopping)
|
|
137
|
+
return; // after stop: no scheduling, no teardown here
|
|
138
|
+
// Prune droplets whose burst has fully decayed.
|
|
139
|
+
for (let i = droplets.length - 1; i >= 0; i--) {
|
|
140
|
+
if (droplets[i].until <= now - 0.05) {
|
|
141
|
+
for (const n of droplets[i].nodes) {
|
|
142
|
+
try {
|
|
143
|
+
n.disconnect();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* already gone */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
droplets.splice(i, 1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Catch-up resync: browsers throttle setInterval in a background tab
|
|
153
|
+
// (down to ~1/min) while the audio clock keeps running — without this,
|
|
154
|
+
// the first tick after returning would fire every missed droplet at
|
|
155
|
+
// once (a loud crackle burst). Skip the gap, don't play it.
|
|
156
|
+
if (nextAt < now)
|
|
157
|
+
nextAt = now + meanInterval() * rand();
|
|
158
|
+
let budget = DROPS_PER_TICK_CAP;
|
|
159
|
+
while (nextAt < now + LEAD_S && budget-- > 0) {
|
|
160
|
+
scheduleDroplet(nextAt);
|
|
161
|
+
nextAt += meanInterval() * (0.4 + 1.2 * rand());
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
return {
|
|
165
|
+
start(at) {
|
|
166
|
+
bed.start(at, randomOffset(audio));
|
|
167
|
+
nextAt = at + meanInterval() * (0.4 + 1.2 * rand());
|
|
168
|
+
unsub = ctx.lookahead(tick);
|
|
169
|
+
},
|
|
170
|
+
stop(at, fade) {
|
|
171
|
+
stopping = true;
|
|
172
|
+
const g = out.gain;
|
|
173
|
+
g.cancelScheduledValues(at);
|
|
174
|
+
g.setValueAtTime(g.value, at);
|
|
175
|
+
g.linearRampToValueAtTime(0, at + Math.max(fade, 0.001));
|
|
176
|
+
for (const s of allSrcs) {
|
|
177
|
+
try {
|
|
178
|
+
s.stop(at + fade); // audio clock — the fade is audible
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
/* already stopped */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (fade <= 0) {
|
|
185
|
+
teardown(); // kill(): immediate
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// Disconnect on the WALL clock: the engine retires our lookahead
|
|
189
|
+
// cbs at fade*1000 + 250 ms, so a tick-based teardown would race
|
|
190
|
+
// it and can be dropped. 50 ms after the fade is safely ahead.
|
|
191
|
+
teardownTimer = setTimeout(teardown, fade * 1000 + 50);
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
setParam(key, value) {
|
|
195
|
+
if (key === 'density')
|
|
196
|
+
density = Math.min(1, Math.max(0, value));
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
/* ----------------------------------------------------------------- waves */
|
|
202
|
+
/** Waves: swell period range (s) and level shaping. */
|
|
203
|
+
const WAVES_SWELL = Object.freeze({
|
|
204
|
+
MIN_PERIOD: 8,
|
|
205
|
+
MAX_PERIOD: 12,
|
|
206
|
+
BASE: 0.05,
|
|
207
|
+
PEAK: 0.3
|
|
208
|
+
});
|
|
209
|
+
/** Max swells scheduled in ONE tick — same background-tab catch-up guard as
|
|
210
|
+
* rain: never stack missed swells into the past. */
|
|
211
|
+
const SWELLS_PER_TICK_CAP = 2;
|
|
212
|
+
export const waves = {
|
|
213
|
+
id: 'waves',
|
|
214
|
+
label: 'Ocean waves',
|
|
215
|
+
group: 'nature',
|
|
216
|
+
description: 'Slow ocean swells — noise breathing through a band filter, soft wash tail.',
|
|
217
|
+
build(ctx) {
|
|
218
|
+
const { audio, input, rand } = ctx;
|
|
219
|
+
let intensity = 1;
|
|
220
|
+
const out = audio.createGain();
|
|
221
|
+
out.gain.value = 1;
|
|
222
|
+
// Looping stereo noise → bandpass (the "swelling band") → swell level
|
|
223
|
+
// → out. Both the band center and the level are driven by the lookahead
|
|
224
|
+
// scheduler (a scheduled LFO — randomized period, no oscillator nodes).
|
|
225
|
+
const src = audio.createBufferSource();
|
|
226
|
+
src.buffer = noiseBuffer(audio);
|
|
227
|
+
src.loop = true;
|
|
228
|
+
const bp = audio.createBiquadFilter();
|
|
229
|
+
bp.type = 'bandpass';
|
|
230
|
+
bp.frequency.value = 420;
|
|
231
|
+
bp.Q.value = 0.7;
|
|
232
|
+
const swell = audio.createGain();
|
|
233
|
+
swell.gain.value = WAVES_SWELL.BASE;
|
|
234
|
+
src.connect(bp);
|
|
235
|
+
bp.connect(swell);
|
|
236
|
+
swell.connect(out);
|
|
237
|
+
out.connect(input);
|
|
238
|
+
let nextSwellAt = 0;
|
|
239
|
+
let stopping = false;
|
|
240
|
+
let teardownTimer = null;
|
|
241
|
+
let unsub = null;
|
|
242
|
+
/** Schedule one swell starting at t0; returns its period so the next
|
|
243
|
+
* swell lands a fresh randomized 8–12 s later. */
|
|
244
|
+
function scheduleSwell(t0) {
|
|
245
|
+
const period = WAVES_SWELL.MIN_PERIOD + rand() * (WAVES_SWELL.MAX_PERIOD - WAVES_SWELL.MIN_PERIOD);
|
|
246
|
+
const rise = period * 0.45;
|
|
247
|
+
const base = WAVES_SWELL.BASE * intensity;
|
|
248
|
+
const peak = WAVES_SWELL.PEAK * (0.55 + rand() * 0.45) * intensity;
|
|
249
|
+
const tau = 2.2 + rand() * 1.3; // wash tail: 2.2–3.5 s decay constant
|
|
250
|
+
const fBase = 380 + rand() * 120;
|
|
251
|
+
const fPeak = fBase + 260 + rand() * 180;
|
|
252
|
+
const g = swell.gain;
|
|
253
|
+
g.cancelScheduledValues(t0);
|
|
254
|
+
g.linearRampToValueAtTime(base, t0); // glide from the previous tail into base
|
|
255
|
+
g.linearRampToValueAtTime(peak, t0 + rise);
|
|
256
|
+
g.setTargetAtTime(base, t0 + rise, tau);
|
|
257
|
+
const f = bp.frequency;
|
|
258
|
+
f.cancelScheduledValues(t0);
|
|
259
|
+
f.linearRampToValueAtTime(fBase, t0);
|
|
260
|
+
f.linearRampToValueAtTime(fPeak, t0 + rise);
|
|
261
|
+
f.setTargetAtTime(fBase, t0 + rise, tau + 0.8); // the band "breathes" with the swell
|
|
262
|
+
return period;
|
|
263
|
+
}
|
|
264
|
+
function teardown() {
|
|
265
|
+
if (teardownTimer) {
|
|
266
|
+
clearTimeout(teardownTimer);
|
|
267
|
+
teardownTimer = null;
|
|
268
|
+
}
|
|
269
|
+
unsub?.();
|
|
270
|
+
unsub = null;
|
|
271
|
+
for (const n of [out, src, bp, swell]) {
|
|
272
|
+
try {
|
|
273
|
+
n.disconnect();
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
/* already gone */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const tick = (now) => {
|
|
281
|
+
if (stopping)
|
|
282
|
+
return; // after stop: no scheduling, no teardown here
|
|
283
|
+
// Swells are long, absolute-time events — keep ~2 s ahead.
|
|
284
|
+
// Catch-up resync (see rain): after a throttled-tab gap, swells restart
|
|
285
|
+
// from now instead of stacking every missed swell into the past.
|
|
286
|
+
if (nextSwellAt < now)
|
|
287
|
+
nextSwellAt = now;
|
|
288
|
+
let budget = SWELLS_PER_TICK_CAP;
|
|
289
|
+
while (nextSwellAt < now + 2 && budget-- > 0) {
|
|
290
|
+
nextSwellAt += scheduleSwell(nextSwellAt);
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
return {
|
|
294
|
+
start(at) {
|
|
295
|
+
src.start(at, randomOffset(audio));
|
|
296
|
+
nextSwellAt = at;
|
|
297
|
+
unsub = ctx.lookahead(tick);
|
|
298
|
+
},
|
|
299
|
+
stop(at, fade) {
|
|
300
|
+
stopping = true;
|
|
301
|
+
const g = out.gain;
|
|
302
|
+
g.cancelScheduledValues(at);
|
|
303
|
+
g.setValueAtTime(g.value, at);
|
|
304
|
+
g.linearRampToValueAtTime(0, at + Math.max(fade, 0.001));
|
|
305
|
+
try {
|
|
306
|
+
src.stop(at + fade); // audio clock — the fade is audible
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
/* already stopped */
|
|
310
|
+
}
|
|
311
|
+
if (fade <= 0) {
|
|
312
|
+
teardown(); // kill(): immediate
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
// Wall-clock disconnect — see the rain note; never a lookahead cb.
|
|
316
|
+
teardownTimer = setTimeout(teardown, fade * 1000 + 50);
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
setParam(key, value) {
|
|
320
|
+
if (key === 'intensity')
|
|
321
|
+
intensity = Math.min(2, Math.max(0, value));
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambience scapes — noise generators: brown, pink, white (jatui-uglol).
|
|
3
|
+
*
|
|
4
|
+
* Three scapes, one file. Each is built the same way, differing only in the
|
|
5
|
+
* noise colour, its loudness trim and its copy:
|
|
6
|
+
*
|
|
7
|
+
* buffer source (one looped 10 s mono buffer, generated ONCE per build)
|
|
8
|
+
* ─► lowshelf ─► highshelf (the `tone` tilt: darker ⇄ lighter)
|
|
9
|
+
* ─► level gain (base = loudness trim; drift LFOs wobble it)
|
|
10
|
+
* ─► ctx.input (the shared master chain — never destination)
|
|
11
|
+
*
|
|
12
|
+
* CPU: no per-sample JS at runtime and no lookahead ticks at all. The buffer
|
|
13
|
+
* is generated once in build() and looped; tone and drift are native nodes
|
|
14
|
+
* (two biquads, two slow sines into a gain param). Nothing to schedule.
|
|
15
|
+
*
|
|
16
|
+
* Loop seam: raw noise has a step where the buffer wraps, and on brown (big
|
|
17
|
+
* low-frequency wander) that step is an audible thump every cycle. So the
|
|
18
|
+
* generator produces BUFFER_SECONDS + one crossfade, and the last crossfade
|
|
19
|
+
* of the buffer blends into the audio that follows the wrap — the seam
|
|
20
|
+
* disappears for all three colours.
|
|
21
|
+
*
|
|
22
|
+
* Never audibly repeats: the loop start offset is drawn from ctx.rand() each
|
|
23
|
+
* build, so a given play begins somewhere else in the buffer. The buffer is
|
|
24
|
+
* long enough (10 s) that the cycle itself is below perception for noise.
|
|
25
|
+
*
|
|
26
|
+
* Loudness matching: all three buffers are normalized to the same RMS, then
|
|
27
|
+
* each colour gets a fixed perceptual trim (NOISE.TRIMS). The trims equalize
|
|
28
|
+
* the A-weighted loudness of what survives the master chain's 1100 Hz
|
|
29
|
+
* lowpass (white keeps only ~5% of its power in band, pink ~79%, brown
|
|
30
|
+
* ~99% — but brown sits where hearing is least sensitive), referenced to
|
|
31
|
+
* pink. They are documented approximations, ±2 dB, and the tone control is
|
|
32
|
+
* there for the rest.
|
|
33
|
+
*
|
|
34
|
+
* The scape contract lives in ../types.ts (jatui-km0qy); this file imports
|
|
35
|
+
* only those types. Registration is the integration task's job (jatui-poq30)
|
|
36
|
+
* — nothing is registered, mutated or played at import time.
|
|
37
|
+
*/
|
|
38
|
+
import type { Scape } from '../types';
|
|
39
|
+
/** Tuning constants — exported for tests and for the integration docs. */
|
|
40
|
+
export declare const NOISE: Readonly<{
|
|
41
|
+
/** Loop length of the generated buffer (s). */
|
|
42
|
+
BUFFER_SECONDS: 10;
|
|
43
|
+
/** Crossfade that hides the loop seam (s). */
|
|
44
|
+
LOOP_CROSSFADE_SECONDS: 0.1;
|
|
45
|
+
/** RMS every buffer is normalized to (before the per-colour trim). Sized
|
|
46
|
+
* so even brown's worst crest (~6x) stays under the peak ceiling. */
|
|
47
|
+
TARGET_RMS: 0.15;
|
|
48
|
+
/** Hard ceiling on |sample| after normalization (headroom for the master limiter). */
|
|
49
|
+
PEAK_CEILING: 0.97;
|
|
50
|
+
/** Tilt filter corners (Hz) — inside the master chain's 1100 Hz band. */
|
|
51
|
+
TILT_LOW_HZ: 240;
|
|
52
|
+
TILT_HIGH_HZ: 900;
|
|
53
|
+
/** Total tilt range across the tone param, in dB — ±4 dB per shelf. */
|
|
54
|
+
TILT_RANGE_DB: 8;
|
|
55
|
+
/** Smoothing time constant for live tone changes (s) — no zipper, no click. */
|
|
56
|
+
TILT_TC: 0.12;
|
|
57
|
+
/** Level drift: two slow sines at irrational-ratio periods summed into the
|
|
58
|
+
* level gain, so the wobble itself never repeats. Depths are fractions of
|
|
59
|
+
* the trim (worst case ≈ ±0.8 dB — felt, not heard). */
|
|
60
|
+
DRIFT: Readonly<{
|
|
61
|
+
PERIOD_A_S: 41;
|
|
62
|
+
PERIOD_B_S: 67;
|
|
63
|
+
DEPTH_A: 0.06;
|
|
64
|
+
DEPTH_B: 0.045;
|
|
65
|
+
}>;
|
|
66
|
+
/** Perceptual loudness trims on top of equal-RMS buffers (see header). */
|
|
67
|
+
TRIMS: Record<NoiseKind, number>;
|
|
68
|
+
}>;
|
|
69
|
+
export type NoiseKind = 'brown' | 'pink' | 'white';
|
|
70
|
+
/** Deep low-end rumble — integrated white noise, the darkest of the three. */
|
|
71
|
+
export declare const brownNoiseScape: Scape;
|
|
72
|
+
/** Flat power per octave — the natural, balanced middle of the three. */
|
|
73
|
+
export declare const pinkNoiseScape: Scape;
|
|
74
|
+
/** Full-spectrum hiss — the brightest of the three, best at masking speech. */
|
|
75
|
+
export declare const whiteNoiseScape: Scape;
|
|
76
|
+
/** For the integration task's default registry — nothing here registers itself. */
|
|
77
|
+
export declare const noiseScapes: Scape[];
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/** Tuning constants — exported for tests and for the integration docs. */
|
|
2
|
+
export const NOISE = Object.freeze({
|
|
3
|
+
/** Loop length of the generated buffer (s). */
|
|
4
|
+
BUFFER_SECONDS: 10,
|
|
5
|
+
/** Crossfade that hides the loop seam (s). */
|
|
6
|
+
LOOP_CROSSFADE_SECONDS: 0.1,
|
|
7
|
+
/** RMS every buffer is normalized to (before the per-colour trim). Sized
|
|
8
|
+
* so even brown's worst crest (~6x) stays under the peak ceiling. */
|
|
9
|
+
TARGET_RMS: 0.15,
|
|
10
|
+
/** Hard ceiling on |sample| after normalization (headroom for the master limiter). */
|
|
11
|
+
PEAK_CEILING: 0.97,
|
|
12
|
+
/** Tilt filter corners (Hz) — inside the master chain's 1100 Hz band. */
|
|
13
|
+
TILT_LOW_HZ: 240,
|
|
14
|
+
TILT_HIGH_HZ: 900,
|
|
15
|
+
/** Total tilt range across the tone param, in dB — ±4 dB per shelf. */
|
|
16
|
+
TILT_RANGE_DB: 8,
|
|
17
|
+
/** Smoothing time constant for live tone changes (s) — no zipper, no click. */
|
|
18
|
+
TILT_TC: 0.12,
|
|
19
|
+
/** Level drift: two slow sines at irrational-ratio periods summed into the
|
|
20
|
+
* level gain, so the wobble itself never repeats. Depths are fractions of
|
|
21
|
+
* the trim (worst case ≈ ±0.8 dB — felt, not heard). */
|
|
22
|
+
DRIFT: Object.freeze({ PERIOD_A_S: 41, PERIOD_B_S: 67, DEPTH_A: 0.06, DEPTH_B: 0.045 }),
|
|
23
|
+
/** Perceptual loudness trims on top of equal-RMS buffers (see header). */
|
|
24
|
+
TRIMS: Object.freeze({ brown: 1.8, pink: 1.0, white: 4.7 })
|
|
25
|
+
});
|
|
26
|
+
/** Brown noise: leaky integrator over white — a −6 dB/oct drift bounded by a
|
|
27
|
+
* ~15 Hz leak, so it can wander but never run away. */
|
|
28
|
+
const BROWN_LEAK = 0.998;
|
|
29
|
+
const BROWN_DRIVE = 0.02;
|
|
30
|
+
/**
|
|
31
|
+
* Paul Kellet's precision pink filter (three-ish poles + correction): flat
|
|
32
|
+
* power per octave, the spectral shape of steady rain and wind.
|
|
33
|
+
*/
|
|
34
|
+
function generateSource(kind, count, rand) {
|
|
35
|
+
// Generate past both ends: a warm-up tail the filters settle in (and the
|
|
36
|
+
// integrator forgets its zero start), discarded before normalization.
|
|
37
|
+
const warm = Math.floor(count * 0.08);
|
|
38
|
+
const total = warm + count;
|
|
39
|
+
const raw = new Float32Array(total);
|
|
40
|
+
let brown = 0;
|
|
41
|
+
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
|
|
42
|
+
for (let i = 0; i < total; i++) {
|
|
43
|
+
const w = rand() * 2 - 1;
|
|
44
|
+
if (kind === 'white') {
|
|
45
|
+
raw[i] = w;
|
|
46
|
+
}
|
|
47
|
+
else if (kind === 'brown') {
|
|
48
|
+
brown += w * BROWN_DRIVE;
|
|
49
|
+
brown *= BROWN_LEAK;
|
|
50
|
+
raw[i] = brown;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
b0 = 0.99886 * b0 + w * 0.0555179;
|
|
54
|
+
b1 = 0.99332 * b1 + w * 0.0750759;
|
|
55
|
+
b2 = 0.969 * b2 + w * 0.153852;
|
|
56
|
+
b3 = 0.8665 * b3 + w * 0.3104856;
|
|
57
|
+
b4 = 0.55 * b4 + w * 0.5329522;
|
|
58
|
+
b5 = -0.7616 * b5 - w * 0.016898;
|
|
59
|
+
raw[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362;
|
|
60
|
+
b6 = w * 0.115926;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return raw.subarray(warm);
|
|
64
|
+
}
|
|
65
|
+
/** Scale in place to a target RMS — the loudness-match foundation. */
|
|
66
|
+
function normalizeToRms(data, target) {
|
|
67
|
+
let sum = 0;
|
|
68
|
+
for (let i = 0; i < data.length; i++)
|
|
69
|
+
sum += data[i] * data[i];
|
|
70
|
+
const rms = Math.sqrt(sum / data.length);
|
|
71
|
+
const k = rms > 0 ? target / rms : 1;
|
|
72
|
+
for (let i = 0; i < data.length; i++)
|
|
73
|
+
data[i] *= k;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Assemble the looped buffer: body = source[data offset .. ], with the final
|
|
77
|
+
* crossfade blending the natural continuation into the audio that follows
|
|
78
|
+
* the wrap (see header). Applies the peak ceiling last.
|
|
79
|
+
*/
|
|
80
|
+
function makeLoopBuffer(ctx, data, fadeSamples, sampleRate) {
|
|
81
|
+
const count = data.length - fadeSamples;
|
|
82
|
+
const buf = ctx.createBuffer(1, count, sampleRate);
|
|
83
|
+
const out = buf.getChannelData(0);
|
|
84
|
+
for (let i = 0; i < count - fadeSamples; i++)
|
|
85
|
+
out[i] = data[fadeSamples + i];
|
|
86
|
+
for (let k = 0; k < fadeSamples; k++) {
|
|
87
|
+
const w = k / fadeSamples;
|
|
88
|
+
out[count - fadeSamples + k] = data[count + k] * (1 - w) + data[k] * w;
|
|
89
|
+
}
|
|
90
|
+
let peak = 0;
|
|
91
|
+
for (let i = 0; i < count; i++)
|
|
92
|
+
peak = Math.max(peak, Math.abs(out[i]));
|
|
93
|
+
if (peak > NOISE.PEAK_CEILING) {
|
|
94
|
+
const k = NOISE.PEAK_CEILING / peak;
|
|
95
|
+
for (let i = 0; i < count; i++)
|
|
96
|
+
out[i] *= k;
|
|
97
|
+
}
|
|
98
|
+
return buf;
|
|
99
|
+
}
|
|
100
|
+
/** The one factory all three colours go through — differences are data, not code. */
|
|
101
|
+
function createNoiseScape(kind, id, label, description) {
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
label,
|
|
105
|
+
group: 'noise',
|
|
106
|
+
description,
|
|
107
|
+
build(ctx) {
|
|
108
|
+
const audio = ctx.audio;
|
|
109
|
+
const rate = audio.sampleRate;
|
|
110
|
+
const fadeSamples = Math.max(1, Math.round(NOISE.LOOP_CROSSFADE_SECONDS * rate));
|
|
111
|
+
const count = Math.round(NOISE.BUFFER_SECONDS * rate);
|
|
112
|
+
const trim = NOISE.TRIMS[kind];
|
|
113
|
+
const source = generateSource(kind, count + fadeSamples, ctx.rand);
|
|
114
|
+
normalizeToRms(source, NOISE.TARGET_RMS);
|
|
115
|
+
const buf = makeLoopBuffer(audio, source, fadeSamples, rate);
|
|
116
|
+
const src = audio.createBufferSource();
|
|
117
|
+
src.buffer = buf;
|
|
118
|
+
src.loop = true;
|
|
119
|
+
// Tone tilt: two shelves around the band the master chain leaves
|
|
120
|
+
// audible. tone 0.5 = neutral (both 0 dB); 0 = darker, 1 = lighter.
|
|
121
|
+
const shelfLow = audio.createBiquadFilter();
|
|
122
|
+
shelfLow.type = 'lowshelf';
|
|
123
|
+
shelfLow.frequency.value = NOISE.TILT_LOW_HZ;
|
|
124
|
+
shelfLow.gain.value = 0;
|
|
125
|
+
const shelfHigh = audio.createBiquadFilter();
|
|
126
|
+
shelfHigh.type = 'highshelf';
|
|
127
|
+
shelfHigh.frequency.value = NOISE.TILT_HIGH_HZ;
|
|
128
|
+
shelfHigh.gain.value = 0;
|
|
129
|
+
// Level: base = loudness trim, wobbled by two slow drift LFOs.
|
|
130
|
+
const level = audio.createGain();
|
|
131
|
+
level.gain.value = trim;
|
|
132
|
+
const driftA = audio.createGain();
|
|
133
|
+
driftA.gain.value = trim * NOISE.DRIFT.DEPTH_A;
|
|
134
|
+
const driftB = audio.createGain();
|
|
135
|
+
driftB.gain.value = trim * NOISE.DRIFT.DEPTH_B;
|
|
136
|
+
const oscA = audio.createOscillator();
|
|
137
|
+
oscA.type = 'sine';
|
|
138
|
+
oscA.frequency.value = 1 / NOISE.DRIFT.PERIOD_A_S;
|
|
139
|
+
const oscB = audio.createOscillator();
|
|
140
|
+
oscB.type = 'sine';
|
|
141
|
+
oscB.frequency.value = 1 / NOISE.DRIFT.PERIOD_B_S;
|
|
142
|
+
src.connect(shelfLow);
|
|
143
|
+
shelfLow.connect(shelfHigh);
|
|
144
|
+
shelfHigh.connect(level);
|
|
145
|
+
level.connect(ctx.input);
|
|
146
|
+
oscA.connect(driftA);
|
|
147
|
+
driftA.connect(level.gain);
|
|
148
|
+
oscB.connect(driftB);
|
|
149
|
+
driftB.connect(level.gain);
|
|
150
|
+
// Everything this build created — disconnected once the fade lands,
|
|
151
|
+
// so a stopped scape leaves no nodes hanging off the master bus.
|
|
152
|
+
const all = [src, shelfLow, shelfHigh, level, driftA, driftB, oscA, oscB];
|
|
153
|
+
let tornDown = false;
|
|
154
|
+
const teardown = () => {
|
|
155
|
+
if (tornDown)
|
|
156
|
+
return;
|
|
157
|
+
tornDown = true;
|
|
158
|
+
for (const n of all) {
|
|
159
|
+
try {
|
|
160
|
+
n.disconnect();
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
/* context already closed */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
return {
|
|
168
|
+
start(at) {
|
|
169
|
+
// Random offset: each play begins somewhere else in the loop.
|
|
170
|
+
const offset = ctx.rand() * (buf.length / rate);
|
|
171
|
+
src.start(at, offset);
|
|
172
|
+
oscA.start(at);
|
|
173
|
+
oscB.start(at);
|
|
174
|
+
// Fires exactly at the stop time on a real context; the fake
|
|
175
|
+
// ignores it and the wall-clock fallback in stop() covers tests.
|
|
176
|
+
src.onended = teardown;
|
|
177
|
+
},
|
|
178
|
+
stop(at, fade) {
|
|
179
|
+
const g = level.gain;
|
|
180
|
+
try {
|
|
181
|
+
g.cancelScheduledValues(at);
|
|
182
|
+
g.setValueAtTime(g.value, at);
|
|
183
|
+
g.linearRampToValueAtTime(0, at + fade);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* context already closed */
|
|
187
|
+
}
|
|
188
|
+
const end = at + fade;
|
|
189
|
+
try {
|
|
190
|
+
src.stop(end);
|
|
191
|
+
oscA.stop(end);
|
|
192
|
+
oscB.stop(end);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
/* never started */
|
|
196
|
+
}
|
|
197
|
+
// Disconnect only after the fade — a wall-clock fallback for
|
|
198
|
+
// environments where onended never fires (and for the fake).
|
|
199
|
+
const ms = Math.max(0, (end - audio.currentTime) * 1000) + 50;
|
|
200
|
+
setTimeout(teardown, ms);
|
|
201
|
+
},
|
|
202
|
+
setParam(key, value) {
|
|
203
|
+
if (key !== 'tone')
|
|
204
|
+
return; // the only live control a noise scape has
|
|
205
|
+
const t = Math.min(1, Math.max(0, value));
|
|
206
|
+
const db = (0.5 - t) * NOISE.TILT_RANGE_DB;
|
|
207
|
+
try {
|
|
208
|
+
shelfLow.gain.setTargetAtTime(db, audio.currentTime, NOISE.TILT_TC);
|
|
209
|
+
shelfHigh.gain.setTargetAtTime(-db, audio.currentTime, NOISE.TILT_TC);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
/* context already closed */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** Deep low-end rumble — integrated white noise, the darkest of the three. */
|
|
220
|
+
export const brownNoiseScape = createNoiseScape('brown', 'noise-brown', 'Brown Noise', 'Deep, soft low-end rumble — the darkest and heaviest of the three, good under everything else.');
|
|
221
|
+
/** Flat power per octave — the natural, balanced middle of the three. */
|
|
222
|
+
export const pinkNoiseScape = createNoiseScape('pink', 'noise-pink', 'Pink Noise', 'Even, balanced hiss like steady rain — the natural-sounding middle of the three.');
|
|
223
|
+
/** Full-spectrum hiss — the brightest of the three, best at masking speech. */
|
|
224
|
+
export const whiteNoiseScape = createNoiseScape('white', 'noise-white', 'White Noise', 'Bright, full-spectrum hiss — the most uniform colour, strongest at masking voices.');
|
|
225
|
+
/** For the integration task's default registry — nothing here registers itself. */
|
|
226
|
+
export const noiseScapes = [brownNoiseScape, pinkNoiseScape, whiteNoiseScape];
|