# Blocks (/docs/blocks)
Blocks wire audiocn components to the Web Audio hooks. Each one installs everything it needs in one command, and after that the code is yours: it has few props and is meant to be edited.
| Block | What it is |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------ |
| [System Audio Mixer](/docs/blocks/system-audio-mixer) | Microphone, system audio, music, sound pads and a master, running on Web Audio |
| [Microphone Setup](/docs/blocks/mic-setup) | Device select, live preview, meter, gain, mute and a level check |
| [System Audio Settings](/docs/blocks/system-audio-settings) | On/off capture, level, meter and what the browser will capture |
| [Quick Audio Popover](/docs/blocks/quick-audio-popover) | Microphone and system audio controls behind one toolbar button |
| [Soundboard](/docs/blocks/soundboard) | Sound pads with hotkeys, modes, volume and drag-and-drop |
| [Music Player](/docs/blocks/music-player) | Playlist, waveform seek bar, shuffle, repeat and ducking |
> **Demo audio**
>
> The previews play music and sound effects synthesised in your browser, so no
> audio files ship with the site. In your app, pass your own tracks and sounds.
# Microphone Setup (/docs/blocks/mic-setup)
```tsx title="components/examples/mic-setup-demo.tsx"
import { MicSetup } from "@/components/blocks/mic-setup/mic-setup";
const MicSetupDemo = () => ;
export default MicSetupDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/mic-setup
```
## What it does
* Lists microphones and asks for access when needed.
* Shows a scrolling waveform and a meter with peak hold, a scale and a clip light.
* Applies gain (−24 to +24 dB) and mute to what the meter shows.
* "Check level" listens for three seconds and says whether the level is good, too quiet, too loud or silent.
The microphone opens when the user clicks "Turn on microphone", or on mount with `autoStart`.
## Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `deviceId / onDeviceChange` | `string \| null` | — | Optional control of the selected device. |
| `gainDb / onGainChange` | `number` | `0` | — |
| `muted / onMutedChange` | `boolean` | `false` | — |
| `autoStart` | `boolean` | `false` | Open the microphone on mount. |
## Files
```tsx title="components/blocks/mic-setup/mic-setup.tsx"
import { Show, createSignal, createUniqueId } from "solid-js";
import {
CheckCircleIcon,
MicrophoneIcon,
WarningCircleIcon,
} from "@/components/icons/phosphor";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AudioDeviceSelect,
AudioDeviceSelectContent,
AudioDeviceSelectPreview,
AudioDeviceSelectTrigger,
AudioDeviceSelectValue,
} from "@/components/ui/audio-device-select";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DbReadout } from "@/components/ui/db-readout";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import {
LevelMeter,
LevelMeterBar,
LevelMeterChannel,
LevelMeterChannels,
LevelMeterClip,
LevelMeterHold,
LevelMeterScale,
LevelMeterTrack,
} from "@/components/ui/level-meter";
import { LiveWaveform } from "@/components/ui/live-waveform";
import {
ParameterSlider,
ParameterSliderControl,
ParameterSliderHeader,
ParameterSliderInput,
ParameterSliderLabel,
ParameterSliderReset,
} from "@/components/ui/parameter-slider";
import { Switch } from "@/components/ui/switch";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import { useAudioDevices } from "@/hooks/use-audio-devices";
import { useGainNode } from "@/hooks/use-gain-node";
import { useMicrophone } from "@/hooks/use-microphone";
import { dbToGain } from "@/lib/audio/decibels";
import type { FrameSource, MeterFrame } from "@/lib/audio/types";
import { createCompatEffect } from "@/lib/solid/effect";
import { cn } from "@/lib/utils";
const CHECK_DURATION_MS = 3000;
type CheckResult = "good" | "quiet" | "loud" | "silent";
const RESULTS = {
good: {
description: "Your level sits in the right range.",
ok: true,
title: "Sounds good",
},
loud: {
description: "Your voice clipped. Lower the gain.",
ok: false,
title: "Too loud",
},
quiet: {
description: "Raise the gain or move closer.",
ok: false,
title: "Too quiet",
},
silent: {
description: "Check the device and that it is not muted.",
ok: false,
title: "No signal",
},
};
const judge = (peakDb: number): CheckResult => {
if (peakDb < -55) return "silent";
if (peakDb >= -1) return "loud";
if (peakDb < -30) return "quiet";
return "good";
};
const useLevelCheck = (meter: FrameSource) => {
const [checking, setChecking] = createSignal(false);
const [result, setResult] = createSignal(null);
createCompatEffect(checking, (active) => {
if (!active) return;
let peak = Number.NEGATIVE_INFINITY;
const unsubscribe = meter.subscribe((frame) => {
for (const level of frame.channels) peak = Math.max(peak, level.peakDb);
});
const timer = setTimeout(() => {
setChecking(false);
setResult(judge(peak));
}, CHECK_DURATION_MS);
return () => {
unsubscribe();
clearTimeout(timer);
};
});
return {
checking,
result,
start: () => {
setResult(null);
setChecking(true);
},
};
};
export interface MicSetupProps {
deviceId?: string | null;
onDeviceChange?: (deviceId: string | null) => void;
gainDb?: number;
onGainChange?: (gainDb: number) => void;
muted?: boolean;
onMutedChange?: (muted: boolean) => void;
autoStart?: boolean;
class?: string;
className?: string;
}
export const MicSetup = (props: MicSetupProps) => {
const muteId = createUniqueId();
const [started, setStarted] = createSignal(props.autoStart ?? false);
const [deviceState, setDeviceState] = createSignal(null);
const [gainState, setGainState] = createSignal(0);
const [mutedState, setMutedState] = createSignal(false);
const deviceId = () =>
props.deviceId === undefined ? deviceState() : props.deviceId;
const gainDb = () => props.gainDb ?? gainState();
const muted = () => props.muted ?? mutedState();
const devices = useAudioDevices();
const microphone = useMicrophone({
get deviceId() {
return deviceId();
},
get enabled() {
return started();
},
});
const gain = useGainNode(() => ({
destination: null,
gain: muted() ? 0 : dbToGain(gainDb()),
input: microphone.stream,
}));
const analyser = useAudioAnalyser(() => (microphone.stream ? gain : null), {
historySize: 120,
});
const check = useLevelCheck(analyser.meter);
const active = () => microphone.status === "active";
const verdict = () => {
const result = check.result();
return result ? RESULTS[result] : null;
};
const setDevice = (next: string | null) => {
if (props.deviceId === undefined) setDeviceState(next);
props.onDeviceChange?.(next);
};
const setGain = (next: number) => {
if (props.gainDb === undefined) setGainState(next);
props.onGainChange?.(next);
};
const setMuted = (next: boolean) => {
if (props.muted === undefined) setMutedState(next);
props.onMutedChange?.(next);
};
return (
Microphone
Pick a microphone and check your level.
Device
void devices.requestPermission()}
onValueChange={setDevice}
permission={
devices.permission === "unsupported"
? "denied"
: devices.permission
}
value={deviceId()}
>
setStarted(true)}
size="xs"
variant="outline"
>
Turn on microphone
Level
Gain
Mute microphone
{check.checking() ? "Listening… say a few words" : "Check level"}
{(result) => (
}>
{result().title}
{result().description}
)}
Microphone blocked
Allow microphone access in your browser's site settings.
);
};
```
# Music Player (/docs/blocks/music-player)
```tsx title="components/examples/music-player-demo.tsx"
import { Show } from "solid-js";
import { MusicPlayer } from "@/components/blocks/music-player/music-player";
import { useDemoTracks } from "@/lib/docs/use-demo-audio";
const MusicPlayerDemo = () => {
const tracks = useDemoTracks();
return (
Preparing tracks…
}
>
);
};
export default MusicPlayerDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/music-player
```
## What it does
* A player with a [waveform](/docs/components/waveform) seek bar that shows what has played, hover time, and elapsed and remaining time.
* A [track list](/docs/components/track-list): select a track to play it; the list advances on its own.
* Shuffle, repeat all and repeat one.
* With a `duckingSource`, the music drops while the source is active, with an on/off switch and an amount. It comes back on its own when the source goes quiet or stops.
## Ducking under the microphone
```tsx title="components/examples/music-player-ducking.tsx"
import { Show } from "solid-js";
import { MusicPlayer } from "@/components/blocks/music-player/music-player";
import { Button } from "@/components/ui/button";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import { useMicrophone } from "@/hooks/use-microphone";
import { useDemoTracks } from "@/lib/docs/use-demo-audio";
const MusicPlayerDucking = () => {
const tracks = useDemoTracks();
const microphone = useMicrophone();
const analyser = useAudioAnalyser(() => microphone.stream);
const listening = () => microphone.status === "active";
return (
listening() ? microphone.stop() : void microphone.start()
}
size="sm"
variant="outline"
>
{listening() ? "Stop microphone" : "Use my microphone to duck"}
);
};
export default MusicPlayerDucking;
```
## Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tracks / defaultTracks` | `{ id, title, artist?, src, artwork?, duration? }[]` | `[]` | — |
| `onTrackChange` | `(track) => void` | — | — |
| `duckingSource` | `FrameSource \| null` | — | The signal that ducks the music, usually the microphone. |
| `output` | `AudioNode \| null` | `speakers` | Route the music into a mixer. |
## Files
```tsx title="components/blocks/music-player/music-player.tsx"
import { For, Show, createSignal, createUniqueId } from "solid-js";
import {
MusicNotesIcon,
PauseFillIcon,
PlayFillIcon,
RepeatIcon,
RepeatOnceIcon,
ShuffleIcon,
SkipBackIcon,
SkipForwardIcon,
SpeakerHighIcon,
SpeakerXIcon,
} from "@/components/icons/phosphor";
import {
AudioPlayer,
AudioPlayerArtwork,
AudioPlayerControls,
AudioPlayerDescription,
AudioPlayerNext,
AudioPlayerPlay,
AudioPlayerPrevious,
AudioPlayerTime,
AudioPlayerTitle,
AudioPlayerVolume,
} from "@/components/ui/audio-player";
import { Card, CardContent } from "@/components/ui/card";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Label } from "@/components/ui/label";
import {
ParameterSlider,
ParameterSliderControl,
ParameterSliderHeader,
ParameterSliderLabel,
ParameterSliderValue,
} from "@/components/ui/parameter-slider";
import { Switch } from "@/components/ui/switch";
import { Toggle } from "@/components/ui/toggle";
import {
TrackList,
TrackListItem,
TrackListItemContent,
TrackListItemDescription,
TrackListItemDuration,
TrackListItemIndex,
TrackListItemTitle,
} from "@/components/ui/track-list";
import {
VolumeControlMute,
VolumeControlSlider,
} from "@/components/ui/volume-control";
import {
Waveform,
WaveformCanvas,
WaveformCursor,
WaveformHover,
} from "@/components/ui/waveform";
import {
disconnectFrom,
getMediaElementSource,
} from "@/hooks/use-audio-analyser";
import { useAudioContext } from "@/hooks/use-audio-context";
import { useAudioPlayer } from "@/hooks/use-audio-player";
import { useFrameSource } from "@/hooks/use-frame-source";
import { useWaveformData } from "@/hooks/use-waveform-data";
import { dbToGain } from "@/lib/audio/decibels";
import { formatTime } from "@/lib/audio/time";
import type { FrameSource, MeterFrame } from "@/lib/audio/types";
import { createCompatEffect } from "@/lib/solid/effect";
export interface MusicTrack {
id: string;
title: string;
artist?: string;
src: string;
artwork?: string;
duration?: number;
}
export interface MusicPlayerProps {
tracks?: MusicTrack[];
defaultTracks?: MusicTrack[];
onTrackChange?: (track: MusicTrack) => void;
duckingSource?: FrameSource | null;
output?: AudioNode | null;
class?: string;
className?: string;
}
type Repeat = "off" | "all" | "one";
const NO_TRACKS: MusicTrack[] = [];
const DUCK_THRESHOLD_DB = -35;
const DUCK_ATTACK = 0.02;
const DUCK_RELEASE = 0.15;
const DUCK_HOLD = 0.2;
const nextRepeat = { all: "one", off: "all", one: "off" } satisfies Record<
Repeat,
Repeat
>;
export const MusicPlayer = (props: MusicPlayerProps) => {
const tracks = () => props.tracks ?? props.defaultTracks ?? NO_TRACKS;
const duckingId = createUniqueId();
const [index, setIndex] = createSignal(0);
const position = () => Math.min(index(), Math.max(0, tracks().length - 1));
const [shuffle, setShuffle] = createSignal(false);
const [repeat, setRepeat] = createSignal("all");
const [ducking, setDucking] = createSignal(true);
const [autoAdvance, setAutoAdvance] = createSignal(false);
const [duckAmountDb, setDuckAmountDb] = createSignal(-12);
const track = () => tracks()[position()];
const go = (direction: number) => {
const count = tracks().length;
if (!count) return;
let next = (position() + direction + count) % count;
if (shuffle() && count > 1)
next = (position() + 1 + Math.floor(Math.random() * (count - 1))) % count;
setIndex(next);
setAutoAdvance(true);
const nextTrack = tracks()[next];
if (nextTrack) props.onTrackChange?.(nextTrack);
};
const player = useAudioPlayer(() => ({
autoPlay: autoAdvance(),
loop: repeat() === "one",
src: track()?.src,
onEnded: () => {
if (repeat() === "all" || position() < tracks().length - 1) go(1);
},
}));
const waveform = useWaveformData(() => track()?.src ?? null, {
samples: 400,
});
const { context } = useAudioContext();
const duckGain = context?.createGain() ?? null;
const routed = () =>
props.output !== undefined || props.duckingSource !== undefined;
createCompatEffect(
() => ({ routed: routed(), output: props.output }),
(settings) => {
if (!(context && duckGain && player.element && settings.routed)) return;
const source = getMediaElementSource(context, player.element);
disconnectFrom(source, context.destination);
source.connect(duckGain);
const target =
settings.output === undefined ? context.destination : settings.output;
if (target) duckGain.connect(target);
return () => {
disconnectFrom(source, duckGain);
if (target) disconnectFrom(duckGain, target);
source.connect(context.destination);
};
}
);
useFrameSource(
() => props.duckingSource,
(frame) => {
if (!(duckGain && context && ducking())) return;
let loudest = Number.NEGATIVE_INFINITY;
for (const level of frame.channels)
loudest = Math.max(loudest, level.peakDb);
if (loudest < DUCK_THRESHOLD_DB) return;
const now = context.currentTime;
duckGain.gain.cancelScheduledValues(now);
duckGain.gain.setTargetAtTime(dbToGain(duckAmountDb()), now, DUCK_ATTACK);
duckGain.gain.setTargetAtTime(1, now + DUCK_HOLD, DUCK_RELEASE);
},
{
get enabled() {
return routed() && Boolean(duckGain);
},
}
);
return (
No music
Add tracks to start playing.
}
>
go(1)}
onPrevious={() => go(-1)}
player={player}
>
}
>
{(src) => }
{track()?.title}
{track()?.artist}
player.seek(value)}
peaks={waveform.peaks}
time={player.time}
>
{(state) =>
state.playing ? :
}
setRepeat(nextRepeat[repeat()])}
pressed={repeat() !== "off"}
size="sm"
>
}>
{player.muted ? : }
{(item, row) => (
{
const same = row() === position();
setIndex(row());
props.onTrackChange?.(item);
if (same) void player.toggle();
else setAutoAdvance(true);
}}
>
{row() + 1}
{item.title}
{item.artist}
{item.duration ? formatTime(item.duration) : null}
)}
Lower music while you talk
Amount
);
};
```
# Quick Audio Popover (/docs/blocks/quick-audio-popover)
```tsx title="components/examples/quick-audio-popover-demo.tsx"
import { QuickAudioPopover } from "@/components/blocks/quick-audio-popover/quick-audio-popover";
import { Button } from "@/components/ui/button";
const QuickAudioPopoverDemo = () => (
More audio settings
);
export default QuickAudioPopoverDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/quick-audio-popover
```
## What it does
The trigger shows the microphone state and a small live [bar visualizer](/docs/components/bar-visualizer). The popover holds the microphone select, on/off and mute buttons, and a system audio switch. Children go at the bottom, for a link to full settings.
## Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `JSXElement` | — | Extra content at the bottom. |
| `side` | `"top" \| "bottom" \| "left" \| "right"` | `"bottom"` | — |
| `align` | `"start" \| "center" \| "end"` | `"center"` | — |
## Files
```tsx title="components/blocks/quick-audio-popover/quick-audio-popover.tsx"
import { createSignal, createUniqueId } from "solid-js";
import {
MicrophoneIcon,
MicrophoneSlashIcon,
} from "@/components/icons/phosphor";
import { AudioDeviceSelect } from "@/components/ui/audio-device-select";
import { BarVisualizer } from "@/components/ui/bar-visualizer";
import { Button } from "@/components/ui/button";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import { useAudioDevices } from "@/hooks/use-audio-devices";
import { useMicrophone } from "@/hooks/use-microphone";
import { useSystemAudio } from "@/hooks/use-system-audio";
import type { JSXElement } from "@/lib/solid/jsx-types";
export interface QuickAudioPopoverProps {
children?: JSXElement;
side?: "top" | "bottom" | "left" | "right";
align?: "start" | "center" | "end";
}
export const QuickAudioPopover = (props: QuickAudioPopoverProps) => {
const systemAudioId = createUniqueId();
const devices = useAudioDevices();
const [deviceId, setDeviceId] = createSignal(null);
const [muted, setMuted] = createSignal(false);
const microphone = useMicrophone({
get deviceId() {
return deviceId();
},
});
const analyser = useAudioAnalyser(() => microphone.stream, {
get enabled() {
return !muted();
},
});
const system = useSystemAudio();
const live = () => microphone.status === "active" && !muted();
const placement = () =>
props.align && props.align !== "center"
? (`${props.side ?? "bottom"}-${props.align}` as const)
: (props.side ?? "bottom");
return (
{live() ? (
) : (
)}
Audio
Microphone and system audio.
Microphone
void devices.requestPermission()}
onValueChange={setDeviceId}
permission={
devices.permission === "unsupported"
? "denied"
: devices.permission
}
value={deviceId()}
/>
microphone.status === "active"
? microphone.stop()
: void microphone.start()
}
size="sm"
variant="outline"
>
{microphone.status === "active" ? "Turn off" : "Turn on"}
setMuted((value) => !value)}
size="sm"
variant={muted() ? "destructive" : "outline"}
>
{muted() ? "Unmute" : "Mute"}
System audio
checked ? void system.start() : system.stop()
}
/>
{props.children}
);
};
```
# Soundboard (/docs/blocks/soundboard)
```tsx title="components/examples/soundboard-demo.tsx"
import { Show } from "solid-js";
import { Soundboard } from "@/components/blocks/soundboard/soundboard";
import { useDemoSounds } from "@/lib/docs/use-demo-audio";
const SoundboardDemo = () => {
const sounds = useDemoSounds();
return (
Preparing sounds…}
>
);
};
export default SoundboardDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/soundboard
```
## 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
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `sounds / defaultSounds` | `{ id, label, src, hotkey?, mode?, volume?, accent? }[]` | `[]` | — |
| `onSoundsChange` | `(sounds) => void` | — | Called when sounds are added, edited or removed. |
| `output` | `AudioNode \| null` | `speakers` | Route pads into a mixer. |
| `columns` | `number` | `4` | The most columns; fewer on narrow screens. |
## Files
```tsx title="components/blocks/soundboard/soundboard.tsx"
import {
For,
Show,
createSignal,
createMemo,
createUniqueId,
onCleanup,
} from "solid-js";
import {
PlusIcon,
SpeakerHighIcon,
StopIcon,
WaveformIcon,
} from "@/components/icons/phosphor";
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 { useGainNode } from "@/hooks/use-gain-node";
import { useSound } from "@/hooks/use-sound";
import { createCompatEffect } from "@/lib/solid/effect";
import { cn } from "@/lib/utils";
export interface SoundboardSound {
id: string;
label: string;
src: string | AudioBuffer;
hotkey?: string;
mode?: SoundPadMode;
volume?: number;
accent?: string;
}
export interface SoundboardProps {
sounds?: SoundboardSound[];
defaultSounds?: SoundboardSound[];
onSoundsChange?: (sounds: SoundboardSound[]) => void;
output?: AudioNode | null;
columns?: number;
class?: string;
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];
const AUDIO_FILE = /^audio\//u;
const FILE_EXTENSION = /\.[^.]+$/u;
const NO_SOUNDS: SoundboardSound[] = [];
interface PadProps {
sound: SoundboardSound;
bus: AudioNode | null;
onChange: (sound: SoundboardSound) => void;
onRemove: () => void;
register: (id: string, stop: () => void) => () => void;
}
const Pad = (props: PadProps) => {
const mode = () => props.sound.mode ?? "one-shot";
const player = useSound(
() => props.sound.src,
() => ({
destination: props.bus,
interrupt: mode() !== "one-shot",
loop: mode() === "loop",
volume: props.sound.volume ?? 1,
})
);
createCompatEffect(
() => props.sound.id,
(id) => props.register(id, player.stop)
);
return (
player.stop()}
onTrigger={() => player.play()}
playing={player.isPlaying}
>
{props.sound.label}
Mode
{
const selected = MODES.find((option) => option.value === value);
if (selected)
props.onChange({ ...props.sound, mode: selected.value });
}}
>
{(option) => (
{option.label}
)}
Volume
props.onChange({ ...props.sound, volume: Number(value) })
}
>
{(volume) => (
{Math.round(volume * 100)}%
)}
props.onRemove()}
variant="destructive"
>
Remove
);
};
export const Soundboard = (props: SoundboardProps) => {
const [soundsState, setSoundsState] = createSignal(
props.defaultSounds ?? NO_SOUNDS
);
const sounds = () => props.sounds ?? soundsState();
const soundMap = createMemo(
() => new Map(sounds().map((sound) => [sound.id, sound]))
);
const soundIds = createMemo(() => Array.from(soundMap().keys()));
const [volume, setVolume] = createSignal(1);
const [muted, setMuted] = createSignal(false);
const bus = useGainNode(() => ({
destination: props.output,
gain: muted() ? 0 : volume() ** 2,
}));
const [hotkeys, setHotkeys] = createSignal(true);
const [dragging, setDragging] = createSignal(false);
const [feedback, setFeedback] = createSignal("");
const [removedSound, setRemovedSound] = createSignal<{
sound: SoundboardSound;
index: number;
} | null>(null);
const hotkeysId = createUniqueId();
let fileInput: HTMLInputElement | undefined;
const pads = new Map void>();
const objectUrls = new Set();
onCleanup(() => {
// A controlling parent may still hold these URLs after this board unmounts.
if (props.sounds !== undefined) return;
for (const url of objectUrls) URL.revokeObjectURL(url);
});
const register = (id: string, stop: () => void) => {
pads.set(id, stop);
return () => {
if (pads.get(id) === stop) pads.delete(id);
};
};
const update = (next: SoundboardSound[]) => {
if (props.sounds === undefined) setSoundsState(next);
props.onSoundsChange?.(next);
};
// Frees a dropped file once no pad uses it and its removal can't be undone.
const releaseObjectUrl = ({ src }: SoundboardSound) => {
const url = [...objectUrls].find((owned) => owned === src);
if (url && !sounds().some((item) => item.src === url)) {
objectUrls.delete(url);
URL.revokeObjectURL(url);
}
};
const addFiles = (files: FileList | null) => {
if (!files?.length) return;
const existingIds = new Set(sounds().map((sound) => sound.id));
const added: SoundboardSound[] = [];
for (const file of files) {
const id = `${file.name}-${file.lastModified}`;
if (!AUDIO_FILE.test(file.type) || existingIds.has(id)) continue;
existingIds.add(id);
const src = URL.createObjectURL(file);
objectUrls.add(src);
const index = sounds().length + added.length;
added.push({
id,
label: file.name.replace(FILE_EXTENSION, ""),
src,
hotkey: index < 9 ? String(index + 1) : undefined,
});
}
if (!added.length) {
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) => {
const previous = removedSound();
if (previous) releaseObjectUrl(previous.sound);
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 = () => {
const removed = removedSound();
if (!removed) return;
if (!sounds().some((item) => item.id === removed.sound.id))
update([
...sounds().slice(0, removed.index),
removed.sound,
...sounds().slice(removed.index),
]);
setRemovedSound(null);
setFeedback(`Restored ${removed.sound.label}.`);
};
return (
setDragging(false)}
onDragOver={(event: DragEvent) => {
event.preventDefault();
setDragging(true);
}}
onDrop={(event: DragEvent) => {
event.preventDefault();
setDragging(false);
addFiles(event.dataTransfer?.files ?? null);
}}
>
Soundboard
Hotkeys
{
for (const stop of pads.values()) stop();
setFeedback("Stopped all sounds.");
}}
size="sm"
variant="outline"
>
Stop all
{feedback()}
Undo removal
No sounds yet
Drop audio files here, or add them from your computer.
fileInput?.click()}
size="sm"
variant="outline"
>
Add sounds
}
>
{(id) => (
{(sound) => (
update(
sounds().map((item) => (item.id === id ? next : item))
)
}
onRemove={() => removeSound(sound())}
/>
)}
)}
fileInput?.click()}
variant="outline"
>
Add
{
addFiles(event.currentTarget.files);
event.currentTarget.value = "";
}}
ref={(element) => {
fileInput = element;
}}
type="file"
/>
);
};
```
# System Audio Mixer (/docs/blocks/system-audio-mixer)
```tsx title="components/examples/system-audio-mixer-demo.tsx"
import { SystemAudioMixer } from "@/components/blocks/system-audio-mixer/system-audio-mixer";
import { useDemoSounds, useDemoTracks } from "@/lib/docs/use-demo-audio";
const SystemAudioMixerDemo = () => {
const tracks = useDemoTracks();
const sounds = useDemoSounds();
return (
);
};
export default SystemAudioMixerDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/system-audio-mixer
```
## 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
```tsx title="components/examples/system-audio-mixer-console.tsx"
import { SystemAudioMixer } from "@/components/blocks/system-audio-mixer/system-audio-mixer";
import { useDemoSounds, useDemoTracks } from "@/lib/docs/use-demo-audio";
const SystemAudioMixerConsole = () => {
const tracks = useDemoTracks();
const sounds = useDemoSounds();
return (
);
};
export default SystemAudioMixerConsole;
```
## Usage
```tsx
import { SystemAudioMixer } from "@/components/blocks/system-audio-mixer/system-audio-mixer";
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
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultOrientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Rows or console strips. |
| `sources` | `("microphone" \| "system" \| "music" \| "sounds")[]` | `all four` | Which strips to show. |
| `persistKey` | `string` | — | Save mixer settings to localStorage. |
| `onOutputChange` | `(stream: MediaStream \| null) => void` | — | The mixed output. |
| `tracks` | `{ id, title, artist?, src }[]` | `[]` | Tracks for the music channel. |
| `sounds` | `{ id, label, src, hotkey?, accent? }[]` | `[]` | Sounds for the sounds channel. |
## Files
```tsx title="components/blocks/system-audio-mixer/system-audio-mixer.tsx"
import { For, Show, createSignal, onCleanup } from "solid-js";
import { MixerMasterStrip } from "@/components/blocks/system-audio-mixer/mixer-master-strip";
import { MixerSourceStrip } from "@/components/blocks/system-audio-mixer/mixer-source-strip";
import {
ArrowCounterClockwiseIcon,
DesktopIcon,
GearSixIcon,
MicrophoneIcon,
MusicNotesIcon,
PauseIcon,
PlayIcon,
SkipForwardIcon,
SquaresFourIcon,
WaveformIcon,
} from "@/components/icons/phosphor";
import { AudioDeviceSelect } from "@/components/ui/audio-device-select";
import { Button } from "@/components/ui/button";
import { ChannelStripNotice } from "@/components/ui/channel-strip";
import type { ChannelStripStatusProps } from "@/components/ui/channel-strip";
import {
Mixer,
MixerActions,
MixerChannels,
MixerEmpty,
MixerHeader,
MixerMaster,
MixerSeparator,
MixerTitle,
} from "@/components/ui/mixer";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
SoundPad,
SoundPadGrid,
SoundPadLabel,
SoundPadProgress,
SoundPadShortcut,
} from "@/components/ui/sound-pad";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useAudioDevices } from "@/hooks/use-audio-devices";
import { useAudioPlayer } from "@/hooks/use-audio-player";
import type { AudioPlayerController } from "@/hooks/use-audio-player";
import { useGainNode } from "@/hooks/use-gain-node";
import { useMicrophone } from "@/hooks/use-microphone";
import type { UseMicrophoneResult } from "@/hooks/use-microphone";
import { useMixer } from "@/hooks/use-mixer";
import type { Mixer as MixerController } from "@/hooks/use-mixer";
import { useSound } from "@/hooks/use-sound";
import { useSystemAudio } from "@/hooks/use-system-audio";
import type { UseSystemAudioResult } from "@/hooks/use-system-audio";
import { useWebAudioMixer } from "@/hooks/use-web-audio-mixer";
import type { FrameSource, MeterFrame, Orientation } from "@/lib/audio/types";
import { createCompatEffect } from "@/lib/solid/effect";
import { cn } from "@/lib/utils";
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 {
defaultOrientation?: Orientation;
sources?: MixerSourceId[];
persistKey?: string;
onOutputChange?: (stream: MediaStream | null) => void;
tracks?: MixerTrack[];
sounds?: MixerSound[];
class?: string;
className?: 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 },
];
interface Status {
label: string;
tone: ChannelStripStatusProps["tone"];
}
interface ChannelProps {
mixer: MixerController;
meter: FrameSource;
}
const MixerPad = (props: { sound: MixerSound; bus: AudioNode | null }) => {
const player = useSound(
() => props.sound.src,
() => ({ destination: props.bus })
);
return (
player.play()}
playing={player.isPlaying}
size="sm"
>
{props.sound.label}
);
};
const microphoneStatus = (
microphone: UseMicrophoneResult,
audible: boolean
): Status => {
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" };
};
const systemStatus = (system: UseSystemAudioResult): Status => {
if (system.status === "active") return { label: "On", tone: "live" };
if (system.status === "unsupported")
return { label: "Unsupported", tone: "warning" };
return { label: "Off", tone: "default" };
};
const MicrophoneChannel = (
props: ChannelProps & {
microphone: UseMicrophoneResult;
deviceId: string | null;
onDeviceChange: (id: string | null) => void;
}
) => {
const devices = useAudioDevices();
const active = () => props.microphone.status === "active";
const label = () =>
devices.devices.find((device) => device.id === props.deviceId)?.label ??
"Default microphone";
return (
}
mixer={props.mixer}
meter={props.meter}
status={microphoneStatus(
props.microphone,
props.mixer.isAudible("microphone")
)}
actions={
<>
void devices.requestPermission()}
onValueChange={props.onDeviceChange}
permission={
devices.permission === "unsupported"
? "denied"
: devices.permission
}
value={props.deviceId}
/>
active() ? props.microphone.stop() : void props.microphone.start()
}
size="xs"
variant={active() ? "secondary" : "outline"}
>
{active() ? "Stop" : "Start"}
>
}
notice={
Microphone access is blocked. Allow it in your browser's site
settings.
}
/>
);
};
const SystemChannel = (
props: ChannelProps & { system: UseSystemAudioResult }
) => (
}
meter={props.meter}
mixer={props.mixer}
monitorable={false}
status={systemStatus(props.system)}
actions={
checked ? void props.system.start() : props.system.stop()
}
size="sm"
/>
}
notice={
Nothing to hear: tick "Share audio" in the browser's picker.
}
/>
);
const MusicChannel = (
props: ChannelProps & {
player: AudioPlayerController;
track: MixerTrack | undefined;
trackCount: number;
onNext: () => void;
}
) => (
}
meter={props.meter}
mixer={props.mixer}
status={
props.player.playing ? { label: "Playing", tone: "live" } : undefined
}
actions={
<>
void props.player.toggle()}
size="icon-xs"
variant="ghost"
>
{props.player.playing ? : }
props.onNext()}
size="icon-xs"
variant="ghost"
>
>
}
/>
);
const SoundsChannel = (
props: ChannelProps & { sounds: MixerSound[]; bus: AudioNode | null }
) => (
}
meter={props.meter}
mixer={props.mixer}
actions={
{(sound) => }
}
/>
);
export const SystemAudioMixer = (props: SystemAudioMixerProps) => {
const [orientation, setOrientation] = createSignal(
props.defaultOrientation ?? "horizontal"
);
const mixer = useMixer({
channels: CHANNELS,
get persistKey() {
return props.persistKey;
},
});
const [deviceId, setDeviceId] = createSignal(null);
const microphone = useMicrophone({
get deviceId() {
return deviceId();
},
});
const system = useSystemAudio();
const [trackIndex, setTrackIndex] = createSignal(0);
const tracks = () => props.tracks ?? NO_TRACKS;
const sounds = () => props.sounds ?? NO_SOUNDS;
const track = () =>
tracks()[Math.min(trackIndex(), Math.max(0, tracks().length - 1))];
const nextTrack = () =>
setTrackIndex((index) =>
tracks().length ? (index + 1) % tracks().length : 0
);
const player = useAudioPlayer(() => ({
onEnded: nextTrack,
src: track()?.src,
}));
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,
},
}));
createCompatEffect(
() => graph.output,
(output) => {
props.onOutputChange?.(output);
}
);
onCleanup(() => props.onOutputChange?.(null));
const show = (id: MixerSourceId) =>
(props.sources ?? ALL_SOURCES).includes(id);
const meterFor = (id: MixerSourceId) =>
graph.meters[id] ?? graph.master.meter;
return (
Audio mixer
{
if (value === "horizontal" || value === "vertical")
setOrientation(value);
}}
>
Rows
Console
mixer.reset()}
size="icon-sm"
variant="ghost"
>
No audio sources.
);
};
```
```tsx title="components/blocks/system-audio-mixer/mixer-source-strip.tsx"
import { Show, children } from "solid-js";
import { HeadphonesIcon } from "@/components/icons/phosphor";
import {
ChannelStrip,
ChannelStripActions,
ChannelStripControls,
ChannelStripDescription,
ChannelStripFader,
ChannelStripHeader,
ChannelStripIcon,
ChannelStripMeter,
ChannelStripStatus,
ChannelStripText,
ChannelStripTitle,
ChannelStripValue,
} from "@/components/ui/channel-strip";
import type { ChannelStripStatusProps } from "@/components/ui/channel-strip";
import {
MonitorToggle,
MuteToggle,
SoloToggle,
} from "@/components/ui/channel-toggle";
import {
Fader,
FaderRange,
FaderThumb,
FaderTrack,
} from "@/components/ui/fader";
import { LevelMeter } from "@/components/ui/level-meter";
import type { Mixer } from "@/hooks/use-mixer";
import { formatDb } from "@/lib/audio/decibels";
import type { FrameSource, MeterFrame } from "@/lib/audio/types";
import type { JSXElement } from "@/lib/solid/jsx-types";
export interface MixerSourceStripProps {
id: string;
title: string;
description?: JSXElement;
icon: JSXElement;
accent?: string;
mixer: Mixer;
meter: FrameSource;
status?: { label: string; tone: ChannelStripStatusProps["tone"] };
actions?: JSXElement;
notice?: JSXElement;
monitorable?: boolean;
disabled?: boolean;
}
export const MixerSourceStrip = (props: MixerSourceStripProps) => {
const description = children(() => props.description);
const actions = children(() => props.actions);
return (
{(channel) => (
{props.icon}
{props.title}
{description()}
{(status) => (
{status().label}
)}
{actions()}
props.mixer.setGain(props.id, gainDb)}
silenceAtMin
size="sm"
taper="audio"
value={channel().gainDb}
>
{formatDb(channel().gainDb, { floorDb: -60 })}
props.mixer.setMuted(props.id, muted)}
pressed={channel().muted}
size="sm"
>
M
props.mixer.setSolo(props.id, solo)}
pressed={channel().solo}
size="sm"
>
S
props.mixer.setMonitor(props.id, monitor)
}
pressed={channel().monitor}
size="sm"
>
{props.notice}
)}
);
};
```
```tsx title="components/blocks/system-audio-mixer/mixer-master-strip.tsx"
import { For } from "solid-js";
import { SpeakerHighIcon } from "@/components/icons/phosphor";
import {
ChannelStrip,
ChannelStripControls,
ChannelStripDescription,
ChannelStripFader,
ChannelStripHeader,
ChannelStripIcon,
ChannelStripMeter,
ChannelStripText,
ChannelStripTitle,
ChannelStripValue,
} from "@/components/ui/channel-strip";
import { MuteToggle } from "@/components/ui/channel-toggle";
import {
Fader,
FaderRange,
FaderThumb,
FaderTrack,
} from "@/components/ui/fader";
import {
LevelMeter,
LevelMeterBar,
LevelMeterChannel,
LevelMeterChannels,
LevelMeterClip,
LevelMeterHold,
LevelMeterTrack,
} from "@/components/ui/level-meter";
import type { Mixer } from "@/hooks/use-mixer";
import { formatDb } from "@/lib/audio/decibels";
import type { FrameSource, MeterFrame } from "@/lib/audio/types";
const CHANNELS = [0, 1];
export interface MixerMasterStripProps {
mixer: Mixer;
meter: FrameSource;
}
export const MixerMasterStrip = (props: MixerMasterStripProps) => (
Master
Mix output
{(index) => (
)}
props.mixer.setMasterGain(value)}
silenceAtMin
size="sm"
taper="audio"
value={props.mixer.master.gainDb}
>
{formatDb(props.mixer.master.gainDb, { floorDb: -60 })}
props.mixer.setMasterMuted(value)}
pressed={props.mixer.master.muted}
size="sm"
>
M
);
```
# System Audio Settings (/docs/blocks/system-audio-settings)
```tsx title="components/examples/system-audio-settings-demo.tsx"
import { SystemAudioSettings } from "@/components/blocks/system-audio-settings/system-audio-settings";
const SystemAudioSettingsDemo = () => (
);
export default SystemAudioSettingsDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/system-audio-settings
```
## What it does
* A switch that opens the browser's screen-share picker and keeps only the audio.
* A state label: Off, On, Choose what to share, No audio shared, Ended, Needs permission or Unsupported.
* A level control (−24 to +12 dB, default −6 dB) and a stereo meter while capture is on.
* Notices that explain what gets captured, or why nothing can be.
Use `onStreamChange` to get the captured audio after the level control.
## Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled / onEnabledChange` | `boolean` | — | Optional control of capture. |
| `gainDb / onGainChange` | `number` | `-6` | — |
| `onStreamChange` | `(stream: MediaStream \| null) => void` | — | The captured audio after the level control. |
## Files
```tsx title="components/blocks/system-audio-settings/system-audio-settings.tsx"
import { Show, createSignal, createUniqueId, onCleanup } from "solid-js";
import {
DesktopIcon,
InfoIcon,
WarningIcon,
} from "@/components/icons/phosphor";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { LevelMeter } from "@/components/ui/level-meter";
import {
ParameterSlider,
ParameterSliderControl,
ParameterSliderHeader,
ParameterSliderInput,
ParameterSliderLabel,
ParameterSliderReset,
} from "@/components/ui/parameter-slider";
import { Switch } from "@/components/ui/switch";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import { useAudioContext } from "@/hooks/use-audio-context";
import { useGainNode } from "@/hooks/use-gain-node";
import { useSystemAudio } from "@/hooks/use-system-audio";
import { dbToGain } from "@/lib/audio/decibels";
import { createCompatEffect } from "@/lib/solid/effect";
import { cn } from "@/lib/utils";
const DEFAULT_GAIN_DB = -6;
const STATE_LABELS = {
active: "On",
denied: "Needs permission",
ended: "Ended",
idle: "Off",
"no-audio": "No audio shared",
prompting: "Choose what to share…",
unsupported: "Unsupported",
};
export interface SystemAudioSettingsProps {
enabled?: boolean;
onEnabledChange?: (enabled: boolean) => void;
gainDb?: number;
onGainChange?: (gainDb: number) => void;
onStreamChange?: (stream: MediaStream | null) => void;
class?: string;
className?: string;
}
export const SystemAudioSettings = (props: SystemAudioSettingsProps) => {
const enabledId = createUniqueId();
const system = useSystemAudio();
const { context } = useAudioContext();
const [gainState, setGainState] = createSignal(DEFAULT_GAIN_DB);
const gainDb = () => props.gainDb ?? gainState();
const output = context?.createMediaStreamDestination() ?? null;
const gain = useGainNode(() => ({
destination: output,
gain: dbToGain(gainDb()),
input: system.stream,
}));
const analyser = useAudioAnalyser(() => (system.stream ? gain : null), {
channels: "stereo",
});
const active = () => system.status === "active";
const processed = () =>
context && gain && output && system.stream ? output.stream : null;
createCompatEffect(processed, (stream) => {
props.onStreamChange?.(stream);
});
onCleanup(() => {
for (const track of output?.stream.getTracks() ?? []) track.stop();
props.onStreamChange?.(null);
});
createCompatEffect(
() => props.enabled,
(enabled) => {
if (
enabled === true &&
system.status !== "active" &&
system.status !== "prompting"
)
void system.start();
else if (enabled === false) system.stop();
}
);
const setEnabled = (next: boolean) => {
props.onEnabledChange?.(next);
if (props.enabled !== undefined && props.enabled !== next) return;
if (next) void system.start();
else system.stop();
};
const setGain = (next: number) => {
if (props.gainDb === undefined) setGainState(next);
props.onGainChange?.(next);
};
return (
System audio
Add the sound from your computer to your recording or stream.
Capture system audio
{STATE_LABELS[system.status]}
Your browser asks what to share. Choose a screen or tab and turn
on its audio.
Level
Not available in this browser
This browser cannot capture system audio. Try a Chromium-based
browser.
}
>
What gets captured
Everything the shared screen, window or tab plays. Some browsers
only offer tab audio.
Capture was not started
No screen or tab was shared. Turn capture on again and approve
the browser's sharing request. If access is blocked, allow
screen sharing in your browser or system settings.
No audio was shared
Turn capture on again and tick the option to share audio.
);
};
```
# Audio Device Select (/docs/components/audio-device-select)
```tsx title="components/examples/audio-device-select-demo.tsx"
import { createSignal } from "solid-js";
import {
AudioDeviceSelect,
AudioDeviceSelectContent,
AudioDeviceSelectPreview,
AudioDeviceSelectTrigger,
AudioDeviceSelectValue,
} from "@/components/ui/audio-device-select";
import { LiveWaveform } from "@/components/ui/live-waveform";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import { useAudioDevices } from "@/hooks/use-audio-devices";
import { useMicrophone } from "@/hooks/use-microphone";
const AudioDeviceSelectDemo = () => {
const devices = useAudioDevices();
const [deviceId, setDeviceId] = createSignal(null);
const microphone = useMicrophone({
get deviceId() {
return deviceId();
},
get enabled() {
return deviceId() !== null;
},
});
const analyser = useAudioAnalyser(() => microphone.stream, {
historySize: 120,
});
return (
void devices.requestPermission()}
onValueChange={setDeviceId}
permission={
devices.permission === "unsupported" ? "denied" : devices.permission
}
value={deviceId()}
>
);
};
export default AudioDeviceSelectDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/audio-device-select
```
## Usage
```tsx
import { AudioDeviceSelect } from "@/components/ui/audio-device-select";
```
```tsx
const audio = useAudioDevices();
;
```
Devices come in as a prop, so the select works with [`useAudioDevices`](/docs/hooks/use-audio-devices) or with any device list, such as one from a native backend.
## Anatomy
```tsx
```
`AudioDeviceSelectContent` renders one item per device. Pass a Select.Listbox item renderer to render `AudioDeviceSelectItem` yourself.
## Examples
### States
Selected device, a "None" option, a device that was unplugged, and loading.
```tsx title="components/examples/audio-device-select-states.tsx"
import { AudioDeviceSelect } from "@/components/ui/audio-device-select";
import type { AudioDevice } from "@/components/ui/audio-device-select";
const devices: AudioDevice[] = [
{ id: "default", isDefault: true, label: "MacBook Pro Microphone" },
{ description: "USB", id: "usb", label: "Shure MV7+" },
{
description: "In use by another app",
id: "busy",
label: "Elgato Wave:3",
status: "unavailable",
},
];
const AudioDeviceSelectStates = () => (
);
export default AudioDeviceSelectStates;
```
## Behaviour
* A selected device that disappears is kept and shown as "(disconnected)", so the selection is never lost silently.
* Devices with `status` `unavailable` or `permission-required` are disabled.
* The default device is marked.
## Theming
| Data attribute | On | Meaning |
| ----------------- | ------- | ------------------------------- |
| `data-loading` | trigger | Devices are loading |
| `data-permission` | trigger | `granted`, `prompt` or `denied` |
| `data-missing` | trigger | The selected device is gone |
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `devices` | `AudioDevice[]` | — | { id, label, isDefault?, status?, description? }. |
| `value` | `string \| null` | — | The device id. null is no selection, or None with allowNone. |
| `defaultValue` | `string \| null` | `null` | — |
| `onValueChange` | `(id: string \| null) => void` | — | — |
| `allowNone` | `boolean` | `false` | Adds a None item. |
| `noneLabel` | `string` | `"None"` | — |
| `loading` | `boolean` | `false` | Shows "Finding devices…". |
| `permission` | `"granted" \| "prompt" \| "denied"` | `"granted"` | — |
| `onRequestPermission` | `() => void` | — | Shows an "Allow access" button when permission is needed. |
| `disabled` | `boolean` | `false` | — |
# Audio Player (/docs/components/audio-player)
```tsx title="components/examples/audio-player-demo.tsx"
import {
MusicNotesIcon,
PauseFillIcon,
PlayFillIcon,
SkipBackIcon,
SkipForwardIcon,
} from "@/components/icons/phosphor";
import {
AudioPlayer,
AudioPlayerControls,
AudioPlayerDescription,
AudioPlayerPlay,
AudioPlayerSeek,
AudioPlayerSkipBack,
AudioPlayerSkipForward,
AudioPlayerTime,
AudioPlayerTitle,
AudioPlayerVolume,
} from "@/components/ui/audio-player";
import { useDemoTracks } from "@/lib/docs/use-demo-audio";
const AudioPlayerDemo = () => {
const tracks = useDemoTracks();
const track = () => tracks()[0];
return (
{track()?.title ?? "Loading…"}
{track()?.artist}
{(state) => (state.playing ? : )}
);
};
export default AudioPlayerDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/audio-player
```
## Usage
```tsx
import {
AudioPlayer,
AudioPlayerPlay,
AudioPlayerSeek,
} from "@/components/ui/audio-player";
```
```tsx
```
Every part is optional. A play button and a seek bar is a complete player. Buttons show a text label unless you pass an icon as children, so the component works with any icon set.
## Anatomy
```tsx
```
## Examples
### Compact
```tsx title="components/examples/audio-player-compact.tsx"
import { PauseFillIcon, PlayFillIcon } from "@/components/icons/phosphor";
import {
AudioPlayer,
AudioPlayerPlay,
AudioPlayerRate,
AudioPlayerSeek,
AudioPlayerTime,
} from "@/components/ui/audio-player";
import { useDemoTracks } from "@/lib/docs/use-demo-audio";
const AudioPlayerCompact = () => {
const tracks = useDemoTracks();
return (
{(state) => (state.playing ? : )}
);
};
export default AudioPlayerCompact;
```
### With a waveform and a playlist
The [Music Player](/docs/blocks/music-player) block uses a [Waveform](/docs/components/waveform) as the seek bar and a [Track List](/docs/components/track-list) for the queue.
### Your own player state
Create the player with [`useAudioPlayer`](/docs/hooks/use-audio-player) and pass it in, to reach it from outside, route it into a mixer or analyse it.
```tsx
const player = useAudioPlayer(() => ({ src: src() }));
const analyser = useAudioAnalyser(() => player.element);
… ;
```
`useAudioPlayerContext()` reads the player from inside any part.
## Keyboard
While focus is inside the player:
| Key | Action |
| -------------------- | ------------- |
| Space or K | Play or pause |
| Left / Right | Seek 5 s |
| Shift + Left / Right | Seek 15 s |
| Up / Down | Volume |
| M | Mute |
| Home / End | Start or end |
## Theming
| Data attribute | Meaning |
| --------------------------------------------------------------------------------------- | ------------------------- |
| `data-playing`, `data-paused`, `data-loading`, `data-ended`, `data-error`, `data-muted` | Player state, on the root |
## API reference
### AudioPlayer
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `src` | `string` | — | — |
| `player` | `AudioPlayerController` | — | Use an external player from useAudioPlayer. |
| `autoPlay / loop / muted` | `boolean` | `false` | — |
| `volume` | `number` | `1` | 0..1. |
| `playbackRate` | `number` | `1` | — |
| `preload` | `"none" \| "metadata" \| "auto"` | `"metadata"` | — |
| `crossOrigin` | `"anonymous" \| "use-credentials"` | — | Needed to analyse remote audio. |
| `onPlay / onPause / onEnded` | `() => void` | — | — |
| `onTimeUpdate` | `(time) => void` | — | — |
| `onPrevious / onNext` | `() => void` | — | Enable the previous and next buttons. |
| `onError` | `(error: MediaError \| null) => void` | — | — |
| `shortcuts` | `boolean` | `true` | Keyboard shortcuts while focus is inside. |
### AudioPlayerPlay
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `JSXElement \| ({ playing, loading }) => JSXElement` | — | An icon, or a function of the state. |
### AudioPlayerSkipBack, AudioPlayerSkipForward
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `seconds` | `number` | `10` | — |
### AudioPlayerTime
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `type` | `"current" \| "remaining" \| "duration"` | `"current"` | — |
| `format` | `(seconds, type) => string` | `formatTime` | — |
### AudioPlayerRate
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `rates` | `number[]` | `[0.5, 0.75, 1, 1.25, 1.5, 2]` | Cycles on click. |
# Bar Visualizer (/docs/components/bar-visualizer)
```tsx title="components/examples/bar-visualizer-demo.tsx"
import { BarVisualizer } from "@/components/ui/bar-visualizer";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const BarVisualizerDemo = () => {
const signal = useDemoSignal({ kind: "speech" });
return (
);
};
export default BarVisualizerDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/bar-visualizer
```
## Usage
```tsx
import { BarVisualizer } from "@/components/ui/bar-visualizer";
```
```tsx
```
Bars take their color from the text color, so set it with a class such as `text-primary`. Bars paint on the animation frame without updating Solid state.
## Examples
### Alignment
```tsx title="components/examples/bar-visualizer-align.tsx"
import { For } from "solid-js";
import { BarVisualizer } from "@/components/ui/bar-visualizer";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const alignments = ["center", "end", "start"] as const;
const BarVisualizerAlign = () => {
const signal = useDemoSignal({ kind: "music" });
return (
);
};
export default BarVisualizerAlign;
```
### Idle and loading
`idle` decides what bars do without a signal. `loading` runs a sweep, for connecting or thinking states.
```tsx title="components/examples/bar-visualizer-states.tsx"
import { BarVisualizer } from "@/components/ui/bar-visualizer";
const BarVisualizerStates = () => (
idle pulse
idle wave
loading
);
export default BarVisualizerStates;
```
### Mirrored
```tsx title="components/examples/bar-visualizer-mirrored.tsx"
import { BarVisualizer } from "@/components/ui/bar-visualizer";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const BarVisualizerMirrored = () => {
const signal = useDemoSignal({ kind: "speech", seed: 8 });
return (
);
};
export default BarVisualizerMirrored;
```
### Inside a badge
```tsx title="components/examples/bar-visualizer-mini.tsx"
import { MicrophoneIcon } from "@/components/icons/phosphor";
import { Badge } from "@/components/ui/badge";
import { BarVisualizer } from "@/components/ui/bar-visualizer";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const BarVisualizerMini = () => {
const signal = useDemoSignal({ kind: "speech", seed: 12 });
return (
Live
);
};
export default BarVisualizerMini;
```
## Theming
| Variable or attribute | Meaning |
| --------------------- | ---------------------------------------- |
| `--bar-width` | Maximum bar width, default `0.375rem` |
| `--bar-gap` | Gap between bars, default `0.1875rem` |
| `--bar-radius` | Bar corner radius, default fully rounded |
| `data-active` | The signal is above the floor |
| `data-loading` | The sweep is running |
| `data-index` | On each bar, its position |
## Accessibility
The root has `role="img"` and the label "Audio visualizer". Pass `aria-label` to describe what it shows, or `aria-hidden` when it is decorative next to a labelled meter.
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `FrameSource \| null` | — | Bars follow the frame's frequency bands. |
| `levels` | `ArrayLike` | — | Levels, 0..1, for declarative use. |
| `barCount` | `number` | `24` | Bands are resampled to fit. |
| `align` | `"center" \| "start" \| "end"` | `"center"` | Where bars grow from. |
| `mirrored` | `boolean` | `false` | Symmetric around the middle bar, lows in the centre. |
| `minLevel` | `number` | `0.08` | Resting bar size, 0..1. |
| `idle` | `"static" \| "pulse" \| "wave"` | `"static"` | What bars do without a signal. |
| `loading` | `boolean` | `false` | Run a sweep animation. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | vertical grows bars sideways. |
| `actionsRef` | `RefTarget` | — | { paint(levels) }. |
# Channel Strip (/docs/components/channel-strip)
```tsx title="components/examples/channel-strip-demo.tsx"
import { createSignal } from "solid-js";
import { MicrophoneIcon } from "@/components/icons/phosphor";
import {
ChannelStrip,
ChannelStripControls,
ChannelStripDescription,
ChannelStripFader,
ChannelStripHeader,
ChannelStripIcon,
ChannelStripMeter,
ChannelStripStatus,
ChannelStripText,
ChannelStripTitle,
ChannelStripValue,
} from "@/components/ui/channel-strip";
import { MuteToggle, SoloToggle } from "@/components/ui/channel-toggle";
import { DbReadout } from "@/components/ui/db-readout";
import { Fader } from "@/components/ui/fader";
import { LevelMeter } from "@/components/ui/level-meter";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const ChannelStripDemo = () => {
const [gainDb, setGainDb] = createSignal(0);
const [muted, setMuted] = createSignal(false);
const [solo, setSolo] = createSignal(false);
const signal = useDemoSignal({
kind: "speech",
get gainDb() {
return gainDb();
},
get playing() {
return !muted();
},
});
return (
Microphone
Shure MV7+
{muted() ? "Muted" : "Live"}
M
S
);
};
export default ChannelStripDemo;
```
In these previews, the fader applies gain to the demo signal and mute silences it. The meter shows the level after both controls. Muting or excluding a channel with solo clears its meter immediately; audible channels keep their normal meter ballistics.
## Installation
```bash
npx shadcn@latest add @audiocn-solid/channel-strip
```
`channel-strip` is layout and context only. Install the meters, faders and toggles you put inside it separately, or install a [block](/docs/blocks/system-audio-mixer) that brings everything.
## Usage
```tsx
import {
ChannelStrip,
ChannelStripHeader,
ChannelStripMeter,
ChannelStripTitle,
} from "@/components/ui/channel-strip";
```
```tsx
Microphone
```
Every part is optional. The smallest useful strip is a title, a meter and a mute toggle.
## Anatomy
```tsx
```
## Examples
### Console strips
With `orientation="vertical"`, the meter and fader stand side by side. These strips share `useMixer` state, so solo silences the other channels.
```tsx title="components/examples/channel-strip-console.tsx"
import {
DesktopIcon,
MicrophoneIcon,
MusicNotesIcon,
} from "@/components/icons/phosphor";
import {
ChannelStrip,
ChannelStripControls,
ChannelStripFader,
ChannelStripHeader,
ChannelStripIcon,
ChannelStripMeter,
ChannelStripTitle,
ChannelStripValue,
} from "@/components/ui/channel-strip";
import { MuteToggle, SoloToggle } from "@/components/ui/channel-toggle";
import { Fader } from "@/components/ui/fader";
import { LevelMeter } from "@/components/ui/level-meter";
import { useDemoSignal } from "@/hooks/use-demo-signal";
import type { DemoSignalKind } from "@/hooks/use-demo-signal";
import { useMixer } from "@/hooks/use-mixer";
import type { Mixer } from "@/hooks/use-mixer";
import { formatDb } from "@/lib/audio/decibels";
import type { JSXElement } from "@/lib/solid/jsx-types";
const Strip = (props: {
title: string;
id: string;
mixer: Mixer;
icon: JSXElement;
accent: string;
kind: DemoSignalKind;
seed?: number;
}) => {
const channel = () => props.mixer.channel(props.id)!;
const signal = useDemoSignal({
channels: 2,
kind: props.kind,
seed: props.seed,
get gainDb() {
return channel().gainDb;
},
get playing() {
return props.mixer.isAudible(props.id);
},
});
return (
{props.icon}
{props.title}
props.mixer.setGain(props.id, gainDb)}
size="sm"
taper="audio"
value={channel().gainDb}
/>
{formatDb(channel().gainDb)}
props.mixer.setMuted(props.id, muted)}
pressed={channel().muted}
size="sm"
>
M
props.mixer.setSolo(props.id, solo)}
pressed={channel().solo}
size="sm"
>
S
);
};
const ChannelStripConsole = () => {
const mixer = useMixer({
channels: [{ id: "mic" }, { id: "music" }, { id: "game" }],
});
return (
}
id="mic"
mixer={mixer}
kind="speech"
title="Mic"
/>
}
id="music"
mixer={mixer}
kind="music"
title="Music"
/>
}
id="game"
mixer={mixer}
kind="noise"
seed={3}
title="Game"
/>
);
};
export default ChannelStripConsole;
```
### Notices
```tsx title="components/examples/channel-strip-notices.tsx"
import {
DesktopIcon,
MicrophoneSlashIcon,
WarningIcon,
} from "@/components/icons/phosphor";
import { Button } from "@/components/ui/button";
import {
ChannelStrip,
ChannelStripHeader,
ChannelStripIcon,
ChannelStripMeter,
ChannelStripNotice,
ChannelStripStatus,
ChannelStripText,
ChannelStripTitle,
} from "@/components/ui/channel-strip";
import { LevelMeter } from "@/components/ui/level-meter";
const ChannelStripNotices = () => (
Microphone
Blocked
Microphone access is blocked. Allow it in your browser's site settings.
System audio
Paused
Paused to prevent an echo from your own stream.
Resume
);
export default ChannelStripNotices;
```
## How it passes settings down
A strip provides its `orientation`, `size` and `disabled` state to the audiocn components inside it, so meters and faders line up without extra props. When the strip is `muted` or `dimmed`, meters inside it render dimmed. Explicit props on a child always win. `useChannelStrip()` reads the strip's state in your own parts.
## Layout
Parts place themselves on a CSS grid by area name: `header`, `meter`, `fader`, `value`, `controls` and `notice`. The grid is the strip's `channel-strip-layout` element, so you can rearrange a strip by overriding `grid-template-areas` there, for example with `*:data-[slot=channel-strip-layout]:[grid-template-areas:…]`.
A row measures its own width with a container query. Below 36rem it puts the header on its own line, with the meter and fader full width underneath, so rows stay readable on phones and in narrow panels. From 36rem up the header sits beside the meter in a column `--channel-strip-header-width` wide. Set that variable on a mixer to align every row; the header wraps its status and actions onto a second line before it truncates the title.
`ChannelStripFader` removes the fader's end padding so its track and scale reach the meter's edges in both orientations. At minimum and maximum, the thumb's centre aligns with the track edge. For track-only faders, strips centre the visible meter and fader tracks as a group in both orientations, while keeping the fader's full thumb and pointer target.
Horizontal strips reserve a notice row only when you include `ChannelStripNotice`, so strips without a notice keep equal top and bottom spacing.
`ChannelStripValue` centres its content in vertical strips and aligns it to the right in horizontal strips.
A vertical strip keeps its width inside a mixer. When the strips do not fit, the mixer's channels scroll instead of squeezing them.
## Theming
| Variable or attribute | Meaning |
| -------------------------------------------------------------------------- | -------------------------------------------- |
| `--channel-accent` | Set by `accent`, drawn as a colour tag |
| `--channel-strip-width` | Width of a vertical strip, default `6.5rem` |
| `--channel-strip-header-width` | Header column of a wide row, default `12rem` |
| `data-muted`, `data-solo`, `data-dimmed`, `data-selected`, `data-disabled` | Channel state |
| `data-clipping` | A meter inside the strip is clipping |
| `data-orientation`, `data-variant`, `data-size` | Current settings |
## Accessibility
The strip is a `group` named by `ChannelStripTitle`. A notice is a `status`, or an `alert` for the `destructive` variant. State is never shown by colour alone: pair a muted strip with a pressed mute toggle and a status label.
## API reference
### ChannelStrip
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"horizontal" \| "vertical"` | `from Mixer` | Horizontal is a row; vertical is a console strip. |
| `variant` | `"default" \| "card" \| "ghost" \| "master"` | `"default"` | — |
| `size` | `"sm" \| "default" \| "lg"` | `from Mixer` | Passed to controls inside. |
| `muted` | `boolean` | `false` | Styling and context only. |
| `solo` | `boolean` | `false` | — |
| `dimmed` | `boolean` | `false` | Silenced by another channel's solo. |
| `selected` | `boolean` | `false` | — |
| `disabled` | `boolean` | `false` | Disables every control inside. |
| `accent` | `string` | — | A CSS colour for the colour tag. |
| `render` | `SolidElement \| function` | `` | — |
### ChannelStripStatus
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tone` | `"default" \| "live" \| "muted" \| "warning" \| "error"` | `"default"` | Rendered with the shadcn Badge. |
### ChannelStripNotice
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "warning" \| "destructive"` | `"default"` | Children can include an action button. |
# Channel Toggle (/docs/components/channel-toggle)
```tsx title="components/examples/channel-toggle-demo.tsx"
import { createSignal } from "solid-js";
import { HeadphonesIcon } from "@/components/icons/phosphor";
import {
MonitorToggle,
MuteToggle,
SoloToggle,
} from "@/components/ui/channel-toggle";
const ChannelToggleDemo = () => {
const [muted, setMuted] = createSignal(true);
const [solo, setSolo] = createSignal(false);
const [monitor, setMonitor] = createSignal(false);
return (
M
S
);
};
export default ChannelToggleDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/channel-toggle
```
## Usage
```tsx
import {
MonitorToggle,
MuteToggle,
SoloToggle,
} from "@/components/ui/channel-toggle";
```
```tsx
M
S
```
The presets set the tone and a default `aria-label`. Children are a letter, a word or an icon; nothing is rendered by default. Use `ChannelToggle` with a `tone` for anything else.
## Examples
### Variants
```tsx title="components/examples/channel-toggle-variants.tsx"
import { For } from "solid-js";
import { MicrophoneSlashIcon } from "@/components/icons/phosphor";
import { ChannelToggle, MuteToggle } from "@/components/ui/channel-toggle";
const variants = ["default", "outline", "ghost"] as const;
const ChannelToggleVariants = () => (
{(variant) => (
Muted
Mute
R
{variant}
)}
);
export default ChannelToggleVariants;
```
## Theming
| Token or attribute | Meaning |
| ------------------------------------------------------- | -------------------------------------- |
| `--channel-mute`, `--channel-solo`, `--channel-monitor` | Pressed colours |
| `data-pressed` | Pressed |
| `data-tone` | `mute`, `solo`, `monitor` or `neutral` |
## Accessibility
Toggles are buttons with `aria-pressed`. The presets are labelled "Mute", "Solo" and "Monitor"; override `aria-label` to name the channel, such as "Mute microphone".
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `pressed` | `boolean` | — | — |
| `defaultPressed` | `boolean` | `false` | — |
| `onPressedChange` | `(pressed, details) => void` | — | — |
| `tone` | `"mute" \| "solo" \| "monitor" \| "neutral"` | `per export` | Colour when pressed. |
| `variant` | `"default" \| "outline" \| "ghost"` | `"default"` | — |
| `size` | `"sm" \| "default" \| "lg" \| "icon"` | `"default"` | — |
| `disabled` | `boolean` | `false` | Inherited from a channel strip. |
# Clip Indicator (/docs/components/clip-indicator)
```tsx title="components/examples/clip-indicator-demo.tsx"
import { ClipIndicator } from "@/components/ui/clip-indicator";
import { useDemoSignal } from "@/hooks/use-demo-signal";
export const ClipIndicatorDemo = () => {
const signal = useDemoSignal({ kind: "music", seed: 11 });
return (
Clip
);
};
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/clip-indicator
```
## Usage
```tsx
import { ClipIndicator } from "@/components/ui/clip-indicator";
```
```tsx
```
The light turns on when any channel reaches −1 dBFS and stays on for 1.5 s. Clicking it resets the light and the count. Its size never changes, so turning on never moves the layout.
## Examples
### Latching, driven by hand
Set `holdMs` to `Infinity` to keep the light on until someone resets it. Use `actionsRef` to report levels from your own code.
```tsx title="components/examples/clip-indicator-latching.tsx"
import { Button } from "@/components/ui/button";
import { ClipIndicator } from "@/components/ui/clip-indicator";
import type { ClipIndicatorActions } from "@/components/ui/clip-indicator";
import type { MutableRef } from "@/lib/solid/ref";
export const ClipIndicatorLatching = () => {
const clip: MutableRef
= { current: null };
return (
clip.current?.report(0)}
size="sm"
variant="outline"
>
Simulate a clip
clip.current?.reset()} size="sm" variant="ghost">
Reset
);
};
```
### Controlled
Pass `clipping` when another part of your app decides whether the signal clipped.
```tsx
```
## Theming
| Token or attribute | Meaning |
| ------------------ | -------------------------- |
| `--meter-clip` | Color of the light when on |
| `data-clipping` | The light is on |
Children replace the default dot, so the light can be a word, an icon or anything else. Use `group-data-clipping/clip-indicator:` to style children.
## Accessibility
* A real ``. Its label changes to "Clipping. Reset clip indicator" while the light is on.
* A polite live region announces "Clipping" once per clip.
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `clipping` | `boolean` | — | Controlled state. When set, the component does no detection. |
| `source` | `FrameSource \| null` | — | Detects clipping from a meter source. |
| `thresholdDb` | `number` | `-1` | Levels at or above this count as a clip. |
| `holdMs` | `number` | `1500` | How long the light stays on. Infinity latches. |
| `onClippingChange` | `(clipping: boolean) => void` | — | — |
| `showCount` | `boolean` | `false` | Show the number of clips. |
| `actionsRef` | `Ref` | — | { report(db), reset() }. |
| `render` | `SolidElement \| function` | `` | Replace the element. |
# dB Readout (/docs/components/db-readout)
```tsx title="components/examples/db-readout-demo.tsx"
import { DbReadout } from "@/components/ui/db-readout";
import { useDemoSignal } from "@/hooks/use-demo-signal";
export const DbReadoutDemo = () => {
const signal = useDemoSignal({ kind: "speech" });
return (
Peak
RMS
Peak, held 1 s
Value
);
};
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/db-readout
```
## Usage
```tsx
import { DbReadout } from "@/components/ui/db-readout";
```
```tsx
```
With a `source`, the readout updates its own text four times a second without re-rendering Solid. Its width is reserved for the longest value, so the text never pushes neighbouring elements around.
## Examples
### Color by zone
The readout sets `data-zone` and `data-silent`, so it can change color with plain Tailwind classes.
```tsx title="components/examples/db-readout-zones.tsx"
import { DbReadout } from "@/components/ui/db-readout";
import { useDemoSignal } from "@/hooks/use-demo-signal";
export const DbReadoutZones = () => {
const signal = useDemoSignal({ kind: "music", seed: 6 });
return (
);
};
```
## Theming
| Data attribute | Meaning |
| -------------- | ---------------------------------- |
| `data-zone` | `ok`, `warn` or `clip` |
| `data-silent` | The level is at or below `floorDb` |
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | A level in dB, for declarative use. |
| `source` | `FrameSource \| null` | — | Updates the text without re-rendering Solid. |
| `measure` | `"peak" \| "rms"` | `"peak"` | — |
| `channel` | `number \| "max"` | `"max"` | A channel index, or the loudest channel. |
| `intervalMs` | `number` | `250` | How often the text changes. |
| `holdMs` | `number` | `0` | Show the highest value within this window. |
| `decimals` | `number` | `1` | — |
| `unit` | `boolean` | `true` | Append " dB". |
| `floorDb` | `number` | `-60` | At or below this, show "−∞". |
| `zones` | `MeterZone[]` | `DEFAULT_ZONES` | Zones used for data-zone. |
| `format` | `(db: number) => string` | — | Replaces all formatting. |
# dB Scale (/docs/components/db-scale)
```tsx title="components/examples/db-scale-demo.tsx"
import { DbScale } from "@/components/ui/db-scale";
export const DbScaleDemo = () => (
);
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/db-scale
```
## Usage
```tsx
import { DbScale } from "@/components/ui/db-scale";
```
```tsx
```
Inside a `LevelMeter` or `Fader`, use `LevelMeterScale` or `FaderScale` instead: they take the range, orientation and taper from their parent so the ticks line up.
## Anatomy
```tsx
```
With no children, the scale renders ticks from the `ticks` prop, or common values inside the range.
## Examples
### Vertical, with custom ticks
```tsx title="components/examples/db-scale-vertical.tsx"
import { DbScale, DbScaleTick } from "@/components/ui/db-scale";
export const DbScaleVertical = () => (
Unity
−∞
);
```
## Theming
| Data attribute | On | Meaning |
| ------------------------------- | ----- | ---------------- |
| `data-orientation`, `data-side` | root | Current settings |
| `data-major` | ticks | A major tick |
Marks use `bg-border` and labels use `text-muted-foreground`.
## Accessibility
The scale is decorative (`aria-hidden`): the meter or fader it belongs to already reports its value.
## API reference
### DbScale
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `minDb` | `number` | `-60` | Bottom of the range, or the surrounding meter's range. |
| `maxDb` | `number` | `0` | Top of the range. |
| `ticks` | `number[]` | — | Tick values. Default: common values inside the range. |
| `taper` | `"linear" \| "audio" \| Taper` | `"linear"` | Position law, so ticks line up with a fader. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | — |
| `side` | `"start" \| "end"` | `"end"` | Which side of the marks the labels sit on. |
| `labels` | `boolean` | `true` | Show labels. |
| `format` | `(db: number) => string` | — | Label text. Default: "+6", "0", "−12". |
### DbScaleTick
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | Position in dB. |
| `major` | `boolean` | `true` | Major ticks have longer marks. |
| `children` | `SolidNode` | — | Replaces the label. |
# Electric Bar Visualizer (/docs/components/electric-bar-visualizer)
```tsx title="components/examples/electric-bar-visualizer-demo.tsx"
import { ElectricBarVisualizer } from "@/components/ui/electric-bar-visualizer";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const ElectricBarVisualizerDemo = () => {
const signal = useDemoSignal({ kind: "speech" });
return (
);
};
export default ElectricBarVisualizerDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/electric-bar-visualizer
```
## Usage
```tsx
import { ElectricBarVisualizer } from "@/components/ui/electric-bar-visualizer";
```
```tsx
```
It takes the same data and props as the [bar visualizer](/docs/components/bar-visualizer), so you can swap one for the other. The electric look comes from the shape and the motion, not from a fixed colour: the bars follow the text colour, so they turn with your theme. In dark mode the core runs white-hot.
The visualizer fills its container, so size it with classes. It paints on the animation frame without re-rendering Solid, redraws on resize and picks up theme changes on its own.
## Examples
### Colors
Set the text color, or `--electric`, to any theme color.
```tsx title="components/examples/electric-bar-visualizer-colors.tsx"
import { For } from "solid-js";
import { ElectricBarVisualizer } from "@/components/ui/electric-bar-visualizer";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const colors = [
{ class: "text-primary", label: "primary" },
{ class: "text-meter-ok", label: "meter-ok" },
{ class: "text-channel-solo", label: "channel-solo" },
{ class: "text-destructive", label: "destructive" },
] as const;
const ElectricBarVisualizerColors = () => {
const signal = useDemoSignal({ kind: "speech", seed: 3 });
return (
{(color) => (
{color.label}
)}
);
};
export default ElectricBarVisualizerColors;
```
### Intensity, arcs and sparks
`intensity` sets how jagged and restless the filaments are. `arcs` jump between loud neighbouring bars and `sparks` fly off the tips when a level rises quickly.
```tsx title="components/examples/electric-bar-visualizer-intensity.tsx"
import { For } from "solid-js";
import { ElectricBarVisualizer } from "@/components/ui/electric-bar-visualizer";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const settings = [
{ arcs: false, intensity: 0.2, label: "calm", sparks: false },
{ arcs: true, intensity: 0.6, label: "default", sparks: true },
{ arcs: true, intensity: 1, label: "charged", sparks: true },
] as const;
const ElectricBarVisualizerIntensity = () => {
const signal = useDemoSignal({ kind: "speech", seed: 5 });
return (
{(setting) => (
{setting.label}
)}
);
};
export default ElectricBarVisualizerIntensity;
```
### Idle and loading
`idle` decides what bars do without a signal. `loading` runs an arc along the bars, for connecting or thinking states.
```tsx title="components/examples/electric-bar-visualizer-states.tsx"
import { ElectricBarVisualizer } from "@/components/ui/electric-bar-visualizer";
const ElectricBarVisualizerStates = () => (
idle pulse
idle wave
loading
);
export default ElectricBarVisualizerStates;
```
### Mirrored
```tsx title="components/examples/electric-bar-visualizer-mirrored.tsx"
import { ElectricBarVisualizer } from "@/components/ui/electric-bar-visualizer";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const ElectricBarVisualizerMirrored = () => {
const signal = useDemoSignal({ kind: "speech", seed: 8 });
return (
);
};
export default ElectricBarVisualizerMirrored;
```
### Microphone
```tsx title="components/examples/electric-bar-visualizer-microphone.tsx"
import { Button } from "@/components/ui/button";
import { ElectricBarVisualizer } from "@/components/ui/electric-bar-visualizer";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import { useMicrophone } from "@/hooks/use-microphone";
const ElectricBarVisualizerMicrophone = () => {
const microphone = useMicrophone();
const analyser = useAudioAnalyser(() => microphone.stream);
return (
microphone.status === "active"
? microphone.stop()
: void microphone.start()
}
size="sm"
variant="outline"
>
{microphone.status === "active"
? "Stop microphone"
: "Use my microphone"}
);
};
export default ElectricBarVisualizerMicrophone;
```
## Theming
| Variable or attribute | Meaning |
| ---------------------- | -------------------------------------------------------------------------------- |
| `--electric` | Filament color, default `currentColor` |
| `--electric-core` | Core and tip color, default `--electric` mixed toward white by `--electric-heat` |
| `--electric-heat` | How white-hot the core runs, default `0%` in light mode and `70%` in dark mode |
| `--electric-glow` | Glow color, default `--electric` |
| `--electric-glow-size` | Glow blur, default `0.5rem` |
| `data-active` | The signal is above the floor |
| `data-loading` | The loading arc is running |
| `data-align` | Where bars grow from |
The variables can be set from a class, such as `[--electric-heat:40%]`, or globally.
## Accessibility
The root has `role="img"` and the label "Audio visualizer". Pass `aria-label` to describe what it shows, or `aria-hidden` when it is decorative next to a labelled meter.
The flicker changes brightness by at most 12%, only on thin strokes, and each jagged shape holds for at least 40 ms, so nothing flashes across a large area. With reduced motion the bars are straight and still, with no arcs, sparks or flicker, and redraw four times a second.
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `FrameSource \| null` | — | Bars follow the frame's frequency bands. |
| `levels` | `ArrayLike` | — | Levels, 0..1, for declarative use. |
| `barCount` | `number` | `16` | Bands are resampled to fit. |
| `align` | `"center" \| "start" \| "end"` | `"center"` | Where bars grow from. |
| `mirrored` | `boolean` | `false` | Symmetric around the middle bar, lows in the centre. |
| `minLevel` | `number` | `0.08` | Resting bar size, 0..1. |
| `idle` | `"static" \| "pulse" \| "wave"` | `"static"` | What bars do without a signal. |
| `loading` | `boolean` | `false` | Run an arc along the bars. |
| `intensity` | `number` | `0.6` | How jagged and restless the filaments are, 0..1. |
| `arcs` | `boolean` | `true` | Arcs jump between loud neighbouring bars. |
| `sparks` | `boolean` | `true` | Sparks fly off the tips on sudden rises. |
| `barWidth` | `number` | `6` | Widest bar, in pixels. |
| `barGap` | `number` | `4` | Pixels. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | vertical grows bars sideways. |
| `actionsRef` | `RefTarget` | — | { paint(levels) }. |
# Electric Waveform (/docs/components/electric-waveform)
```tsx title="components/examples/electric-waveform-demo.tsx"
import { ElectricWaveform } from "@/components/ui/electric-waveform";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const ElectricWaveformDemo = () => {
const signal = useDemoSignal({ kind: "speech" });
return (
);
};
export default ElectricWaveformDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/electric-waveform
```
## Usage
```tsx
import { ElectricWaveform } from "@/components/ui/electric-waveform";
```
```tsx
```
The line follows the text colour, so it changes with your theme, and in dark mode its core runs white-hot. It shares its look and variables with the [electric bar visualizer](/docs/components/electric-bar-visualizer).
The waveform fills its container, so size it with classes. It paints on the animation frame without re-rendering Solid, redraws on resize and picks up theme changes on its own.
## Examples
### Wave and scope
`wave` draws a smooth wave shaped by the frequency bands, calm enough for a voice assistant. `scope` draws the signal itself, like an oscilloscope, and starts each frame on a rising zero crossing so steady tones stand still.
```tsx title="components/examples/electric-waveform-modes.tsx"
import { For } from "solid-js";
import { ElectricWaveform } from "@/components/ui/electric-waveform";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const modes = ["wave", "scope"] as const;
const ElectricWaveformModes = () => {
const signal = useDemoSignal({ kind: "speech", seed: 4 });
return (
);
};
export default ElectricWaveformModes;
```
### Intensity, arcs and sparks
`intensity` sets how jagged and restless the line is. `arcs` fork off the peaks and `sparks` fly when the level rises quickly.
```tsx title="components/examples/electric-waveform-intensity.tsx"
import { For } from "solid-js";
import { ElectricWaveform } from "@/components/ui/electric-waveform";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const settings = [
{ arcs: false, intensity: 0.15, label: "calm", sparks: false },
{ arcs: true, intensity: 0.6, label: "default", sparks: true },
{ arcs: true, intensity: 1, label: "charged", sparks: true },
] as const;
const ElectricWaveformIntensity = () => {
const signal = useDemoSignal({ kind: "speech", seed: 6 });
return (
{(setting) => (
{setting.label}
)}
);
};
export default ElectricWaveformIntensity;
```
### Idle and loading
With no signal the line rests flat and hums. `loading` runs a pulse along it, for connecting or thinking states.
```tsx title="components/examples/electric-waveform-states.tsx"
import { ElectricWaveform } from "@/components/ui/electric-waveform";
const ElectricWaveformStates = () => (
);
export default ElectricWaveformStates;
```
### Microphone
```tsx title="components/examples/electric-waveform-microphone.tsx"
import { Button } from "@/components/ui/button";
import { ElectricWaveform } from "@/components/ui/electric-waveform";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import { useMicrophone } from "@/hooks/use-microphone";
const ElectricWaveformMicrophone = () => {
const microphone = useMicrophone();
const analyser = useAudioAnalyser(() => microphone.stream);
return (
microphone.status === "active"
? microphone.stop()
: void microphone.start()
}
size="sm"
variant="outline"
>
{microphone.status === "active"
? "Stop microphone"
: "Use my microphone"}
);
};
export default ElectricWaveformMicrophone;
```
## Theming
| Variable or attribute | Meaning |
| ---------------------- | ------------------------------------------------------------------------------ |
| `--electric` | Line color, default `currentColor` |
| `--electric-core` | Core color, default `--electric` mixed toward white by `--electric-heat` |
| `--electric-heat` | How white-hot the core runs, default `0%` in light mode and `70%` in dark mode |
| `--electric-glow` | Glow color, default `--electric` |
| `--electric-glow-size` | Glow blur, default `0.5rem` |
| `data-mode` | `wave` or `scope` |
| `data-active` | The signal is above the floor |
| `data-loading` | The loading pulse is running |
## Accessibility
The root has `role="img"` and the label "Audio waveform". Pass `aria-label` to describe what it shows, or `aria-hidden` when it is decorative next to a labelled meter.
The flicker changes brightness by at most 12% and each jagged shape holds for at least 40 ms, so nothing flashes. With reduced motion the line is smooth and still, with no crackle, forks or sparks, and redraws four times a second.
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `FrameSource \| null` | — | — |
| `mode` | `"wave" \| "scope"` | `"wave"` | wave follows the frequency bands; scope draws the signal. |
| `loading` | `boolean` | `false` | Run a pulse along the line. |
| `intensity` | `number` | `0.6` | How jagged and restless the line is, 0..1. |
| `arcs` | `boolean` | `true` | Forks of lightning branch off the peaks. |
| `sparks` | `boolean` | `true` | Sparks fly off the peaks on sudden rises. |
| `sensitivity` | `number` | `1` | Visual gain. |
| `lineWidth` | `number` | `3` | Pixels. |
| `fadeEdges` | `boolean` | `true` | Fade the left and right ends. |
| `actionsRef` | `RefTarget` | — | { paint(frame), clear() }. |
# Fader (/docs/components/fader)
```tsx title="components/examples/fader-demo.tsx"
import { createSignal } from "solid-js";
import {
Fader,
FaderLabel,
FaderRange,
FaderReset,
FaderScale,
FaderThumb,
FaderTrack,
FaderValue,
} from "@/components/ui/fader";
const FaderDemo = () => {
const [gainDb, setGainDb] = createSignal(-6);
return (
Microphone
);
};
export default FaderDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/fader
```
## Usage
```tsx
import { Fader } from "@/components/ui/fader";
```
```tsx
const [gainDb, setGainDb] = createSignal(0);
;
```
The value is always in dB. The taper decides where each dB value sits on the travel. The thumb snaps to 0 dB while dragging, and double-clicking it resets.
## Anatomy
```tsx
Microphone
```
`FaderTrack` accepts any children, so a `LevelMeter` can sit inside it.
## Examples
### Console faders
`taper="audio"` gives more travel around 0 dB, like a mixing console.
```tsx title="components/examples/fader-vertical.tsx"
import { createSignal } from "solid-js";
import {
Fader,
FaderLabel,
FaderRange,
FaderScale,
FaderThumb,
FaderTrack,
FaderValue,
} from "@/components/ui/fader";
const channels = ["Mic", "Music", "Game"];
const FaderVertical = () => {
const [levels, setLevels] = createSignal>({
Game: -12,
Mic: 0,
Music: -18,
});
return (
{channels.map((channel) => (
setLevels({ ...levels(), [channel]: value })
}
orientation="vertical"
taper="audio"
value={levels()[channel]}
variant="console"
>
{channel}
))}
);
};
export default FaderVertical;
```
### Bipolar gain
Set `origin={0}` so the fill grows from the middle.
```tsx title="components/examples/fader-bipolar.tsx"
import { createSignal } from "solid-js";
import {
Fader,
FaderLabel,
FaderRange,
FaderScale,
FaderThumb,
FaderTrack,
FaderValue,
} from "@/components/ui/fader";
const FaderBipolar = () => {
const [gainDb, setGainDb] = createSignal(0);
return (
Input gain
);
};
export default FaderBipolar;
```
### Silence at the bottom
With `silenceAtMin`, the lowest position reports `-Infinity`.
```tsx title="components/examples/fader-silence.tsx"
import { createSignal } from "solid-js";
import {
Fader,
FaderLabel,
FaderRange,
FaderScale,
FaderThumb,
FaderTrack,
FaderValue,
} from "@/components/ui/fader";
const FaderSilence = () => {
const [volumeDb, setVolumeDb] = createSignal(-10);
return (
Output
);
};
export default FaderSilence;
```
### With a meter in the track
This preview passes the fader value to `useDemoSignal({ gainDb })`, so the meter shows the level after gain.
```tsx title="components/examples/fader-with-meter.tsx"
import { createSignal } from "solid-js";
import {
Fader,
FaderThumb,
FaderTrack,
FaderValue,
} from "@/components/ui/fader";
import { LevelMeter } from "@/components/ui/level-meter";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const FaderWithMeter = () => {
const [gainDb, setGainDb] = createSignal(0);
const signal = useDemoSignal({
channels: 2,
kind: "music",
get gainDb() {
return gainDb();
},
});
return (
);
};
export default FaderWithMeter;
```
### Sizes
```tsx title="components/examples/fader-sizes.tsx"
import { Fader } from "@/components/ui/fader";
const sizes = ["sm", "default", "lg"] as const;
const FaderSizes = () => (
{sizes.map((size) => (
))}
);
export default FaderSizes;
```
## Keyboard
| Key | Action |
| ---------------------------------- | ------------------------------ |
| Arrow keys | Change by `step` (0.5 dB) |
| Shift + arrow, Page Up / Page Down | Change by `largeStep` (6 dB) |
| Alt + arrow | Change by `fineStep` (0.1 dB) |
| Home / End | Minimum (or silence) / maximum |
Holding Alt while dragging uses `fineStep` resolution.
## Theming
| Variable or attribute | Meaning |
| ----------------------------------------------- | -------------------------- |
| `--fader-track-size`, `--fader-thumb-size` | Set by `size` |
| `data-orientation`, `data-variant`, `data-size` | Current settings |
| `data-dragging` | The thumb is held |
| `data-at-detent` | The value sits on a detent |
| `data-silent` | The value is `-Infinity` |
| `data-disabled` | Disabled |
## Accessibility
The thumb is a slider whose `aria-valuetext` is the formatted value, such as "−6.0 dB" or "Silent". Give the fader a name with `FaderLabel` or `aria-label`.
## API reference
### Fader
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | The value in dB. |
| `defaultValue` | `number` | `resetValue` | — |
| `onValueChange` | `(value, details) => void` | — | details.reason: drag, track-press, keyboard, wheel, reset or input. |
| `onValueCommitted` | `(value) => void` | — | Fires when a drag ends, after keyboard input and on reset. |
| `min` | `number` | `-60` | — |
| `max` | `number` | `6` | — |
| `step` | `number` | `0.5` | Arrow keys and drag resolution. |
| `largeStep` | `number` | `6` | Shift+arrow, Page Up and Page Down. |
| `fineStep` | `number` | `0.1` | Alt+arrow and Alt+drag. |
| `resetValue` | `number` | `0` | Restored by double-click and FaderReset. |
| `taper` | `"linear" \| "audio" \| Taper` | `"linear"` | Position law. |
| `origin` | `number` | `min` | Where the range fill starts. |
| `detents` | `number[]` | `[0]` | Values the thumb snaps to while dragging. |
| `silenceAtMin` | `boolean` | `false` | The lowest position reports -Infinity. |
| `allowWheel` | `boolean` | `false` | The wheel adjusts the value while focused. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Inherited from a channel strip. |
| `variant` | `"default" \| "console"` | `"default"` | console has a wide cap thumb. |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | — |
| `format` | `(db: number) => string` | — | Used by FaderValue and assistive technology. |
| `disabled` | `boolean` | `false` | — |
### FaderValue
The value is as wide as the longest text the fader's range can show, and right-aligned, so moving the fader never shifts the layout. The editor opens at the same width.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `editable` | `boolean` | `false` | Click to type a value. Accepts "-6", "−6 dB" and "-inf". |
### FaderScale
Takes [`DbScale`](/docs/components/db-scale) props. The range, taper and orientation come from the fader.
# Components (/docs/components)
## Meters and visualizers
| Component | What it is |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| [Level Meter](/docs/components/level-meter) | Peak and RMS meter with zones, peak hold, a scale, a readout and a clip light |
| [dB Scale](/docs/components/db-scale) | Tick marks and labels for a decibel range |
| [dB Readout](/docs/components/db-readout) | A numeric level that updates at a readable rate |
| [Clip Indicator](/docs/components/clip-indicator) | A clip light that holds, counts and resets |
| [Bar Visualizer](/docs/components/bar-visualizer) | Bars driven by frequency bands |
| [Electric Bar Visualizer](/docs/components/electric-bar-visualizer) | Bars drawn as crackling filaments, with arcs and sparks |
| [Electric Waveform](/docs/components/electric-waveform) | One crackling electric line, as a smooth wave or an oscilloscope |
| [Smooth Waveform](/docs/components/smooth-waveform) | One clean line, as a smooth wave or an oscilloscope |
| [Live Waveform](/docs/components/live-waveform) | A canvas waveform of a live signal |
| [Waveform](/docs/components/waveform) | A clip's waveform with a playhead, seeking, regions and markers |
| [Spectrum](/docs/components/spectrum) | A frequency spectrum analyser |
## Controls
| Component | What it is |
| ----------------------------------------------------------- | ------------------------------------------------------- |
| [Fader](/docs/components/fader) | A volume fader in decibels |
| [Parameter Slider](/docs/components/parameter-slider) | A labelled slider with a number input and a unit |
| [Knob](/docs/components/knob) | A rotary control |
| [Pan Control](/docs/components/pan-control) | Left and right balance |
| [Channel Toggle](/docs/components/channel-toggle) | Mute, solo and monitor buttons |
| [Volume Control](/docs/components/volume-control) | A 0..1 volume slider with mute, for players |
| [Audio Device Select](/docs/components/audio-device-select) | A device picker with permission and disconnected states |
## Mixer
| Component | What it is |
| ----------------------------------------------- | ---------------------------------------- |
| [Channel Strip](/docs/components/channel-strip) | One channel, as a row or a console strip |
| [Mixer](/docs/components/mixer) | The container for strips, with a master |
## Sounds and music
| Component | What it is |
| --------------------------------------------- | ----------------------------------- |
| [Audio Player](/docs/components/audio-player) | A composable player |
| [Track List](/docs/components/track-list) | A playlist |
| [Sound Pad](/docs/components/sound-pad) | Trigger pads with hotkeys and modes |
# Knob (/docs/components/knob)
```tsx title="components/examples/knob-demo.tsx"
import {
Knob,
KnobDial,
KnobLabel,
KnobPointer,
KnobRange,
KnobTrack,
KnobValue,
} from "@/components/ui/knob";
import { formatPan, parsePan } from "@/components/ui/pan-control";
import { formatDb } from "@/lib/audio/decibels";
const formatHz = (hz: number) =>
hz >= 1000 ? `${(hz / 1000).toFixed(1)}k` : `${Math.round(hz)}`;
const KnobDemo = () => (
formatDb(db, { decimals: 0 })}
max={24}
min={-24}
origin={0}
>
Gain
Pan
Low cut
);
export default KnobDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/knob
```
## Usage
```tsx
import { Knob, KnobDial } from "@/components/ui/knob";
import { createClickSound } from "@/lib/audio/click";
```
```tsx
```
Customize the optional detent tick by passing a `ClickSound`:
```tsx
const softTick = createClickSound({
tone: { hz: 900, decayMs: 2, gain: 0.8 },
noise: { decayMs: 0.5, gain: 0.1 },
lengthMs: 8,
volume: 0.2,
});
;
```
Drag the dial up to increase the value and down to decrease it. Set `dragDirection="circular"` to turn it by circling around the dial, clockwise to increase and anticlockwise to decrease.
| Action | Result |
| -------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Drag | Turns the knob |
| Shift+drag, Shift+wheel | Ten times finer, for precise adjustment. Press or release Shift mid-drag without a jump |
| Double-click the dial, Alt+click | Resets to `resetValue` |
| Double-click the value or label, Enter on the dial | Type a value; Enter applies it, Escape cancels |
The value keeps one width across the whole range, so the layout never moves while you turn it.
## Anatomy
```tsx
```
`KnobTrack`, `KnobRange` and `KnobPointer` are SVG elements inside a 100 × 100 view box, so you can add your own shapes next to them. `KnobScale` and `KnobCap` are another set of parts for the same dial, shown in the [volume dial](#volume-dial) and the [metal knobs](#metal-knobs).
## Examples
### Drag directions
Choose `dragDirection` to match how you want to move the dial:
* `vertical`, the default: drag up to increase the value, down to decrease it. The cursor shows an up/down arrow while dragging.
* `horizontal`: drag right to increase the value, left to decrease it. The cursor shows a left/right arrow while dragging.
* `circular`: turn clockwise to increase the value, anticlockwise to decrease it.
Hold Shift for finer control in all three modes. For vertical and horizontal drags, `sensitivity` sets the distance in pixels for the full range. The default is `200`.
```tsx title="components/examples/knob-drag-directions.tsx"
import {
Knob,
KnobDial,
KnobLabel,
KnobPointer,
KnobRange,
KnobTrack,
KnobValue,
} from "@/components/ui/knob";
import { formatDb } from "@/lib/audio/decibels";
const directions = [
{ label: "Vertical", value: "vertical" },
{ label: "Horizontal", value: "horizontal" },
{ label: "Circular", value: "circular" },
] as const;
const KnobDragDirections = () => (
{directions.map(({ label, value }) => (
formatDb(db, { decimals: 1 })}
max={24}
min={-24}
origin={0}
>
{label}
))}
);
export default KnobDragDirections;
```
### Sizes and disabled
```tsx title="components/examples/knob-sizes.tsx"
import {
Knob,
KnobDial,
KnobLabel,
KnobPointer,
KnobRange,
KnobTrack,
} from "@/components/ui/knob";
const sizes = ["sm", "default", "lg"] as const;
const KnobSizes = () => (
{sizes.map((size) => (
{size}
))}
disabled
);
export default KnobSizes;
```
### Volume dial
A hi-fi volume dial: `KnobScale` draws numbered ticks around the dial and `KnobCap` a brushed aluminium cap with an indicator dot. Give it room for the numbers with a larger `--knob-size`. `clickSound` adds a soft tick on each long graduation.
```tsx title="components/examples/knob-volume.tsx"
import {
Knob,
KnobCap,
KnobDial,
KnobLabel,
KnobScale,
} from "@/components/ui/knob";
const KnobVolume = () => (
Volume
);
export default KnobVolume;
```
Ticks between `origin` and the value light up, so a bipolar knob lights from its centre. The ticks are spread evenly along the arc, so they follow `scale="log"` too.
### Metal knobs
The same aluminium at everyday sizes: swap `KnobPointer` for ` `. The mini cap fills the inside of `KnobTrack` and marks the value with an engraved line, which still reads at `size="sm"`.
```tsx title="components/examples/knob-metal.tsx"
import {
Knob,
KnobCap,
KnobDial,
KnobLabel,
KnobRange,
KnobTrack,
KnobValue,
} from "@/components/ui/knob";
import { formatPan, parsePan } from "@/components/ui/pan-control";
import { formatDb } from "@/lib/audio/decibels";
const formatHz = (hz: number) =>
hz >= 1000 ? `${(hz / 1000).toFixed(1)}k` : `${Math.round(hz)}`;
const formatPercent = (percent: number) => `${Math.round(percent)}%`;
const KnobMetal = () => (
formatDb(db, { decimals: 0 })}
max={24}
min={-24}
origin={0}
>
Gain
Pan
Low cut
Mix
);
export default KnobMetal;
```
```tsx
```
## Theming
| Variable or attribute | Meaning |
| --------------------- | ----------------------------------------- |
| `--knob-size` | Set by `size` |
| `--knob-angle` | The pointer angle, live, for custom skins |
| `data-dragging` | On the dial while dragging |
| `data-at-origin` | The value sits at `origin` |
| `--knob-cap-metal` | `KnobCap`'s metal, white by default |
| `--knob-cap-shade` | `KnobCap`'s shading, black by default |
| `--knob-cap-pitch` | Spacing of `KnobCap`'s brushed rings |
| `data-active` | On `KnobScale` ticks inside the lit range |
| `data-major` | On `KnobScale`'s long ticks |
Style the SVG parts with `stroke-*` and `fill-*` classes. Restyle the lit ticks from the scale, for example ` `.
## Accessibility
The dial is a focusable slider with `aria-valuetext` from `format`, named by `KnobLabel`. It supports the same keys as the [fader](/docs/components/fader#keyboard), and Enter opens the value editor.
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | — |
| `defaultValue` | `number` | — | — |
| `onValueChange` | `(value, details) => void` | — | details.reason: drag, keyboard, wheel, reset or input. |
| `onValueCommitted` | `(value) => void` | — | — |
| `min / max` | `number` | `0 / 100` | — |
| `step / largeStep / fineStep` | `number` | `1 / 10 / step ÷ 10` | fineStep applies to Shift+drag, Shift+wheel and Alt+arrow. |
| `resetValue` | `number` | `defaultValue ?? min` | Double-click or Alt+click restores it. |
| `origin` | `number` | `min` | Where the arc starts; the centre for bipolar knobs. |
| `arc` | `number` | `270` | Sweep in degrees. |
| `dragDirection` | `"vertical" \| "horizontal" \| "circular"` | `"vertical"` | Circular turns the knob as you circle around it. Vertical and horizontal drag in a straight line, as in most DAWs. |
| `sensitivity` | `number` | `200` | Pixels of vertical or horizontal drag for the full range. |
| `scale` | `"linear" \| "log"` | `"linear"` | — |
| `allowWheel` | `boolean` | `false` | — |
| `clickSound` | `boolean \| ClickSound` | `false` | Plays a soft tick on each graduation: KnobScale's long ticks, or every largeStep without a scale. Pass createClickSound() to customize it. |
| `format` | `(value: number) => string` | `String` | — |
| `parse` | `(text: string) => number \| null` | `parseKnobValue` | Reads a typed value. The default takes the first number and reads "k" as thousands. Use parsePan for pan knobs. |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | — |
| `disabled` | `boolean` | `false` | — |
### KnobValue
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `editable` | `boolean` | `true` | Double-click, or Enter on the dial, to type a value. |
### KnobScale
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `ticks` | `number` | `50` | Divisions across the arc; draws ticks + 1 marks. |
| `majorEvery` | `number` | `5` | Every nth tick is long. |
| `labelEvery` | `number` | `10` | Every nth tick is numbered. 0 hides the numbers. |
| `format` | `(value: number) => string` | `the knob's format` | — |
### KnobCap
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "mini"` | `"default"` | default sits inside KnobScale with an indicator dot. mini fills the inside of KnobTrack with an engraved line, for small knobs. |
# Level Meter (/docs/components/level-meter)
```tsx title="components/examples/level-meter-demo.tsx"
import {
LevelMeter,
LevelMeterBar,
LevelMeterChannel,
LevelMeterChannels,
LevelMeterClip,
LevelMeterHold,
LevelMeterScale,
LevelMeterTrack,
LevelMeterValue,
} from "@/components/ui/level-meter";
import { useDemoSignal } from "@/hooks/use-demo-signal";
export const LevelMeterDemo = () => {
const signal = useDemoSignal({ channels: 2, kind: "music" });
return (
);
};
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/level-meter
```
## Usage
```tsx
import { LevelMeter } from "@/components/ui/level-meter";
```
```tsx
```
With no children, `LevelMeter` renders one track per channel with a peak bar and a peak-hold tick. Add children to control the anatomy.
## Anatomy
```tsx
```
## Examples
### Default
```tsx title="components/examples/level-meter-simple.tsx"
import { LevelMeter } from "@/components/ui/level-meter";
import { useDemoSignal } from "@/hooks/use-demo-signal";
export const LevelMeterSimple = () => {
const signal = useDemoSignal({ kind: "speech" });
return (
);
};
```
### Values
Pass `peakDb` (and optionally `rmsDb`) for data that arrives slowly. The meter still moves smoothly between updates.
```tsx title="components/examples/level-meter-values.tsx"
import { For } from "solid-js";
import { LevelMeter } from "@/components/ui/level-meter";
const levels = [
{ label: "Quiet", peakDb: -42 },
{ label: "Good", peakDb: -16 },
{ label: "Hot", peakDb: -7 },
{ label: "Clipping", peakDb: 0 },
];
export const LevelMeterValues = () => (
{(level) => (
{level.label}
)}
);
```
### Vertical
```tsx title="components/examples/level-meter-vertical.tsx"
import { For } from "solid-js";
import {
LevelMeter,
LevelMeterBar,
LevelMeterChannel,
LevelMeterChannels,
LevelMeterHold,
LevelMeterScale,
LevelMeterTrack,
LevelMeterValue,
} from "@/components/ui/level-meter";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const channels = [0, 1];
export const LevelMeterVertical = () => {
const signal = useDemoSignal({ channels: 2, kind: "music", seed: 4 });
return (
{(index) => (
)}
);
};
```
### Variants
```tsx title="components/examples/level-meter-variants.tsx"
import { For } from "solid-js";
import { LevelMeter } from "@/components/ui/level-meter";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const variants = ["solid", "segmented", "gradient"] as const;
export const LevelMeterVariants = () => {
const signal = useDemoSignal({ channels: 2, kind: "music", seed: 2 });
return (
{(variant) => (
{variant}
)}
);
};
```
### Peak and RMS
Layer an `rms` bar over a translucent `peak` bar for a dual meter.
```tsx title="components/examples/level-meter-dual.tsx"
import {
LevelMeter,
LevelMeterBar,
LevelMeterChannel,
LevelMeterChannels,
LevelMeterHold,
LevelMeterScale,
LevelMeterTrack,
} from "@/components/ui/level-meter";
import { useDemoSignal } from "@/hooks/use-demo-signal";
export const LevelMeterDual = () => {
const signal = useDemoSignal({ kind: "speech", seed: 7 });
return (
);
};
```
### Ballistics
`peak` rises in 15 ms and falls steadily; `vu` is slow and averaged; `instant` follows the input exactly.
```tsx title="components/examples/level-meter-ballistics.tsx"
import { For } from "solid-js";
import { LevelMeter } from "@/components/ui/level-meter";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const presets = ["peak", "vu", "instant"] as const;
export const LevelMeterBallistics = () => {
const signal = useDemoSignal({ kind: "speech", seed: 3 });
return (
{(preset) => (
{preset}
)}
);
};
```
### Colors and zones
Override the meter tokens with a class, or pass your own `zones`.
```tsx title="components/examples/level-meter-custom-colors.tsx"
import { LevelMeter } from "@/components/ui/level-meter";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const zones = [
{ fromDb: Number.NEGATIVE_INFINITY, zone: "ok" as const },
{ fromDb: -12, zone: "warn" as const },
{ fromDb: -3, zone: "clip" as const },
];
export const LevelMeterCustomColors = () => {
const signal = useDemoSignal({ kind: "music", seed: 9 });
return (
);
};
```
### Build your own visual
Each channel writes its live level to `--meter-level` (0..1). Anything inside a `LevelMeterChannel` can use it in CSS.
```tsx title="components/examples/level-meter-css-level.tsx"
import { LevelMeter, LevelMeterChannel } from "@/components/ui/level-meter";
import { useDemoSignal } from "@/hooks/use-demo-signal";
export const LevelMeterCssLevel = () => {
const signal = useDemoSignal({ kind: "speech", seed: 5 });
return (
);
};
```
### Microphone
```tsx title="components/examples/level-meter-microphone.tsx"
import {
MicrophoneIcon,
MicrophoneSlashIcon,
} from "@/components/icons/phosphor";
import { Button } from "@/components/ui/button";
import {
LevelMeter,
LevelMeterBar,
LevelMeterChannel,
LevelMeterChannels,
LevelMeterClip,
LevelMeterHold,
LevelMeterTrack,
LevelMeterValue,
} from "@/components/ui/level-meter";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import { useMicrophone } from "@/hooks/use-microphone";
export const LevelMeterMicrophone = () => {
const microphone = useMicrophone();
const analyser = useAudioAnalyser(() => microphone.stream);
const listening = () => microphone.status === "active";
return (
{
if (listening()) microphone.stop();
else void microphone.start();
}}
size="sm"
variant="outline"
>
{listening() ? (
) : (
)}
{listening() ? "Stop microphone" : "Use my microphone"}
);
};
```
## Feeding data
A meter accepts levels three ways. See [Feeding data](/docs/concepts/feeding-data).
```tsx
// A source: subscribes and paints itself without Solid component rerenders.
// Values: for slow data. Ballistics smooth the movement.
// A handle: for callers that own their own frame loop.
const meter = { current: null as LevelMeterActions | null };
meter.current?.paint({ channels: [{ peakDb: -9.1 }] });
```
## Theming
| Token or variable | Where | Default |
| ---------------------------------------------- | ------------------ | ------------------------------------- |
| `--meter-ok`, `--meter-warn`, `--meter-clip` | theme | green, amber, `--destructive` |
| `--meter-thickness` | root | `0.5rem` (`sm` 0.25rem, `lg` 0.75rem) |
| `--meter-gap` | root | `0.25rem`, between channels |
| `--meter-level`, `--meter-rms`, `--meter-hold` | each channel, live | 0..1 |
| Data attribute | On | Meaning |
| ----------------------------------------------- | -------------- | ------------------------------------------ |
| `data-orientation`, `data-variant`, `data-size` | root | Current settings |
| `data-zone` | root, channels | `ok`, `warn` or `clip` |
| `data-clipping` | root | The signal clipped in the last 1.5 s |
| `data-active` | channels | The level is above the bottom of the range |
| `data-dimmed` | root | Inside a muted or silenced channel strip |
| `data-measure` | bars | `peak` or `rms` |
## Accessibility
* The root has `role="meter"` with `aria-valuemin`, `aria-valuemax` and `aria-valuenow` in dB, and `aria-valuetext` such as "−12.0 dB". These update at most four times a second.
* Give every meter a name with `aria-label` or `aria-labelledby`.
* With reduced motion, the meter shows the current level four times a second instead of animating.
## API reference
### LevelMeter
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `FrameSource \| null` | — | Subscribes and paints without component rerenders. |
| `peakDb` | `number` | — | Mono peak level in dBFS, for declarative use. |
| `rmsDb` | `number` | — | Mono RMS level in dBFS. |
| `channels` | `ChannelLevel[]` | — | Levels per channel, for declarative multi-channel use. |
| `channelCount` | `number` | `1` | Tracks to render before data arrives. |
| `minDb` | `number` | `-60` | Bottom of the displayed range. |
| `maxDb` | `number` | `0` | Top of the displayed range. |
| `zones` | `MeterZone[]` | `DEFAULT_ZONES` | Colour zones: ok, warn from −20, clip from −9. |
| `ballistics` | `"peak" \| "vu" \| "instant" \| Partial` | `"peak"` | How the meter moves. |
| `taper` | `"linear" \| "audio" \| Taper` | `"linear"` | Scale law. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Inherited from a channel strip. |
| `variant` | `"solid" \| "segmented" \| "gradient"` | `"solid"` | segmented is an LED ladder. |
| `segments` | `number` | `24` | Segments for the segmented variant. |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | Track thickness. |
| `actionsRef` | `RefTarget` | — | { paint(frame), reset() }. |
### LevelMeterChannel
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `index` | `number` | `0` | Which channel of the frames this track shows. |
### LevelMeterBar
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `measure` | `"peak" \| "rms"` | `"peak"` | Which measurement the bar shows. |
### LevelMeterScale, LevelMeterValue, LevelMeterClip
These wrap [`DbScale`](/docs/components/db-scale), [`DbReadout`](/docs/components/db-readout) and [`ClipIndicator`](/docs/components/clip-indicator), and take their props. The range, orientation and data come from the meter.
# Live Waveform (/docs/components/live-waveform)
```tsx title="components/examples/live-waveform-demo.tsx"
import { LiveWaveform } from "@/components/ui/live-waveform";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const LiveWaveformDemo = () => {
const signal = useDemoSignal({ historySize: 120, kind: "speech" });
return (
);
};
export default LiveWaveformDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/live-waveform
```
## Usage
```tsx
import { LiveWaveform } from "@/components/ui/live-waveform";
```
```tsx
```
The waveform fills its container, so size it with classes. It redraws on resize and picks up theme changes on its own.
Scrolling moves between history samples without adding samples. The source's `historyIntervalMs` option sets the minimum wait, 50 ms by default. The source takes the next sample on the first frame after that wait. With 16 ms frames, samples arrive every 64 ms, and a bar moves one step over those 64 ms.
The drawing uses a short buffer so it can follow measured sample times without guessing when the next sample will arrive. This adds twice the configured history interval as display delay, 100 ms by default. If the browser pauses or throttles frames, the built-in sources skip missed samples rather than repeat stale levels. If you use a custom source, add the [history timing fields](/docs/concepts/feeding-data#frames) to enable smooth scrolling.
When the source stops sending frames, the waveform finishes its buffered scroll and stops requesting animation frames. A new source frame wakes it again.
## Examples
### Variants
```tsx title="components/examples/live-waveform-variants.tsx"
import { LiveWaveform } from "@/components/ui/live-waveform";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const LiveWaveformVariants = () => {
const signal = useDemoSignal({ historySize: 120, kind: "music" });
return (
static bars
static mirror
static line
scrolling line
);
};
export default LiveWaveformVariants;
```
### Idle
With `active={false}` the waveform shows a dotted line.
```tsx title="components/examples/live-waveform-idle.tsx"
import { createSignal } from "solid-js";
import { Button } from "@/components/ui/button";
import { LiveWaveform } from "@/components/ui/live-waveform";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const LiveWaveformIdle = () => {
const [active, setActive] = createSignal(false);
const signal = useDemoSignal({ historySize: 120, kind: "speech" });
return (
setActive((value) => !value)}
size="sm"
variant="outline"
>
{active() ? "Pause" : "Listen"}
);
};
export default LiveWaveformIdle;
```
### Microphone
```tsx title="components/examples/live-waveform-microphone.tsx"
import { Button } from "@/components/ui/button";
import { LiveWaveform } from "@/components/ui/live-waveform";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import { useMicrophone } from "@/hooks/use-microphone";
const LiveWaveformMicrophone = () => {
const microphone = useMicrophone();
const analyser = useAudioAnalyser(() => microphone.stream, {
historySize: 120,
});
return (
microphone.status === "active"
? microphone.stop()
: void microphone.start()
}
size="sm"
variant="outline"
>
{microphone.status === "active"
? "Stop microphone"
: "Use my microphone"}
);
};
export default LiveWaveformMicrophone;
```
## Theming
| Variable or attribute | Meaning |
| --------------------------- | -------------------------------------------- |
| `--waveform` | Drawing color, default `currentColor` |
| `data-mode`, `data-variant` | Current settings |
| `data-active` | Drawing the signal rather than the idle line |
## Accessibility
The root has `role="img"`. Give it an `aria-label`, or `aria-hidden` when a labelled meter is next to it. With reduced motion it redraws four times a second.
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `FrameSource \| null` | — | — |
| `mode` | `"scrolling" \| "static"` | `"static"` | scrolling shows level history; static shows the current frame. |
| `variant` | `"bars" \| "line" \| "mirror"` | `"bars"` | line draws the waveform trace in static mode. |
| `barWidth` | `number` | `3` | Pixels. |
| `barGap` | `number` | `1` | Pixels. |
| `barRadius` | `number` | `1.5` | Pixels. |
| `minBarHeight` | `number` | `4` | Pixels. |
| `lineWidth` | `number` | `1.5` | For the line variant. |
| `fadeEdges` | `boolean` | `true` | Fade the left and right edges. |
| `fadeWidth` | `number` | `24` | Pixels. |
| `active` | `boolean` | `true` | false shows an idle dotted line. |
| `sensitivity` | `number` | `1` | Visual gain. |
| `actionsRef` | `RefTarget` | — | { paint(frame), clear() }. |
# Mixer (/docs/components/mixer)
```tsx title="components/examples/mixer-demo.tsx"
import { For, Show } from "solid-js";
import {
DesktopIcon,
MicrophoneIcon,
MusicNotesIcon,
SpeakerHighIcon,
} from "@/components/icons/phosphor";
import {
ChannelStrip,
ChannelStripControls,
ChannelStripFader,
ChannelStripHeader,
ChannelStripIcon,
ChannelStripMeter,
ChannelStripText,
ChannelStripTitle,
ChannelStripValue,
} from "@/components/ui/channel-strip";
import { MuteToggle, SoloToggle } from "@/components/ui/channel-toggle";
import { Fader } from "@/components/ui/fader";
import { LevelMeter } from "@/components/ui/level-meter";
import {
Mixer,
MixerChannels,
MixerHeader,
MixerMaster,
MixerSeparator,
MixerTitle,
} from "@/components/ui/mixer";
import { useMixer } from "@/hooks/use-mixer";
import type { Mixer as MixerController } from "@/hooks/use-mixer";
import { formatDb } from "@/lib/audio/decibels";
import type { FrameSource, MeterFrame } from "@/lib/audio/types";
import { useDemoMixer } from "@/lib/docs/use-demo-mixer";
import type { JSXElement } from "@/lib/solid/jsx-types";
const channels = [
{ Icon: MicrophoneIcon, id: "mic", kind: "speech", title: "Microphone" },
{
Icon: DesktopIcon,
id: "system",
kind: "noise",
channels: 2,
title: "System audio",
},
{
Icon: MusicNotesIcon,
id: "music",
kind: "music",
channels: 2,
title: "Music",
},
] as const;
const Strip = (props: {
id: string;
title: string;
icon: JSXElement;
source: FrameSource;
mixer: MixerController;
}) => {
const channel = () => props.mixer.channel(props.id);
return (
{(current) => (
{props.icon}
{props.title}
props.mixer.setGain(props.id, gainDb)}
size="sm"
value={current().gainDb}
/>
{formatDb(current().gainDb)}
props.mixer.setMuted(props.id, muted)}
pressed={current().muted}
size="sm"
>
M
props.mixer.setSolo(props.id, solo)}
pressed={current().solo}
size="sm"
>
S
)}
);
};
const MixerDemo = () => {
const mixer = useMixer({ channels: channels.map(({ id }) => ({ id })) });
const demo = useDemoMixer(channels, () => mixer.state);
const audible = () => channels.some(({ id }) => mixer.isAudible(id));
return (
Audio mixer
{(channel) => (
}
id={channel.id}
mixer={mixer}
source={demo.sources[channel.id]!}
title={channel.title}
/>
)}
Master
mixer.setMasterGain(value)}
size="sm"
value={mixer.master.gainDb}
/>
);
};
export default MixerDemo;
```The preview meters each synthetic channel after its fader, mute and solo settings. Muting or excluding a channel with solo clears its meter immediately. The master combines those channels, then applies the master fader. It clears immediately when no channels are audible.Copy the demo signal helperThe example uses a docs-only `useDemoMixer` helper. To copy the preview, save the code below as `lib/docs/use-demo-mixer.ts`. It uses [`useDemoSignal`](/docs/hooks/use-demo-signal) and [`useMixer`](/docs/hooks/use-mixer).Mono channels feed both sides. The helper estimates peak by summing amplitudes and RMS by summing powers. For audio samples, connect [`useWebAudioMixer`](/docs/hooks/use-web-audio-mixer) to the same components.```tsx title="lib/docs/use-demo-mixer.ts"
import type { Accessor } from "solid-js";
import { createDemoSignal } from "@/hooks/use-demo-signal";
import type { DemoSignalOptions } from "@/hooks/use-demo-signal";
import { isChannelAudible } from "@/hooks/use-mixer";
import type { MixerState } from "@/hooks/use-mixer";
import { dbToGain, gainToDb } from "@/lib/audio/decibels";
import { subscribeFrame } from "@/lib/audio/frame-loop";
import type { FrameSource, MeterFrame } from "@/lib/audio/types";
import { createCompatEffect } from "@/lib/solid/effect";
interface DemoMixerChannel extends DemoSignalOptions {
id: string;
}
/** Docs-only metering: sum peak amplitudes and RMS powers, with mono on both sides. */
export const createDemoMixer = (channels: readonly DemoMixerChannel[]) => {
const signals = channels.map(({ id, ...options }) => ({
id,
signal: createDemoSignal(options),
}));
let masterGain = 1;
const subscribers = new Set<(frame: MeterFrame) => void>();
let release: (() => void) | undefined;
const master: FrameSource = {
subscribe: (listener) => {
subscribers.add(listener);
if (!release) {
const frames = new Map();
const unsubscribes = signals.map(({ id, signal }) =>
signal.meter.subscribe((frame) => frames.set(id, frame))
);
// Input subscriptions run first, so every mix uses the same frame.
const stop = subscribeFrame(() => {
const frame: MeterFrame = { channels: [] };
for (let side = 0; side < 2; side += 1) {
let peak = 0;
let power = 0;
for (const input of frames.values()) {
const level =
input.channels[input.channels.length === 1 ? 0 : side];
if (level) {
peak += dbToGain(level.peakDb);
power += dbToGain(level.rmsDb ?? level.peakDb) ** 2;
}
}
frame.channels.push({
peakDb: gainToDb(peak * masterGain),
rmsDb: gainToDb(Math.sqrt(power) * masterGain),
});
}
for (const subscriber of subscribers) subscriber(frame);
}, "update");
release = () => {
stop();
for (const unsubscribe of unsubscribes) unsubscribe();
};
}
return () => {
subscribers.delete(listener);
if (subscribers.size === 0) {
release?.();
release = undefined;
}
};
},
};
return {
configure: (state: MixerState) => {
for (const { id, signal } of signals) {
const channel = state.channels.find((item) => item.id === id);
signal.configure({
gainDb: channel?.gainDb ?? 0,
playing: isChannelAudible(state, id),
});
}
masterGain = state.master.muted ? 0 : dbToGain(state.master.gainDb);
},
master,
sources: Object.fromEntries(
signals.map(({ id, signal }) => [id, signal.meter])
),
};
};
/** Stable demo sources; gain, mute and solo follow the reactive mixer state. */
export const useDemoMixer = (
channels: readonly DemoMixerChannel[],
state: Accessor
) => {
const demo = createDemoMixer(channels);
createCompatEffect(state, demo.configure);
return demo;
};
```## Installation```bash
npx shadcn@latest add @audiocn-solid/mixer
```## Usage```tsx
import {
Mixer,
MixerChannels,
MixerHeader,
MixerMaster,
MixerTitle,
} from "@/components/ui/mixer";
``````tsx
Audio mixer
{channels.map((channel) => (
…
))}
…
```The mixer holds no audio and no state. Pair it with [`useMixer`](/docs/hooks/use-mixer) for state and [`useWebAudioMixer`](/docs/hooks/use-web-audio-mixer) for sound, or with your own engine.## Anatomy```tsx
```## Examples### ConsoleSixteen strips in a console. Meters paint outside Solid, so a full console keeps the frame rate with no re-renders while it meters.```tsx title="components/examples/mixer-console.tsx"
import { For, Show } from "solid-js";
import {
ChannelStrip,
ChannelStripControls,
ChannelStripFader,
ChannelStripHeader,
ChannelStripMeter,
ChannelStripTitle,
ChannelStripValue,
} from "@/components/ui/channel-strip";
import { MuteToggle, SoloToggle } from "@/components/ui/channel-toggle";
import { Fader } from "@/components/ui/fader";
import { LevelMeter } from "@/components/ui/level-meter";
import {
Mixer,
MixerChannels,
MixerHeader,
MixerTitle,
} from "@/components/ui/mixer";
import { useDemoSignal } from "@/hooks/use-demo-signal";
import type { DemoSignalKind } from "@/hooks/use-demo-signal";
import { useMixer } from "@/hooks/use-mixer";
import type { Mixer as MixerController } from "@/hooks/use-mixer";
import { formatDb } from "@/lib/audio/decibels";
const CHANNEL_COUNT = 16;
const KINDS: DemoSignalKind[] = ["speech", "music", "noise", "music"];
const CHANNELS = Array.from({ length: CHANNEL_COUNT }, (_, index) => ({
id: `input-${index + 1}`,
kind: KINDS[index % KINDS.length] ?? "music",
seed: index + 1,
title: `In ${index + 1}`,
}));
const INITIAL_CHANNELS = CHANNELS.map(({ id }) => ({ id }));
const ConsoleStrip = (props: {
id: string;
title: string;
kind: DemoSignalKind;
seed: number;
mixer: MixerController;
}) => {
const channel = () => props.mixer.channel(props.id);
const signal = useDemoSignal({
kind: props.kind,
seed: props.seed,
get gainDb() {
return channel()?.gainDb;
},
get playing() {
return props.mixer.isAudible(props.id);
},
});
return (
{(current) => (
{props.title}
props.mixer.setGain(props.id, gainDb)}
size="sm"
value={current().gainDb}
/>
{formatDb(current().gainDb)}
props.mixer.setMuted(props.id, muted)}
pressed={current().muted}
size="sm"
>
M
props.mixer.setSolo(props.id, solo)}
pressed={current().solo}
size="sm"
>
S
)}
);
};
const MixerConsole = () => {
const mixer = useMixer({ channels: INITIAL_CHANNELS });
return (
Console
{(channel) => (
)}
);
};
export default MixerConsole;
```### Empty```tsx title="components/examples/mixer-empty.tsx"
import {
Mixer,
MixerChannels,
MixerEmpty,
MixerHeader,
MixerTitle,
} from "@/components/ui/mixer";
const MixerEmptyDemo = () => (
Audio mixer
No audio sources yet. Add a microphone to get started.
);
export default MixerEmptyDemo;
```### A complete mixerThe [System Audio Mixer](/docs/blocks/system-audio-mixer) block is a working mixer built from these parts.## Behaviour- Every meter in the mixer shares `minDb`, `maxDb`, `zones` and `ballistics`, so levels compare fairly across channels.
- With focus on a control in a strip, Ctrl + arrow keys move to the same control on the neighbouring strip: left and right in a console, up and down in rows. Tab still moves through every control in order.
- `MixerChannels` scrolls along the strip axis when strips overflow; the master area stays in place.
- `MixerEmpty` appears when `MixerChannels` has no children.## API reference### Mixer| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | horizontal stacks row strips; vertical lays console strips side by side. |
| `size` | `"sm" \| "default" \| "lg"` | — | Inherited by every strip and control. |
| `minDb / maxDb` | `number` | `-60 / 0` | Shared meter range. |
| `zones` | `MeterZone[]` | `DEFAULT_ZONES` | Shared meter zones. |
| `ballistics` | `BallisticsInput` | `"peak"` | Shared meter movement. |
| `disabled` | `boolean` | `false` | — |### MixerChannels| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `scrollable` | `boolean` | `true` | Scroll when strips overflow. |
# Pan Control (/docs/components/pan-control)
```tsx title="components/examples/pan-control-demo.tsx"
import { createSignal } from "solid-js";
import { formatPan, PanControl } from "@/components/ui/pan-control";
const PanControlDemo = () => {
const [pan, setPan] = createSignal(-0.3);
return (
Pan
{formatPan(pan())}
L
R
);
};
export default PanControlDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/pan-control
```
## Usage
```tsx
import { PanControl } from "@/components/ui/pan-control";
```
```tsx
```
The value runs from −1 (left) to 1 (right). Dragging near the centre snaps to it, and double-clicking returns to it. `formatPan` turns a value into "L30", "C" or "R30", and `parsePan` reads that text back.
For a rotary pan, use a [Knob](/docs/components/knob) with `min={-1}`, `max={1}`, `origin={0}`, `format={formatPan}`, and `parse={parsePan}`:
```tsx title="components/examples/pan-control-knob.tsx"
import { createSignal } from "solid-js";
import {
Knob,
KnobDial,
KnobLabel,
KnobPointer,
KnobRange,
KnobTrack,
KnobValue,
} from "@/components/ui/knob";
import { describePan, formatPan, parsePan } from "@/components/ui/pan-control";
const PanControlKnob = () => {
const [pan, setPan] = createSignal(0);
return (
Pan
);
};
export default PanControlKnob;
```
## Accessibility
The thumb is a slider named "Pan", with `aria-valuetext` such as "30% left" or "Center". The knob example uses `describePan` for the same text on its dial, while `formatPan` keeps the visible value short. Both support arrow keys, Page Up, Page Down, Home and End.
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | −1 to 1. |
| `defaultValue` | `number` | `0` | — |
| `onValueChange` | `(value) => void` | — | — |
| `onValueCommitted` | `(value) => void` | — | — |
| `step` | `number` | `0.05` | — |
| `largeStep` | `number` | `0.25` | — |
| `detent` | `boolean` | `true` | Snap to the centre while dragging near it. |
| `format` | `(value: number) => string` | `formatPan` | — |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | — |
| `disabled` | `boolean` | `false` | — |
# Parameter Slider (/docs/components/parameter-slider)
```tsx title="components/examples/parameter-slider-demo.tsx"
import { createSignal } from "solid-js";
import {
ParameterSlider,
ParameterSliderControl,
ParameterSliderDescription,
ParameterSliderHeader,
ParameterSliderInput,
ParameterSliderLabel,
ParameterSliderReset,
} from "@/components/ui/parameter-slider";
const ParameterSliderDemo = () => {
const [offsetMs, setOffsetMs] = createSignal(0);
return (
Sync offset
Delays the microphone to line up with your camera.
);
};
export default ParameterSliderDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/parameter-slider
```
## Usage
```tsx
import {
ParameterSlider,
ParameterSliderControl,
ParameterSliderHeader,
ParameterSliderInput,
ParameterSliderLabel,
} from "@/components/ui/parameter-slider";
```
```tsx
Sync offset
```
Use it for gain trims, delays, thresholds, times and frequencies. For volume, use [Fader](/docs/components/fader).
## Anatomy
```tsx
```
## Examples
### Log scale with marks
```tsx title="components/examples/parameter-slider-frequency.tsx"
import {
ParameterSlider,
ParameterSliderControl,
ParameterSliderHeader,
ParameterSliderLabel,
ParameterSliderMarks,
ParameterSliderValue,
} from "@/components/ui/parameter-slider";
const formatHz = (hz: number) =>
hz >= 1000
? `${(hz / 1000).toFixed(hz >= 10_000 ? 0 : 1)} kHz`
: `${Math.round(hz)} Hz`;
const marks = [
{ label: "20", value: 20 },
{ label: "200", value: 200 },
{ label: "2k", value: 2000 },
{ label: "20k", value: 20_000 },
];
const ParameterSliderFrequency = () => (
High-pass filter
);
export default ParameterSliderFrequency;
```
## Theming
| Data attribute | Meaning |
| --------------- | ----------------------------------- |
| `data-modified` | The value differs from `resetValue` |
| `data-disabled` | Disabled |
## Accessibility
* The label names both the slider and the number input.
* `ParameterSliderDescription` describes the slider thumb.
* The same keys as the [fader](/docs/components/fader#keyboard) work on the thumb; the number input supports arrow keys and typing.
* Drag the unit next to the input to scrub the value.
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | — |
| `defaultValue` | `number` | — | — |
| `onValueChange` | `(value, details) => void` | — | details.reason: drag, keyboard, input or reset. |
| `onValueCommitted` | `(value) => void` | — | — |
| `min` | `number` | `0` | — |
| `max` | `number` | `100` | — |
| `step` | `number` | `1` | — |
| `largeStep` | `number` | `10` | — |
| `unit` | `string` | — | Suffix such as "ms", "dB" or "Hz". |
| `decimals` | `number` | `from step` | — |
| `scale` | `"linear" \| "log"` | `"linear"` | log suits frequency and time. |
| `origin` | `number` | `min` | Where the fill starts. |
| `resetValue` | `number` | `defaultValue ?? min` | — |
| `marks` | `{ value: number; label?: string }[]` | — | Shown by ParameterSliderMarks. |
| `format` | `(value: number) => string` | — | — |
| `disabled` | `boolean` | `false` | — |
### ParameterSliderInput
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `scrub` | `boolean` | `true` | Drag the unit to change the value. |
# Smooth Waveform (/docs/components/smooth-waveform)
```tsx title="components/examples/smooth-waveform-demo.tsx"
import { SmoothWaveform } from "@/components/ui/smooth-waveform";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const SmoothWaveformDemo = () => {
const signal = useDemoSignal({ kind: "speech" });
return (
);
};
export default SmoothWaveformDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/smooth-waveform
```
## Usage
```tsx
import { SmoothWaveform } from "@/components/ui/smooth-waveform";
```
```tsx
```
The line takes its color from the text color. It is the plain version of the [electric waveform](/docs/components/electric-waveform): the same shape, with no crackle, glow or sparks.
The waveform fills its container, so size it with classes. It paints on the animation frame without re-rendering Solid, redraws on resize and picks up theme changes on its own.
## Examples
### Wave and scope
`wave` draws a smooth wave shaped by the frequency bands, calm enough for a voice assistant. `scope` draws the signal itself, like an oscilloscope, and starts each frame on a rising zero crossing so steady tones stand still.
```tsx title="components/examples/smooth-waveform-modes.tsx"
import { For } from "solid-js";
import { SmoothWaveform } from "@/components/ui/smooth-waveform";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const modes = ["wave", "scope"] as const;
const SmoothWaveformModes = () => {
const signal = useDemoSignal({ kind: "speech", seed: 4 });
return (
);
};
export default SmoothWaveformModes;
```
### Idle and loading
With no signal the line rests flat. `loading` runs a pulse along it, for connecting or thinking states.
```tsx title="components/examples/smooth-waveform-states.tsx"
import { SmoothWaveform } from "@/components/ui/smooth-waveform";
const SmoothWaveformStates = () => (
);
export default SmoothWaveformStates;
```
### Microphone
```tsx title="components/examples/smooth-waveform-microphone.tsx"
import { Button } from "@/components/ui/button";
import { SmoothWaveform } from "@/components/ui/smooth-waveform";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import { useMicrophone } from "@/hooks/use-microphone";
const SmoothWaveformMicrophone = () => {
const microphone = useMicrophone();
const analyser = useAudioAnalyser(() => microphone.stream);
return (
microphone.status === "active"
? microphone.stop()
: void microphone.start()
}
size="sm"
variant="outline"
>
{microphone.status === "active"
? "Stop microphone"
: "Use my microphone"}
);
};
export default SmoothWaveformMicrophone;
```
## Theming
| Variable or attribute | Meaning |
| --------------------- | ---------------------------------- |
| `--waveform` | Line color, default `currentColor` |
| `data-mode` | `wave` or `scope` |
| `data-active` | The signal is above the floor |
| `data-loading` | The loading pulse is running |
## Accessibility
The root has `role="img"` and the label "Audio waveform". Pass `aria-label` to describe what it shows, or `aria-hidden` when it is decorative next to a labelled meter. With reduced motion the wave stops drifting and the line redraws four times a second.
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `FrameSource \| null` | — | — |
| `mode` | `"wave" \| "scope"` | `"wave"` | wave follows the frequency bands; scope draws the signal. |
| `loading` | `boolean` | `false` | Run a pulse along the line. |
| `sensitivity` | `number` | `1` | Visual gain. |
| `lineWidth` | `number` | `2` | Pixels. |
| `fadeEdges` | `boolean` | `true` | Fade the left and right ends. |
| `actionsRef` | `RefTarget` | — | { paint(frame), clear() }. |
# Sound Pad (/docs/components/sound-pad)
```tsx title="components/examples/sound-pad-demo.tsx"
import { MegaphoneIcon } from "@/components/icons/phosphor";
import {
SoundPad,
SoundPadIcon,
SoundPadLabel,
SoundPadProgress,
SoundPadShortcut,
} from "@/components/ui/sound-pad";
import { useSound } from "@/hooks/use-sound";
import { useDemoSounds } from "@/lib/docs/use-demo-audio";
const SoundPadDemo = () => {
const sounds = useDemoSounds();
const airhorn = useSound(() => sounds()[0]?.src ?? null);
return (
Airhorn
);
};
export default SoundPadDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/sound-pad
```
## Usage
```tsx
const airhorn = useSound("/sounds/airhorn.mp3");
Airhorn
;
```
The pad plays nothing itself. It reports triggers; [`useSound`](/docs/hooks/use-sound) or your own engine does the playback.
## Anatomy
```tsx
```
## Modes
| Mode | Behaviour |
| ---------- | --------------------------------------------- |
| `one-shot` | Every press triggers; pressing again restarts |
| `toggle` | Press to start, press again to stop |
| `hold` | Plays while held |
| `loop` | Like toggle, and marks the pad as looping |
## Examples
### A grid with hotkeys
Press 1–8 while the grid has focus. Whoosh toggles and the drum roll plays only while held.
```tsx title="components/examples/sound-pad-grid.tsx"
import { For } from "solid-js";
import {
SoundPad,
SoundPadGrid,
SoundPadLabel,
SoundPadProgress,
SoundPadShortcut,
} from "@/components/ui/sound-pad";
import type { SoundPadMode } from "@/components/ui/sound-pad";
import { useSound } from "@/hooks/use-sound";
import { useDemoSounds } from "@/lib/docs/use-demo-audio";
import type { DemoSoundSource } from "@/lib/docs/use-demo-audio";
const Pad = (props: { sound: DemoSoundSource }) => {
const mode = (): SoundPadMode =>
props.sound.id === "drumroll"
? "hold"
: props.sound.id === "whoosh"
? "toggle"
: "one-shot";
const player = useSound(
() => props.sound.src,
() => ({ loop: mode() !== "one-shot" })
);
return (
{props.sound.label}
);
};
const SoundPadGridDemo = () => {
const sounds = useDemoSounds();
return (
{(sound) => }
);
};
export default SoundPadGridDemo;
```
## Theming
| Variable or attribute | Meaning |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `--pad-accent` | Set by `accent`, default `primary` |
| `--pad-progress` | Live progress, 0..1 |
| `--pad-min-width` | Narrowest pad before the grid drops a column, default `5.5rem`. Set on `SoundPadGrid` |
| `--pad-gap` | Gap between pads, default `0.5rem`. Set on `SoundPadGrid` |
| `data-playing`, `data-pressed`, `data-mode`, `data-loading` | State |
## Accessibility
A pad is a real ``. In toggle and loop modes it has `aria-pressed`. The grid moves focus between pads with the arrow keys, and hotkeys are announced with `aria-keyshortcuts`. Global hotkeys never fire while someone is typing in a field.
## API reference
### SoundPad
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onTrigger` | `() => void` | — | — |
| `onStop` | `() => void` | — | — |
| `playing` | `boolean` | `false` | — |
| `mode` | `"one-shot" \| "toggle" \| "hold" \| "loop"` | `"one-shot"` | — |
| `hotkey` | `string` | — | Active when the grid enables hotkeys. |
| `loading` | `boolean` | `false` | The sound is not ready yet. |
| `accent` | `string` | — | A CSS colour. |
| `variant` | `"default" \| "outline" \| "ghost"` | `"default"` | — |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | — |
| `disabled` | `boolean` | `false` | — |
### SoundPadGrid
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `columns` | `number` | `4` | The most columns. The grid drops columns to keep pads at least `--pad-min-width` wide; a `grid-cols-*` class fixes the count. |
| `hotkeys` | `boolean` | `false` | Listen for pad hotkeys. |
| `hotkeyScope` | `"focus" \| "global"` | `"focus"` | global listens on the whole page. |
### SoundPadProgress
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | 0..1, declarative. |
| `source` | `FrameSource \| null` | — | Smooth progress with no renders. |
| `variant` | `"bar" \| "fill" \| "ring"` | `"bar"` | — |
# Spectrum (/docs/components/spectrum)
```tsx title="components/examples/spectrum-demo.tsx"
import { Spectrum } from "@/components/ui/spectrum";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const SpectrumDemo = () => {
const signal = useDemoSignal({ bands: 48, kind: "music" });
return (
);
};
export default SpectrumDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/spectrum
```
## Usage
```tsx
import { Spectrum } from "@/components/ui/spectrum";
```
```tsx
const analyser = useAudioAnalyser(() => stream(), { bands: 64 });
;
```
The spectrum draws the frequency bands of a visual frame. Set `minHz`, `maxHz`, `minDb` and `maxDb` to match the analyser that produced them, so the axes are labelled correctly. The defaults match `useAudioAnalyser`.
## Anatomy
```tsx
```
## Examples
### Line and area
```tsx title="components/examples/spectrum-variants.tsx"
import { Spectrum } from "@/components/ui/spectrum";
import { useDemoSignal } from "@/hooks/use-demo-signal";
const SpectrumVariants = () => {
const signal = useDemoSignal({ bands: 64, kind: "speech" });
return (
);
};
export default SpectrumVariants;
```
### Microphone
```tsx title="components/examples/spectrum-microphone.tsx"
import { Button } from "@/components/ui/button";
import { Spectrum } from "@/components/ui/spectrum";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import { useMicrophone } from "@/hooks/use-microphone";
const SpectrumMicrophone = () => {
const microphone = useMicrophone();
const analyser = useAudioAnalyser(() => microphone.stream, {
bands: 64,
fftSize: 4096,
});
return (
microphone.status === "active"
? microphone.stop()
: void microphone.start()
}
size="sm"
variant="outline"
>
{microphone.status === "active"
? "Stop microphone"
: "Use my microphone"}
);
};
export default SpectrumMicrophone;
```
## Theming
| Variable | Default |
| ----------------- | ------------ |
| `--spectrum` | `primary` |
| `--spectrum-peak` | `foreground` |
| `--spectrum-grid` | `border` |
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `FrameSource \| null` | — | — |
| `variant` | `"bars" \| "line" \| "area"` | `"bars"` | — |
| `minDb / maxDb` | `number` | `-100 / -30` | The level range the bands cover. |
| `minHz / maxHz` | `number` | `40 / 16000` | The frequency range the bands cover. |
| `scale` | `"log" \| "linear"` | `"log"` | Frequency axis law. |
| `peakHold` | `boolean` | `false` | Falling peak markers. |
| `grid` | `boolean` | `true` | — |
# Track List (/docs/components/track-list)
```tsx title="components/examples/track-list-demo.tsx"
import { For, createSignal } from "solid-js";
import { DotsThreeIcon } from "@/components/icons/phosphor";
import { Button } from "@/components/ui/button";
import {
TrackList,
TrackListItem,
TrackListItemActions,
TrackListItemContent,
TrackListItemDescription,
TrackListItemDuration,
TrackListItemIndex,
TrackListItemTitle,
} from "@/components/ui/track-list";
import { formatTime } from "@/lib/audio/time";
const tracks = [
{
artist: "audiocn",
duration: 19.2,
id: "night-drive",
title: "Night Drive",
},
{ artist: "audiocn", duration: 24, id: "low-tide", title: "Low Tide" },
{ artist: "audiocn", duration: 13.7, id: "arcade", title: "Arcade" },
{ artist: "Not available", duration: 201, id: "locked", title: "Unreleased" },
];
const TrackListDemo = () => {
const [current, setCurrent] = createSignal("low-tide");
const [playing, setPlaying] = createSignal(true);
return (
{(track, index) => (
{
setPlaying(track.id === current() ? !playing() : true);
setCurrent(track.id);
}}
playing={track.id === current() && playing()}
>
{index() + 1}
{track.title}
{track.artist}
{formatTime(track.duration)}
)}
);
};
export default TrackListDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/track-list
```
## Usage
```tsx
{(track, index) => (
play(track)}
playing={track.id === current() && playing()}
>
{index() + 1}
{track.title}
)}
```
`TrackListItemIndex` shows its children, or a small animated equaliser while the item is playing.
## Anatomy
```tsx
```
## Accessibility
* Up and Down move between items; Home and End jump; Enter or Space selects.
* The current item has `aria-current`.
* Buttons inside `TrackListItemActions` stay reachable with Tab and do not select the item.
Reordering is a recipe with your drag-and-drop library of choice, not built in.
## API reference
### TrackList
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | Row height. |
| `variant` | `"default" \| "outline"` | `"default"` | — |
### TrackListItem
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `active` | `boolean` | `false` | The current track. |
| `playing` | `boolean` | `false` | The current track, playing. |
| `disabled` | `boolean` | `false` | — |
| `onSelect` | `() => void` | — | Click, Enter or Space. |
| `render` | `(props, state) => JSX.Element` | `` | A Solid render callback. |
# Volume Control (/docs/components/volume-control)
```tsx title="components/examples/volume-control-demo.tsx"
import {
SpeakerHighIcon,
SpeakerLowIcon,
SpeakerNoneIcon,
SpeakerXIcon,
} from "@/components/icons/phosphor";
import {
VolumeControl,
VolumeControlMute,
VolumeControlSlider,
VolumeControlValue,
} from "@/components/ui/volume-control";
const VolumeControlDemo = () => (
);
export default VolumeControlDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/volume-control
```
## Usage
```tsx
import { VolumeControl } from "@/components/ui/volume-control";
```
```tsx
```
Unlike the mixer components, the volume here runs from 0 to 1, the same as `HTMLMediaElement.volume`. The `perceptual` curve gives the slider more travel at low volumes, where ears are most sensitive. Unmuting at zero restores the last audible volume.
## Anatomy
```tsx
```
## Examples
### In a popover
```tsx title="components/examples/volume-control-popover.tsx"
import { Show, createSignal } from "solid-js";
import { SpeakerHighIcon, SpeakerXIcon } from "@/components/icons/phosphor";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
VolumeControl,
VolumeControlMute,
VolumeControlSlider,
} from "@/components/ui/volume-control";
const VolumeControlPopover = () => {
const [volume, setVolume] = createSignal(0.8);
const [muted, setMuted] = createSignal(false);
return (
}>
}>
);
};
export default VolumeControlPopover;
```
## Theming
The mute button has `data-level` of `muted`, `low`, `medium` or `high`, so icons can switch with CSS alone, as in the first example.
## API reference
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | 0..1. |
| `defaultValue` | `number` | `1` | — |
| `onValueChange` | `(value) => void` | — | — |
| `onValueCommitted` | `(value) => void` | — | — |
| `muted` | `boolean` | — | — |
| `defaultMuted` | `boolean` | `false` | — |
| `onMutedChange` | `(muted) => void` | — | — |
| `step` | `number` | `0.05` | Slider step. |
| `curve` | `"linear" \| "perceptual"` | `"perceptual"` | How slider position maps to volume. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | — |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | — |
| `disabled` | `boolean` | `false` | — |
# Waveform (/docs/components/waveform)
```tsx title="components/examples/waveform-demo.tsx"
import { Button } from "@/components/ui/button";
import {
Waveform,
WaveformCanvas,
WaveformCursor,
WaveformHover,
} from "@/components/ui/waveform";
import { useAudioPlayer } from "@/hooks/use-audio-player";
import { useWaveformData } from "@/hooks/use-waveform-data";
import { useDemoTracks } from "@/lib/docs/use-demo-audio";
const WaveformDemo = () => {
const tracks = useDemoTracks();
const track = () => tracks()[0];
const player = useAudioPlayer(() => ({ src: track()?.src }));
const waveform = useWaveformData(() => track()?.src ?? null);
return (
void player.toggle()}
size="sm"
variant="outline"
>
{player.playing ? "Pause" : "Play"}
);
};
export default WaveformDemo;
```
## Installation
```bash
npx shadcn@latest add @audiocn-solid/waveform
```
## Usage
```tsx
import { Waveform } from "@/components/ui/waveform";
```
```tsx
const player = useAudioPlayer(() => ({ src: src() }));
const waveform = useWaveformData(src);
;
```
Pass `time` (a frame source) for a playhead that moves every frame without re-rendering Solid, or `currentTime` for plain controlled use.
## Anatomy
```tsx
```
## Examples
### Variants
```tsx title="components/examples/waveform-variants.tsx"
import { For } from "solid-js";
import { Waveform } from "@/components/ui/waveform";
const peaks = Float32Array.from({ length: 240 }, (_, index) => {
const envelope = Math.sin((index / 240) * Math.PI) ** 0.6;
return (
envelope *
(0.55 + 0.45 * Math.abs(Math.sin(index * 0.37) * Math.cos(index * 0.11)))
);
});
const variants = ["bars", "mirror", "line"] as const;
const WaveformVariants = () => (
{(variant) => (
{variant}
)}
);
export default WaveformVariants;
```
### Regions and markers
```tsx title="components/examples/waveform-regions.tsx"
import { createSignal } from "solid-js";
import {
Waveform,
WaveformCanvas,
WaveformCursor,
WaveformMarker,
WaveformRegion,
} from "@/components/ui/waveform";
import { formatTime } from "@/lib/audio/time";
const peaks = Float32Array.from({ length: 320 }, (_, index) =>
Math.min(
1,
0.2 + Math.abs(Math.sin(index * 0.21)) * 0.6 + (index % 40 < 4 ? 0.3 : 0)
)
);
const WaveformRegions = () => {
const [region, setRegion] = createSignal({ end: 22, start: 9 });
return (
Intro ends
Clip {formatTime(region().start)} – {formatTime(region().end)}
);
};
export default WaveformRegions;
```
## Theming
| Variable or attribute | Meaning |
| ------------------------------------------------ | ------------------------------------------- |
| `--waveform` | Unplayed colour, default `muted-foreground` |
| `--waveform-progress` | Played colour, default `primary` |
| `--waveform-cursor` | Playhead colour |
| `--waveform-position` | Live playhead position, 0..1 |
| `data-loading`, `data-dragging`, `data-disabled` | State |
## Accessibility
When interactive, the waveform is a slider with `aria-valuetext` such as "1:24 of 3:40". Arrow keys seek by `step`, Shift by `largeStep`, and Home and End jump to the ends. Region edges are separate sliders.
## API reference
### Waveform
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `peaks` | `ArrayLike \| null` | — | 0..1, from useWaveformData. |
| `duration` | `number` | — | Seconds. |
| `currentTime` | `number` | — | Controlled playhead in seconds. |
| `defaultCurrentTime` | `number` | `0` | — |
| `time` | `FrameSource \| null` | — | A smooth playhead with no renders. |
| `onSeek` | `(time) => void` | — | While seeking. |
| `onSeekCommitted` | `(time) => void` | — | On release or after keyboard seeking. |
| `step / largeStep` | `number` | `5 / 15` | Seconds for the keyboard. |
| `variant` | `"bars" \| "line" \| "mirror"` | `"bars"` | — |
| `barWidth / barGap / barRadius` | `number` | `2 / 1 / 1` | Pixels. |
| `interactive` | `boolean` | `true` | false is display only. |
| `loading` | `boolean` | `false` | Shows a skeleton. |
| `disabled` | `boolean` | `false` | — |
### WaveformRegion
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `start / end` | `number` | — | Seconds. |
| `onValueChange` | `({ start, end }) => void` | — | — |
| `resizable` | `boolean` | `true` | Drag the edges. |
| `draggable` | `boolean` | `true` | Drag the whole region. |
| `minLength` | `number` | `0.1` | Seconds. |
### WaveformMarker
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `time` | `number` | — | Seconds. Children become the label. |
# Accessibility (/docs/concepts/accessibility)
## Meters
* Level meters have `role="meter"` and report their value in dB, such as "−12.0 dB". Screen readers get updates at most four times a second, so they are not flooded.
* Give every meter a name with `aria-label` or `aria-labelledby`.
* Visualizers have `role="img"`. Mark decorative ones `aria-hidden` when a labelled meter sits next to them.
* A clip indicator announces "Clipping" politely, once per clip.
## Controls
Faders, parameter sliders, knobs, pan controls and seek bars are sliders with a readable `aria-valuetext`: "−6.0 dB", "Silent", "30% left", "1:24 of 3:40". They share one keyboard map:
| Key | Action |
| ---------------------------------- | ----------------- |
| Arrow keys | One step |
| Shift + arrow, Page Up / Page Down | A large step |
| Alt + arrow | A fine step |
| Home / End | Minimum / maximum |
Double-clicking resets a fader, knob or pan control; `FaderReset` and `ParameterSliderReset` give the same action a keyboard path.
Mute, solo and monitor toggles are buttons with `aria-pressed`.
## Mixers and lists
* Strips are named groups. Ctrl + arrow keys move focus to the same control on the next strip; Tab still reaches everything in order.
* Track lists and sound pad grids move focus with the arrow keys.
* Sound pad hotkeys are announced with `aria-keyshortcuts` and never fire while someone is typing in a field.
## Colour is never the only signal
Muted, soloed and clipping states also show as pressed toggles, labels or shapes. Zone colours sit on top of a level you can read without them.
## Reduced motion
With `prefers-reduced-motion`, meters and visualizers stop animating and show the current level four times a second. Idle animations and sweeps turn off.
# Using your own audio engine (/docs/concepts/custom-engine)
The Web Audio hooks are optional. If your audio runs somewhere else, such as a Rust or C++ engine in a desktop app, a server, or an `AudioWorklet`, use the components and the audio core on their own.
## Levels from anywhere
Wrap your data in a frame source with `createFrameEmitter`:
```ts
import { createFrameEmitter } from "@/lib/audio/frame-source";
import type { MeterFrame } from "@/lib/audio/types";
export const micLevels = createFrameEmitter();
backend.on("levels", ({ peakDb, rmsDb }) => {
micLevels.emit({ channels: [{ peakDb, rmsDb }] });
});
```
```tsx
```
Meters apply their own ballistics, so any update rate works: at 30 updates a second or more they look like a hardware meter, and at one a second they still move smoothly.
## Controls to anywhere
Controls report values; they never touch audio. Send changes to your engine:
```tsx
backend.setGain("mic", gainDb)}
onValueCommitted={(gainDb) => settings.save({ micGainDb: gainDb })}
value={micGainDb}
/>
```
Use `onValueChange` for live updates and `onValueCommitted` for anything slow, such as saving settings.
## Mixer state without Web Audio
[`useMixer`](/docs/hooks/use-mixer) holds gain, mute, solo, pan and monitor for every channel and knows which channels are audible. Send its state to your engine:
```tsx
const mixer = useMixer({
channels,
onStateChange: (state) => backend.applyMixer(state),
});
```
## Devices from anywhere
[`AudioDeviceSelect`](/docs/components/audio-device-select) takes its devices as a prop, including their status, so a native device list works the same as the browser's.
## Swapping sources
If the audio behind a meter restarts, use a relay so components keep their subscription:
```ts
import { createFrameRelay } from "@/lib/audio/frame-source";
const relay = createFrameRelay();
relay.setSource(currentSession.levels);
```
# Decibels and levels (/docs/concepts/decibels)
Audio levels span a huge range, so audio tools measure them in decibels (dB), a logarithmic scale. audiocn uses dB wherever audio engineers would.
## dBFS
**dBFS** means decibels relative to full scale. 0 dBFS is the loudest a digital signal can be; everything else is negative. Silence is `-Infinity`.
| Level | What it sounds like |
| --------------- | ----------------------------------------------- |
| 0 dBFS | Full scale. Anything louder clips and distorts. |
| −6 dBFS | About half the amplitude |
| −18 to −12 dBFS | A good level for speech |
| −60 dBFS | The bottom of a typical meter |
| `-Infinity` | Digital silence |
Meters show dBFS. By default they cover −60 to 0 dBFS and colour three zones: green below −20, amber from −20, and red from −9.
## dB as gain
A fader's value is **gain** in dB: how much a channel is raised or lowered. 0 dB leaves the signal unchanged, −6 dB halves the amplitude, and +6 dB doubles it. Faders can reach `-Infinity` to silence a channel completely.
## Peak and RMS
* **Peak** is the loudest single sample. It tells you whether you are about to clip.
* **RMS** is the average power. It is closer to how loud something sounds.
Speech usually peaks 10–15 dB above its RMS. A dual meter shows both.
## Ballistics
A meter that showed every sample would flicker too fast to read. Ballistics decide how it moves:
| Preset | Attack | Release | Peak hold |
| --------- | ------ | ---------------------------- | --------- |
| `peak` | 15 ms | falls about 25 dB per second | 1.2 s |
| `vu` | 300 ms | 300 ms | none |
| `instant` | none | none | none |
## The audio core
The audio core, installed as `lib/audio`, has the maths every component uses:
```ts
import { dbToGain, dbToLevel, formatDb, gainToDb } from "@/lib/audio/decibels";
dbToGain(-6); // 0.501
gainToDb(0.5); // -6.02
dbToLevel(-30); // 0.5, linear in dB over −60..0
formatDb(-12.34); // "−12.3 dB"
formatDb(-Infinity); // "−∞ dB"
```
| Module | What it has |
| -------------- | ------------------------------------------------------------------------------------------ |
| `decibels` | `dbToGain`, `gainToDb`, `dbToLevel`, `levelToDb`, `clampDb`, `formatDb`, `peakDb`, `rmsDb` |
| `ballistics` | `createBallistics`, `BALLISTICS`, `resolveBallistics` |
| `zones` | `DEFAULT_ZONES`, `zoneForDb`, `CLIP_THRESHOLD_DB`, `CLIP_HOLD_MS` |
| `taper` | `linearTaper`, `audioTaper`, `logTaper`, `resolveTaper` |
| `bands` | `logBandEdges`, `bandsFromSpectrum`, `resampleLevels` |
| `time` | `formatTime` |
| `frame-loop` | `subscribeFrame`, one animation-frame loop for the page |
| `frame-source` | `createFrameEmitter`, `createFrameRelay` |
| `types` | `MeterFrame`, `VisualFrame`, `FrameSource`, `MeterZone`, `Taper` |
## Volume in players
Players are the one exception. `VolumeControl` and `AudioPlayer` use a 0..1 volume, the same as `HTMLMediaElement.volume`, because that is what media elements expect.
# Feeding data (/docs/concepts/feeding-data)
audiocn components never open a microphone or connect to Web Audio themselves. They receive levels, and they accept them three ways.
## 1. A source
A **frame source** is anything with a `subscribe` method:
```ts
interface FrameSource {
subscribe: (callback: (frame: T) => void) => () => void;
}
```
Pass one as `source` and the component subscribes and paints itself on every animation frame, without invalidating Solid reactive state for every audio frame. This is the fastest way, and the one to use for live audio. A meter that has settled, has gone quiet or is off screen stops requesting frames, and the next frame or value wakes it, so an idle mixer costs nothing.
```tsx
const analyser = useAudioAnalyser(stream);
;
;
```
The hooks in this library return sources, and so can your own code:
```ts
import { createFrameEmitter } from "@/lib/audio/frame-source";
const levels = createFrameEmitter();
socket.on("levels", (frame) => levels.emit(frame));
;
```
## 2. Values
For data that arrives slowly, such as once a second from a server, pass plain values. The meter's ballistics still move it smoothly between updates.
```tsx
;
;
```
## 3. A handle
If you already run your own frame loop, paint through `actionsRef`:
```tsx
const meter = { current: null as LevelMeterActions | null };
onFrame((levels) => {
meter.current?.paint({ channels: levels });
});
;
```
## Frames
```ts
interface MeterFrame {
channels: { peakDb: number; rmsDb?: number }[];
}
interface VisualFrame {
bands: Float32Array; // 0..1 per frequency band
history: Float32Array; // ring of recent levels, 0..1
historyStart: number;
historyLength: number;
historyUpdatedAt?: number; // newest history entry's time, in ms
historyIntervalMs?: number; // minimum wait between history entries
historyPreviousLevel?: number; // last entry removed from a full ring
timeDomain?: Float32Array; // −1..1 samples
peakDb: number;
}
```
Sources reuse their frame objects to avoid allocating on every frame. Copy a frame if you need to keep it.
For smooth `LiveWaveform` scrolling, set `historyUpdatedAt` and a positive `historyIntervalMs`. Use the same clock as `requestAnimationFrame` or `performance.now()`. Set the timestamp when you add a history entry, and set `historyIntervalMs` to the minimum wait between entries. The drawing keeps a short buffer of history snapshots and uses their timestamps to follow the actual sample cadence. Playback runs twice that interval behind the source, 100 ms with the default settings. Set `historyPreviousLevel` to the last value removed from a full ring so it can scroll out of view.
The built-in demo and analyser sources set these fields for you. After a pause or throttled frames, they append only the new sample and restart the history clock. They do not fill missed intervals with stale levels. Custom sources that omit the timing fields still work, but their history moves one step per update.
If your source uses the shared `subscribeFrame` loop from `@/lib/audio/frame-loop`, pass `"update"` as its second argument. The loop runs source updates before canvas drawing, even when you connect a source after mounting a component.
## In Solid state
When you need a level in Solid, for a label or a warning, use [`useLevel`](/docs/hooks/use-level). It samples a source a few times a second.
# Theming (/docs/concepts/theming)
## Your theme applies
Components use the standard shadcn tokens: `background`, `foreground`, `muted`, `primary`, `border`, `ring`, `destructive`. Any shadcn theme or preset restyles audiocn with no extra work.
## Audio tokens
Six tokens cover meanings shadcn has no token for. The CLI adds them to your CSS file when you install your first component:
| Token | Default | Used for |
| ------------------- | -------------------- | ------------------------ |
| `--meter-ok` | green | Normal level zone |
| `--meter-warn` | amber | Warning zone |
| `--meter-clip` | `var(--destructive)` | Clip zone and clip light |
| `--channel-mute` | `var(--destructive)` | Pressed mute toggle |
| `--channel-solo` | amber | Pressed solo toggle |
| `--channel-monitor` | blue | Pressed monitor toggle |
Each accent also has a `-foreground` token for text: `--meter-ok-foreground`, `--meter-warn-foreground`, `--meter-clip-foreground`, `--channel-mute-foreground`, `--channel-solo-foreground`, and `--channel-monitor-foreground`. In light mode these are darker than the meter accents; in dark mode they follow the accent. This keeps small labels readable without dimming the meters.
They are also Tailwind colours: `bg-meter-ok`, `text-channel-solo-foreground` and so on. Change them the same way as any shadcn token:
```css title="app/globals.css"
:root {
--meter-ok: oklch(0.72 0.12 200);
}
.dark {
--meter-ok: oklch(0.78 0.12 200);
}
```
## Component variables
Geometry and drawing colours are CSS variables on each component, so a class can change them:
```tsx
```
Canvas components read their colours when the theme changes, so switching between light and dark repaints them without a remount.
## Data attributes
State is exposed as data attributes, so you can style it with Tailwind:
```tsx
```
Each component page lists its variables and attributes.
## Live values in CSS
Meters write their live level to `--meter-level` (0..1) on every channel, so you can build a visual in CSS alone. See [Build your own visual](/docs/components/level-meter#build-your-own-visual).
# useAudioAnalyser (/docs/hooks/use-audio-analyser)
`useAudioAnalyser` connects any audio to audiocn's meters and visualizers. It measures peak and RMS per channel and reduces the spectrum to frequency bands, and only runs while a component is subscribed.
```bash
npx shadcn@latest add @audiocn-solid/use-audio-analyser
```
## Usage
```tsx
const microphone = useMicrophone({ enabled: true });
const analyser = useAudioAnalyser(microphone.stream, { channels: "stereo" });
return (
<>
>
);
```
> **Media elements**
>
> A media element can only be connected to Web Audio once. The hook remembers
> the connection, routes the element to the speakers through the context, and
> reuses it for every analyser.
## Options
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `input` | `MediaStream \| HTMLMediaElement \| AudioNode \| null` | — | What to analyse. |
| `fftSize` | `number` | `2048` | Analyser FFT size. |
| `smoothing` | `number` | `0.3` | Analyser smoothing constant, 0..1. |
| `bands` | `number` | `32` | Frequency bands per visual frame. |
| `minHz / maxHz` | `number` | `40 / 16000` | Band range, spaced on a log scale. |
| `historySize` | `number` | `60` | Entries in the level history ring. |
| `historyIntervalMs` | `number` | `50` | Time between history entries. |
| `intervalMs` | `number` | `0` | Minimum time between frames. 0 sends one every animation frame. |
| `channels` | `"mono" \| "stereo"` | `"mono"` | stereo measures left and right separately. |
| `enabled` | `boolean` | `true` | Pause analysis without tearing it down. |
## Returns
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `meter` | `FrameSource` | — | Peak and RMS per channel. |
| `visual` | `FrameSource` | — | Bands, history, time-domain samples and peak. |
| `status` | `"idle" \| "running" \| "suspended"` | — | idle with no input; suspended until the context resumes. |
Frames are reused between updates to avoid allocating. Copy a frame if you need to keep it.
# useAudioContext (/docs/hooks/use-audio-context)
Browsers only let audio start after the user interacts with the page. Every audiocn Web Audio hook shares one `AudioContext`, and this hook resumes it on the first click, tap or key press.
On browsers that expose `navigator.audioSession` (including Safari on iOS 16.4 and later), the first gesture or `resume()` call changes its default `auto` type to `playback`, even if the context is already running. This requests playback routing so Web Audio can play with iPhone Silent Mode enabled. Browsers without this API, or those that reject the setting, keep their native behavior and may still require Silent Mode to be off.
The setting affects all audio on the page and can pause other playback on the device or change microphone routing. audiocn leaves explicitly selected types, including `ambient` and `play-and-record`, unchanged. Apps that need mixing or microphone capture should select their own session type before interacting with audio controls.
```bash
npx shadcn@latest add @audiocn-solid/use-audio-context
```
## Usage
```tsx
const { context, status, resume } = useAudioContext();
if (status === "suspended") {
return Enable audio ;
}
```
## Your own context
Wrap your app in `AudioContextProvider` to make every hook use a context you created, for example one with a specific sample rate. `destination` routes Knob click sounds through a node in your audio graph instead of the speakers.
```tsx
const context = new AudioContext({ sampleRate: 48_000 });
const effectsBus = context.createGain();
effectsBus.connect(context.destination);
;
```
## Returns
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `context` | `AudioContext \| null` | — | Null during server rendering and in browsers without Web Audio. |
| `status` | `"suspended" \| "running" \| "interrupted" \| "closed" \| "unsupported"` | — | The context state. |
| `resume` | `() => Promise` | — | Select playback from the default audio session type and resume a suspended or interrupted context. Call it from a user gesture. |
# useAudioDevices (/docs/hooks/use-audio-devices)
```bash
npx shadcn@latest add @audiocn-solid/use-audio-devices
```
## Usage
```tsx
const audio = useAudioDevices();
return (
Allow microphone access
}
>
);
```
Browsers hide device names until the page has microphone access. Until then devices are labelled "Microphone 1", "Microphone 2" and so on.
## Options
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `kind` | `"audioinput" \| "audiooutput"` | `"audioinput"` | Which devices to list. |
## Returns
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `devices` | `AudioDeviceInfo[]` | — | { id, label, kind, groupId, isDefault }. Updates when devices are plugged in or removed. |
| `permission` | `"granted" \| "prompt" \| "denied" \| "unsupported"` | — | Microphone permission. |
| `isLoading` | `boolean` | — | True until the first list arrives. |
| `error` | `Error \| null` | — | The last error. |
| `refresh` | `() => Promise` | — | List devices again. |
| `requestPermission` | `() => Promise` | — | Ask for microphone access so labels become readable. |
# useAudioPlayer (/docs/hooks/use-audio-player)
```bash
npx shadcn@latest add @audiocn-solid/use-audio-player
```
## Usage
```tsx
const player = useAudioPlayer({ src: "/episode.mp3" });
void player.toggle()}>
{player.playing ? "Pause" : "Play"}
;
;
```
The hook creates and owns an `` element. Pass `player.element` to [`useAudioAnalyser`](/docs/hooks/use-audio-analyser) or [`useWebAudioMixer`](/docs/hooks/use-web-audio-mixer), and `player` to [`AudioPlayer`](/docs/components/audio-player).
Options accept getters or an accessor. Keep the controller intact so its reactive fields stay live:
```tsx
const player = useAudioPlayer(() => ({ src: selectedTrack().src }));
```
## Options
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `src` | `string` | — | — |
| `autoPlay / loop / muted` | `boolean` | `false` | — |
| `volume` | `number` | `1` | 0..1. |
| `playbackRate` | `number` | `1` | — |
| `preload` | `"none" \| "metadata" \| "auto"` | `"metadata"` | — |
| `crossOrigin` | `"anonymous" \| "use-credentials"` | — | Needed to analyse audio from another origin. |
| `onPlay / onPause / onEnded` | `() => void` | — | — |
| `onError` | `(error) => void` | — | — |
## Returns
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `element` | `HTMLAudioElement \| null` | — | — |
| `status` | `"idle" \| "loading" \| "ready" \| "playing" \| "paused" \| "ended" \| "error"` | — | — |
| `playing` | `boolean` | — | — |
| `currentTime` | `number` | — | Updates about four times a second. |
| `time` | `FrameSource` | — | The time on every animation frame while playing. |
| `duration / buffered` | `number` | — | Seconds. |
| `volume / muted / playbackRate / loop` | `number \| boolean` | — | Current settings. |
| `play / pause / toggle / seek` | `() => void \| (seconds) => void` | — | toggle and play return a promise. |
| `setVolume / setMuted / setPlaybackRate / setLoop` | `(value) => void` | — | — |
# useClipHold (/docs/hooks/use-clip-hold)
`useClipHold` powers the [clip indicator](/docs/components/clip-indicator). Feed it every level reading; it tells you when the signal clipped and keeps that state for a while so people can see it.
```bash
npx shadcn@latest add @audiocn-solid/use-clip-hold
```
## Usage
```tsx
const clip = useClipHold({ holdMs: 2000 });
useFrameSource(analyser.meter, (frame) => {
clip.report(frame.channels[0]?.peakDb ?? -Infinity);
});
return clip.clipping ? Clipping ({clip.count}) : null;
```
## Options
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `thresholdDb` | `number` | `-1` | Levels at or above this count as a clip. |
| `holdMs` | `number` | `1500` | How long the clip state holds. Infinity latches until reset(). |
| `onClippingChange` | `(clipping: boolean) => void` | — | Called when the state changes. |
## Returns
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `clipping` | `boolean` | — | The clip state, including the hold. |
| `count` | `number` | — | Separate clips since mount or the last reset. A long clip counts once. |
| `report` | `(db: number) => void` | — | Feed a level reading. Cheap to call every frame. |
| `reset` | `() => void` | — | Clear the state and the count. |
# useDemoSignal (/docs/hooks/use-demo-signal)
`useDemoSignal` makes meters and visualizers move without a microphone. Every preview on this site runs on it. The signal is repeatable for a given `seed`, and it only runs while something is subscribed.
```bash
npx shadcn@latest add @audiocn-solid/use-demo-signal
```
## Usage
```tsx
import { LevelMeter } from "@/components/ui/level-meter";
import { useDemoSignal } from "@/hooks/use-demo-signal";
export const Preview = () => {
const signal = useDemoSignal({ kind: "music", channels: 2 });
return ;
};
```
```tsx title="components/examples/frame-source-demo.tsx"
import { Button } from "@/components/ui/button";
import { useAudioAnalyser } from "@/hooks/use-audio-analyser";
import { useDemoSignal } from "@/hooks/use-demo-signal";
import { useLevel } from "@/hooks/use-level";
import { useMicrophone } from "@/hooks/use-microphone";
import { formatDb } from "@/lib/audio/decibels";
import type { FrameSource, MeterFrame } from "@/lib/audio/types";
interface LevelRowProps {
label: string;
source: FrameSource;
}
const LevelRow = (props: LevelRowProps) => {
const level = useLevel(() => props.source, { intervalMs: 100 });
return (
{props.label}
peak {formatDb(level.peakDb, { floorDb: -90 })}
rms{" "}
{formatDb(level.rmsDb ?? Number.NEGATIVE_INFINITY, { floorDb: -90 })}
{level.zone}
);
};
const FrameSourceDemo = () => {
const demo = useDemoSignal({ kind: "speech" });
const microphone = useMicrophone();
const analyser = useAudioAnalyser(() => microphone.stream);
return (
microphone.status === "active"
? microphone.stop()
: void microphone.start()
}
size="sm"
variant="outline"
>
{microphone.status === "active"
? "Stop microphone"
: "Use my microphone"}
Microphone: {microphone.status}
);
};
export default FrameSourceDemo;
```
Pass a fader's value as `gainDb` to meter the signal after gain. Set `playing: false` to mute it. Gain changes preserve the signal's timing and subscriptions, and boosted readings can exceed 0 dBFS.
## Options
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `kind` | `"speech" \| "music" \| "tone" \| "noise" \| "silence"` | `"speech"` | What the signal sounds like. |
| `channels` | `number` | `1` | 1 for mono, 2 for stereo. |
| `gainDb` | `number` | `0` | Gain applied to meter and visual frames. -Infinity silences the signal. |
| `seed` | `number` | `1` | Changes the pattern while keeping it repeatable. |
| `bands` | `number` | `32` | Frequency bands per visual frame. |
| `historySize` | `number` | `60` | Entries in the level history ring. |
| `historyIntervalMs` | `number` | `50` | Time between history entries. |
| `playing` | `boolean` | `true` | When false the signal falls silent, so meters fall. |
## Returns
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `meter` | `FrameSource` | — | Peak and RMS per channel, every animation frame. |
| `visual` | `FrameSource` | — | Bands, level history and time-domain samples. |
| `configure` | `(options) => void` | — | Change options without creating a new signal. |
Outside Solid, `createDemoSignal(options)` returns the same object.
# useFrameSource (/docs/hooks/use-frame-source)
Every audiocn meter and visualizer uses this hook for its `source` prop. Use it to build your own components on any frame source.
```bash
npx shadcn@latest add @audiocn-solid/use-frame-source
```
## Usage
```tsx
import { useFrameSource } from "@/hooks/use-frame-source";
import { dbToLevel } from "@/lib/audio/decibels";
import type { FrameSource, MeterFrame } from "@/lib/audio/types";
export const TinyMeter = ({ source }: { source: FrameSource }) => {
let barRef!: HTMLDivElement;
useFrameSource(source, (frame) => {
const level = dbToLevel(frame.channels[0]?.peakDb ?? -Infinity);
barRef.style.setProperty("--level", String(level));
});
return (
);
};
```
The callback runs outside Solid rendering, so it can update the DOM on every frame without re-rendering.
## API
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `source` | `FrameSource \| null \| undefined` | — | The source to subscribe to. Changing it resubscribes. |
| `onFrame` | `(frame: T) => void` | — | Called for every frame. May change on every render. |
| `options.enabled` | `boolean` | `true` | Pause the subscription without unmounting. |
# useGainNode (/docs/hooks/use-gain-node)
The blocks use this hook for every level control and bus: the microphone gain in Mic Setup, the soundboard's master bus, and the output of `useSound`.
```bash
npx shadcn@latest add @audiocn-solid/use-gain-node
```
## Usage
```tsx
import { useGainNode } from "@/hooks/use-gain-node";
import { useMicrophone } from "@/hooks/use-microphone";
import { dbToGain } from "@/lib/audio/decibels";
export const MicMonitor = (props: { gainDb: number }) => {
const microphone = useMicrophone({ enabled: true });
// The microphone, through a gain stage, to the speakers.
useGainNode(() => ({
gain: dbToGain(props.gainDb),
input: microphone.stream,
}));
return null;
};
```
## Routing
* `input` takes a `MediaStream`, which the hook wraps in a source node, or any `AudioNode`. Leave it out to feed the node yourself.
* `destination` is where the node plays. Leave it out for the speakers, pass `null` to route it yourself, or pass a node such as a mixer input or a `MediaStreamAudioDestinationNode`.
* The hook returns the node, so you can connect more to it, or pass it to `useAudioAnalyser` to meter the level after gain.
## Levels
`gain` is linear: use `dbToGain` from `@/lib/audio/decibels` for decibels. Changes ramp with a short time constant (`timeConstant`, default 10 ms), so they never click. A new node starts at its gain instead of ramping up from unity, so a muted node is never heard as it starts.
Each connection lives in an effect and is undone on cleanup. Reactive option changes disconnect the previous edge; disposing the Solid owner releases every connection the hook owns.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `input` | `MediaStream \| AudioNode \| null` | — | What feeds the node. |
| `gain` | `number` | `1` | Linear gain. |
| `destination` | `AudioNode \| null` | `the speakers` | Where the node plays; null routes nowhere. |
| `timeConstant` | `number` | `0.01` | Ramp time constant, in seconds. |
# useLevel (/docs/hooks/use-level)
Meters paint every frame without component updates. When you need the level in Solid, for a label or to show a warning, `useLevel` samples the source a few times a second.
```bash
npx shadcn@latest add @audiocn-solid/use-level
```
## Usage
```tsx
const level = useLevel(analyser.meter);
return (
Turn your microphone down.
);
```
## Options
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `intervalMs` | `number` | `250` | How often Solid state updates. |
| `channel` | `number \| "max"` | `"max"` | A channel index, or the loudest channel. |
| `zones` | `MeterZone[]` | `DEFAULT_ZONES` | Zones used to compute zone. |
| `enabled` | `boolean` | `true` | Pause sampling. |
## Returns
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `peakDb` | `number` | — | Peak level in dBFS. -Infinity until the first frame. |
| `rmsDb` | `number \| undefined` | — | RMS level in dBFS, when the source measures it. |
| `zone` | `"ok" \| "warn" \| "clip"` | — | The zone of peakDb. |
# useMicrophone (/docs/hooks/use-microphone)
```bash
npx shadcn@latest add @audiocn-solid/use-microphone
```
## Usage
```tsx
const microphone = useMicrophone({ deviceId });
const analyser = useAudioAnalyser(microphone.stream);
Start ;
```
Echo cancellation, noise suppression and automatic gain control are off by default. Browsers turn them on for calls, but they change the level a meter shows. Turn them on when you want call-style audio.
## Options
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `deviceId` | `string \| null` | — | The device to open. Omit for the system default. Changing it reopens the stream. |
| `enabled` | `boolean` | `false` | Open the microphone as soon as possible. |
| `echoCancellation` | `boolean` | `false` | Browser echo cancellation. |
| `noiseSuppression` | `boolean` | `false` | Browser noise suppression. |
| `autoGainControl` | `boolean` | `false` | Browser automatic gain control. |
| `channelCount` | `number` | — | Requested channel count. |
## Returns
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `stream` | `MediaStream \| null` | — | The open stream. |
| `status` | `"idle" \| "acquiring" \| "active" \| "denied" \| "unavailable" \| "error"` | — | unavailable also covers a device that was unplugged. |
| `error` | `Error \| null` | — | The last error. |
| `start` | `() => Promise` | — | Open the microphone. |
| `stop` | `() => void` | — | Close it and release the device. |
# useMixer (/docs/hooks/use-mixer)
```bash
npx shadcn@latest add @audiocn-solid/use-mixer
```
## Usage
```tsx
const mixer = useMixer({
channels: [{ id: "mic" }, { id: "music", gainDb: -12 }],
persistKey: "my-app-mixer",
});
mixer.setGain("mic", -3);
mixer.setSolo("music", true, { exclusive: true });
mixer.isAudible("mic"); // false while music is soloed
```
`useMixer` only holds state. Connect it to sound with [`useWebAudioMixer`](/docs/hooks/use-web-audio-mixer), or read `mixer.state` and send it to your own engine.
## Options
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `channels` | `(Partial & { id })[]` | `[]` | Initial channels. Missing fields get defaults. |
| `master` | `Partial` | `{ gainDb: 0, muted: false }` | — |
| `state` | `MixerState` | — | Controlled state. |
| `onStateChange` | `(state) => void` | — | Called after every change. |
| `persistKey` | `string` | — | Save state to localStorage under this key. |
A channel is `{ id, gainDb, muted, solo, pan, monitor }`. `pan` runs from −1 to 1.
## Returns
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `state / channels / master` | `MixerState` | — | Current state. |
| `channel` | `(id) => MixerChannelState \| undefined` | — | — |
| `setGain / setMuted / setPan / setMonitor` | `(id, value) => void` | — | — |
| `setSolo` | `(id, solo, { exclusive? }) => void` | — | exclusive un-solos every other channel. |
| `setMasterGain / setMasterMuted` | `(value) => void` | — | — |
| `isAudible` | `(id) => boolean` | — | False when muted, or when another channel is soloed. |
| `isDimmed` | `(id) => boolean` | — | True when silenced only by another channel's solo. |
| `addChannel / removeChannel` | `(channel \| id) => void` | — | — |
| `reset` | `() => void` | — | Back to the initial state. |
`mixerReducer` and `isChannelAudible` are exported for use outside Solid.
# useReducedMotion (/docs/hooks/use-reduced-motion)
Meters and visualizers use this to show a static level instead of animating.
```bash
npx shadcn@latest add @audiocn-solid/use-reduced-motion
```
```tsx
const reducedMotion = useReducedMotion();
```
It returns `false` during server rendering and updates when the setting changes.
# useSound (/docs/hooks/use-sound)
```bash
npx shadcn@latest add @audiocn-solid/use-sound
```
## Usage
```tsx
const ding = useSound("/sounds/ding.mp3", { volume: 0.8 });
Ding ;
```
The sound is fetched and decoded once per page, then plays instantly on every trigger. `src` can also be an `AudioBuffer` you made yourself.
Pass an accessor for a changing source or options. Read `ding.isPlaying` in JSX rather than destructuring the controller.
## Routing
By default a sound plays through the speakers. Pass `destination: null` and route `output` yourself, for example into a mixer channel:
```tsx
const pad = useSound(() => src(), { destination: null });
useWebAudioMixer(mixer, { inputs: { sounds: pad.output } });
```
## Options
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `volume` | `number` | `1` | 0..1 gain. |
| `playbackRate` | `number` | `1` | — |
| `loop` | `boolean` | `false` | — |
| `interrupt` | `boolean` | `true` | Restart instead of layering. |
| `maxVoices` | `number` | `4` | Most simultaneous voices when not interrupting. |
| `destination` | `AudioNode \| null` | `speakers` | null leaves output unconnected. |
## Returns
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `play / stop` | `() => void` | — | — |
| `isPlaying / isLoaded` | `boolean` | — | — |
| `duration` | `number` | — | Seconds. |
| `progress` | `FrameSource` | — | 0..1 while playing. |
| `output` | `AudioNode \| null` | — | The sound's output node. |
| `error` | `Error \| null` | — | — |
`loadAudioBuffer(context, src)` is exported for preloading.
# useSystemAudio (/docs/hooks/use-system-audio)
```bash
npx shadcn@latest add @audiocn-solid/use-system-audio
```
## Usage
```tsx
const system = useSystemAudio();
const analyser = useAudioAnalyser(() => system.stream, { channels: "stereo" });
Capture system audio
;
```
The browser shows its screen-share picker. The user must choose a screen, window or tab and tick the option to share audio. The video track is dropped immediately; only audio is kept.
> **Browser support varies**
>
> What can be captured depends on the browser and operating system. Chromium
> browsers can share tab audio everywhere and whole-system audio on some
> platforms. Other browsers may offer no audio at all. Always handle the
> `no-audio` and `unsupported` states.
## Options
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `systemAudio` | `boolean` | `true` | Ask for the whole system's audio where the browser allows it. |
| `preferCurrentTab` | `boolean` | `false` | Offer the current tab first in the picker. |
## Returns
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `isSupported` | `boolean` | — | The browser can capture display media at all. |
| `stream` | `MediaStream \| null` | — | The captured audio. |
| `status` | `"idle" \| "prompting" \| "active" \| "no-audio" \| "denied" \| "ended" \| "unsupported"` | — | no-audio means the user shared without audio; ended means they stopped sharing. |
| `error` | `Error \| null` | — | The last error. |
| `start` | `() => Promise` | — | Open the picker. Call it from a user gesture. |
| `stop` | `() => void` | — | Stop capturing. |
# useVisibility (/docs/hooks/use-visibility)
Every audiocn meter and visualizer that paints on animation frames uses this hook to stop painting while it is scrolled out of view.
```bash
npx shadcn@latest add @audiocn-solid/use-visibility
```
## Usage
```tsx
import { createEffect, onCleanup } from "solid-js";
import { useVisibility } from "@/hooks/use-visibility";
export const Spinner = () => {
let ref!: HTMLDivElement;
const visibleRef = useVisibility(() => ref);
createEffect(() => {
let frame = requestAnimationFrame(function tick(now) {
if (visibleRef.current) {
ref.style.setProperty("--angle", `${(now / 10) % 360}deg`);
}
frame = requestAnimationFrame(tick);
});
onCleanup(() => cancelAnimationFrame(frame));
});
return
;
};
```
The hook returns a ref, not state: reading `visibleRef.current` inside a loop costs nothing and never re-renders. It reads `true` until the first observation, and stays `true` where `IntersectionObserver` is unavailable.
## Waking a sleeping loop
Pass `onChange` to hear when the element comes into view or leaves it. audiocn painters use it with `createFrameTask` from `lib/audio/frame-loop`: a meter that is off screen, or has settled, requests no frames at all, and coming back into view wakes it.
```tsx
let task: FrameTask | null = null;
const visibleRef = useVisibility(ref, (visible) => {
if (visible) {
task?.wake();
}
});
```
# useWaveformData (/docs/hooks/use-waveform-data)
```bash
npx shadcn@latest add @audiocn-solid/use-waveform-data
```
## Usage
```tsx
const waveform = useWaveformData("/episode.mp3", { samples: 800 });
;
```
Peaks are the loudest sample in each slice, normalised so the loudest slice is 1. Results are cached per file and sample count. `src` can also be an `AudioBuffer`.
Pass an accessor for a changing source or sample count. Read the returned fields in JSX so changes stay reactive.
## Options
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `samples` | `number` | `512` | How many peaks to compute. |
## Returns
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `peaks` | `Float32Array \| null` | — | 0..1 per slice. |
| `duration` | `number` | — | Seconds. |
| `status` | `"idle" \| "loading" \| "ready" \| "error"` | — | — |
| `error` | `Error \| null` | — | — |
`computePeaks(buffer, samples)` is exported for use outside Solid.
# useWebAudioMixer (/docs/hooks/use-web-audio-mixer)
```bash
npx shadcn@latest add @audiocn-solid/use-web-audio-mixer
```
## Usage
```tsx
const mixer = useMixer({
channels: [{ id: "mic" }, { id: "music", monitor: true }],
});
const microphone = useMicrophone({ enabled: true });
const player = useAudioPlayer({ src: "/music.mp3" });
const graph = useWebAudioMixer(mixer, () => ({
inputs: { mic: microphone.stream, music: player.element },
ducking: { trigger: "mic", targets: ["music"] },
}));
;
;
```
The [System Audio Mixer](/docs/blocks/system-audio-mixer) block is built on it.
## The graph
Each channel runs through a gain, a ducking stage and a stereo panner. The panner feeds the master bus, and a monitor send feeds the speakers. The master bus runs through an optional peak limiter into a `MediaStream` for recording.
```
input → gain → duck → pan ─┬─→ master → limiter → output stream
└─→ monitor send → speakers
```
* Mute and solo are gain ramps, so they never click. Channels are built silent and fade in, so a muted channel is never heard while it is created.
* A duck holds for 200 ms after the trigger's last loud frame, then releases. It also releases when the trigger channel goes away mid-duck.
* `monitor` decides whether a channel is heard in the speakers. It is off by default so a microphone does not feed back; turn it on for music and sounds.
* A media element used as an input is taken over by the mixer while mounted, and routed back to the speakers afterwards.
## Options
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `inputs` | `Record` | — | One input per channel id. |
| `ducking` | `DuckingOptions \| DuckingOptions[]` | — | { trigger, targets, thresholdDb = -35, amountDb = -12, attackMs = 50, releaseMs = 400 }. |
| `limiter` | `boolean` | `true` | A peak limiter on the master. |
| `analyser` | `{ fftSize?, bands?, historySize?, smoothing? }` | — | Settings for the meters. |
| `enabled` | `boolean` | `true` | Build the graph. |
## Returns
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `meters` | `Record>` | — | Post-fader stereo meter per channel. |
| `visuals` | `Record>` | — | Post-fader visual frames per channel. |
| `master` | `{ meter, visual }` | — | After the limiter. |
| `output` | `MediaStream \| null` | — | The mix, for MediaRecorder or WebRTC. |
| `destination` | `AudioNode \| null` | — | The master bus input, for your own nodes. |
| `context` | `AudioContext \| null` | — | — |
The sources stay the same while the graph is rebuilt, so components never need to resubscribe.
# Introduction (/docs)
audiocn is a set of audio components for Solid: level meters, visualizers, faders, channel strips, a complete mixer, players and sound pads.
It is not a package you install. Like shadcn/ui, each component is copied into your project with the shadcn CLI, so you own the code and can change anything.
## What makes it different
* **Built on Kobalte**, styled with Tailwind CSS, and themed with the same CSS variables as shadcn/ui. Any shadcn theme restyles audiocn.
* **Fast by default.** Meters and visualizers paint on the animation frame without driving Solid component updates for every audio frame, so a mixer with many channels stays smooth.
* **Bring your own audio.** Components receive levels and values; they never open a microphone themselves. Use the included Web Audio hooks, or feed them from any other engine.
* **Real units.** Mixer components speak decibels, the way audio engineers do.
# Installation (/docs/installation)
audiocn Solid is a [shadcn registry](https://ui.shadcn.com/docs/registry). You add components with the same `shadcn` CLI, and the code lands in your project.
## Requirements
* Solid 1.9.15 or later. Solid 2.0.0-rc.14 is an experimental lane, tested with the AudioCN-patched Kobalte prerelease.
* Tailwind CSS v4.
* A project set up with shadcn and a `components.json`. audiocn Solid components use the standard shadcn tokens (`background`, `muted`, `primary`, `ring`...) and the `cn` helper from `@/lib/utils`.
* Interactive primitives use Kobalte. The runtime-specific registry installs the matching Kobalte line for you.
The Kobalte dependencies are prerelease archives from AudioCN-owned forks, not upstream releases or upstream-endorsed packages. Their provenance and checksums are documented in the AudioCN repository's third-party notices.
### Set up a Solid project
Skip this if your Solid project already has Tailwind v4, the theme tokens below, and a `components.json`. Use this manual Solid setup instead of the React-oriented shadcn initializer.
```bash title="Create the app"
bunx --bun create-vite@9.2.1 audio-app --template solid-ts --no-interactive --no-immediate
cd audio-app
bun add -d tailwindcss@4.3.3 @tailwindcss/vite@4.3.3 typescript@7.0.2
```
Choose one runtime. The stable Solid 1 setup:
```bash title="Solid 1 dependencies"
bun add solid-js@1.9.15
bun add -d vite@7.3.6 vite-plugin-solid@2.11.14
```
The experimental Solid 2 setup:
```bash title="Solid 2 dependencies"
bun remove vite-plugin-solid
bun add solid-js@2.0.0-rc.14 @solidjs/web@2.0.0-rc.14
bun add -d vite@8.3.2 @solidjs/vite-plugin@3.0.0-next.47
```
Replace `tsconfig.json` with the following. Solid 1 uses `"jsxImportSource": "solid-js"`; for Solid 2, change that one value to `"jsxImportSource": "@solidjs/web"`.
```json title="tsconfig.json"
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"paths": { "@/*": ["./src/*"] },
"types": ["vite/client"]
},
"include": ["src"]
}
```
Replace `vite.config.ts` with the matching configuration.
```ts title="Solid 1 vite.config.ts"
import { fileURLToPath, URL } from "node:url";
import solid from "vite-plugin-solid";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [solid(), tailwindcss()],
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
});
```
```ts title="Solid 2 vite.config.ts"
import { fileURLToPath, URL } from "node:url";
import solid from "@solidjs/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [solid(), tailwindcss()],
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
dedupe: ["solid-js", "@solidjs/web"],
},
});
```
Replace the demo `src/App.tsx` with your app. For Solid 2, also change the render import in `src/index.tsx` from `solid-js/web` to `@solidjs/web`. Keep the global CSS import, replacing `src/index.css` with:
```css title="src/index.css"
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: white;
--foreground: oklch(0.145 0 0);
--card: var(--background);
--card-foreground: var(--foreground);
--popover: var(--background);
--popover-foreground: var(--foreground);
--primary: oklch(0.205 0 0);
--primary-foreground: white;
--secondary: oklch(0.97 0 0);
--secondary-foreground: var(--primary);
--muted: var(--secondary);
--muted-foreground: oklch(0.556 0 0);
--accent: var(--secondary);
--accent-foreground: var(--primary);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: var(--border);
--ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: var(--foreground);
--primary-foreground: var(--background);
--secondary: oklch(0.269 0 0);
--secondary-foreground: var(--foreground);
--muted-foreground: oklch(0.708 0 0);
--accent-foreground: var(--foreground);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--ring: oklch(0.556 0 0);
}
```
Create a `components.json`. The style identifies CLI formatting, not a React component dependency; add only the `@audiocn-solid` items.
```json title="components.json"
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-rhea",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"ui": "@/components/ui",
"utils": "@/lib/utils",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
```
Check the configuration without installing React primitives:
```bash title="Check the CLI configuration"
npx shadcn@4.21.0 info
```
### Add the registry
Use the registry URL for the Solid version in your app.
Solid 1:
```json title="components.json"
{
"registries": {
"@audiocn-solid": "https://audiocn-solid.jhonra121.workers.dev/r/solid1/{name}.json"
}
}
```
Solid 2:
```json title="components.json"
{
"registries": {
"@audiocn-solid": "https://audiocn-solid.jhonra121.workers.dev/r/solid2/{name}.json"
}
}
```
### Add a component
```bash
npx shadcn@4.21.0 add @audiocn-solid/level-meter
```
The CLI copies the component and everything it depends on: the audio core (`lib/audio`), Solid compatibility helpers, audio theme tokens, and any hooks it uses.
### Use it
```tsx
import { LevelMeter } from "@/components/ui/level-meter";
export const MicLevel = (props: { peakDb: number }) => (
);
```
## What gets installed
| Path | What it is |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `components/ui/*` | The components, one file each |
| `hooks/use-*.ts` | Hooks: frame sources, Web Audio, mixer state |
| `lib/audio/*.ts` | The framework-neutral audio core: decibels, ballistics, tapers, types |
| `lib/solid/*.ts` | Small Solid compatibility helpers used by registry files |
| `lib/audiocn-license.txt` | Upstream MIT and Phosphor copyright and permission notices; retain this file when redistributing |
| global CSS | Six audio tokens: `--meter-ok`, `--meter-warn`, `--meter-clip`, `--channel-mute`, `--channel-solo`, `--channel-monitor` |
The paths follow the aliases in your `components.json`, so they adapt to projects with a `src` folder or custom aliases.
## For AI agents
Every component, hook and block page has a **Copy prompt for AI** button. It copies one document with the install steps, usage, API reference, and links to the page examples.
The same document is served at the page URL with `.md` appended, so an agent can fetch it directly:
```bash
curl https://audiocn-solid.jhonra121.workers.dev/docs/components/bar-visualizer.md
```
[llms.txt](/llms.txt) indexes every page, and [llms-full.txt](/llms-full.txt) is the whole documentation in one file.
## Solid 1 and Solid 2
The two registry URLs expose the same public audiocn API. The Solid 1 registry targets Solid 1.9.15+ and the Solid 1 Kobalte line. The Solid 2 registry targets Solid 2 and the Solid 2 Kobalte line. Runtime-specific compatibility files are selected when the registry is built; application code should not branch on the Solid version.
* The six audio tokens land in your global CSS. If your theme already has status colours, point the tokens at them instead (see [Theming](/docs/concepts/theming)).
* The code compiles against an ES2022 `lib`.
## Install everything
Every block pulls in the components it uses. The full mixer installs most of the library in one step:
```bash
npx shadcn@4.21.0 add @audiocn-solid/system-audio-mixer
```