vibepulse 0.1.0 → 0.1.2

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 (68) hide show
  1. package/README.md +7 -13
  2. package/bin/vibepulse.js +1 -0
  3. package/dist/index.js +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/docs/session-status-detection.md +258 -0
  6. package/next.config.ts +11 -0
  7. package/package.json +17 -11
  8. package/postcss.config.mjs +7 -0
  9. package/public/file.svg +1 -0
  10. package/public/globe.svg +1 -0
  11. package/public/next.svg +1 -0
  12. package/public/readme-cover.png +0 -0
  13. package/public/vercel.svg +1 -0
  14. package/public/window.svg +1 -0
  15. package/src/app/api/opencode-config/route.ts +304 -0
  16. package/src/app/api/opencode-config/status/route.ts +31 -0
  17. package/src/app/api/opencode-events/route.ts +86 -0
  18. package/src/app/api/opencode-models/route.test.ts +135 -0
  19. package/src/app/api/opencode-models/route.ts +58 -0
  20. package/src/app/api/profiles/[id]/apply/route.ts +49 -0
  21. package/src/app/api/profiles/[id]/route.ts +160 -0
  22. package/src/app/api/profiles/route.ts +107 -0
  23. package/src/app/api/sessions/[id]/archive/route.ts +35 -0
  24. package/src/app/api/sessions/[id]/delete/route.ts +26 -0
  25. package/src/app/api/sessions/[id]/route.ts +45 -0
  26. package/src/app/api/sessions/route.ts +596 -0
  27. package/src/app/favicon.ico +0 -0
  28. package/src/app/globals.css +66 -0
  29. package/src/app/layout.tsx +37 -0
  30. package/src/app/page.tsx +239 -0
  31. package/src/components/ErrorBoundary.tsx +72 -0
  32. package/src/components/KanbanBoard.tsx +442 -0
  33. package/src/components/LoadingState.tsx +37 -0
  34. package/src/components/ProjectCard.tsx +382 -0
  35. package/src/components/QueryProvider.tsx +25 -0
  36. package/src/components/SessionCard.tsx +291 -0
  37. package/src/components/SessionList.tsx +60 -0
  38. package/src/components/opencode-config/AgentConfigForm.test.tsx +66 -0
  39. package/src/components/opencode-config/AgentConfigForm.tsx +445 -0
  40. package/src/components/opencode-config/AgentModelSelector.tsx +284 -0
  41. package/src/components/opencode-config/AgentsConfigPanel.tsx +162 -0
  42. package/src/components/opencode-config/ConfigButton.tsx +43 -0
  43. package/src/components/opencode-config/ConfigPanel.tsx +91 -0
  44. package/src/components/opencode-config/FullscreenConfigPanel.tsx +360 -0
  45. package/src/components/opencode-config/categories/CategoriesList.tsx +328 -0
  46. package/src/components/opencode-config/categories/CategoriesManager.test.tsx +97 -0
  47. package/src/components/opencode-config/categories/CategoriesManager.tsx +174 -0
  48. package/src/components/opencode-config/categories/CategoryConfigForm.tsx +384 -0
  49. package/src/components/opencode-config/profiles/ProfileCard.tsx +140 -0
  50. package/src/components/opencode-config/profiles/ProfileEditor.tsx +446 -0
  51. package/src/components/opencode-config/profiles/ProfileList.tsx +398 -0
  52. package/src/components/opencode-config/profiles/ProfileManager.test.tsx +122 -0
  53. package/src/components/opencode-config/profiles/ProfileManager.tsx +293 -0
  54. package/src/components/ui/Tabs.tsx +59 -0
  55. package/src/hooks/useOpencodeSync.ts +378 -0
  56. package/src/index.ts +2 -0
  57. package/src/lib/notificationSound.ts +266 -0
  58. package/src/lib/opencodeConfig.test.ts +81 -0
  59. package/src/lib/opencodeConfig.ts +48 -0
  60. package/src/lib/opencodeDiscovery.ts +154 -0
  61. package/src/lib/profiles/storage.ts +264 -0
  62. package/src/lib/transform.ts +84 -0
  63. package/src/test/setup.ts +8 -0
  64. package/src/types/index.ts +89 -0
  65. package/src/types/opencodeConfig.ts +133 -0
  66. package/src/types/testing-library-vitest.d.ts +17 -0
  67. package/tsconfig.json +34 -0
  68. package/tsconfig.lib.json +17 -0
@@ -0,0 +1,378 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useCallback } from 'react';
4
+ import { useQueryClient } from '@tanstack/react-query';
5
+ import { OpencodeEvent, OpencodeSession } from '@/types';
6
+ import { playAttentionSound, playAlertSound, playCompleteSound } from '@/lib/notificationSound';
7
+
8
+ const WAITING_STORAGE_KEY = 'vibepulse:waiting-sessions';
9
+
10
+ function getPersistedWaiting(): Record<string, boolean> {
11
+ if (typeof window === 'undefined') return {};
12
+ try {
13
+ return JSON.parse(localStorage.getItem(WAITING_STORAGE_KEY) || '{}');
14
+ } catch {
15
+ return {};
16
+ }
17
+ }
18
+
19
+ function persistWaiting(sessionId: string, waiting: boolean) {
20
+ if (typeof window === 'undefined') return;
21
+ const state = getPersistedWaiting();
22
+ if (waiting) {
23
+ state[sessionId] = true;
24
+ } else {
25
+ delete state[sessionId];
26
+ }
27
+ localStorage.setItem(WAITING_STORAGE_KEY, JSON.stringify(state));
28
+ }
29
+
30
+ function inferProjectName(directory?: string): string {
31
+ if (!directory) return 'Unknown Project';
32
+ const normalized = directory.replace(/\\/g, '/');
33
+ const parts = normalized.split('/').filter(Boolean);
34
+ return parts[parts.length - 1] || 'Unknown Project';
35
+ }
36
+
37
+ function buildOptimisticSession(info: OpencodeSession): OpencodeSession {
38
+ const now = Date.now();
39
+ return {
40
+ ...info,
41
+ slug: info.slug || info.id,
42
+ title: info.title || 'Untitled Session',
43
+ directory: info.directory || '',
44
+ projectName: info.projectName || inferProjectName(info.directory),
45
+ time: info.time || { created: now, updated: now },
46
+ realTimeStatus: info.realTimeStatus || 'busy',
47
+ waitingForUser: !!info.waitingForUser,
48
+ children: info.children || [],
49
+ };
50
+ }
51
+
52
+ export function useOpencodeSync() {
53
+ const queryClient = useQueryClient();
54
+ const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
55
+ const reconnectAttemptsRef = useRef(0);
56
+ const refetchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
57
+ const streamRotateTimeoutRef = useRef<NodeJS.Timeout | null>(null);
58
+ const rotatingRef = useRef(false);
59
+ const initialLoadRef = useRef(true);
60
+ const initialLoadTimerRef = useRef<NodeJS.Timeout | null>(null);
61
+ const MAX_RECONNECT_ATTEMPTS = 5;
62
+ const BASE_RECONNECT_DELAY = 1000;
63
+ const STREAM_ROTATE_INTERVAL = 15000;
64
+
65
+ const scheduleRefetch = useCallback(() => {
66
+ if (refetchTimeoutRef.current) return;
67
+ refetchTimeoutRef.current = setTimeout(() => {
68
+ queryClient.invalidateQueries({ queryKey: ['sessions'] });
69
+ refetchTimeoutRef.current = null;
70
+ }, 500);
71
+ }, [queryClient]);
72
+
73
+ const handleEvent = useCallback((rawEvent: OpencodeEvent | { payload: OpencodeEvent; directory: string }) => {
74
+ // Unwrap GlobalEvent wrapper if present
75
+ const event: OpencodeEvent = 'payload' in rawEvent ? rawEvent.payload as OpencodeEvent : rawEvent;
76
+ if (!event?.type) {
77
+ scheduleRefetch();
78
+ return;
79
+ }
80
+ const sessionId = event.properties?.sessionID;
81
+
82
+ const handledEvents = [
83
+ 'session.status',
84
+ 'session.updated',
85
+ 'session.created',
86
+ 'session.deleted',
87
+ 'question.asked',
88
+ 'permission.asked',
89
+ 'permission.updated',
90
+ 'question.replied',
91
+ 'question.rejected',
92
+ 'permission.replied',
93
+ 'session.archived'
94
+ ];
95
+
96
+ if (!handledEvents.includes(event.type)) {
97
+ scheduleRefetch();
98
+ return;
99
+ }
100
+
101
+ // Update cache based on event type
102
+ queryClient.setQueryData(['sessions'], (old: { sessions: OpencodeSession[] } | undefined) => {
103
+ if (!old?.sessions) {
104
+ scheduleRefetch();
105
+ return old;
106
+ }
107
+
108
+ switch (event.type) {
109
+ case 'session.updated':
110
+ case 'session.created': {
111
+ const info = event.properties?.info as OpencodeSession | undefined;
112
+ if (!info) { scheduleRefetch(); return old; }
113
+
114
+ if (info.parentID) {
115
+ let updated = false;
116
+ const sessions = old.sessions.map((parent) => {
117
+ const hasTargetChild = parent.children?.some((child) => child.id === info.id);
118
+ const isTargetParent = parent.id === info.parentID;
119
+
120
+ if (!hasTargetChild && !isTargetParent) {
121
+ return parent;
122
+ }
123
+
124
+ updated = true;
125
+ const children = parent.children || [];
126
+ const existingChild = children.find((child) => child.id === info.id);
127
+ const child = buildOptimisticSession({
128
+ ...existingChild,
129
+ ...info,
130
+ realTimeStatus: info.realTimeStatus ?? existingChild?.realTimeStatus ?? 'busy',
131
+ waitingForUser: info.waitingForUser ?? existingChild?.waitingForUser,
132
+ } as OpencodeSession);
133
+
134
+ if (existingChild) {
135
+ return {
136
+ ...parent,
137
+ children: children.map((entry) => (entry.id === info.id ? child : entry)),
138
+ };
139
+ }
140
+
141
+ return {
142
+ ...parent,
143
+ children: [...children, child],
144
+ };
145
+ });
146
+
147
+ if (!updated) {
148
+ scheduleRefetch();
149
+ return old;
150
+ }
151
+
152
+ return {
153
+ ...old,
154
+ sessions,
155
+ };
156
+ }
157
+
158
+ const existing = old.sessions.find(s => s.id === info.id);
159
+ if (!existing) {
160
+ scheduleRefetch();
161
+ return {
162
+ ...old,
163
+ sessions: [buildOptimisticSession(info), ...old.sessions],
164
+ };
165
+ }
166
+ const merged = {
167
+ ...info,
168
+ projectName: existing.projectName,
169
+ branch: existing.branch,
170
+ realTimeStatus: info.realTimeStatus ?? existing.realTimeStatus,
171
+ waitingForUser: info.waitingForUser ?? existing.waitingForUser,
172
+ children: existing.children,
173
+ };
174
+ return {
175
+ ...old,
176
+ sessions: old.sessions.map(s => (s.id === info.id ? merged : s)),
177
+ };
178
+ }
179
+ case 'session.deleted': {
180
+ const info = event.properties?.info as OpencodeSession | undefined;
181
+ const id = info?.id ?? event.properties?.sessionID;
182
+ if (!id) { scheduleRefetch(); return old; }
183
+ return { ...old, sessions: old.sessions.filter(s => s.id !== id) };
184
+ }
185
+ default: {
186
+ if (!sessionId) {
187
+ scheduleRefetch();
188
+ return old;
189
+ }
190
+
191
+ const applyEvent = (s: OpencodeSession): OpencodeSession => {
192
+ switch (event.type) {
193
+ case 'session.status': {
194
+ const statusType = event.properties?.status?.type as 'idle' | 'busy' | 'retry' | undefined;
195
+ if (!statusType) return s;
196
+ const previousStatus = s.realTimeStatus;
197
+ const isParentSession = !s.parentID;
198
+ if (statusType === 'retry' && !initialLoadRef.current) {
199
+ playAlertSound();
200
+ }
201
+ if (
202
+ statusType === 'idle' &&
203
+ !initialLoadRef.current &&
204
+ !s.parentID &&
205
+ (previousStatus === 'busy' || previousStatus === 'retry')
206
+ ) {
207
+ playCompleteSound();
208
+ }
209
+ if (statusType === 'idle') {
210
+ persistWaiting(s.id, false);
211
+ }
212
+ if (statusType === 'retry') {
213
+ persistWaiting(s.id, true);
214
+ }
215
+ if (statusType === 'idle' && isParentSession && (s.children?.length || 0) > 0) {
216
+ scheduleRefetch();
217
+ }
218
+ return {
219
+ ...s,
220
+ realTimeStatus: statusType,
221
+ waitingForUser:
222
+ statusType === 'retry'
223
+ ? true
224
+ : statusType === 'idle'
225
+ ? false
226
+ : s.waitingForUser,
227
+ children: s.children,
228
+ };
229
+ }
230
+ case 'question.asked':
231
+ case 'permission.asked':
232
+ if (!initialLoadRef.current) {
233
+ playAttentionSound();
234
+ }
235
+ persistWaiting(sessionId!, true);
236
+ return { ...s, waitingForUser: true };
237
+ case 'permission.updated':
238
+ scheduleRefetch();
239
+ return s;
240
+ case 'question.replied':
241
+ case 'question.rejected':
242
+ case 'permission.replied':
243
+ persistWaiting(sessionId!, false);
244
+ return { ...s, waitingForUser: false };
245
+ case 'session.archived':
246
+ return {
247
+ ...s,
248
+ time: { ...(s.time || {}), archived: Date.now() }
249
+ };
250
+ default:
251
+ return s;
252
+ }
253
+ };
254
+
255
+ let found = false;
256
+ const newSessions = old.sessions.map((session: OpencodeSession) => {
257
+ if (session.id === sessionId) {
258
+ found = true;
259
+ return applyEvent(session);
260
+ }
261
+ if (session.children?.some(c => c.id === sessionId)) {
262
+ found = true;
263
+ // If the event is a status update to 'idle', we should filter the child out
264
+ // so it disappears from the UI without needing a full refetch, matching backend logic.
265
+ if (event.type === 'session.status' && event.properties?.status?.type === 'idle') {
266
+ return {
267
+ ...session,
268
+ children: session.children.filter(c => c.id !== sessionId)
269
+ };
270
+ }
271
+
272
+ return {
273
+ ...session,
274
+ children: session.children.map(c => c.id === sessionId ? applyEvent(c) : c)
275
+ };
276
+ }
277
+ return session;
278
+ });
279
+
280
+ if (!found) {
281
+ scheduleRefetch();
282
+ return old;
283
+ }
284
+
285
+ return { ...old, sessions: newSessions };
286
+ }
287
+ }
288
+ });
289
+ }, [queryClient, scheduleRefetch]);
290
+
291
+ // After initial connection, mark as no longer initial load after 3 seconds
292
+ useEffect(() => {
293
+ initialLoadTimerRef.current = setTimeout(() => {
294
+ initialLoadRef.current = false;
295
+ }, 3000);
296
+ return () => {
297
+ if (initialLoadTimerRef.current) {
298
+ clearTimeout(initialLoadTimerRef.current);
299
+ }
300
+ };
301
+ }, []);
302
+
303
+ useEffect(() => {
304
+ let eventSource: EventSource | null = null;
305
+
306
+ const connect = () => {
307
+ if (reconnectTimeoutRef.current) {
308
+ clearTimeout(reconnectTimeoutRef.current);
309
+ reconnectTimeoutRef.current = null;
310
+ }
311
+ if (streamRotateTimeoutRef.current) {
312
+ clearTimeout(streamRotateTimeoutRef.current);
313
+ streamRotateTimeoutRef.current = null;
314
+ }
315
+
316
+ eventSource = new EventSource('/api/opencode-events');
317
+
318
+ eventSource.onopen = () => {
319
+ reconnectAttemptsRef.current = 0;
320
+ rotatingRef.current = false;
321
+ streamRotateTimeoutRef.current = setTimeout(() => {
322
+ if (!eventSource) return;
323
+ rotatingRef.current = true;
324
+ eventSource.close();
325
+ scheduleRefetch();
326
+ connect();
327
+ }, STREAM_ROTATE_INTERVAL);
328
+ };
329
+
330
+ eventSource.onmessage = (event) => {
331
+ try {
332
+ const data = JSON.parse(event.data) as OpencodeEvent;
333
+ handleEvent(data);
334
+ reconnectAttemptsRef.current = 0; // Reset on success
335
+ } catch (err) {
336
+ console.error('Failed to parse SSE event:', err);
337
+ }
338
+ };
339
+
340
+ eventSource.onerror = () => {
341
+ eventSource?.close();
342
+
343
+ if (streamRotateTimeoutRef.current) {
344
+ clearTimeout(streamRotateTimeoutRef.current);
345
+ streamRotateTimeoutRef.current = null;
346
+ }
347
+
348
+ if (rotatingRef.current) {
349
+ rotatingRef.current = false;
350
+ return;
351
+ }
352
+
353
+ if (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
354
+ const delay = BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttemptsRef.current);
355
+ reconnectTimeoutRef.current = setTimeout(() => {
356
+ reconnectAttemptsRef.current++;
357
+ connect();
358
+ }, delay);
359
+ }
360
+ };
361
+ };
362
+
363
+ connect();
364
+
365
+ return () => {
366
+ eventSource?.close();
367
+ if (reconnectTimeoutRef.current) {
368
+ clearTimeout(reconnectTimeoutRef.current);
369
+ reconnectTimeoutRef.current = null;
370
+ }
371
+ if (streamRotateTimeoutRef.current) {
372
+ clearTimeout(streamRotateTimeoutRef.current);
373
+ streamRotateTimeoutRef.current = null;
374
+ }
375
+ };
376
+ }, [handleEvent, scheduleRefetch]);
377
+
378
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type { Profile, ProfileConfig, CategoryConfig, AgentConfig, OhMyOpencodeConfig, ProfileIndex } from './types/opencodeConfig';
2
+ export { CONFIG_DIR, CONFIG_PATH, type OpenCodeConfig, detectConfig, readConfig, writeConfig } from './lib/opencodeConfig';
@@ -0,0 +1,266 @@
1
+ 'use client';
2
+
3
+ let audioContext: AudioContext | null = null;
4
+
5
+ const STORAGE_KEY = 'vibepulse:sound-muted';
6
+ const SOUND_LOG_THROTTLE_MS = 10000;
7
+ const soundLogLastAt = new Map<string, number>();
8
+
9
+ function shouldLogSound(key: string): boolean {
10
+ const now = Date.now();
11
+ const lastAt = soundLogLastAt.get(key) ?? 0;
12
+ if (now - lastAt < SOUND_LOG_THROTTLE_MS) {
13
+ return false;
14
+ }
15
+ soundLogLastAt.set(key, now);
16
+ return true;
17
+ }
18
+
19
+ function logSoundInfo(key: string, message: string): void {
20
+ if (!shouldLogSound(`info:${key}`)) return;
21
+ console.info(`[VibePulse sound] ${message}`);
22
+ }
23
+
24
+ function logSoundWarning(key: string, message: string, error: unknown): void {
25
+ if (!shouldLogSound(`warn:${key}`)) return;
26
+ console.warn(`[VibePulse sound] ${message}`, error);
27
+ }
28
+
29
+ function getAudioContext(): AudioContext {
30
+ if (!audioContext) {
31
+ audioContext = new AudioContext();
32
+ }
33
+ return audioContext;
34
+ }
35
+
36
+ function runWithAudio(
37
+ key: string,
38
+ mutedMessage: string,
39
+ failureMessage: string,
40
+ play: (ctx: AudioContext, now: number) => void
41
+ ): void {
42
+ if (isMuted()) {
43
+ logSoundInfo(`${key}-muted`, mutedMessage);
44
+ return;
45
+ }
46
+
47
+ try {
48
+ const ctx = getAudioContext();
49
+ const playNow = () => {
50
+ try {
51
+ play(ctx, ctx.currentTime);
52
+ } catch (error) {
53
+ logSoundWarning(key, failureMessage, error);
54
+ }
55
+ };
56
+
57
+ if (ctx.state === 'running') {
58
+ playNow();
59
+ return;
60
+ }
61
+
62
+ void ctx
63
+ .resume()
64
+ .then(() => {
65
+ if (ctx.state === 'running') {
66
+ playNow();
67
+ return;
68
+ }
69
+ logSoundWarning(`${key}-state`, `${failureMessage} (AudioContext not running after resume)`, new Error(ctx.state));
70
+ })
71
+ .catch((error) => {
72
+ logSoundWarning(`${key}-resume`, `${failureMessage} (AudioContext resume failed)`, error);
73
+ });
74
+ } catch (error) {
75
+ logSoundWarning(key, failureMessage, error);
76
+ }
77
+ }
78
+
79
+ export function isMuted(): boolean {
80
+ if (typeof window === 'undefined') return false;
81
+ return window.localStorage.getItem(STORAGE_KEY) === 'true';
82
+ }
83
+
84
+ export function setMuted(muted: boolean): void {
85
+ if (typeof window === 'undefined') return;
86
+ window.localStorage.setItem(STORAGE_KEY, String(muted));
87
+ }
88
+
89
+ /**
90
+ * Ensure AudioContext is unlocked after a user gesture.
91
+ * Call this from any click handler to enable future sound playback.
92
+ */
93
+ export function unlockAudio(): void {
94
+ try {
95
+ const ctx = getAudioContext();
96
+ const prime = () => {
97
+ const buffer = ctx.createBuffer(1, 1, 22050);
98
+ const source = ctx.createBufferSource();
99
+ source.buffer = buffer;
100
+ source.connect(ctx.destination);
101
+ source.start(0);
102
+ };
103
+
104
+ if (ctx.state === 'running') {
105
+ prime();
106
+ return;
107
+ }
108
+
109
+ void ctx
110
+ .resume()
111
+ .then(() => {
112
+ if (ctx.state === 'running') {
113
+ prime();
114
+ return;
115
+ }
116
+ logSoundWarning('unlock-state', 'unlockAudio() resume did not reach running state', new Error(ctx.state));
117
+ })
118
+ .catch((error) => {
119
+ logSoundWarning('unlock-resume', 'Failed to resume AudioContext in unlockAudio()', error);
120
+ });
121
+ } catch (error) {
122
+ logSoundWarning('unlock-audio', 'unlockAudio() failed', error);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Two-tone ascending chime — used for question.asked / permission.asked
128
+ * A gentle, non-alarming notification that user input is needed.
129
+ */
130
+ export function playAttentionSound(): void {
131
+ runWithAudio(
132
+ 'play-attention',
133
+ 'Skipped playAttentionSound() because muted',
134
+ 'playAttentionSound() failed',
135
+ (ctx, now) => {
136
+ // First tone: C5 (523 Hz)
137
+ const osc1 = ctx.createOscillator();
138
+ const gain1 = ctx.createGain();
139
+ osc1.type = 'sine';
140
+ osc1.frequency.value = 523;
141
+ gain1.gain.setValueAtTime(0.3, now);
142
+ gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
143
+ osc1.connect(gain1);
144
+ gain1.connect(ctx.destination);
145
+ osc1.start(now);
146
+ osc1.stop(now + 0.2);
147
+
148
+ // Second tone: E5 (659 Hz) — ascending interval
149
+ const osc2 = ctx.createOscillator();
150
+ const gain2 = ctx.createGain();
151
+ osc2.type = 'sine';
152
+ osc2.frequency.value = 659;
153
+ gain2.gain.setValueAtTime(0.3, now + 0.15);
154
+ gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.4);
155
+ osc2.connect(gain2);
156
+ gain2.connect(ctx.destination);
157
+ osc2.start(now + 0.15);
158
+ osc2.stop(now + 0.4);
159
+
160
+ // Third tone: G5 (784 Hz) — more noticeable
161
+ const osc3 = ctx.createOscillator();
162
+ const gain3 = ctx.createGain();
163
+ osc3.type = 'sine';
164
+ osc3.frequency.value = 784;
165
+ gain3.gain.setValueAtTime(0.25, now + 0.3);
166
+ gain3.gain.exponentialRampToValueAtTime(0.001, now + 0.55);
167
+ osc3.connect(gain3);
168
+ gain3.connect(ctx.destination);
169
+ osc3.start(now + 0.3);
170
+ osc3.stop(now + 0.55);
171
+ }
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Descending alert tone — used for retry/error states.
177
+ * Slightly more urgent than the attention chime.
178
+ */
179
+ export function playAlertSound(): void {
180
+ runWithAudio(
181
+ 'play-alert',
182
+ 'Skipped playAlertSound() because muted',
183
+ 'playAlertSound() failed',
184
+ (ctx, now) => {
185
+ // Descending tone: A5 → F5
186
+ const osc = ctx.createOscillator();
187
+ const gain = ctx.createGain();
188
+ osc.type = 'triangle';
189
+ osc.frequency.setValueAtTime(880, now);
190
+ osc.frequency.exponentialRampToValueAtTime(698, now + 0.25);
191
+ gain.gain.setValueAtTime(0.3, now);
192
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
193
+ osc.connect(gain);
194
+ gain.connect(ctx.destination);
195
+ osc.start(now);
196
+ osc.stop(now + 0.35);
197
+
198
+ // Second hit
199
+ const osc2 = ctx.createOscillator();
200
+ const gain2 = ctx.createGain();
201
+ osc2.type = 'triangle';
202
+ osc2.frequency.setValueAtTime(698, now + 0.2);
203
+ osc2.frequency.exponentialRampToValueAtTime(523, now + 0.45);
204
+ gain2.gain.setValueAtTime(0.25, now + 0.2);
205
+ gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.5);
206
+ osc2.connect(gain2);
207
+ gain2.connect(ctx.destination);
208
+ osc2.start(now + 0.2);
209
+ osc2.stop(now + 0.5);
210
+ }
211
+ );
212
+ }
213
+
214
+ export function playCompleteSound(): void {
215
+ runWithAudio(
216
+ 'play-complete',
217
+ 'Skipped playCompleteSound() because muted',
218
+ 'playCompleteSound() failed',
219
+ (ctx, now) => {
220
+ const osc = ctx.createOscillator();
221
+ const gain = ctx.createGain();
222
+ osc.type = 'sine';
223
+ osc.frequency.setValueAtTime(659, now);
224
+ osc.frequency.exponentialRampToValueAtTime(988, now + 0.18);
225
+ gain.gain.setValueAtTime(0.22, now);
226
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.22);
227
+ osc.connect(gain);
228
+ gain.connect(ctx.destination);
229
+ osc.start(now);
230
+ osc.stop(now + 0.22);
231
+
232
+ const osc2 = ctx.createOscillator();
233
+ const gain2 = ctx.createGain();
234
+ osc2.type = 'sine';
235
+ osc2.frequency.setValueAtTime(988, now + 0.12);
236
+ osc2.frequency.exponentialRampToValueAtTime(1318, now + 0.32);
237
+ gain2.gain.setValueAtTime(0.18, now + 0.12);
238
+ gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.36);
239
+ osc2.connect(gain2);
240
+ gain2.connect(ctx.destination);
241
+ osc2.start(now + 0.12);
242
+ osc2.stop(now + 0.36);
243
+ }
244
+ );
245
+ }
246
+
247
+ export function playToggleFeedbackSound(): void {
248
+ runWithAudio(
249
+ 'play-toggle-feedback',
250
+ 'Skipped playToggleFeedbackSound() because muted',
251
+ 'playToggleFeedbackSound() failed',
252
+ (ctx, now) => {
253
+ const osc = ctx.createOscillator();
254
+ const gain = ctx.createGain();
255
+ osc.type = 'sine';
256
+ osc.frequency.setValueAtTime(740, now);
257
+ osc.frequency.exponentialRampToValueAtTime(920, now + 0.1);
258
+ gain.gain.setValueAtTime(0.13, now);
259
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.13);
260
+ osc.connect(gain);
261
+ gain.connect(ctx.destination);
262
+ osc.start(now);
263
+ osc.stop(now + 0.13);
264
+ }
265
+ );
266
+ }