@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.
Files changed (39) hide show
  1. package/README.md +46 -0
  2. package/dist/ambience/README.md +131 -0
  3. package/dist/ambience/breath.d.ts +22 -0
  4. package/dist/ambience/breath.js +43 -0
  5. package/dist/ambience/default.d.ts +24 -0
  6. package/dist/ambience/default.js +34 -0
  7. package/dist/ambience/engine.d.ts +21 -0
  8. package/dist/ambience/engine.js +313 -0
  9. package/dist/ambience/index.d.ts +23 -0
  10. package/dist/ambience/index.js +27 -0
  11. package/dist/ambience/master.d.ts +49 -0
  12. package/dist/ambience/master.js +83 -0
  13. package/dist/ambience/persist.d.ts +11 -0
  14. package/dist/ambience/persist.js +24 -0
  15. package/dist/ambience/registry.d.ts +11 -0
  16. package/dist/ambience/registry.js +16 -0
  17. package/dist/ambience/scapes/focus.d.ts +55 -0
  18. package/dist/ambience/scapes/focus.js +245 -0
  19. package/dist/ambience/scapes/hibiscus.d.ts +103 -0
  20. package/dist/ambience/scapes/hibiscus.js +345 -0
  21. package/dist/ambience/scapes/nature.d.ts +36 -0
  22. package/dist/ambience/scapes/nature.js +325 -0
  23. package/dist/ambience/scapes/noise.d.ts +77 -0
  24. package/dist/ambience/scapes/noise.js +226 -0
  25. package/dist/ambience/scapes/playlist.d.ts +129 -0
  26. package/dist/ambience/scapes/playlist.js +569 -0
  27. package/dist/ambience/types.d.ts +80 -0
  28. package/dist/ambience/types.js +9 -0
  29. package/dist/components/AmbienceToggle.svelte +599 -0
  30. package/dist/components/AmbienceToggle.svelte.d.ts +74 -0
  31. package/dist/components/VideoPlayer.svelte +73 -6
  32. package/dist/components/VideoPlayer.svelte.d.ts +9 -0
  33. package/dist/index.d.ts +6 -0
  34. package/dist/index.js +9 -0
  35. package/dist/video/keys.d.ts +4 -3
  36. package/dist/video/keys.js +5 -4
  37. package/dist/video/settings/remembered.d.ts +9 -0
  38. package/dist/video/settings/remembered.js +9 -0
  39. package/package.json +6 -1
@@ -0,0 +1,345 @@
1
+ export const HIBISCUS = Object.freeze({
2
+ /** Drone partials: F2 + fifth (C3), tenth (A3), twelfth (C4). */
3
+ DRONE_HZ: [87.31, 130.81, 220.0, 261.63],
4
+ /** Relative level of each drone partial, brightest at the bottom. */
5
+ DRONE_GAINS: [1, 0.55, 0.32, 0.2],
6
+ /** Stereo pair detune (± cents) — the "width" of the pad. */
7
+ DETUNE_CENTS: 7,
8
+ /** Where the detuned pairs sit in the stereo field. */
9
+ PAD_PAN: 0.35,
10
+ /** Soft wave: y = x + SOFT_3RD·x³ + SOFT_5TH·x⁵, normalised — odd
11
+ * symmetry, so a sine keeps its fundamental and gains a little 3rd and
12
+ * 5th harmonic and nothing else. */
13
+ SOFT_3RD: 0.16,
14
+ SOFT_5TH: 0.05,
15
+ /** Pad lowpass: resting frequency and the in-breath lift (+Hz at peak). */
16
+ PAD_BASE_HZ: 420,
17
+ PAD_BREATH_HZ: 180,
18
+ PAD_Q: 0.6,
19
+ PAD_LEVEL: 0.15,
20
+ /** How much of each section's level rides the breath (0..1 — the level
21
+ * sinks to (1 - depth)·level at the bottom of the exhale). */
22
+ PAD_BREATH_DEPTH: 0.25,
23
+ FLOAT_LEVEL: 0.09,
24
+ FLOAT_BREATH_DEPTH: 0.2,
25
+ NOISE_LEVEL: 0.01,
26
+ NOISE_BREATH_DEPTH: 0.25,
27
+ /** Floating tones: F4 → A4 → C5 → A4 (→ F4 …). */
28
+ TONES_HZ: [349.23, 440.0, 523.25, 440.0],
29
+ TONE_HOLD_S: 70,
30
+ TONE_JITTER_S: 8,
31
+ TONE_FADE_S: 16,
32
+ /** Chimes: the current chord an octave up (F5, A5, C6), first 3 partials. */
33
+ CHIME_HZ: [698.46, 880.0, 1046.5],
34
+ CHIME_PARTIAL_GAINS: [1, 0.35, 0.18],
35
+ CHIME_MIN_GAP_S: 35,
36
+ CHIME_MAX_GAP_S: 65,
37
+ CHIME_ATTACK_S: 2.5,
38
+ CHIME_TAU_S: 3.5,
39
+ /** Oscillators stop after this many decay taus. */
40
+ CHIME_TAILS: 7,
41
+ CHIME_LEVEL: 0.05,
42
+ CHIME_PAN_SPREAD: 0.8,
43
+ /** Air noise. */
44
+ AIR_SECONDS: 4,
45
+ AIR_HP_HZ: 80,
46
+ AIR_LP_HZ: 420,
47
+ /** Internal entrance fade — the engine bus fade handles crossfades; this
48
+ * just keeps a fresh build from stepping in at full level. */
49
+ ENTRANCE_S: 6,
50
+ /** Smoothing tau for the per-tick breath modulation. */
51
+ MOD_TAU_S: 0.45
52
+ });
53
+ /** A soft wave curve: fundamental plus a little 3rd and 5th, nothing else. */
54
+ export function softWaveCurve(n = 1024) {
55
+ const { SOFT_3RD: a, SOFT_5TH: b } = HIBISCUS;
56
+ const curve = new Float32Array(n);
57
+ let max = 0;
58
+ for (let i = 0; i < n; i++) {
59
+ const x = (i / (n - 1)) * 2 - 1;
60
+ const y = x + a * x ** 3 + b * x ** 5;
61
+ curve[i] = y;
62
+ max = Math.max(max, Math.abs(y));
63
+ }
64
+ for (let i = 0; i < n; i++)
65
+ curve[i] /= max;
66
+ return curve;
67
+ }
68
+ /** Pink noise (Paul Kellet's filter), one channel's worth, in place. */
69
+ function fillPink(data, rand) {
70
+ let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
71
+ for (let i = 0; i < data.length; i++) {
72
+ const w = rand() * 2 - 1;
73
+ b0 = 0.99886 * b0 + w * 0.0555179;
74
+ b1 = 0.99332 * b1 + w * 0.0750759;
75
+ b2 = 0.969 * b2 + w * 0.1538522;
76
+ b3 = 0.8665 * b3 + w * 0.3104856;
77
+ b4 = 0.55 * b4 + w * 0.5329522;
78
+ b5 = -0.7616 * b5 - w * 0.016898;
79
+ data[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + w * 0.5362) * 0.11;
80
+ b6 = w * 0.115926;
81
+ }
82
+ }
83
+ /** A stereo panner when the context has one, a plain gain otherwise (the
84
+ * test fake — and any context too old to pan — falls back to mono). */
85
+ function makePan(audio, pan) {
86
+ if (typeof audio.createStereoPanner === 'function') {
87
+ const p = audio.createStereoPanner();
88
+ p.pan.value = pan;
89
+ return p;
90
+ }
91
+ return audio.createGain();
92
+ }
93
+ export const hibiscusScape = {
94
+ id: 'hibiscus',
95
+ label: 'Hibiscus',
96
+ group: 'meditation',
97
+ description: 'Warm F-major drone, floating tones and soft chimes — six breaths a minute.',
98
+ build(scapeCtx) {
99
+ const { audio, rand } = scapeCtx;
100
+ const H = HIBISCUS;
101
+ /** Every node this build created — the final disconnect walks it. */
102
+ const allNodes = [];
103
+ const keep = (node) => {
104
+ allNodes.push(node);
105
+ return node;
106
+ };
107
+ function disconnectAll(nodes) {
108
+ for (const n of nodes) {
109
+ try {
110
+ n.disconnect();
111
+ }
112
+ catch {
113
+ /* already gone */
114
+ }
115
+ }
116
+ }
117
+ // ---- pad ------------------------------------------------------------
118
+ const padFilter = keep(audio.createBiquadFilter());
119
+ padFilter.type = 'lowpass';
120
+ padFilter.frequency.value = H.PAD_BASE_HZ;
121
+ padFilter.Q.value = H.PAD_Q;
122
+ const padGain = keep(audio.createGain());
123
+ padGain.gain.value = 0;
124
+ padFilter.connect(padGain);
125
+ padGain.connect(scapeCtx.input);
126
+ const padOscs = [];
127
+ H.DRONE_HZ.forEach((hz, i) => {
128
+ const shaper = keep(audio.createWaveShaper());
129
+ shaper.curve = softWaveCurve();
130
+ const partial = keep(audio.createGain());
131
+ partial.gain.value = H.DRONE_GAINS[i];
132
+ shaper.connect(partial);
133
+ partial.connect(padFilter);
134
+ for (const side of [-1, 1]) {
135
+ const osc = keep(audio.createOscillator());
136
+ osc.type = 'sine';
137
+ osc.frequency.value = hz;
138
+ osc.detune.value = side * H.DETUNE_CENTS;
139
+ const pan = keep(makePan(audio, side * H.PAD_PAN));
140
+ osc.connect(pan).connect(shaper);
141
+ padOscs.push(osc);
142
+ }
143
+ });
144
+ // ---- floating pair ----------------------------------------------------
145
+ const floatGain = keep(audio.createGain());
146
+ floatGain.gain.value = 0;
147
+ floatGain.connect(scapeCtx.input);
148
+ const voices = [0, 1].map(() => {
149
+ const osc = keep(audio.createOscillator());
150
+ osc.type = 'sine';
151
+ const gain = keep(audio.createGain());
152
+ gain.gain.value = 0;
153
+ osc.connect(gain);
154
+ gain.connect(floatGain);
155
+ return { osc, gain };
156
+ });
157
+ // ---- air --------------------------------------------------------------
158
+ const rate = audio.sampleRate;
159
+ const airBuffer = audio.createBuffer(2, Math.floor(rate * H.AIR_SECONDS), rate);
160
+ for (let ch = 0; ch < 2; ch++)
161
+ fillPink(airBuffer.getChannelData(ch), rand);
162
+ const airSrc = keep(audio.createBufferSource());
163
+ airSrc.buffer = airBuffer;
164
+ airSrc.loop = true;
165
+ const airHp = keep(audio.createBiquadFilter());
166
+ airHp.type = 'highpass';
167
+ airHp.frequency.value = H.AIR_HP_HZ;
168
+ const airLp = keep(audio.createBiquadFilter());
169
+ airLp.type = 'lowpass';
170
+ airLp.frequency.value = H.AIR_LP_HZ;
171
+ const airGain = keep(audio.createGain());
172
+ airGain.gain.value = 0;
173
+ airSrc.connect(airHp);
174
+ airHp.connect(airLp);
175
+ airLp.connect(airGain);
176
+ airGain.connect(scapeCtx.input);
177
+ // ---- chime bus ----------------------------------------------------------
178
+ const chimeGain = keep(audio.createGain());
179
+ chimeGain.gain.value = 1;
180
+ chimeGain.connect(scapeCtx.input);
181
+ // ---- scheduler state ----------------------------------------------------
182
+ let toneIdx = 1; // voice 0 starts on TONES[0]; the next drift goes to A4
183
+ let activeVoice = 0;
184
+ let nextToneAt = Number.POSITIVE_INFINITY;
185
+ let nextChimeAt = Number.POSITIVE_INFINITY;
186
+ let chimesOn = true;
187
+ let entranceUntil = 0;
188
+ let unsub = null;
189
+ let activeChimes = [];
190
+ const gap = () => H.CHIME_MIN_GAP_S + rand() * (H.CHIME_MAX_GAP_S - H.CHIME_MIN_GAP_S);
191
+ function breathMul(value, depth) {
192
+ return 1 - depth * (1 - value);
193
+ }
194
+ function rampTo(param, v, at, dur) {
195
+ param.cancelScheduledValues(at);
196
+ param.setValueAtTime(param.value, at);
197
+ param.linearRampToValueAtTime(v, at + dur);
198
+ }
199
+ /** Hand the drift to the other voice: set its tone, cross-fade the two
200
+ * gains over TONE_FADE_S, re-arm the next drift ~TONE_HOLD_S out. */
201
+ function driftTone(now) {
202
+ const freq = H.TONES_HZ[toneIdx % H.TONES_HZ.length];
203
+ toneIdx++;
204
+ const incoming = voices[1 - activeVoice];
205
+ const outgoing = voices[activeVoice];
206
+ incoming.osc.frequency.setValueAtTime(freq, now);
207
+ rampTo(incoming.gain.gain, 1, now, H.TONE_FADE_S);
208
+ rampTo(outgoing.gain.gain, 0, now, H.TONE_FADE_S);
209
+ activeVoice = 1 - activeVoice;
210
+ nextToneAt = now + H.TONE_HOLD_S + (rand() - 0.5) * 2 * H.TONE_JITTER_S;
211
+ }
212
+ /** One soft rounded chime: 3 partials of a chord tone, a slow swell,
213
+ * then a long exponential decay (setTargetAtTime), random pan. */
214
+ function chime(now) {
215
+ const f = H.CHIME_HZ[Math.floor(rand() * H.CHIME_HZ.length)];
216
+ const pan = keep(makePan(audio, (rand() * 2 - 1) * H.CHIME_PAN_SPREAD));
217
+ pan.connect(chimeGain);
218
+ const env = keep(audio.createGain());
219
+ env.gain.value = 0;
220
+ env.connect(pan);
221
+ const oscs = [];
222
+ const pgs = [];
223
+ H.CHIME_PARTIAL_GAINS.forEach((g, i) => {
224
+ const osc = keep(audio.createOscillator());
225
+ osc.type = 'sine';
226
+ osc.frequency.value = f * (i + 1);
227
+ const pg = keep(audio.createGain());
228
+ pg.gain.value = g;
229
+ osc.connect(pg);
230
+ pg.connect(env);
231
+ oscs.push(osc);
232
+ pgs.push(pg);
233
+ });
234
+ const tail = H.CHIME_ATTACK_S + H.CHIME_TAILS * H.CHIME_TAU_S;
235
+ for (const osc of oscs) {
236
+ osc.start(now);
237
+ osc.stop(now + tail);
238
+ }
239
+ // A swell, not a strike: slow attack, then exponential decay.
240
+ env.gain.setValueAtTime(0, now);
241
+ env.gain.linearRampToValueAtTime(H.CHIME_LEVEL, now + H.CHIME_ATTACK_S);
242
+ env.gain.setTargetAtTime(0.0001, now + H.CHIME_ATTACK_S, H.CHIME_TAU_S);
243
+ // Self-cleaning: disconnect on a timer once the tail is done — never
244
+ // on a lookahead cb (a stopped instance's cbs get retired mid-fade).
245
+ const nodes = [pan, env, ...pgs, ...oscs];
246
+ const timer = setTimeout(() => disconnectAll(nodes), (tail + 0.05) * 1000);
247
+ activeChimes.push({ oscs, nodes, until: now + tail, timer });
248
+ nextChimeAt = now + gap();
249
+ }
250
+ /** The ONE per-tick callback: breath modulation, chime retirement,
251
+ * tone drift, chime scheduling. Runs every engine tick (~250 ms). */
252
+ function onTick(now) {
253
+ const b = scapeCtx.breath.state;
254
+ // The pad filter opens ~180 Hz on the in-breath; the three section
255
+ // levels swell with it. During the entrance the ramps own the
256
+ // levels (the filter is silent-safe and starts modulating at once).
257
+ padFilter.frequency.setTargetAtTime(H.PAD_BASE_HZ + H.PAD_BREATH_HZ * b.value, now, H.MOD_TAU_S);
258
+ if (now >= entranceUntil) {
259
+ padGain.gain.setTargetAtTime(H.PAD_LEVEL * breathMul(b.value, H.PAD_BREATH_DEPTH), now, H.MOD_TAU_S);
260
+ floatGain.gain.setTargetAtTime(H.FLOAT_LEVEL * breathMul(b.value, H.FLOAT_BREATH_DEPTH), now, H.MOD_TAU_S);
261
+ airGain.gain.setTargetAtTime(H.NOISE_LEVEL * breathMul(b.value, H.NOISE_BREATH_DEPTH), now, H.MOD_TAU_S);
262
+ }
263
+ // Bookkeeping only — no disconnects here (teardown never rides the
264
+ // lookahead queue). Chimes past their tail prune themselves; their
265
+ // nodes were already disconnected by their own timers.
266
+ if (activeChimes.length) {
267
+ activeChimes = activeChimes.filter((c) => now < c.until);
268
+ }
269
+ if (now >= nextToneAt)
270
+ driftTone(now);
271
+ if (chimesOn && now >= nextChimeAt)
272
+ chime(now);
273
+ }
274
+ function tryStop(node, at) {
275
+ try {
276
+ node.stop(at);
277
+ }
278
+ catch {
279
+ /* already stopped */
280
+ }
281
+ }
282
+ return {
283
+ start(at) {
284
+ // Entrance: sections rise from silence over ENTRANCE_S; breath
285
+ // modulation of the levels takes over once they arrive.
286
+ entranceUntil = at + H.ENTRANCE_S;
287
+ rampTo(padGain.gain, H.PAD_LEVEL, at, H.ENTRANCE_S);
288
+ rampTo(floatGain.gain, H.FLOAT_LEVEL, at, H.ENTRANCE_S);
289
+ rampTo(airGain.gain, H.NOISE_LEVEL, at, H.ENTRANCE_S);
290
+ // Voice 0 holds the first tone; voice 1 waits silent.
291
+ voices[0].osc.frequency.setValueAtTime(H.TONES_HZ[0], at);
292
+ voices[0].gain.gain.setValueAtTime(1, at);
293
+ nextToneAt = at + H.TONE_HOLD_S + (rand() - 0.5) * 2 * H.TONE_JITTER_S;
294
+ nextChimeAt = at + gap();
295
+ for (const osc of padOscs)
296
+ osc.start(at);
297
+ voices[0].osc.start(at);
298
+ voices[1].osc.start(at);
299
+ // Random loop offset: the air never audibly repeats.
300
+ airSrc.start(at, rand() * H.AIR_SECONDS);
301
+ unsub = scapeCtx.lookahead(onTick);
302
+ },
303
+ stop(at, fade) {
304
+ unsub?.();
305
+ unsub = null;
306
+ const end = at + fade;
307
+ for (const g of [padGain, floatGain, airGain, chimeGain, voices[0].gain, voices[1].gain]) {
308
+ rampTo(g.gain, 0, at, fade);
309
+ }
310
+ for (const osc of padOscs)
311
+ tryStop(osc, end);
312
+ tryStop(voices[0].osc, end);
313
+ tryStop(voices[1].osc, end);
314
+ tryStop(airSrc, end);
315
+ for (const c of activeChimes) {
316
+ for (const osc of c.oscs)
317
+ tryStop(osc, end);
318
+ clearTimeout(c.timer); // the final disconnect covers them
319
+ }
320
+ // Final teardown OFF the lookahead path: the engine retires a
321
+ // stopped instance's cbs at fade·1000 + 250 ms, so anything
322
+ // waiting for one more tick can be dropped mid-fade and leave
323
+ // nodes connected to the shared input. Disconnect on a timer
324
+ // instead — after the gains have finished ramping to 0.
325
+ setTimeout(() => disconnectAll(allNodes), fade * 1000 + 50);
326
+ },
327
+ setParam(key, value) {
328
+ if (key === 'breathsPerMinute') {
329
+ // The SHARED clock — every sounding scape breathes together.
330
+ scapeCtx.breath.bpm = Math.min(15, Math.max(1, value));
331
+ }
332
+ else if (key === 'chimes') {
333
+ chimesOn = value !== 0;
334
+ if (chimesOn) {
335
+ // A fresh full gap, not an instant chime on re-enable.
336
+ nextChimeAt = audio.currentTime + gap();
337
+ }
338
+ else {
339
+ nextChimeAt = Number.POSITIVE_INFINITY;
340
+ }
341
+ }
342
+ }
343
+ };
344
+ }
345
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Nature scapes — rain and waves (phase 2, jatui-5y1y7).
3
+ *
4
+ * Two scapes against the Scape contract in ../types.ts. Nothing here is
5
+ * registered globally — the integration task wires the default registry.
6
+ *
7
+ * rain a filtered noise bed plus sparse randomized droplet transients
8
+ * (short bandpassed noise bursts with varied pitch, pan and level,
9
+ * scheduled on the engine's lookahead tick; `density` sets how busy
10
+ * the patter is)
11
+ * waves noise through a slowly swelling bandpass with a scheduler-driven
12
+ * level LFO — randomized 8–12 s swell periods, each swell rising
13
+ * over ~45% of its period and decaying into a soft wash tail
14
+ * (setTargetAtTime with a multi-second tau)
15
+ *
16
+ * CPU rules (task brief): no per-sample JS — everything is AudioBufferSource
17
+ * loops and native filter/gain nodes. The noise buffer is generated ONCE per
18
+ * AudioContext (WeakMap cache) and every user of it — bed and droplets alike
19
+ * — loops or slices it with a randomized start offset, so the bed never
20
+ * audibly repeats and droplets never land on the same grain twice.
21
+ *
22
+ * Cleanup contract (per WideFrost's contract note): the engine RETIRES a
23
+ * stopped instance's lookahead callbacks at fade*1000 + 250 ms — so teardown
24
+ * must NOT wait for a lookahead tick (it would race the retire and can be
25
+ * dropped, leaving nodes wired to the shared input). stop(at, fade) schedules
26
+ * source.stop(at+fade) on the AUDIO clock and disconnects every node on a
27
+ * wall-clock setTimeout(fade*1000 + 50) — comfortably ahead of the retire.
28
+ * Lookahead ticks only ever schedule sound (and prune long-play droplets).
29
+ *
30
+ * Background tabs: browsers throttle setInterval (down to ~1/min) while the
31
+ * audio clock keeps running, so both schedulers resync after a tick gap
32
+ * instead of firing every missed event as a burst when the tab returns.
33
+ */
34
+ import type { Scape } from '../types';
35
+ export declare const rain: Scape;
36
+ export declare const waves: Scape;