@gratiaos/presence-kernel 1.1.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 (40) hide show
  1. package/LICENSE +243 -0
  2. package/README.md +166 -0
  3. package/dist/ConstellationHUD.d.ts +18 -0
  4. package/dist/ConstellationHUD.d.ts.map +1 -0
  5. package/dist/ConstellationHUD.js +103 -0
  6. package/dist/ConstellationHUD.js.map +1 -0
  7. package/dist/Heartbeat.d.ts +4 -0
  8. package/dist/Heartbeat.d.ts.map +1 -0
  9. package/dist/Heartbeat.js +161 -0
  10. package/dist/Heartbeat.js.map +1 -0
  11. package/dist/index.d.ts +83 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +140 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/phase-sound-profile.d.ts +9 -0
  16. package/dist/phase-sound-profile.d.ts.map +1 -0
  17. package/dist/phase-sound-profile.js +8 -0
  18. package/dist/phase-sound-profile.js.map +1 -0
  19. package/dist/useConstellationAudio.d.ts +9 -0
  20. package/dist/useConstellationAudio.d.ts.map +1 -0
  21. package/dist/useConstellationAudio.js +16 -0
  22. package/dist/useConstellationAudio.js.map +1 -0
  23. package/dist/usePhaseSound.d.ts +2 -0
  24. package/dist/usePhaseSound.d.ts.map +1 -0
  25. package/dist/usePhaseSound.js +137 -0
  26. package/dist/usePhaseSound.js.map +1 -0
  27. package/dist/usePhaseSpatialSound.d.ts +2 -0
  28. package/dist/usePhaseSpatialSound.d.ts.map +1 -0
  29. package/dist/usePhaseSpatialSound.js +206 -0
  30. package/dist/usePhaseSpatialSound.js.map +1 -0
  31. package/package.json +56 -0
  32. package/src/ConstellationHUD.tsx +136 -0
  33. package/src/Heartbeat.tsx +178 -0
  34. package/src/constellation-hud.css +101 -0
  35. package/src/heartbeat.css +75 -0
  36. package/src/index.ts +177 -0
  37. package/src/phase-sound-profile.ts +15 -0
  38. package/src/useConstellationAudio.ts +16 -0
  39. package/src/usePhaseSound.ts +163 -0
  40. package/src/usePhaseSpatialSound.ts +237 -0
@@ -0,0 +1,75 @@
1
+ .heartbeat-wrapper {
2
+ position: fixed;
3
+ bottom: 0.75rem;
4
+ left: 0.75rem;
5
+ z-index: 90;
6
+ }
7
+
8
+ .heartbeat {
9
+ position: relative;
10
+ border-radius: 9999px;
11
+ cursor: pointer;
12
+ }
13
+
14
+ .heartbeat-ring,
15
+ .heartbeat-ring.echo {
16
+ position: absolute;
17
+ top: 50%;
18
+ left: 50%;
19
+ width: 16px;
20
+ height: 16px;
21
+ border-radius: 50%;
22
+ transform: translate(-50%, -50%) scale(1);
23
+ pointer-events: none;
24
+ opacity: 0;
25
+ border: 2px solid currentColor;
26
+ }
27
+
28
+ .heartbeat-wrapper:hover .heartbeat-ring {
29
+ animation: ring-breath 1.8s ease-out infinite;
30
+ opacity: 0.7;
31
+ }
32
+
33
+ .heartbeat-wrapper:hover .heartbeat-ring.echo {
34
+ animation: ring-breath 1.8s ease-out infinite;
35
+ animation-delay: 0.9s;
36
+ opacity: 0.5;
37
+ }
38
+
39
+ .pulse-on .heartbeat-ring {
40
+ animation: ring-breath 1.8s ease-out;
41
+ opacity: 0.6;
42
+ }
43
+
44
+ .pulse-on .heartbeat-ring.echo {
45
+ animation: ring-breath 1.8s ease-out;
46
+ animation-delay: 0.9s;
47
+ opacity: 0.4;
48
+ }
49
+
50
+ .heartbeat-wave {
51
+ position: absolute;
52
+ left: 24px;
53
+ bottom: 0;
54
+ opacity: 0.65;
55
+ filter: drop-shadow(0 0 4px currentColor);
56
+ transition: opacity 0.45s ease;
57
+ }
58
+
59
+ .heartbeat-wrapper:hover .heartbeat-wave {
60
+ opacity: 1;
61
+ }
62
+
63
+ @keyframes ring-breath {
64
+ 0% {
65
+ transform: translate(-50%, -50%) scale(1);
66
+ opacity: 0.8;
67
+ }
68
+ 70% {
69
+ transform: translate(-50%, -50%) scale(2.6);
70
+ opacity: 0;
71
+ }
72
+ 100% {
73
+ opacity: 0;
74
+ }
75
+ }
package/src/index.ts ADDED
@@ -0,0 +1,177 @@
1
+ export type Phase = 'companion' | 'presence' | 'archive' | (string & {});
2
+ export type Mood = 'soft' | 'presence' | 'focused' | 'celebratory' | (string & {});
3
+
4
+ export type PresenceSnapshot = Readonly<{
5
+ t: number;
6
+ phase: Phase;
7
+ mood: Mood;
8
+ peers: number;
9
+ whisper?: string;
10
+ meta?: Record<string, unknown>;
11
+ }>;
12
+
13
+ export type KernelEvent =
14
+ | { type: 'tick'; snap: PresenceSnapshot }
15
+ | { type: 'phase:set'; phase: Phase; snap: PresenceSnapshot }
16
+ | { type: 'mood:set'; mood: Mood; snap: PresenceSnapshot }
17
+ | { type: 'whisper'; message: string; snap: PresenceSnapshot }
18
+ | { type: 'peer:up'; id: string; snap: PresenceSnapshot }
19
+ | { type: 'peer:down'; id: string; snap: PresenceSnapshot };
20
+
21
+ export type Unsubscribe = () => void;
22
+
23
+ export interface PresenceAdapter {
24
+ init?(kernel: PresenceKernel): void;
25
+ onTick?(snap: PresenceSnapshot): void;
26
+ emit?(evt: KernelEvent): void;
27
+ dispose?(): void;
28
+ }
29
+
30
+ export type KernelPlugin = (kernel: PresenceKernel) => void;
31
+
32
+ import { createSignal, type Signal, type SignalListener } from '@gratiaos/signal';
33
+ // Signals are now sourced from @gratiaos/signal (shared micro observable).
34
+
35
+ export const phase$ = createSignal<Phase>('presence');
36
+ export const mood$ = createSignal<Mood>('soft'); // phase can be vivid while mood stays gentle by default
37
+ export const peers$ = createSignal<string[]>([]);
38
+ export const pulse$ = createSignal<number>(0);
39
+
40
+ export const setPhase = (phase: Phase) => phase$.set(phase);
41
+ export const setMood = (mood: Mood) => mood$.set(mood);
42
+
43
+ export class PresenceKernel {
44
+ private phase: Phase = 'presence';
45
+ private mood: Mood = 'soft';
46
+ private peers = new Map<string, number>();
47
+ private whisperMsg = '';
48
+ private listeners = new Set<(event: KernelEvent) => void>();
49
+ private adapters = new Set<PresenceAdapter>();
50
+ private timer: ReturnType<typeof setInterval> | null = null;
51
+ private readonly intervalMs: number;
52
+ private readonly now: () => number;
53
+
54
+ private syncPeers() {
55
+ peers$.set(Array.from(this.peers.keys()));
56
+ }
57
+
58
+ constructor(intervalMs: number = 1000, now: () => number = () => Date.now()) {
59
+ this.intervalMs = intervalMs;
60
+ this.now = now;
61
+ }
62
+
63
+ start() {
64
+ if (this.timer) return;
65
+ this.timer = setInterval(() => this.tick(), this.intervalMs);
66
+ for (const adapter of this.adapters) adapter.init?.(this);
67
+ this.tick();
68
+ }
69
+
70
+ stop() {
71
+ if (this.timer) clearInterval(this.timer);
72
+ this.timer = null;
73
+ for (const adapter of this.adapters) adapter.dispose?.();
74
+ }
75
+
76
+ setPhase(next: Phase) {
77
+ if (this.phase === next) return;
78
+ this.phase = next;
79
+ setPhase(next);
80
+ this.publish({ type: 'phase:set', phase: next, snap: this.snapshot });
81
+ }
82
+
83
+ setMood(next: Mood) {
84
+ if (this.mood === next) return;
85
+ this.mood = next;
86
+ setMood(next);
87
+ this.publish({ type: 'mood:set', mood: next, snap: this.snapshot });
88
+ }
89
+
90
+ whisper(message: string) {
91
+ this.whisperMsg = message;
92
+ this.publish({ type: 'whisper', message, snap: this.snapshot });
93
+ }
94
+
95
+ upsertPeer(id: string) {
96
+ const isNew = !this.peers.has(id);
97
+ this.peers.set(id, this.now());
98
+ if (isNew) this.syncPeers();
99
+ this.publish({ type: 'peer:up', id, snap: this.snapshot });
100
+ }
101
+
102
+ dropPeer(id: string) {
103
+ const existed = this.peers.delete(id);
104
+ if (!existed) return;
105
+ this.syncPeers();
106
+ this.publish({ type: 'peer:down', id, snap: this.snapshot });
107
+ }
108
+
109
+ activePeerCount(staleMs = 15_000) {
110
+ const now = this.now();
111
+ let changed = false;
112
+ for (const [id, seen] of Array.from(this.peers)) {
113
+ if (now - seen > staleMs) {
114
+ this.peers.delete(id);
115
+ changed = true;
116
+ this.publish({ type: 'peer:down', id, snap: this.snapshot });
117
+ }
118
+ }
119
+ if (changed) this.syncPeers();
120
+ return this.peers.size;
121
+ }
122
+
123
+ use(adapter: PresenceAdapter): this {
124
+ this.adapters.add(adapter);
125
+ if (this.timer) adapter.init?.(this);
126
+ return this;
127
+ }
128
+
129
+ plugin(plugin: KernelPlugin): this {
130
+ plugin(this);
131
+ return this;
132
+ }
133
+
134
+ on(listener: (event: KernelEvent) => void): Unsubscribe {
135
+ this.listeners.add(listener);
136
+ return () => this.listeners.delete(listener);
137
+ }
138
+
139
+ get snapshot(): PresenceSnapshot {
140
+ return Object.freeze({
141
+ t: this.now(),
142
+ phase: this.phase,
143
+ mood: this.mood,
144
+ peers: this.activePeerCount(),
145
+ whisper: this.whisperMsg || undefined,
146
+ });
147
+ }
148
+
149
+ private tick() {
150
+ pulse$.set(pulse$.value + 1);
151
+ const snap = this.snapshot;
152
+ this.publish({ type: 'tick', snap });
153
+ for (const adapter of this.adapters) adapter.onTick?.(snap);
154
+ }
155
+
156
+ private publish(event: KernelEvent) {
157
+ this.listeners.forEach((listener) => {
158
+ try {
159
+ listener(event);
160
+ } catch {
161
+ // keep kernel resilient when listeners misbehave
162
+ }
163
+ });
164
+ for (const adapter of this.adapters) adapter.emit?.(event);
165
+ }
166
+ }
167
+
168
+ export { Heartbeat } from './Heartbeat';
169
+ export { ConstellationHUD } from './ConstellationHUD';
170
+
171
+ export { usePhaseSound } from './usePhaseSound';
172
+ export { usePhaseSpatialSound } from './usePhaseSpatialSound';
173
+
174
+ // Re-export micro signal primitives to preserve historical import patterns
175
+ // for downstream packages that previously consumed signals via presence-kernel.
176
+ export { createSignal } from '@gratiaos/signal';
177
+ export type { Signal, SignalListener } from '@gratiaos/signal';
@@ -0,0 +1,15 @@
1
+ export type PhaseSoundProfile = {
2
+ root: number;
3
+ intervals: number[];
4
+ interval: number;
5
+ filter: BiquadFilterType;
6
+ };
7
+
8
+ export const PHASE_SOUND_PROFILE: Record<string, PhaseSoundProfile> = {
9
+ presence: { root: 220, intervals: [0, 3, 7], interval: 4500, filter: 'lowpass' },
10
+ soft: { root: 294, intervals: [0, 4, 7], interval: 3500, filter: 'bandpass' },
11
+ focused: { root: 392, intervals: [0, 3, 6], interval: 2000, filter: 'highpass' },
12
+ celebratory: { root: 523.25, intervals: [0, 4, 8], interval: 1500, filter: 'notch' },
13
+ };
14
+
15
+ export const DEFAULT_SOUND_PROFILE = PHASE_SOUND_PROFILE.presence;
@@ -0,0 +1,16 @@
1
+ import { usePhaseSpatialSound } from './usePhaseSpatialSound';
2
+ import { usePhaseSound } from './usePhaseSound';
3
+
4
+ /**
5
+ * useConstellationAudio — unified audio gating for Constellation HUD.
6
+ * Always calls underlying hooks (Rules of Hooks) but passes enabled flags
7
+ * derived from soundMode so audio engines mount/unmount cleanly.
8
+ */
9
+ export function useConstellationAudio(soundMode: 'spatial' | 'phase' | 'both' | 'none', selfId?: string, opts?: { haptics?: boolean }) {
10
+ const spatialEnabled = soundMode === 'spatial' || soundMode === 'both';
11
+ const phaseEnabled = soundMode === 'phase' || soundMode === 'both';
12
+
13
+ // Pass enabled flags; underlying hooks early-exit when disabled.
14
+ usePhaseSpatialSound(selfId, spatialEnabled);
15
+ usePhaseSound(opts?.haptics ?? false, phaseEnabled);
16
+ }
@@ -0,0 +1,163 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { phase$ } from './index';
3
+ import { DEFAULT_SOUND_PROFILE, PHASE_SOUND_PROFILE, type PhaseSoundProfile } from './phase-sound-profile';
4
+
5
+ type AudioWindow = Window & {
6
+ AudioContext?: typeof AudioContext;
7
+ webkitAudioContext?: typeof AudioContext;
8
+ };
9
+
10
+ type PendingTone = {
11
+ freq: number;
12
+ duration: number;
13
+ delay: number;
14
+ filter: BiquadFilterType;
15
+ };
16
+
17
+ export function usePhaseSound(enableHaptics = false, enabled: boolean = true) {
18
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
19
+ const ctxRef = useRef<AudioContext | null>(null);
20
+ const readyRef = useRef(false);
21
+ const pendingRef = useRef<PendingTone[]>([]);
22
+
23
+ useEffect(() => {
24
+ if (!enabled) return; // gated effect; previous teardown runs before disabled state
25
+ if (typeof window === 'undefined') return;
26
+
27
+ const win = window as AudioWindow;
28
+ const events: Array<keyof WindowEventMap> = ['pointerdown', 'keydown', 'touchstart'];
29
+
30
+ const playToneInternal = (frequency: number, duration = 0.25, delay = 0, filterType: BiquadFilterType = 'lowpass') => {
31
+ const ctx = ctxRef.current;
32
+ if (!ctx) return;
33
+
34
+ const start = ctx.currentTime + Math.max(0, delay);
35
+ const osc = ctx.createOscillator();
36
+ const gain = ctx.createGain();
37
+ const filter = ctx.createBiquadFilter();
38
+
39
+ osc.type = 'sine';
40
+ osc.frequency.setValueAtTime(frequency, start);
41
+
42
+ filter.type = filterType;
43
+ const defaultFreq = 1200;
44
+ const filterFreq = filterType === 'lowpass' ? 650 : filterType === 'bandpass' ? 1500 : filterType === 'highpass' ? 2600 : defaultFreq;
45
+ filter.frequency.setValueAtTime(filterFreq, start);
46
+
47
+ gain.gain.setValueAtTime(0.05, start);
48
+ gain.gain.exponentialRampToValueAtTime(0.001, start + duration);
49
+
50
+ osc.connect(filter);
51
+ filter.connect(gain);
52
+ gain.connect(ctx.destination);
53
+ osc.start(start);
54
+ osc.stop(start + duration);
55
+ };
56
+
57
+ const flushPending = () => {
58
+ if (!ctxRef.current || pendingRef.current.length === 0) return;
59
+ const pending = pendingRef.current.splice(0, pendingRef.current.length);
60
+ pending.forEach(({ freq, duration, delay, filter }) => playToneInternal(freq, duration, delay, filter));
61
+ };
62
+
63
+ const removeUnlockListeners = () => {
64
+ events.forEach((event) => win.removeEventListener(event, unlock));
65
+ };
66
+
67
+ const ensureContext = (allowResume: boolean): boolean => {
68
+ const AudioCtx = win.AudioContext ?? win.webkitAudioContext;
69
+ if (!AudioCtx) return false;
70
+
71
+ try {
72
+ if (!ctxRef.current) {
73
+ ctxRef.current = new AudioCtx();
74
+ }
75
+ } catch {
76
+ return false;
77
+ }
78
+
79
+ if (!ctxRef.current) return false;
80
+
81
+ if (ctxRef.current.state === 'suspended') {
82
+ if (!allowResume) return false;
83
+ void ctxRef.current
84
+ .resume()
85
+ .then(() => {
86
+ readyRef.current = true;
87
+ removeUnlockListeners();
88
+ flushPending();
89
+ })
90
+ .catch(() => {
91
+ // still awaiting gesture
92
+ });
93
+ return false;
94
+ }
95
+
96
+ readyRef.current = true;
97
+ removeUnlockListeners();
98
+ flushPending();
99
+ return true;
100
+ };
101
+
102
+ const playTone = (frequency: number, duration: number, delay: number, filter: BiquadFilterType) => {
103
+ if (!readyRef.current && !ensureContext(false)) {
104
+ pendingRef.current.push({ freq: frequency, duration, delay, filter });
105
+ return;
106
+ }
107
+ readyRef.current = true;
108
+ playToneInternal(frequency, duration, delay, filter);
109
+ };
110
+
111
+ const scheduleBaseline = (profile: PhaseSoundProfile) => {
112
+ if (timerRef.current) {
113
+ window.clearInterval(timerRef.current);
114
+ timerRef.current = null;
115
+ }
116
+ timerRef.current = window.setInterval(() => {
117
+ playTone(profile.root, 0.18, 0, profile.filter);
118
+ }, profile.interval);
119
+ };
120
+
121
+ const unlock = () => {
122
+ if (ensureContext(true)) {
123
+ readyRef.current = true;
124
+ flushPending();
125
+ }
126
+ };
127
+
128
+ events.forEach((event) => win.addEventListener(event, unlock, { passive: true }));
129
+
130
+ // Try to initialize immediately in case interaction already happened
131
+ ensureContext(false);
132
+
133
+ const stopPhase = phase$.subscribe((phase) => {
134
+ const profile = PHASE_SOUND_PROFILE[phase as keyof typeof PHASE_SOUND_PROFILE] ?? DEFAULT_SOUND_PROFILE;
135
+
136
+ profile.intervals.forEach((semi, index) => {
137
+ const freq = profile.root * Math.pow(2, semi / 12);
138
+ playTone(freq, 0.25, index * 0.08, profile.filter);
139
+ });
140
+
141
+ if (enableHaptics && typeof navigator !== 'undefined' && 'vibrate' in navigator) {
142
+ navigator.vibrate?.([30, 60, 30]);
143
+ }
144
+
145
+ scheduleBaseline(profile);
146
+ });
147
+
148
+ return () => {
149
+ stopPhase();
150
+ removeUnlockListeners();
151
+ if (timerRef.current) {
152
+ window.clearInterval(timerRef.current);
153
+ timerRef.current = null;
154
+ }
155
+ pendingRef.current = [];
156
+ readyRef.current = false;
157
+ void ctxRef.current?.close().catch(() => {
158
+ // ignore close errors
159
+ });
160
+ ctxRef.current = null;
161
+ };
162
+ }, [enableHaptics, enabled]);
163
+ }
@@ -0,0 +1,237 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { phase$, peers$, pulse$ } from './index';
3
+ import { DEFAULT_SOUND_PROFILE, PHASE_SOUND_PROFILE, type PhaseSoundProfile } from './phase-sound-profile';
4
+
5
+ type AudioWindow = Window & {
6
+ AudioContext?: typeof AudioContext;
7
+ webkitAudioContext?: typeof AudioContext;
8
+ };
9
+
10
+ type PendingPlayback = () => void;
11
+
12
+ const LISTEN_EVENTS: Array<keyof WindowEventMap> = ['pointerdown', 'keydown', 'touchstart'];
13
+
14
+ const BASE_GAIN = 0.14;
15
+
16
+ const filterFrequency = (type: BiquadFilterType) => {
17
+ switch (type) {
18
+ case 'lowpass':
19
+ return 720;
20
+ case 'bandpass':
21
+ return 1500;
22
+ case 'highpass':
23
+ return 2600;
24
+ case 'notch':
25
+ return 1800;
26
+ default:
27
+ return 1200;
28
+ }
29
+ };
30
+
31
+ const hashCode = (input: string) =>
32
+ Array.from(input).reduce(
33
+ (acc, char, index) => (acc + char.charCodeAt(0) * (index + 1)) % 2147483647,
34
+ 0,
35
+ );
36
+
37
+ // Cache per peer so we only compute hash + derived pan/detune once per id.
38
+ // Peer IDs are stable for a session; memory footprint is minimal.
39
+ const peerMetricsCache = new Map<string, { hash: number; pan: number; detune: number }>();
40
+
41
+ const getPeerMetrics = (peerId: string) => {
42
+ let cached = peerMetricsCache.get(peerId);
43
+ if (cached) return cached;
44
+ const hash = hashCode(peerId);
45
+ const pan = Math.max(-0.85, Math.min(0.85, Math.sin(hash)));
46
+ const semitone = (hash % 9) - 4; // 9 discrete steps in [-4, 4]
47
+ // Micro‑detune per peer:
48
+ // Divide by 24 → range ≈ [-0.1667, +0.1667] semitones (± ~16.7 cents ≈ one sixth of a semitone).
49
+ // This is a subtle colorization — previously commented as "quarter‑tone" which would be ~50 cents; kept subtle.
50
+ const detune = semitone / 24;
51
+ cached = { hash, pan, detune };
52
+ peerMetricsCache.set(peerId, cached);
53
+ return cached;
54
+ };
55
+
56
+ const gainForPeer = (pan: number) => {
57
+ const centerBoost = 1 - Math.min(Math.abs(pan), 0.85) * 0.45;
58
+ return 0.028 + centerBoost * 0.018;
59
+ };
60
+
61
+ const mergePeers = (ids: string[], selfId?: string) => {
62
+ const cleaned = ids.filter(Boolean);
63
+ const unique = Array.from(new Set(cleaned));
64
+ if (selfId && !unique.includes(selfId)) unique.unshift(selfId);
65
+ return unique;
66
+ };
67
+
68
+ export function usePhaseSpatialSound(selfId?: string, enabled: boolean = true) {
69
+ const ctxRef = useRef<AudioContext | null>(null);
70
+ const masterRef = useRef<GainNode | null>(null);
71
+ const readyRef = useRef(false);
72
+ const pendingRef = useRef<PendingPlayback[]>([]);
73
+ const peersRef = useRef<string[]>(mergePeers(peers$.value, selfId));
74
+ const profileRef = useRef<PhaseSoundProfile>(DEFAULT_SOUND_PROFILE);
75
+
76
+ useEffect(() => {
77
+ if (!enabled) return; // gated; cleanup occurs when toggling from enabled→false
78
+ if (typeof window === 'undefined') return;
79
+
80
+ const win = window as AudioWindow;
81
+
82
+ const ensureMaster = () => {
83
+ const ctx = ctxRef.current;
84
+ if (!ctx) return false;
85
+ if (!masterRef.current) {
86
+ const master = ctx.createGain();
87
+ master.gain.value = BASE_GAIN;
88
+ master.connect(ctx.destination);
89
+ masterRef.current = master;
90
+ }
91
+ return true;
92
+ };
93
+
94
+ const flushPending = () => {
95
+ if (!readyRef.current || !ensureMaster()) return;
96
+ const queue = pendingRef.current.splice(0, pendingRef.current.length);
97
+ queue.forEach((fn) => fn());
98
+ };
99
+
100
+ const ensureContext = (allowResume: boolean) => {
101
+ const AudioCtx = win.AudioContext ?? win.webkitAudioContext;
102
+ if (!AudioCtx) return false;
103
+ try {
104
+ if (!ctxRef.current) {
105
+ ctxRef.current = new AudioCtx();
106
+ }
107
+ } catch {
108
+ return false;
109
+ }
110
+
111
+ const ctx = ctxRef.current;
112
+ if (!ctx) return false;
113
+
114
+ if (ctx.state === 'suspended') {
115
+ if (!allowResume) return false;
116
+ void ctx
117
+ .resume()
118
+ .then(() => {
119
+ readyRef.current = true;
120
+ ensureMaster();
121
+ flushPending();
122
+ })
123
+ .catch(() => {
124
+ /* still locked */
125
+ });
126
+ return false;
127
+ }
128
+
129
+ readyRef.current = true;
130
+ ensureMaster();
131
+ flushPending();
132
+ return true;
133
+ };
134
+
135
+ const queuePlayback = (fn: PendingPlayback) => {
136
+ if (!readyRef.current && !ensureContext(false)) {
137
+ pendingRef.current.push(fn);
138
+ return;
139
+ }
140
+ readyRef.current = true;
141
+ if (!ensureMaster()) {
142
+ pendingRef.current.push(fn);
143
+ return;
144
+ }
145
+ fn();
146
+ };
147
+
148
+ const unlock = () => {
149
+ if (ensureContext(true)) {
150
+ readyRef.current = true;
151
+ LISTEN_EVENTS.forEach((event) => win.removeEventListener(event, unlock));
152
+ }
153
+ };
154
+
155
+ LISTEN_EVENTS.forEach((event) => win.addEventListener(event, unlock, { passive: true }));
156
+ ensureContext(false);
157
+
158
+ const playPeerTone = (peerId: string, profile: PhaseSoundProfile, index: number) => {
159
+ const base = profile.root;
160
+ const { detune, pan: rawPan } = getPeerMetrics(peerId);
161
+ const frequency = base * Math.pow(2, detune);
162
+ const pan = peerId === selfId ? 0 : rawPan;
163
+ const gainAmount = gainForPeer(pan);
164
+ const delay = index * 0.045;
165
+
166
+ queuePlayback(() => {
167
+ const ctx = ctxRef.current;
168
+ const master = masterRef.current;
169
+ if (!ctx || !master) return;
170
+
171
+ const start = ctx.currentTime + delay;
172
+ const osc = ctx.createOscillator();
173
+ const filter = ctx.createBiquadFilter();
174
+ const panner = ctx.createStereoPanner();
175
+ const gain = ctx.createGain();
176
+
177
+ osc.type = 'sine';
178
+ osc.frequency.setValueAtTime(frequency, start);
179
+
180
+ filter.type = profile.filter;
181
+ filter.frequency.setValueAtTime(filterFrequency(profile.filter), start);
182
+
183
+ panner.pan.setValueAtTime(pan, start);
184
+
185
+ gain.gain.setValueAtTime(gainAmount, start);
186
+ gain.gain.exponentialRampToValueAtTime(0.001, start + 0.24);
187
+
188
+ osc.connect(filter);
189
+ filter.connect(panner);
190
+ panner.connect(gain);
191
+ gain.connect(master);
192
+
193
+ osc.start(start);
194
+ osc.stop(start + 0.26);
195
+ });
196
+ };
197
+
198
+ const pulsePeers = (profile: PhaseSoundProfile) => {
199
+ const peers = peersRef.current.length ? [...peersRef.current] : selfId ? [selfId] : [];
200
+ peers.forEach((peerId, index) => playPeerTone(peerId, profile, index));
201
+ };
202
+
203
+ const phaseSub = phase$.subscribe((phase) => {
204
+ const profile = PHASE_SOUND_PROFILE[phase as keyof typeof PHASE_SOUND_PROFILE] ?? DEFAULT_SOUND_PROFILE;
205
+ profileRef.current = profile;
206
+ });
207
+
208
+ const peersSub = peers$.subscribe((ids) => {
209
+ peersRef.current = mergePeers(ids, selfId);
210
+ });
211
+
212
+ let firstPulse = true;
213
+ const pulseSub = pulse$.subscribe(() => {
214
+ if (firstPulse) {
215
+ firstPulse = false;
216
+ return;
217
+ }
218
+ pulsePeers(profileRef.current);
219
+ });
220
+
221
+ return () => {
222
+ phaseSub();
223
+ peersSub();
224
+ pulseSub();
225
+ LISTEN_EVENTS.forEach((event) => win.removeEventListener(event, unlock));
226
+ pendingRef.current = [];
227
+ readyRef.current = false;
228
+ masterRef.current = null;
229
+ if (ctxRef.current) {
230
+ void ctxRef.current.close().catch(() => {
231
+ /* ignore */
232
+ });
233
+ ctxRef.current = null;
234
+ }
235
+ };
236
+ }, [selfId, enabled]);
237
+ }