Skip to content

Voice response: record with MediaRecorder, convert to TypeScript, and limit answers to 15 MB - #8624

Open
adi-herwana-nus wants to merge 4 commits into
masterfrom
adi/voice-response-mediarecorder
Open

adi-herwana-nus wants to merge 4 commits into
masterfrom
adi/voice-response-mediarecorder

Conversation

@adi-herwana-nus

@adi-herwana-nus adi-herwana-nus commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Voice response answers were recorded with recorderjs, a git submodule of an unmaintained library:

  • It captures audio through ScriptProcessorNode, which is deprecated.
  • It needs its own babel 6 / browserify toolchain, installed by a postinstall script on every
    yarn install and cached separately in CI.
  • It writes uncompressed 16-bit stereo WAV, about 10 MB per minute. Production has voice answers of up to
    93 MB.

The answering flow around it was the last JavaScript in that area:

  • a class component;
  • four thunk wrappers that only dispatched plain actions;
  • a reducer that called the recorder from inside itself.

That code also had bugs:

  • the microphone was never released;
  • the recorder could get stuck;
  • the audio preview leaked object URLs.

This PR replaces recorderjs with the browser's native
MediaRecorder and converts the flow to
TypeScript. Existing user-facing behaviour is kept wherever possible, and every deliberate difference is
listed in the table below. It also adds a 15 MB answer size limit, sized from production data.

Change

1. Recording with MediaRecorder; recorderjs removed

utils/recorderHelper.ts keeps the old module's path and its startRecord / stopRecord / isRecording
exports, so callers did not change.

  • Format.

    • The recorder picks the first of MP4/AAC, WebM/Opus, WebM and Ogg/Opus that the browser can both record
      and play back (MediaRecorder.isTypeSupported and canPlayType). That keeps the student's own preview
      playable.
    • Files are named <unix-seconds>.m4a (or .webm / .ogg) instead of .wav.
    • MediaRecorder can't produce WAV or MP3, but both are still accepted as uploads.
    • The bitrate is left at the browser default, typically around 128 kbps, which is about 1 MB per minute.
  • Stereo, as before. The recorder asks for channelCount: 2. Built-in mics typically deliver one channel
    anyway. recorderjs copied that channel into both sides of the WAV, so a mono file sounds the same.

  • Each take gets its own MediaRecorder, while the mic stream is kept between takes. recorderjs's stop()
    only paused its pipeline, but recorderHelper called clear() straight after exporting, so every take was
    a fresh recording. Mapping that onto one paused-and-resumed MediaRecorder doesn't work:

    • only the first chunk of a MediaRecorder session carries the container header, so splitting one session
      into takes leaves every take after the first unplayable;
    • keeping all the chunks instead would make take 2 contain take 1.

    So the live stream, which is what recorderjs actually reused, stays open between takes. Later takes get no
    second prompt and no warm-up.

  • Robustness.

    • A mic that is unplugged mid-take still yields what was recorded.
    • A stream that arrives after the page has already let go of the mic is stopped. That covers StrictMode's
      dev-only remount, and a student leaving while the permission prompt is open.
    • Mic errors (blocked, not found, busy) are reported instead of failing silently.

The submodule is gone:

  • the .gitmodules entry and the checkout are removed;
  • the postinstall script is removed from client/package.json;
  • the recorderjs cache key and path are removed from .circleci/config.yml;
  • the vendor entries are removed from client/.eslintignore and client/.prettierignore.

checkout_with_submodules stays, because the Keycloak federation submodule still needs it.

2. Microphone lifetime

Why the page holds the mic open while it is open.

  • Acquiring and releasing the mic. acquireMicrophone() calls getUserMedia(), which does two separate
    things:

    1. Permission. If the site has no saved grant, the browser prompts. A grant belongs to the site, and the
      browser may remember it.
    2. Capture. Once allowed, the browser opens the device and returns a live stream. The mic stays open for
      as long as the stream's tracks are live, whether or not anything is recording from it.

    releaseMicrophone() stops those tracks, which closes the device. It doesn't revoke the permission.

  • What the page does. useMicrophoneAccess runs in SubmissionForm.

    • On open, it acquires the mic if the viewer is the creator of a submission that is still being
      attempted, and the assessment has a voice question. Graders opening an in-progress submission still see
      the record buttons, but are asked only if they press Start.
    • On close, it always releases the mic, including a mic a grader acquired by pressing Start.
  • Why. This matches the existing behaviour: recorderjs acquired the stream on the first Start and held it
    until the page reloaded. The only differences are that the mic is acquired when the page opens rather than
    on the first Start, and that it is now released when the page closes.

  • It doesn't lock other users out. On desktop browsers, other tabs and apps can still use the mic at the
    same time.

  • Known costs, accepted for now:

    • the browser's recording indicators stay on while the page is open;
    • Bluetooth headsets switch to their lower-quality call profile while the mic is open;
    • on mobile, where usually only one tab or app can capture at a time, the open mic can conflict with other
      apps.

    A permission-only gate is listed under follow-ups.

3. Answer component, hooks and reducer converted to TypeScript

Names. Existing names are kept wherever the concept is unchanged:

  • the actions setRecording / setNotRecording;
  • the state fields recording / recordingComponentId;
  • the handlers onStartRecord / onStopRecord;
  • checkVoiceResponseRecorded, and the existing translation IDs.

Where things now live:

  • components/answers/VoiceResponse/index.tsx replaces containers/VoiceResponseAnswer.jsx. It is a
    function component, in the one-folder-per-question-type layout used by the other answer components.
  • useVoiceRecorder is the only code that touches the recorder's Redux state.
  • reducers/recorder.ts is a createSlice that owns its actions.
    • actions/answers/voiceResponse.js and the four RECORDER_* action types are deleted.
    • The state keeps its old fields, minus recorderComponentsCount.

What recorderComponentsCount did, and where that went. Every voice question dispatched mount and unmount
actions. When the last one unmounted, the reducer called recorderHelper.stopRecord() from inside itself,
so the mic didn't keep recording with no Stop button on screen. That had two problems:

  • A side effect in a reducer. It already caused one crash, fixed in cd6dddb.
  • It measured the wrong thing. What matters is whether the recording question is still there. If that
    question unmounted while another stayed, as on a tabbed assessment's step change, the count stayed above 0,
    so nothing stopped the recorder. Meanwhile recording was set to false, so the next Start failed with
    "Recorder has already started", and nothing could stop the take.

Its job is now split between two places:

  • The recording question owns the cleanup. When it unmounts, useVoiceRecorder cancels the take and
    clears the state. It also handles a question unmounting while Start is still waiting for the mic, for
    example on the permission prompt: the take that starts afterwards is discarded rather than left running
    with no Stop button. The old code had that gap too.
  • The page owns the "everyone has left" case. useMicrophoneAccess's cleanup cancels any take still
    running, then releases the mic. Voice answers are only rendered inside SubmissionForm, so this is exactly
    when the old count reached 0.

Other fixes and changes in the component:

  • Audio preview. AudioPreview is a new standalone preview in lib/components/form/fields/SingleFileInput/,
    next to SpreadsheetPreview. It creates and revokes its object URL in an effect, which fixes the leak. It
    isn't a previewComponent, because SingleFileInput shows those only inside the dropzone and only for
    dropped files, so a previewComponent would miss recordings and disappear in read-only views.
  • Upload formats. The dropzone accepts the common audio formats: mp3, wav, m4a/mp4, aac, ogg/oga/opus,
    webm and flac. That fixes the old 'wav' entry, which was missing its dot, and the non-standard
    'audio/mp3' MIME type.
  • Messages.
    • The dropzone's help text lists the new formats.
    • New messages cover a blocked, missing or busy mic, and browsers that can't record. Those browsers still get
      the upload option.
    • All are in en, zh and ko; the existing message IDs are kept.

4. 15 MB answer size limit

Why 15 MB. These are the sizes and durations of existing voice answers in production, for three courses
with substantial use. Durations come from each WAV's header, read with a ranged S3 GET:

Course Files Median recording p99 Longest Largest file
Course 1 (2020) 101 2 min 45 s 6 min 30 s 6 min 38 s 76.5 MB
Course 2 (2019–20) 207 3 min 9 s 5 min 57 s 6 min 37 s 93.2 MB
Course 3 (2025) 125 9 s 1 min 0 s 1 min 8 s 12.0 MB

At about 1 MB per minute of compressed audio, 15 MB holds about 15 minutes of recording. That's more than
twice the longest recording on record. The 2 MB programming-answer limit would have held about 2 minutes,
shorter than the median in both 2020 courses.

What students see:

  • Recording stops automatically at 95% of the limit, about 14½ minutes at typical bitrates. The margin
    covers the final chunk, which arrives after the stop.
    • The take is kept and saved, and a toast says why it stopped.
    • To enforce this, the recorder hands over audio every second (start(1000)) and counts the bytes.
  • Uploads over the limit are rejected with a toast suggesting mp3 or m4a. WAV uploads longer than about
    80 seconds will hit this, but recorded answers are no longer WAV.
  • On the server, Answer::VoiceResponse rejects a newly assigned file over MAX_FILE_SIZE
    (15.megabytes), with :exceed_size_limit on :attachment.
    • Only new files are checked, so existing answers of up to 93 MB can still be saved and finalised.
    • The client's MAX_VOICE_RESPONSE_FILE_SIZE (15,000,000 bytes) is slightly stricter than the server's
      15 MiB, the same arrangement as the programming answer limit.

SingleFileInput changes, for the dropzone:

  • Two new optional pass-through props, maxSize and onDropRejected.
  • onDrop now ignores a drop in which every file was rejected. Before, such a drop cleared the current file
    and fired onChange. That already happened for wrong-type drops in every form using it, and for voice
    answers it triggered a save with no new file.

What students will notice

Behaviour Before After
Start/Stop buttons Start is disabled everywhere while one question records; only that question can stop Same
Each take A fresh recording replaces the answer and is saved immediately Same
Later takes No second prompt, no warm-up Same
Navigating away mid-recording The take is discarded Same
Mic permission prompt On the first Start Once, when the student opens their own in-progress submission, if it has a voice question
Mic indicator On from the first Start until the page reloads On while the submission page is open, off when the student leaves
Recorded file .wav, about 10 MB/min .m4a (or .webm / .ogg), about 1 MB/min
Size limit None 15 MB, about 15 minutes; recording stops automatically, and oversized uploads are rejected
Upload formats mp3, wav The common audio formats
Mic blocked or missing Start silently did nothing An explanation is shown
The recording question unmounts while another stays The recorder got stuck The take is discarded, as on navigation

Commits

  1. Renames only. Four files are renamed to their new paths and .ts/.tsx extensions, with no content
    changes, so git history follows them. This commit doesn't type-check on its own.
  2. Recorder and answer component:
    • the MediaRecorder rewrite of recorderHelper and the submodule removal;
    • the component and its hooks, converted to TypeScript;
    • the mic request on page open, the upload formats, AudioPreview and the translations.
  3. Reducer: the recorder slice; the old actions file and action types are deleted.
  4. Size limit: the client constant, the recorder's auto-stop, the dropzone limit, SingleFileInput's new
    props and rejected-drop fix, the model validation and the Rails error messages.

Tests

  • Frontend: tsc exits 0, and eslint and prettier are clean. The whole Jest suite passes, since a shared
    module moved. The new and rewritten test files have 41 tests:
    • recorderHelper, 18 tests. jsdom has neither MediaRecorder nor getUserMedia, so these use fakes that
      behave like the browser: the final chunk and stop arrive asynchronously, and a recorder stops itself when
      its track ends. They cover:
      • format selection and file names;
      • stream reuse, where each file holds only its own take;
      • stream lifetime, including a stream that arrives after release and StrictMode's
        acquire–release–acquire;
      • auto-stop at the size limit;
      • errors.
    • The answer component, 11 tests:
      • only one question records at a time;
      • Stop saves and previews the take;
      • unmounting mid-take, and unmounting while the mic prompt is open;
      • saved-file playback and accepted formats;
      • read-only mode, mic errors and unsupported browsers;
      • auto-stop at the limit, and oversized-upload rejection.
    • useMicrophoneAccess, 5 tests. It asks only a student attempting their own submission that has a voice
      question, and always releases on unmount, including for graders.
    • AudioPreview, 4 tests, and the reducer, 3 tests.
  • Backend: model specs for a new file over the limit, a new file at the limit, and an existing oversized
    file that isn't re-checked. They stub MAX_FILE_SIZE to 1 KB, so no large files are created. They assert
    the error's kind, since I18n returns keys in tests.
  • Each regression test fails when its fix is removed. That covers:
    • the stuck recorder, the unmount during the mic prompt, the grader's mic release;
    • a stream arriving after release, a track ending mid-take;
    • the auto-stop, the dropzone limit and the rejected-drop fix;
    • the server's size check and its new-file guard.

Deployment notes

  • No migration. Existing WAV answers keep playing, including those over 15 MB.
  • Developers: run git submodule sync after pulling. The old checkout's metadata,
    .git/modules/vendor/assets/javascripts/recorderjs, can be deleted. yarn install no longer runs a
    postinstall.
  • CI may restore the old Yarn cache once, because the new cache key is a prefix of the old one. That's
    harmless: it only leaves an unused folder.

Follow-ups (not in this PR)

  • A permission-only gate for the mic. On page open, ask for the mic and then stop its tracks immediately,
    so only the permission is kept. Open the mic just for each take. That avoids the costs listed in section 2,
    at the price of a possible second prompt on browsers that don't remember the grant (Safari, and Firefox
    without "Remember this decision") and a short warm-up on each Start.
  • An AnswerDetails renderer for voice answers, which currently show AnswerNotImplemented.
  • Server-side validation of audio types, or transcoding. Only size is checked today.

🤖 Generated with Claude Code

@adi-herwana-nus
adi-herwana-nus requested a balanced review from Copilot October 16, 2026 07:08
@adi-herwana-nus adi-herwana-nus changed the title Adi/voice response mediarecorder Voice response: record with MediaRecorder, convert to TypeScript, and limit answers to 15 MB Oct 16, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Attachment-validation regressions and asynchronous recording-state races remain unresolved.

4 open findings
What changed in this PR

Replaces the assessment voice recorder with native MediaRecorder and modernizes the answering flow.

Changes:

  • Converts recording components and state management to TypeScript, with microphone cleanup and reusable audio previews.
  • Adds recording/upload size limits, broader audio-format support, localized messages, and regression tests.
  • Removes recorderjs installation and CI configuration.
File Description
spec/​models/​course/​assessment/​answer/​voice_response_spec.rb Tests attachment size validation.
config/​locales/​zh/​activerecord/​errors.yml Adds Chinese size-limit error.
config/​locales/​ko/​activerecord/​errors.yml Adds Korean size-limit error.
config/​locales/​en/​activerecord/​errors.yml Adds English size-limit error.
client/​package.json Removes recorderjs postinstall.
client/​locales/​zh.json Updates Chinese recording messages.
client/​locales/​ko.json Updates Korean recording messages.
client/​locales/​en.json Updates English recording messages.
client/​app/​lib/​constants/​sharedConstants.ts Defines client audio size limit.
client/​app/​lib/​components/​form/​fields/​SingleFileInput/​index.tsx Adds size limits and rejection handling.
client/​app/​lib/​components/​form/​fields/​SingleFileInput/​AudioPreview.tsx Adds preview with object-URL cleanup.
client/​app/​lib/​components/​form/​fields/​SingleFileInput/​__test__/​AudioPreview.test.tsx Tests playback sources and cleanup.
client/​app/​bundles/​course/​assessment/​utils/​recorderHelper.ts Implements native recording and microphone lifecycle.
client/​app/​bundles/​course/​assessment/​utils/​recorderHelper.js Removes legacy recorder helper.
client/​app/​bundles/​course/​assessment/​utils/​__test__/​recorderHelper.test.ts Tests recording, limits, and microphone lifecycle.
client/​app/​bundles/​course/​assessment/​submission/​reducers/​recorder.ts Adds side-effect-free recorder slice.
client/​app/​bundles/​course/​assessment/​submission/​reducers/​recorder.js Removes legacy reducer.
client/​app/​bundles/​course/​assessment/​submission/​reducers/​__test__/​recorder.test.ts Tests recorder state transitions.
client/​app/​bundles/​course/​assessment/​submission/​reducers/​__test__/​recorder.test.js Removes legacy reducer tests.
client/​app/​bundles/​course/​assessment/​submission/​pages/​SubmissionEditIndex/​SubmissionForm.tsx Integrates microphone lifecycle hook.
client/​app/​bundles/​course/​assessment/​submission/​containers/​VoiceResponseAnswer.jsx Removes legacy class component.
client/​app/​bundles/​course/​assessment/​submission/​constants.ts Removes obsolete recorder action types.
client/​app/​bundles/​course/​assessment/​submission/​components/​answers/​VoiceResponse/​useVoiceRecorder.ts Coordinates recording controls and cleanup.
client/​app/​bundles/​course/​assessment/​submission/​components/​answers/​VoiceResponse/​useMicrophoneAccess.ts Manages page-level microphone access.
client/​app/​bundles/​course/​assessment/​submission/​components/​answers/​VoiceResponse/​translations.ts Defines recording and upload messages.
client/​app/​bundles/​course/​assessment/​submission/​components/​answers/​VoiceResponse/​index.tsx Adds TypeScript voice-answer component.
client/​app/​bundles/​course/​assessment/​submission/​components/​answers/​VoiceResponse/​__test__/​VoiceResponseAnswer.test.tsx Tests voice-answer interactions.
client/​app/​bundles/​course/​assessment/​submission/​components/​answers/​VoiceResponse/​__test__/​useMicrophoneAccess.test.tsx Tests microphone acquisition and release.
client/​app/​bundles/​course/​assessment/​submission/​components/​answers/​adapters/​VoiceResponseAdapter.tsx Uses relocated answer component.
client/​app/​bundles/​course/​assessment/​submission/​actions/​answers/​voiceResponse.js Removes obsolete thunk wrappers.
client/​.prettierignore Removes vendor exclusion.
client/​.eslintignore Removes vendor exclusion.
app/​models/​course/​assessment/​answer/​voice_response.rb Adds server-side audio size validation.
.gitmodules Removes recorderjs submodule entry.
.circleci/​config.yml Removes recorderjs dependency caching.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.


# Only a newly assigned file is checked, so answers saved before the limit existed, which can be far
# larger, can still be saved and finalised.
validate :validate_file_size, if: -> { attachment_reference&.new_record? }
Comment on lines +31 to +34
return if file_size <= MAX_FILE_SIZE

# Round up to 2 decimal places, so a student sees "15.01 MB" if the file is slightly over.
errors.add(:attachment, :exceed_size_limit, file_size_mb: (file_size.to_f / 1.megabyte).ceil(2))
Comment on lines +63 to +66
onLimitReached: (file) => {
dispatch(setNotRecording());
onLimitReached(file);
},
Comment on lines +81 to +82
const onStopRecord = (onRecorded: (file: File) => void): void => {
recorderHelper
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants