System Audio Mixer
The complete mixer, with microphone, system audio, music, sound pads and a master, running on Web Audio.
Audio mixer
MicrophoneNot listening
Off0.0 dB
System audioShare a screen or tab with audio
Unsupported−6.0 dB
MusicNo tracks
−12.0 dB
Sounds0 pads
−6.0 dB
MasterMix output
0.0 dB
Installation
$ pnpm dlx shadcn@latest add @audiocn-solid/system-audio-mixerWhat 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
MediaStreamthroughonOutputChange, ready forMediaRecorderor WebRTC. - Layouts: switch between rows and console strips.
Console layout
Audio mixer
MicrophoneNot listening
Off0.0 dB
System audioShare a screen or tab with audio
Unsupported−6.0 dB
MusicNo tracks
−12.0 dB
Sounds0 pads
−6.0 dB
MasterMix output
0.0 dB
Usage
import { SystemAudioMixer } from "@/components/blocks/system-audio-mixer/system-audio-mixer";
<SystemAudioMixer
onOutputChange={(stream) => recorder.setStream(stream)}
persistKey="studio-mixer"
sounds={[{ id: "ding", label: "Ding", src: "/sounds/ding.mp3", hotkey: "1" }]}
tracks={[{ id: "intro", title: "Intro", src: "/music/intro.mp3" }]}
/>;Props
Prop
Type
Files
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<MeterFrame>;
}
const MixerPad = (props: { sound: MixerSound; bus: AudioNode | null }) => {
const player = useSound(
() => props.sound.src,
() => ({ destination: props.bus })
);
return (
<SoundPad
accent={props.sound.accent}
hotkey={props.sound.hotkey}
loading={!player.isLoaded}
onTrigger={() => player.play()}
playing={player.isPlaying}
size="sm"
>
<SoundPadLabel>{props.sound.label}</SoundPadLabel>
<SoundPadShortcut />
<SoundPadProgress source={player.progress} />
</SoundPad>
);
};
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 (
<MixerSourceStrip
accent="var(--chart-2)"
id="microphone"
title="Microphone"
description={active() ? label() : "Not listening"}
icon={<MicrophoneIcon />}
mixer={props.mixer}
meter={props.meter}
status={microphoneStatus(
props.microphone,
props.mixer.isAudible("microphone")
)}
actions={
<>
<Popover>
<PopoverTrigger
aria-label="Microphone settings"
size="icon-xs"
variant="ghost"
>
<GearSixIcon />
</PopoverTrigger>
<PopoverContent class="w-72">
<AudioDeviceSelect
devices={devices.devices}
loading={devices.isLoading}
onRequestPermission={() => void devices.requestPermission()}
onValueChange={props.onDeviceChange}
permission={
devices.permission === "unsupported"
? "denied"
: devices.permission
}
value={props.deviceId}
/>
</PopoverContent>
</Popover>
<Button
onClick={() =>
active() ? props.microphone.stop() : void props.microphone.start()
}
size="xs"
variant={active() ? "secondary" : "outline"}
>
{active() ? "Stop" : "Start"}
</Button>
</>
}
notice={
<Show when={props.microphone.status === "denied"}>
<ChannelStripNotice variant="destructive">
Microphone access is blocked. Allow it in your browser's site
settings.
</ChannelStripNotice>
</Show>
}
/>
);
};
const SystemChannel = (
props: ChannelProps & { system: UseSystemAudioResult }
) => (
<MixerSourceStrip
accent="var(--chart-4)"
id="system"
title="System audio"
description={
props.system.status === "active"
? "Capturing"
: "Share a screen or tab with audio"
}
icon={<DesktopIcon />}
meter={props.meter}
mixer={props.mixer}
monitorable={false}
status={systemStatus(props.system)}
actions={
<Switch
aria-label="Capture system audio"
checked={props.system.status === "active"}
disabled={!props.system.isSupported}
onCheckedChange={(checked) =>
checked ? void props.system.start() : props.system.stop()
}
size="sm"
/>
}
notice={
<Show when={props.system.status === "no-audio"}>
<ChannelStripNotice variant="warning">
Nothing to hear: tick "Share audio" in the browser's picker.
</ChannelStripNotice>
</Show>
}
/>
);
const MusicChannel = (
props: ChannelProps & {
player: AudioPlayerController;
track: MixerTrack | undefined;
trackCount: number;
onNext: () => void;
}
) => (
<MixerSourceStrip
accent="var(--chart-1)"
id="music"
title="Music"
description={props.track ? props.track.title : "No tracks"}
icon={<MusicNotesIcon />}
meter={props.meter}
mixer={props.mixer}
status={
props.player.playing ? { label: "Playing", tone: "live" } : undefined
}
actions={
<>
<Button
aria-label={props.player.playing ? "Pause music" : "Play music"}
disabled={!props.track}
onClick={() => void props.player.toggle()}
size="icon-xs"
variant="ghost"
>
{props.player.playing ? <PauseIcon /> : <PlayIcon />}
</Button>
<Button
aria-label="Next track"
disabled={props.trackCount < 2}
onClick={() => props.onNext()}
size="icon-xs"
variant="ghost"
>
<SkipForwardIcon />
</Button>
</>
}
/>
);
const SoundsChannel = (
props: ChannelProps & { sounds: MixerSound[]; bus: AudioNode | null }
) => (
<MixerSourceStrip
accent="var(--chart-3)"
id="sounds"
title="Sounds"
description={`${props.sounds.length} pads`}
icon={<WaveformIcon />}
meter={props.meter}
mixer={props.mixer}
actions={
<Popover>
<PopoverTrigger
aria-label="Sound pads"
disabled={!props.sounds.length}
size="icon-xs"
variant="ghost"
>
<SquaresFourIcon />
</PopoverTrigger>
<PopoverContent class="w-80">
<SoundPadGrid class="[--pad-min-width:4rem]" columns={4} hotkeys>
<For each={props.sounds}>
{(sound) => <MixerPad sound={sound} bus={props.bus} />}
</For>
</SoundPadGrid>
</PopoverContent>
</Popover>
}
/>
);
export const SystemAudioMixer = (props: SystemAudioMixerProps) => {
const [orientation, setOrientation] = createSignal<Orientation>(
props.defaultOrientation ?? "horizontal"
);
const mixer = useMixer({
channels: CHANNELS,
get persistKey() {
return props.persistKey;
},
});
const [deviceId, setDeviceId] = createSignal<string | null>(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 (
<Mixer
class={cn(
"[--channel-strip-header-width:16rem]",
props.class,
props.className
)}
orientation={orientation()}
>
<MixerHeader>
<MixerTitle>Audio mixer</MixerTitle>
<MixerActions>
<Tabs
value={orientation()}
onValueChange={(value) => {
if (value === "horizontal" || value === "vertical")
setOrientation(value);
}}
>
<TabsList aria-label="Mixer layout">
<TabsTrigger value="horizontal">Rows</TabsTrigger>
<TabsTrigger value="vertical">Console</TabsTrigger>
</TabsList>
</Tabs>
<Button
aria-label="Reset mixer"
onClick={() => mixer.reset()}
size="icon-sm"
variant="ghost"
>
<ArrowCounterClockwiseIcon />
</Button>
</MixerActions>
</MixerHeader>
<MixerChannels>
<Show when={show("microphone")}>
<MicrophoneChannel
deviceId={deviceId()}
onDeviceChange={setDeviceId}
microphone={microphone}
mixer={mixer}
meter={meterFor("microphone")}
/>
</Show>
<Show when={show("system")}>
<SystemChannel
system={system}
mixer={mixer}
meter={meterFor("system")}
/>
</Show>
<Show when={show("music")}>
<MusicChannel
player={player}
track={track()}
trackCount={tracks().length}
onNext={nextTrack}
mixer={mixer}
meter={meterFor("music")}
/>
</Show>
<Show when={show("sounds")}>
<SoundsChannel
sounds={sounds()}
bus={soundBus}
mixer={mixer}
meter={meterFor("sounds")}
/>
</Show>
</MixerChannels>
<MixerEmpty>No audio sources.</MixerEmpty>
<MixerSeparator />
<MixerMaster>
<MixerMasterStrip mixer={mixer} meter={graph.master.meter} />
</MixerMaster>
</Mixer>
);
};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<MeterFrame>;
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 (
<Show when={props.mixer.channel(props.id)}>
{(channel) => (
<ChannelStrip
accent={props.accent}
dimmed={props.mixer.isDimmed(props.id)}
disabled={props.disabled ?? false}
muted={channel().muted}
solo={channel().solo}
>
<ChannelStripHeader>
<ChannelStripIcon>{props.icon}</ChannelStripIcon>
<ChannelStripText>
<ChannelStripTitle>{props.title}</ChannelStripTitle>
<Show when={description()}>
<ChannelStripDescription>
{description()}
</ChannelStripDescription>
</Show>
</ChannelStripText>
<Show when={props.status}>
{(status) => (
<ChannelStripStatus tone={status().tone}>
{status().label}
</ChannelStripStatus>
)}
</Show>
<Show when={actions()}>
<ChannelStripActions>{actions()}</ChannelStripActions>
</Show>
</ChannelStripHeader>
<ChannelStripMeter>
<LevelMeter
aria-label={`${props.title} level`}
channelCount={2}
class="h-full"
size="sm"
source={props.meter}
/>
</ChannelStripMeter>
<ChannelStripFader>
<Fader
aria-label={`${props.title} volume`}
max={12}
min={-60}
onValueChange={(gainDb) => props.mixer.setGain(props.id, gainDb)}
silenceAtMin
size="sm"
taper="audio"
value={channel().gainDb}
>
<FaderTrack>
<FaderRange />
<FaderThumb />
</FaderTrack>
</Fader>
</ChannelStripFader>
<ChannelStripValue>
{formatDb(channel().gainDb, { floorDb: -60 })}
</ChannelStripValue>
<ChannelStripControls>
<MuteToggle
aria-label={`Mute ${props.title}`}
onPressedChange={(muted) => props.mixer.setMuted(props.id, muted)}
pressed={channel().muted}
size="sm"
>
M
</MuteToggle>
<SoloToggle
aria-label={`Solo ${props.title}`}
onPressedChange={(solo) => props.mixer.setSolo(props.id, solo)}
pressed={channel().solo}
size="sm"
>
S
</SoloToggle>
<Show when={props.monitorable !== false}>
<MonitorToggle
aria-label={`Monitor ${props.title}`}
onPressedChange={(monitor) =>
props.mixer.setMonitor(props.id, monitor)
}
pressed={channel().monitor}
size="sm"
>
<HeadphonesIcon />
</MonitorToggle>
</Show>
</ChannelStripControls>
{props.notice}
</ChannelStrip>
)}
</Show>
);
};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<MeterFrame>;
}
export const MixerMasterStrip = (props: MixerMasterStripProps) => (
<ChannelStrip muted={props.mixer.master.muted} variant="master">
<ChannelStripHeader>
<ChannelStripIcon>
<SpeakerHighIcon />
</ChannelStripIcon>
<ChannelStripText>
<ChannelStripTitle>Master</ChannelStripTitle>
<ChannelStripDescription>Mix output</ChannelStripDescription>
</ChannelStripText>
</ChannelStripHeader>
<ChannelStripMeter>
<LevelMeter aria-label="Master level" class="h-full" source={props.meter}>
<LevelMeterChannels>
<For each={CHANNELS}>
{(index) => (
<LevelMeterChannel index={index}>
<LevelMeterTrack>
<LevelMeterBar />
<LevelMeterHold />
</LevelMeterTrack>
</LevelMeterChannel>
)}
</For>
</LevelMeterChannels>
<LevelMeterClip showCount />
</LevelMeter>
</ChannelStripMeter>
<ChannelStripFader>
<Fader
aria-label="Master volume"
max={6}
min={-60}
onValueChange={(value) => props.mixer.setMasterGain(value)}
silenceAtMin
size="sm"
taper="audio"
value={props.mixer.master.gainDb}
>
<FaderTrack>
<FaderRange />
<FaderThumb />
</FaderTrack>
</Fader>
</ChannelStripFader>
<ChannelStripValue>
{formatDb(props.mixer.master.gainDb, { floorDb: -60 })}
</ChannelStripValue>
<ChannelStripControls>
<MuteToggle
aria-label="Mute master"
onPressedChange={(value) => props.mixer.setMasterMuted(value)}
pressed={props.mixer.master.muted}
size="sm"
>
M
</MuteToggle>
</ChannelStripControls>
</ChannelStrip>
);