audiocn

System Audio Mixer

The complete mixer, with microphone, system audio, music, sound pads and a master, running on Web Audio.

Audio mixer

MicrophoneNot listening
Off
0.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-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

Audio mixer

MicrophoneNot listening
Off
0.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

components/blocks/system-audio-mixer/system-audio-mixer.tsx
"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&apos;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 &quot;Share audio&quot; in the browser&apos;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>
  );
};
components/blocks/system-audio-mixer/mixer-source-strip.tsx
"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>
  );
};
components/blocks/system-audio-mixer/mixer-master-strip.tsx
"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>
);