audiocn-svelte
Esc

Start typing to search the docs

Soundboard

Sound pads with hotkeys, modes, volume, stop all and drag-and-drop.

2 min read · 231 words

Preparing sounds…

Installation

$ pnpm dlx shadcn-svelte@latest add https://juddisjudd.github.io/audiocn-svelte/r/soundboard.json

What 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. Each file is held as an object URL, which the board releases once its removal can no longer be undone.
  • 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

PropTypeDefault
sounds / defaultSounds

Type SoundboardSound. src is a URL or an AudioBuffer.

{ id, label, src, hotkey?, mode?, volume?, accent? }[][]
onSoundsChange

Called when sounds are added, edited or removed.

(sounds) => void—
output

Route pads into a mixer.

AudioNode | nullspeakers
columns

The most columns; fewer on narrow screens.

number4
class string—

Files

src/lib/components/blocks/soundboard/soundboard.svelte
<script lang="ts" module>
	import type { SoundboardPadActions, SoundboardSound } from "./soundboard-pad.svelte";

	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;
		class?: string;
	}

	const AUDIO_FILE = /^audio\//u;
	const FILE_EXTENSION = /\.[^.]+$/u;
	const NO_SOUNDS: SoundboardSound[] = [];

	const fileToSound = (file: File, index: number): SoundboardSound & { src: string } => ({
		hotkey: index < 9 ? String(index + 1) : undefined,
		id: `${file.name}-${file.lastModified}`,
		label: file.name.replace(FILE_EXTENSION, ""),
		src: URL.createObjectURL(file),
	});
</script>

<script lang="ts">
	import PlusIcon from "phosphor-svelte/lib/PlusIcon";
	import SpeakerHighIcon from "phosphor-svelte/lib/SpeakerHighIcon";
	import StopIcon from "phosphor-svelte/lib/StopIcon";
	import WaveformIcon from "phosphor-svelte/lib/WaveformIcon";
	import { untrack } from "svelte";

	import { Alert, AlertDescription } from "#lib/components/ui/alert/index.js";
	import { Button } from "#lib/components/ui/button/index.js";
	import {
		Empty,
		EmptyDescription,
		EmptyHeader,
		EmptyMedia,
		EmptyTitle,
	} from "#lib/components/ui/empty/index.js";
	import { Label } from "#lib/components/ui/label/index.js";
	import { SoundPad, SoundPadGrid, SoundPadLabel } from "#lib/components/ui/sound-pad/index.js";
	import { Switch } from "#lib/components/ui/switch/index.js";
	import {
		VolumeControl,
		VolumeControlMute,
		VolumeControlSlider,
	} from "#lib/components/ui/volume-control/index.js";
	import { useGainNode } from "#lib/hooks/use-gain-node.svelte.js";

	import SoundboardPad from "./soundboard-pad.svelte";

	let {
		sounds: soundsProp,
		defaultSounds = NO_SOUNDS,
		onSoundsChange,
		output,
		columns = 4,
		class: className,
	}: SoundboardProps = $props();

	let soundsState = $state.raw(untrack(() => defaultSounds));
	const sounds = $derived(soundsProp ?? soundsState);
	let volume = $state(1);
	let muted = $state(false);
	const bus = useGainNode(() => ({
		destination: output,
		gain: muted ? 0 : volume ** 2,
	}));
	let hotkeys = $state(true);
	let dragging = $state(false);
	let feedback = $state("");
	let removedSound = $state.raw<{ sound: SoundboardSound; index: number } | null>(null);
	const hotkeysId = $props.id();
	let fileInput = $state<HTMLInputElement | null>(null);
	const pads: Record<string, SoundboardPadActions | null> = {};
	// Object URLs this board created for dropped files.
	// eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping, never rendered
	const objectUrls = new Set<string>();

	const update = (next: SoundboardSound[]) => {
		soundsState = next;
		onSoundsChange?.(next);
	};

	const addFiles = (files: FileList | null | undefined) => {
		if (!files || files.length === 0) {
			return;
		}
		// eslint-disable-next-line svelte/prefer-svelte-reactivity -- a local lookup, never rendered
		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) {
			feedback = "No new sounds added. Choose audio files that aren't already on the board.";
			return;
		}
		for (const sound of added) {
			objectUrls.add(sound.src);
		}
		update([...sounds, ...added]);
		feedback = `Added ${added.length} ${added.length === 1 ? "sound" : "sounds"}.`;
	};

	// Frees a dropped file once no pad uses it and its removal can't be undone.
	const releaseObjectUrl = ({ src }: SoundboardSound) => {
		const inUse = sounds.some((item) => item.src === src);
		if (typeof src === "string" && !inUse && objectUrls.delete(src)) {
			URL.revokeObjectURL(src);
		}
	};

	const removeSound = (sound: SoundboardSound) => {
		if (removedSound) {
			releaseObjectUrl(removedSound.sound);
		}
		removedSound = {
			index: sounds.findIndex((item) => item.id === sound.id),
			sound,
		};
		update(sounds.filter((item) => item.id !== sound.id));
		feedback = `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)]);
		}
		removedSound = null;
		feedback = `Restored ${sound.label}.`;
	};

	const stopAll = () => {
		for (const pad of Object.values(pads)) {
			pad?.stop();
		}
		feedback = "Stopped all sounds.";
	};
</script>

<!-- Dropping files is a shortcut for the Add sounds controls, so the board needs no role. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
	class={className}
	data-dragging={dragging ? "" : undefined}
	data-slot="soundboard"
	ondragleave={() => {
		dragging = false;
	}}
	ondragover={(event) => {
		event.preventDefault();
		dragging = true;
	}}
	ondrop={(event) => {
		event.preventDefault();
		dragging = false;
		addFiles(event.dataTransfer?.files);
	}}
>
	<div class="mb-3 flex flex-wrap items-center gap-3">
		<h3 class="font-heading mr-auto font-medium">Soundboard</h3>
		<div class="flex items-center gap-2">
			<Switch bind:checked={hotkeys} id={hotkeysId} size="sm" />
			<Label for={hotkeysId}>Hotkeys</Label>
		</div>
		<VolumeControl class="w-36" bind:muted bind:value={volume}>
			<VolumeControlMute>
				<SpeakerHighIcon />
			</VolumeControlMute>
			<VolumeControlSlider />
		</VolumeControl>
		<Button onclick={stopAll} size="sm" variant="outline">
			<StopIcon data-icon="inline-start" />
			Stop all
		</Button>
	</div>
	{#if feedback}
		<output aria-live="polite" class="mb-3 block">
			<Alert role="none">
				<AlertDescription>{feedback}</AlertDescription>
				{#if removedSound}
					<div class="mt-2">
						<Button onclick={undoRemoval} size="sm" variant="outline">Undo removal</Button>
					</div>
				{/if}
			</Alert>
		</output>
	{/if}
	{#if sounds.length === 0}
		<div class="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={() => fileInput?.click()} size="sm" variant="outline">
					<PlusIcon data-icon="inline-start" />
					Add sounds
				</Button>
			</Empty>
		</div>
	{:else}
		<SoundPadGrid class="data-dragging:ring-2" {columns} hotkeyScope="global" {hotkeys}>
			{#each sounds as sound (sound.id)}
				<SoundboardPad
					bind:this={pads[sound.id]}
					{bus}
					onChange={(next) => update(sounds.map((item) => (item.id === sound.id ? next : item)))}
					onRemove={() => removeSound(sound)}
					{sound}
				/>
			{/each}
			<SoundPad
				aria-label="Add sounds"
				class="items-center justify-center border-dashed text-muted-foreground"
				onTrigger={() => fileInput?.click()}
				variant="outline"
			>
				<PlusIcon class="size-5" />
				<SoundPadLabel>Add</SoundPadLabel>
			</SoundPad>
		</SoundPadGrid>
	{/if}
	<input
		accept="audio/*"
		aria-label="Add audio files"
		bind:this={fileInput}
		class="hidden"
		multiple
		onchange={(event) => {
			addFiles(event.currentTarget.files);
			event.currentTarget.value = "";
		}}
		type="file"
	/>
</div>
src/lib/components/blocks/soundboard/soundboard-pad.svelte
<script lang="ts" module>
	import type { SoundPadMode } from "#lib/components/ui/sound-pad/index.js";

	export interface SoundboardSound {
		id: string;
		label: string;
		src: string | AudioBuffer;
		hotkey?: string;
		mode?: SoundPadMode;
		/** 0..1. Default 1. */
		volume?: number;
		accent?: string;
	}

	export interface SoundboardPadActions {
		stop: () => void;
	}

	export interface SoundboardPadProps {
		sound: SoundboardSound;
		bus: AudioNode | null;
		onChange: (sound: SoundboardSound) => void;
		onRemove: () => void;
	}

	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];
</script>

<script lang="ts">
	import {
		ContextMenu,
		ContextMenuContent,
		ContextMenuGroup,
		ContextMenuItem,
		ContextMenuLabel,
		ContextMenuRadioGroup,
		ContextMenuRadioItem,
		ContextMenuSeparator,
		ContextMenuTrigger,
	} from "#lib/components/ui/context-menu/index.js";
	import {
		SoundPad,
		SoundPadLabel,
		SoundPadProgress,
		SoundPadShortcut,
	} from "#lib/components/ui/sound-pad/index.js";
	import { useSound } from "#lib/hooks/use-sound.svelte.js";

	// eslint-disable-next-line svelte/no-unused-props -- onChange hands the whole sound back
	let { sound, bus, onChange, onRemove }: SoundboardPadProps = $props();

	const mode = $derived(sound.mode ?? "one-shot");
	const player = useSound(
		() => sound.src,
		() => ({
			destination: bus,
			interrupt: mode !== "one-shot",
			loop: mode === "loop",
			volume: sound.volume ?? 1,
		})
	);

	export function stop() {
		player.stop();
	}
</script>

<ContextMenu>
	<ContextMenuTrigger>
		{#snippet child({ props })}
			<SoundPad
				{...props}
				accent={sound.accent}
				hotkey={sound.hotkey}
				loading={!player.isLoaded}
				{mode}
				onStop={() => player.stop()}
				onTrigger={() => player.play()}
				playing={player.isPlaying}
			>
				<SoundPadLabel>{sound.label}</SoundPadLabel>
				<SoundPadShortcut />
				<SoundPadProgress source={player.progress} />
			</SoundPad>
		{/snippet}
	</ContextMenuTrigger>
	<ContextMenuContent class="w-48">
		<ContextMenuGroup>
			<ContextMenuLabel>Mode</ContextMenuLabel>
			<ContextMenuRadioGroup
				bind:value={() => mode, (value) => onChange({ ...sound, mode: value as SoundPadMode })}
			>
				{#each MODES as option (option.value)}
					<ContextMenuRadioItem value={option.value}>{option.label}</ContextMenuRadioItem>
				{/each}
			</ContextMenuRadioGroup>
		</ContextMenuGroup>
		<ContextMenuSeparator />
		<ContextMenuGroup>
			<ContextMenuLabel>Volume</ContextMenuLabel>
			<ContextMenuRadioGroup
				bind:value={
					() => String(sound.volume ?? 1), (value) => onChange({ ...sound, volume: Number(value) })
				}
			>
				{#each VOLUMES as volume (volume)}
					<ContextMenuRadioItem value={String(volume)}>
						{Math.round(volume * 100)}%
					</ContextMenuRadioItem>
				{/each}
			</ContextMenuRadioGroup>
		</ContextMenuGroup>
		<ContextMenuSeparator />
		<ContextMenuGroup>
			<ContextMenuItem onSelect={onRemove} variant="destructive">Remove</ContextMenuItem>
		</ContextMenuGroup>
	</ContextMenuContent>
</ContextMenu>
Last updated on October 7, 2026