@joewinke/jatui 0.46.0 → 0.47.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 +1 -1
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Ambience engine — public entry. Headless by design: hosts (e.g.
3
+ * AmbienceToggle.svelte) import createAmbience and wire their own UI.
4
+ *
5
+ * @example
6
+ * import { createAmbience, createDefaultAmbience } from '../../../dist/ambience'; // once integration lands
7
+ * const ambience = createDefaultAmbience(); // hibiscus, focus, noise, rain, waves pre-registered
8
+ * ambience.register(myScape);
9
+ * // inside a click handler:
10
+ * await ambience.play('rain');
11
+ */
12
+ export { createAmbience, TIMING } from './engine';
13
+ export { createDefaultAmbience, DEFAULT_SCAPES } from './default';
14
+ // The shipped scapes — hosts that want a hand-picked registry import them
15
+ // individually instead of using createDefaultAmbience().
16
+ export { hibiscusScape } from './scapes/hibiscus';
17
+ export { focusScape } from './scapes/focus';
18
+ export { noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape } from './scapes/noise';
19
+ export { rain, waves } from './scapes/nature';
20
+ // Playlist scape (phase 2) — host-supplied manifest of public-domain
21
+ // recordings. NOTE: never auto-registered in DEFAULT_SCAPES — a playlist is
22
+ // per-host (its manifest URL and licensing claims are the host's); build one
23
+ // with createPlaylistScape() and engine.register() it yourself.
24
+ export { createPlaylistScape } from './scapes/playlist';
25
+ export { BreathClock, BREATHS_PER_MINUTE } from './breath';
26
+ export { MASTER } from './master';
27
+ export { ScapeRegistry } from './registry';
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The shared master chain — built ONCE per engine AudioContext, fed by every
3
+ * scape through one `input` gain. Written fresh from the hibiscus recipe
4
+ * (generative Web Audio, no files, no libraries); the node vocabulary mirrors
5
+ * src/lib/video/audio but the numbers and purpose are ambience's own.
6
+ *
7
+ * input (bus gain)
8
+ * ─► peaking EQ (+2.5 dB @ 220 Hz — the "warm wood" tilt)
9
+ * ─► lowpass (1100 Hz — ambience lives below the hiss band)
10
+ * ─► dry ──────────────────────────────┐
11
+ * ─► convolver (≈6 s decaying-noise ├─► soft-clip limiter ─► master ─► volume ─► destination
12
+ * impulse) ─► wet (50%) ────────────┘
13
+ *
14
+ * The limiter is a WaveShaper with a tanh knee: bit-transparent below −6 dBFS,
15
+ * progressively softer above, so a loud scape can never clip the mix. The
16
+ * convolution reverb is synthesized (decaying noise impulse) — no IR file.
17
+ *
18
+ * Everything the host moves (bus fades, volume ramps) is scheduled on the two
19
+ * gain params, never `.value` writes mid-stream — a `.value` write clicks.
20
+ */
21
+ import type { AudioContextLike } from './types';
22
+ export declare const MASTER: Readonly<{
23
+ EQ_HZ: 220;
24
+ EQ_GAIN_DB: 2.5;
25
+ EQ_Q: 0.8;
26
+ LOWPASS_HZ: 1100;
27
+ /** Reverb send: 50% wet. */
28
+ WET: 0.5;
29
+ /** Transparent below this, tanh knee above (linear gain ≈ 0.501). */
30
+ KNEE: 0.501;
31
+ /** Impulse length and decay for the synthesized reverb. */
32
+ IR_SECONDS: 6;
33
+ IR_DECAY: 3.5;
34
+ }>;
35
+ /** Build the graph; returns the entry node and the two params the engine drives. */
36
+ export interface MasterChain {
37
+ input: AudioNode;
38
+ bus: GainNode;
39
+ volume: GainNode;
40
+ dispose(): void;
41
+ }
42
+ export declare function createMasterChain(ctx: AudioContextLike): MasterChain;
43
+ /**
44
+ * tanh soft clip. Below the knee (≈ −6 dBFS) the curve is the identity to
45
+ * within float noise — the limiter only exists for the peaks.
46
+ */
47
+ export declare function softClipCurve(n?: number): Float32Array;
48
+ /** Decaying-noise impulse response — a ~6 s synthesized hall, no IR file. */
49
+ export declare function makeImpulse(ctx: AudioContextLike, seconds: number, decay: number): AudioBuffer;
@@ -0,0 +1,83 @@
1
+ export const MASTER = Object.freeze({
2
+ EQ_HZ: 220,
3
+ EQ_GAIN_DB: 2.5,
4
+ EQ_Q: 0.8,
5
+ LOWPASS_HZ: 1100,
6
+ /** Reverb send: 50% wet. */
7
+ WET: 0.5,
8
+ /** Transparent below this, tanh knee above (linear gain ≈ 0.501). */
9
+ KNEE: 0.501,
10
+ /** Impulse length and decay for the synthesized reverb. */
11
+ IR_SECONDS: 6,
12
+ IR_DECAY: 3.5
13
+ });
14
+ export function createMasterChain(ctx) {
15
+ const bus = ctx.createGain();
16
+ const eq = ctx.createBiquadFilter();
17
+ eq.type = 'peaking';
18
+ eq.frequency.value = MASTER.EQ_HZ;
19
+ eq.gain.value = MASTER.EQ_GAIN_DB;
20
+ eq.Q.value = MASTER.EQ_Q;
21
+ const lowpass = ctx.createBiquadFilter();
22
+ lowpass.type = 'lowpass';
23
+ lowpass.frequency.value = MASTER.LOWPASS_HZ;
24
+ const shaper = ctx.createWaveShaper();
25
+ shaper.curve = softClipCurve();
26
+ const convolver = ctx.createConvolver();
27
+ convolver.buffer = makeImpulse(ctx, MASTER.IR_SECONDS, MASTER.IR_DECAY);
28
+ const wet = ctx.createGain();
29
+ wet.gain.value = MASTER.WET;
30
+ const master = ctx.createGain();
31
+ const volume = ctx.createGain();
32
+ bus.connect(eq);
33
+ eq.connect(lowpass);
34
+ lowpass.connect(shaper); // dry
35
+ lowpass.connect(convolver);
36
+ convolver.connect(wet);
37
+ wet.connect(shaper); // wet send sums into the limiter
38
+ shaper.connect(master);
39
+ master.connect(volume);
40
+ volume.connect(ctx.destination);
41
+ return {
42
+ input: bus,
43
+ bus,
44
+ volume,
45
+ dispose() {
46
+ for (const n of [bus, eq, lowpass, shaper, convolver, wet, master, volume]) {
47
+ try {
48
+ n.disconnect();
49
+ }
50
+ catch {
51
+ /* already torn down */
52
+ }
53
+ }
54
+ }
55
+ };
56
+ }
57
+ /**
58
+ * tanh soft clip. Below the knee (≈ −6 dBFS) the curve is the identity to
59
+ * within float noise — the limiter only exists for the peaks.
60
+ */
61
+ export function softClipCurve(n = 2048) {
62
+ const curve = new Float32Array(n);
63
+ for (let i = 0; i < n; i++) {
64
+ const x = (i / (n - 1)) * 2 - 1;
65
+ const a = Math.abs(x);
66
+ curve[i] =
67
+ a <= MASTER.KNEE ? x : Math.tanh((x - Math.sign(x) * MASTER.KNEE) / (1 - MASTER.KNEE)) * (1 - MASTER.KNEE) + Math.sign(x) * MASTER.KNEE;
68
+ }
69
+ return curve;
70
+ }
71
+ /** Decaying-noise impulse response — a ~6 s synthesized hall, no IR file. */
72
+ export function makeImpulse(ctx, seconds, decay) {
73
+ const rate = ctx.sampleRate;
74
+ const len = Math.max(1, Math.floor(rate * seconds));
75
+ const buf = ctx.createBuffer(2, len, rate);
76
+ for (let ch = 0; ch < 2; ch++) {
77
+ const data = buf.getChannelData(ch);
78
+ for (let i = 0; i < len; i++) {
79
+ data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
80
+ }
81
+ }
82
+ return buf;
83
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * localStorage persistence, try/catch everywhere: private mode, storage
3
+ * disabled and SSR all degrade to "no memory" and the engine still works.
4
+ * Restoring NEVER plays — it only preselects.
5
+ */
6
+ export interface PersistedAmbience {
7
+ volume?: number;
8
+ lastScape?: string;
9
+ }
10
+ export declare function loadPersisted(key: string): PersistedAmbience;
11
+ export declare function savePersisted(key: string, value: PersistedAmbience): void;
@@ -0,0 +1,24 @@
1
+ export function loadPersisted(key) {
2
+ try {
3
+ const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null;
4
+ if (!raw)
5
+ return {};
6
+ const parsed = JSON.parse(raw);
7
+ return typeof parsed === 'object' && parsed !== null
8
+ ? parsed
9
+ : {};
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ export function savePersisted(key, value) {
16
+ try {
17
+ if (typeof localStorage === 'undefined')
18
+ return;
19
+ localStorage.setItem(key, JSON.stringify(value));
20
+ }
21
+ catch {
22
+ /* private mode / quota — persistence is best-effort */
23
+ }
24
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The scape registry — a named map with duplicate-id protection. list()
3
+ * returns registration order (stable for menus).
4
+ */
5
+ import type { Scape } from './types';
6
+ export declare class ScapeRegistry {
7
+ #private;
8
+ register(scape: Scape): void;
9
+ get(id: string): Scape | undefined;
10
+ list(): Scape[];
11
+ }
@@ -0,0 +1,16 @@
1
+ export class ScapeRegistry {
2
+ #scapes = new Map();
3
+ register(scape) {
4
+ if (!scape?.id)
5
+ throw new Error('Scape must have an id');
6
+ if (this.#scapes.has(scape.id))
7
+ throw new Error(`Scape id already registered: ${scape.id}`);
8
+ this.#scapes.set(scape.id, scape);
9
+ }
10
+ get(id) {
11
+ return this.#scapes.get(id);
12
+ }
13
+ list() {
14
+ return [...this.#scapes.values()];
15
+ }
16
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The focus scape — a neutral, low-distraction drone for work. Tuned for
3
+ * concentration, not meditation: a static open-fifth sine drone (A2 + E3 +
4
+ * a quiet A3 octave, just intonation — no thirds, no melodic movement) over
5
+ * a slightly fuller brown/pink noise bed. NO chimes by default; the only
6
+ * event is an optional soft bell a host can trigger for pomodoro-style
7
+ * boundaries via `setParam('bell', true)`.
8
+ *
9
+ * No binaural beats, no 40 Hz gimmicks, no per-sample JS: the noise buffers
10
+ * are generated once per build and looped as native AudioBufferSources with
11
+ * randomized start offsets (so they never audibly repeat from the same
12
+ * point), and the only modulation is the shared breath clock nudging the
13
+ * drone's lowpass a few percent — subtle and slow, not a "breathing" sound.
14
+ *
15
+ * Built against the Scape contract (src/lib/ambience/types.ts) only; the
16
+ * integration task registers it. Everything connects into ctx.input — the
17
+ * shared master chain owns EQ, reverb, limiting and volume after that.
18
+ */
19
+ import type { Scape } from "../types";
20
+ export declare const FOCUS: Readonly<{
21
+ /** Open-fifth drone: A2 root, E3 fifth (110 × 1.5), A3 octave. */
22
+ ROOT_HZ: 110;
23
+ FIFTH_HZ: 165;
24
+ OCTAVE_HZ: 220;
25
+ /** Per-voice drone gains (sum ≈ 0.135 — present, not foreground). */
26
+ ROOT_GAIN: 0.07;
27
+ FIFTH_GAIN: 0.05;
28
+ OCTAVE_GAIN: 0.018;
29
+ /** Noise bed: the brown/pink mix, lowpassed to stay dark and behind. */
30
+ NOISE_SECONDS: 8;
31
+ BROWN_GAIN: 0.055;
32
+ PINK_GAIN: 0.04;
33
+ NOISE_LOWPASS_HZ: 500;
34
+ /** Drone lowpass the breath nudges. */
35
+ DRONE_LOWPASS_HZ: 900;
36
+ /** Breath modulation: shallow (±8%) and slow (2.5 s time constant) —
37
+ * felt as "the room moves", never heard as a wobble. */
38
+ BREATH_DEPTH: 0.08;
39
+ BREATH_TAU: 2.5;
40
+ /** Scape output level and the fade the instance applies itself (the
41
+ * engine fades the bus on crossfades; the instance fade keeps the very
42
+ * first play click-free). */
43
+ LEVEL: 1;
44
+ FADE_IN: 3;
45
+ /** Optional soft bell (pomodoro boundaries): C5 fundamental + a faint
46
+ * inharmonic partial, ~5 s decay, quiet enough to never jar. */
47
+ BELL_HZ: 523.25;
48
+ BELL_PARTIAL_HZ: 1255;
49
+ BELL_GAIN: 0.05;
50
+ BELL_PARTIAL_GAIN: 0.018;
51
+ BELL_TAU: 1.2;
52
+ BELL_PARTIAL_TAU: 0.5;
53
+ BELL_SECONDS: 5;
54
+ }>;
55
+ export declare const focusScape: Scape;
@@ -0,0 +1,245 @@
1
+ export const FOCUS = Object.freeze({
2
+ /** Open-fifth drone: A2 root, E3 fifth (110 × 1.5), A3 octave. */
3
+ ROOT_HZ: 110,
4
+ FIFTH_HZ: 165,
5
+ OCTAVE_HZ: 220,
6
+ /** Per-voice drone gains (sum ≈ 0.135 — present, not foreground). */
7
+ ROOT_GAIN: 0.07,
8
+ FIFTH_GAIN: 0.05,
9
+ OCTAVE_GAIN: 0.018,
10
+ /** Noise bed: the brown/pink mix, lowpassed to stay dark and behind. */
11
+ NOISE_SECONDS: 8,
12
+ BROWN_GAIN: 0.055,
13
+ PINK_GAIN: 0.04,
14
+ NOISE_LOWPASS_HZ: 500,
15
+ /** Drone lowpass the breath nudges. */
16
+ DRONE_LOWPASS_HZ: 900,
17
+ /** Breath modulation: shallow (±8%) and slow (2.5 s time constant) —
18
+ * felt as "the room moves", never heard as a wobble. */
19
+ BREATH_DEPTH: 0.08,
20
+ BREATH_TAU: 2.5,
21
+ /** Scape output level and the fade the instance applies itself (the
22
+ * engine fades the bus on crossfades; the instance fade keeps the very
23
+ * first play click-free). */
24
+ LEVEL: 1,
25
+ FADE_IN: 3,
26
+ /** Optional soft bell (pomodoro boundaries): C5 fundamental + a faint
27
+ * inharmonic partial, ~5 s decay, quiet enough to never jar. */
28
+ BELL_HZ: 523.25,
29
+ BELL_PARTIAL_HZ: 1255, // ≈ C5 × 2.4 — bell-like inharmonic overtone
30
+ BELL_GAIN: 0.05,
31
+ BELL_PARTIAL_GAIN: 0.018,
32
+ BELL_TAU: 1.2,
33
+ BELL_PARTIAL_TAU: 0.5,
34
+ BELL_SECONDS: 5,
35
+ });
36
+ /** Normalize a mono buffer to a sane peak so the mix never relies on luck. */
37
+ function normalize(data, peak = 0.5) {
38
+ let max = 0;
39
+ for (let i = 0; i < data.length; i++) {
40
+ const a = Math.abs(data[i]);
41
+ if (a > max)
42
+ max = a;
43
+ }
44
+ if (max === 0)
45
+ return;
46
+ const k = peak / max;
47
+ for (let i = 0; i < data.length; i++)
48
+ data[i] *= k;
49
+ }
50
+ /** Brown noise: leaky integrator over white — the deepest "room rumble". */
51
+ function brownNoise(length, rand) {
52
+ const out = new Float32Array(length);
53
+ let last = 0;
54
+ for (let i = 0; i < length; i++) {
55
+ const white = rand() * 2 - 1;
56
+ last = (last + 0.02 * white) / 1.02;
57
+ out[i] = last * 3.5;
58
+ }
59
+ normalize(out);
60
+ return out;
61
+ }
62
+ /** Pink noise: Paul Kellet's economical filter bank — even power per octave. */
63
+ function pinkNoise(length, rand) {
64
+ const out = new Float32Array(length);
65
+ let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
66
+ for (let i = 0; i < length; i++) {
67
+ const white = rand() * 2 - 1;
68
+ b0 = 0.99886 * b0 + white * 0.0555179;
69
+ b1 = 0.99332 * b1 + white * 0.0750759;
70
+ b2 = 0.969 * b2 + white * 0.153852;
71
+ b3 = 0.8665 * b3 + white * 0.3104856;
72
+ b4 = 0.55 * b4 + white * 0.5329522;
73
+ b5 = -0.7616 * b5 - white * 0.016898;
74
+ out[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362) * 0.11;
75
+ b6 = white * 0.115926;
76
+ }
77
+ normalize(out);
78
+ return out;
79
+ }
80
+ export const focusScape = {
81
+ id: "focus",
82
+ label: "Focus",
83
+ group: "focus",
84
+ description: "Neutral low-distraction drone — open fifths over a soft brown/pink noise bed.",
85
+ build(ctx) {
86
+ const audio = ctx.audio;
87
+ /** Every node this instance created — disconnect() all on teardown. */
88
+ const nodes = [];
89
+ const sources = [];
90
+ let stopped = false;
91
+ const out = audio.createGain();
92
+ out.gain.value = 0; // start() ramps up; never a mid-stream .value write
93
+ out.connect(ctx.input); // the master chain owns everything after this
94
+ nodes.push(out);
95
+ // --- Noise bed: brown + pink, generated once, looped with a random
96
+ // --- start offset so every play begins at a different point.
97
+ const rate = audio.sampleRate;
98
+ const len = Math.round(FOCUS.NOISE_SECONDS * rate);
99
+ const brownBuf = audio.createBuffer(1, len, rate);
100
+ brownBuf.getChannelData(0).set(brownNoise(len, ctx.rand));
101
+ const pinkBuf = audio.createBuffer(1, len, rate);
102
+ pinkBuf.getChannelData(0).set(pinkNoise(len, ctx.rand));
103
+ const noiseFilter = audio.createBiquadFilter();
104
+ noiseFilter.type = "lowpass";
105
+ noiseFilter.frequency.value = FOCUS.NOISE_LOWPASS_HZ;
106
+ nodes.push(noiseFilter);
107
+ const bed = [
108
+ { buf: brownBuf, gain: FOCUS.BROWN_GAIN },
109
+ { buf: pinkBuf, gain: FOCUS.PINK_GAIN },
110
+ ];
111
+ const offsets = [];
112
+ for (const { buf, gain: level } of bed) {
113
+ const src = audio.createBufferSource();
114
+ src.buffer = buf;
115
+ src.loop = true;
116
+ const g = audio.createGain();
117
+ g.gain.value = level;
118
+ src.connect(g);
119
+ g.connect(noiseFilter);
120
+ nodes.push(src, g);
121
+ sources.push(src);
122
+ // Randomized loop offset — the bed never restarts from the same
123
+ // point, so the loop seam never becomes a rhythm.
124
+ offsets.push(ctx.rand() * (FOCUS.NOISE_SECONDS - 0.5));
125
+ }
126
+ noiseFilter.connect(out);
127
+ // --- Drone: static open fifths through one shared lowpass.
128
+ const droneFilter = audio.createBiquadFilter();
129
+ droneFilter.type = "lowpass";
130
+ droneFilter.frequency.value = FOCUS.DRONE_LOWPASS_HZ;
131
+ droneFilter.Q.value = 0.5;
132
+ nodes.push(droneFilter);
133
+ const voices = [
134
+ { hz: FOCUS.ROOT_HZ, gain: FOCUS.ROOT_GAIN },
135
+ { hz: FOCUS.FIFTH_HZ, gain: FOCUS.FIFTH_GAIN },
136
+ { hz: FOCUS.OCTAVE_HZ, gain: FOCUS.OCTAVE_GAIN },
137
+ ];
138
+ for (const { hz, gain: level } of voices) {
139
+ const osc = audio.createOscillator();
140
+ osc.type = "sine";
141
+ osc.frequency.value = hz;
142
+ const g = audio.createGain();
143
+ g.gain.value = level;
144
+ osc.connect(g);
145
+ g.connect(droneFilter);
146
+ nodes.push(osc, g);
147
+ sources.push(osc);
148
+ }
149
+ droneFilter.connect(out);
150
+ // --- Subtle breath modulation: the drone lowpass opens ~8% on the
151
+ // --- inhale (setTargetAtTime keeps it slow; no per-sample JS).
152
+ const unsubBreath = ctx.lookahead((now) => {
153
+ if (stopped)
154
+ return;
155
+ const b = ctx.breath.state;
156
+ const target = FOCUS.DRONE_LOWPASS_HZ * (1 + FOCUS.BREATH_DEPTH * b.value);
157
+ droneFilter.frequency.setTargetAtTime(target, now, FOCUS.BREATH_TAU);
158
+ });
159
+ // --- Optional soft bell. Its nodes disconnect via a timer one beat
160
+ // --- after the decay ends (see the teardown note in stop()).
161
+ const ringBell = (velocity) => {
162
+ const t0 = audio.currentTime + 0.02;
163
+ const partials = [
164
+ {
165
+ hz: FOCUS.BELL_HZ,
166
+ peak: FOCUS.BELL_GAIN * velocity,
167
+ tau: FOCUS.BELL_TAU,
168
+ },
169
+ {
170
+ hz: FOCUS.BELL_PARTIAL_HZ,
171
+ peak: FOCUS.BELL_PARTIAL_GAIN * velocity,
172
+ tau: FOCUS.BELL_PARTIAL_TAU,
173
+ },
174
+ ];
175
+ const bellNodes = [];
176
+ const bellEnd = t0 + FOCUS.BELL_SECONDS;
177
+ for (const { hz, peak, tau } of partials) {
178
+ const osc = audio.createOscillator();
179
+ osc.type = "sine";
180
+ osc.frequency.value = hz;
181
+ const g = audio.createGain();
182
+ g.gain.setValueAtTime(0, t0);
183
+ g.gain.linearRampToValueAtTime(peak, t0 + 0.012);
184
+ g.gain.setTargetAtTime(0, t0 + 0.012, tau);
185
+ osc.connect(g);
186
+ g.connect(out);
187
+ osc.start(t0);
188
+ osc.stop(bellEnd);
189
+ nodes.push(osc, g);
190
+ bellNodes.push(osc, g);
191
+ }
192
+ // Teardown on a TIMER, never a lookahead cb: the engine retires a
193
+ // stopped instance's callbacks at fade*1000 + 250 ms, so a cb parked
194
+ // past the bell's end can be dropped mid-decay with nodes still
195
+ // connected. Lookahead is for scheduling sound, not teardown.
196
+ setTimeout(() => {
197
+ for (const n of bellNodes)
198
+ n.disconnect();
199
+ }, Math.max(0, (bellEnd - audio.currentTime) * 1000) + 50);
200
+ };
201
+ return {
202
+ start(at) {
203
+ out.gain.setValueAtTime(0, at);
204
+ out.gain.linearRampToValueAtTime(FOCUS.LEVEL, at + FOCUS.FADE_IN);
205
+ let i = 0;
206
+ for (const src of sources) {
207
+ const offset = offsets[i++];
208
+ if (offset === undefined)
209
+ src.start(at);
210
+ else
211
+ src.start(at, offset);
212
+ }
213
+ },
214
+ stop(at, fade) {
215
+ if (stopped)
216
+ return;
217
+ stopped = true;
218
+ unsubBreath();
219
+ const end = at + fade;
220
+ out.gain.cancelScheduledValues(at);
221
+ out.gain.setValueAtTime(out.gain.value, at);
222
+ out.gain.linearRampToValueAtTime(0, end);
223
+ for (const src of sources)
224
+ src.stop(end);
225
+ // Disconnect on a TIMER, not a lookahead cb: the engine RETIRES a
226
+ // stopped instance's lookahead callbacks at fade*1000 + 250 ms
227
+ // (engine.ts retireCbs), so a teardown cb waiting for a tick just
228
+ // past the fade end races that retire and can be dropped — leaving
229
+ // nodes connected to the shared input forever. 50 ms past the fade
230
+ // end is silent (gain is 0) and cannot lose the race. Lookahead
231
+ // stays for scheduling sound only.
232
+ setTimeout(() => {
233
+ for (const n of nodes)
234
+ n.disconnect();
235
+ }, Math.max(0, (end - audio.currentTime) * 1000) + 50);
236
+ },
237
+ setParam(key, value) {
238
+ // Wider than the contract's `number` (method bivariance keeps it
239
+ // assignable) so a host can write setParam('bell', true).
240
+ if (key === "bell" && value && !stopped)
241
+ ringBell(value === true ? 1 : Number(value));
242
+ },
243
+ };
244
+ },
245
+ };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The hibiscus scape — a warm F-major meditation drone, six breaths a minute.
3
+ * Written FRESH from the recipe in jatui-sgx2i (the tamaracleaver hibiscus
4
+ * audio is the inspiration, not the source).
5
+ *
6
+ * Graph (everything sums into the shared master `input`; the master chain owns
7
+ * the EQ, reverb, limiter and volume after us):
8
+ *
9
+ * pad 4 partials — F2 + fifth (C3), tenth (A3), twelfth (C4) — each a
10
+ * ±7-cent detuned oscillator pair through its own soft waveshaper
11
+ * (y = x + a·x³ + b·x⁵: mostly fundamental, a little 3rd and 5th
12
+ * harmonic), panned ±0.35 where the context supports it, summed
13
+ * into a breath-modulated lowpass → pad level
14
+ * float two sine voices alternating F4 → A4 → C5 → A4 (→ F4 …), ~70 s per
15
+ * tone with 16 s cross-fades, lookahead-scheduled
16
+ * chimes every 35–65 s, one current-chord-tone bell an octave up: first 3
17
+ * partials, a 2.5 s swell (never a strike) and a long exponential
18
+ * decay via setTargetAtTime, random pan; each chime disconnects its
19
+ * own nodes on a timer once its tail is done
20
+ * air a 4 s pink-noise buffer generated once and looped from a random
21
+ * offset (so it never audibly repeats), highpassed at 80 Hz and
22
+ * lowpassed at 420 Hz, very quiet
23
+ *
24
+ * ONE lookahead callback does all the SOUND scheduling on each engine tick
25
+ * (~250 ms): breath modulation, the tone drift and the chime schedule. No
26
+ * per-sample JS anywhere — native nodes only.
27
+ *
28
+ * Teardown NEVER waits for a lookahead tick: the engine retires a stopped
29
+ * instance's callbacks at fade·1000 + 250 ms, so anything teardown-ish parked
30
+ * on the tick queue can be dropped mid-fade and leave nodes hanging off the
31
+ * shared input. disconnect() runs on timers instead (WideFrost's contract
32
+ * note): each chime schedules its own disconnect for tail-end + 50 ms, and
33
+ * stop(at, fade) schedules one final disconnect-everything at fade·1000 + 50
34
+ * ms — after the gains have finished ramping to 0 on the audio clock.
35
+ *
36
+ * Params (ScapeInstance.setParam):
37
+ * breathsPerMinute — moves the SHARED breath clock (default 6)
38
+ * chimes — 0 turns the chimes off, anything else on
39
+ *
40
+ * `onToneChange`: the scape contract (types.ts) has no callback channel —
41
+ * setParam only carries numbers — so tone changes are not currently
42
+ * observable from outside. If the contract grows a callback hook, the drift
43
+ * scheduler below is the place to emit it.
44
+ *
45
+ * CPU: nothing runs per sample. The noise buffer is filled once per build;
46
+ * oscillators and setTargetAtTime do the rest natively.
47
+ */
48
+ import type { Scape } from '../types';
49
+ export declare const HIBISCUS: Readonly<{
50
+ /** Drone partials: F2 + fifth (C3), tenth (A3), twelfth (C4). */
51
+ DRONE_HZ: number[];
52
+ /** Relative level of each drone partial, brightest at the bottom. */
53
+ DRONE_GAINS: number[];
54
+ /** Stereo pair detune (± cents) — the "width" of the pad. */
55
+ DETUNE_CENTS: 7;
56
+ /** Where the detuned pairs sit in the stereo field. */
57
+ PAD_PAN: 0.35;
58
+ /** Soft wave: y = x + SOFT_3RD·x³ + SOFT_5TH·x⁵, normalised — odd
59
+ * symmetry, so a sine keeps its fundamental and gains a little 3rd and
60
+ * 5th harmonic and nothing else. */
61
+ SOFT_3RD: 0.16;
62
+ SOFT_5TH: 0.05;
63
+ /** Pad lowpass: resting frequency and the in-breath lift (+Hz at peak). */
64
+ PAD_BASE_HZ: 420;
65
+ PAD_BREATH_HZ: 180;
66
+ PAD_Q: 0.6;
67
+ PAD_LEVEL: 0.15;
68
+ /** How much of each section's level rides the breath (0..1 — the level
69
+ * sinks to (1 - depth)·level at the bottom of the exhale). */
70
+ PAD_BREATH_DEPTH: 0.25;
71
+ FLOAT_LEVEL: 0.09;
72
+ FLOAT_BREATH_DEPTH: 0.2;
73
+ NOISE_LEVEL: 0.01;
74
+ NOISE_BREATH_DEPTH: 0.25;
75
+ /** Floating tones: F4 → A4 → C5 → A4 (→ F4 …). */
76
+ TONES_HZ: number[];
77
+ TONE_HOLD_S: 70;
78
+ TONE_JITTER_S: 8;
79
+ TONE_FADE_S: 16;
80
+ /** Chimes: the current chord an octave up (F5, A5, C6), first 3 partials. */
81
+ CHIME_HZ: number[];
82
+ CHIME_PARTIAL_GAINS: number[];
83
+ CHIME_MIN_GAP_S: 35;
84
+ CHIME_MAX_GAP_S: 65;
85
+ CHIME_ATTACK_S: 2.5;
86
+ CHIME_TAU_S: 3.5;
87
+ /** Oscillators stop after this many decay taus. */
88
+ CHIME_TAILS: 7;
89
+ CHIME_LEVEL: 0.05;
90
+ CHIME_PAN_SPREAD: 0.8;
91
+ /** Air noise. */
92
+ AIR_SECONDS: 4;
93
+ AIR_HP_HZ: 80;
94
+ AIR_LP_HZ: 420;
95
+ /** Internal entrance fade — the engine bus fade handles crossfades; this
96
+ * just keeps a fresh build from stepping in at full level. */
97
+ ENTRANCE_S: 6;
98
+ /** Smoothing tau for the per-tick breath modulation. */
99
+ MOD_TAU_S: 0.45;
100
+ }>;
101
+ /** A soft wave curve: fundamental plus a little 3rd and 5th, nothing else. */
102
+ export declare function softWaveCurve(n?: number): Float32Array;
103
+ export declare const hibiscusScape: Scape;