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.
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/dist/index.d.ts +1000 -0
- package/dist/index.js +2716 -0
- package/dist/index.js.map +1 -0
- package/dist/sonarium.iife.js +236 -0
- package/dist/sonarium.iife.js.map +1 -0
- package/package.json +63 -0
- package/src/core/engine.ts +468 -0
- package/src/core/listener.ts +61 -0
- package/src/core/matter-voice.ts +240 -0
- package/src/core/profile.ts +313 -0
- package/src/core/room.ts +135 -0
- package/src/core/scanner.ts +176 -0
- package/src/core/voices.ts +222 -0
- package/src/index.ts +74 -0
- package/src/interact/activate.ts +56 -0
- package/src/interact/drag.ts +52 -0
- package/src/interact/keyboard.ts +48 -0
- package/src/interact/midi.ts +40 -0
- package/src/interact/motion.ts +61 -0
- package/src/interact/pointer.ts +65 -0
- package/src/interact/scroll.ts +43 -0
- package/src/math/chroma.ts +111 -0
- package/src/math/mapping.ts +100 -0
- package/src/math/matter.ts +201 -0
- package/src/math/modular.ts +99 -0
- package/src/math/pulse.ts +64 -0
- package/src/math/scales.ts +90 -0
- package/src/math/util.ts +16 -0
- package/src/spatial/backend.ts +166 -0
- package/src/spatial/bus.ts +114 -0
- package/src/spatial/decoder.ts +63 -0
- package/src/spatial/encoder.ts +54 -0
- package/src/spatial/field.ts +75 -0
- package/src/spatial/perceptual.ts +43 -0
- package/src/spatial/room-foa.ts +81 -0
- package/src/spatial/rotation.ts +73 -0
- package/src/spatial/sh.ts +38 -0
- package/src/spatial/sphere.ts +36 -0
- package/src/themes/index.ts +60 -0
- package/src/types.ts +157 -0
- 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
|
+
}
|