@joewinke/jatui 0.46.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/ambience/README.md +131 -0
- package/dist/ambience/breath.d.ts +22 -0
- package/dist/ambience/breath.js +43 -0
- package/dist/ambience/default.d.ts +24 -0
- package/dist/ambience/default.js +34 -0
- package/dist/ambience/engine.d.ts +21 -0
- package/dist/ambience/engine.js +313 -0
- package/dist/ambience/index.d.ts +23 -0
- package/dist/ambience/index.js +27 -0
- package/dist/ambience/master.d.ts +49 -0
- package/dist/ambience/master.js +83 -0
- package/dist/ambience/persist.d.ts +11 -0
- package/dist/ambience/persist.js +24 -0
- package/dist/ambience/registry.d.ts +11 -0
- package/dist/ambience/registry.js +16 -0
- package/dist/ambience/scapes/focus.d.ts +55 -0
- package/dist/ambience/scapes/focus.js +245 -0
- package/dist/ambience/scapes/hibiscus.d.ts +103 -0
- package/dist/ambience/scapes/hibiscus.js +345 -0
- package/dist/ambience/scapes/nature.d.ts +36 -0
- package/dist/ambience/scapes/nature.js +325 -0
- package/dist/ambience/scapes/noise.d.ts +77 -0
- package/dist/ambience/scapes/noise.js +226 -0
- package/dist/ambience/scapes/playlist.d.ts +129 -0
- package/dist/ambience/scapes/playlist.js +569 -0
- package/dist/ambience/types.d.ts +80 -0
- package/dist/ambience/types.js +9 -0
- package/dist/components/AmbienceToggle.svelte +599 -0
- package/dist/components/AmbienceToggle.svelte.d.ts +74 -0
- package/dist/components/VideoPlayer.svelte +73 -6
- package/dist/components/VideoPlayer.svelte.d.ts +9 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +9 -0
- package/dist/video/keys.d.ts +4 -3
- package/dist/video/keys.js +5 -4
- package/dist/video/settings/remembered.d.ts +9 -0
- package/dist/video/settings/remembered.js +9 -0
- package/package.json +6 -1
|
@@ -0,0 +1,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;
|