System Audio Mixer
The complete mixer, with microphone, system audio, music, sound pads and a master, running on Web Audio.
Audio mixer
Microphone Not listening
Off 0.0 dB
System audio Share a screen or tab with audio
Unsupported −6.0 dB
Music No tracks
−12.0 dB
Sounds 0 pads
−6.0 dB
No audio sources.
Master Mix output
0.0 dB
Installation
$ pnpm dlx shadcn-svelte@latest add https://juddisjudd.github.io/audiocn-svelte/r/system-audio-mixer.json$ npx shadcn-svelte@latest add https://juddisjudd.github.io/audiocn-svelte/r/system-audio-mixer.json$ yarn dlx shadcn-svelte@latest add https://juddisjudd.github.io/audiocn-svelte/r/system-audio-mixer.json$ bunx --bun shadcn-svelte@latest add https://juddisjudd.github.io/audiocn-svelte/r/system-audio-mixer.jsonWhat 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
Microphone Not listening
Off 0.0 dB
System audio Share a screen or tab with audio
Unsupported −6.0 dB
Music No tracks
−12.0 dB
Sounds 0 pads
−6.0 dB
No audio sources.
Master Mix output
0.0 dB
Usage
<script lang="ts">
import { SystemAudioMixer } from "#lib/components/blocks/system-audio-mixer/index.js";
</script>
<SystemAudioMixer
onOutputChange={(stream) => recorder.setStream(stream)}
persistKey="studio-mixer"
sounds={[{ id: "ding", label: "Ding", src: "/sounds/ding.mp3", hotkey: "1" }]}
tracks={[{ id: "intro", title: "Intro", src: "/music/intro.mp3" }]}
/>Props
| Prop | Type | Default |
|---|---|---|
orientation Rows or console strips. Bindable. | "horizontal" | "vertical" | "horizontal" |
onOrientationChange When the layout tabs change it. | (orientation) => void | — |
sources Which strips to show. | ("microphone" | "system" | "music" | "sounds")[] | all four |
persistKey Save mixer settings to localStorage. | string | — |
onOutputChange The mixed output. | (stream: MediaStream | null) => void | — |
tracks Tracks for the music channel. | { id, title, artist?, src }[] | [] |
sounds Sounds for the sounds channel. src is a URL or an AudioBuffer. | { id, label, src, hotkey?, accent? }[] | [] |
class | string | — |
Files
src/lib/components/blocks/system-audio-mixer/system-audio-mixer.svelte
<script lang="ts" module>
import type { ChannelStripStatusTone } from "#lib/components/ui/channel-strip/index.js";
import type { UseSystemAudioResult } from "#lib/hooks/use-system-audio.svelte.js";
import type { Orientation } from "#lib/audio/types.js";
export type MixerSourceId = "microphone" | "system" | "music" | "sounds";
export interface MixerTrack {
id: string;
title: string;
artist?: string;
src: string;
}
export interface MixerSound {
id: string;
label: string;
src: string | AudioBuffer;
hotkey?: string;
accent?: string;
}
export interface SystemAudioMixerProps {
/** Rows or console strips. Default `horizontal`. */
orientation?: Orientation;
onOrientationChange?: (orientation: Orientation) => void;
/** Which sources to show. Default all four. */
sources?: MixerSourceId[];
/** Save mixer settings to localStorage under this key. */
persistKey?: string;
/** The mixed output, for recording or streaming. */
onOutputChange?: (stream: MediaStream | null) => void;
/** Tracks for the music channel. */
tracks?: MixerTrack[];
/** Sounds for the sounds channel. */
sounds?: MixerSound[];
class?: string;
}
const ALL_SOURCES: MixerSourceId[] = ["microphone", "system", "music", "sounds"];
const NO_TRACKS: MixerTrack[] = [];
const NO_SOUNDS: MixerSound[] = [];
const CHANNELS = [
{ id: "microphone" },
{ gainDb: -6, id: "system" },
{ gainDb: -12, id: "music", monitor: true },
{ gainDb: -6, id: "sounds", monitor: true },
];
const systemStatus = (
system: UseSystemAudioResult
): { label: string; tone: ChannelStripStatusTone } => {
if (system.status === "active") {
return { label: "On", tone: "live" };
}
if (system.status === "unsupported") {
return { label: "Unsupported", tone: "warning" };
}
return { label: "Off", tone: "default" };
};
</script>
<script lang="ts">
import { untrack } from "svelte";
import ArrowCounterClockwiseIcon from "phosphor-svelte/lib/ArrowCounterClockwiseIcon";
import DesktopIcon from "phosphor-svelte/lib/DesktopIcon";
import MusicNotesIcon from "phosphor-svelte/lib/MusicNotesIcon";
import PauseIcon from "phosphor-svelte/lib/PauseIcon";
import PlayIcon from "phosphor-svelte/lib/PlayIcon";
import SkipForwardIcon from "phosphor-svelte/lib/SkipForwardIcon";
import SquaresFourIcon from "phosphor-svelte/lib/SquaresFourIcon";
import WaveformIcon from "phosphor-svelte/lib/WaveformIcon";
import { Button } from "#lib/components/ui/button/index.js";
import { ChannelStripNotice } from "#lib/components/ui/channel-strip/index.js";
import {
Mixer,
MixerActions,
MixerChannels,
MixerEmpty,
MixerHeader,
MixerMaster,
MixerSeparator,
MixerTitle,
} from "#lib/components/ui/mixer/index.js";
import { Popover, PopoverContent, PopoverTrigger } from "#lib/components/ui/popover/index.js";
import { SoundPadGrid } from "#lib/components/ui/sound-pad/index.js";
import { Switch } from "#lib/components/ui/switch/index.js";
import { Tabs, TabsList, TabsTrigger } from "#lib/components/ui/tabs/index.js";
import { useAudioPlayer } from "#lib/hooks/use-audio-player.svelte.js";
import { useGainNode } from "#lib/hooks/use-gain-node.svelte.js";
import { useMicrophone } from "#lib/hooks/use-microphone.svelte.js";
import { useMixer } from "#lib/hooks/use-mixer.svelte.js";
import { useSystemAudio } from "#lib/hooks/use-system-audio.svelte.js";
import { useWebAudioMixer } from "#lib/hooks/use-web-audio-mixer.svelte.js";
import { cn } from "#lib/utils.js";
import MixerMasterStrip from "./mixer-master-strip.svelte";
import MixerMicrophoneChannel from "./mixer-microphone-channel.svelte";
import MixerPad from "./mixer-pad.svelte";
import MixerSourceStrip from "./mixer-source-strip.svelte";
let {
orientation = $bindable("horizontal"),
onOrientationChange,
sources = ALL_SOURCES,
persistKey,
onOutputChange,
tracks = NO_TRACKS,
sounds = NO_SOUNDS,
class: className,
}: SystemAudioMixerProps = $props();
const mixer = useMixer(() => ({ channels: CHANNELS, persistKey }));
let deviceId = $state<string | null>(null);
const microphone = useMicrophone(() => ({ deviceId }));
const system = useSystemAudio();
let trackIndex = $state(0);
const track = $derived(tracks[trackIndex]);
const nextTrack = () => {
trackIndex = tracks.length > 0 ? (trackIndex + 1) % tracks.length : 0;
};
const player = useAudioPlayer(() => ({ onEnded: nextTrack, src: track?.src }));
// Sound pads play into one bus so the mixer sees them as a single source.
const soundBus = useGainNode({ destination: null });
const graph = useWebAudioMixer(mixer, () => ({
ducking: { targets: ["music"], trigger: "microphone" },
inputs: {
microphone: microphone.stream,
music: player.element,
sounds: soundBus,
system: system.stream,
},
}));
// Fires when the stream changes, not when the callback does.
$effect(() => {
const output = graph.output;
untrack(() => onOutputChange?.(output));
});
const show = (id: MixerSourceId) => sources.includes(id);
const meterFor = (id: MixerSourceId) => graph.meters[id] ?? graph.master.meter;
const setOrientation = (next: string) => {
orientation = next as Orientation;
onOrientationChange?.(orientation);
};
const systemActive = $derived(system.status === "active");
</script>
{#snippet systemIcon()}
<DesktopIcon />
{/snippet}
{#snippet systemActions()}
<Switch
aria-label="Capture system audio"
bind:checked={() => systemActive, (checked) => (checked ? system.start() : system.stop())}
disabled={!system.isSupported}
size="sm"
/>
{/snippet}
{#snippet systemNotice()}
<ChannelStripNotice variant="warning">
Nothing to hear: tick "Share audio" in the browser's picker.
</ChannelStripNotice>
{/snippet}
{#snippet musicIcon()}
<MusicNotesIcon />
{/snippet}
{#snippet musicActions()}
<Button
aria-label={player.playing ? "Pause music" : "Play music"}
disabled={!track}
onclick={() => player.toggle()}
size="icon-xs"
variant="ghost"
>
{#if player.playing}
<PauseIcon />
{:else}
<PlayIcon />
{/if}
</Button>
<Button
aria-label="Next track"
disabled={tracks.length < 2}
onclick={nextTrack}
size="icon-xs"
variant="ghost"
>
<SkipForwardIcon />
</Button>
{/snippet}
{#snippet soundsIcon()}
<WaveformIcon />
{/snippet}
{#snippet soundsActions()}
<Popover>
<PopoverTrigger>
{#snippet child({ props })}
<Button
{...props}
aria-label="Sound pads"
disabled={sounds.length === 0}
size="icon-xs"
variant="ghost"
>
<SquaresFourIcon />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-80">
<SoundPadGrid class="[--pad-min-width:4rem]" columns={4} hotkeys>
{#each sounds as sound (sound.id)}
<MixerPad bus={soundBus} {sound} />
{/each}
</SoundPadGrid>
</PopoverContent>
</Popover>
{/snippet}
<Mixer class={cn("[--channel-strip-header-width:16rem]", className)} {orientation}>
<MixerHeader>
<MixerTitle>Audio mixer</MixerTitle>
<MixerActions>
<Tabs bind:value={() => orientation, setOrientation}>
<TabsList>
<TabsTrigger value="horizontal">Rows</TabsTrigger>
<TabsTrigger value="vertical">Console</TabsTrigger>
</TabsList>
</Tabs>
<Button aria-label="Reset mixer" onclick={() => mixer.reset()} size="icon-sm" variant="ghost">
<ArrowCounterClockwiseIcon />
</Button>
</MixerActions>
</MixerHeader>
<MixerChannels>
{#if show("microphone")}
<MixerMicrophoneChannel
{deviceId}
meter={meterFor("microphone")}
{microphone}
{mixer}
onDeviceChange={(next) => (deviceId = next)}
/>
{/if}
{#if show("system")}
<MixerSourceStrip
accent="var(--chart-4)"
actions={systemActions}
description={systemActive ? "Capturing" : "Share a screen or tab with audio"}
icon={systemIcon}
id="system"
meter={meterFor("system")}
{mixer}
monitorable={false}
notice={system.status === "no-audio" ? systemNotice : undefined}
status={systemStatus(system)}
title="System audio"
/>
{/if}
{#if show("music")}
<MixerSourceStrip
accent="var(--chart-1)"
actions={musicActions}
description={track ? track.title : "No tracks"}
icon={musicIcon}
id="music"
meter={meterFor("music")}
{mixer}
status={player.playing ? { label: "Playing", tone: "live" } : undefined}
title="Music"
/>
{/if}
{#if show("sounds")}
<MixerSourceStrip
accent="var(--chart-3)"
actions={soundsActions}
description={`${sounds.length} pads`}
icon={soundsIcon}
id="sounds"
meter={meterFor("sounds")}
{mixer}
title="Sounds"
/>
{/if}
</MixerChannels>
<MixerEmpty>No audio sources.</MixerEmpty>
<MixerSeparator />
<MixerMaster>
<MixerMasterStrip meter={graph.master.meter} {mixer} />
</MixerMaster>
</Mixer>src/lib/components/blocks/system-audio-mixer/mixer-microphone-channel.svelte
<script lang="ts" module>
import type { ChannelStripStatusTone } from "#lib/components/ui/channel-strip/index.js";
import type { UseMicrophoneResult } from "#lib/hooks/use-microphone.svelte.js";
import type { Mixer } from "#lib/hooks/use-mixer.svelte.js";
import type { FrameSource, MeterFrame } from "#lib/audio/types.js";
export interface MixerMicrophoneChannelProps {
microphone: UseMicrophoneResult;
deviceId: string | null;
onDeviceChange: (deviceId: string | null) => void;
mixer: Mixer;
meter: FrameSource<MeterFrame>;
}
const microphoneStatus = (
microphone: UseMicrophoneResult,
audible: boolean
): { label: string; tone: ChannelStripStatusTone } => {
if (microphone.status === "active") {
return audible ? { label: "Live", tone: "live" } : { label: "Muted", tone: "muted" };
}
if (microphone.status === "denied") {
return { label: "Blocked", tone: "error" };
}
return { label: "Off", tone: "default" };
};
</script>
<script lang="ts">
import GearSixIcon from "phosphor-svelte/lib/GearSixIcon";
import MicrophoneIcon from "phosphor-svelte/lib/MicrophoneIcon";
import { AudioDeviceSelect } from "#lib/components/ui/audio-device-select/index.js";
import { Button } from "#lib/components/ui/button/index.js";
import { ChannelStripNotice } from "#lib/components/ui/channel-strip/index.js";
import { Popover, PopoverContent, PopoverTrigger } from "#lib/components/ui/popover/index.js";
import { useAudioDevices } from "#lib/hooks/use-audio-devices.svelte.js";
import MixerSourceStrip from "./mixer-source-strip.svelte";
let { microphone, deviceId, onDeviceChange, mixer, meter }: MixerMicrophoneChannelProps =
$props();
const devices = useAudioDevices();
const active = $derived(microphone.status === "active");
const deviceLabel = $derived(
devices.devices.find((device) => device.id === deviceId)?.label ?? "Default microphone"
);
</script>
{#snippet icon()}
<MicrophoneIcon />
{/snippet}
{#snippet actions()}
<Popover>
<PopoverTrigger>
{#snippet child({ props })}
<Button {...props} aria-label="Microphone settings" size="icon-xs" variant="ghost">
<GearSixIcon />
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-72">
<AudioDeviceSelect
bind:value={() => deviceId, onDeviceChange}
devices={devices.devices}
loading={devices.isLoading}
onRequestPermission={() => devices.requestPermission()}
permission={devices.permission === "unsupported" ? "denied" : devices.permission}
/>
</PopoverContent>
</Popover>
<Button
onclick={() => (active ? microphone.stop() : microphone.start())}
size="xs"
variant={active ? "secondary" : "outline"}
>
{active ? "Stop" : "Start"}
</Button>
{/snippet}
{#snippet deniedNotice()}
<ChannelStripNotice variant="destructive">
Microphone access is blocked. Allow it in your browser's site settings.
</ChannelStripNotice>
{/snippet}
<MixerSourceStrip
accent="var(--chart-2)"
{actions}
description={active ? deviceLabel : "Not listening"}
{icon}
id="microphone"
{meter}
{mixer}
notice={microphone.status === "denied" ? deniedNotice : undefined}
status={microphoneStatus(microphone, mixer.isAudible("microphone"))}
title="Microphone"
/>src/lib/components/blocks/system-audio-mixer/mixer-source-strip.svelte
<script lang="ts" module>
import type { Snippet } from "svelte";
import type { ChannelStripStatusTone } from "#lib/components/ui/channel-strip/index.js";
import type { Mixer } from "#lib/hooks/use-mixer.svelte.js";
import type { FrameSource, MeterFrame } from "#lib/audio/types.js";
export interface MixerSourceStripProps {
id: string;
title: string;
description?: string | Snippet;
icon: Snippet;
accent?: string;
mixer: Mixer;
meter: FrameSource<MeterFrame>;
status?: { label: string; tone: ChannelStripStatusTone };
/** Buttons in the header. */
actions?: Snippet;
/** A ChannelStripNotice, shown under the strip. */
notice?: Snippet;
/** Show the monitor toggle. Default true. */
monitorable?: boolean;
disabled?: boolean;
}
</script>
<script lang="ts">
import HeadphonesIcon from "phosphor-svelte/lib/HeadphonesIcon";
import {
ChannelStrip,
ChannelStripActions,
ChannelStripControls,
ChannelStripDescription,
ChannelStripFader,
ChannelStripHeader,
ChannelStripIcon,
ChannelStripMeter,
ChannelStripStatus,
ChannelStripText,
ChannelStripTitle,
ChannelStripValue,
} from "#lib/components/ui/channel-strip/index.js";
import {
MonitorToggle,
MuteToggle,
SoloToggle,
} from "#lib/components/ui/channel-toggle/index.js";
import { Fader, FaderRange, FaderThumb, FaderTrack } from "#lib/components/ui/fader/index.js";
import { LevelMeter } from "#lib/components/ui/level-meter/index.js";
import { formatDb } from "#lib/audio/decibels.js";
let {
id,
title,
description,
icon,
accent,
mixer,
meter,
status,
actions,
notice,
monitorable = true,
disabled = false,
}: MixerSourceStripProps = $props();
const channel = $derived(mixer.channel(id));
</script>
{#if channel}
<ChannelStrip
{accent}
dimmed={mixer.isDimmed(id)}
{disabled}
muted={channel.muted}
solo={channel.solo}
>
<ChannelStripHeader>
<ChannelStripIcon>{@render icon()}</ChannelStripIcon>
<ChannelStripText>
<ChannelStripTitle>{title}</ChannelStripTitle>
{#if description}
<ChannelStripDescription>
{#if typeof description === "string"}
{description}
{:else}
{@render description()}
{/if}
</ChannelStripDescription>
{/if}
</ChannelStripText>
{#if status}
<ChannelStripStatus tone={status.tone}>
{status.label}
</ChannelStripStatus>
{/if}
{#if actions}
<ChannelStripActions>{@render actions()}</ChannelStripActions>
{/if}
</ChannelStripHeader>
<ChannelStripMeter>
<LevelMeter
aria-label={`${title} level`}
channelCount={2}
class="h-full"
size="sm"
source={meter}
/>
</ChannelStripMeter>
<ChannelStripFader>
<Fader
aria-label={`${title} volume`}
bind:value={() => channel.gainDb, (gainDb) => mixer.setGain(id, gainDb)}
max={12}
min={-60}
silenceAtMin
size="sm"
taper="audio"
>
<FaderTrack>
<FaderRange />
<FaderThumb />
</FaderTrack>
</Fader>
</ChannelStripFader>
<ChannelStripValue>
{formatDb(channel.gainDb, { floorDb: -60 })}
</ChannelStripValue>
<ChannelStripControls>
<MuteToggle
aria-label={`Mute ${title}`}
bind:pressed={() => channel.muted, (muted) => mixer.setMuted(id, muted)}
size="sm"
>
M
</MuteToggle>
<SoloToggle
aria-label={`Solo ${title}`}
bind:pressed={() => channel.solo, (solo) => mixer.setSolo(id, solo)}
size="sm"
>
S
</SoloToggle>
{#if monitorable}
<MonitorToggle
aria-label={`Monitor ${title}`}
bind:pressed={() => channel.monitor, (monitor) => mixer.setMonitor(id, monitor)}
size="sm"
>
<HeadphonesIcon />
</MonitorToggle>
{/if}
</ChannelStripControls>
{@render notice?.()}
</ChannelStrip>
{/if}src/lib/components/blocks/system-audio-mixer/mixer-master-strip.svelte
<script lang="ts" module>
import type { Mixer } from "#lib/hooks/use-mixer.svelte.js";
import type { FrameSource, MeterFrame } from "#lib/audio/types.js";
export interface MixerMasterStripProps {
mixer: Mixer;
meter: FrameSource<MeterFrame>;
}
const CHANNELS = [0, 1];
</script>
<script lang="ts">
import SpeakerHighIcon from "phosphor-svelte/lib/SpeakerHighIcon";
import {
ChannelStrip,
ChannelStripControls,
ChannelStripDescription,
ChannelStripFader,
ChannelStripHeader,
ChannelStripIcon,
ChannelStripMeter,
ChannelStripText,
ChannelStripTitle,
ChannelStripValue,
} from "#lib/components/ui/channel-strip/index.js";
import { MuteToggle } from "#lib/components/ui/channel-toggle/index.js";
import { Fader, FaderRange, FaderThumb, FaderTrack } from "#lib/components/ui/fader/index.js";
import {
LevelMeter,
LevelMeterBar,
LevelMeterChannel,
LevelMeterChannels,
LevelMeterClip,
LevelMeterHold,
LevelMeterTrack,
} from "#lib/components/ui/level-meter/index.js";
import { formatDb } from "#lib/audio/decibels.js";
let { mixer, meter }: MixerMasterStripProps = $props();
</script>
<ChannelStrip muted={mixer.master.muted} variant="master">
<ChannelStripHeader>
<ChannelStripIcon>
<SpeakerHighIcon />
</ChannelStripIcon>
<ChannelStripText>
<ChannelStripTitle>Master</ChannelStripTitle>
<ChannelStripDescription>Mix output</ChannelStripDescription>
</ChannelStripText>
</ChannelStripHeader>
<ChannelStripMeter>
<LevelMeter aria-label="Master level" class="h-full" source={meter}>
<LevelMeterChannels>
{#each CHANNELS as index (index)}
<LevelMeterChannel {index}>
<LevelMeterTrack>
<LevelMeterBar />
<LevelMeterHold />
</LevelMeterTrack>
</LevelMeterChannel>
{/each}
</LevelMeterChannels>
<LevelMeterClip showCount />
</LevelMeter>
</ChannelStripMeter>
<ChannelStripFader>
<Fader
aria-label="Master volume"
bind:value={() => mixer.master.gainDb, (value) => mixer.setMasterGain(value)}
max={6}
min={-60}
silenceAtMin
size="sm"
taper="audio"
>
<FaderTrack>
<FaderRange />
<FaderThumb />
</FaderTrack>
</Fader>
</ChannelStripFader>
<ChannelStripValue>
{formatDb(mixer.master.gainDb, { floorDb: -60 })}
</ChannelStripValue>
<ChannelStripControls>
<MuteToggle
aria-label="Mute master"
bind:pressed={() => mixer.master.muted, (value) => mixer.setMasterMuted(value)}
size="sm"
>
M
</MuteToggle>
</ChannelStripControls>
</ChannelStrip>src/lib/components/blocks/system-audio-mixer/mixer-pad.svelte
<script lang="ts">
import {
SoundPad,
SoundPadLabel,
SoundPadProgress,
SoundPadShortcut,
} from "#lib/components/ui/sound-pad/index.js";
import { useSound } from "#lib/hooks/use-sound.svelte.js";
import type { MixerSound } from "./system-audio-mixer.svelte";
let { sound, bus }: { sound: MixerSound; bus: AudioNode | null } = $props();
const player = useSound(
() => sound.src,
() => ({ destination: bus })
);
</script>
<SoundPad
accent={sound.accent}
hotkey={sound.hotkey}
loading={!player.isLoaded}
onTrigger={() => player.play()}
playing={player.isPlaying}
size="sm"
>
<SoundPadLabel>{sound.label}</SoundPadLabel>
<SoundPadShortcut />
<SoundPadProgress source={player.progress} />
</SoundPad>