audiocnSolid

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-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

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
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>
  );
};
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<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>
  );
};
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<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>
);