Audio Visualizer
kai-audio-visualizer
Render live audio (a microphone stream, an <audio>/<video> element, or precomputed levels) as bars, a grid, a ring, a flowing wave, or a glowing aurora. One element, six variants, chosen by variant.
- 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 plain scalars: set them as HTML attributes. Live audio is not a scalar. Assign stream, audioElement, and bands in JavaScript, never as attributes. shader (for variant="custom") works 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. Setting bands short-circuits Web Audio entirely, so stream and audioElement are ignored even if you also set them. Streaming levels into bands needs a fresh array reference on every update; mutating the existing array in place will not re-render.
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, matching LiveKit’s disconnected) each get their own timing and pattern per variant (LiveKit’s room-lifecycle names are accepted as aliases). This is what backs <kai-voice-output>: the browser’s speechSynthesis exposes no audio node to analyze, so there is nothing to tap. Set state="speaking" while it talks and skip the audio props entirely.
Examples
Section titled “Examples”variant="bar" (default). A compact row of levels, good for a mic button or a slim status strip.
variant="grid". A dot matrix that pulses outward, sized for a call tile.
Radial
Section titled “Radial”variant="radial". A ring of spokes around a center point, for wrapping an avatar.
variant="wave". A flowing oscilloscope line, rendered through WebGL.
Aurora
Section titled “Aurora”variant="aurora". A glowing ambient wash, good 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. It never exposes the raw MediaStream it records with. 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/elements';
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 when composing the Solid component directly, 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/elements';
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 for you and injects them ahead of your source. Redeclaring any of them (uniform float iTime;, uniform vec3 uColor;, and so on) inside fragment 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), `connecting`, `listening`, `thinking`, `speaking`, `disconnected` (connection down: the dead, flat look). LiveKit's room-lifecycle state names are accepted as aliases. Attribute: `state`. |
| 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. |
| audioElement | HTMLMediaElement | — | An `<audio>` or `<video>` element to tap for its audio. JS property only. |
| bands | number[] | — | Pre-computed levels, 0..1. Set this and no AudioContext is ever built, which is what keeps headless/SSR rendering and browser-speech-synthesis playback (which exposes no audio node) free of Web Audio entirely. JS property only. A new array reference is required for each update; mutating the existing array in place will not re-render. |
| shader | — | Custom fragment shader for `variant="custom"`. JS property only. | |
| animateWhenNotVisible | boolean | — | Shader variants only: keep animating while scrolled off screen. Off by default, which stops drawing and releases the WebGL context until the element comes back (browsers ration contexts to roughly 16 a page). Does not override `prefers-reduced-motion`. Attribute: `animate-when-not-visible`. |
-
wave,aurora, andcustomload behind a dynamic import and fall back tobarif the chunk fails or WebGL is not available. Akai-chat-only page never pays for the shader runtime. -
Scrolled off screen, a shader variant stops drawing and hands its WebGL context back to the browser, taking it again on the way in. Browsers only allow 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 rather than jumping forward.
-
animate-when-not-visibleopts a single element out of that, for a visualizer that has to keep running unseen. It holds its context for as long as it is mounted, so use it on one or two elements, not on 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.