# Block compressor

> A compressor you can plug in: its state, a panel that draws it and a Web Audio engine that plays it, side by side. Play a pulsing signal and turn the knobs

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-compressor).

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

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/audio-ui/block-compressor.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/react`
- Registry items: `@audio-ui/compressor-panel`, `@audio-ui/compressor-device`, `@audio-ui/compressor-engine`, `@audio-ui/demo-loop`, `@audio-ui/use-master-bus`, `@audio-ui/web-audio`, `@shadcn/button`

## Files

### `block-compressor.tsx`

```tsx
"use client";

import { createSource, type Source } from "@audio-ui/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder";
import { Button } from "@/components/ui/button";
import { CompressorPanel } from "@/components/audio-ui/devices/compressor/panel";
import { useMasterBus } from "@/hooks/use-master-bus";
import {
  type CompressorState,
  compressorDevice,
} from "@/components/audio-ui/devices/compressor/device";
import {
  type CompressorEngine,
  createCompressorEngine,
} from "@/components/audio-ui/devices/compressor/engine";
import {
  renderHeldChord,
  renderKickLoop,
} from "@/lib/demo-loop";
import { webAudio } from "@/lib/web-audio";

const LOOP_SECONDS = 2;
const PULSES_PER_LOOP = 8;
const PULSE_DECAY_SECONDS = 0.18;
const SIGNAL_GAIN = 0.8;
const FLOOR_DB = -80;
const FFT_SIZE = 1024;
/** How fast the level falls back between pulses, in dB per frame. */
const LEVEL_FALL_DB = 1.2;
const MASTER_VOLUME = 0.7;

/** Noise bursts that start loud and fade, eight to a loop, every other one quieter, so the level moves along the curve. */
function pulses(ctx: AudioContext): AudioBuffer {
  const buffer = ctx.createBuffer(
    1,
    ctx.sampleRate * LOOP_SECONDS,
    ctx.sampleRate
  );
  const data = buffer.getChannelData(0);
  const pulseLength = data.length / PULSES_PER_LOOP;
  for (let index = 0; index < data.length; index += 1) {
    const seconds = (index % pulseLength) / ctx.sampleRate;
    const accent = Math.floor(index / pulseLength) % 2 === 0 ? 1 : 0.35;
    data[index] =
      (Math.random() * 2 - 1) *
      accent *
      SIGNAL_GAIN *
      Math.exp(-seconds / PULSE_DECAY_SECONDS);
  }
  return buffer;
}

/** Gain reduction in dB, written into one text node as the source moves: no React render per frame. */
function ReductionReadout({ source }: { source: Source<number> }) {
  const element = useRef<HTMLOutputElement>(null);
  useEffect(() => {
    const node = element.current?.appendChild(document.createTextNode(""));
    if (!node) {
      return;
    }
    const paint = () => {
      node.data = `${source.getSnapshot().toFixed(1)} dB`;
    };
    paint();
    const unsubscribe = source.subscribe(paint);
    return () => {
      unsubscribe();
      node.remove();
    };
  }, [source]);
  return (
    <output
      aria-label="Gain reduction"
      className="font-mono text-muted-foreground text-xs tabular-nums"
      ref={element}
    />
  );
}

/**
 * A compressor you can plug in: its state, a panel that draws it, an engine
 * that plays it, side by side. Each is its own file and none knows the others:
 * the state is a plain object, so a host keeps its own and uses what it needs.
 * Press play and turn the knobs; switch Sidechain on and a kick ducks a held chord.
 */
export function BlockCompressor() {
  const [state, setState] = useState<CompressorState>(() =>
    compressorDevice.defaults()
  );
  const [playing, setPlaying] = useState(false);
  const master = useMasterBus(MASTER_VOLUME);
  const [level] = useState(() => createSource(FLOOR_DB));
  const [reduction] = useState(() => createSource(0));
  const engine = useRef<CompressorEngine | null>(null);
  const stateRef = useRef(state);
  stateRef.current = state;

  // With the key on the pulses give way to a held chord that the kick of a loop ducks: the key is heard by the compressor only.
  const keyed = state.external;
  useEffect(() => {
    const ctx = webAudio.getContext();
    if (!(playing && ctx && master)) {
      return;
    }
    ctx.resume();
    let cancelled = false;
    let stop = () => undefined;
    const buffers = keyed
      ? Promise.all([
          renderHeldChord(ctx.sampleRate),
          renderKickLoop(ctx.sampleRate),
        ])
      : Promise.resolve([pulses(ctx), null] as const);
    buffers.then(([signal, key]) => {
      if (cancelled) {
        return;
      }
      const source = ctx.createBufferSource();
      source.buffer = signal;
      source.loop = true;
      const keySource = key ? ctx.createBufferSource() : null;
      const compressor = createCompressorEngine(ctx, stateRef.current);
      const tap = ctx.createAnalyser();
      tap.fftSize = FFT_SIZE;
      source.connect(tap);
      source.connect(compressor.input);
      if (keySource && key) {
        keySource.buffer = key;
        keySource.loop = true;
        keySource.connect(compressor.sidechain);
      }
      compressor.output.connect(master);
      engine.current = compressor;
      const startAt = ctx.currentTime + 0.05;
      source.start(startAt);
      keySource?.start(startAt);

      const samples = new Float32Array(new ArrayBuffer(tap.fftSize * 4));
      let held = FLOOR_DB;
      let frame = 0;
      const tick = () => {
        tap.getFloatTimeDomainData(samples);
        let peak = 0;
        for (const sample of samples) {
          peak = Math.max(peak, Math.abs(sample));
        }
        const db =
          peak > 0 ? Math.max(FLOOR_DB, 20 * Math.log10(peak)) : FLOOR_DB;
        held = Math.max(db, held - LEVEL_FALL_DB);
        level.set(held);
        reduction.set(compressor.reduction);
        frame = requestAnimationFrame(tick);
      };
      frame = requestAnimationFrame(tick);
      stop = () => {
        cancelAnimationFrame(frame);
        source.stop();
        source.disconnect();
        keySource?.stop();
        keySource?.disconnect();
        tap.disconnect();
        compressor.dispose();
        engine.current = null;
        level.set(FLOOR_DB);
        reduction.set(0);
      };
    });
    return () => {
      cancelled = true;
      stop();
    };
  }, [keyed, level, master, playing, reduction]);

  // The engine follows the state, whoever changed it: a knob or a call.
  useEffect(() => {
    engine.current?.update(state);
  }, [state]);

  const togglePlaying = useCallback(() => setPlaying((on) => !on), []);

  return (
    <div className="rounded-xl h-full w-full min-w-0">
      <CompressorPanel level={level} onValueChange={setState} value={state}>
        <Button
          aria-label={
            playing ? "Stop the signal" : "Play a pulsing signal through it"
          }
          onClick={togglePlaying}
          size="icon-sm"
          variant="outline"
        >
          {playing ? (
            <IconPlaceholder
              hugeicons="PauseIcon"
              lucide="PauseIcon"
              phosphor="PauseIcon"
              remixicon="RiPauseLine"
              tabler="IconPlayerPause"
            />
          ) : (
            <IconPlaceholder
              hugeicons="PlayIcon"
              lucide="PlayIcon"
              phosphor="PlayIcon"
              remixicon="RiPlayLine"
              tabler="IconPlayerPlay"
            />
          )}
        </Button>
        <ReductionReadout source={reduction} />
      </CompressorPanel>
    </div>
  );
}
```
