# Block mixer

> Mixing console: four tracks of a loop with pan, fader, meter, mute and solo, into a master with a stereo meter, every meter reading its real signal

A shadcn/ui block from the [@audio-ui](https://www.audio-ui.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/audio-ui/block-mixer).

- Page, with a live preview: https://shadcn.exchange/r/audio-ui/block-mixer
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/audio-ui/block-mixer-light-f5c5e14047af474d.png
- Registry item: https://www.audio-ui.dev/r/new-york-v4/block-mixer.json
- Listed: 2026-10-17, source last changed 2026-10-17

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/audio-ui/block-mixer.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@audio-ui/core`, `@audio-ui/react`
- Registry items: `@audio-ui/fader`, `@audio-ui/knob`, `@audio-ui/meter`, `@audio-ui/use-audio-levels`, `@audio-ui/use-master-bus`, `@audio-ui/use-transport-clock`, `@audio-ui/web-audio`, `@audio-ui/drum-synthesis`, `@shadcn/button`, `@shadcn/toggle`

## Files

### `block-mixer.tsx`

```tsx
"use client";

import { noteToFrequency } from "@audio-ui/core/pitch";
import { createSource, type Meter as MeterPrimitive } from "@audio-ui/react";
import { useCallback, useEffect, useState } from "react";
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder";
import { Button } from "@/components/ui/button";
import { Toggle } from "@/components/ui/toggle";
import { Fader } from "@/components/audio-ui/fader";
import { Knob } from "@/components/audio-ui/knob";
import { Meter } from "@/components/audio-ui/meter";
import { useAudioLevels } from "@/hooks/use-audio-levels";
import { useMasterBus } from "@/hooks/use-master-bus";
import { useTransportClock } from "@/hooks/use-transport-clock";
import {
  triggerHihat,
  triggerKick,
  triggerSnare,
} from "@/lib/drum-synthesis";
import { webAudio } from "@/lib/web-audio";

const BPM = 100;
const STEPS_PER_BEAT = 4;
const STEPS_PER_BAR = 16;
/** The fader's floor: at it, the channel is silent. */
const MIN_DB = -60;
const MAX_DB = 6;
/** How fast a gain or a pan follows its control, as a time constant in seconds. */
const RAMP = 0.01;
/** Mute and solo are filled when on, as the transport's toggles are. */
const SWITCH =
  "min-w-0 flex-1 px-0 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90 aria-pressed:hover:text-primary-foreground";

/** Am, F, C, G, a bar each: the bass note, then the chord above it. */
const CHORDS = [
  [45, 57, 60, 64],
  [41, 53, 57, 60],
  [36, 55, 60, 64],
  [43, 55, 59, 62],
];
const TOTAL_STEPS = CHORDS.length * STEPS_PER_BAR;

interface Tone {
  /** Seconds. */
  attack: number;
  /** Low-pass corner in hertz, for a darker tone. */
  cutoff?: number;
  /** Seconds from the start to the release. */
  duration: number;
  /** Gain at the top of the attack. */
  level: number;
  /** Seconds. */
  release: number;
  type: OscillatorType;
}

/** One note: an oscillator through an envelope, scheduled at `time`. */
function playTone(
  ctx: AudioContext,
  destination: AudioNode,
  note: number,
  time: number,
  { attack, cutoff, duration, level, release, type }: Tone
) {
  const oscillator = ctx.createOscillator();
  oscillator.type = type;
  oscillator.frequency.value = noteToFrequency(note);
  const envelope = ctx.createGain();
  envelope.gain.setValueAtTime(0, time);
  envelope.gain.linearRampToValueAtTime(level, time + attack);
  envelope.gain.setValueAtTime(level, time + duration);
  envelope.gain.linearRampToValueAtTime(0, time + duration + release);
  if (cutoff) {
    const filter = ctx.createBiquadFilter();
    filter.frequency.value = cutoff;
    oscillator.connect(filter).connect(envelope);
  } else {
    oscillator.connect(envelope);
  }
  envelope.connect(destination);
  oscillator.start(time);
  oscillator.stop(time + duration + release);
}

interface Track {
  /** Fader position in dB. */
  gain: number;
  id: string;
  label: string;
  /** From -1 (left) to 1 (right). */
  pan: number;
  /** Schedules the track's part for one sixteenth: `step` within the bar, `chord` the bar's. */
  play: (
    ctx: AudioContext,
    destination: AudioNode,
    step: number,
    chord: readonly number[],
    time: number,
    secondsPerStep: number
  ) => void;
}

const playDrums: Track["play"] = (ctx, destination, step, _chord, time) => {
  if (step % 8 === 0 || step === 10) {
    triggerKick(ctx, destination, time);
  }
  if (step % 8 === 4) {
    triggerSnare(ctx, destination, time);
  }
  if (step % 2 === 0) {
    const hat = ctx.createGain();
    hat.gain.value = step % 4 === 2 ? 0.6 : 0.35;
    hat.connect(destination);
    triggerHihat(ctx, hat, time);
  }
};

/** Eighths on the root, up an octave on the last of each beat pair. */
const playBass: Track["play"] = (ctx, destination, step, chord, time, spb) => {
  if (step % 2 !== 0) {
    return;
  }
  const root = chord[0] ?? 0;
  playTone(ctx, destination, step % 8 === 6 ? root + 12 : root, time, {
    attack: 0.005,
    cutoff: 700,
    duration: spb * 1.6,
    level: 0.5,
    release: 0.05,
    type: "sawtooth",
  });
};

/** Short chords on the offbeats. */
const playKeys: Track["play"] = (ctx, destination, step, chord, time) => {
  if (step % 4 !== 2) {
    return;
  }
  for (const note of chord.slice(1)) {
    playTone(ctx, destination, note + 12, time, {
      attack: 0.005,
      duration: 0.06,
      level: 0.2,
      release: 0.12,
      type: "triangle",
    });
  }
};

/** The chord held for the bar, fading in. */
const playPad: Track["play"] = (ctx, destination, step, chord, time, spb) => {
  if (step !== 0) {
    return;
  }
  for (const note of chord.slice(1)) {
    playTone(ctx, destination, note, time, {
      attack: 0.4,
      cutoff: 1800,
      duration: spb * STEPS_PER_BAR - 0.4,
      level: 0.12,
      release: 0.4,
      type: "sawtooth",
    });
  }
};

const TRACKS: Track[] = [
  { gain: 0, id: "drums", label: "Drums", pan: 0, play: playDrums },
  { gain: -4, id: "bass", label: "Bass", pan: 0, play: playBass },
  { gain: -6, id: "keys", label: "Keys", pan: -0.3, play: playKeys },
  { gain: -9, id: "pad", label: "Pad", pan: 0.3, play: playPad },
];

const dbToGain = (db: number) => (db <= MIN_DB ? 0 : 10 ** (db / 20));
const formatDb = (db: number) => {
  if (db <= MIN_DB) {
    return "-∞";
  }
  return `${db > 0 ? "+" : ""}${db.toFixed(1)}`;
};
const formatVolume = (db: number) => `${formatDb(db)} dB`;
const formatPan = (pan: number) => {
  const amount = Math.round(Math.abs(pan) * 100);
  if (amount === 0) {
    return "C";
  }
  return `${pan < 0 ? "L" : "R"}${amount}`;
};

/** The nodes of one channel: what plays into `input` comes out of `output` into the master. */
interface ChannelNodes {
  analysers: AnalyserNode[];
  fader: GainNode;
  input: GainNode;
  mute: GainNode;
  panner: StereoPannerNode;
}

function createChannel(ctx: AudioContext, output: AudioNode): ChannelNodes {
  const input = ctx.createGain();
  const fader = ctx.createGain();
  const mute = ctx.createGain();
  const panner = ctx.createStereoPanner();
  const analyser = ctx.createAnalyser();
  analyser.fftSize = 1024;
  input.connect(fader).connect(mute).connect(panner).connect(output);
  // The meter reads the channel after its fader and its mute, as it plays into the mix.
  mute.connect(analyser);
  return { analysers: [analyser], fader, input, mute, panner };
}

const SILENT_MONO = createSource<MeterPrimitive.Root.Frame>({
  levels: [-100],
});
const SILENT_STEREO = createSource<MeterPrimitive.Root.Frame>({
  levels: [-100, -100],
});
const NO_ANALYSERS: AnalyserNode[] = [];

function ChannelMeter({
  analysers,
  label,
  stereo = false,
}: {
  analysers: AnalyserNode[] | undefined;
  label: string;
  stereo?: boolean;
}) {
  const levels = useAudioLevels(analysers ?? NO_ANALYSERS);
  const silent = stereo ? SILENT_STEREO : SILENT_MONO;
  return (
    <Meter
      aria-label={`${label} level`}
      className="data-[orientation=vertical]:h-full"
      peakMarks="hidden"
      size="sm"
      source={analysers ? levels : silent}
      warnThreshold={-12}
    />
  );
}

/** A channel's fader and its meter side by side, the value under them. */
function ChannelLevel({
  analysers,
  gain,
  label,
  onGainChange,
  stereo,
}: {
  analysers: AnalyserNode[] | undefined;
  gain: number;
  label: string;
  onGainChange: (gain: number) => void;
  stereo?: boolean;
}) {
  return (
    <>
      <div className="flex h-44 justify-center gap-1.5">
        <Fader
          aria-label={`${label} volume`}
          formatValue={formatVolume}
          largeStep={6}
          max={MAX_DB}
          min={MIN_DB}
          onValueChange={onGainChange}
          size="sm"
          step={0.5}
          thumbMarks={1}
          value={gain}
          valueLabel="hidden"
        />
        <ChannelMeter analysers={analysers} label={label} stereo={stereo} />
      </div>
      <span
        aria-hidden="true"
        className="text-muted-foreground text-xs tabular-nums"
      >
        {formatDb(gain)}
      </span>
    </>
  );
}

interface ChannelState {
  gain: number;
  muted: boolean;
  pan: number;
  soloed: boolean;
}

function Channel({
  index,
  nodes,
  onChange,
  state,
  track,
}: {
  index: number;
  nodes: ChannelNodes | undefined;
  onChange: (index: number, change: Partial<ChannelState>) => void;
  state: ChannelState;
  track: Track;
}) {
  const setGain = useCallback(
    (gain: number) => onChange(index, { gain }),
    [index, onChange]
  );
  const setPan = useCallback(
    (pan: number) => onChange(index, { pan }),
    [index, onChange]
  );
  const setMuted = useCallback(
    (muted: boolean) => onChange(index, { muted }),
    [index, onChange]
  );
  const setSoloed = useCallback(
    (soloed: boolean) => onChange(index, { soloed }),
    [index, onChange]
  );
  return (
    <div
      aria-label={track.label}
      className="row-span-5 grid min-w-0 grid-rows-subgrid justify-items-center"
      role="group"
    >
      <Knob
        anchor={0}
        aria-label={`${track.label} pan`}
        defaultValue={track.pan}
        formatValue={formatPan}
        largeStep={0.25}
        max={1}
        min={-1}
        onValueChange={setPan}
        size="sm"
        step={0.01}
        value={state.pan}
        valueLabel="always"
      />
      <ChannelLevel
        analysers={nodes?.analysers}
        gain={state.gain}
        label={track.label}
        onGainChange={setGain}
      />
      <div className="flex w-full gap-1">
        <Toggle
          aria-label={`Mute ${track.label}`}
          className={SWITCH}
          onPressedChange={setMuted}
          pressed={state.muted}
          size="sm"
          variant="outline"
        >
          M
        </Toggle>
        <Toggle
          aria-label={`Solo ${track.label}`}
          className={SWITCH}
          onPressedChange={setSoloed}
          pressed={state.soloed}
          size="sm"
          variant="outline"
        >
          S
        </Toggle>
      </div>
      <span className="max-w-full truncate text-xs">{track.label}</span>
    </div>
  );
}

const INITIAL_CHANNELS: ChannelState[] = TRACKS.map((track) => ({
  gain: track.gain,
  muted: false,
  pan: track.pan,
  soloed: false,
}));

/**
 * A mixing console: four tracks of a loop, each with its pan, its fader and
 * its meter, mute and solo, into a master with a stereo meter. Every meter
 * reads the real signal of its channel.
 */
export function BlockMixer() {
  const [channels, setChannels] = useState(INITIAL_CHANNELS);
  const [masterGain, setMasterGain] = useState(0);
  const master = useMasterBus(dbToGain(masterGain));
  const [nodes, setNodes] = useState<ChannelNodes[] | null>(null);
  const [masterAnalysers, setMasterAnalysers] = useState<AnalyserNode[]>();

  useEffect(() => {
    const ctx = webAudio.getContext();
    if (!(ctx && master)) {
      return;
    }
    const created = TRACKS.map(() => createChannel(ctx, master));
    // The master's meter reads its two sides after its fader.
    const splitter = ctx.createChannelSplitter(2);
    master.connect(splitter);
    const sides = [0, 1].map((side) => {
      const analyser = ctx.createAnalyser();
      analyser.fftSize = 1024;
      splitter.connect(analyser, side);
      return analyser;
    });
    setNodes(created);
    setMasterAnalysers(sides);
    return () => {
      for (const channel of created) {
        channel.input.disconnect();
        channel.panner.disconnect();
      }
      splitter.disconnect();
      setNodes(null);
      setMasterAnalysers(undefined);
    };
  }, [master]);

  // A soloed channel silences every channel that is not.
  const anySoloed = channels.some((channel) => channel.soloed);
  useEffect(() => {
    const ctx = webAudio.getContext();
    if (!(ctx && nodes)) {
      return;
    }
    channels.forEach((channel, index) => {
      const node = nodes[index];
      if (!node) {
        return;
      }
      const audible = !channel.muted && (channel.soloed || !anySoloed);
      node.fader.gain.setTargetAtTime(
        dbToGain(channel.gain),
        ctx.currentTime,
        RAMP
      );
      node.mute.gain.setTargetAtTime(audible ? 1 : 0, ctx.currentTime, RAMP);
      node.panner.pan.setTargetAtTime(channel.pan, ctx.currentTime, RAMP);
    });
  }, [anySoloed, channels, nodes]);

  const playStep = useCallback(
    (step: number, time: number) => {
      const ctx = webAudio.getContext();
      if (!(ctx && nodes)) {
        return;
      }
      const chord = CHORDS[Math.floor(step / STEPS_PER_BAR)] ?? [];
      const secondsPerStep = 60 / BPM / STEPS_PER_BEAT;
      TRACKS.forEach((track, index) => {
        const input = nodes[index]?.input;
        if (input) {
          track.play(
            ctx,
            input,
            step % STEPS_PER_BAR,
            chord,
            time,
            secondsPerStep
          );
        }
      });
    },
    [nodes]
  );
  const { isPlaying, start, stop } = useTransportClock({
    bpm: BPM,
    onStep: playStep,
    stepsPerBeat: STEPS_PER_BEAT,
    totalSteps: TOTAL_STEPS,
  });
  const toggle = useCallback(() => {
    if (isPlaying) {
      stop();
      return;
    }
    const ctx = webAudio.getContext();
    if (ctx?.state === "suspended") {
      ctx.resume();
    }
    start();
  }, [isPlaying, start, stop]);

  const change = useCallback(
    (index: number, update: Partial<ChannelState>) =>
      setChannels((current) =>
        current.map((channel, at) =>
          at === index ? { ...channel, ...update } : channel
        )
      ),
    []
  );

  return (
    <div className="rounded-xl @container/panel w-full min-w-0 border bg-card p-4">
      <div className="mx-auto grid w-full max-w-md grid-cols-[repeat(4,minmax(0,1fr))_minmax(min-content,1fr)] gap-2">
        {TRACKS.map((track, index) => (
          <Channel
            index={index}
            key={track.id}
            nodes={nodes?.[index]}
            onChange={change}
            state={channels[index] ?? INITIAL_CHANNELS[0]}
            track={track}
          />
        ))}
        <div
          aria-label="Master"
          className="row-span-5 grid min-w-0 grid-rows-subgrid justify-items-center border-s ps-2"
          role="group"
        >
          <Button
            aria-label={isPlaying ? "Stop" : "Play"}
            onClick={toggle}
            size="icon"
          >
            {isPlaying ? (
              <IconPlaceholder
                hugeicons="StopIcon"
                lucide="SquareIcon"
                phosphor="StopIcon"
                remixicon="RiStopFill"
                tabler="IconPlayerStopFilled"
              />
            ) : (
              <IconPlaceholder
                hugeicons="PlayIcon"
                lucide="PlayIcon"
                phosphor="PlayIcon"
                remixicon="RiPlayFill"
                tabler="IconPlayerPlayFilled"
              />
            )}
          </Button>
          <ChannelLevel
            analysers={masterAnalysers}
            gain={masterGain}
            label="Master"
            onGainChange={setMasterGain}
            stereo
          />
          <div />
          <span className="text-xs">Master</span>
        </div>
      </div>
    </div>
  );
}
```
