Sunflower UI
Search
Copy Code
Light Mode

Sound System

Synthetic Web Audio interactions.

Presses

A thing being hit.

Toggles

Direction carries the state.

Surfaces

Slower than a press, because the room changed.

Values

Nearly subliminal, because they repeat.

Outcomes

The only cues allowed to be a phrase.

Named

Asked for by a control through data-sound.
sound-system.tsx
prompt.md
Collapse
Reset
Copy Code
Download
Run
1
"use client";
2
3
import React, { useCallback, useRef, useState } from "react";
4
import { cn } from "@/lib/utils";
5
import { Volume2, VolumeX } from "lucide-react";
6
7
let globalAudioCtx: AudioContext | null = null;
8
9
function getAudioContext() {
10
if (typeof window === "undefined") return null;
11
if (!globalAudioCtx) {
12
globalAudioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
13
}
14
if (globalAudioCtx.state === "suspended") {
15
globalAudioCtx.resume();
16
}
17
return globalAudioCtx;
18
}
19
20
const SOUND_CATEGORIES = [
21
{
22
title: "Presses",
23
description: "A thing being hit.",
24
items: [
25
{ id: "tap", label: "tap", description: "Any button. By volume, the sound of the whole app." },
26
{ id: "select", label: "select", description: "A menu row, a tab, an option. Pitched by its place." },
27
{ id: "destructive", label: "destructive", description: "A destructive button. Lower and heavier, so it lands." }
28
]
29
},
30
{
31
title: "Toggles",
32
description: "Direction carries the state.",
33
items: [
34
{ id: "toggleOn", label: "toggleOn", description: "A switch, checkbox or toggle going on. It rises." },
35
{ id: "toggleOff", label: "toggleOff", description: "The same control going off. It falls." }
36
]
37
},
38
{
39
title: "Surfaces",
40
description: "Slower than a press, because the room changed.",
41
items: [
42
{ id: "open", label: "open", description: "A dialog, popover, accordion or menu arriving." },
43
{ id: "close", label: "close", description: "The surface leaving, or the button that shuts it." },
44
{ id: "swoosh", label: "swoosh", description: "The press that ends and shuts the surface. Also unmute." }
45
]
46
},
47
{
48
title: "Values",
49
description: "Nearly subliminal, because they repeat.",
50
items: [
51
{ id: "tick", label: "tick", description: "Taking hold of a slider, or stepping a value." },
52
{ id: "sliderTick", label: "sliderTick", description: "Every value a drag passes through. The quietest here." },
53
{ id: "key", label: "key", description: "Each digit of a one-time code, pitched by its position." }
54
]
55
},
56
{
57
title: "Outcomes",
58
description: "The only cues allowed to be a phrase.",
59
items: [
60
{ id: "success", label: "success", description: "Two notes, upward. It resolves." },
61
{ id: "error", label: "error", description: "Two notes, downward. It does not." },
62
{ id: "warning", label: "warning", description: "The same note twice. It insists." }
63
]
64
},
65
{
66
title: "Named",
67
description: "Asked for by a control through data-sound.",
68
items: [
69
{ id: "copy", label: "copy", description: "Taken to the clipboard. Two clicks, like a shutter." },
70
{ id: "notification", label: "notification", description: "The interface changing under you: the theme swapping." },
71
{ id: "chirp", label: "chirp", description: "Clearing a field or chip, or one step into the palette." },
72
{ id: "command", label: "command", description: "Running a palette command. The other half of chirp." },
73
{ id: "blocked", label: "blocked", description: "A press that will not be answered. Dead on purpose." }
74
]
75
}
76
];
77
78
export function SoundSystem() {
79
const [muted, setMuted] = useState(false);
80
const playingRef = useRef<string | null>(null);
81
const [, forceRender] = useState({});
82
83
const playSound = useCallback((id: string) => {
84
if (muted) return;
85
86
const ctx = getAudioContext();
87
if (!ctx) return;
88
const t = ctx.currentTime + 0.02;
89
90
console.log("Playing sound:", id);
91
92
playingRef.current = id;
93
forceRender({});
94
95
// Auto reset the active state after animation
96
setTimeout(() => {
97
if (playingRef.current === id) {
98
playingRef.current = null;
99
forceRender({});
100
}
101
}, 400);
102
103
const playTone = (freq: number, type: OscillatorType, time: number, dur: number, vol: number = 0.5, slideFreq?: number) => {
104
const osc = ctx.createOscillator();
105
const gain = ctx.createGain();
106
osc.type = type;
107
osc.frequency.setValueAtTime(freq, time);
108
if (slideFreq) {
109
osc.frequency.exponentialRampToValueAtTime(slideFreq, time + dur);
110
}
111
gain.gain.setValueAtTime(0, time);
112
gain.gain.linearRampToValueAtTime(vol * 3, time + dur * 0.1); // Increased volume x3
113
gain.gain.exponentialRampToValueAtTime(0.001, time + dur);
114
osc.connect(gain);
115
gain.connect(ctx.destination);
116
osc.start(time);
117
osc.stop(time + dur);
118
};
119
120
const playNoise = (time: number, dur: number, vol: number = 0.5) => {
121
const bufferSize = ctx.sampleRate * dur;
122
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
123
const data = buffer.getChannelData(0);
124
for (let i = 0; i < bufferSize; i++) {
125
data[i] = Math.random() * 2 - 1;
126
}
127
const noise = ctx.createBufferSource();
128
noise.buffer = buffer;
129
130
const filter = ctx.createBiquadFilter();
131
filter.type = 'bandpass';
132
filter.frequency.setValueAtTime(1000, time);
133
filter.frequency.linearRampToValueAtTime(4000, time + dur);
134
135
const gain = ctx.createGain();
136
gain.gain.setValueAtTime(0, time);
137
gain.gain.linearRampToValueAtTime(vol * 3, time + dur * 0.1); // Increased volume x3
138
gain.gain.exponentialRampToValueAtTime(0.001, time + dur);
139
140
noise.connect(filter);
141
filter.connect(gain);
142
gain.connect(ctx.destination);
143
noise.start(time);
144
noise.stop(time + dur);
145
};
146
147
switch(id) {
148
case 'tap':
149
playTone(700, 'sine', t, 0.05, 0.15);
150
playTone(1200, 'triangle', t, 0.03, 0.05);
151
break;
152
case 'select':
153
playTone(900, 'sine', t, 0.05, 0.15);
154
playTone(1500, 'triangle', t, 0.03, 0.05);
155
break;
156
case 'destructive':
157
playTone(150, 'sawtooth', t, 0.15, 0.2, 50);
158
playNoise(t, 0.1, 0.1);
159
break;
160
161
case 'toggleOn':
162
playTone(400, 'sine', t, 0.1, 0.1);
163
playTone(600, 'sine', t + 0.1, 0.15, 0.1);
164
break;
165
case 'toggleOff':
166
playTone(600, 'sine', t, 0.1, 0.1);
167
playTone(400, 'sine', t + 0.1, 0.15, 0.1);
168
break;
169
170
case 'open':
171
playTone(300, 'sine', t, 0.2, 0.1, 600);
172
playNoise(t, 0.2, 0.03);
173
break;
174
case 'close':
175
playTone(600, 'sine', t, 0.2, 0.1, 300);
176
playNoise(t, 0.2, 0.03);
177
break;
178
case 'swoosh':
179
playNoise(t, 0.3, 0.15);
180
break;
181
182
case 'tick':
183
playTone(1800, 'triangle', t, 0.015, 0.03);
184
break;
185
case 'sliderTick':
186
playTone(2200, 'triangle', t, 0.01, 0.01);
187
break;
188
case 'key':
189
playTone(1200, 'sine', t, 0.05, 0.05);
190
playTone(2000, 'sine', t, 0.03, 0.02);
191
break;
192
193
case 'success':
194
playTone(440, 'sine', t, 0.1, 0.1);
195
playTone(554, 'sine', t + 0.1, 0.1, 0.1); // C#
196
playTone(659, 'sine', t + 0.2, 0.4, 0.15); // E
197
break;
198
case 'error':
199
playTone(300, 'sawtooth', t, 0.15, 0.1);
200
playTone(250, 'sawtooth', t + 0.15, 0.3, 0.1);
201
break;
202
case 'warning':
203
playTone(400, 'square', t, 0.1, 0.05);
204
playTone(400, 'square', t + 0.2, 0.2, 0.05);
205
break;
206
207
case 'copy':
208
playNoise(t, 0.02, 0.1);
209
playNoise(t + 0.06, 0.02, 0.1);
210
break;
211
case 'notification':
212
playTone(700, 'sine', t, 0.15, 0.1);
213
playTone(1050, 'sine', t + 0.15, 0.4, 0.15);
214
break;
215
case 'chirp':
216
playTone(900, 'sine', t, 0.15, 0.1, 1400);
217
break;
218
case 'command':
219
playTone(1400, 'sine', t, 0.15, 0.1, 900);
220
break;
221
case 'blocked':
222
playTone(150, 'triangle', t, 0.08, 0.15);
223
break;
224
}
225
}, [muted]);
226
227
return (
228
<div className="w-full max-w-[900px] mx-auto py-6 px-4 bg-transparent text-zinc-900 dark:text-[#F5F5F7] font-matter">
229
<div className="w-full">
230
<div className="flex items-center justify-between mb-12">
231
<div>
232
<h1 className="text-2xl font-medium font-matter mb-2 text-zinc-900 dark:text-[#F5F5F7]">Sound System</h1>
233
<p className="text-zinc-500 dark:text-[#86868B] text-sm font-matter">Synthetic Web Audio interactions.</p>
234
</div>
235
<button
236
onClick={() => setMuted(!muted)}
237
className="p-3 rounded-full bg-zinc-100 dark:bg-gradient-to-b dark:from-[#242426] dark:to-[#141416] hover:bg-zinc-200 dark:hover:from-[#2C2C2E] dark:hover:to-[#1C1C1E] border border-zinc-200/80 dark:border-white/10 transition-all text-zinc-600 dark:text-[#86868B] hover:text-zinc-900 dark:hover:text-[#F5F5F7] shadow-sm"
238
>
239
{muted ? <VolumeX size={20} /> : <Volume2 size={20} />}
240
</button>
241
</div>
242
243
<div className="flex flex-col gap-10">
244
{SOUND_CATEGORIES.map((category) => (
245
<div key={category.title}>
246
<div className="flex items-baseline gap-3 mb-5">
247
<h2 className="text-[15px] font-medium font-matter text-zinc-900 dark:text-[#F5F5F7]">{category.title}</h2>
248
<span className="text-[14px] font-matter text-zinc-500 dark:text-[#86868B]">{category.description}</span>
249
</div>
250
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
251
{category.items.map((item) => (
252
<button
253
key={item.id}
254
onClick={() => playSound(item.id)}
255
className={cn(
256
"flex flex-col text-left p-4.5 rounded-xl bg-white dark:bg-gradient-to-b dark:from-[#1C1C1E] dark:to-[#0A0A0C] border border-zinc-200/80 dark:border-white/5 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] shadow-sm",
257
"hover:bg-zinc-50 hover:border-zinc-300 dark:hover:from-[#242426] dark:hover:to-[#0F0F12] dark:hover:border-white/15 dark:hover:shadow-[0_8px_30px_rgba(0,0,0,0.6),inset_0_1px_0_rgba(255,255,255,0.12)] transition-all duration-200 active:scale-[0.98]",
258
playingRef.current === item.id ? "bg-zinc-100 border-zinc-300 ring-1 ring-zinc-300/50 dark:from-[#2C2C2E] dark:to-[#141416] dark:border-white/20 dark:ring-white/10" : ""
259
)}
260
>
261
<span className="font-mono text-[13px] text-zinc-800 dark:text-[#F5F5F7] mb-2">{item.label}</span>
262
<span className="text-[13.5px] font-matter leading-relaxed text-zinc-500 dark:text-[#86868B]">{item.description}</span>
263
</button>
264
))}
265
</div>
266
</div>
267
))}
268
</div>
269
</div>
270
</div>
271
);
272
}
273