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.
Preview
Scrolling (demo)
Static (demo)
<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>
Installation
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:
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.
-
resources/views/components/ui/live-waveform.blade.php -
resources/js/ui/live-waveform.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<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.
@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>
/**
* 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