Soundboard
Sound pads with hotkeys, modes, volume, stop all and drag-and-drop.
Preparing sounds…
Installation
$ pnpm dlx shadcn@latest add @audiocn/soundboardWhat it does
- Pads play on click or with hotkeys 1–9 anywhere on the page, except while typing in a field. Hotkeys can be turned off.
- Right-click a pad to change its mode (one shot, toggle, hold, loop), its volume, or remove it.
- Removing a pad shows an inline Undo action that restores it in its original position. The most recent removal stays undoable until another pad is removed.
- A master volume and a "Stop all" button.
- Drop audio files onto the board, or use the Add pad, to add sounds.
- Inline status messages announce additions, rejected or duplicate files, removals and stopping playback. There is no global toast provider.
Pass output to route the board into a mixer channel instead of the speakers.
Props
Prop
Type
Files
"use client";
import {
PlusIcon,
SpeakerHighIcon,
StopIcon,
WaveformIcon,
} from "@phosphor-icons/react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { DragEvent } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Label } from "@/components/ui/label";
import {
SoundPad,
SoundPadGrid,
SoundPadLabel,
SoundPadProgress,
SoundPadShortcut,
} from "@/components/ui/sound-pad";
import type { SoundPadMode } from "@/components/ui/sound-pad";
import { Switch } from "@/components/ui/switch";
import {
VolumeControl,
VolumeControlMute,
VolumeControlSlider,
} from "@/components/ui/volume-control";
import { useAudioContext } from "@/hooks/use-audio-context";
import { useSound } from "@/hooks/use-sound";
export interface SoundboardSound {
id: string;
label: string;
src: string | AudioBuffer;
hotkey?: string;
mode?: SoundPadMode;
/** 0..1. Default 1. */
volume?: number;
accent?: string;
}
export interface SoundboardProps {
sounds?: SoundboardSound[];
defaultSounds?: SoundboardSound[];
onSoundsChange?: (sounds: SoundboardSound[]) => void;
/** Route pads into a mixer instead of the speakers. */
output?: AudioNode | null;
/** Default 4. */
columns?: number;
className?: string;
}
const MODES: { value: SoundPadMode; label: string }[] = [
{ label: "One shot", value: "one-shot" },
{ label: "Toggle", value: "toggle" },
{ label: "Hold", value: "hold" },
{ label: "Loop", value: "loop" },
];
const VOLUMES = [1, 0.75, 0.5, 0.25];
interface PadProps {
sound: SoundboardSound;
bus: AudioNode | null;
stopSignal: number;
onChange: (sound: SoundboardSound) => void;
onRemove: () => void;
}
const Pad = ({ sound, bus, stopSignal, onChange, onRemove }: PadProps) => {
const mode = sound.mode ?? "one-shot";
const player = useSound(sound.src, {
destination: bus,
interrupt: mode !== "one-shot",
loop: mode === "loop",
volume: sound.volume ?? 1,
});
const { stop } = player;
useEffect(() => {
if (stopSignal > 0) {
stop();
}
}, [stop, stopSignal]);
return (
<ContextMenu>
<ContextMenuTrigger
render={
<SoundPad
accent={sound.accent}
hotkey={sound.hotkey}
loading={!player.isLoaded}
mode={mode}
onStop={() => player.stop()}
onTrigger={() => player.play()}
playing={player.isPlaying}
/>
}
>
<SoundPadLabel>{sound.label}</SoundPadLabel>
<SoundPadShortcut />
<SoundPadProgress source={player.progress} />
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuGroup>
<ContextMenuLabel>Mode</ContextMenuLabel>
<ContextMenuRadioGroup
onValueChange={(value) =>
onChange({ ...sound, mode: value as SoundPadMode })
}
value={mode}
>
{MODES.map((option) => (
<ContextMenuRadioItem key={option.value} value={option.value}>
{option.label}
</ContextMenuRadioItem>
))}
</ContextMenuRadioGroup>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuLabel>Volume</ContextMenuLabel>
<ContextMenuRadioGroup
onValueChange={(value) =>
onChange({ ...sound, volume: Number(value) })
}
value={String(sound.volume ?? 1)}
>
{VOLUMES.map((volume) => (
<ContextMenuRadioItem key={volume} value={String(volume)}>
{Math.round(volume * 100)}%
</ContextMenuRadioItem>
))}
</ContextMenuRadioGroup>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem onClick={onRemove} variant="destructive">
Remove
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
);
};
const AUDIO_FILE = /^audio\//u;
const FILE_EXTENSION = /\.[^.]+$/u;
const NO_SOUNDS: SoundboardSound[] = [];
const fileToSound = (file: File, index: number): SoundboardSound => ({
hotkey: index < 9 ? String(index + 1) : undefined,
id: `${file.name}-${file.lastModified}`,
label: file.name.replace(FILE_EXTENSION, ""),
src: URL.createObjectURL(file),
});
export const Soundboard = ({
sounds: soundsProp,
defaultSounds = NO_SOUNDS,
onSoundsChange,
output,
columns = 4,
className,
}: SoundboardProps) => {
const [soundsState, setSoundsState] = useState(defaultSounds);
const sounds = soundsProp ?? soundsState;
const { context } = useAudioContext();
const bus = useMemo(() => context?.createGain() ?? null, [context]);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [hotkeys, setHotkeys] = useState(true);
const [stopSignal, setStopSignal] = useState(0);
const [dragging, setDragging] = useState(false);
const [feedback, setFeedback] = useState("");
const [removedSound, setRemovedSound] = useState<{
sound: SoundboardSound;
index: number;
} | null>(null);
const hotkeysId = useId();
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!(bus && context)) {
return;
}
const target = output === undefined ? context.destination : output;
if (!target) {
return;
}
bus.connect(target);
return () => {
bus.disconnect(target);
};
}, [bus, context, output]);
useEffect(() => {
if (bus && context) {
bus.gain.setTargetAtTime(
muted ? 0 : volume ** 2,
context.currentTime,
0.01
);
}
}, [bus, context, muted, volume]);
const update = (next: SoundboardSound[]) => {
setSoundsState(next);
onSoundsChange?.(next);
};
const addFiles = (files: FileList | null) => {
if (!files || files.length === 0) {
return;
}
const existingIds = new Set(sounds.map((sound) => sound.id));
const added = [...files]
.filter((file) => AUDIO_FILE.test(file.type))
.filter((file) => {
const id = `${file.name}-${file.lastModified}`;
if (existingIds.has(id)) {
return false;
}
existingIds.add(id);
return true;
})
.map((file, index) => fileToSound(file, sounds.length + index));
if (added.length === 0) {
setFeedback(
"No new sounds added. Choose audio files that aren't already on the board."
);
return;
}
update([...sounds, ...added]);
setFeedback(
`Added ${added.length} ${added.length === 1 ? "sound" : "sounds"}.`
);
};
const removeSound = (sound: SoundboardSound) => {
setRemovedSound({
index: sounds.findIndex((item) => item.id === sound.id),
sound,
});
update(sounds.filter((item) => item.id !== sound.id));
setFeedback(`Removed ${sound.label}. You can undo this removal.`);
};
const undoRemoval = () => {
if (!removedSound) {
return;
}
const { sound, index } = removedSound;
if (!sounds.some((item) => item.id === sound.id)) {
update([...sounds.slice(0, index), sound, ...sounds.slice(index)]);
}
setRemovedSound(null);
setFeedback(`Restored ${sound.label}.`);
};
const dropProps = useMemo(
() => ({
onDragLeave: () => setDragging(false),
onDragOver: (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setDragging(true);
},
}),
[]
);
return (
<div
className={className}
data-dragging={dragging ? "" : undefined}
data-slot="soundboard"
{...dropProps}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
addFiles(event.dataTransfer.files);
}}
>
<div className="mb-3 flex flex-wrap items-center gap-3">
<h3 className="font-heading mr-auto font-medium">Soundboard</h3>
<div className="flex items-center gap-2">
<Switch
checked={hotkeys}
id={hotkeysId}
onCheckedChange={setHotkeys}
size="sm"
/>
<Label htmlFor={hotkeysId}>Hotkeys</Label>
</div>
<VolumeControl
className="w-36"
muted={muted}
onMutedChange={setMuted}
onValueChange={setVolume}
value={volume}
>
<VolumeControlMute>
<SpeakerHighIcon />
</VolumeControlMute>
<VolumeControlSlider />
</VolumeControl>
<Button
onClick={() => {
setStopSignal((signal) => signal + 1);
setFeedback("Stopped all sounds.");
}}
size="sm"
variant="outline"
>
<StopIcon data-icon="inline-start" />
Stop all
</Button>
</div>
{feedback && (
<output aria-live="polite" className="mb-3 block">
<Alert role="none">
<AlertDescription>{feedback}</AlertDescription>
{removedSound && (
<div className="mt-2">
<Button onClick={undoRemoval} size="sm" variant="outline">
Undo removal
</Button>
</div>
)}
</Alert>
</output>
)}
{sounds.length === 0 ? (
<div className="rounded-xl border border-dashed">
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<WaveformIcon />
</EmptyMedia>
<EmptyTitle>No sounds yet</EmptyTitle>
<EmptyDescription>
Drop audio files here, or add them from your computer.
</EmptyDescription>
</EmptyHeader>
<Button
onClick={() => fileInputRef.current?.click()}
size="sm"
variant="outline"
>
<PlusIcon data-icon="inline-start" />
Add sounds
</Button>
</Empty>
</div>
) : (
<SoundPadGrid
className="data-dragging:ring-2"
columns={columns}
hotkeyScope="global"
hotkeys={hotkeys}
>
{sounds.map((sound) => (
<Pad
bus={bus}
key={sound.id}
onChange={(next) =>
update(
sounds.map((item) => (item.id === sound.id ? next : item))
)
}
onRemove={() => removeSound(sound)}
sound={sound}
stopSignal={stopSignal}
/>
))}
<SoundPad
aria-label="Add sounds"
className="text-muted-foreground items-center justify-center border-dashed"
onTrigger={() => fileInputRef.current?.click()}
variant="outline"
>
<PlusIcon className="size-5" />
<SoundPadLabel>Add</SoundPadLabel>
</SoundPad>
</SoundPadGrid>
)}
<input
accept="audio/*"
aria-label="Add audio files"
className="hidden"
multiple
onChange={(event) => {
addFiles(event.target.files);
event.target.value = "";
}}
ref={fileInputRef}
type="file"
/>
</div>
);
};