# Fader

> A linear slider for gain and other continuous parameters.

```tsx
import { Fader } from "@/components/audio-ui/fader";

const MARKS = [
  { label: "0", value: 0 },
  25,
  50,
  75,
  { label: "100", value: 100 },
];

export function FaderVerticalDemo() {
  return (
    <Fader
      aria-label="Volume"
      defaultValue={50}
      editableValue
      marks={MARKS}
      max={100}
      min={0}
      step={1}
    />
  );
}
```

A linear control for continuous parameters such as volume, gain or a send level. The registry component wraps the `Fader` primitive from `@audio-ui/react` and adds size variants and thumb marks.

## Overview

- **Input:** drag the thumb or press the track, or use the mouse wheel or the keyboard. `onValueCommitted` fires when the interaction ends.
- **Layout:** vertical by default, or horizontal, in `sm`, `default` and `lg` sizes.
- **Display:** a filled range and a thumb, with optional marks inside the thumb (`thumbMarks`).
- **Accessibility:** the thumb holds a native `<input type="range">` that carries the accessible name (`aria-label` or `aria-labelledby`), the value text (formatted with `format`) and the form value (`name`).

## Installation

**CLI**

```bash
npx shadcn@latest add @audio-ui/fader
```

**Manual**

**1. Install the following dependencies:**

```bash
npm install @audio-ui/react class-variance-authority
```

**2. Copy and paste the following code into your project.**

```tsx title="components/audio-ui/fader.tsx"
"use client";

import { Fader as FaderPrimitive } from "@audio-ui/react";
import { cva, type VariantProps } from "class-variance-authority";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";

const faderVariants = cva("", {
  defaultVariants: {
    size: "default",
  },
  variants: {
    size: {
      default: "",
      lg: "",
      sm: "",
    },
  },
});

const faderTrackVariants = cva(
  "rounded-full bg-input/90 relative grow cursor-pointer select-none overflow-hidden",
  {
    defaultVariants: {
      size: "default",
    },
    variants: {
      size: {
        default: "data-[orientation=horizontal]:h-2 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-2",
        lg: "data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-2.5",
        sm: "data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5",
      },
    },
  }
);

const faderThumbVariants = cva(
  "border-ring border bg-card transition-[color,box-shadow] hover:ring-2 hover:ring-ring/40 has-focus-visible:ring-3 has-focus-visible:ring-ring/50 active:ring-3 active:ring-ring/50 motion-reduce:transition-none before:-inset-2 absolute z-10 block shrink-0 cursor-grab select-none outline-none before:absolute before:content-[''] active:cursor-grabbing data-disabled:pointer-events-none",
  {
    defaultVariants: {
      size: "default",
    },
    variants: {
      size: {
        default: "rounded-[min(var(--radius-md),10px)] data-[orientation=horizontal]:h-4 data-[orientation=vertical]:h-6 data-[orientation=horizontal]:w-6 data-[orientation=vertical]:w-4",
        lg: "rounded-[min(var(--radius-lg),12px)] data-[orientation=horizontal]:h-5 data-[orientation=vertical]:h-7 data-[orientation=horizontal]:w-7 data-[orientation=vertical]:w-5",
        sm: "rounded-[min(var(--radius-sm),8px)] data-[orientation=horizontal]:h-3.5 data-[orientation=vertical]:h-5 data-[orientation=horizontal]:w-5 data-[orientation=vertical]:w-3.5",
      },
    },
  }
);

const faderThumbInnerVariants = cva(
  "flex h-full w-full items-center justify-center gap-0.5 data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
  {
    defaultVariants: {
      size: "default",
    },
    variants: {
      size: {
        default: "data-[orientation=horizontal]:px-1.5 data-[orientation=horizontal]:py-1 data-[orientation=vertical]:px-1 data-[orientation=vertical]:py-1.5",
        lg: "data-[orientation=horizontal]:px-2 data-[orientation=horizontal]:py-1 data-[orientation=vertical]:px-1 data-[orientation=vertical]:py-2",
        sm: "data-[orientation=horizontal]:px-1 data-[orientation=horizontal]:py-0.5 data-[orientation=vertical]:px-0.5 data-[orientation=vertical]:py-1",
      },
    },
  }
);

const faderThumbMarkVariants = cva("bg-primary opacity-50", {
  defaultVariants: {
    size: "default",
  },
  variants: {
    size: {
      default: "data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:h-px data-[orientation=horizontal]:w-px data-[orientation=vertical]:w-2.5",
      lg: "data-[orientation=horizontal]:h-3 data-[orientation=vertical]:h-px data-[orientation=horizontal]:w-px data-[orientation=vertical]:w-3",
      sm: "data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:h-px data-[orientation=horizontal]:w-px data-[orientation=vertical]:w-2.5",
    },
  },
});

/** A graduation on the fader's scale: a value, or a value and its label. */
export type FaderMark = number | { label?: ReactNode; value: number };

interface FaderProps
  extends FaderPrimitive.Root.Props,
    VariantProps<typeof faderVariants> {
  /** Lets you type the value: a double click on the readout, or Enter while it has focus. */
  editableValue?: boolean;
  /** Prints the value in the readout and in what screen readers announce, e.g. as `"-6 dB"`. Defaults to `format`/`locale`. */
  formatValue?: (value: number) => string;
  /** Graduations drawn beside the track: a ruler for the value. */
  marks?: readonly FaderMark[];
  thumbMarks?: number | false;
  /** When the value shows beside the thumb: while hovered, dragged or focused (`auto`), `always`, or `hidden`. */
  valueLabel?: "always" | "auto" | "hidden";
}

const markValue = (mark: FaderMark) =>
  typeof mark === "number" ? mark : mark.value;
const markLabel = (mark: FaderMark) =>
  typeof mark === "number" ? undefined : mark.label;

export function Fader({
  "aria-describedby": ariaDescribedBy,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
  className,
  editableValue = false,
  formatValue,
  marks,
  size,
  orientation = "vertical",
  thumbMarks = 3,
  valueLabel = "auto",
  ...props
}: FaderProps) {
  const hasLabels = marks?.some((mark) => markLabel(mark) !== undefined);
  return (
    <FaderPrimitive.Root
      className={cn(
        "group/fader relative data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-disabled:cursor-not-allowed",
        marks &&
          "data-[orientation=horizontal]:pb-3 data-[orientation=vertical]:ps-3",
        hasLabels &&
          "data-[orientation=horizontal]:pb-6 data-[orientation=vertical]:ps-10",
        className
      )}
      orientation={orientation}
      {...props}
    >
      <FaderPrimitive.Control
        className={cn(
          "relative flex w-full touch-none select-none items-center",
          "data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-40 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
          "data-[orientation=horizontal]:w-full data-[orientation=horizontal]:min-w-32",
          "transition-opacity duration-150 ease-out motion-reduce:transition-none data-disabled:pointer-events-none data-disabled:opacity-50",
          faderVariants({ size })
        )}
      >
        <FaderPrimitive.Track
          className={faderTrackVariants({ size })}
          data-slot="fader-track"
        >
          <FaderPrimitive.Indicator
            className={cn(
              "bg-primary absolute select-none",
              "data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
              "data-[orientation=vertical]:bottom-0"
            )}
          />
        </FaderPrimitive.Track>

        {marks ? (
          <div
            aria-hidden="true"
            className={cn(
              "pointer-events-none absolute",
              orientation === "vertical"
                ? "inset-y-0 end-[calc(50%+0.75rem)]"
                : "inset-x-0 top-[calc(50%+0.75rem)]"
            )}
            data-slot="fader-scale"
          >
            {marks.map((mark) => (
              <FaderPrimitive.Mark
                className={cn(
                  "flex items-center gap-1",
                  orientation === "vertical"
                    ? "end-0 translate-y-1/2 flex-row"
                    : "-translate-x-1/2 flex-col"
                )}
                key={markValue(mark)}
                value={markValue(mark)}
              >
                {orientation === "vertical" ? (
                  <>
                    {markLabel(mark) === undefined ? null : (
                      <span className="text-[10px] leading-none text-muted-foreground tabular-nums in-data-[active]:text-foreground">
                        {markLabel(mark)}
                      </span>
                    )}
                    <span className="bg-muted-foreground/40 transition-colors duration-150 ease-out in-data-[active]:data-[color]:bg-primary motion-reduce:transition-none h-px w-1.5" />
                  </>
                ) : (
                  <>
                    <span className="bg-muted-foreground/40 transition-colors duration-150 ease-out in-data-[active]:data-[color]:bg-primary motion-reduce:transition-none h-1.5 w-px" />
                    {markLabel(mark) === undefined ? null : (
                      <span className="text-[10px] leading-none text-muted-foreground tabular-nums in-data-[active]:text-foreground">
                        {markLabel(mark)}
                      </span>
                    )}
                  </>
                )}
              </FaderPrimitive.Mark>
            ))}
          </div>
        ) : null}

        <FaderPrimitive.Thumb
          aria-describedby={ariaDescribedBy}
          aria-label={ariaLabel}
          aria-labelledby={ariaLabelledBy}
          className={faderThumbVariants({ size })}
          data-slot="fader-thumb"
          getAriaValueText={
            formatValue ? (_formatted, value) => formatValue(value) : undefined
          }
        >
          <div
            className={faderThumbInnerVariants({ size })}
            data-orientation={orientation}
            data-slot="fader-thumb-inner"
          >
            {thumbMarks !== false &&
              Array.from({ length: thumbMarks }, (_, i) => (
                <div
                  className={faderThumbMarkVariants({ size })}
                  data-orientation={orientation}
                  key={String(i)}
                />
              ))}
          </div>
          {valueLabel === "hidden" ? null : (
            <FaderPrimitive.Value
              className={cn(
                "rounded-[min(var(--radius-sm),6px)] bg-foreground px-1.5 py-0.5 font-medium text-[10px] text-background tabular-nums leading-none shadow-sm absolute z-20 whitespace-nowrap",
                editableValue ? "cursor-text" : "pointer-events-none",
                orientation === "vertical"
                  ? "start-full top-1/2 ms-2 -translate-y-1/2"
                  : "bottom-full left-1/2 mb-2 -translate-x-1/2",
                valueLabel === "auto" &&
                  "opacity-0 transition-opacity duration-150 ease-out group-hover/fader:opacity-100 group-has-focus-visible/fader:opacity-100 group-has-data-dragging/fader:opacity-100 data-editing:opacity-100 motion-reduce:transition-none"
              )}
              data-slot="fader-value"
              editable={editableValue}
            >
              {(formatted, values) =>
                formatValue && values[0] !== undefined
                  ? formatValue(values[0])
                  : formatted[0]
              }
            </FaderPrimitive.Value>
          )}
        </FaderPrimitive.Thumb>
      </FaderPrimitive.Control>
    </FaderPrimitive.Root>
  );
}
```

**3. Update the import paths to match your project setup.**

## Anatomy

Import the primitive and assemble its parts.

```tsx
import { Fader } from "@audio-ui/react";

<Fader.Root>
  <Fader.Label />
  <Fader.Control>
    <Fader.Track>
      <Fader.Indicator />
    </Fader.Track>
    <Fader.Mark value={50} />
    <Fader.Thumb />
  </Fader.Control>
  <Fader.Value />
</Fader.Root>
```

## Usage

```tsx
import { Fader } from "@/components/audio-ui/fader";
```

**Uncontrolled:** set only the initial value.

```tsx
<Fader defaultValue={0} max={6} min={-60} step={1} />
```

**Controlled:** drive the value from state.

```tsx
const [gain, setGain] = useState(0);

<Fader
  max={6}
  min={-60}
  onValueChange={setGain}
  onValueCommitted={(value, details) => console.log("Committed:", value, details.reason)}
  step={1}
  value={gain}
/>
```

## Interaction

### Pointer

**Behavior**

| Gesture | Effect |
| --- | --- |
| Track click / drag | Click or drag on the track to set the value from the pointer position. |
| Thumb drag | Dragging the thumb changes the value continuously, within `min…max`. |
| Commit event | `onValueCommitted` fires when an interaction ends with a new value. |
| Lagging value | While dragging, the thumb follows the pointer even if a controlled `value` updates late; it returns to `value` on release. Reject a value with `eventDetails.cancel()`. |

### Keyboard

On the focused thumb:

**Keys**

| Key | Effect |
| --- | --- |
| `ArrowUp` / `ArrowRight` | Increase by `step` |
| `ArrowDown` / `ArrowLeft` | Decrease by `step` |
| `PageUp` / `Shift` + `ArrowUp` | Increase by `largeStep` (default `10`) |
| `PageDown` / `Shift` + `ArrowDown` | Decrease by `largeStep` (default `10`) |
| `Home` | Set to `min` |
| `End` | Set to `max` |

### Wheel

The mouse wheel over the fader moves the value by `step` and reports `eventDetails.reason === "wheel"`.

## API reference

### Root

Groups all parts of the fader, with audio defaults (vertical, -60…+6) and mouse-wheel stepping. While a pointer drags it, the thumb follows the pointer even if a controlled `value` lags behind, as with a seek bar driven by playback time or automation. To reject a value, call `eventDetails.cancel()`. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `anchor` | `number` |  | Value the `Indicator` fills from, such as `0` on a -1…1 pan. Defaults to `min`. |
| `defaultValue` | `number` |  | Uncontrolled starting value, and what a double click, an Alt + click or the Delete key return to; defaults to the midpoint of `min…max`. |
| `max` | `number` | `6` |  |
| `min` | `number` | `-60` |  |
| `onValueChange` | `(value: number, eventDetails: ChangeEventDetails) => void` |  | Fires on drag, track press, keyboard, wheel and form input changes. |
| `onValueCommitted` | `(value: number, eventDetails: CommitEventDetails) => void` |  | Fires when an interaction ends with a new value. |
| `orientation` | `"horizontal" \| "vertical"` | `"vertical"` |  |
| `value` | `number` |  |  |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-active` | Whether the value has reached this mark from the anchor. |
| `data-disabled` | Present when disabled. |

### Control

The interactive area: pointer presses and drags, plus mouse-wheel stepping. Sets `touch-action: none` so touch drags don't scroll the page. Renders a `<div>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-active` | Whether the value has reached this mark from the anchor. |
| `data-disabled` | Present when disabled. |

### Indicator

The filled part of the track: from `min` up to the value, or between the Root's `anchor` and the value, on whichever side of it the value is. Renders a `<div>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-active` | Whether the value has reached this mark from the anchor. |
| `data-disabled` | Present when disabled. |

### Mark

A graduation along the track at `value`: placed from the start of the travel in the fader's direction, where the thumb's center would be at that value. It carries `data-active` while the value has reached it from the anchor, and `data-orientation`. Decorative: the thumb's input announces the value. Render your own tick and label inside it. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` |  | Where the mark sits, between `min` and `max`. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-active` | Whether the value has reached this mark from the anchor. |
| `data-disabled` | Present when disabled. |

### Registry component

`Fader` accepts style props (`size`, `thumbMarks`, `className`) plus primitive props from `FaderPrimitive.Root.Props` (`name`, `form`, `format`, `locale`, `largeStep`, `render`, …). The primitive's `Fader.Mark` places a graduation at a `value` along the track, with `data-active` once the value has reached it, to build your own ruler. `aria-label`, `aria-labelledby`, and `aria-describedby` are forwarded to the thumb's input.

#### Value and state

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` |  | Where the mark sits, between `min` and `max`. |
| `defaultValue` | `number` | `midpoint` | Uncontrolled starting value, and what a double click, an Alt + click or the Delete key return to; defaults to the midpoint of `min…max`. |
| `min` | `number` | `-60` | Minimum value. |
| `max` | `number` | `6` | Maximum value. |
| `step` | `number` | `1` | Granularity of every change. |
| `marks` | `(number \| { value, label? })[]` |  | Graduations drawn beside the track: a ruler for the value. Those the value has reached from `anchor` light up. |
| `valueLabel` | `"auto" \| "always" \| "hidden"` | `"auto"` | When the value shows beside the thumb: `auto` while hovered, dragged or focused from the keyboard. |
| `formatValue` | `(value: number) => string` | `format` | How the value prints in that readout and in what screen readers announce, e.g. `"-6.0 dB"`. |
| `editableValue` | `boolean` | `false` | Lets you type the value in the readout: a double click, or Enter while it has focus. Enter commits it, Escape drops it (`reason: "input-change"`). |
| `anchor` | `number` | `min` | Value the `Indicator` fills from, such as `0` on a -1…1 pan. Defaults to `min`. |
| `disabled` | `boolean` | `false` | Disables interaction. |
| `onValueChange` | `(value: number, eventDetails) => void` |  | Fires on drag, track press, keyboard, wheel and form input changes. |
| `onValueCommitted` | `(value: number, eventDetails) => void` |  | Fires when an interaction ends with a new value. |
| `aria-label` | `string` |  | Accessible name for the slider. |
| `aria-labelledby` | `string` |  | ID of a labeling element. |

#### Layout and visuals

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"horizontal" \| "vertical"` | `"vertical"` | Direction of travel. |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | Track and thumb size preset. |
| `thumbMarks` | `number \| false` | `3` | Number of marks in the thumb or `false` to hide them. |
| `className` | `string` |  | Root wrapper class name. |

Additional valid HTML attributes passed to `Fader.Root` are forwarded.

## Examples

### Vertical (default)

```tsx
import { Fader } from "@/components/audio-ui/fader";

const MARKS = [
  { label: "0", value: 0 },
  25,
  50,
  75,
  { label: "100", value: 100 },
];

export function FaderVerticalDemo() {
  return (
    <Fader
      aria-label="Volume"
      defaultValue={50}
      editableValue
      marks={MARKS}
      max={100}
      min={0}
      step={1}
    />
  );
}
```

### Horizontal

```tsx
import { Fader } from "@/components/audio-ui/fader";

const MARKS = [
  { label: "0", value: 0 },
  25,
  50,
  75,
  { label: "100", value: 100 },
];

export function FaderHorizontalDemo() {
  return (
    <Fader
      aria-label="Volume"
      defaultValue={50}
      marks={MARKS}
      max={100}
      min={0}
      orientation="horizontal"
      step={1}
    />
  );
}
```

### Pan, filled from the center

`anchor={0}` makes the fill grow from the center of a -1…1 range, left or right of it.

```tsx
"use client";

import { useState } from "react";
import { Fader } from "@/components/audio-ui/fader";

const formatPan = (pan: number) => {
  const amount = Math.round(Math.abs(pan) * 100);
  if (amount === 0) {
    return "C";
  }
  return `${pan < 0 ? "L" : "R"}${amount}`;
};

export function FaderPanDemo() {
  const [pan, setPan] = useState(-0.3);

  return (
    <div className="flex w-full max-w-xs flex-col items-center gap-2">
      <Fader
        anchor={0}
        aria-label="Pan"
        max={1}
        min={-1}
        onValueChange={setPan}
        orientation="horizontal"
        step={0.01}
        value={pan}
        valueLabel="hidden"
      />
      <output className="font-mono text-sm tabular-nums">
        {formatPan(pan)}
      </output>
    </div>
  );
}
```

### Mixing console

A fader per channel in dB, from silent at the bottom to +6 dB, beside the meter of what it lets through.

```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 "@/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>
  );
}
```

