@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
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 at the manifest level; every track requires `title`, `composer`, `url` and `license` — tracks missing any are dropped — with `performer` and `sourceUrl` optional):
|
|
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';
|