The media helpers are framework-agnostic @a4anthony/proctorkit-sdk calls — usable from any framework today. Where an example mounts a Vue component, that path is available now; the React wrapper is 🚧 in progress. Plain JavaScript and other frameworks use the shipped raw helpers directly. See Choose your integration.
The SDK exposes first-class media helpers for assessment media triggered by the customer app. These are separate from passive proctoring observers.
Use media helpers when a candidate:
Listens to an audio prompt.
Views a video prompt.
Records a speaking answer.
Records a video answer.
Uploads a final media blob from a custom recorder.
Every helper stamps the action onto the session timeline and associates it with the same internal session, app key, and device fingerprint.
Vue integrations can render first-party components inside ProctoredAssessment and save the returned clip or playback state in the assessment answer.
Available components include:
AudioPlayer
VideoPlayer
AudioRecorder
VideoRecorder
For Storybook or non-proctored previews, AudioPlayer and VideoPlayer can run without a client; they use local browser playback with the same UI events but no SDK telemetry.
All four Vue media components hide informational metadata by default. Pass :hide-metadata="false" when candidates should see replay allowances, recording limits, or completed clip details. Controls, progress, errors, and upload status remain visible either way.
Pass stream when your assessment already owns microphone/camera access — the video component previews the stream but leaves track lifecycle ownership with your app. Save the returned clipNumber (recorders) or playback state (players) in your assessment answers.
For listening and viewing prompts, allowedReplays controls how many extra plays are allowed after the first playback. Both players render a custom progress bar, support autoplay attempts, and hide pause/stop controls by default. While a prompt is actively playing, the Play button is hidden; after playback ends, a Replay button appears only when another replay is allowed. Neither player exposes seeking, so candidates cannot skip through a prompt.ProctoredAssessment carries the preflight speaker/headphone selection into the SDK session, and both players also accept sinkId for an explicit output override. Pair autoplay with muted on VideoPlayer — browsers block un-muted video autoplay without a candidate gesture.
Audio file to play. Query strings are stripped from emitted events.
client
ProctoringClient
-
SDK client. Omit for standalone local playback with no telemetry.
label
string
-
Reviewer-facing label included in playback events.
allowedReplays
number or null
null
Extra plays after the first. null means unlimited; 0 means one play only.
autoplay
boolean
false
Attempt playback on mount. Browser autoplay policy still applies.
loop
boolean
false
Loop the audio.
volume
number
1
Playback volume, 0-1.
playbackRate
number
1
Playback speed, 0.25-4.
sinkId
string or null
-
Audio output device id. Defaults to the preflight speaker selection.
showPauseButton
boolean
false
Show the Pause control.
showStopButton
boolean
false
Show the Stop control.
hideMetadata
boolean
true
Hide the informational metadata around the player (e.g. the "2 replays left" allowance). Set false to show it. Controls and playback are unaffected.
playLabel
string
"Play"
Play button label.
replayLabel
string
"Replay"
Replay button label.
pauseLabel
string
"Pause"
Pause button label.
stopLabel
string
"Stop"
Stop button label.
disabled
boolean
false
Disable all controls.
Events:
started: { payload }, emitted when playback starts or resumes.
paused: { payload }, emitted when playback pauses.
stopped: { payload }, emitted when the Stop control rewinds playback to 0.
ended: { payload }, emitted when playback reaches the end.
error: emitted for playback or media-load failure. The payload is a union of two shapes: a playback-event failure { payload, error }, or a thrown-error shape { kind: "playback", error, message } when the media element itself throws. message is only present on the thrown shape — read error.message for a value that's always there.
replayLimitReached: { allowedReplays, playCount }, emitted when a play is attempted past the replay cap.
SDK client used to record and upload the clip. When omitted (and no active session client exists), the recorder runs standalone — see below.
maxDurationMs
number
120000
Hard recording cap; auto-stops at the limit.
stream
MediaStream
-
Pre-acquired mic stream. Omit to let the recorder request one.
micDeviceId
string or null
-
Microphone device id when the recorder acquires its own stream. Defaults to the preflight mic selection.
prepareRecording
() => Promise<void> | void
-
Hook run before recording starts, for example to acquire a stream.
showWaveform
boolean
true
Show the live input waveform.
waveformBarCount
number
12
Number of waveform bars.
title
string
"Audio answer"
Card heading.
description
string
-
Optional sub-text under the title.
recordLabel
string
"Record audio"
Record button label.
stopLabel
string
"Stop"
Stop button label.
hideStopButton
boolean
false
Hide the Stop control while recording so the candidate cannot end it manually. Recording then runs until the maxDurationMs cap auto-stops it. The Record button still appears before recording.
hideMetadata
boolean
true
Hide descriptive/default metadata such as the maximum-duration caption and completed clip number/duration. Set false to show it. Controls, progress, errors, and recording/upload status remain visible.
allowedRetakes
number or null
0
How many times the candidate may re-record after the first take. 0 = a single recording (the Record button disappears once it uploads). 2 = 3 takes total. null = unlimited. Only successful recordings consume an attempt; a failed take can always be retried.
unavailableMessage
string
-
Legacy. With the standalone fallback the recorder no longer blocks on a missing client, so this rarely shows.
disabled
boolean
false
Disable the recorder.
autoStart
boolean
false
Enable automatic recording mode. The manual Record audio control is hidden immediately; use startRecording to tell the component when the countdown or host flow has finished.
startRecording
boolean
false
When autoStart is enabled, a false → true transition starts a take through the normal prepare → acquire-stream → start path. Returning it to false does not stop an active take; it rearms a later rising edge. It is ignored when autoStart is false.
Mirror each finalized clip to your own backend, in addition to the proctoring server. Receives the recorded audio as a Blob. Resolve when your upload succeeds; throw/reject when it fails. A failure is non-fatal — the clip still uploads and uploaded still fires; the rejection surfaces on clip-mirror-failed. Both uploads run in parallel.
While a clip uploads (and during the brief pre-record preparation), the card shows a spinner with Uploading… / Preparing….
For a countdown-controlled recording, enable automatic mode before the countdown begins and switch startRecording on at zero:
The Record control stays hidden throughout automatic mode. Set startRecording back to false before issuing another true transition for an allowed retake. Neither transition stops an active recording; stopping remains controlled by the Stop button, hideStopButton, or maxDurationMs.
Events:
started: { kind, clipNumber }, emitted when recording starts.
stopped: { kind, clipNumber }, emitted when recording stops and upload begins.
uploaded: { kind, clipNumber, byteSize, durationMs, volumeAnalysis? }, emitted when the clip uploads to the proctoring server.
dropped: { kind, clipNumber, code, reason, volumeAnalysis? }, emitted when the recorded clip could not be stored. See Handling recorder errors.
error: { kind, code, error, message }, emitted when recording could not start. See Handling recorder errors.
clip-mirror-failed: { kind, clipNumber, error, message }, emitted when a sendClip mirror rejects. Non-fatal: the clip still uploaded to the proctoring server (uploaded fired too).
If no client is supplied — neither the client prop nor an active ProctoredAssessment session — the recorder runs standalone: it captures with a local MediaRecorder and, instead of uploading to the proctoring server, simulates the upload (and delivers the blob to sendClip if one is wired). This is the recorder counterpart to AudioPlayer's client-less local playback, useful for standalone testing, docs, or non-proctored capture. There is no prop to toggle this; it is purely "is a valid client present or not".
Standalone is safe by design inside a proctored flow: standalone only ever applies when no client exists, so a real session's answer can never silently bypass the server.
The recorder reports failure through two separate events, because they need different candidate messaging:
error — recording never produced a clip (the microphone could not be acquired or started). Nothing was captured.
dropped — the clip recorded fine but could not be stored (the upload failed, or it captured nothing). Think of it like a dropped network packet: it existed, then didn't reach its destination.
Both carry a stable code alongside the human-readable message/reason. Branch on code, not the message — messages come from the browser and vary by locale; codes do not. The codes are validated at the component boundary, so the value you receive is always one of the closed sets below (anything unexpected is coerced to unknown / network-error).
error.code (MediaClipErrorCode) — recording could not start:
code
Cause
Retry helps?
permission-denied
Mic permission blocked.
No — the candidate must allow it.
no-device
No microphone found.
Yes, once a mic is connected.
device-in-use
Mic busy in another app (Zoom, Teams…).
Yes, once it's freed.
no-audio-track
A supplied stream had no audio track.
No — fix the stream.
no-video-track
A supplied stream had no video track (VideoRecorder).
No — fix the stream.
unsupported
No getUserMedia / MediaRecorder (insecure context, old browser).
No.
unknown
Unclassified failure.
Maybe — show message.
dropped.code (MediaClipDropCode) — clip recorded but not stored:
code
Cause
Retry helps?
empty-recording
Zero bytes captured (instant stop, dead track).
Yes.
rejected
Upload got a 4xx (auth, quota, origin not allowed, validation).
No — a hard limit; surface to the proctor.
server-error
Upload got a 5xx.
Yes — transient.
network-error
Upload request threw (offline, CORS, DNS).
Yes.
timeout
Upload did not settle before the SDK's size-aware deadline.
Yes — transient.
vue
<script setup lang="ts">import{AudioRecorder}from"@a4anthony/proctorkit-vue";importtype{MediaClipErrorPayload,MediaClipDroppedPayload}from"@a4anthony/proctorkit-vue";// Recording could not start.functiononError({ code, message }:MediaClipErrorPayload){switch(code){case"permission-denied":notify("Microphone is blocked. Enable it in your browser, then press Record.");break;case"no-device":notify("No microphone found. Plug one in and try again.");break;case"device-in-use":notify("Your microphone is in use by another app. Close it and retry.");break;case"unsupported":notify("This browser can't record audio. Use a recent Chrome, Edge, or Safari over HTTPS.");break;default:notify(`Couldn't start recording: ${message}`);}}// Clip recorded but was not stored.functiononDropped({ code }:MediaClipDroppedPayload){if(code ==="rejected"){// 4xx — auth / quota / origin. Retrying won't help.notify("Your answer was refused by the server. Please contact your proctor.");}else{// empty-recording / server-error / network-error — retry is worth offering.notify("Your answer wasn't saved. Please record again.");}}</script><template><AudioRecorder:client="client" @error="onError" @dropped="onDropped"/></template>
The code unions are exported for exhaustive handling, along with runtime guards:
ts
import{typeMediaClipErrorCode,typeMediaClipDropCode}from"@a4anthony/proctorkit-vue";import{ isClipDropCode, normalizeClipDropCode }from"@a4anthony/proctorkit-sdk";constCOPY:Record<MediaClipErrorCode, string>={"permission-denied":"Microphone is blocked.","no-device":"No microphone found.","device-in-use":"Microphone is busy.","no-audio-track":"No audio input.","no-video-track":"No video input.",
unsupported:"Recording unsupported in this browser.",
unknown:"Recording failed.",};
Note: in standalone mode there is no server upload, so the only dropped code that can occur is empty-recording.
sendClip lets you send a copy of each clip — audio or video — to your own storage or API while the proctoring server keeps the canonical copy. The mirror is best-effort and never affects the clip outcome.
SDK client used to record and upload the clip. When omitted (and no active session client exists), the recorder runs standalone — see Standalone recording.
maxDurationMs
number
120000
Hard recording cap; auto-stops at the limit.
stream
MediaStream
-
Host-owned camera stream to preview and record. Pass when your app owns the camera — the recorder never stops its tracks. When omitted, the recorder acquires its own camera (+ mic when audio) as a live viewfinder on mount so the candidate can frame themselves before pressing Record.
cameraDeviceId
string or null
-
Camera device id when the recorder acquires its own stream. Defaults to the preflight camera selection.
audio
boolean
true
Include the microphone track in the recording.
videoBitrate
number
-
Target video bitrate in bits per second.
prepareRecording
() => Promise<void> | void
-
Hook run before recording starts, for example to acquire a stream.
title
string
"Video answer"
Card heading.
description
string
-
Optional sub-text under the title.
recordLabel
string
"Record video"
Record button label.
stopLabel
string
"Stop"
Stop button label.
hideStopButton
boolean
false
Hide the Stop control while recording so the candidate cannot end it manually. Recording then runs until the maxDurationMs cap auto-stops it. The Record button still appears before recording.
hideMetadata
boolean
true
Hide completed clip duration metadata. Set false to show it. Controls, progress, errors, and recording/upload status remain visible.
allowedRetakes
number or null
0
How many times the candidate may re-record after the first take. 0 = a single recording (the Record button disappears once it uploads). 2 = 3 takes total. null = unlimited. Only successful recordings consume an attempt; a failed take can always be retried.
unavailableMessage
string
-
Legacy. With the standalone fallback the recorder no longer blocks on a missing client, so this rarely shows.
disabled
boolean
false
Disable the recorder.
autoStart
boolean
false
Enable automatic recording mode. The manual Record video control is hidden immediately; use startRecording to tell the component when the countdown or host flow has finished.
startRecording
boolean
false
When autoStart is enabled, a false → true transition starts a take through the normal prepare → acquire-viewfinder → start path. Returning it to false does not stop an active take; it rearms a later rising edge. It is ignored when autoStart is false.
Mirror each finalized clip to your own backend, in addition to the proctoring server. Receives the recorded video as a Blob. Resolve when your upload succeeds; throw/reject when it fails. A failure is non-fatal — the clip still uploads and uploaded still fires; the rejection surfaces on clip-mirror-failed. Both uploads run in parallel.
VideoRecorder emits the same started, stopped, uploaded, dropped, error, and clip-mirror-failed events as AudioRecorder, with kind: "video" in clip payloads. The sendClip mirror works identically on both recorders — see Mirroring clips to your own backend.
The wizard writes the candidate's camera, microphone, and speaker selections through to the session, and every media helper defaults to them (micDeviceId, sinkId, and the camera the webcam observer uses). Two helpers let you work with devices directly.
Build your own device picker without hitting the browser's empty-label gotcha (labels are hidden until the page has been granted permission to a device of that kind). enumerateMediaDevices() optionally probes with a one-shot getUserMedia call to unlock labels, stops the probe stream, then enumerates:
ts
import{ enumerateMediaDevices }from"@a4anthony/proctorkit-sdk";const{ cameras, mics, speakers }=awaitenumerateMediaDevices();// each entry is { deviceId, label }
Call it from inside a user gesture when probing (the default) — getUserMedia needs user activation. Pass { probe: false } when the page already holds camera + mic permission (e.g. during a live session) and you just want the labelled list. speakers lists audiooutput devices; Safari does not expose them, so speakers can be empty even when a speaker is in use.
Each kind is { id, label }. An id of null (label "System default") means the candidate never overrode the browser default — the browser doesn't expose the default device's id without opening a stream, so it's reported as such. If a picked device has been unplugged since preflight, its label falls back to the raw id. The method opens no probe stream (a live session already holds permission).
Via the Vue wrapper, reach the client through useProctoringClient() — the method lives on that same instance:
<ProctoredAssessment> includes the first-party audio and video settings dialog. The host application does not mount ProctoringDeviceSettings, maintain modal state, pass device lists, or configure a second face detector. Add your Settings button anywhere inside the Vue application and open the wrapper-owned dialog through the active client:
openSettings() returns true when the dialog opens and false when settings are unavailable. It fails closed when there is no active client, the session is not ready, recovery is already in progress, the session is ending or ended, the client belongs to an older assessment, or the resolved policy has no configurable camera, microphone, or speaker. The method is added by @a4anthony/proctorkit-vue to the client exposed by useProctoringClient() and the <ProctoredAssessment> default slot; it is not a method on a standalone ProctoringClient created directly from @a4anthony/proctorkit-sdk.
The dialog only shows device kinds enabled by the resolved assessment policy. It renders the same MicStep, SpeakerStep, and CameraStep used by initial preflight and the Resume Assessment wizard, so permission, selection, verification, recovery, and Help behaviour stay aligned. The wrapper also forwards its resolved vadAssets paths to Device Settings, so microphone verification loads VAD and ONNX Runtime files from the same configured server paths as preflight and resume recovery. When the browser exposes a labelled default microphone or speaker, the collapsed row shows that hardware label instead of the generic “System default”. Opening a device row initially shows only switching guidance and its dropdown. Selecting a different device reveals the shared verification flow, temporarily locks the other device rows, and disables Save and close until that replacement passes:
Microphone: the candidate runs the shared live waveform and speech-verification check.
Speaker: the candidate plays a test tone, confirms it was audible, and then stages the output. Browsers without setSinkId remember the saved choice but may require browser or operating-system sound settings to route it.
Camera: recognised virtual cameras are excluded, the shared live preview opens, and the same face-verification path used by resume recovery must find exactly one face.
Passing verification stages the replacement without changing the live proctoring client or its persisted device selection. Save and close applies and persists every staged replacement, then dismisses the dialog. Camera and microphone switches are rejected at save time while an assessment response is actively recording, so an in-progress clip is never silently moved to another input; if a switch fails, the dialog remains open and the previous device stays selected. The full-screen backdrop blocks interaction with the assessment and does not dismiss the dialog when clicked, preventing an accidental loss of staged choices. Closing it without saving through Escape or a host-controlled close discards those choices. If integrity recovery starts while settings are open, the wrapper closes the settings dialog and leaves the Resume Assessment flow as the single recovery surface.
client.playAudioFile() plays a URL-backed audio prompt and emits audio-playback.started, audio-playback.paused, audio-playback.stopped, audio-playback.ended, or audio-playback.error. When a selected speaker/headphone is configured, output routing also emits audio-playback.sink-applied, audio-playback.sink-unsupported, or audio-playback.sink-failed. If the selected output disappears during playback, the SDK emits audio-playback.sink-disconnected and attempts to reroute the same audio element to the system default output. Event payloads remove query strings and hash fragments from the URL, so signed audio links are not written into the timeline.
client.playVideoFile() is the video twin of playAudioFile(): it plays a URL-backed video prompt and emits video-playback.started, video-playback.paused, video-playback.stopped, video-playback.ended, or video-playback.error. The video's audio track routes through the same speaker/headphone selection as audio prompts, emitting the matching video-playback.sink-* events. Payload URLs are stripped of query strings and hash fragments, so signed video links are not written into the timeline.
On top of the shared options, video adds poster, muted, and playsInline. Each is applied only when set, so a host-supplied <video> element keeps its own configuration; playsInline defaults to true for elements the SDK creates so iOS Safari keeps playback inline.
tsx
const handle = client.playVideoFile({
url:"https://cdn.example.com/prompts/q7.mp4?signature=...",
label:"Question 7 viewing prompt",
poster: question.posterUrl,
autoplay:false,
onEnded:()=>markPromptWatched(),
onError:(_error)=>showPlaybackError(),});// Later, from a candidate gesture:await handle.play();
client.recordAudioClip() records microphone audio, uploads to the audio-clips endpoint, and emits audio-clip.started, audio-clip.stopped, and audio-clip.uploaded or audio-clip.dropped. The default safety cap is 2 minutes; pass maxDurationMs to make the limit explicit in your UI. Clip uploads are drained before the clean session.ended event is sent.
client.recordVideoClip() records a bounded video answer and uploads it when the returned handle is stopped. It can use a stream you pass in, reuse the SDK webcam observer's camera stream, or request a fresh camera/microphone stream. When it must request media itself, call it directly from the candidate's button click so the browser permission prompt has user activation. These answer clips are separate from passive webcam or screen-share proctoring recordings.
If passive webcam recording is enabled, the SDK reuses that camera stream where possible. That avoids a second camera prompt and avoids interrupting continuous webcam evidence.
A retake should be treated as a new clip, not an overwrite. Previous clips remain in the session evidence trail, while your assessment app decides which clipNumber is the submitted answer for that question.
ts
let acceptedClipNumber: number |null=null;asyncfunctionrecordAttempt(questionId: string){const clip =await client.recordVideoClip({
maxDurationMs:120_000,
onUploaded:(clipNumber)=>{
acceptedClipNumber = clipNumber;saveQuestionAnswer({ questionId, clipNumber });},});return clip;}// Retake flow:// 1. Stop the current clip.// 2. Let the candidate preview or discard it in your UI.// 3. Start a new clip and store the new accepted clipNumber.// The old clip stays visible to reviewers as evidence.
If your assessment app already owns the recorder and preview/retake UI, send the final video blob through client.uploadVideoClip(). It lands in the same dashboard surface as recordVideoClip() and emits the same uploaded/dropped timeline events.
There is no matching custom audio-blob upload helper yet. For V1 speaking questions, use recordAudioClip() unless we add a first-class uploadAudioClip() API.
The Vue package also exports presentational building blocks used across the proctoring UI. Use them when composing custom screens around the first-party wizard or media controls.
ProctoringSelect is a branded single-select dropdown built on Headless UI's Listbox, so the expanded option list is fully styleable (a native select can't style its list). Headless UI supplies the keyboard navigation, type-ahead, focus management, and ARIA roles. Because it renders a custom listbox rather than a native select, it does not surface the OS-native picker on mobile — a deliberate trade for a fully brandable list.
Options come in via the required options prop — an array of { value, label, disabled? } — not slotted <option> children. Layout attributes such as class and style land on the wrapper; everything else (id, aria-*) passes through to the button, so a <label for> still associates.
The option list: { value, label, disabled? } per entry.
size
"sm" or "md"
"sm"
sm is compact; md matches standard form inputs.
placeholder
string
-
Button text shown when no option matches the current value.
disabled
boolean
false
Disable the control.
Slot:
action: optional compact action rendered visually inside the field (e.g. a "Test" button beside the value), without nesting an interactive control inside the listbox button.