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,176 @@
1
+ /**
2
+ * L1 Page Reading — eligibility, registry, observers, visible set (ARCHITECTURE.md §5).
3
+ * Imports DOM only; produces data + callbacks, triggers nothing audible itself.
4
+ */
5
+ import type { SonicProfile } from '../types'
6
+ import { profileOf, roleOf, type ProfileEnv } from './profile'
7
+
8
+ export const ELIGIBLE_SELECTOR = [
9
+ 'button', '[role=button]', 'input', 'textarea', 'select', '[contenteditable]',
10
+ 'a[href]', '[role=link]', 'summary', '[role=switch]', '[role=checkbox]',
11
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', '[role=heading]',
12
+ 'img', 'video', 'canvas', 'svg', 'picture',
13
+ 'li', 'tr', '[role=listitem]', '[role=option]', '[role=menuitem]', '[role=tab]',
14
+ 'nav', 'section', 'article', 'aside', 'form', 'fieldset', 'dialog',
15
+ '[data-sonic-role]', '[data-sonic-note]',
16
+ ].join(',')
17
+
18
+ const MAX_TRACKED = 400
19
+
20
+ interface Entry {
21
+ profile: SonicProfile | null
22
+ visible: boolean
23
+ }
24
+
25
+ export interface ScannerCallbacks {
26
+ /** Fired when an element scrolls/loads into view after the initial scan (I7 whisper). */
27
+ onAppear: (el: Element) => void
28
+ }
29
+
30
+ export class Scanner {
31
+ readonly registry = new Map<Element, Entry>()
32
+ private io: IntersectionObserver | null = null
33
+ private mo: MutationObserver | null = null
34
+ private capWarned = false
35
+ private initialScanDone = false
36
+ private mutationTimer: ReturnType<typeof setTimeout> | null = null
37
+
38
+ constructor(
39
+ private env: ProfileEnv,
40
+ private cb: ScannerCallbacks,
41
+ ) {}
42
+
43
+ scan(): void {
44
+ if (typeof IntersectionObserver !== 'undefined') {
45
+ this.io = new IntersectionObserver((entries) => {
46
+ for (const e of entries) {
47
+ const entry = this.registry.get(e.target)
48
+ if (!entry) continue
49
+ const was = entry.visible
50
+ entry.visible = e.isIntersecting
51
+ if (e.isIntersecting) entry.profile = null // rect changed while away; recompute lazily
52
+ if (e.isIntersecting && !was && this.initialScanDone) this.cb.onAppear(e.target)
53
+ }
54
+ }, { threshold: 0.15 })
55
+ }
56
+
57
+ this.register(this.env.root)
58
+ for (const el of Array.from(this.env.root.querySelectorAll(ELIGIBLE_SELECTOR))) this.register(el)
59
+
60
+ if (typeof MutationObserver !== 'undefined') {
61
+ this.mo = new MutationObserver((muts) => this.queueMutations(muts))
62
+ this.mo.observe(this.env.root, { subtree: true, childList: true, attributes: true, attributeFilter: ['class', 'style', 'data-sonic', 'data-sonic-note', 'data-sonic-wave', 'data-sonic-role'] })
63
+ }
64
+
65
+ // Let the initial IntersectionObserver flood settle before treating entries as "appearances".
66
+ setTimeout(() => { this.initialScanDone = true }, 300)
67
+ }
68
+
69
+ private register(el: Element): void {
70
+ if (this.registry.has(el) || this.isOff(el)) return
71
+ if (this.registry.size >= MAX_TRACKED) {
72
+ if (!this.capWarned) {
73
+ this.capWarned = true
74
+ console.warn(`[sonarium] page exceeds ${MAX_TRACKED} tracked elements; extra elements stay silent`)
75
+ }
76
+ return
77
+ }
78
+ this.registry.set(el, { profile: null, visible: false })
79
+ this.io?.observe(el)
80
+ }
81
+
82
+ private unregister(el: Element): void {
83
+ if (!this.registry.has(el)) return
84
+ this.registry.delete(el)
85
+ this.io?.unobserve(el)
86
+ }
87
+
88
+ isOff(el: Element): boolean {
89
+ let n: Element | null = el
90
+ while (n) {
91
+ if ((n as HTMLElement).dataset?.sonic === 'off') return true
92
+ n = n.parentElement
93
+ }
94
+ return false
95
+ }
96
+
97
+ private queueMutations(muts: MutationRecord[]): void {
98
+ if (this.mutationTimer) clearTimeout(this.mutationTimer)
99
+ const records = muts
100
+ this.mutationTimer = setTimeout(() => {
101
+ for (const m of records) {
102
+ if (m.type === 'attributes' && m.target instanceof Element) {
103
+ const entry = this.registry.get(m.target)
104
+ if (entry) entry.profile = null
105
+ continue
106
+ }
107
+ for (const node of Array.from(m.addedNodes)) {
108
+ if (!(node instanceof Element)) continue
109
+ if (node.matches?.(ELIGIBLE_SELECTOR)) this.register(node)
110
+ for (const el of Array.from(node.querySelectorAll?.(ELIGIBLE_SELECTOR) ?? [])) this.register(el)
111
+ }
112
+ for (const node of Array.from(m.removedNodes)) {
113
+ if (!(node instanceof Element)) continue
114
+ this.unregister(node)
115
+ for (const el of Array.from(node.querySelectorAll?.(ELIGIBLE_SELECTOR) ?? [])) this.unregister(el)
116
+ }
117
+ }
118
+ }, 150)
119
+ }
120
+
121
+ /** Profile with lazy compute + cache (cheap path: geometry invalidation only nulls it). */
122
+ profileFor(el: Element): SonicProfile | null {
123
+ if (this.isOff(el)) return null
124
+ let entry = this.registry.get(el)
125
+ if (!entry) {
126
+ // Untracked but asked for (e.g. describe() on an arbitrary element): compute one-off.
127
+ return safeProfile(el, this.env)
128
+ }
129
+ if (!entry.profile) entry.profile = safeProfile(el, this.env)
130
+ return entry.profile
131
+ }
132
+
133
+ /** Geometry changed globally (scroll/resize): rects are stale, voices params survive. */
134
+ invalidateRects(): void {
135
+ for (const entry of this.registry.values()) entry.profile = null
136
+ }
137
+
138
+ visibleElements(): Element[] {
139
+ const out: Element[] = []
140
+ for (const [el, entry] of this.registry) if (entry.visible) out.push(el)
141
+ return out
142
+ }
143
+
144
+ /** The element (or nearest registered ancestor) the scanner knows about. */
145
+ resolve(target: Element | null): Element | null {
146
+ let n: Element | null = target
147
+ while (n) {
148
+ if (this.registry.has(n)) return n
149
+ n = n.parentElement
150
+ }
151
+ return null
152
+ }
153
+
154
+ updateEnv(vw: number, vh: number): void {
155
+ this.env.vw = vw
156
+ this.env.vh = vh
157
+ }
158
+
159
+ dispose(): void {
160
+ if (this.mutationTimer) clearTimeout(this.mutationTimer)
161
+ this.io?.disconnect()
162
+ this.mo?.disconnect()
163
+ this.registry.clear()
164
+ }
165
+ }
166
+
167
+ function safeProfile(el: Element, env: ProfileEnv): SonicProfile | null {
168
+ try {
169
+ return profileOf(el, env)
170
+ } catch (err) {
171
+ console.warn('[sonarium] profile failed', err)
172
+ return null
173
+ }
174
+ }
175
+
176
+ export { roleOf }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * L0 Acoustic Substrate — VoicePool (ARCHITECTURE.md §4).
3
+ * Profiles are data; voices are rented lanes configured at trigger time. Spatial placement is
4
+ * delegated to the active SpatialBackend (ambisonic field or per-voice panners — SPATIAL.md §6).
5
+ * Imports Tone only; never reads the DOM.
6
+ */
7
+ import * as Tone from 'tone'
8
+ import { clamp } from '../math/util'
9
+ import type { SonicProfile, SynthKind } from '../types'
10
+ import type { LaneOutput, SpatialBackend } from '../spatial/backend'
11
+ import { MatterVoice } from './matter-voice'
12
+
13
+ type AnySynth = Tone.Synth | Tone.FMSynth | Tone.PluckSynth | Tone.MembraneSynth | Tone.NoiseSynth | MatterVoice
14
+
15
+ interface Lane {
16
+ kind: SynthKind
17
+ synth: AnySynth
18
+ filter: Tone.Filter
19
+ out: LaneOutput
20
+ busyUntil: number
21
+ }
22
+
23
+ export interface VoiceBuses {
24
+ dryIn: Tone.InputNode
25
+ wetIn: Tone.InputNode
26
+ }
27
+
28
+ export class VoicePool {
29
+ private lanes: Lane[] = []
30
+ private sleepTimer: ReturnType<typeof setInterval>
31
+
32
+ constructor(
33
+ private backend: SpatialBackend,
34
+ private maxVoices: number,
35
+ ) {
36
+ this.maxVoices = clamp(maxVoices, 4, 24)
37
+ // MODULAR.md §4 — idle matter lanes power down (silent page ⇒ ~zero steady-state CPU).
38
+ this.sleepTimer = setInterval(() => {
39
+ const now = Tone.now()
40
+ for (const lane of this.lanes) {
41
+ if (lane.synth instanceof MatterVoice && lane.busyUntil < now - 30) lane.synth.sleep()
42
+ }
43
+ }, 10000)
44
+ }
45
+
46
+ private createSynth(kind: SynthKind): AnySynth {
47
+ switch (kind) {
48
+ case 'matter':
49
+ return new MatterVoice()
50
+ case 'fm':
51
+ return new Tone.FMSynth({ harmonicity: 3, modulationIndex: 8, envelope: { attack: 0.01, decay: 0.3, sustain: 0.1, release: 1.4 }, modulationEnvelope: { attack: 0.01, decay: 0.4, sustain: 0.2, release: 1 } })
52
+ case 'pluck':
53
+ return new Tone.PluckSynth({ attackNoise: 1, dampening: 3000, resonance: 0.92 })
54
+ case 'membrane':
55
+ return new Tone.MembraneSynth({ pitchDecay: 0.04, octaves: 5, envelope: { attack: 0.001, decay: 0.35, sustain: 0.01, release: 0.6 } })
56
+ case 'noise':
57
+ return new Tone.NoiseSynth({ noise: { type: 'white' }, envelope: { attack: 0.001, decay: 0.06, sustain: 0, release: 0.05 } })
58
+ default:
59
+ return new Tone.Synth({ oscillator: { type: 'triangle' }, envelope: { attack: 0.01, decay: 0.1, sustain: 0.25, release: 0.3 } })
60
+ }
61
+ }
62
+
63
+ private createLane(kind: SynthKind): Lane {
64
+ const synth = this.createSynth(kind)
65
+ const filter = new Tone.Filter({ frequency: 4000, type: 'lowpass', rolloff: -12, Q: 1 })
66
+ const out = this.backend.createOutput()
67
+ synth.connect(filter)
68
+ filter.connect(out.input)
69
+ // The LFO's filter-wobble tap (MODULAR.md M4/M5) modulates the lane filter additively.
70
+ if (synth instanceof MatterVoice) synth.modFilterOut.connect(filter.frequency)
71
+ const lane: Lane = { kind, synth, filter, out, busyUntil: 0 }
72
+ this.lanes.push(lane)
73
+ return lane
74
+ }
75
+
76
+ private acquire(kind: SynthKind): Lane {
77
+ const now = Tone.now()
78
+ let candidate: Lane | null = null
79
+ let oldestSameKind: Lane | null = null
80
+ let oldestAny: Lane | null = null
81
+ for (const lane of this.lanes) {
82
+ if (lane.kind === kind) {
83
+ if (lane.busyUntil <= now) { candidate = lane; break }
84
+ if (!oldestSameKind || lane.busyUntil < oldestSameKind.busyUntil) oldestSameKind = lane
85
+ }
86
+ if (!oldestAny || lane.busyUntil < oldestAny.busyUntil) oldestAny = lane
87
+ }
88
+ if (candidate) return candidate
89
+ if (this.lanes.length < this.maxVoices) return this.createLane(kind)
90
+ if (oldestSameKind) return oldestSameKind
91
+ // Cap reached and no lane of this kind: recycle the globally oldest lane into this kind.
92
+ const victim = oldestAny ?? (this.lanes[0] as Lane)
93
+ victim.synth.dispose()
94
+ victim.synth = this.createSynth(kind)
95
+ victim.synth.connect(victim.filter)
96
+ victim.kind = kind
97
+ return victim
98
+ }
99
+
100
+ /** Configure a lane from the profile, then sound it. `when` lets strums schedule ahead. */
101
+ trigger(profile: SonicProfile, velocity: number, when?: number): void {
102
+ const raw = velocity * profile.velocityScale
103
+ if (raw <= 0.01) return // silent roles (e.g. mono theme text) stay silent
104
+ const t = when ?? Tone.now()
105
+ const vel = clamp(raw, 0.03, 1)
106
+ const lane = this.acquire(profile.synthKind)
107
+
108
+ const baseCutoff = Math.max(200, profile.filterHz)
109
+ if (lane.kind === 'matter') {
110
+ // EDGE bite: the filter strikes bright then settles (MATTER.md filter weave).
111
+ const w = profile.voice
112
+ lane.filter.Q.rampTo(w.filter.q, 0.02, t)
113
+ lane.filter.frequency.cancelScheduledValues(t)
114
+ lane.filter.frequency.setValueAtTime(Math.min(12000, baseCutoff * w.filter.biteAmount), t)
115
+ lane.filter.frequency.exponentialRampTo(baseCutoff, w.filter.biteDecayS, t)
116
+ } else {
117
+ lane.filter.frequency.rampTo(baseCutoff, 0.02, t)
118
+ lane.filter.Q.rampTo(profile.filterQ, 0.02, t)
119
+ }
120
+ lane.out.setPlacement(profile, t)
121
+
122
+ const dur = profile.durationS
123
+ try {
124
+ switch (lane.kind) {
125
+ case 'matter': {
126
+ const mv = lane.synth as MatterVoice
127
+ mv.trigger({
128
+ freqHz: profile.freqHz,
129
+ velocity: vel,
130
+ durationS: dur,
131
+ releaseScaleBase: profile.release / 0.3,
132
+ voice: profile.voice,
133
+ }, t, baseCutoff)
134
+ lane.busyUntil = t + dur + mv.releaseTail()
135
+ return
136
+ }
137
+ case 'noise': {
138
+ ;(lane.synth as Tone.NoiseSynth).triggerAttackRelease(Math.min(dur, 0.2), t, vel)
139
+ break
140
+ }
141
+ case 'pluck': {
142
+ const pluck = lane.synth as Tone.PluckSynth
143
+ pluck.set({ dampening: clamp(profile.filterHz, 400, 7000) })
144
+ pluck.triggerAttackRelease(profile.freqHz, dur, t, vel)
145
+ break
146
+ }
147
+ case 'membrane': {
148
+ const mem = lane.synth as Tone.MembraneSynth
149
+ mem.set({ envelope: { attack: Math.max(0.001, profile.attack / 4), release: profile.release } })
150
+ mem.triggerAttackRelease(Math.max(30, profile.freqHz / 4), dur, t, vel)
151
+ break
152
+ }
153
+ case 'fm': {
154
+ const fm = lane.synth as Tone.FMSynth
155
+ fm.set({ envelope: { attack: profile.attack, release: profile.release * 2 } })
156
+ fm.triggerAttackRelease(profile.freqHz, dur, t, vel)
157
+ break
158
+ }
159
+ default: {
160
+ const syn = lane.synth as Tone.Synth
161
+ syn.set({
162
+ oscillator: { type: profile.wave },
163
+ envelope: { attack: profile.attack, decay: 0.08, sustain: 0.25, release: profile.release },
164
+ })
165
+ syn.triggerAttackRelease(profile.freqHz, dur, t, vel)
166
+ }
167
+ }
168
+ lane.busyUntil = t + dur + profile.release
169
+ } catch (err) {
170
+ console.warn('[sonarium] trigger failed', err)
171
+ }
172
+ }
173
+
174
+ get activeCount(): number {
175
+ const now = Tone.now()
176
+ return this.lanes.filter((l) => l.busyUntil > now).length
177
+ }
178
+
179
+ /** MODULAR.md §3 — a sustained ribbon voice. Returns null when no matter lane can gate. */
180
+ sustain(profile: SonicProfile, velocity: number): { setFreq(hz: number, glideS: number): void; release(): void } | null {
181
+ const raw = velocity * profile.velocityScale
182
+ if (raw <= 0.01) return null
183
+ const lane = this.acquire('matter')
184
+ if (!(lane.synth instanceof MatterVoice)) return null
185
+ const mv = lane.synth
186
+ const t = Tone.now()
187
+ const baseCutoff = Math.max(200, profile.filterHz)
188
+ lane.filter.Q.rampTo(profile.voice.filter.q, 0.02, t)
189
+ lane.filter.frequency.cancelScheduledValues(t)
190
+ lane.filter.frequency.setValueAtTime(Math.min(12000, baseCutoff * profile.voice.filter.biteAmount), t)
191
+ lane.filter.frequency.exponentialRampTo(baseCutoff, profile.voice.filter.biteDecayS, t)
192
+ lane.out.setPlacement(profile, t)
193
+ mv.gateOn({
194
+ freqHz: profile.freqHz,
195
+ velocity: clamp(raw, 0.03, 1),
196
+ durationS: 9999,
197
+ releaseScaleBase: profile.release / 0.3,
198
+ voice: profile.voice,
199
+ }, t, baseCutoff)
200
+ lane.busyUntil = t + 9999
201
+ const voice = profile.voice
202
+ return {
203
+ setFreq(hz: number, glideS: number) {
204
+ mv.setFreq(hz, glideS, voice.subShimmer.interval, voice.patch.fm.index)
205
+ },
206
+ release() {
207
+ const tail = mv.gateOff()
208
+ lane.busyUntil = Tone.now() + tail
209
+ },
210
+ }
211
+ }
212
+
213
+ dispose(): void {
214
+ clearInterval(this.sleepTimer)
215
+ for (const lane of this.lanes) {
216
+ lane.synth.dispose()
217
+ lane.filter.dispose()
218
+ lane.out.dispose()
219
+ }
220
+ this.lanes = []
221
+ }
222
+ }
package/src/index.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Sonarium — drop-in acoustic UX.
3
+ * One script tag turns any webpage into a spatial sound field: layout becomes a stereo stage,
4
+ * geometry becomes timbre (Kiki/Bouba), the DOM tree becomes depth and harmony, the viewport
5
+ * becomes a room, and your cursor becomes your ears.
6
+ *
7
+ * import { create } from 'sonarium' // ESM
8
+ * const space = create({ theme: 'aurora' })
9
+ *
10
+ * <script src=".../sonarium.iife.js" data-auto></script> // zero-code
11
+ *
12
+ * Docs: https://github.com/frank890417/sonarium — start with docs/PLAN.md.
13
+ */
14
+ import { Engine } from './core/engine'
15
+ import type { SonariumOptions } from './types'
16
+
17
+ export const version = '0.6.0'
18
+
19
+ export type {
20
+ SonariumOptions, SonicProfile, Theme, VoiceRecipe, Role, Wave, SynthKind,
21
+ Articulation, TriggerDetail, SonariumEvent, SphereProps, PerceptualFactors,
22
+ } from './types'
23
+ export { THEMES } from './themes/index'
24
+ export { siteKey, parseKey, degreeToMidi, midiToFreq, midiToNoteName, stepInScale, SCALES } from './math/scales'
25
+ export * as mapping from './math/mapping'
26
+ export * as matter from './math/matter'
27
+ export * as chroma from './math/chroma'
28
+ export * as pulse from './math/pulse'
29
+ export * as modular from './math/modular'
30
+ // The pure spatial layer (SPATIAL.md) — reusable beyond the DOM.
31
+ export { foaGains, unitVector, DEG } from './spatial/sh'
32
+ export { rotationMatrix, lookMatrix, applyMat3 } from './spatial/rotation'
33
+ export { CUBE_LAYOUT, decodeMatrix, decodeGains } from './spatial/decoder'
34
+ export * as sphereMapping from './spatial/sphere'
35
+ export { DEFAULT_FACTORS } from './spatial/perceptual'
36
+ export type { Engine }
37
+
38
+ /**
39
+ * Create a Sonarium instance. Safe to call before any user gesture: audio arms itself and
40
+ * starts on the first pointer/key interaction (browser autoplay policy treated as a feature).
41
+ */
42
+ export function create(options: SonariumOptions = {}): Engine {
43
+ return new Engine(options)
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------- auto-init
47
+ // <script src="sonarium.iife.js" data-auto data-theme="paper" data-ambient="0.2"></script>
48
+ function autoInit(): void {
49
+ if (typeof document === 'undefined') return
50
+ const script = document.currentScript as HTMLScriptElement | null
51
+ if (!script || script.dataset.auto === undefined) return
52
+ const opts: SonariumOptions = {}
53
+ if (script.dataset.theme) opts.theme = script.dataset.theme as SonariumOptions['theme']
54
+ if (script.dataset.key) opts.key = script.dataset.key
55
+ if (script.dataset.ambient) opts.ambient = parseFloat(script.dataset.ambient)
56
+ if (script.dataset.listener) opts.listener = script.dataset.listener as SonariumOptions['listener']
57
+ if (script.dataset.volume) opts.volume = parseFloat(script.dataset.volume)
58
+ if (script.dataset.panning) opts.panning = script.dataset.panning as SonariumOptions['panning']
59
+ const boot = () => {
60
+ try {
61
+ const engine = create(opts)
62
+ ;(window as unknown as Record<string, unknown>).sonarium = engine
63
+ } catch (err) {
64
+ console.warn('[sonarium] auto-init failed', err)
65
+ }
66
+ }
67
+ if (document.readyState === 'loading') {
68
+ document.addEventListener('DOMContentLoaded', boot, { once: true })
69
+ } else {
70
+ boot()
71
+ }
72
+ }
73
+
74
+ autoInit()
@@ -0,0 +1,56 @@
1
+ /**
2
+ * L2 — activation: full notes on press (I2), container strums (I3), toggle intervals (I6).
3
+ */
4
+ import type { Engine } from '../core/engine'
5
+ import { ELIGIBLE_SELECTOR } from '../core/scanner'
6
+
7
+ const DEDUPE_MS = 80
8
+
9
+ export function attachActivate(engine: Engine): () => void {
10
+ const lastHit = new WeakMap<Element, number>()
11
+
12
+ const activate = (target: Element | null) => {
13
+ const el = engine.scanner?.resolve(target)
14
+ if (!el) return
15
+ const now = performance.now()
16
+ if (now - (lastHit.get(el) ?? -Infinity) < DEDUPE_MS) return
17
+ lastHit.set(el, now)
18
+
19
+ const profile = engine.scanner.profileFor(el)
20
+ if (!profile) return
21
+ if (profile.role === 'toggle') return // sounded by the change handler (I6)
22
+ if (profile.role === 'container') {
23
+ const children = Array.from(el.querySelectorAll(ELIGIBLE_SELECTOR))
24
+ .filter((c) => c.parentElement && engine.scanner.resolve(c) === c)
25
+ .filter((c) => {
26
+ const r = c.getBoundingClientRect()
27
+ return r.width > 0 && r.bottom > 0 && r.top < window.innerHeight
28
+ })
29
+ if (children.length >= 2) {
30
+ engine.strum(children, 0.5)
31
+ return
32
+ }
33
+ }
34
+ engine.excite(el, 0.75, 'hit')
35
+ }
36
+
37
+ // pointerdown for latency (<30 ms budget); click catches keyboard activation (Enter/Space).
38
+ const onPointerDown = (e: PointerEvent) => activate(e.target as Element | null)
39
+ const onClick = (e: MouseEvent) => activate(e.target as Element | null)
40
+
41
+ const onChange = (e: Event) => {
42
+ const t = e.target
43
+ if (!(t instanceof HTMLInputElement) || (t.type !== 'checkbox' && t.type !== 'radio')) return
44
+ const on = t.checked
45
+ engine.excite(t, 0.5, on ? 'toggle-on' : 'toggle-off')
46
+ }
47
+
48
+ window.addEventListener('pointerdown', onPointerDown, { passive: true })
49
+ window.addEventListener('click', onClick, { passive: true })
50
+ window.addEventListener('change', onChange, { passive: true })
51
+ return () => {
52
+ window.removeEventListener('pointerdown', onPointerDown)
53
+ window.removeEventListener('click', onClick)
54
+ window.removeEventListener('change', onChange)
55
+ }
56
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * L2 — drag glissando (MODULAR.md §3): press an interactive element and drag — the pointer
3
+ * becomes a ribbon controller sweeping scale degrees. The initial hit stays immediate (the
4
+ * ribbon only gates after 14 px of travel; latency invariant untouched).
5
+ */
6
+ import type { Engine } from '../core/engine'
7
+
8
+ const START_PX = 14
9
+
10
+ export function attachDrag(engine: Engine): () => void {
11
+ let session: { x0: number; el: Element; handle: ReturnType<Engine['ribbon']> } | null = null
12
+
13
+ const onDown = (e: PointerEvent) => {
14
+ if (!e.isPrimary) return
15
+ const el = engine.scanner?.resolve(e.target as Element | null)
16
+ if (!el) return
17
+ const p = engine.scanner.profileFor(el)
18
+ if (!p || p.role === 'container' || p.role === 'text') return
19
+ session = { x0: e.clientX, el, handle: null }
20
+ }
21
+
22
+ const onMove = (e: PointerEvent) => {
23
+ if (!session) return
24
+ const dx = e.clientX - session.x0
25
+ if (!session.handle) {
26
+ if (Math.abs(dx) < START_PX) return
27
+ session.handle = engine.ribbon(session.el)
28
+ if (!session.handle) {
29
+ session = null
30
+ return
31
+ }
32
+ }
33
+ session.handle.move(dx)
34
+ }
35
+
36
+ const end = () => {
37
+ session?.handle?.release()
38
+ session = null
39
+ }
40
+
41
+ window.addEventListener('pointerdown', onDown, { passive: true })
42
+ window.addEventListener('pointermove', onMove, { passive: true })
43
+ window.addEventListener('pointerup', end, { passive: true })
44
+ window.addEventListener('pointercancel', end, { passive: true })
45
+ return () => {
46
+ end()
47
+ window.removeEventListener('pointerdown', onDown)
48
+ window.removeEventListener('pointermove', onMove)
49
+ window.removeEventListener('pointerup', end)
50
+ window.removeEventListener('pointercancel', end)
51
+ }
52
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * L2 — keyboard: focus previews for Tab navigation (I4, accessibility parity) and
3
+ * typing ticks that rise as the field fills (I5).
4
+ */
5
+ import type { Engine } from '../core/engine'
6
+
7
+ const TICK_THROTTLE_MS = 30
8
+ // Pentatonic-safe intervals: ticks climb without ever clashing with the site key.
9
+ const FILL_INTERVALS = [0, 2, 4, 7, 9, 12, 14, 16] as const
10
+
11
+ export function attachKeyboard(engine: Engine): () => void {
12
+ let lastTick = 0
13
+ let lastPointerDownT = -Infinity
14
+
15
+ // Clicking focuses too — that focus must stay silent or every click sounds twice
16
+ // (ghost-trigger fix, v0.5). Only keyboard-driven focus (Tab) previews.
17
+ const onPointerDown = () => {
18
+ lastPointerDownT = performance.now()
19
+ }
20
+
21
+ const onFocus = (e: FocusEvent) => {
22
+ if (performance.now() - lastPointerDownT < 500) return
23
+ const el = engine.scanner?.resolve(e.target as Element | null)
24
+ if (el) engine.excite(el, 0.35, 'preview')
25
+ }
26
+
27
+ const onKeydown = (e: KeyboardEvent) => {
28
+ const t = e.target
29
+ const editable = t instanceof HTMLElement
30
+ && (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || t.isContentEditable)
31
+ if (!editable) return
32
+ const now = performance.now()
33
+ if (now - lastTick < TICK_THROTTLE_MS) return
34
+ lastTick = now
35
+ const len = (t as HTMLInputElement).value?.length ?? t.textContent?.length ?? 0
36
+ const interval = FILL_INTERVALS[Math.min(FILL_INTERVALS.length - 1, Math.floor(len / 4))] as number
37
+ engine.excite(t, 0.15, 'tick', undefined, interval)
38
+ }
39
+
40
+ window.addEventListener('pointerdown', onPointerDown, { passive: true, capture: true })
41
+ window.addEventListener('focusin', onFocus, { passive: true })
42
+ window.addEventListener('keydown', onKeydown, { passive: true })
43
+ return () => {
44
+ window.removeEventListener('pointerdown', onPointerDown, { capture: true })
45
+ window.removeEventListener('focusin', onFocus)
46
+ window.removeEventListener('keydown', onKeydown)
47
+ }
48
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * L2-out — Web MIDI bridge (option `midi: true`): every trigger mirrors to the first MIDI
3
+ * output as note events, channel per role. The page becomes a MIDI controller — record your
4
+ * browsing into a DAW, or drive hardware from the DOM. Feature-detected; silent elsewhere.
5
+ */
6
+ import type { Engine } from '../core/engine'
7
+ import type { Role, TriggerDetail } from '../types'
8
+
9
+ const ROLE_CHANNEL: Partial<Record<Role, number>> = {
10
+ button: 0, link: 1, toggle: 2, input: 3, heading: 4, media: 5, item: 6, text: 7, container: 8,
11
+ }
12
+
13
+ export function attachMidi(engine: Engine): () => void {
14
+ const nav = navigator as Navigator & { requestMIDIAccess?: () => Promise<unknown> }
15
+ if (typeof nav.requestMIDIAccess !== 'function') return () => {}
16
+ let out: { send(data: number[]): void } | null = null
17
+ let off: (() => void) | null = null
18
+
19
+ nav.requestMIDIAccess()
20
+ .then((access) => {
21
+ const a = access as unknown as { outputs: Map<string, { send(data: number[]): void }> }
22
+ out = a.outputs.values().next().value ?? null
23
+ if (!out) return
24
+ off = engine.on('trigger', (detail) => {
25
+ const d = detail as TriggerDetail
26
+ const note = Math.max(0, Math.min(127, Math.round(d.profile.midi)))
27
+ const vel = Math.max(1, Math.min(127, Math.round(d.velocity * d.profile.velocityScale * 127)))
28
+ const ch = ROLE_CHANNEL[d.profile.role] ?? 9
29
+ try {
30
+ out!.send([0x90 | ch, note, vel])
31
+ setTimeout(() => {
32
+ try { out?.send([0x80 | ch, note, 0]) } catch { /* device gone */ }
33
+ }, Math.min(4000, d.profile.durationS * 1000 + 60))
34
+ } catch { /* device gone */ }
35
+ })
36
+ })
37
+ .catch(() => { /* permission denied — stay silent */ })
38
+
39
+ return () => off?.()
40
+ }