Skip to content
Brok UI

Loading…

No results

Live Waveform

Open source

A real-time audio waveform on canvas (scrolling or static bars) driven by the live microphone via Web Audio, with a synthetic demo fallback. DPR-aware, token-coloured, reduced-motion safe, with full teardown.

Version
v1.0.4
Stability
stable
License
MIT
Related
Audio Player
Voice Dictator
Bar Visualizer

Preview

Scrolling (demo)

Static (demo)

Mode
previews.components.live-waveform.default.blade.php Blade
<div class="mx-auto w-full max-w-md space-y-6">
    <div class="space-y-2">
        <p class="text-xs font-medium text-muted-foreground">{{ __('Scrolling (demo)') }}</p>
        <x-ui.live-waveform mode="scrolling" :demo="true" :height="64" />
    </div>
    <div class="space-y-2">
        <p class="text-xs font-medium text-muted-foreground">{{ __('Static (demo)') }}</p>
        <x-ui.live-waveform mode="static" :demo="true" :height="64" />
    </div>
</div>
Scrolling Current
Static Current

Installation

terminal
php artisan ui:add live-waveform

Note

This component ships an Alpine behavior module at resources/js/ui/live-waveform.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './live-waveform.js';

Registry contract

php artisan ui:add live-waveform writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/live-waveform.blade.php
  • js resources/js/ui/live-waveform.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

live-waveform.md
# Brok UI: Live Waveform (`live-waveform`)

A real-time audio waveform on canvas (scrolling or static bars) driven by the live microphone via Web Audio, with a synthetic demo fallback. DPR-aware, token-coloured, reduced-motion safe, with full teardown.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add live-waveform
```

## Usage

```blade
<div class="mx-auto w-full max-w-md space-y-6">
    <div class="space-y-2">
        <p class="text-xs font-medium text-muted-foreground">{{ __('Scrolling (demo)') }}</p>
        <x-ui.live-waveform mode="scrolling" :demo="true" :height="64" />
    </div>
    <div class="space-y-2">
        <p class="text-xs font-medium text-muted-foreground">{{ __('Static (demo)') }}</p>
        <x-ui.live-waveform mode="static" :demo="true" :height="64" />
    </div>
</div>
```

## Props

- `active` (bool, default `false`)
- `demo` (bool, default `true`)
- `mode` (string, default `scrolling`)
- `height` (int, default `64`)
- `barWidth` (int, default `3`)
- `barGap` (int, default `2`)
- `sensitivity` (int, default `1`)
- `color` (string, default `primary`)
- `label` (mixed|null, default `null`)

## Use when

- Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
- Showing a live microphone waveform while recording or dictating.
- Giving a call or voice surface a visual level indicator.

## Avoid when

- Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
- Rendering a recorded waveform for seeking; scrub-bar handles a timeline.
- Any use that must work without canvas or Web Audio; the demo fallback is decorative only.

## Anti-patterns

- Autoplay with sound
- Audio as the only access to essential information

## Rules

- Use the `<brok:live-waveform>` tag (or `<x-ui.live-waveform>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/live-waveform
- Registry JSON (files, props, contract): https://brokui.dev/r/open/live-waveform.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
active bool false
demo bool true
mode string scrolling
height int 64
barWidth int 3
barGap int 2
sensitivity int 1
color string primary
label mixed | null null

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

live-waveform

Behavior

  • active starts the canvas; with demo set it animates a synthetic signal, otherwise it asks for the microphone.
  • mode switches between a scrolling waveform and static bars, sized by height, barWidth, barGap and sensitivity.
  • The canvas is drawn at device pixel ratio and tears down its audio context when deactivated or removed.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.

Guidance

Audio and voice interaction

Play, inspect, record, or dictate temporal audio.

Use when

  • Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
  • Showing a live microphone waveform while recording or dictating.
  • Giving a call or voice surface a visual level indicator.

Avoid when

  • Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
  • Rendering a recorded waveform for seeking; scrub-bar handles a timeline.
  • Any use that must work without canvas or Web Audio; the demo fallback is decorative only.

Use instead

  • Silent scannable controls
  • Transcript or text input

Anti-patterns

  • Autoplay with sound
  • Audio as the only access to essential information
Anatomy
root
Theming hooks
live-waveform

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The canvas is a role="img" with an accessible name, so it is announced as a waveform rather than skipped.
  • It is decorative feedback: any transcript or status must be rendered as text elsewhere.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<div wire:ignore>
    <div class="mx-auto w-full max-w-md space-y-6">
        <div class="space-y-2">
            <p class="text-xs font-medium text-muted-foreground">{{ __('Scrolling (demo)') }}</p>
            <x-ui.live-waveform mode="scrolling" :demo="true" :height="64" />
        </div>
        <div class="space-y-2">
            <p class="text-xs font-medium text-muted-foreground">{{ __('Static (demo)') }}</p>
            <x-ui.live-waveform mode="static" :demo="true" :height="64" />
        </div>
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/live-waveform.blade.php Blade
@props([
    'active' => false,
    'demo' => true,
    'mode' => 'scrolling',
    'height' => 64,
    'barWidth' => 3,
    'barGap' => 2,
    'sensitivity' => 1,
    'color' => 'primary',
    'label' => null,
])

@php
    // A live audio waveform on a <canvas>. By default it runs a synthetic `demo`
    // animation so it renders anywhere; set `active` to draw the real microphone
    // (Web Audio). `mode` is "scrolling" (history streams in from the edge) or
    // "static" (a centred, symmetric bar field). The bar colour is SEMANTIC: the
    // canvas reads its own computed `color` (a text-* token) at runtime — nothing
    // raw-coloured lives in markup. DPR-aware and reduced-motion safe.
    $colorClasses = [
        'primary' => 'text-primary',
        'secondary' => 'text-secondary-foreground',
        'accent' => 'text-accent-foreground',
        'muted' => 'text-muted-foreground',
        'destructive' => 'text-destructive',
        'success' => 'text-success',
        'warning' => 'text-warning',
        'info' => 'text-info',
        'foreground' => 'text-foreground',
    ];
    $colorClass = $colorClasses[$color] ?? 'text-primary';

    $config = [
        'active' => (bool) $active,
        'demo' => (bool) $demo,
        'mode' => in_array($mode, ['scrolling', 'static'], true) ? $mode : 'scrolling',
        'barWidth' => max(1, (float) $barWidth),
        'barGap' => max(0, (float) $barGap),
        'sensitivity' => max(0.1, (float) $sensitivity),
    ];
    $h = max(16, (int) $height);
    $ariaLabel = $label ?? __('Live waveform');
@endphp

<div
    data-slot="live-waveform"
    x-data="uiLiveWaveform(@js($config))"
    x-ref="root"
    role="img"
    aria-label="{{ $ariaLabel }}"
    style="height: {{ $h }}px"
    {{ $attributes->merge(['class' => $colorClass.' relative block w-full overflow-hidden']) }}
>
    <canvas x-ref="canvas" class="block size-full"></canvas>
</div>
resources/js/ui/live-waveform.js JS
/**
 * Live Waveform behaviour.
 *
 * Draws an audio waveform on a <canvas> as a row of rounded bars, vertically
 * centred (symmetric about the mid-line). Two layouts:
 *   • scrolling — newest amplitude pushes in from the inline-end; history streams
 *   • static    — bars map across the full width each frame (a standing field)
 *
 * Source:
 *   • demo (default) — a synthetic, breathing envelope so it animates anywhere;
 *   • active         — the live microphone via Web Audio. If getUserMedia is
 *                      rejected we fall back to the synthetic envelope.
 * Toggling `active`/`demo` at runtime (x-data props) is honoured via $watch.
 *
 * Colour is read from the canvas's computed `color` (a semantic text-* token),
 * so theming the wrapper themes the wave — nothing is hard-coded.
 *
 * Sizing: the backing store is scaled for devicePixelRatio and a ResizeObserver
 * relays out on resize. Reduced motion paints a single static frame, no rAF.
 * destroy() cancels the frame, disconnects the observer, stops mic tracks and
 * closes the AudioContext.
 *
 * Self-registers on `alpine:init`. Plain JS — no Blade syntax.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiLiveWaveform', (config = {}) => ({
        active: !!config.active,
        demo: config.demo !== false,
        mode: config.mode === 'static' ? 'static' : 'scrolling',
        barWidth: Math.max(1, config.barWidth ?? 3),
        barGap: Math.max(0, config.barGap ?? 2),
        sensitivity: Math.max(0.1, config.sensitivity ?? 1),

        _ctx: null,
        _dpr: 1,
        _cssW: 0,
        _cssH: 0,
        _bars: 0,
        _history: [],
        _color: null,
        _raf: null,
        _ro: null,
        _start: null,
        _reduced: false,
        _viewObserver: null,
        _offscreen: false,

        // Web Audio (mic mode)
        _audioCtx: null,
        _analyser: null,
        _stream: null,
        _data: null,

        init() {
            const canvas = this.$refs.canvas;
            this._ctx = canvas.getContext('2d');
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

            this.layout();

            this._ro = new ResizeObserver(() => this.layout());
            this._ro.observe(this.$refs.root);

            if (this.active) this.setupMic();

            // React to runtime prop changes (e.g. a parent toggling `active`).
            this.$watch('active', (on) => (on ? this.setupMic() : this.teardownMic()));

            if (this._reduced) {
                this.drawStatic();
                return;
            }
            this._raf = requestAnimationFrame((t) => this.loop(t));

            // Pause the rAF loop while fully scrolled out of view (same-tab;
            // backgrounded tabs are already throttled by the browser) so
            // off-screen instances don't burn GPU/CPU. Resume on re-entry.
            // Static/reduced-motion renders never set _raf, so the `&& this._raf`
            // guard leaves them untouched.
            if (typeof IntersectionObserver !== 'undefined') {
                this._viewObserver = new IntersectionObserver((entries) => {
                    const visible = entries.some((e) => e.isIntersecting);
                    if (!visible && this._raf) {
                        cancelAnimationFrame(this._raf);
                        this._raf = null;
                        this._offscreen = true;
                    } else if (visible && this._offscreen) {
                        this._offscreen = false;
                        this._raf = requestAnimationFrame((t) => this.loop(t));
                    }
                });
                this._viewObserver.observe(this.$refs.root);
            }
        },

        resolveColor() {
            const c = getComputedStyle(this.$refs.canvas).color;
            this._color = c && c !== '' ? c : getComputedStyle(document.body).color;
        },

        layout() {
            const canvas = this.$refs.canvas;
            const root = this.$refs.root;
            this._dpr = Math.max(1, window.devicePixelRatio || 1);
            this._cssW = Math.max(1, root.clientWidth || 1);
            this._cssH = Math.max(1, root.clientHeight || 1);
            canvas.width = Math.round(this._cssW * this._dpr);
            canvas.height = Math.round(this._cssH * this._dpr);
            this._ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
            this.resolveColor();

            this._bars = Math.max(1, Math.floor(this._cssW / (this.barWidth + this.barGap)));
            if (this._history.length !== this._bars) {
                const next = new Array(this._bars).fill(0);
                // Preserve the tail of existing history on resize.
                for (let i = 0; i < Math.min(this._history.length, this._bars); i++) {
                    next[this._bars - 1 - i] = this._history[this._history.length - 1 - i] || 0;
                }
                this._history = next;
            }
            if (this._reduced) this.drawStatic();
        },

        // Current overall amplitude 0..1 (mic RMS or synthetic breathing).
        // Called only from loop(), which already filled _data via
        // getByteFrequencyData this frame, so it does NOT re-pull it (that was a
        // redundant second FFT copy per frame in mic mode).
        amplitude(time) {
            if (this._analyser && this._data) {
                let sum = 0;
                for (let i = 0; i < this._data.length; i++) sum += this._data[i] * this._data[i];
                const rms = Math.sqrt(sum / this._data.length) / 255;
                return Math.min(1, rms * this.sensitivity);
            }
            if (!this.demo && !this.active) return 0;
            const a = Math.sin(time * 1.7) * 0.25 + 0.35;
            const b = Math.sin(time * 4.3 + 1.2) * 0.15;
            return Math.max(0.04, Math.min(1, (a + b) * this.sensitivity));
        },

        // Per-bar value for the static layout at a given time.
        staticValue(i, time) {
            const pos = (i - this._bars / 2) / (this._bars / 2); // -1..1
            const centre = 1 - Math.abs(pos) * 0.35;
            if (this._analyser && this._data) {
                const idx = Math.floor((i / this._bars) * (this._data.length * 0.7));
                return Math.min(1, ((this._data[idx] || 0) / 255) * this.sensitivity) * centre;
            }
            const w1 = Math.sin(time * 1.5 + pos * 3) * 0.25;
            const w2 = Math.cos(time * 2 + pos) * 0.15;
            return Math.max(0.04, Math.min(1, (0.25 + w1 + w2) * this.sensitivity)) * centre;
        },

        loop(ts) {
            if (this._start == null) this._start = ts;
            const time = (ts - this._start) / 1000;

            if (this._analyser && this._data) this._analyser.getByteFrequencyData(this._data);

            if (this.mode === 'scrolling') {
                this._history.push(this.amplitude(time));
                if (this._history.length > this._bars) this._history.shift();
                this.draw(this._history);
            } else {
                const vals = new Array(this._bars);
                for (let i = 0; i < this._bars; i++) vals[i] = this.staticValue(i, time);
                this.draw(vals);
            }

            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        draw(values) {
            const ctx = this._ctx;
            const w = this._cssW;
            const h = this._cssH;
            ctx.clearRect(0, 0, w, h);
            ctx.fillStyle = this._color;

            const mid = h / 2;
            const step = this.barWidth + this.barGap;
            const radius = this.barWidth / 2;
            const maxBar = h * 0.92;

            for (let i = 0; i < values.length; i++) {
                const v = Math.max(0, Math.min(1, values[i] || 0));
                const barH = Math.max(this.barWidth, v * maxBar);
                const x = i * step;
                const y = mid - barH / 2;
                this.roundRect(ctx, x, y, this.barWidth, barH, radius);
            }
        },

        roundRect(ctx, x, y, w, h, r) {
            const rr = Math.min(r, w / 2, h / 2);
            ctx.beginPath();
            ctx.moveTo(x + rr, y);
            ctx.arcTo(x + w, y, x + w, y + h, rr);
            ctx.arcTo(x + w, y + h, x, y + h, rr);
            ctx.arcTo(x, y + h, x, y, rr);
            ctx.arcTo(x, y, x + w, y, rr);
            ctx.closePath();
            ctx.fill();
        },

        drawStatic() {
            // A calm symmetric arch for the reduced-motion / idle frame.
            const vals = new Array(this._bars);
            for (let i = 0; i < this._bars; i++) {
                const pos = (i - this._bars / 2) / (this._bars / 2);
                vals[i] = Math.max(0.05, (1 - Math.abs(pos)) * 0.5);
            }
            this.draw(vals);
        },

        async setupMic() {
            if (this._analyser || !navigator.mediaDevices?.getUserMedia) return;
            try {
                const stream = await navigator.mediaDevices.getUserMedia({
                    audio: { echoCancellation: true, noiseSuppression: true },
                });
                this._stream = stream;
                const Ctx = window.AudioContext || window.webkitAudioContext;
                this._audioCtx = new Ctx();
                const source = this._audioCtx.createMediaStreamSource(stream);
                this._analyser = this._audioCtx.createAnalyser();
                this._analyser.fftSize = 256;
                this._analyser.smoothingTimeConstant = 0.8;
                source.connect(this._analyser);
                this._data = new Uint8Array(this._analyser.frequencyBinCount);
            } catch {
                this.teardownMic();
            }
        },

        teardownMic() {
            if (this._stream) {
                this._stream.getTracks().forEach((t) => t.stop());
                this._stream = null;
            }
            if (this._audioCtx && this._audioCtx.state !== 'closed') {
                this._audioCtx.close();
            }
            this._audioCtx = null;
            this._analyser = null;
            this._data = null;
        },

        destroy() {
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._raf) cancelAnimationFrame(this._raf);
            this._raf = null;
            if (this._ro) this._ro.disconnect();
            this._ro = null;
            this.teardownMic();
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.0.4
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0