@joewinke/jatui 0.27.1 → 0.29.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/CLAUDE.md +37 -0
- package/dist/assets/sounds/README.md +27 -14
- package/dist/assets/sounds/attention.d.ts +1 -0
- package/dist/assets/sounds/attention.js +4 -0
- package/dist/assets/sounds/chime.d.ts +1 -0
- package/dist/assets/sounds/chime.js +4 -0
- package/dist/assets/sounds/error-soft.d.ts +1 -0
- package/dist/assets/sounds/error-soft.js +4 -0
- package/dist/assets/sounds/select.d.ts +1 -0
- package/dist/assets/sounds/select.js +4 -0
- package/dist/assets/sounds/tick.d.ts +1 -0
- package/dist/assets/sounds/tick.js +4 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +10 -2
- package/dist/motion/easing.d.ts +14 -0
- package/dist/motion/easing.js +67 -0
- package/dist/motion/index.d.ts +17 -0
- package/dist/motion/index.js +11 -0
- package/dist/motion/level.d.ts +88 -0
- package/dist/motion/level.js +132 -0
- package/dist/motion/store.d.ts +40 -0
- package/dist/motion/store.js +132 -0
- package/dist/motion/transitions.d.ts +72 -0
- package/dist/motion/transitions.js +112 -0
- package/dist/sound/SoundSettings.svelte +209 -0
- package/dist/sound/SoundSettings.svelte.d.ts +18 -0
- package/dist/sound/backend.d.ts +24 -0
- package/dist/sound/backend.js +96 -0
- package/dist/sound/index.d.ts +20 -0
- package/dist/sound/index.js +14 -0
- package/dist/sound/level.d.ts +42 -0
- package/dist/sound/level.js +63 -0
- package/dist/sound/names.d.ts +62 -0
- package/dist/sound/names.js +196 -0
- package/dist/sound/player.d.ts +73 -0
- package/dist/sound/player.js +158 -0
- package/dist/sound/policy.d.ts +36 -0
- package/dist/sound/policy.js +56 -0
- package/dist/sound/preferences.d.ts +76 -0
- package/dist/sound/preferences.js +152 -0
- package/dist/styles/motion.css +88 -9
- package/dist/transitions/glide.d.ts +18 -6
- package/dist/transitions/glide.js +26 -31
- package/dist/utils/sound.d.ts +6 -0
- package/dist/utils/sound.js +12 -54
- package/package.json +1 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio output backend. The player talks to this interface so its policy and
|
|
3
|
+
* bookkeeping run in node tests with a fake; `createWebAudioBackend` is the
|
|
4
|
+
* real one (lazy AudioContext, decoded-buffer playback through a GainNode).
|
|
5
|
+
*/
|
|
6
|
+
function dataUriBytes(uri) {
|
|
7
|
+
const binary = atob(uri.slice(uri.indexOf(',') + 1));
|
|
8
|
+
const bytes = new Uint8Array(binary.length);
|
|
9
|
+
for (let i = 0; i < binary.length; i++)
|
|
10
|
+
bytes[i] = binary.charCodeAt(i);
|
|
11
|
+
return bytes.buffer;
|
|
12
|
+
}
|
|
13
|
+
export function createWebAudioBackend() {
|
|
14
|
+
let ctx = null;
|
|
15
|
+
function context() {
|
|
16
|
+
if (ctx)
|
|
17
|
+
return ctx;
|
|
18
|
+
if (typeof AudioContext === 'undefined')
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
ctx = new AudioContext();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return ctx;
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
async load(src) {
|
|
30
|
+
const audio = context();
|
|
31
|
+
if (!audio)
|
|
32
|
+
return null;
|
|
33
|
+
try {
|
|
34
|
+
const bytes = src.startsWith('data:')
|
|
35
|
+
? dataUriBytes(src)
|
|
36
|
+
: await (await fetch(src)).arrayBuffer();
|
|
37
|
+
return await audio.decodeAudioData(bytes);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null; // codec unsupported / network error — stay silent
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
start(clip, { gain, loop, loopGapMs, playbackRate = 1, onEnded }) {
|
|
44
|
+
const audio = context();
|
|
45
|
+
if (!audio || !clip)
|
|
46
|
+
return null;
|
|
47
|
+
// Autoplay policy: the context starts suspended until a user gesture.
|
|
48
|
+
if (audio.state === 'suspended')
|
|
49
|
+
audio.resume().catch(() => { });
|
|
50
|
+
const out = audio.createGain();
|
|
51
|
+
out.gain.value = gain;
|
|
52
|
+
out.connect(audio.destination);
|
|
53
|
+
let source = null;
|
|
54
|
+
let timer = null;
|
|
55
|
+
let stopped = false;
|
|
56
|
+
const once = () => {
|
|
57
|
+
if (stopped)
|
|
58
|
+
return;
|
|
59
|
+
source = audio.createBufferSource();
|
|
60
|
+
source.buffer = clip;
|
|
61
|
+
source.playbackRate.value = playbackRate;
|
|
62
|
+
source.loop = loop && loopGapMs <= 0;
|
|
63
|
+
source.connect(out);
|
|
64
|
+
source.onended = () => {
|
|
65
|
+
if (stopped)
|
|
66
|
+
return;
|
|
67
|
+
if (loop && loopGapMs > 0)
|
|
68
|
+
timer = setTimeout(once, loopGapMs);
|
|
69
|
+
else if (!loop) {
|
|
70
|
+
stopped = true;
|
|
71
|
+
out.disconnect();
|
|
72
|
+
onEnded?.();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
source.start();
|
|
76
|
+
};
|
|
77
|
+
once();
|
|
78
|
+
return {
|
|
79
|
+
stop() {
|
|
80
|
+
if (stopped)
|
|
81
|
+
return;
|
|
82
|
+
stopped = true;
|
|
83
|
+
if (timer)
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
try {
|
|
86
|
+
source?.stop();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// already ended
|
|
90
|
+
}
|
|
91
|
+
out.disconnect();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sound — first-class sibling of motion (jatui-kf62q).
|
|
3
|
+
*
|
|
4
|
+
* One preferences store every app sound goes through, semantic sound names
|
|
5
|
+
* mapped to bundled CC0 clips (or a host's own files), a pure policy function,
|
|
6
|
+
* and a drop-in SoundSettings section. Discipline: docs/sound.md.
|
|
7
|
+
*/
|
|
8
|
+
export { SOUND_CATEGORIES, SOUND_NAMES, SOUND_SPECS, CUSTOM_SOUND_SPEC, BUILTIN_CLIPS, SOUND_CLIP_VARIANTS, PENTATONIC_STEPS, isSoundName, soundSpecFor, pentatonicPitch } from './names';
|
|
9
|
+
export type { SoundCategory, SoundName, SoundSpec } from './names';
|
|
10
|
+
export { DEFAULT_SOUND_PREFERENCES, SOUND_PREFS_STORAGE_KEY, LEGACY_SOUND_ENABLED_KEY, createSoundPreferences, getSoundPreferences, setSoundPreferences, normalizeSoundPreferences } from './preferences';
|
|
11
|
+
export type { SoundPreferences, SoundPreferencesPatch, SoundPreferencesStore, SoundPreferencesStorage, SoundQuietMode, QuietHours, CreateSoundPreferencesOptions } from './preferences';
|
|
12
|
+
export { SOUND_LEVELS, DEFAULT_SOUND_LEVEL, SOUND_LEVEL_ATTR, isSoundLevel, parseSoundLevel, resolveSoundLevel, setSoundLevel, soundLevelDefaults } from './level';
|
|
13
|
+
export type { SoundLevel } from './level';
|
|
14
|
+
export { decideSound, inQuietHours, readSoundEnvironment } from './policy';
|
|
15
|
+
export type { SoundEnvironment, SoundRequest, SoundDecision, SoundBlockReason } from './policy';
|
|
16
|
+
export { createSoundPlayer, getSoundPlayer, setSoundPlayer, playSound, rateFor as soundPlaybackRate } from './player';
|
|
17
|
+
export type { SoundId, SoundPlayer, SoundHandle, PlaySoundOptions, CreateSoundPlayerOptions } from './player';
|
|
18
|
+
export { createWebAudioBackend } from './backend';
|
|
19
|
+
export type { SoundBackend, SoundVoice, StartOptions as SoundStartOptions } from './backend';
|
|
20
|
+
export { default as SoundSettings } from './SoundSettings.svelte';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sound — first-class sibling of motion (jatui-kf62q).
|
|
3
|
+
*
|
|
4
|
+
* One preferences store every app sound goes through, semantic sound names
|
|
5
|
+
* mapped to bundled CC0 clips (or a host's own files), a pure policy function,
|
|
6
|
+
* and a drop-in SoundSettings section. Discipline: docs/sound.md.
|
|
7
|
+
*/
|
|
8
|
+
export { SOUND_CATEGORIES, SOUND_NAMES, SOUND_SPECS, CUSTOM_SOUND_SPEC, BUILTIN_CLIPS, SOUND_CLIP_VARIANTS, PENTATONIC_STEPS, isSoundName, soundSpecFor, pentatonicPitch } from './names';
|
|
9
|
+
export { DEFAULT_SOUND_PREFERENCES, SOUND_PREFS_STORAGE_KEY, LEGACY_SOUND_ENABLED_KEY, createSoundPreferences, getSoundPreferences, setSoundPreferences, normalizeSoundPreferences } from './preferences';
|
|
10
|
+
export { SOUND_LEVELS, DEFAULT_SOUND_LEVEL, SOUND_LEVEL_ATTR, isSoundLevel, parseSoundLevel, resolveSoundLevel, setSoundLevel, soundLevelDefaults } from './level';
|
|
11
|
+
export { decideSound, inQuietHours, readSoundEnvironment } from './policy';
|
|
12
|
+
export { createSoundPlayer, getSoundPlayer, setSoundPlayer, playSound, rateFor as soundPlaybackRate } from './player';
|
|
13
|
+
export { createWebAudioBackend } from './backend';
|
|
14
|
+
export { default as SoundSettings } from './SoundSettings.svelte';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site sound level — the host's say in whether UI sound starts on.
|
|
3
|
+
*
|
|
4
|
+
* A host stamps `data-sound="off|subtle"` on `<html>` (or any subtree) the
|
|
5
|
+
* same way it stamps `data-motion`, usually from per-site config. The level
|
|
6
|
+
* only changes the DEFAULT for a visitor with no saved preference:
|
|
7
|
+
*
|
|
8
|
+
* off the library default — UI + notification sounds start off.
|
|
9
|
+
* subtle they start on (`enabled: true`) for a visitor who never chose.
|
|
10
|
+
*
|
|
11
|
+
* A saved visitor preference (the panel mute, SoundSettings) always wins,
|
|
12
|
+
* because `createSoundPreferences` applies `defaults` only when nothing is
|
|
13
|
+
* saved. The level never touches volume, categories, quiet hours or the
|
|
14
|
+
* `decideSound` policy — hidden tabs, quiet hours and `quiet: auto` (reduced
|
|
15
|
+
* motion) still silence what they silence.
|
|
16
|
+
*/
|
|
17
|
+
import type { SoundPreferencesPatch } from './preferences';
|
|
18
|
+
export type SoundLevel = 'off' | 'subtle';
|
|
19
|
+
export declare const SOUND_LEVELS: readonly SoundLevel[];
|
|
20
|
+
export declare const DEFAULT_SOUND_LEVEL: SoundLevel;
|
|
21
|
+
/** The attribute a host sets on `<html>` or any subtree. */
|
|
22
|
+
export declare const SOUND_LEVEL_ATTR = "data-sound";
|
|
23
|
+
export declare function isSoundLevel(value: unknown): value is SoundLevel;
|
|
24
|
+
/** Coerce any attribute/config value to a level; unknown values become `off`. */
|
|
25
|
+
export declare function parseSoundLevel(value: unknown, fallback?: SoundLevel): SoundLevel;
|
|
26
|
+
/**
|
|
27
|
+
* The level in force for `node`: its nearest `[data-sound]` ancestor
|
|
28
|
+
* (inclusive), else `<html>`'s, else `off`. An unknown value counts as `off`
|
|
29
|
+
* — a typo fails silent, never loud. SSR-safe (`off`).
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveSoundLevel(node?: Element | null): SoundLevel;
|
|
32
|
+
/**
|
|
33
|
+
* Set the level on a surface (default `<html>`). `null` removes the attribute.
|
|
34
|
+
* No-op without a DOM. Set it before the first sound plays: the shared
|
|
35
|
+
* preferences store reads the level once, when it is created.
|
|
36
|
+
*/
|
|
37
|
+
export declare function setSoundLevel(level: SoundLevel | null, target?: Element | null): void;
|
|
38
|
+
/**
|
|
39
|
+
* The preference defaults a level implies, for `createSoundPreferences({ defaults })`.
|
|
40
|
+
* A panel scoping its own store to a subtree passes `soundLevelDefaults(resolveSoundLevel(panelEl))`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function soundLevelDefaults(level: SoundLevel): SoundPreferencesPatch;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site sound level — the host's say in whether UI sound starts on.
|
|
3
|
+
*
|
|
4
|
+
* A host stamps `data-sound="off|subtle"` on `<html>` (or any subtree) the
|
|
5
|
+
* same way it stamps `data-motion`, usually from per-site config. The level
|
|
6
|
+
* only changes the DEFAULT for a visitor with no saved preference:
|
|
7
|
+
*
|
|
8
|
+
* off the library default — UI + notification sounds start off.
|
|
9
|
+
* subtle they start on (`enabled: true`) for a visitor who never chose.
|
|
10
|
+
*
|
|
11
|
+
* A saved visitor preference (the panel mute, SoundSettings) always wins,
|
|
12
|
+
* because `createSoundPreferences` applies `defaults` only when nothing is
|
|
13
|
+
* saved. The level never touches volume, categories, quiet hours or the
|
|
14
|
+
* `decideSound` policy — hidden tabs, quiet hours and `quiet: auto` (reduced
|
|
15
|
+
* motion) still silence what they silence.
|
|
16
|
+
*/
|
|
17
|
+
export const SOUND_LEVELS = ['off', 'subtle'];
|
|
18
|
+
export const DEFAULT_SOUND_LEVEL = 'off';
|
|
19
|
+
/** The attribute a host sets on `<html>` or any subtree. */
|
|
20
|
+
export const SOUND_LEVEL_ATTR = 'data-sound';
|
|
21
|
+
export function isSoundLevel(value) {
|
|
22
|
+
return typeof value === 'string' && SOUND_LEVELS.includes(value);
|
|
23
|
+
}
|
|
24
|
+
/** Coerce any attribute/config value to a level; unknown values become `off`. */
|
|
25
|
+
export function parseSoundLevel(value, fallback = DEFAULT_SOUND_LEVEL) {
|
|
26
|
+
if (typeof value !== 'string')
|
|
27
|
+
return fallback;
|
|
28
|
+
const v = value.trim().toLowerCase();
|
|
29
|
+
return isSoundLevel(v) ? v : fallback;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The level in force for `node`: its nearest `[data-sound]` ancestor
|
|
33
|
+
* (inclusive), else `<html>`'s, else `off`. An unknown value counts as `off`
|
|
34
|
+
* — a typo fails silent, never loud. SSR-safe (`off`).
|
|
35
|
+
*/
|
|
36
|
+
export function resolveSoundLevel(node) {
|
|
37
|
+
const el = (node ?? (typeof document !== 'undefined' ? document.documentElement : null));
|
|
38
|
+
if (!el || typeof el.closest !== 'function')
|
|
39
|
+
return DEFAULT_SOUND_LEVEL;
|
|
40
|
+
const host = el.closest(`[${SOUND_LEVEL_ATTR}]`);
|
|
41
|
+
return host ? parseSoundLevel(host.getAttribute(SOUND_LEVEL_ATTR)) : DEFAULT_SOUND_LEVEL;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Set the level on a surface (default `<html>`). `null` removes the attribute.
|
|
45
|
+
* No-op without a DOM. Set it before the first sound plays: the shared
|
|
46
|
+
* preferences store reads the level once, when it is created.
|
|
47
|
+
*/
|
|
48
|
+
export function setSoundLevel(level, target) {
|
|
49
|
+
const el = target ?? (typeof document !== 'undefined' ? document.documentElement : null);
|
|
50
|
+
if (!el)
|
|
51
|
+
return;
|
|
52
|
+
if (level === null)
|
|
53
|
+
el.removeAttribute(SOUND_LEVEL_ATTR);
|
|
54
|
+
else
|
|
55
|
+
el.setAttribute(SOUND_LEVEL_ATTR, level);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The preference defaults a level implies, for `createSoundPreferences({ defaults })`.
|
|
59
|
+
* A panel scoping its own store to a subtree passes `soundLevelDefaults(resolveSoundLevel(panelEl))`.
|
|
60
|
+
*/
|
|
61
|
+
export function soundLevelDefaults(level) {
|
|
62
|
+
return level === 'subtle' ? { enabled: true } : {};
|
|
63
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic sound names — what an app SAYS ("confirm", "incoming-call"), not
|
|
3
|
+
* which file it plays. Each name carries the policy facts the preferences
|
|
4
|
+
* layer needs: its category, a base volume, whether it is essential (survives
|
|
5
|
+
* the quiet/reduced-motion preference), and how it loops.
|
|
6
|
+
*
|
|
7
|
+
* Hosts can re-point any name at their own file (`player.register('incoming-call',
|
|
8
|
+
* '/ringer.mp3')`) and can register names of their own; unknown names fall back
|
|
9
|
+
* to `CUSTOM_SOUND_SPEC` (a non-essential UI sound).
|
|
10
|
+
*/
|
|
11
|
+
export type SoundCategory = 'ui' | 'notifications' | 'calls';
|
|
12
|
+
export declare const SOUND_CATEGORIES: readonly SoundCategory[];
|
|
13
|
+
export type SoundName = 'confirm' | 'error' | 'notify' | 'message' | 'send' | 'toggle' | 'tap' | 'tick' | 'select' | 'chime' | 'attention' | 'incoming-call';
|
|
14
|
+
export declare const SOUND_NAMES: readonly SoundName[];
|
|
15
|
+
export interface SoundSpec {
|
|
16
|
+
category: SoundCategory;
|
|
17
|
+
/** Base gain 0–1 before the master volume is applied. */
|
|
18
|
+
volume: number;
|
|
19
|
+
/** Essential UI sounds still play when the quiet preference is active. */
|
|
20
|
+
essential: boolean;
|
|
21
|
+
/** Loop until the returned handle is stopped (call ringers). */
|
|
22
|
+
loop: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Silence between repeats when looping, in ms. 0 = seamless loop (a real
|
|
25
|
+
* ringer file); >0 replays a short clip on an interval (the built-in ring).
|
|
26
|
+
*/
|
|
27
|
+
loopGapMs: number;
|
|
28
|
+
/**
|
|
29
|
+
* Throttle floor in ms: a play within this long of the last one that
|
|
30
|
+
* actually sounded is DROPPED (never queued). 0 = no throttle.
|
|
31
|
+
*/
|
|
32
|
+
minIntervalMs: number;
|
|
33
|
+
description: string;
|
|
34
|
+
}
|
|
35
|
+
export declare const SOUND_SPECS: Record<SoundName, SoundSpec>;
|
|
36
|
+
/** Spec for a host-registered name that is not one of the semantic names. */
|
|
37
|
+
export declare const CUSTOM_SOUND_SPEC: SoundSpec;
|
|
38
|
+
export declare function isSoundName(name: string): name is SoundName;
|
|
39
|
+
export declare function soundSpecFor(name: string): SoundSpec;
|
|
40
|
+
/**
|
|
41
|
+
* The bundled CC0 clip for each semantic name, loaded on first use so apps
|
|
42
|
+
* that never play a sound never ship the base64 payloads in their entry chunk.
|
|
43
|
+
*/
|
|
44
|
+
export declare const BUILTIN_CLIPS: Record<SoundName, () => Promise<string>>;
|
|
45
|
+
/**
|
|
46
|
+
* Alternative bundled clips a host can point a name at:
|
|
47
|
+
*
|
|
48
|
+
* await getSoundPlayer().register('error', await SOUND_CLIP_VARIANTS['error-soft'](), { volume: 0.08 })
|
|
49
|
+
*
|
|
50
|
+
* `error-soft` is two low sine tones (392 → 330 Hz) with no buzz — for
|
|
51
|
+
* surfaces where the stock error buzz is too harsh (booking flows).
|
|
52
|
+
*/
|
|
53
|
+
export declare const SOUND_CLIP_VARIANTS: {
|
|
54
|
+
'error-soft': () => Promise<string>;
|
|
55
|
+
};
|
|
56
|
+
/** Semitone offsets of a rising major pentatonic — one per step of a 7-item strip. */
|
|
57
|
+
export declare const PENTATONIC_STEPS: readonly number[];
|
|
58
|
+
/**
|
|
59
|
+
* Semitone offset for item `index` on a rising pentatonic (wraps up an octave
|
|
60
|
+
* past the table), for `playSound('tick', { pitch: pentatonicPitch(i) })`.
|
|
61
|
+
*/
|
|
62
|
+
export declare function pentatonicPitch(index: number): number;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic sound names — what an app SAYS ("confirm", "incoming-call"), not
|
|
3
|
+
* which file it plays. Each name carries the policy facts the preferences
|
|
4
|
+
* layer needs: its category, a base volume, whether it is essential (survives
|
|
5
|
+
* the quiet/reduced-motion preference), and how it loops.
|
|
6
|
+
*
|
|
7
|
+
* Hosts can re-point any name at their own file (`player.register('incoming-call',
|
|
8
|
+
* '/ringer.mp3')`) and can register names of their own; unknown names fall back
|
|
9
|
+
* to `CUSTOM_SOUND_SPEC` (a non-essential UI sound).
|
|
10
|
+
*/
|
|
11
|
+
export const SOUND_CATEGORIES = ['ui', 'notifications', 'calls'];
|
|
12
|
+
export const SOUND_NAMES = [
|
|
13
|
+
'confirm',
|
|
14
|
+
'error',
|
|
15
|
+
'notify',
|
|
16
|
+
'message',
|
|
17
|
+
'send',
|
|
18
|
+
'toggle',
|
|
19
|
+
'tap',
|
|
20
|
+
'tick',
|
|
21
|
+
'select',
|
|
22
|
+
'chime',
|
|
23
|
+
'attention',
|
|
24
|
+
'incoming-call'
|
|
25
|
+
];
|
|
26
|
+
export const SOUND_SPECS = {
|
|
27
|
+
confirm: {
|
|
28
|
+
category: 'ui',
|
|
29
|
+
volume: 0.15,
|
|
30
|
+
essential: false,
|
|
31
|
+
loop: false,
|
|
32
|
+
loopGapMs: 0,
|
|
33
|
+
minIntervalMs: 0,
|
|
34
|
+
description: 'An important action succeeded (saved, paid, published)'
|
|
35
|
+
},
|
|
36
|
+
error: {
|
|
37
|
+
category: 'ui',
|
|
38
|
+
volume: 0.2,
|
|
39
|
+
essential: true,
|
|
40
|
+
loop: false,
|
|
41
|
+
loopGapMs: 0,
|
|
42
|
+
minIntervalMs: 0,
|
|
43
|
+
description: 'An action failed'
|
|
44
|
+
},
|
|
45
|
+
notify: {
|
|
46
|
+
category: 'notifications',
|
|
47
|
+
volume: 0.3,
|
|
48
|
+
essential: false,
|
|
49
|
+
loop: false,
|
|
50
|
+
loopGapMs: 0,
|
|
51
|
+
minIntervalMs: 0,
|
|
52
|
+
description: 'Something happened the user did not cause'
|
|
53
|
+
},
|
|
54
|
+
message: {
|
|
55
|
+
category: 'notifications',
|
|
56
|
+
volume: 0.3,
|
|
57
|
+
essential: false,
|
|
58
|
+
loop: false,
|
|
59
|
+
loopGapMs: 0,
|
|
60
|
+
minIntervalMs: 0,
|
|
61
|
+
description: 'A message arrived from another person'
|
|
62
|
+
},
|
|
63
|
+
send: {
|
|
64
|
+
category: 'ui',
|
|
65
|
+
volume: 0.15,
|
|
66
|
+
essential: false,
|
|
67
|
+
loop: false,
|
|
68
|
+
loopGapMs: 0,
|
|
69
|
+
minIntervalMs: 0,
|
|
70
|
+
description: 'The user sent a message or submitted a form'
|
|
71
|
+
},
|
|
72
|
+
toggle: {
|
|
73
|
+
category: 'ui',
|
|
74
|
+
volume: 0.12,
|
|
75
|
+
essential: false,
|
|
76
|
+
loop: false,
|
|
77
|
+
loopGapMs: 0,
|
|
78
|
+
minIntervalMs: 0,
|
|
79
|
+
description: 'A switch changed state'
|
|
80
|
+
},
|
|
81
|
+
tap: {
|
|
82
|
+
category: 'ui',
|
|
83
|
+
volume: 0.1,
|
|
84
|
+
essential: false,
|
|
85
|
+
loop: false,
|
|
86
|
+
loopGapMs: 0,
|
|
87
|
+
minIntervalMs: 0,
|
|
88
|
+
description: 'A deliberate, low-frequency press'
|
|
89
|
+
},
|
|
90
|
+
tick: {
|
|
91
|
+
category: 'ui',
|
|
92
|
+
volume: 0.03,
|
|
93
|
+
essential: false,
|
|
94
|
+
loop: false,
|
|
95
|
+
loopGapMs: 0,
|
|
96
|
+
minIntervalMs: 80,
|
|
97
|
+
description: 'The pointer or keyboard focus crossed onto a choice (pointer-fine only, pitch by position)'
|
|
98
|
+
},
|
|
99
|
+
select: {
|
|
100
|
+
category: 'ui',
|
|
101
|
+
volume: 0.08,
|
|
102
|
+
essential: false,
|
|
103
|
+
loop: false,
|
|
104
|
+
loopGapMs: 0,
|
|
105
|
+
minIntervalMs: 120,
|
|
106
|
+
description: 'The user chose something (a card, a day, a time, an answer)'
|
|
107
|
+
},
|
|
108
|
+
chime: {
|
|
109
|
+
category: 'ui',
|
|
110
|
+
volume: 0.12,
|
|
111
|
+
essential: false,
|
|
112
|
+
loop: false,
|
|
113
|
+
loopGapMs: 0,
|
|
114
|
+
minIntervalMs: 0,
|
|
115
|
+
description: 'A big thing just went right (the visit is booked) — rarer and warmer than confirm'
|
|
116
|
+
},
|
|
117
|
+
attention: {
|
|
118
|
+
category: 'ui',
|
|
119
|
+
volume: 0.1,
|
|
120
|
+
essential: false,
|
|
121
|
+
loop: false,
|
|
122
|
+
loopGapMs: 0,
|
|
123
|
+
minIntervalMs: 0,
|
|
124
|
+
description: 'Read this before you continue — calm, never an alarm'
|
|
125
|
+
},
|
|
126
|
+
'incoming-call': {
|
|
127
|
+
category: 'calls',
|
|
128
|
+
volume: 0.5,
|
|
129
|
+
essential: true,
|
|
130
|
+
loop: true,
|
|
131
|
+
loopGapMs: 1200,
|
|
132
|
+
minIntervalMs: 0,
|
|
133
|
+
description: 'Someone is calling — rings until answered or declined'
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
/** Spec for a host-registered name that is not one of the semantic names. */
|
|
137
|
+
export const CUSTOM_SOUND_SPEC = {
|
|
138
|
+
category: 'ui',
|
|
139
|
+
volume: 0.15,
|
|
140
|
+
essential: false,
|
|
141
|
+
loop: false,
|
|
142
|
+
loopGapMs: 0,
|
|
143
|
+
minIntervalMs: 0,
|
|
144
|
+
description: 'Host-registered sound'
|
|
145
|
+
};
|
|
146
|
+
/** Pre-semantic clip names (utils/sound.ts era) mapped to their semantic spec. */
|
|
147
|
+
const LEGACY_ALIASES = { success: 'confirm', pop: 'tap' };
|
|
148
|
+
export function isSoundName(name) {
|
|
149
|
+
return SOUND_NAMES.includes(name);
|
|
150
|
+
}
|
|
151
|
+
export function soundSpecFor(name) {
|
|
152
|
+
if (isSoundName(name))
|
|
153
|
+
return SOUND_SPECS[name];
|
|
154
|
+
const alias = LEGACY_ALIASES[name];
|
|
155
|
+
return alias ? SOUND_SPECS[alias] : CUSTOM_SOUND_SPEC;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The bundled CC0 clip for each semantic name, loaded on first use so apps
|
|
159
|
+
* that never play a sound never ship the base64 payloads in their entry chunk.
|
|
160
|
+
*/
|
|
161
|
+
export const BUILTIN_CLIPS = {
|
|
162
|
+
confirm: () => import('../assets/sounds/success').then((m) => m.successSound),
|
|
163
|
+
error: () => import('../assets/sounds/error').then((m) => m.errorSound),
|
|
164
|
+
notify: () => import('../assets/sounds/notify').then((m) => m.notifySound),
|
|
165
|
+
message: () => import('../assets/sounds/notify').then((m) => m.notifySound),
|
|
166
|
+
send: () => import('../assets/sounds/send').then((m) => m.sendSound),
|
|
167
|
+
toggle: () => import('../assets/sounds/toggle').then((m) => m.toggleSound),
|
|
168
|
+
tap: () => import('../assets/sounds/pop').then((m) => m.popSound),
|
|
169
|
+
tick: () => import('../assets/sounds/tick').then((m) => m.tickSound),
|
|
170
|
+
select: () => import('../assets/sounds/select').then((m) => m.selectSound),
|
|
171
|
+
chime: () => import('../assets/sounds/chime').then((m) => m.chimeSound),
|
|
172
|
+
attention: () => import('../assets/sounds/attention').then((m) => m.attentionSound),
|
|
173
|
+
'incoming-call': () => import('../assets/sounds/notify').then((m) => m.notifySound)
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Alternative bundled clips a host can point a name at:
|
|
177
|
+
*
|
|
178
|
+
* await getSoundPlayer().register('error', await SOUND_CLIP_VARIANTS['error-soft'](), { volume: 0.08 })
|
|
179
|
+
*
|
|
180
|
+
* `error-soft` is two low sine tones (392 → 330 Hz) with no buzz — for
|
|
181
|
+
* surfaces where the stock error buzz is too harsh (booking flows).
|
|
182
|
+
*/
|
|
183
|
+
export const SOUND_CLIP_VARIANTS = {
|
|
184
|
+
'error-soft': () => import('../assets/sounds/error-soft').then((m) => m.errorSoftSound)
|
|
185
|
+
};
|
|
186
|
+
/** Semitone offsets of a rising major pentatonic — one per step of a 7-item strip. */
|
|
187
|
+
export const PENTATONIC_STEPS = [0, 2, 4, 7, 9, 12, 14];
|
|
188
|
+
/**
|
|
189
|
+
* Semitone offset for item `index` on a rising pentatonic (wraps up an octave
|
|
190
|
+
* past the table), for `playSound('tick', { pitch: pentatonicPitch(i) })`.
|
|
191
|
+
*/
|
|
192
|
+
export function pentatonicPitch(index) {
|
|
193
|
+
const i = Math.max(0, Math.floor(index));
|
|
194
|
+
const n = 5; // notes per octave — the table's last two entries are the next octave
|
|
195
|
+
return PENTATONIC_STEPS[i % n] + 12 * Math.floor(i / n);
|
|
196
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sound player — semantic names in, policy-gated audio out.
|
|
3
|
+
*
|
|
4
|
+
* const sound = getSoundPlayer()
|
|
5
|
+
* sound.play('confirm')
|
|
6
|
+
* await sound.register('incoming-call', '/sounds/ringer.mp3', { loopGapMs: 0 })
|
|
7
|
+
* const ring = sound.play('incoming-call') // loops
|
|
8
|
+
* ring.stop() // answered / declined
|
|
9
|
+
*
|
|
10
|
+
* `play` is synchronous and never throws: a sound that is blocked, missing or
|
|
11
|
+
* still decoding returns a handle anyway (`handle.blocked` says why it is
|
|
12
|
+
* silent). Looping sounds are re-checked whenever preferences change, so
|
|
13
|
+
* turning sound off or muting "calls" silences a ringing call immediately.
|
|
14
|
+
*/
|
|
15
|
+
import { type SoundBackend } from './backend';
|
|
16
|
+
import { type SoundBlockReason, type SoundEnvironment } from './policy';
|
|
17
|
+
import { type SoundPreferencesStore } from './preferences';
|
|
18
|
+
import { type SoundCategory, type SoundName, type SoundSpec } from './names';
|
|
19
|
+
/** A semantic name, or any name the host registered itself. */
|
|
20
|
+
export type SoundId = SoundName | (string & {});
|
|
21
|
+
export interface PlaySoundOptions {
|
|
22
|
+
/** Base gain 0–1, replacing the spec's (still scaled by master volume). */
|
|
23
|
+
volume?: number;
|
|
24
|
+
category?: SoundCategory;
|
|
25
|
+
essential?: boolean;
|
|
26
|
+
loop?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Pitch offset in semitones, applied through `playbackRate` (so it also
|
|
29
|
+
* shortens/lengthens the clip). `pentatonicPitch(i)` gives a rising scale.
|
|
30
|
+
*/
|
|
31
|
+
pitch?: number;
|
|
32
|
+
/** Explicit playback rate; multiplied with `pitch` when both are given. */
|
|
33
|
+
playbackRate?: number;
|
|
34
|
+
/** Throttle floor in ms for this play, replacing the spec's `minIntervalMs`. */
|
|
35
|
+
minIntervalMs?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Explicit user test (a "Preview" button in settings): bypasses every gate
|
|
38
|
+
* except master volume (and the throttle). Never use it for app-triggered sounds.
|
|
39
|
+
*/
|
|
40
|
+
preview?: boolean;
|
|
41
|
+
}
|
|
42
|
+
export interface SoundHandle {
|
|
43
|
+
stop(): void;
|
|
44
|
+
/** True while audible or still decoding toward audible. */
|
|
45
|
+
readonly playing: boolean;
|
|
46
|
+
/** Why it is silent, when the policy blocked it. */
|
|
47
|
+
readonly blocked: SoundBlockReason | null;
|
|
48
|
+
}
|
|
49
|
+
export interface SoundPlayer {
|
|
50
|
+
/** Point a name at a clip (data URI or URL) and decode it. */
|
|
51
|
+
register(name: SoundId, src: string, spec?: Partial<SoundSpec>): Promise<void>;
|
|
52
|
+
play(name: SoundId, options?: PlaySoundOptions): SoundHandle;
|
|
53
|
+
/** Decode clips ahead of time (defaults to every semantic name). */
|
|
54
|
+
preload(names?: readonly SoundId[]): Promise<void>;
|
|
55
|
+
stopAll(): void;
|
|
56
|
+
destroy(): void;
|
|
57
|
+
}
|
|
58
|
+
export interface CreateSoundPlayerOptions {
|
|
59
|
+
preferences?: SoundPreferencesStore;
|
|
60
|
+
environment?: () => SoundEnvironment;
|
|
61
|
+
backend?: SoundBackend;
|
|
62
|
+
/** Monotonic ms clock for the throttle (test seam). Defaults to `performance.now`. */
|
|
63
|
+
now?: () => number;
|
|
64
|
+
}
|
|
65
|
+
export declare function createSoundPlayer(options?: CreateSoundPlayerOptions): SoundPlayer;
|
|
66
|
+
/** Effective playback rate for a play: `playbackRate × 2^(pitch/12)`, clamped to a sane range. */
|
|
67
|
+
export declare function rateFor(opts: Pick<PlaySoundOptions, 'pitch' | 'playbackRate'>): number;
|
|
68
|
+
/** The app-wide player, bound to the shared preferences store. */
|
|
69
|
+
export declare function getSoundPlayer(): SoundPlayer;
|
|
70
|
+
/** Test seam: replace (or with no argument, drop) the shared player. */
|
|
71
|
+
export declare function setSoundPlayer(player: SoundPlayer | null): void;
|
|
72
|
+
/** Shorthand for `getSoundPlayer().play(name, options)`. */
|
|
73
|
+
export declare function playSound(name: SoundId, options?: PlaySoundOptions): SoundHandle;
|