# 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()} >
Level
Gain Mute microphone
{(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 (
); }; 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} )}
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 = () => ( ); 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()} />
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

{feedback()}
No sounds yet Drop audio files here, or add them from your computer.
} > {(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} /> } 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={ <> } /> ); 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 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 (
{(align) => (
{align}
)}
); }; 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.
); 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 (
); }; ``` ### 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 `
); }; 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 (
{(mode) => (
{mode}
)}
); }; 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 = () => (
idle
loading
); 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 (
); }; 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 (
); }; ``` ## 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 (
); }; 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 (
); }; 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 (
{(mode) => (
{mode}
)}
); }; 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 = () => (
idle
loading
); 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 (
); }; 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 ` ); }; 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 (
    ); }; 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 ; } ``` ## 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" }); ; ; ``` The hook creates and owns an `