Audio Visualizer
kai-audio-visualizer
Reacts to a microphone, a media element, or levels you supply, in a choice of shapes.
- Shadow DOM
- 6 variants
- Live audio or scripted state
- Custom GLSL shaders
- 3 parts
Preview
Section titled “Preview”variant, state, size, and the rest of the shape props (bar-count, count, radius, spread, interval, color, complexity, label) are scalars, so they work as HTML attributes. Live audio is not: assign stream, audioElement, and bands in JavaScript, never as attributes, and shader (for variant="custom") the same way.
<kai-audio-visualizer variant="grid" state="listening" size="lg"></kai-audio-visualizer>const viz = document.querySelector('kai-audio-visualizer');viz.stream = mediaStream; // a live MediaStreamviz.audioElement = audioEl; // HTMLAudioElement or HTMLVideoElementviz.bands = [0.2, 0.8, 0.4, 0.5]; // number[], 0..1, skips Web Audio entirelyOnly one source wins: bands short-circuits Web Audio entirely, so stream and audioElement are ignored even if you set them too. Streaming levels into bands needs a fresh array reference on every update.
State without audio
Section titled “State without audio”state alone drives a full scripted animation with no audio source at all: idle, connecting, listening, thinking, speaking, and disconnected (connection down, the dead flat look) each get their own timing and pattern per variant, with LiveKit’s room-lifecycle names accepted as aliases. This is what backs <kai-voice-output>: speechSynthesis exposes no audio node to analyze, so set state="speaking" while it talks and skip the audio props.
Examples
Section titled “Examples”A compact row of levels for a mic button or a slim status strip.
A dot matrix that pulses outward, sized for a call tile.
Radial
Section titled “Radial”A ring of spokes around a centre point, for wrapping an avatar.
A flowing oscilloscope line, rendered through WebGL.
Aurora
Section titled “Aurora”A glowing ambient wash for a hero-sized idle state.
Wiring it to the microphone
Section titled “Wiring it to the microphone”<kai-voice-input> hands you a transcript, not a stream, so request your own with getUserMedia, gated by the mic’s kai-recording-change event so the visualizer’s state and its stream change together:
<kai-voice-input id="mic"></kai-voice-input><kai-audio-visualizer id="viz" variant="bar" size="icon"></kai-audio-visualizer>
<script type="module"> import '@kitn.ai/ui/web-components';
await customElements.whenDefined('kai-voice-input');
const mic = document.getElementById('mic'); const viz = document.getElementById('viz'); let micStream;
mic.addEventListener('kai-recording-change', async (e) => { viz.state = e.detail.recording ? 'speaking' : 'idle'; if (e.detail.recording) { // Request processed capture, not bare `audio: true`. The default // analysis window expects gain-controlled speech (it reads the // 2-4kHz band and gates the room's noise floor out); these are the // same capture constraints LiveKit's own client applies to local // mic tracks. micStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, voiceIsolation: true, }, }); viz.stream = micStream; } else { viz.stream = undefined; micStream?.getTracks().forEach((track) => track.stop()); } });</script>Styling
Section titled “Styling”Restyle the bar, cell, or canvas part from outside. A lit bar or cell also carries the highlighted part, as a second token in the same part attribute, so target it by combining both names in one ::part():
/* wrong: a CSS attribute selector cannot follow a pseudo-element, so this never matches anything -- CSS.supports(selector(...)) returns false for it */kai-audio-visualizer::part(bar)[data-kai-highlighted="true"] { background: var(--brand); }
/* right */kai-audio-visualizer::part(bar highlighted) { background: var(--brand); }kai-audio-visualizer::part(cell highlighted) { background: var(--brand); }Bar and cell items also carry data-kai-index and data-kai-highlighted ("true"/"false") for reading from inside the shadow root or from a render-prop, but a ::part() selector from outside cannot see them.
| Part | Purpose | Example |
|---|---|---|
| bar | A single bar in the `bar` variant, or a single spoke in the `radial` variant. Also carries `data-kai-index` and `data-kai-highlighted` ("true"/"false") for use inside the shadow root; to style the lit state from OUTSIDE, combine with the `highlighted` part below rather than an attribute selector. | |
| cell | A single dot in the `grid` variant. Also carries `data-kai-index` and `data-kai-highlighted` ("true"/"false") for use inside the shadow root; to style the lit state from OUTSIDE, combine with the `highlighted` part below rather than an attribute selector. | |
| highlighted | A second part TOKEN present on a `bar` or `cell` exactly when the sequencer or live audio has it lit, not a standalone styleable element. Combine it in the same `::part()` argument: `::part(bar highlighted)` or `::part(cell highlighted)`. This is the external equivalent of the internal `data-kai-highlighted="true"` attribute, which a `::part()` selector cannot reach (an attribute selector cannot follow a pseudo-element). | |
| canvas | The WebGL canvas backing the `wave` and `aurora` variants. Restyle its size or radius, or layer a mask/filter, from outside. | |
Custom shaders
Section titled “Custom shaders”Set variant="custom" and a shader property to render your own fragment shader instead of the built-in geometry:
<kai-audio-visualizer id="viz" variant="custom"></kai-audio-visualizer><script type="module"> import '@kitn.ai/ui/web-components';
await customElements.whenDefined('kai-audio-visualizer');
document.getElementById('viz').shader = { fragment: ` void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; float alpha = smoothstep(1.0, 0.0, length(uv - 0.5) * 2.0) * uIntensity; fragColor = vec4(uColor * alpha, alpha); // premultiplied, not vec4(uColor, alpha) } `, };</script>Your fragment must define mainImage(out vec4 fragColor, in vec2 fragCoord). The canvas declares every uniform below and injects them ahead of your source; redeclaring any of them (uniform float iTime;, uniform vec3 uColor;, and so on) is a GLSL redefinition and fails to compile.
| Uniform | GLSL type | What it carries |
|---|---|---|
iTime | float | Seconds since the shader mounted. |
iResolution | vec2 | Canvas size in device pixels. |
iMouse | vec4 | Pointer position in .xy. .zw stays zero; only .xy is implemented. |
iFrame | int | Frame counter, incrementing every draw. |
iDate | vec4 | Year, month, day, and seconds into the day. |
uColor | vec3 | The color attribute (or the shader default), as 0..1 RGB. |
uIntensity | float | An eased 0..1 value that follows state. |
uSpeed | float | An eased animation speed that follows state. |
uComplexity | float | The complexity attribute, 0..1 (default 0.5). |
uVolume | float | A single scalar volume: from live analysis, or the RMS of bands when you supply it directly. |
uBands[N] | float[N] | Per-band levels, N tracking the actual band count (never zero-length). |
uBands is ours: LiveKit’s upstream shader path only ever hands a shader a scalar volume, so a spectrum-reactive custom shader is only possible here.
| Property | Type | Default | Notes |
|---|---|---|---|
| theme | "light" | "dark" | "auto" | 'auto' | Color mode (`auto` follows prefers-color-scheme). |
| variant | string | 'bar' | Look to render: `bar` (default), `grid`, `radial`, `wave`, `aurora`, `custom`. `aura` is accepted as a LiveKit-markup alias for `aurora`. Attribute: `variant`. |
| state | string | 'idle' | `idle` (default) or `connecting`/`listening`/`thinking`/`speaking`/`disconnected`; LiveKit's room-lifecycle names are aliases. |
| size | string | 'md' | `icon` | `sm` | `md` (default) | `lg` | `xl`. Attribute: `size`. |
| barCount | number | — | Bars to draw. Bar and radial only. Attribute: `bar-count`. |
| count | number | — | Grid only: rows and columns of the (always square) grid. Attribute: `count`. |
| radius | number | — | Radial only: ring distance from center, in px. Attribute: `radius`. |
| spread | number | — | Grid only: ring distance for the connecting animation, in cells. Attribute: `spread`. |
| interval | number | — | Grid only: ms between scripted frames. Attribute: `interval`. |
| color | string | — | CSS color for the geometry, overriding the inherited `currentColor`. Attribute: `color`. |
| complexity | number | — | Shader variants only: pattern density, 0..1. Attribute: `complexity`. |
| label | string | — | Setting this makes the element an announced image (`role="img"`) instead of decorative (`aria-hidden`). Attribute: `label`. |
| stream | MediaStream | — | Live microphone or WebRTC audio to analyze. JS property only; amplitude renders only while `state` is `speaking`. |
| audioElement | HTMLMediaElement | — | An `<audio>` or `<video>` element to tap for its audio. JS property only; amplitude renders only while `state` is `speaking`. |
| bands | number[] | — | Pre-computed levels, 0..1. JS property only; a NEW array reference per update; amplitude renders only while `state` is `speaking`. |
| listeningAmplitude | boolean | — | Render live amplitude during the `listening` state too. Off by default. |
| shader | — | Custom fragment shader for `variant="custom"`. JS property only. | |
| animateWhenNotVisible | boolean | — | Shader variants only: keep animating while scrolled off screen. Off by default; does not override `prefers-reduced-motion`. |
-
wave,aurora, andcustomload behind a dynamic import and fall back tobarif the chunk fails or WebGL is unavailable, so akai-chat-only page never pays for the shader runtime. -
Scrolled off screen, a shader variant stops drawing and hands its WebGL context back, taking it again on the way in. Browsers allow only around 16 live contexts per page and silently kill the oldest past that, so without this a page with a dozen shader tiles ends up with dead canvases; the animation resumes where you left it.
-
animate-when-not-visibleopts a single element out of that, for a visualizer that has to keep running unseen. It holds its context while mounted, so use it on one or two elements, not a page full of them.<kai-audio-visualizer variant="wave" animate-when-not-visible></kai-audio-visualizer> -
prefers-reduced-motionfreezes the scripted animation on all six variants (bar,grid,radial,wave,aurora, andcustom): no easing, no pulsing, no drift. It wins overanimate-when-not-visible, which only decides whether frames keep being drawn, never what they show. -
aurais accepted as an alias foraurora, for markup ported from LiveKit.
Composed from
Section titled “Composed from”This element wraps these SolidJS components — reach for them directly when you need finer control than the props expose.