@joewinke/jatui 0.45.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 +8 -0
  34. package/dist/index.js +23 -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
package/README.md CHANGED
@@ -12,6 +12,52 @@ Requires Svelte 5 and DaisyUI 5 as peer dependencies.
12
12
 
13
13
  ---
14
14
 
15
+ ## Ambience — generative background sound
16
+
17
+ A headless Web Audio engine (`createAmbience` / `createDefaultAmbience`) plus the `AmbienceToggle` app-shell button — the audio twin of `ThemeToggle`. Everything is generative: no audio files, no libraries, one lazily-created AudioContext started only from a user gesture.
18
+
19
+ ```svelte
20
+ <script>
21
+ // ONE engine, mounted once at the root layout, so sound survives navigation.
22
+ import { createDefaultAmbience } from 'jatui/ambience';
23
+ const engine = createDefaultAmbience(); // hibiscus, focus, brown/pink/white noise, rain, waves pre-registered
24
+ </script>
25
+
26
+ <AmbienceToggle {engine} />
27
+ ```
28
+
29
+ `createDefaultAmbience()` registers every shipped scape (via the `jatui/ambience` subpath); register extras on top with `engine.register(myScape)`. Custom registry instead: `createAmbience()` + individually imported scapes (`hibiscusScape`, `focusScape`, `noiseScapes`, `rain`, `waves`).
30
+
31
+ Key contract points:
32
+
33
+ - **Gesture-gated** — `engine.play(id)` must run inside a click/keydown handler; it rejects otherwise. The AudioContext closes itself ~3 s after stop, and a play inside that window fades back in instead of restarting.
34
+ - **Scape contract** — `{ id, label, group, description?, build(ctx) }`; `build` connects into `ctx.input` (never straight to `destination`), gets the shared `ctx.breath` clock and a lookahead scheduler. Scapes are re-buildable and tear themselves down on `stop`.
35
+ - **Playlist scapes** — per-host, never in the default set. Build one with `createPlaylistScape({ id, label, manifestUrl })` and `engine.register()` it; the manifest is fetched lazily once on the first play, tracks stream through the master chain with gain-ramp crossfades, and the returned `PlaylistScape` adds `state()/subscribe()/skip()/setShuffle()` for the UI. Manifest shape (`title` optional; `license` and `composer` required on every track):
36
+
37
+ ```json
38
+ {
39
+ "title": "Library",
40
+ "tracks": [
41
+ {
42
+ "title": "Nocturne Op. 9 No. 2",
43
+ "composer": "Frédéric Chopin",
44
+ "performer": "Public-domain recording",
45
+ "url": "/audio/nocturne-9-2.mp3",
46
+ "license": "Public domain",
47
+ "sourceUrl": "https://musopen.org/music/..."
48
+ }
49
+ ]
50
+ }
51
+ ```
52
+
53
+ Track URLs must be served with CORS headers (they are fetched as `HTMLAudioElement` sources fed into the Web Audio graph), and the host owns the manifest and its licensing claims — an example of public-domain Musopen recordings can be listed with attribution in the scape's `description`.
54
+ - **Persistence** — volume + last scape persist through the engine (custom `storageKey`); restoring preselects but never autoplays.
55
+ - **AmbienceToggle** — click plays/stops the last scape; ArrowDown, press-and-hold or right-click opens the grouped scape menu with volume; `aria-pressed` / `menuitemradio` / `aria-live` status wired; a rejected `play()` announces a transient “couldn't start — tap again” in the sr-only status, cleared by the next successful play; the glyph breathes with the shared breath clock and is static under reduced motion / `data-motion="calm"`.
56
+
57
+ Demo: `/components/ambience-toggle` on the docs site.
58
+
59
+ ---
60
+
15
61
  ## Dialog Utilities
16
62
 
17
63
  `ConfirmDialog` and `InputDialog` are global, promise-based dialogs that replace the browser's native `confirm()` / `prompt()`. They're keyboard-accessible, animated, and theme-aware.
@@ -0,0 +1,131 @@
1
+ # jatui/ambience — headless scape engine
2
+
3
+ Generative background sound for host apps: one shared `AudioContext`, a master
4
+ chain (warm peaking tilt → lowpass → soft-clip limiter → 50% synthesized-convolution
5
+ reverb), per-scape "scapes" that build a Web Audio graph into it, a ~250 ms
6
+ lookahead tick, crossfades between scapes, volume persistence and a shared
7
+ breath LFO. **Headless by design** — no UI, no `window` at import, the context
8
+ is created lazily on the first gesture-driven `play()`.
9
+
10
+ ```ts
11
+ import { createAmbience } from 'jatui/ambience'; // wired by the integration task
12
+ import { playlistScape } from './scapes/playlist';
13
+
14
+ const ambience = createAmbience();
15
+ ambience.register(playlistScape({ id: 'library', label: 'Library', manifestUrl: '/ambience.json' }));
16
+ // inside a click handler:
17
+ await ambience.play('library');
18
+ ```
19
+
20
+ - **The scape contract** lives in `src/lib/ambience/types.ts`: a `Scape` is
21
+ `{ id, label, group, description?, build(ctx) → ScapeInstance }`; an instance
22
+ is `{ start(at), stop(at, fade), setParam?(key, value) }`. A scape connects
23
+ into `ctx.input` — never into `destination` — and must be re-buildable (a
24
+ fresh graph per play, torn down by its own `stop()`).
25
+ - **Teardown is wall-clock, never a lookahead tick.** The engine retires a
26
+ stopped instance's lookahead callbacks `fade*1000 + 250 ms` after `stop()`
27
+ (`retireCbs` in engine.ts) — a disconnect waiting on a tick can be dropped
28
+ with nodes still wired into the shared input. Schedule silencing on the
29
+ audio clock (gain ramps / `source.stop(at+fade)`) and disconnect from a
30
+ `setTimeout(fade*1000 + 50)`.
31
+ - **Keep CPU low**: native nodes only, no per-sample JS. If you need noise,
32
+ generate the buffer once and loop it (randomized offsets so it never
33
+ audibly repeats).
34
+ - **Lookahead is for scheduling sound, statelessly.** Re-anchor against
35
+ `ctx.currentTime` each tick (a background tab throttles `setInterval` to
36
+ ~1/min while the audio clock runs on) and cap events per tick, or a
37
+ returning tab fires every missed event at once.
38
+
39
+ ## Playlist scape — host-supplied manifest (phase 2)
40
+
41
+ `src/lib/ambience/scapes/playlist.ts` — `createPlaylistScape()` streams a
42
+ host-curated list of public-domain recordings through the master chain.
43
+ **No audio files (and no manifest) ship in jatui** — the host supplies either
44
+ inline `tracks` or a `manifestUrl`:
45
+
46
+ | Option | Meaning |
47
+ | --- | --- |
48
+ | `id`, `label` | Scape identity (required). |
49
+ | `manifestUrl` | Fetched **once, lazily, on the first play** — never at import. A failed fetch is not cached; the next play retries it. |
50
+ | `tracks` | Inline track list — no fetch at all (resolved eagerly, a pure local filter). |
51
+ | `crossfade` | Track-to-track crossfade in seconds, 0–30 (default 3). |
52
+ | `shuffle` | Fisher–Yates play order over the engine's per-scape PRNG (default off). `setShuffle()` toggles live: the current track stays, the rest reorder. |
53
+ | `gain` | Initial gain of the scape mix into the master chain, 0–1 (default 1). `setParam('gain', v)` ramps it live. |
54
+ | `fetchFn`, `createMediaElement` | Injectable for tests (default `fetch` / `new Audio(url)`). |
55
+
56
+ ### Manifest format
57
+
58
+ ```json
59
+ {
60
+ "title": "Evening piano — public domain",
61
+ "tracks": [
62
+ {
63
+ "title": "Nocturne in E-flat major, Op. 9 No. 2",
64
+ "composer": "Frédéric Chopin",
65
+ "performer": "Musopen performer recording",
66
+ "url": "https://cdn.example.org/chopin-nocturne-op9-2.mp3",
67
+ "license": "Public domain",
68
+ "sourceUrl": "https://musopen.org/music/4373-nocturnes-op-9/"
69
+ }
70
+ ]
71
+ }
72
+ ```
73
+
74
+ A bare `[ track, … ]` array is also accepted. Entries are validated: `title`,
75
+ `composer`, `url` and `license` are required non-empty strings
76
+ (`composer`/`performer`/`license` are exactly the attribution the toggle
77
+ shows, `sourceUrl` is the link target); `performer` and `sourceUrl` are
78
+ optional. Invalid entries are **filtered out**, not fatal — but a manifest
79
+ with no valid tracks left puts the scape in the `error` state. In the example
80
+ above the `url` is illustrative: point it at whatever stream the host serves
81
+ (or an archive.org item). The **host is responsible for the manifest's
82
+ licensing claims** — jatui only displays them.
83
+
84
+ ### Streaming + CORS
85
+
86
+ Each track plays through `HTMLAudioElement → MediaElementAudioSourceNode →
87
+ per-track gain → mix → ctx.input`, so the engine's warm tilt and soft-clip
88
+ limiter shape the stream and crossfades are native gain ramps — no per-sample
89
+ JS. Elements are created with `crossOrigin = 'anonymous'`, so **the host
90
+ serving the audio must send CORS headers** (`Access-Control-Allow-Origin`);
91
+ a non-CORS source then fails loudly per track (the scape skips to the next
92
+ track) instead of playing silence into a tainted graph.
93
+
94
+ ### Behavior notes
95
+
96
+ - Two ping-pong slots (element + source node + gain each) are **built once per
97
+ play** and reused for every track; the idle slot preloads the next track so
98
+ transitions don't wait on buffering.
99
+ - End-of-track is detected statelessly in the lookahead (≤ `crossfade` +
100
+ 0.25 s remaining) — at most one transition per tick, anchored at that tick's
101
+ `now`, so a throttled returning tab cannot burst missed transitions. A
102
+ stream without metadata falls back to the element's `ended` event with a
103
+ short ramp.
104
+ - A single-track playlist loops its element instead of advancing.
105
+ - A track that fails to play (bad URL, play() rejection, `error` event) is
106
+ skipped; if **every** track fails, the state goes `error` and the scape
107
+ fades itself out. The engine is not told (the Scape contract has no
108
+ failure channel) — the UI should surface `state().error`.
109
+ - `stop()` flips the state to `idle` immediately, ramps the gains out on the
110
+ audio clock, then pauses the elements and disconnects every node
111
+ wall-clock (`fade*1000 + 50`). Nothing leaks into the shared input.
112
+
113
+ ### UI surface
114
+
115
+ The returned object is a plain `Scape` for the engine plus a playlist surface:
116
+
117
+ ```ts
118
+ const scape = playlistScape({ id: 'library', label: 'Library', manifestUrl });
119
+ scape.subscribe((s) => {
120
+ // s.status: 'idle' | 'loading' | 'playing' | 'error'
121
+ // s.track: { title, composer, performer?, license, sourceUrl? } | null
122
+ // s.index, s.count, s.error?
123
+ });
124
+ scape.skip(); // crossfade to the next track now
125
+ scape.setShuffle(true);
126
+ scape.tracks(); // resolved list, or null until the manifest resolved
127
+ ```
128
+
129
+ Cast a scape from `ambience.list()` to `PlaylistScape` in the toggle to reach
130
+ it. The toggle renders the attribution line — `composer · performer · license`
131
+ — linked to `sourceUrl` while a playlist scape is playing.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The one shared breath LFO. Six breaths a minute on the hibiscus recipe; any
3
+ * scape can read it (and hosts sync visuals via engine.onBreath) so everything
4
+ * breathes together instead of each scape running its own wobble.
5
+ *
6
+ * Pure math on an elapsed-seconds accumulator — no WebAudio, no timers of its
7
+ * own; the engine's lookahead tick drives `update`.
8
+ */
9
+ import type { BreathState } from './types';
10
+ export declare const BREATHS_PER_MINUTE = 6;
11
+ export declare class BreathClock {
12
+ #private;
13
+ get bpm(): number;
14
+ set bpm(v: number);
15
+ get running(): boolean;
16
+ start(now: number): void;
17
+ stop(): void;
18
+ reset(): void;
19
+ /** Advance the clock to `now` (seconds, same domain as ctx.currentTime). */
20
+ update(now: number): void;
21
+ get state(): BreathState;
22
+ }
@@ -0,0 +1,43 @@
1
+ export const BREATHS_PER_MINUTE = 6;
2
+ export class BreathClock {
3
+ #bpm = BREATHS_PER_MINUTE;
4
+ #elapsed = 0;
5
+ #lastNow = null;
6
+ #running = false;
7
+ get bpm() {
8
+ return this.#bpm;
9
+ }
10
+ set bpm(v) {
11
+ this.#bpm = Math.max(0.5, v);
12
+ }
13
+ get running() {
14
+ return this.#running;
15
+ }
16
+ start(now) {
17
+ this.#lastNow = now;
18
+ this.#running = true;
19
+ }
20
+ stop() {
21
+ this.#running = false;
22
+ this.#lastNow = null;
23
+ }
24
+ reset() {
25
+ this.#elapsed = 0;
26
+ }
27
+ /** Advance the clock to `now` (seconds, same domain as ctx.currentTime). */
28
+ update(now) {
29
+ if (!this.#running || this.#lastNow === null) {
30
+ this.#lastNow = now;
31
+ return;
32
+ }
33
+ const dt = Math.max(0, now - this.#lastNow);
34
+ this.#lastNow = now;
35
+ this.#elapsed += dt;
36
+ }
37
+ get state() {
38
+ const half = 30 / this.#bpm; // seconds per inhale (== per exhale)
39
+ const t = this.#elapsed % (half * 2);
40
+ const rising = t < half;
41
+ return { value: rising ? t / half : 1 - (t - half) / half, rising };
42
+ }
43
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * createDefaultAmbience() — the "just works" engine.
3
+ *
4
+ * Every scape the library ships is pre-registered, in the menu order the
5
+ * AmbienceToggle groups them (meditation / focus / noise / nature). A host
6
+ * that supplies extra scapes (e.g. a playlist scape from a manifest of
7
+ * public-domain recordings) registers them on top — registry duplicate-id
8
+ * protection still applies.
9
+ *
10
+ * @example
11
+ * import { createDefaultAmbience } from '../../../dist/ambience';
12
+ * const ambience = createDefaultAmbience();
13
+ * // inside a click handler:
14
+ * await ambience.play('rain');
15
+ */
16
+ import { type Ambience } from './engine';
17
+ import type { Scape } from './types';
18
+ /** The scapes the library ships, in canonical menu order. */
19
+ export declare const DEFAULT_SCAPES: readonly Scape[];
20
+ export interface DefaultAmbienceOptions {
21
+ /** Passed through to `createAmbience` (e.g. a custom `storageKey`). */
22
+ storageKey?: string;
23
+ }
24
+ export declare function createDefaultAmbience(options?: DefaultAmbienceOptions): Ambience;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * createDefaultAmbience() — the "just works" engine.
3
+ *
4
+ * Every scape the library ships is pre-registered, in the menu order the
5
+ * AmbienceToggle groups them (meditation / focus / noise / nature). A host
6
+ * that supplies extra scapes (e.g. a playlist scape from a manifest of
7
+ * public-domain recordings) registers them on top — registry duplicate-id
8
+ * protection still applies.
9
+ *
10
+ * @example
11
+ * import { createDefaultAmbience } from '../../../dist/ambience';
12
+ * const ambience = createDefaultAmbience();
13
+ * // inside a click handler:
14
+ * await ambience.play('rain');
15
+ */
16
+ import { createAmbience } from './engine';
17
+ import { hibiscusScape } from './scapes/hibiscus';
18
+ import { focusScape } from './scapes/focus';
19
+ import { noiseScapes } from './scapes/noise';
20
+ import { rain, waves } from './scapes/nature';
21
+ /** The scapes the library ships, in canonical menu order. */
22
+ export const DEFAULT_SCAPES = Object.freeze([
23
+ hibiscusScape,
24
+ focusScape,
25
+ ...noiseScapes,
26
+ rain,
27
+ waves
28
+ ]);
29
+ export function createDefaultAmbience(options = {}) {
30
+ const ambience = createAmbience(options);
31
+ for (const scape of DEFAULT_SCAPES)
32
+ ambience.register(scape);
33
+ return ambience;
34
+ }
@@ -0,0 +1,21 @@
1
+ import type { Ambience, AmbienceOptions } from './types';
2
+ export declare const TIMING: Readonly<{
3
+ /** Lookahead tick interval (ms). */
4
+ TICK_MS: 250;
5
+ /** Crossfade between scapes (s). */
6
+ CROSSFADE: 2.5;
7
+ /** stop() fade-out (s). */
8
+ STOP_FADE: 0.55;
9
+ /** Seconds after stop() before the context closes. */
10
+ CLOSE_DELAY_MS: 3000;
11
+ /** Fade-back-in after a stop-then-play inside the close window (s). */
12
+ RESUME_FADE: 4;
13
+ /** setVolume ramp (s). */
14
+ VOLUME_RAMP: 0.3;
15
+ /** The fade handed to instance.stop() on kill — 0 = immediate. */
16
+ KILL_FADE: 0;
17
+ /** After a scape's stop fade, its un-released lookahead cbs are retired
18
+ * this many ms past the fade end (margin so the last ticks still land). */
19
+ RETIRE_MARGIN_MS: 250;
20
+ }>;
21
+ export declare function createAmbience(options?: AmbienceOptions): Ambience;
@@ -0,0 +1,313 @@
1
+ /**
2
+ * The ambience engine — the headless core. One AudioContext per engine,
3
+ * created lazily on the first gesture-driven play; a lookahead tick every
4
+ * 250 ms schedules ahead of currentTime (drift- and jitter-free); crossfades
5
+ * between scapes are ~2.5 s; stop() fades ~0.55 s and closes the context ~3 s
6
+ * later, and a play() inside that window cancels the close and fades back in
7
+ * over ~4 s; setVolume ramps 0.3 s; kill() is immediate. Persistence stores
8
+ * volume + last scape id only — restoring never autoplays.
9
+ *
10
+ * NO UI here. The AmbienceToggle task (jatui-b7qvr) hosts this module.
11
+ */
12
+ import { BreathClock } from './breath';
13
+ import { createMasterChain } from './master';
14
+ import { loadPersisted, savePersisted } from './persist';
15
+ import { ScapeRegistry } from './registry';
16
+ export const TIMING = Object.freeze({
17
+ /** Lookahead tick interval (ms). */
18
+ TICK_MS: 250,
19
+ /** Crossfade between scapes (s). */
20
+ CROSSFADE: 2.5,
21
+ /** stop() fade-out (s). */
22
+ STOP_FADE: 0.55,
23
+ /** Seconds after stop() before the context closes. */
24
+ CLOSE_DELAY_MS: 3000,
25
+ /** Fade-back-in after a stop-then-play inside the close window (s). */
26
+ RESUME_FADE: 4,
27
+ /** setVolume ramp (s). */
28
+ VOLUME_RAMP: 0.3,
29
+ /** The fade handed to instance.stop() on kill — 0 = immediate. */
30
+ KILL_FADE: 0,
31
+ /** After a scape's stop fade, its un-released lookahead cbs are retired
32
+ * this many ms past the fade end (margin so the last ticks still land). */
33
+ RETIRE_MARGIN_MS: 250
34
+ });
35
+ export function createAmbience(options = {}) {
36
+ const storageKey = options.storageKey ?? 'jatui:ambience';
37
+ const isGesture = options.isGesture ??
38
+ (() => {
39
+ // SSR/node: no navigator — permissive. In the browser the user's own
40
+ // activation state is the truth (the ctx would stay suspended anyway).
41
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
42
+ return nav?.userActivation ? nav.userActivation.isActive : true;
43
+ });
44
+ const registry = new ScapeRegistry();
45
+ const breath = new BreathClock();
46
+ // Resting state restored from storage (never autoplays — preselect only).
47
+ const persisted = loadPersisted(storageKey);
48
+ let volume = clamp(typeof persisted.volume === 'number' ? persisted.volume : (options.defaultVolume ?? 0.6));
49
+ let currentId = persisted.lastScape ?? null;
50
+ let playing = false;
51
+ let ctx = null;
52
+ let chain = null;
53
+ let active = null;
54
+ let tick = null;
55
+ let closeTimer = null;
56
+ /** Monotonic token: any await in play() is followed by a `gen !== playGen`
57
+ * check, so a superseded or killed play() never builds/starts a scape. */
58
+ let playGen = 0;
59
+ const stateSubs = new Set();
60
+ const breathSubs = new Set();
61
+ const lookaheadCbs = new Set();
62
+ function state() {
63
+ return { currentId, playing, volume };
64
+ }
65
+ function emit() {
66
+ const s = state();
67
+ for (const fn of stateSubs)
68
+ fn(s);
69
+ }
70
+ function clamp(v) {
71
+ return Math.min(1, Math.max(0, v));
72
+ }
73
+ function ensureContext() {
74
+ if (ctx)
75
+ return ctx;
76
+ // Lazy: never constructed at import or on load (SSR-safe, no autoplay).
77
+ ctx = options.createContext ? options.createContext() : new AudioContext();
78
+ chain = createMasterChain(ctx);
79
+ chain.bus.gain.value = 1;
80
+ chain.volume.gain.value = volume;
81
+ breath.reset();
82
+ breath.start(ctx.currentTime);
83
+ tick = setInterval(onTick, TIMING.TICK_MS);
84
+ return ctx;
85
+ }
86
+ function onTick() {
87
+ if (!ctx)
88
+ return;
89
+ const now = ctx.currentTime;
90
+ breath.update(now);
91
+ const b = breath.state;
92
+ for (const fn of breathSubs)
93
+ fn(b);
94
+ for (const cb of lookaheadCbs)
95
+ cb(now);
96
+ }
97
+ function scheduleLookaheadFor(cbs) {
98
+ return (cb) => {
99
+ cbs.add(cb);
100
+ lookaheadCbs.add(cb);
101
+ return () => {
102
+ cbs.delete(cb);
103
+ lookaheadCbs.delete(cb);
104
+ };
105
+ };
106
+ }
107
+ /** Retire an instance's lookahead cbs once its stop fade has fully elapsed
108
+ * (with a small margin so its final scheduled ticks still land). */
109
+ function retireCbs(a, fade) {
110
+ const t = setTimeout(() => {
111
+ for (const cb of a.cbs)
112
+ lookaheadCbs.delete(cb);
113
+ a.retireTimers.clear();
114
+ }, fade * 1000 + TIMING.RETIRE_MARGIN_MS);
115
+ a.retireTimers.add(t);
116
+ }
117
+ function stopInstanceNow(fade) {
118
+ if (active && ctx) {
119
+ const a = active;
120
+ active = null;
121
+ try {
122
+ a.inst.stop(ctx.currentTime, fade);
123
+ }
124
+ catch {
125
+ /* a broken scape must not take the engine down */
126
+ }
127
+ retireCbs(a, fade);
128
+ }
129
+ }
130
+ function persist() {
131
+ savePersisted(storageKey, { volume, lastScape: currentId ?? undefined });
132
+ }
133
+ async function play(id) {
134
+ const scape = registry.get(id);
135
+ if (!scape)
136
+ throw new Error(`Unknown scape: ${id}`);
137
+ if (!isGesture())
138
+ throw new Error('play() must be called from a user gesture');
139
+ // Monotonic play token: a second play() (or a stop()/kill()) while this
140
+ // one is suspended at `await c.resume()` invalidates it — the loser
141
+ // returns without building or starting anything.
142
+ const gen = ++playGen;
143
+ // Inside the post-stop close window: cancel the close, fade back in.
144
+ const resuming = closeTimer !== null;
145
+ if (closeTimer !== null) {
146
+ clearTimeout(closeTimer);
147
+ closeTimer = null;
148
+ }
149
+ const c = ensureContext();
150
+ if (c.state === 'suspended')
151
+ await c.resume();
152
+ // Superseded (another play won) or killed (ctx/chain nulled) mid-resume.
153
+ if (gen !== playGen || ctx !== c || !chain)
154
+ return;
155
+ // Crossfade: old scape fades out over CROSSFADE while the new one starts.
156
+ stopInstanceNow(resuming ? TIMING.STOP_FADE : TIMING.CROSSFADE);
157
+ // A stop() stopped the breath clock; a resume inside the close window
158
+ // must restart it or the clock is dead for the rest of the session.
159
+ if (!breath.running)
160
+ breath.start(c.currentTime);
161
+ const instCbs = new Set();
162
+ const scapeCtx = {
163
+ audio: c,
164
+ input: chain.input,
165
+ breath,
166
+ lookahead: scheduleLookaheadFor(instCbs),
167
+ // Fresh PRNG per build: a shared stream would interleave between the
168
+ // outgoing and incoming scape during a crossfade.
169
+ rand: makeRand(id)
170
+ };
171
+ let inst;
172
+ try {
173
+ inst = scape.build(scapeCtx);
174
+ }
175
+ catch {
176
+ return; // a broken scape must not take the engine down
177
+ }
178
+ if (gen !== playGen)
179
+ return; // stop()/kill() raced the (sync) build
180
+ active = { inst, cbs: instCbs, retireTimers: new Set() };
181
+ if (resuming) {
182
+ // Fade the bus back in over RESUME_FADE — scheduled, no .value write.
183
+ const g = chain.bus.gain;
184
+ g.cancelScheduledValues(c.currentTime);
185
+ g.setValueAtTime(g.value, c.currentTime);
186
+ g.linearRampToValueAtTime(1, c.currentTime + TIMING.RESUME_FADE);
187
+ }
188
+ inst.start(c.currentTime);
189
+ // Tick the new scape once immediately so it can schedule ahead of "now".
190
+ for (const cb of instCbs)
191
+ cb(c.currentTime);
192
+ currentId = id;
193
+ playing = true;
194
+ persist();
195
+ emit();
196
+ }
197
+ function stop() {
198
+ playGen++; // invalidate any in-flight play()
199
+ if (!ctx || !playing) {
200
+ playing = false;
201
+ emit();
202
+ return;
203
+ }
204
+ const now = ctx.currentTime;
205
+ stopInstanceNow(TIMING.STOP_FADE + 0.05);
206
+ const g = chain.bus.gain;
207
+ g.cancelScheduledValues(now);
208
+ g.setValueAtTime(g.value, now);
209
+ g.linearRampToValueAtTime(0, now + TIMING.STOP_FADE);
210
+ breath.stop();
211
+ playing = false;
212
+ // Close the context after the window; a play() inside it cancels this.
213
+ if (closeTimer)
214
+ clearTimeout(closeTimer);
215
+ closeTimer = setTimeout(() => {
216
+ closeTimer = null;
217
+ kill();
218
+ }, TIMING.CLOSE_DELAY_MS);
219
+ emit();
220
+ }
221
+ function kill() {
222
+ playGen++; // invalidate any in-flight play()
223
+ if (closeTimer) {
224
+ clearTimeout(closeTimer);
225
+ closeTimer = null;
226
+ }
227
+ if (tick) {
228
+ clearInterval(tick);
229
+ tick = null;
230
+ }
231
+ if (ctx)
232
+ stopInstanceNow(TIMING.KILL_FADE); // immediate
233
+ // Retire every live scape's lookahead cbs NOW — no more ticks at all.
234
+ lookaheadCbs.clear();
235
+ if (active) {
236
+ for (const t of active.retireTimers)
237
+ clearTimeout(t);
238
+ active = null;
239
+ }
240
+ breath.stop();
241
+ if (chain) {
242
+ chain.dispose();
243
+ chain = null;
244
+ }
245
+ if (ctx) {
246
+ try {
247
+ ctx.close();
248
+ }
249
+ catch {
250
+ /* already closed */
251
+ }
252
+ ctx = null;
253
+ }
254
+ playing = false;
255
+ emit();
256
+ }
257
+ function setVolume(v) {
258
+ volume = clamp(v);
259
+ if (chain && ctx) {
260
+ const g = chain.volume.gain;
261
+ g.cancelScheduledValues(ctx.currentTime);
262
+ g.setValueAtTime(g.value, ctx.currentTime);
263
+ g.linearRampToValueAtTime(volume, ctx.currentTime + TIMING.VOLUME_RAMP);
264
+ }
265
+ persist();
266
+ emit();
267
+ }
268
+ // Deterministic PRNG scoped to ONE scape build (mulberry32, seeded by the
269
+ // scape id): fresh closure per play so crossfading scapes never share
270
+ // (and interleave) one stream.
271
+ function makeRand(id) {
272
+ let s = hashId(id) || 1;
273
+ return () => {
274
+ s = (s + 0x6d2b79f5) >>> 0;
275
+ let t = s;
276
+ t = Math.imul(t ^ (t >>> 15), t | 1);
277
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
278
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
279
+ };
280
+ }
281
+ return {
282
+ register(scape) {
283
+ registry.register(scape);
284
+ },
285
+ list() {
286
+ return registry.list();
287
+ },
288
+ play,
289
+ stop,
290
+ kill,
291
+ setVolume,
292
+ get state() {
293
+ return state();
294
+ },
295
+ subscribe(fn) {
296
+ stateSubs.add(fn);
297
+ fn(state());
298
+ return () => stateSubs.delete(fn);
299
+ },
300
+ onBreath(fn) {
301
+ breathSubs.add(fn);
302
+ return () => breathSubs.delete(fn);
303
+ }
304
+ };
305
+ }
306
+ function hashId(id) {
307
+ let h = 2166136261;
308
+ for (let i = 0; i < id.length; i++) {
309
+ h ^= id.charCodeAt(i);
310
+ h = Math.imul(h, 16777619);
311
+ }
312
+ return h >>> 0;
313
+ }
@@ -0,0 +1,23 @@
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, type DefaultAmbienceOptions } from './default';
14
+ export { hibiscusScape } from './scapes/hibiscus';
15
+ export { focusScape } from './scapes/focus';
16
+ export { noiseScapes, brownNoiseScape, pinkNoiseScape, whiteNoiseScape } from './scapes/noise';
17
+ export { rain, waves } from './scapes/nature';
18
+ export { createPlaylistScape } from './scapes/playlist';
19
+ export type { PlaylistScape, PlaylistScapeOptions, PlaylistManifest, PlaylistState, PlaylistStatus, PlaylistTrack } from './scapes/playlist';
20
+ export { BreathClock, BREATHS_PER_MINUTE } from './breath';
21
+ export { MASTER } from './master';
22
+ export { ScapeRegistry } from './registry';
23
+ export type { Ambience, AmbienceOptions, AmbienceState, BreathState, Scape, ScapeContext, ScapeGroup, ScapeInstance } from './types';