Audio Recorder
Captures a voice note in the browser with the MediaRecorder API, previews it, then uploads it against a workshop entity.
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
entity_type values are enforced by the API, so the recorder is only correct where one of them applies.
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.
| State | Contains | Leaves via |
|---|---|---|
idle | One "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(). |
recording | A 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. |
done | A 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. |
uploading | A 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. |
| Hover | Buttons lift their border to --gray-300 over a --gray-50 ground. Stop and Save darken instead, since they are already filled. | |
| Disabled | Not 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>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.
| Option | Type | Default | Purpose |
|---|---|---|---|
id | string | 'ws-recorder-' . uniqid() | Root element ID. Auto-generated, but pass a stable one: the JS needs a handle to call initRecorder() with. |
entity_type | string | '' | Rendered as data-entity-type. The API accepts exactly exercise, icebreaker, plan_item, session and rejects anything else with a 400. |
entity_id | string|int | '' | Rendered as data-entity-id. See the warning above about the empty default. |
class | string | '' | Extra classes on the root, appended after .ws-audio-recorder. |
attrs | array | [] | Extra HTML attributes on the root. Values are escaped; boolean-style attributes are not supported here, every entry renders as key="value". |
Accessibility
| Concern | Behavior |
|---|---|
| Controls | Real <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 announced | Gap. 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 silent | Elapsed 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. |
| Motion | Gap. 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. |
| Colour | Recording 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
| Token | Used for |
|---|---|
--color-error / --color-error-dark | Mic glyph, pulse, timer, Stop fill and its hover |
--color-success-darker / --color-success-dark | Save fill and its hover |
--color-primary | The spinner's leading arc |
--text-inverse | Label and glyph on the filled Stop and Save buttons |
--gray-50 / --gray-200 / --gray-300 | Button hover ground, resting border, hover border, spinner track |
--bg-surface | Button ground |
--text-secondary / --text-tertiary | Button label, preview duration, discard glyph |
--radius-md | Button shape |
--space-1 / --space-1-5 / --space-2 / --space-3 | Button padding and the gaps between states' parts |
--font-body / --font-semibold | Face and label weight |
--text-caption / --text-body | Label and timer scale, icon size |
CSS Classes
| Class | Purpose |
|---|---|
.ws-audio-recorder | Root, carrying the entity data attributes |
.ws-audio-recorder__state | One state block; three of the four carry hidden |
.ws-audio-recorder__state--idle / --recording / --done / --uploading | The four states |
.ws-audio-recorder__btn | Base button, with --record, --stop, --play-preview, --discard and --save modifiers |
.ws-audio-recorder__indicator / __pulse / __timer | Recording readout |
.ws-audio-recorder__preview / __duration / __actions | Done-state layout |
.ws-audio-recorder__spinner | Upload indicator |
.ws-audio-recorder__label | Button 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
| File | Purpose |
|---|---|
includes/components/helpers.php | ws_audio_recorder() helper function |
includes/components/audio-recorder.php | Template, all four states |
includes/components/components.css | Styles (.ws-audio-recorder rules) |
facilitator/js/modules/voice-notes.js | FacilitatorVoiceNotes, the behaviour. Not bundled into components.js. |
api/upload-voice-note.php | Upload, list and delete. Validates entity_type. |
design-system/components/php/AudioRecorder.php | Portable namespaced mirror of the helper |