Waveform
The custom element is <hui-waveform>.
Overview
Draws audio levels as a row of mirrored, rounded bars on a canvas: levels the server analysed, an idle processing animation, or the live level of a MediaStream the host hands it. The element never asks for a microphone.
Example
Source
<hui-waveform label="Recorded levels" style="height:80px"> <script type="application/json">{ "levels": [0.08,0.14,0.26,0.42,0.61,0.78,0.9,0.97,1,0.94,0.82,0.66,0.49,0.35,0.24,0.18,0.22,0.34,0.5,0.68,0.83,0.93,0.98,0.95,0.86,0.72,0.56,0.4,0.28,0.19,0.13,0.1,0.15,0.27,0.44,0.62,0.77,0.88,0.94,0.9,0.79,0.64,0.47,0.33,0.22,0.15,0.1,0.07] }</script></hui-waveform>The Go template that renders it:
{{/* Static levels: peaks your server already computed. The element only draws them. */}}<hui-waveform label="{{ .Recording.Title }}" style="height:80px"> <script type="application/json">{{ .Recording.LevelsJSON }}</script></hui-waveform>
{{/* Live: the page owns the permission prompt, not the element. */}}<hui-waveform id="mic" mode="live" label="Live audio waveform" style="height:80px"></hui-waveform><button type="button" onclick="startListening()">Start Listening</button><button type="button" onclick="stopListening()">Stop Listening</button>
<script> const waveform = document.getElementById('mic'); let stream = null;
async function startListening() { try { // Your page asks. The element never will. stream = await navigator.mediaDevices.getUserMedia({ audio: true }); waveform.listen(stream); waveform.mode = 'live'; } catch (error) { // The reader said no, or there is no microphone. Fall back to the // idle animation rather than leaving an empty box. waveform.mode = 'processing'; } }
function stopListening() { waveform.stop(); stream?.getTracks().forEach((track) => track.stop()); stream = null; waveform.mode = 'processing'; }</script>API
Attributes5
| Name | Type | Default | Description |
|---|---|---|---|
label | string | "" | Required. The accessible name. The mode is appended to it, so a listening waveform reads "…, listening" and a processing one "…, processing". |
mode | "static" | "processing" | "live" | "static" | static draws the JSON child, processing the layered-wave animation, live the stream given to listen(). Live mode mirrors the speech-band spectrum around the centre; scrolling mode draws its average energy over time. An unrecognised value falls back to static. |
scrolling | boolean | false | Bars move right to left rather than holding still. Ignored under reduced motion. |
bar-width | number | 3 | The width of one bar in pixels, measured from the reference. |
gap | number | 2 | The gap between two bars in pixels. |
Properties5
| Name | Type | Default | Description |
|---|---|---|---|
label | string | '' | Reflects to the label attribute. |
mode | WaveformMode | 'static' | Reflects to the mode attribute, normalised. |
scrolling | boolean | false | Reflects to the scrolling attribute. |
barWidth | number | 3 | Reflects to the bar-width attribute. |
gap | number | 2 | Reflects to the gap attribute. |
Methods2
| Name | Type | Description |
|---|---|---|
listen(stream) | (stream: MediaStream) => void | Draw the levels of a stream the host obtained. The element reads it through an AnalyserNode and never connects it to the speakers, records it or sends it anywhere. |
stop() | () => void | Release the analyser and the stream's source node. Safe to call when nothing is playing. |
Slots1
| Name | Description |
|---|---|
(default) | One <script type="application/json"> child holding { "levels": [] }, each level 0 to 1. Rewriting it redraws the bars, so an HTMX swap is the whole update. |
::part() hooks2
| Name | Description |
|---|---|
figure | The role="img" wrapper carrying the accessible name. |
canvas | The <canvas> the bars are drawn on, aria-hidden. |
States
Processing
mode="processing" layers three smooth waves, weighted towards the centre, with a different spatial pattern when scrolling is set. It needs no data and no stream. Switching from live input cross-fades into processing; stopping settles to a dotted baseline.
Source
<hui-waveform mode="processing" label="Live audio waveform" style="height:80px"></hui-waveform>Scrolling
scrolling moves the row right to left rather than holding it still. Under prefers-reduced-motion it holds still instead - as does the processing animation, while live levels keep updating, because the information they carry is the point.
Source
<hui-waveform scrolling label="Scrolling levels" style="height:80px"> <script type="application/json">{ "levels": [0.08,0.14,0.26,0.42,0.61,0.78,0.9,0.97,1,0.94,0.82,0.66,0.49,0.35,0.24,0.18,0.22,0.34,0.5,0.68,0.83,0.93,0.98,0.95,0.86,0.72,0.56,0.4,0.28,0.19,0.13,0.1,0.15,0.27,0.44,0.62,0.77,0.88,0.94,0.9,0.79,0.64,0.47,0.33,0.22,0.15,0.1,0.07] }</script></hui-waveform>Wider bars
The defaults - a 3px bar at a 5px pitch - are measured from the reference. bar-width and gap are the host's to change; the bar count follows from the width available.
Source
<hui-waveform bar-width="6" gap="4" label="Wide bars" style="height:80px"> <script type="application/json">{ "levels": [0.08,0.14,0.26,0.42,0.61,0.78,0.9,0.97,1,0.94,0.82,0.66,0.49,0.35,0.24,0.18,0.22,0.34,0.5,0.68,0.83,0.93,0.98,0.95,0.86,0.72,0.56,0.4,0.28,0.19,0.13,0.1,0.15,0.27,0.44,0.62,0.77,0.88,0.94,0.9,0.79,0.64,0.47,0.33,0.22,0.15,0.1,0.07] }</script></hui-waveform>Server-side mechanics
The server owns the levels and the mode; the viewer owns nothing the element keeps. Static levels arrive as a JSON child - peaks the server computed from a recording - and rewriting that child is the entire update, so an hx-swap that replaces it redraws the bars with no memory of the old ones. The element holds no copy of the audio: in live mode the analyser reads the signal frame by frame and the numbers are discarded. Deliberately not the charts' series/rows shape (UI-CONTRACT-008): a list of peaks is not a table, and forcing one would make a host invent a series name and a category for every peak.
Accessibility
- The wrapper is
role="img"named bylabelplus the mode; the canvas isaria-hidden. - Nothing is live-read. A picture of sound announced as it moved would be noise, not information.
- A silent bar is still drawn, as a dot the width of the stroke, so the row reads as a waveform at rest rather than as an empty box.
- Under forced colours the bars are drawn in
CanvasTextat full opacity, which a canvas has to be told explicitly.
Gotchas
- The element never calls
getUserMedia. A microphone is a permission prompt the page should own, and a library element that raised one by itself would surprise everyone - so the host asks, and passes the resultingMediaStreamtolisten(). - Unparseable JSON draws a silent baseline, sets
data-invalidand warns once. It never throws into the host's render, and never warns twice for the same fault. - Under
prefers-reduced-motionthe processing animation, transitions and the scroll stop, but live levels keep updating. Stopping those would remove the information the element exists to show. - A canvas sees no CSS, so the bar colour is read back from a probe element in the shadow root. That is also how forced colours work: the probe resolves to
CanvasTextand the bars are drawn solid, without the opacity that carries level in a themed palette.