audiocnSolid

Mixer

The container for channel strips, with shared meter settings and keyboard navigation.

Audio mixer

Microphone
0.0 dB
System audio
0.0 dB
Music
0.0 dB
Master

The preview meters each synthetic channel after its fader, mute and solo settings. Muting or excluding a channel with solo clears its meter immediately. The master combines those channels, then applies the master fader. It clears immediately when no channels are audible.

Copy the demo signal helper

The example uses a docs-only useDemoMixer helper. To copy the preview, save the code below as lib/docs/use-demo-mixer.ts. It uses useDemoSignal and useMixer.

Mono channels feed both sides. The helper estimates peak by summing amplitudes and RMS by summing powers. For audio samples, connect useWebAudioMixer to the same components.

lib/docs/use-demo-mixer.ts
import type { Accessor } from "solid-js";

import { createDemoSignal } from "@/hooks/use-demo-signal";
import type { DemoSignalOptions } from "@/hooks/use-demo-signal";
import { isChannelAudible } from "@/hooks/use-mixer";
import type { MixerState } from "@/hooks/use-mixer";
import { dbToGain, gainToDb } from "@/lib/audio/decibels";
import { subscribeFrame } from "@/lib/audio/frame-loop";
import type { FrameSource, MeterFrame } from "@/lib/audio/types";
import { createCompatEffect } from "@/lib/solid/effect";

interface DemoMixerChannel extends DemoSignalOptions {
  id: string;
}

/** Docs-only metering: sum peak amplitudes and RMS powers, with mono on both sides. */
export const createDemoMixer = (channels: readonly DemoMixerChannel[]) => {
  const signals = channels.map(({ id, ...options }) => ({
    id,
    signal: createDemoSignal(options),
  }));

  let masterGain = 1;
  const subscribers = new Set<(frame: MeterFrame) => void>();
  let release: (() => void) | undefined;

  const master: FrameSource<MeterFrame> = {
    subscribe: (listener) => {
      subscribers.add(listener);

      if (!release) {
        const frames = new Map<string, MeterFrame>();

        const unsubscribes = signals.map(({ id, signal }) =>
          signal.meter.subscribe((frame) => frames.set(id, frame))
        );

        // Input subscriptions run first, so every mix uses the same frame.
        const stop = subscribeFrame(() => {
          const frame: MeterFrame = { channels: [] };

          for (let side = 0; side < 2; side += 1) {
            let peak = 0;
            let power = 0;

            for (const input of frames.values()) {
              const level =
                input.channels[input.channels.length === 1 ? 0 : side];

              if (level) {
                peak += dbToGain(level.peakDb);
                power += dbToGain(level.rmsDb ?? level.peakDb) ** 2;
              }
            }

            frame.channels.push({
              peakDb: gainToDb(peak * masterGain),
              rmsDb: gainToDb(Math.sqrt(power) * masterGain),
            });
          }

          for (const subscriber of subscribers) subscriber(frame);
        }, "update");

        release = () => {
          stop();

          for (const unsubscribe of unsubscribes) unsubscribe();
        };
      }

      return () => {
        subscribers.delete(listener);

        if (subscribers.size === 0) {
          release?.();
          release = undefined;
        }
      };
    },
  };

  return {
    configure: (state: MixerState) => {
      for (const { id, signal } of signals) {
        const channel = state.channels.find((item) => item.id === id);
        signal.configure({
          gainDb: channel?.gainDb ?? 0,
          playing: isChannelAudible(state, id),
        });
      }

      masterGain = state.master.muted ? 0 : dbToGain(state.master.gainDb);
    },
    master,
    sources: Object.fromEntries(
      signals.map(({ id, signal }) => [id, signal.meter])
    ),
  };
};

/** Stable demo sources; gain, mute and solo follow the reactive mixer state. */
export const useDemoMixer = (
  channels: readonly DemoMixerChannel[],
  state: Accessor<MixerState>
) => {
  const demo = createDemoMixer(channels);
  createCompatEffect(state, demo.configure);

  return demo;
};

Installation

$ pnpm dlx shadcn@latest add @audiocn-solid/mixer

Usage

import {
  Mixer,
  MixerChannels,
  MixerHeader,
  MixerMaster,
  MixerTitle,
} from "@/components/ui/mixer";
<Mixer orientation="vertical">
  <MixerHeader>
    <MixerTitle>Audio mixer</MixerTitle>
  </MixerHeader>
  <MixerChannels>
    {channels.map((channel) => (
      <ChannelStrip key={channel.id}>…</ChannelStrip>
    ))}
  </MixerChannels>
  <MixerSeparator />
  <MixerMaster>
    <ChannelStrip variant="master">…</ChannelStrip>
  </MixerMaster>
</Mixer>

The mixer holds no audio and no state. Pair it with useMixer for state and useWebAudioMixer for sound, or with your own engine.

Anatomy

<Mixer>
  <MixerHeader>
    <MixerTitle />
    <MixerActions />
  </MixerHeader>
  <MixerChannels />
  <MixerEmpty />
  <MixerSeparator />
  <MixerMaster />
</Mixer>

Examples

Console

Sixteen strips in a console. Meters paint outside Solid, so a full console keeps the frame rate with no re-renders while it meters.

Console

In 1
0.0 dB
In 2
0.0 dB
In 3
0.0 dB
In 4
0.0 dB
In 5
0.0 dB
In 6
0.0 dB
In 7
0.0 dB
In 8
0.0 dB
In 9
0.0 dB
In 10
0.0 dB
In 11
0.0 dB
In 12
0.0 dB
In 13
0.0 dB
In 14
0.0 dB
In 15
0.0 dB
In 16
0.0 dB

Empty

Audio mixer

A complete mixer

The System Audio Mixer block is a working mixer built from these parts.

Behaviour

  • Every meter in the mixer shares minDb, maxDb, zones and ballistics, so levels compare fairly across channels.
  • With focus on a control in a strip, Ctrl + arrow keys move to the same control on the neighbouring strip: left and right in a console, up and down in rows. Tab still moves through every control in order.
  • MixerChannels scrolls along the strip axis when strips overflow; the master area stays in place.
  • MixerEmpty appears when MixerChannels has no children.

API reference

Mixer

Prop

Type

MixerChannels

Prop

Type