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/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
"use client";
import {
ArrowCounterClockwiseIcon,
DesktopIcon,
GearSixIcon,
MicrophoneIcon,
MusicNotesIcon,
PauseIcon,
PlayIcon,
SkipForwardIcon,
SquaresFourIcon,
WaveformIcon,
} from "@phosphor-icons/react";
import { useEffect, useMemo, useState } from "react";
import { MixerMasterStrip } from "@/components/blocks/system-audio-mixer/mixer-master-strip";
import { MixerSourceStrip } from "@/components/blocks/system-audio-mixer/mixer-source-strip";
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 { useAudioContext } from "@/hooks/use-audio-context";
import { useAudioDevices } from "@/hooks/use-audio-devices";
import { useAudioPlayer } from "@/hooks/use-audio-player";
import type { AudioPlayerController } from "@/hooks/use-audio-player";
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 { 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 {
/** Rows or console strips. Default `horizontal`. */
defaultOrientation?: Orientation;
/** Which sources to show. Default all four. */
sources?: MixerSourceId[];
/** Save mixer settings to localStorage under this key. */
persistKey?: string;
/** The mixed output, for recording or streaming. */
onOutputChange?: (stream: MediaStream | null) => void;
/** Tracks for the music channel. */
tracks?: MixerTrack[];
/** Sounds for the sounds channel. */
sounds?: MixerSound[];
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"];
}
const MixerPad = ({
sound,
bus,
}: {
sound: MixerSound;
bus: AudioNode | null;
}) => {
const player = useSound(sound.src, { destination: bus });
return (
<SoundPad
accent={sound.accent}
hotkey={sound.hotkey}
loading={!player.isLoaded}
onTrigger={() => player.play()}
playing={player.isPlaying}
size="sm"
>
<SoundPadLabel>{sound.label}</SoundPadLabel>
<SoundPadShortcut />
<SoundPadProgress source={player.progress} />
</SoundPad>
);
};
interface ChannelProps {
mixer: MixerController;
meter: FrameSource<MeterFrame>;
}
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 = ({
microphone,
deviceId,
onDeviceChange,
mixer,
meter,
}: ChannelProps & {
microphone: UseMicrophoneResult;
deviceId: string | null;
onDeviceChange: (deviceId: string | null) => void;
}) => {
const devices = useAudioDevices();
const active = microphone.status === "active";
const deviceLabel =
devices.devices.find((device) => device.id === deviceId)?.label ??
"Default microphone";
return (
<MixerSourceStrip
accent="var(--chart-2)"
actions={
<>
<Popover>
<PopoverTrigger
render={
<Button
aria-label="Microphone settings"
size="icon-xs"
variant="ghost"
/>
}
>
<GearSixIcon />
</PopoverTrigger>
<PopoverContent className="w-72">
<AudioDeviceSelect
devices={devices.devices}
loading={devices.isLoading}
onRequestPermission={() => devices.requestPermission()}
onValueChange={onDeviceChange}
permission={
devices.permission === "unsupported"
? "denied"
: devices.permission
}
value={deviceId}
/>
</PopoverContent>
</Popover>
<Button
onClick={active ? microphone.stop : microphone.start}
size="xs"
variant={active ? "secondary" : "outline"}
>
{active ? "Stop" : "Start"}
</Button>
</>
}
description={active ? deviceLabel : "Not listening"}
icon={<MicrophoneIcon />}
id="microphone"
meter={meter}
mixer={mixer}
notice={
microphone.status === "denied" ? (
<ChannelStripNotice variant="destructive">
Microphone access is blocked. Allow it in your browser's site
settings.
</ChannelStripNotice>
) : null
}
status={microphoneStatus(microphone, mixer.isAudible("microphone"))}
title="Microphone"
/>
);
};
const SystemChannel = ({
system,
mixer,
meter,
}: ChannelProps & { system: UseSystemAudioResult }) => {
const active = system.status === "active";
return (
<MixerSourceStrip
accent="var(--chart-4)"
actions={
<Switch
aria-label="Capture system audio"
checked={active}
disabled={!system.isSupported}
onCheckedChange={(checked) =>
checked ? system.start() : system.stop()
}
size="sm"
/>
}
description={active ? "Capturing" : "Share a screen or tab with audio"}
icon={<DesktopIcon />}
id="system"
meter={meter}
mixer={mixer}
monitorable={false}
notice={
system.status === "no-audio" ? (
<ChannelStripNotice variant="warning">
Nothing to hear: tick "Share audio" in the browser's
picker.
</ChannelStripNotice>
) : null
}
status={systemStatus(system)}
title="System audio"
/>
);
};
const MusicChannel = ({
player,
track,
trackCount,
onNext,
mixer,
meter,
}: ChannelProps & {
player: AudioPlayerController;
track: MixerTrack | undefined;
trackCount: number;
onNext: () => void;
}) => (
<MixerSourceStrip
accent="var(--chart-1)"
actions={
<>
<Button
aria-label={player.playing ? "Pause music" : "Play music"}
disabled={!track}
onClick={() => player.toggle()}
size="icon-xs"
variant="ghost"
>
{player.playing ? <PauseIcon /> : <PlayIcon />}
</Button>
<Button
aria-label="Next track"
disabled={trackCount < 2}
onClick={onNext}
size="icon-xs"
variant="ghost"
>
<SkipForwardIcon />
</Button>
</>
}
description={track ? track.title : "No tracks"}
icon={<MusicNotesIcon />}
id="music"
meter={meter}
mixer={mixer}
status={player.playing ? { label: "Playing", tone: "live" } : undefined}
title="Music"
/>
);
const SoundsChannel = ({
sounds,
bus,
mixer,
meter,
}: ChannelProps & { sounds: MixerSound[]; bus: AudioNode | null }) => (
<MixerSourceStrip
accent="var(--chart-3)"
actions={
<Popover>
<PopoverTrigger
render={
<Button
aria-label="Sound pads"
disabled={sounds.length === 0}
size="icon-xs"
variant="ghost"
/>
}
>
<SquaresFourIcon />
</PopoverTrigger>
<PopoverContent className="w-80">
<SoundPadGrid className="[--pad-min-width:4rem]" columns={4} hotkeys>
{sounds.map((sound) => (
<MixerPad bus={bus} key={sound.id} sound={sound} />
))}
</SoundPadGrid>
</PopoverContent>
</Popover>
}
description={`${sounds.length} pads`}
icon={<WaveformIcon />}
id="sounds"
meter={meter}
mixer={mixer}
title="Sounds"
/>
);
export const SystemAudioMixer = ({
defaultOrientation = "horizontal",
sources = ALL_SOURCES,
persistKey,
onOutputChange,
tracks = NO_TRACKS,
sounds = NO_SOUNDS,
className,
}: SystemAudioMixerProps) => {
const [orientation, setOrientation] =
useState<Orientation>(defaultOrientation);
const mixer = useMixer({ channels: CHANNELS, persistKey });
const { context } = useAudioContext();
const [deviceId, setDeviceId] = useState<string | null>(null);
const microphone = useMicrophone({ deviceId });
const system = useSystemAudio();
const [trackIndex, setTrackIndex] = useState(0);
const track = tracks[trackIndex];
const nextTrack = () =>
setTrackIndex((index) =>
tracks.length > 0 ? (index + 1) % tracks.length : 0
);
const player = useAudioPlayer({ onEnded: nextTrack, src: track?.src });
// Sound pads play into one bus so the mixer sees them as a single source.
const soundBus = useMemo(() => context?.createGain() ?? null, [context]);
useEffect(() => () => soundBus?.disconnect(), [soundBus]);
const graph = useWebAudioMixer(mixer, {
ducking: { targets: ["music"], trigger: "microphone" },
inputs: {
microphone: microphone.stream,
music: player.element,
sounds: soundBus,
system: system.stream,
},
});
useEffect(() => {
onOutputChange?.(graph.output);
}, [graph.output, onOutputChange]);
const show = (id: MixerSourceId) => sources.includes(id);
const meterFor = (id: MixerSourceId) =>
graph.meters[id] ?? graph.master.meter;
return (
<Mixer
className={cn("[--channel-strip-header-width:16rem]", className)}
orientation={orientation}
>
<MixerHeader>
<MixerTitle>Audio mixer</MixerTitle>
<MixerActions>
<Tabs
onValueChange={(value) => setOrientation(value as Orientation)}
value={orientation}
>
<TabsList>
<TabsTrigger value="horizontal">Rows</TabsTrigger>
<TabsTrigger value="vertical">Console</TabsTrigger>
</TabsList>
</Tabs>
<Button
aria-label="Reset mixer"
onClick={() => mixer.reset()}
size="icon-sm"
variant="ghost"
>
<ArrowCounterClockwiseIcon />
</Button>
</MixerActions>
</MixerHeader>
<MixerChannels>
{show("microphone") ? (
<MicrophoneChannel
deviceId={deviceId}
meter={meterFor("microphone")}
microphone={microphone}
mixer={mixer}
onDeviceChange={setDeviceId}
/>
) : null}
{show("system") ? (
<SystemChannel
meter={meterFor("system")}
mixer={mixer}
system={system}
/>
) : null}
{show("music") ? (
<MusicChannel
meter={meterFor("music")}
mixer={mixer}
onNext={nextTrack}
player={player}
track={track}
trackCount={tracks.length}
/>
) : null}
{show("sounds") ? (
<SoundsChannel
bus={soundBus}
meter={meterFor("sounds")}
mixer={mixer}
sounds={sounds}
/>
) : null}
</MixerChannels>
<MixerEmpty>No audio sources.</MixerEmpty>
<MixerSeparator />
<MixerMaster>
<MixerMasterStrip meter={graph.master.meter} mixer={mixer} />
</MixerMaster>
</Mixer>
);
};"use client";
import { HeadphonesIcon } from "@phosphor-icons/react";
import type { ReactNode } from "react";
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";
export interface MixerSourceStripProps {
id: string;
title: string;
description?: ReactNode;
icon: ReactNode;
accent?: string;
mixer: Mixer;
meter: FrameSource<MeterFrame>;
status?: { label: string; tone: ChannelStripStatusProps["tone"] };
/** Buttons in the header. */
actions?: ReactNode;
/** A ChannelStripNotice, shown under the strip. */
notice?: ReactNode;
/** Show the monitor toggle. Default true. */
monitorable?: boolean;
disabled?: boolean;
}
export const MixerSourceStrip = ({
id,
title,
description,
icon,
accent,
mixer,
meter,
status,
actions,
notice,
monitorable = true,
disabled = false,
}: MixerSourceStripProps) => {
const channel = mixer.channel(id);
if (!channel) {
return null;
}
return (
<ChannelStrip
accent={accent}
dimmed={mixer.isDimmed(id)}
disabled={disabled}
muted={channel.muted}
solo={channel.solo}
>
<ChannelStripHeader>
<ChannelStripIcon>{icon}</ChannelStripIcon>
<ChannelStripText>
<ChannelStripTitle>{title}</ChannelStripTitle>
{description ? (
<ChannelStripDescription>{description}</ChannelStripDescription>
) : null}
</ChannelStripText>
{status ? (
<ChannelStripStatus tone={status.tone}>
{status.label}
</ChannelStripStatus>
) : null}
{actions ? <ChannelStripActions>{actions}</ChannelStripActions> : null}
</ChannelStripHeader>
<ChannelStripMeter>
<LevelMeter
aria-label={`${title} level`}
channelCount={2}
className="h-full"
size="sm"
source={meter}
/>
</ChannelStripMeter>
<ChannelStripFader>
<Fader
aria-label={`${title} volume`}
max={12}
min={-60}
onValueChange={(gainDb) => mixer.setGain(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 ${title}`}
onPressedChange={(muted) => mixer.setMuted(id, muted)}
pressed={channel.muted}
size="sm"
>
M
</MuteToggle>
<SoloToggle
aria-label={`Solo ${title}`}
onPressedChange={(solo) => mixer.setSolo(id, solo)}
pressed={channel.solo}
size="sm"
>
S
</SoloToggle>
{monitorable ? (
<MonitorToggle
aria-label={`Monitor ${title}`}
onPressedChange={(monitor) => mixer.setMonitor(id, monitor)}
pressed={channel.monitor}
size="sm"
>
<HeadphonesIcon />
</MonitorToggle>
) : null}
</ChannelStripControls>
{notice}
</ChannelStrip>
);
};"use client";
import { SpeakerHighIcon } from "@phosphor-icons/react";
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 = ({ mixer, meter }: MixerMasterStripProps) => (
<ChannelStrip muted={mixer.master.muted} variant="master">
<ChannelStripHeader>
<ChannelStripIcon>
<SpeakerHighIcon />
</ChannelStripIcon>
<ChannelStripText>
<ChannelStripTitle>Master</ChannelStripTitle>
<ChannelStripDescription>Mix output</ChannelStripDescription>
</ChannelStripText>
</ChannelStripHeader>
<ChannelStripMeter>
<LevelMeter aria-label="Master level" className="h-full" source={meter}>
<LevelMeterChannels>
{CHANNELS.map((index) => (
<LevelMeterChannel index={index} key={index}>
<LevelMeterTrack>
<LevelMeterBar />
<LevelMeterHold />
</LevelMeterTrack>
</LevelMeterChannel>
))}
</LevelMeterChannels>
<LevelMeterClip showCount />
</LevelMeter>
</ChannelStripMeter>
<ChannelStripFader>
<Fader
aria-label="Master volume"
max={6}
min={-60}
onValueChange={(value) => mixer.setMasterGain(value)}
silenceAtMin
size="sm"
taper="audio"
value={mixer.master.gainDb}
>
<FaderTrack>
<FaderRange />
<FaderThumb />
</FaderTrack>
</Fader>
</ChannelStripFader>
<ChannelStripValue>
{formatDb(mixer.master.gainDb, { floorDb: -60 })}
</ChannelStripValue>
<ChannelStripControls>
<MuteToggle
aria-label="Mute master"
onPressedChange={(value) => mixer.setMasterMuted(value)}
pressed={mixer.master.muted}
size="sm"
>
M
</MuteToggle>
</ChannelStripControls>
</ChannelStrip>
);