Canvas Design System
Main Site Tokens

Audio Recorder

Captures a voice note in the browser with the MediaRecorder API, previews it, then uploads it against a workshop entity.

info The helper renders markup only; the behaviour ships separately. ws_audio_recorder() emits all four states with data-recorder-action hooks and stops there. Microphone access, the timer, the preview and the upload all live in FacilitatorVoiceNotes (facilitator/js/modules/voice-notes.js), which is not part of components.js. Drop the component onto a page without loading that module and you get four buttons that do nothing. The examples below are static markup for the same reason.

When to Use

Use when: A facilitator needs to capture something mid-session faster than they can type it, and the note belongs to a specific thing: an exercise, an icebreaker, an agenda item, or a session. The four entity_type values are enforced by the API, so the recorder is only correct where one of them applies.
Don't use when: The note is typed. That is a plain textarea saved through coach.php?action=add_note. Also not for playback: once a note exists, render it with Audio Player.

Variants

There is one variant. The component's whole surface is the four-state machine below, and the only thing a caller changes is which entity the recording gets attached to.

Bound to an agenda item

The form the Facilitator ships. entity_type and entity_id land on the root element as data-entity-type and data-entity-id, which is where the upload reads them from at save time, not from PHP.

<?= ws_audio_recorder([
    'id'          => 'fac-voice-recorder',
    'entity_type' => 'plan_item',
    'entity_id'   => $currentItemId,
]) ?>

Bound to an exercise

Same markup, different owner. Nothing about the control changes; the entity pair decides which record the file is filed against and which notes a later GET /api/upload-voice-note.php?entity_type=&entity_id= returns.

<?= ws_audio_recorder([
    'entity_type' => 'exercise',
    'entity_id'   => $exercise['id'],
]) ?>

States

All four are rendered into the page at once, three carrying the native hidden attribute. The JS swaps hidden between them; it never rebuilds the markup. Each stage below is one real component with the demo forcing a different state visible.

Idle
Recording
Done (preview before upload)
Uploading
StateContainsLeaves via
idleOne "Record Voice Note" button with a red mic glyph.data-recorder-action="start", which requests the microphone. A denial or a missing device keeps the state at idle and raises a browser alert().
recordingA pulsing red dot, a tabular-numeral timer, and a solid red Stop button.data-recorder-action="stop". Duration is captured at stop, not at save, so a slow upload cannot inflate it.
doneA circular preview play button, the recorded duration, then discard and Save pushed right by margin-left: auto.discard returns to idle and drops the blob; save goes to uploading. preview plays the blob in place and swaps its own glyph between play_arrow and pause.
uploadingA spinner and "Saving...". No cancel.Success or failure in /api/upload-voice-note.php. There is no error state in the markup, so a failed upload surfaces as an alert() and a return to an earlier state.
HoverButtons lift their border to --gray-300 over a --gray-50 ground. Stop and Save darken instead, since they are already filled.
DisabledNot supported. There is no disabled option and no disabled styling. If a surface must block recording, hide or unmount the component.

Real-World Usage

The Facilitator's note card, in app/views/facilitate/run.php. The recorder is rendered once with entity_id at '0' and then repointed as the agenda advances, because the current item is not known at render time.

<div class="note-card__voice-section" id="voice-section" hidden>
    <div class="note-card__voice-divider">or record a voice note</div>
    <?= ws_audio_recorder([
        'id'          => 'fac-voice-recorder',
        'entity_type' => 'plan_item',
        'entity_id'   => '0',
    ]) ?>
    <div class="note-card__voice-list" id="voice-list"></div>
</div>

<script src="/facilitator/js/modules/voice-notes.js?v=2"></script>
<script>
FacilitatorVoiceNotes.initRecorder(
    document.getElementById('fac-voice-recorder'), 'plan_item', currentItemId);

// When the agenda moves on, repoint the same recorder rather than re-rendering.
FacilitatorVoiceNotes.setEntity('plan_item', nextItemId);
</script>
warning An empty entity_id fails silently at save. Omit the option and the template writes data-entity-id=""; the upload then refuses with a console message and no visible feedback, because the missing-entity branch never reaches the UI. Either pass a real ID or call setEntity() before the user can reach Save.

Options

Five, all of them plumbing. The component has no visual options at all: no size, no variant, no label override.

OptionTypeDefaultPurpose
idstring'ws-recorder-' . uniqid()Root element ID. Auto-generated, but pass a stable one: the JS needs a handle to call initRecorder() with.
entity_typestring''Rendered as data-entity-type. The API accepts exactly exercise, icebreaker, plan_item, session and rejects anything else with a 400.
entity_idstring|int''Rendered as data-entity-id. See the warning above about the empty default.
classstring''Extra classes on the root, appended after .ws-audio-recorder.
attrsarray[]Extra HTML attributes on the root. Values are escaped; boolean-style attributes are not supported here, every entry renders as key="value".

Accessibility

ConcernBehavior
ControlsReal <button type="button"> throughout, so keyboard and AT behaviour comes free. Record, Stop and Save carry visible text; the icon-only preview and discard buttons carry aria-label.
State changes are not announcedGap. The state swap uses the hidden attribute with no live region, so a screen reader user gets no signal that recording started, that it stopped, or that the upload finished. If you place this on a new surface, add an aria-live="polite" status near it.
The timer is silentElapsed time is visual only. A blind user cannot tell how long they have been recording.
Errors go through alert()Microphone denial, a missing device and an upload failure all raise a native alert rather than an inline message. It is announced, but it is not the platform's ws_alert pattern and there is no persistent record once dismissed.
MotionGap. The recording pulse and the upload spinner run continuously with no prefers-reduced-motion guard, unlike the sidenav and modal rules in the same stylesheet.
ColourRecording state is red plus a moving dot plus a running timer, so it does not depend on hue alone. Save uses --color-success-darker rather than the base green because white on the base step measures 2.28:1.

Tokens

TokenUsed for
--color-error / --color-error-darkMic glyph, pulse, timer, Stop fill and its hover
--color-success-darker / --color-success-darkSave fill and its hover
--color-primaryThe spinner's leading arc
--text-inverseLabel and glyph on the filled Stop and Save buttons
--gray-50 / --gray-200 / --gray-300Button hover ground, resting border, hover border, spinner track
--bg-surfaceButton ground
--text-secondary / --text-tertiaryButton label, preview duration, discard glyph
--radius-mdButton shape
--space-1 / --space-1-5 / --space-2 / --space-3Button padding and the gaps between states' parts
--font-body / --font-semiboldFace and label weight
--text-caption / --text-bodyLabel and timer scale, icon size

CSS Classes

ClassPurpose
.ws-audio-recorderRoot, carrying the entity data attributes
.ws-audio-recorder__stateOne state block; three of the four carry hidden
.ws-audio-recorder__state--idle / --recording / --done / --uploadingThe four states
.ws-audio-recorder__btnBase button, with --record, --stop, --play-preview, --discard and --save modifiers
.ws-audio-recorder__indicator / __pulse / __timerRecording readout
.ws-audio-recorder__preview / __duration / __actionsDone-state layout
.ws-audio-recorder__spinnerUpload indicator
.ws-audio-recorder__labelButton text, held on one line

Two JS contracts sit alongside the classes: data-state on each state block and data-recorder-action on each button (start, stop, preview, discard, save). Renaming either breaks the module.

Files

FilePurpose
includes/components/helpers.phpws_audio_recorder() helper function
includes/components/audio-recorder.phpTemplate, all four states
includes/components/components.cssStyles (.ws-audio-recorder rules)
facilitator/js/modules/voice-notes.jsFacilitatorVoiceNotes, the behaviour. Not bundled into components.js.
api/upload-voice-note.phpUpload, list and delete. Validates entity_type.
design-system/components/php/AudioRecorder.phpPortable namespaced mirror of the helper