audiocn-svelte
Esc

Start typing to search the docs

System Audio Mixer

The complete mixer, with microphone, system audio, music, sound pads and a master, running on Web Audio.

2 min read · 307 words

Audio mixer

Microphone Not listening
Off
0.0 dB
System audio Share a screen or tab with audio
Unsupported
−6.0 dB
Music No tracks
−12.0 dB
Sounds 0 pads
−6.0 dB
Master Mix output
0.0 dB

Installation

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

What it does

  • Microphone: choose a device, start and stop it, and see when it is live, muted or blocked.
  • System audio: capture a screen or tab with its audio through the browser's picker, with notices when nothing was shared or the browser cannot capture.
  • Music: play and skip tracks. Music ducks by 12 dB while the microphone is active.
  • Sounds: a popover of sound pads with hotkeys.
  • Every channel has a stereo meter, a fader, mute, solo and a monitor send to the speakers. Microphone monitoring is off by default to avoid feedback.
  • Master: a limited stereo mix with a clip counter and a master fader. The mix is available as a MediaStream through onOutputChange, ready for MediaRecorder or WebRTC.
  • Layouts: switch between rows and console strips.

Console layout

Audio mixer

Microphone Not listening
Off
0.0 dB
System audio Share a screen or tab with audio
Unsupported
−6.0 dB
Music No tracks
−12.0 dB
Sounds 0 pads
−6.0 dB
Master Mix output
0.0 dB

Usage

<script lang="ts">
	import { SystemAudioMixer } from "#lib/components/blocks/system-audio-mixer/index.js";
</script>

<SystemAudioMixer
	onOutputChange={(stream) => recorder.setStream(stream)}
	persistKey="studio-mixer"
	sounds={[{ id: "ding", label: "Ding", src: "/sounds/ding.mp3", hotkey: "1" }]}
	tracks={[{ id: "intro", title: "Intro", src: "/music/intro.mp3" }]}
/>

Props

PropTypeDefault
orientation

Rows or console strips. Bindable.

"horizontal" | "vertical""horizontal"
onOrientationChange

When the layout tabs change it.

(orientation) => void—
sources

Which strips to show.

("microphone" | "system" | "music" | "sounds")[]all four
persistKey

Save mixer settings to localStorage.

string—
onOutputChange

The mixed output.

(stream: MediaStream | null) => void—
tracks

Tracks for the music channel.

{ id, title, artist?, src }[][]
sounds

Sounds for the sounds channel. src is a URL or an AudioBuffer.

{ id, label, src, hotkey?, accent? }[][]
class string—

Files

src/lib/components/blocks/system-audio-mixer/system-audio-mixer.svelte
<script lang="ts" module>
	import type { ChannelStripStatusTone } from "#lib/components/ui/channel-strip/index.js";
	import type { UseSystemAudioResult } from "#lib/hooks/use-system-audio.svelte.js";
	import type { Orientation } from "#lib/audio/types.js";

	export type MixerSourceId = "microphone" | "system" | "music" | "sounds";

	export interface MixerTrack {
		id: string;
		title: string;
		artist?: string;
		src: string;
	}

	export interface MixerSound {
		id: string;
		label: string;
		src: string | AudioBuffer;
		hotkey?: string;
		accent?: string;
	}

	export interface SystemAudioMixerProps {
		/** Rows or console strips. Default `horizontal`. */
		orientation?: Orientation;
		onOrientationChange?: (orientation: Orientation) => void;
		/** Which sources to show. Default all four. */
		sources?: MixerSourceId[];
		/** Save mixer settings to localStorage under this key. */
		persistKey?: string;
		/** The mixed output, for recording or streaming. */
		onOutputChange?: (stream: MediaStream | null) => void;
		/** Tracks for the music channel. */
		tracks?: MixerTrack[];
		/** Sounds for the sounds channel. */
		sounds?: MixerSound[];
		class?: string;
	}

	const ALL_SOURCES: MixerSourceId[] = ["microphone", "system", "music", "sounds"];

	const NO_TRACKS: MixerTrack[] = [];
	const NO_SOUNDS: MixerSound[] = [];

	const CHANNELS = [
		{ id: "microphone" },
		{ gainDb: -6, id: "system" },
		{ gainDb: -12, id: "music", monitor: true },
		{ gainDb: -6, id: "sounds", monitor: true },
	];

	const systemStatus = (
		system: UseSystemAudioResult
	): { label: string; tone: ChannelStripStatusTone } => {
		if (system.status === "active") {
			return { label: "On", tone: "live" };
		}
		if (system.status === "unsupported") {
			return { label: "Unsupported", tone: "warning" };
		}
		return { label: "Off", tone: "default" };
	};
</script>

<script lang="ts">
	import { untrack } from "svelte";
	import ArrowCounterClockwiseIcon from "phosphor-svelte/lib/ArrowCounterClockwiseIcon";
	import DesktopIcon from "phosphor-svelte/lib/DesktopIcon";
	import MusicNotesIcon from "phosphor-svelte/lib/MusicNotesIcon";
	import PauseIcon from "phosphor-svelte/lib/PauseIcon";
	import PlayIcon from "phosphor-svelte/lib/PlayIcon";
	import SkipForwardIcon from "phosphor-svelte/lib/SkipForwardIcon";
	import SquaresFourIcon from "phosphor-svelte/lib/SquaresFourIcon";
	import WaveformIcon from "phosphor-svelte/lib/WaveformIcon";

	import { Button } from "#lib/components/ui/button/index.js";
	import { ChannelStripNotice } from "#lib/components/ui/channel-strip/index.js";
	import {
		Mixer,
		MixerActions,
		MixerChannels,
		MixerEmpty,
		MixerHeader,
		MixerMaster,
		MixerSeparator,
		MixerTitle,
	} from "#lib/components/ui/mixer/index.js";
	import { Popover, PopoverContent, PopoverTrigger } from "#lib/components/ui/popover/index.js";
	import { SoundPadGrid } from "#lib/components/ui/sound-pad/index.js";
	import { Switch } from "#lib/components/ui/switch/index.js";
	import { Tabs, TabsList, TabsTrigger } from "#lib/components/ui/tabs/index.js";
	import { useAudioPlayer } from "#lib/hooks/use-audio-player.svelte.js";
	import { useGainNode } from "#lib/hooks/use-gain-node.svelte.js";
	import { useMicrophone } from "#lib/hooks/use-microphone.svelte.js";
	import { useMixer } from "#lib/hooks/use-mixer.svelte.js";
	import { useSystemAudio } from "#lib/hooks/use-system-audio.svelte.js";
	import { useWebAudioMixer } from "#lib/hooks/use-web-audio-mixer.svelte.js";
	import { cn } from "#lib/utils.js";
	import MixerMasterStrip from "./mixer-master-strip.svelte";
	import MixerMicrophoneChannel from "./mixer-microphone-channel.svelte";
	import MixerPad from "./mixer-pad.svelte";
	import MixerSourceStrip from "./mixer-source-strip.svelte";

	let {
		orientation = $bindable("horizontal"),
		onOrientationChange,
		sources = ALL_SOURCES,
		persistKey,
		onOutputChange,
		tracks = NO_TRACKS,
		sounds = NO_SOUNDS,
		class: className,
	}: SystemAudioMixerProps = $props();

	const mixer = useMixer(() => ({ channels: CHANNELS, persistKey }));

	let deviceId = $state<string | null>(null);
	const microphone = useMicrophone(() => ({ deviceId }));
	const system = useSystemAudio();

	let trackIndex = $state(0);
	const track = $derived(tracks[trackIndex]);
	const nextTrack = () => {
		trackIndex = tracks.length > 0 ? (trackIndex + 1) % tracks.length : 0;
	};
	const player = useAudioPlayer(() => ({ onEnded: nextTrack, src: track?.src }));

	// Sound pads play into one bus so the mixer sees them as a single source.
	const soundBus = useGainNode({ destination: null });

	const graph = useWebAudioMixer(mixer, () => ({
		ducking: { targets: ["music"], trigger: "microphone" },
		inputs: {
			microphone: microphone.stream,
			music: player.element,
			sounds: soundBus,
			system: system.stream,
		},
	}));

	// Fires when the stream changes, not when the callback does.
	$effect(() => {
		const output = graph.output;
		untrack(() => onOutputChange?.(output));
	});

	const show = (id: MixerSourceId) => sources.includes(id);
	const meterFor = (id: MixerSourceId) => graph.meters[id] ?? graph.master.meter;

	const setOrientation = (next: string) => {
		orientation = next as Orientation;
		onOrientationChange?.(orientation);
	};

	const systemActive = $derived(system.status === "active");
</script>

{#snippet systemIcon()}
	<DesktopIcon />
{/snippet}

{#snippet systemActions()}
	<Switch
		aria-label="Capture system audio"
		bind:checked={() => systemActive, (checked) => (checked ? system.start() : system.stop())}
		disabled={!system.isSupported}
		size="sm"
	/>
{/snippet}

{#snippet systemNotice()}
	<ChannelStripNotice variant="warning">
		Nothing to hear: tick "Share audio" in the browser's picker.
	</ChannelStripNotice>
{/snippet}

{#snippet musicIcon()}
	<MusicNotesIcon />
{/snippet}

{#snippet musicActions()}
	<Button
		aria-label={player.playing ? "Pause music" : "Play music"}
		disabled={!track}
		onclick={() => player.toggle()}
		size="icon-xs"
		variant="ghost"
	>
		{#if player.playing}
			<PauseIcon />
		{:else}
			<PlayIcon />
		{/if}
	</Button>
	<Button
		aria-label="Next track"
		disabled={tracks.length < 2}
		onclick={nextTrack}
		size="icon-xs"
		variant="ghost"
	>
		<SkipForwardIcon />
	</Button>
{/snippet}

{#snippet soundsIcon()}
	<WaveformIcon />
{/snippet}

{#snippet soundsActions()}
	<Popover>
		<PopoverTrigger>
			{#snippet child({ props })}
				<Button
					{...props}
					aria-label="Sound pads"
					disabled={sounds.length === 0}
					size="icon-xs"
					variant="ghost"
				>
					<SquaresFourIcon />
				</Button>
			{/snippet}
		</PopoverTrigger>
		<PopoverContent class="w-80">
			<SoundPadGrid class="[--pad-min-width:4rem]" columns={4} hotkeys>
				{#each sounds as sound (sound.id)}
					<MixerPad bus={soundBus} {sound} />
				{/each}
			</SoundPadGrid>
		</PopoverContent>
	</Popover>
{/snippet}

<Mixer class={cn("[--channel-strip-header-width:16rem]", className)} {orientation}>
	<MixerHeader>
		<MixerTitle>Audio mixer</MixerTitle>
		<MixerActions>
			<Tabs bind:value={() => orientation, setOrientation}>
				<TabsList>
					<TabsTrigger value="horizontal">Rows</TabsTrigger>
					<TabsTrigger value="vertical">Console</TabsTrigger>
				</TabsList>
			</Tabs>
			<Button aria-label="Reset mixer" onclick={() => mixer.reset()} size="icon-sm" variant="ghost">
				<ArrowCounterClockwiseIcon />
			</Button>
		</MixerActions>
	</MixerHeader>
	<MixerChannels>
		{#if show("microphone")}
			<MixerMicrophoneChannel
				{deviceId}
				meter={meterFor("microphone")}
				{microphone}
				{mixer}
				onDeviceChange={(next) => (deviceId = next)}
			/>
		{/if}
		{#if show("system")}
			<MixerSourceStrip
				accent="var(--chart-4)"
				actions={systemActions}
				description={systemActive ? "Capturing" : "Share a screen or tab with audio"}
				icon={systemIcon}
				id="system"
				meter={meterFor("system")}
				{mixer}
				monitorable={false}
				notice={system.status === "no-audio" ? systemNotice : undefined}
				status={systemStatus(system)}
				title="System audio"
			/>
		{/if}
		{#if show("music")}
			<MixerSourceStrip
				accent="var(--chart-1)"
				actions={musicActions}
				description={track ? track.title : "No tracks"}
				icon={musicIcon}
				id="music"
				meter={meterFor("music")}
				{mixer}
				status={player.playing ? { label: "Playing", tone: "live" } : undefined}
				title="Music"
			/>
		{/if}
		{#if show("sounds")}
			<MixerSourceStrip
				accent="var(--chart-3)"
				actions={soundsActions}
				description={`${sounds.length} pads`}
				icon={soundsIcon}
				id="sounds"
				meter={meterFor("sounds")}
				{mixer}
				title="Sounds"
			/>
		{/if}
	</MixerChannels>
	<MixerEmpty>No audio sources.</MixerEmpty>
	<MixerSeparator />
	<MixerMaster>
		<MixerMasterStrip meter={graph.master.meter} {mixer} />
	</MixerMaster>
</Mixer>
src/lib/components/blocks/system-audio-mixer/mixer-microphone-channel.svelte
<script lang="ts" module>
	import type { ChannelStripStatusTone } from "#lib/components/ui/channel-strip/index.js";
	import type { UseMicrophoneResult } from "#lib/hooks/use-microphone.svelte.js";
	import type { Mixer } from "#lib/hooks/use-mixer.svelte.js";
	import type { FrameSource, MeterFrame } from "#lib/audio/types.js";

	export interface MixerMicrophoneChannelProps {
		microphone: UseMicrophoneResult;
		deviceId: string | null;
		onDeviceChange: (deviceId: string | null) => void;
		mixer: Mixer;
		meter: FrameSource<MeterFrame>;
	}

	const microphoneStatus = (
		microphone: UseMicrophoneResult,
		audible: boolean
	): { label: string; tone: ChannelStripStatusTone } => {
		if (microphone.status === "active") {
			return audible ? { label: "Live", tone: "live" } : { label: "Muted", tone: "muted" };
		}
		if (microphone.status === "denied") {
			return { label: "Blocked", tone: "error" };
		}
		return { label: "Off", tone: "default" };
	};
</script>

<script lang="ts">
	import GearSixIcon from "phosphor-svelte/lib/GearSixIcon";
	import MicrophoneIcon from "phosphor-svelte/lib/MicrophoneIcon";

	import { AudioDeviceSelect } from "#lib/components/ui/audio-device-select/index.js";
	import { Button } from "#lib/components/ui/button/index.js";
	import { ChannelStripNotice } from "#lib/components/ui/channel-strip/index.js";
	import { Popover, PopoverContent, PopoverTrigger } from "#lib/components/ui/popover/index.js";
	import { useAudioDevices } from "#lib/hooks/use-audio-devices.svelte.js";
	import MixerSourceStrip from "./mixer-source-strip.svelte";

	let { microphone, deviceId, onDeviceChange, mixer, meter }: MixerMicrophoneChannelProps =
		$props();

	const devices = useAudioDevices();
	const active = $derived(microphone.status === "active");
	const deviceLabel = $derived(
		devices.devices.find((device) => device.id === deviceId)?.label ?? "Default microphone"
	);
</script>

{#snippet icon()}
	<MicrophoneIcon />
{/snippet}

{#snippet actions()}
	<Popover>
		<PopoverTrigger>
			{#snippet child({ props })}
				<Button {...props} aria-label="Microphone settings" size="icon-xs" variant="ghost">
					<GearSixIcon />
				</Button>
			{/snippet}
		</PopoverTrigger>
		<PopoverContent class="w-72">
			<AudioDeviceSelect
				bind:value={() => deviceId, onDeviceChange}
				devices={devices.devices}
				loading={devices.isLoading}
				onRequestPermission={() => devices.requestPermission()}
				permission={devices.permission === "unsupported" ? "denied" : devices.permission}
			/>
		</PopoverContent>
	</Popover>
	<Button
		onclick={() => (active ? microphone.stop() : microphone.start())}
		size="xs"
		variant={active ? "secondary" : "outline"}
	>
		{active ? "Stop" : "Start"}
	</Button>
{/snippet}

{#snippet deniedNotice()}
	<ChannelStripNotice variant="destructive">
		Microphone access is blocked. Allow it in your browser's site settings.
	</ChannelStripNotice>
{/snippet}

<MixerSourceStrip
	accent="var(--chart-2)"
	{actions}
	description={active ? deviceLabel : "Not listening"}
	{icon}
	id="microphone"
	{meter}
	{mixer}
	notice={microphone.status === "denied" ? deniedNotice : undefined}
	status={microphoneStatus(microphone, mixer.isAudible("microphone"))}
	title="Microphone"
/>
src/lib/components/blocks/system-audio-mixer/mixer-source-strip.svelte
<script lang="ts" module>
	import type { Snippet } from "svelte";

	import type { ChannelStripStatusTone } from "#lib/components/ui/channel-strip/index.js";
	import type { Mixer } from "#lib/hooks/use-mixer.svelte.js";
	import type { FrameSource, MeterFrame } from "#lib/audio/types.js";

	export interface MixerSourceStripProps {
		id: string;
		title: string;
		description?: string | Snippet;
		icon: Snippet;
		accent?: string;
		mixer: Mixer;
		meter: FrameSource<MeterFrame>;
		status?: { label: string; tone: ChannelStripStatusTone };
		/** Buttons in the header. */
		actions?: Snippet;
		/** A ChannelStripNotice, shown under the strip. */
		notice?: Snippet;
		/** Show the monitor toggle. Default true. */
		monitorable?: boolean;
		disabled?: boolean;
	}
</script>

<script lang="ts">
	import HeadphonesIcon from "phosphor-svelte/lib/HeadphonesIcon";

	import {
		ChannelStrip,
		ChannelStripActions,
		ChannelStripControls,
		ChannelStripDescription,
		ChannelStripFader,
		ChannelStripHeader,
		ChannelStripIcon,
		ChannelStripMeter,
		ChannelStripStatus,
		ChannelStripText,
		ChannelStripTitle,
		ChannelStripValue,
	} from "#lib/components/ui/channel-strip/index.js";
	import {
		MonitorToggle,
		MuteToggle,
		SoloToggle,
	} from "#lib/components/ui/channel-toggle/index.js";
	import { Fader, FaderRange, FaderThumb, FaderTrack } from "#lib/components/ui/fader/index.js";
	import { LevelMeter } from "#lib/components/ui/level-meter/index.js";
	import { formatDb } from "#lib/audio/decibels.js";

	let {
		id,
		title,
		description,
		icon,
		accent,
		mixer,
		meter,
		status,
		actions,
		notice,
		monitorable = true,
		disabled = false,
	}: MixerSourceStripProps = $props();

	const channel = $derived(mixer.channel(id));
</script>

{#if channel}
	<ChannelStrip
		{accent}
		dimmed={mixer.isDimmed(id)}
		{disabled}
		muted={channel.muted}
		solo={channel.solo}
	>
		<ChannelStripHeader>
			<ChannelStripIcon>{@render icon()}</ChannelStripIcon>
			<ChannelStripText>
				<ChannelStripTitle>{title}</ChannelStripTitle>
				{#if description}
					<ChannelStripDescription>
						{#if typeof description === "string"}
							{description}
						{:else}
							{@render description()}
						{/if}
					</ChannelStripDescription>
				{/if}
			</ChannelStripText>
			{#if status}
				<ChannelStripStatus tone={status.tone}>
					{status.label}
				</ChannelStripStatus>
			{/if}
			{#if actions}
				<ChannelStripActions>{@render actions()}</ChannelStripActions>
			{/if}
		</ChannelStripHeader>
		<ChannelStripMeter>
			<LevelMeter
				aria-label={`${title} level`}
				channelCount={2}
				class="h-full"
				size="sm"
				source={meter}
			/>
		</ChannelStripMeter>
		<ChannelStripFader>
			<Fader
				aria-label={`${title} volume`}
				bind:value={() => channel.gainDb, (gainDb) => mixer.setGain(id, gainDb)}
				max={12}
				min={-60}
				silenceAtMin
				size="sm"
				taper="audio"
			>
				<FaderTrack>
					<FaderRange />
					<FaderThumb />
				</FaderTrack>
			</Fader>
		</ChannelStripFader>
		<ChannelStripValue>
			{formatDb(channel.gainDb, { floorDb: -60 })}
		</ChannelStripValue>
		<ChannelStripControls>
			<MuteToggle
				aria-label={`Mute ${title}`}
				bind:pressed={() => channel.muted, (muted) => mixer.setMuted(id, muted)}
				size="sm"
			>
				M
			</MuteToggle>
			<SoloToggle
				aria-label={`Solo ${title}`}
				bind:pressed={() => channel.solo, (solo) => mixer.setSolo(id, solo)}
				size="sm"
			>
				S
			</SoloToggle>
			{#if monitorable}
				<MonitorToggle
					aria-label={`Monitor ${title}`}
					bind:pressed={() => channel.monitor, (monitor) => mixer.setMonitor(id, monitor)}
					size="sm"
				>
					<HeadphonesIcon />
				</MonitorToggle>
			{/if}
		</ChannelStripControls>
		{@render notice?.()}
	</ChannelStrip>
{/if}
src/lib/components/blocks/system-audio-mixer/mixer-master-strip.svelte
<script lang="ts" module>
	import type { Mixer } from "#lib/hooks/use-mixer.svelte.js";
	import type { FrameSource, MeterFrame } from "#lib/audio/types.js";

	export interface MixerMasterStripProps {
		mixer: Mixer;
		meter: FrameSource<MeterFrame>;
	}

	const CHANNELS = [0, 1];
</script>

<script lang="ts">
	import SpeakerHighIcon from "phosphor-svelte/lib/SpeakerHighIcon";

	import {
		ChannelStrip,
		ChannelStripControls,
		ChannelStripDescription,
		ChannelStripFader,
		ChannelStripHeader,
		ChannelStripIcon,
		ChannelStripMeter,
		ChannelStripText,
		ChannelStripTitle,
		ChannelStripValue,
	} from "#lib/components/ui/channel-strip/index.js";
	import { MuteToggle } from "#lib/components/ui/channel-toggle/index.js";
	import { Fader, FaderRange, FaderThumb, FaderTrack } from "#lib/components/ui/fader/index.js";
	import {
		LevelMeter,
		LevelMeterBar,
		LevelMeterChannel,
		LevelMeterChannels,
		LevelMeterClip,
		LevelMeterHold,
		LevelMeterTrack,
	} from "#lib/components/ui/level-meter/index.js";
	import { formatDb } from "#lib/audio/decibels.js";

	let { mixer, meter }: MixerMasterStripProps = $props();
</script>

<ChannelStrip muted={mixer.master.muted} variant="master">
	<ChannelStripHeader>
		<ChannelStripIcon>
			<SpeakerHighIcon />
		</ChannelStripIcon>
		<ChannelStripText>
			<ChannelStripTitle>Master</ChannelStripTitle>
			<ChannelStripDescription>Mix output</ChannelStripDescription>
		</ChannelStripText>
	</ChannelStripHeader>
	<ChannelStripMeter>
		<LevelMeter aria-label="Master level" class="h-full" source={meter}>
			<LevelMeterChannels>
				{#each CHANNELS as index (index)}
					<LevelMeterChannel {index}>
						<LevelMeterTrack>
							<LevelMeterBar />
							<LevelMeterHold />
						</LevelMeterTrack>
					</LevelMeterChannel>
				{/each}
			</LevelMeterChannels>
			<LevelMeterClip showCount />
		</LevelMeter>
	</ChannelStripMeter>
	<ChannelStripFader>
		<Fader
			aria-label="Master volume"
			bind:value={() => mixer.master.gainDb, (value) => mixer.setMasterGain(value)}
			max={6}
			min={-60}
			silenceAtMin
			size="sm"
			taper="audio"
		>
			<FaderTrack>
				<FaderRange />
				<FaderThumb />
			</FaderTrack>
		</Fader>
	</ChannelStripFader>
	<ChannelStripValue>
		{formatDb(mixer.master.gainDb, { floorDb: -60 })}
	</ChannelStripValue>
	<ChannelStripControls>
		<MuteToggle
			aria-label="Mute master"
			bind:pressed={() => mixer.master.muted, (value) => mixer.setMasterMuted(value)}
			size="sm"
		>
			M
		</MuteToggle>
	</ChannelStripControls>
</ChannelStrip>
src/lib/components/blocks/system-audio-mixer/mixer-pad.svelte
<script lang="ts">
	import {
		SoundPad,
		SoundPadLabel,
		SoundPadProgress,
		SoundPadShortcut,
	} from "#lib/components/ui/sound-pad/index.js";
	import { useSound } from "#lib/hooks/use-sound.svelte.js";
	import type { MixerSound } from "./system-audio-mixer.svelte";

	let { sound, bus }: { sound: MixerSound; bus: AudioNode | null } = $props();

	const player = useSound(
		() => sound.src,
		() => ({ destination: bus })
	);
</script>

<SoundPad
	accent={sound.accent}
	hotkey={sound.hotkey}
	loading={!player.isLoaded}
	onTrigger={() => player.play()}
	playing={player.isPlaying}
	size="sm"
>
	<SoundPadLabel>{sound.label}</SoundPadLabel>
	<SoundPadShortcut />
	<SoundPadProgress source={player.progress} />
</SoundPad>
Last updated on October 7, 2026