sonarium 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +271 -0
  3. package/dist/index.d.ts +1000 -0
  4. package/dist/index.js +2716 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/sonarium.iife.js +236 -0
  7. package/dist/sonarium.iife.js.map +1 -0
  8. package/package.json +63 -0
  9. package/src/core/engine.ts +468 -0
  10. package/src/core/listener.ts +61 -0
  11. package/src/core/matter-voice.ts +240 -0
  12. package/src/core/profile.ts +313 -0
  13. package/src/core/room.ts +135 -0
  14. package/src/core/scanner.ts +176 -0
  15. package/src/core/voices.ts +222 -0
  16. package/src/index.ts +74 -0
  17. package/src/interact/activate.ts +56 -0
  18. package/src/interact/drag.ts +52 -0
  19. package/src/interact/keyboard.ts +48 -0
  20. package/src/interact/midi.ts +40 -0
  21. package/src/interact/motion.ts +61 -0
  22. package/src/interact/pointer.ts +65 -0
  23. package/src/interact/scroll.ts +43 -0
  24. package/src/math/chroma.ts +111 -0
  25. package/src/math/mapping.ts +100 -0
  26. package/src/math/matter.ts +201 -0
  27. package/src/math/modular.ts +99 -0
  28. package/src/math/pulse.ts +64 -0
  29. package/src/math/scales.ts +90 -0
  30. package/src/math/util.ts +16 -0
  31. package/src/spatial/backend.ts +166 -0
  32. package/src/spatial/bus.ts +114 -0
  33. package/src/spatial/decoder.ts +63 -0
  34. package/src/spatial/encoder.ts +54 -0
  35. package/src/spatial/field.ts +75 -0
  36. package/src/spatial/perceptual.ts +43 -0
  37. package/src/spatial/room-foa.ts +81 -0
  38. package/src/spatial/rotation.ts +73 -0
  39. package/src/spatial/sh.ts +38 -0
  40. package/src/spatial/sphere.ts +36 -0
  41. package/src/themes/index.ts +60 -0
  42. package/src/types.ts +157 -0
  43. package/src/ui/gate.ts +65 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * FoaRoom — the Spat room model in the field (SPATIAL.md §3.3): early reflections encoded at
3
+ * mirror directions + the diffuse tail whose extent IS the envelopment factor. Imports Tone only.
4
+ */
5
+ import * as Tone from 'tone'
6
+ import { foaGains, DEG } from './sh'
7
+ import { reflectionScaleFromViewport } from './sphere'
8
+ import { roomGain, tailExtent, tailLevel, type PerceptualFactors } from './perceptual'
9
+ import { SourceEncoder, type BusInputs } from './encoder'
10
+
11
+ const ER_BASE_TIMES = [0.013, 0.019, 0.027, 0.034]
12
+ const ER_TAP_LEVELS = [1, 0.85, 0.7, 0.6]
13
+ const ER_DIRECTIONS: Array<[number, number]> = [
14
+ [110 * DEG, 30 * DEG],
15
+ [-110 * DEG, 30 * DEG],
16
+ [110 * DEG, -30 * DEG],
17
+ [-110 * DEG, -30 * DEG],
18
+ ]
19
+
20
+ export class FoaRoom {
21
+ /** Lanes tap their direct signal here (mono sum). */
22
+ readonly erIn: Tone.Gain
23
+ private erMaster: Tone.Gain
24
+ private delays: Tone.Delay[] = []
25
+ private split: Tone.Split
26
+ private tailL: SourceEncoder
27
+ private tailR: SourceEncoder
28
+ private nodes: { dispose(): void }[] = []
29
+
30
+ constructor(bus: BusInputs, reverb: Tone.Reverb, vw: number, factors: PerceptualFactors) {
31
+ this.erIn = new Tone.Gain(1)
32
+ this.erMaster = new Tone.Gain(0.22 * roomGain(factors.roomPresence))
33
+ this.erIn.connect(this.erMaster)
34
+
35
+ const scale = reflectionScaleFromViewport(vw)
36
+ ER_BASE_TIMES.forEach((t, i) => {
37
+ const delay = new Tone.Delay({ delayTime: t * scale, maxDelay: 0.12 })
38
+ const tap = new Tone.Gain(ER_TAP_LEVELS[i] as number)
39
+ const enc = new SourceEncoder(bus)
40
+ const [az, el] = ER_DIRECTIONS[i] as [number, number]
41
+ enc.set(foaGains(az, el, 0.35), 1)
42
+ this.erMaster.connect(delay)
43
+ delay.connect(tap)
44
+ tap.connect(enc.input)
45
+ this.delays.push(delay)
46
+ this.nodes.push(delay, tap, enc)
47
+ })
48
+
49
+ // Diffuse tail: reverb L/R become two wide sources at ±120°; their extent = envelopment.
50
+ this.split = new Tone.Split()
51
+ reverb.connect(this.split)
52
+ this.tailL = new SourceEncoder(bus)
53
+ this.tailR = new SourceEncoder(bus)
54
+ this.split.connect(this.tailL.input, 0)
55
+ this.split.connect(this.tailR.input, 1)
56
+ this.applyTail(factors)
57
+ this.nodes.push(this.erIn, this.erMaster, this.split, this.tailL, this.tailR)
58
+ }
59
+
60
+ private applyTail(f: PerceptualFactors): void {
61
+ const ext = tailExtent(f.envelopment)
62
+ const level = tailLevel(f.envelopment)
63
+ this.tailL.set(foaGains(120 * DEG, 0, ext), level)
64
+ this.tailR.set(foaGains(-120 * DEG, 0, ext), level)
65
+ }
66
+
67
+ setFactors(f: PerceptualFactors): void {
68
+ this.erMaster.gain.rampTo(0.22 * roomGain(f.roomPresence), 0.1)
69
+ this.applyTail(f)
70
+ }
71
+
72
+ setViewport(vw: number): void {
73
+ const scale = reflectionScaleFromViewport(vw)
74
+ this.delays.forEach((d, i) => d.delayTime.rampTo((ER_BASE_TIMES[i] as number) * scale, 0.3))
75
+ }
76
+
77
+ dispose(): void {
78
+ for (const n of this.nodes) n.dispose()
79
+ this.nodes = []
80
+ }
81
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Pure field rotation — SPATIAL.md §3.1. The 3×3 matrix acts on the (X, Y, Z) channel triple
3
+ * (W is rotation-invariant). Row-major: m[r][c], applied as X' = m00·X + m01·Y + m02·Z, etc.
4
+ * Composition R = Rz(yaw) · Ry(pitch) · Rx(roll). Angles in radians.
5
+ */
6
+
7
+ export type Mat3 = [
8
+ [number, number, number],
9
+ [number, number, number],
10
+ [number, number, number],
11
+ ]
12
+
13
+ export const IDENTITY: Mat3 = [
14
+ [1, 0, 0],
15
+ [0, 1, 0],
16
+ [0, 0, 1],
17
+ ]
18
+
19
+ function mul(a: Mat3, b: Mat3): Mat3 {
20
+ const out: Mat3 = [
21
+ [0, 0, 0],
22
+ [0, 0, 0],
23
+ [0, 0, 0],
24
+ ]
25
+ for (let r = 0; r < 3; r++)
26
+ for (let c = 0; c < 3; c++)
27
+ out[r]![c] = a[r]![0]! * b[0]![c]! + a[r]![1]! * b[1]![c]! + a[r]![2]! * b[2]![c]!
28
+ return out
29
+ }
30
+
31
+ /** Acts on column vectors (X, Y, Z)ᵀ in the AmbiX frame (+x fwd, +y left, +z up). */
32
+ export function rotationMatrix(yaw: number, pitch: number, roll = 0): Mat3 {
33
+ const cy = Math.cos(yaw), sy = Math.sin(yaw)
34
+ const cp = Math.cos(pitch), sp = Math.sin(pitch)
35
+ const cr = Math.cos(roll), sr = Math.sin(roll)
36
+ const Rz: Mat3 = [
37
+ [cy, -sy, 0],
38
+ [sy, cy, 0],
39
+ [0, 0, 1],
40
+ ]
41
+ const Ry: Mat3 = [
42
+ [cp, 0, sp],
43
+ [0, 1, 0],
44
+ [-sp, 0, cp],
45
+ ]
46
+ const Rx: Mat3 = [
47
+ [1, 0, 0],
48
+ [0, cr, -sr],
49
+ [0, sr, cr],
50
+ ]
51
+ return mul(mul(Rz, Ry), Rx)
52
+ }
53
+
54
+ export function applyMat3(m: Mat3, v: [number, number, number]): [number, number, number] {
55
+ return [
56
+ m[0]![0]! * v[0] + m[0]![1]! * v[1] + m[0]![2]! * v[2],
57
+ m[1]![0]! * v[0] + m[1]![1]! * v[1] + m[1]![2]! * v[2],
58
+ m[2]![0]! * v[0] + m[2]![1]! * v[1] + m[2]![2]! * v[2],
59
+ ]
60
+ }
61
+
62
+ /**
63
+ * Look semantics (SPATIAL.md §3.1): the field rotation is the INVERSE of the head's intrinsic
64
+ * yaw-then-pitch rotation. Looking right by α (head Rz(−α)) and up by β (head Ry(−β)) gives
65
+ * field R = (Rz(−α)·Ry(−β))⁻¹ = Ry(β)·Rz(α). The sign/composition decision lives here and
66
+ * only here, pinned by tests: look right → right-side sources arrive frontward; look up →
67
+ * overhead sources arrive frontward.
68
+ */
69
+ export function lookMatrix(yawRight: number, pitchUp: number): Mat3 {
70
+ const pitch = rotationMatrix(0, pitchUp, 0) // = Ry(pitchUp)
71
+ const yaw = rotationMatrix(yawRight, 0, 0) // = Rz(yawRight)
72
+ return mul(pitch, yaw)
73
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pure spherical-harmonic encoding — SPATIAL.md §1–2. AmbiX: ACN order [W, Y, Z, X],
3
+ * SN3D normalization, +x forward, +y left, +z up, +azimuth left. No DOM, no Tone.
4
+ */
5
+
6
+ export const DEG = Math.PI / 180
7
+
8
+ /** ACN channel indices, for readability everywhere else. */
9
+ export const ACN = { W: 0, Y: 1, Z: 2, X: 3 } as const
10
+
11
+ export interface FoaGains {
12
+ w: number
13
+ y: number
14
+ z: number
15
+ x: number
16
+ }
17
+
18
+ /**
19
+ * First-order encode of a plane wave from (azimuth, elevation), with extent σ blending the
20
+ * directional components into the omni channel (SPATIAL.md §2). Angles in radians.
21
+ */
22
+ export function foaGains(azimuth: number, elevation: number, extent = 0): FoaGains {
23
+ const s = Math.min(1, Math.max(0, extent))
24
+ const cosEl = Math.cos(elevation)
25
+ const dir = 1 - s
26
+ return {
27
+ w: 1 + 0.41 * s,
28
+ y: dir * Math.sin(azimuth) * cosEl,
29
+ z: dir * Math.sin(elevation),
30
+ x: dir * Math.cos(azimuth) * cosEl,
31
+ }
32
+ }
33
+
34
+ /** Unit direction vector for (azimuth, elevation) in the AmbiX frame. */
35
+ export function unitVector(azimuth: number, elevation: number): [number, number, number] {
36
+ const cosEl = Math.cos(elevation)
37
+ return [Math.cos(azimuth) * cosEl, Math.sin(azimuth) * cosEl, Math.sin(elevation)]
38
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Pure sphere mappings — SPATIAL.md §4 (canon rows SP1–SP5). The page is laid on the front
3
+ * hemisphere of a sphere around the listener; geometry becomes spherical source properties.
4
+ * No DOM, no Tone.
5
+ */
6
+ import { clamp, norm } from '../math/util'
7
+ import { DEG } from './sh'
8
+ import type { Rect } from '../types'
9
+
10
+ export const AZ_MAX = 70 * DEG
11
+ export const EL_MAX = 45 * DEG
12
+
13
+ /** SP1/SP2 — screen position → direction. Screen-right = −azimuth (sign tested). */
14
+ export function sphereFromRect(rect: Rect, vw: number, vh: number): { azimuth: number; elevation: number } {
15
+ const tx = norm(rect.x + rect.w / 2, 0, vw)
16
+ const ty = norm(rect.y + rect.h / 2, 0, vh)
17
+ return {
18
+ azimuth: -(2 * tx - 1) * AZ_MAX,
19
+ elevation: (1 - 2 * ty) * EL_MAX,
20
+ }
21
+ }
22
+
23
+ /** SP4 — big elements wrap around the listener (T2 extended into space). */
24
+ export function extentFromSize(sizeT: number): number {
25
+ return clamp(0.05 + 0.75 * Math.pow(clamp(sizeT, 0, 1), 1.2), 0, 0.95)
26
+ }
27
+
28
+ /** SP5 — Kiki/Bouba in the spatial domain: sharp beams, round radiates. */
29
+ export function directivityFromRoundness(roundness: number): number {
30
+ return clamp(1 - roundness, 0, 1)
31
+ }
32
+
33
+ /** SP6 — early-reflection time scale follows the room (viewport). */
34
+ export function reflectionScaleFromViewport(vw: number): number {
35
+ return 0.6 + norm(vw, 360, 2200) * 1.0
36
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * L3 Composition — themes are plain data (MAPPING.md §3). Adding a theme means adding an
3
+ * object here; no engine code may special-case a theme name.
4
+ */
5
+ import type { Theme } from '../types'
6
+
7
+ // v0.3: aurora speaks through the woven MatterVoice — geometry IS the instrument
8
+ // (MATTER.md). fm bells (headings) and membrane thuds (media) stay as character voices.
9
+ const aurora: Theme = {
10
+ name: 'aurora',
11
+ defaults: { synthKind: 'matter', octaveShift: 0, baseVelocity: 0.8, releaseScale: 1 },
12
+ roles: {
13
+ toggle: { synthKind: 'matter', baseVelocity: 0.7, releaseScale: 0.8 },
14
+ button: { synthKind: 'matter', baseVelocity: 0.9 },
15
+ link: { synthKind: 'matter', octaveShift: 1, baseVelocity: 0.6, releaseScale: 0.7 },
16
+ input: { synthKind: 'matter', baseVelocity: 0.55, releaseScale: 1.6 },
17
+ heading: { synthKind: 'fm', octaveShift: 0, baseVelocity: 0.75, releaseScale: 2.2 },
18
+ media: { synthKind: 'membrane', octaveShift: -1, baseVelocity: 0.8 },
19
+ item: { synthKind: 'matter', baseVelocity: 0.6, releaseScale: 0.8 },
20
+ text: { synthKind: 'matter', baseVelocity: 0.35, releaseScale: 1.8 },
21
+ },
22
+ }
23
+
24
+ const mono: Theme = {
25
+ name: 'mono',
26
+ defaults: { synthKind: 'synth', octaveShift: 0, baseVelocity: 0.7, releaseScale: 0.5 },
27
+ roles: {
28
+ toggle: { synthKind: 'noise', baseVelocity: 0.6 },
29
+ button: { synthKind: 'synth', pinWave: 'square', baseVelocity: 0.7, releaseScale: 0.4 },
30
+ link: { synthKind: 'noise', baseVelocity: 0.45, releaseScale: 0.3 },
31
+ input: { synthKind: 'synth', pinWave: 'sine', octaveShift: -1, baseVelocity: 0.5 },
32
+ heading: { synthKind: 'synth', pinWave: 'sine', octaveShift: 1, baseVelocity: 0.6 },
33
+ media: { synthKind: 'noise', baseVelocity: 0.55 },
34
+ item: { synthKind: 'noise', baseVelocity: 0.4, releaseScale: 0.3 },
35
+ text: { synthKind: 'synth', pinWave: 'sine', baseVelocity: 0 },
36
+ },
37
+ }
38
+
39
+ const paper: Theme = {
40
+ name: 'paper',
41
+ defaults: { synthKind: 'pluck', octaveShift: 0, baseVelocity: 0.8, releaseScale: 1 },
42
+ roles: {
43
+ toggle: { synthKind: 'pluck', baseVelocity: 0.7 },
44
+ button: { synthKind: 'pluck', baseVelocity: 0.9 },
45
+ link: { synthKind: 'pluck', octaveShift: 1, baseVelocity: 0.65 },
46
+ input: { synthKind: 'synth', pinWave: 'triangle', baseVelocity: 0.5, releaseScale: 1.4 },
47
+ heading: { synthKind: 'pluck', octaveShift: -1, baseVelocity: 0.85, releaseScale: 1.6 },
48
+ media: { synthKind: 'membrane', octaveShift: -1, baseVelocity: 0.7 },
49
+ item: { synthKind: 'pluck', baseVelocity: 0.6 },
50
+ text: { synthKind: 'synth', pinWave: 'sine', baseVelocity: 0.3, releaseScale: 1.6 },
51
+ },
52
+ }
53
+
54
+ export const THEMES: Record<string, Theme> = { aurora, mono, paper }
55
+
56
+ export function resolveTheme(theme: string | Theme | undefined): Theme {
57
+ if (!theme) return aurora
58
+ if (typeof theme === 'string') return THEMES[theme] ?? aurora
59
+ return theme
60
+ }
package/src/types.ts ADDED
@@ -0,0 +1,157 @@
1
+ export type Wave = 'sine' | 'triangle' | 'sawtooth' | 'square'
2
+
3
+ export type Role =
4
+ | 'toggle'
5
+ | 'button'
6
+ | 'link'
7
+ | 'input'
8
+ | 'heading'
9
+ | 'media'
10
+ | 'item'
11
+ | 'container'
12
+ | 'text'
13
+
14
+ export type SynthKind = 'matter' | 'synth' | 'fm' | 'pluck' | 'membrane' | 'noise'
15
+
16
+ export type Articulation =
17
+ | 'hit' | 'preview' | 'tick' | 'strum' | 'whisper' | 'toggle-on' | 'toggle-off' | 'motif'
18
+ | 'echo' | 'phrase' | 'ribbon'
19
+
20
+ export interface Rect {
21
+ x: number
22
+ y: number
23
+ w: number
24
+ h: number
25
+ }
26
+
27
+ /** Spherical source properties — the 聲球 (SPATIAL.md §2, §4). */
28
+ export interface SphereProps {
29
+ /** Radians, AmbiX convention: +azimuth = left. */
30
+ azimuth: number
31
+ /** Radians, + = up. */
32
+ elevation: number
33
+ /** Apparent angular size σ ∈ [0,1] — point source … wraps around the listener. */
34
+ extent: number
35
+ /** δ ∈ [0,1]: 1 = focused beam at the listener, 0 = omni radiator. */
36
+ directivity: number
37
+ }
38
+
39
+ /** The spat5.oper surface (SPATIAL.md §5) — all ∈ [0,1], perceptually monotonic. */
40
+ export interface PerceptualFactors {
41
+ presence: number
42
+ roomPresence: number
43
+ envelopment: number
44
+ warmth: number
45
+ brilliance: number
46
+ }
47
+
48
+ /** The Matter weave, resolved per element (MATTER.md) — consumed by MatterVoice + backends. */
49
+ export interface MatterVoiceParams {
50
+ matter: { edge: number; mass: number; texture: number; air: number }
51
+ /** 24 additive partial amplitudes, energy-normalized (the continuous spectrum). */
52
+ partials: number[]
53
+ transient: { lengthS: number; hpHz: number; level: number }
54
+ breath: { level: number; bpRatio: number }
55
+ subShimmer: { interval: number; level: number }
56
+ glideS: number
57
+ jitterCents: number
58
+ envelope: { attackS: number; decayS: number; sustain: number; releaseScale: number }
59
+ filter: { q: number; biteAmount: number; biteDecayS: number }
60
+ reverb: { sendScale: number; sendCutoffHz: number; bloom: number; extentBonus: number }
61
+ /** The modular patch (MODULAR.md): CSS plugs the cables. */
62
+ patch: {
63
+ fold: { drive: number; mix: number }
64
+ fm: { index: number }
65
+ unison: { detuneCents: number; mix: number }
66
+ lfo: { rateHz: number; shape: 'sine' | 'square'; vibratoCents: number; tremolo: number; filterDepth: number }
67
+ portamentoS: number
68
+ }
69
+ }
70
+
71
+ /** The contract between page reading (L1) and the audio substrate (L0). See ARCHITECTURE.md §2. */
72
+ export interface SonicProfile {
73
+ role: Role
74
+ rect: Rect
75
+ pan: { x: number; y: number; z: number }
76
+ sphere: SphereProps
77
+ midi: number
78
+ freqHz: number
79
+ degree: number
80
+ wave: Wave
81
+ attack: number
82
+ release: number
83
+ durationS: number
84
+ filterHz: number
85
+ filterQ: number
86
+ velocityScale: number
87
+ reverbSend: number
88
+ synthKind: SynthKind
89
+ octaveShift: number
90
+ /** The full Matter weave (MATTER.md) — always computed; the 'matter' voice consumes all of
91
+ * it, other synth kinds consume the reverb/filter threads. */
92
+ voice: MatterVoiceParams
93
+ /** The Chroma weave (CHROMA.md): the element's color as mood. */
94
+ chroma: { warmth: number; saturation: number; luminance: number }
95
+ /** Human-readable provenance of every parameter — describe() truth (PLAN.md Invariant #6). */
96
+ reasons: Record<string, string>
97
+ }
98
+
99
+ export interface VoiceRecipe {
100
+ synthKind: SynthKind
101
+ /** When set, geometry does not override the waveform (e.g. heading bells stay bells). */
102
+ pinWave?: Wave
103
+ octaveShift: number
104
+ baseVelocity: number
105
+ releaseScale: number
106
+ }
107
+
108
+ export interface Theme {
109
+ name: string
110
+ roles: Partial<Record<Role, Partial<VoiceRecipe>>>
111
+ defaults: VoiceRecipe
112
+ }
113
+
114
+ export interface SonariumOptions {
115
+ /** Root element to sonify. Default: document.body */
116
+ root?: Element
117
+ /** Sound palette. Default 'aurora'. */
118
+ theme?: 'aurora' | 'mono' | 'paper' | Theme
119
+ /** 'auto' = deterministic per-hostname key (MAPPING.md §0), or e.g. 'D dorian'. */
120
+ key?: string
121
+ /** Listener mode: cursor as ears, or fixed center. Default 'pointer'. */
122
+ listener?: 'pointer' | 'center'
123
+ /** Ambience level 0..1 (room tone + sparkles). Default 0.12; 0 disables. */
124
+ ambient?: number
125
+ /** Enable device tilt/shake drivers on mobile. Default true. */
126
+ motion?: boolean
127
+ /** Autoplay-unlock & mute UI. 'chip' = floating control, 'none' = bring your own. */
128
+ gate?: 'chip' | 'none'
129
+ /** Master volume in dB. Default -10. */
130
+ volume?: number
131
+ /** Max concurrent voices (pool size). Default 18, hard cap 24. */
132
+ maxVoices?: number
133
+ /** 'hrtf' (default) or 'equalpower' for low-end devices. */
134
+ panning?: 'hrtf' | 'equalpower'
135
+ /**
136
+ * Spatial engine (SPATIAL.md): 'ambisonic' (default) encodes everything into one rotatable
137
+ * FOA field, Spat-style; 'panner' is the v0.1 per-voice HRTF path (also the auto-fallback).
138
+ */
139
+ spatial?: 'ambisonic' | 'panner'
140
+ /** Spat-style perceptual factors (presence, roomPresence, envelopment, warmth, brilliance). */
141
+ perceptual?: Partial<PerceptualFactors>
142
+ /** Reverb: 'auto' sizes the room from viewport width, or a fixed decay in seconds. */
143
+ reverb?: 'auto' | number
144
+ /** Respect prefers-reduced-motion by softening output. Default true. */
145
+ respectReducedMotion?: boolean
146
+ /** Mirror every trigger to the first Web MIDI output (the page as a MIDI controller). Default false. */
147
+ midi?: boolean
148
+ }
149
+
150
+ export type SonariumEvent = 'start' | 'trigger' | 'mute' | 'dispose'
151
+
152
+ export interface TriggerDetail {
153
+ el: Element
154
+ profile: SonicProfile
155
+ velocity: number
156
+ articulation: Articulation
157
+ }
package/src/ui/gate.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The unlock & mute chip — autoplay policy treated as a feature (PLAN.md R2).
3
+ * Shadow DOM so host styles can't break it; ARIA so keyboard/SR users get the same control.
4
+ */
5
+ const STORAGE_KEY = 'sonarium:muted'
6
+
7
+ export const isMutedPersisted = (): boolean => {
8
+ try { return localStorage.getItem(STORAGE_KEY) === '1' } catch { return false }
9
+ }
10
+
11
+ export const persistMuted = (muted: boolean): void => {
12
+ try { localStorage.setItem(STORAGE_KEY, muted ? '1' : '0') } catch { /* private mode */ }
13
+ }
14
+
15
+ export interface GateHandle {
16
+ setState: (state: 'armed' | 'on' | 'muted') => void
17
+ dispose: () => void
18
+ }
19
+
20
+ export function mountGate(onToggle: () => void): GateHandle {
21
+ const host = document.createElement('div')
22
+ host.setAttribute('data-sonic', 'off') // the chip itself must never sound
23
+ const shadow = host.attachShadow({ mode: 'open' })
24
+ shadow.innerHTML = `
25
+ <style>
26
+ button {
27
+ position: fixed; right: 16px; bottom: 16px; z-index: 2147483646;
28
+ width: 44px; height: 44px; border-radius: 50%; border: 1px solid rgba(255,255,255,.25);
29
+ background: rgba(20, 22, 30, .82); color: #fff; font-size: 18px; line-height: 1;
30
+ cursor: pointer; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
31
+ box-shadow: 0 4px 16px rgba(0,0,0,.35); transition: transform .15s ease, opacity .3s ease;
32
+ }
33
+ button:hover { transform: scale(1.08); }
34
+ button:focus-visible { outline: 2px solid #7cd4fd; outline-offset: 2px; }
35
+ button.armed { animation: pulse 1.6s ease-in-out infinite; }
36
+ @keyframes pulse { 0%,100% { box-shadow: 0 4px 16px rgba(0,0,0,.35); } 50% { box-shadow: 0 0 0 10px rgba(124,212,253,.18); } }
37
+ </style>
38
+ <button type="button" class="armed" aria-label="Enable sound" title="Sonarium — enable sound">🔇</button>
39
+ `
40
+ const btn = shadow.querySelector('button') as HTMLButtonElement
41
+ btn.addEventListener('click', (e) => {
42
+ e.stopPropagation()
43
+ onToggle()
44
+ })
45
+ document.body.appendChild(host)
46
+
47
+ return {
48
+ setState(state) {
49
+ btn.classList.toggle('armed', state === 'armed')
50
+ if (state === 'on') {
51
+ btn.textContent = '🔊'
52
+ btn.setAttribute('aria-label', 'Mute Sonarium')
53
+ } else if (state === 'muted') {
54
+ btn.textContent = '🔇'
55
+ btn.setAttribute('aria-label', 'Unmute Sonarium')
56
+ } else {
57
+ btn.textContent = '🔇'
58
+ btn.setAttribute('aria-label', 'Enable sound')
59
+ }
60
+ },
61
+ dispose() {
62
+ host.remove()
63
+ },
64
+ }
65
+ }