Repository navigation
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
Open
adi-herwana-nus wants to merge 4 commits into
adi-herwana-nus wants to merge 4 commits into
Conversation
…components to typescript
Contributor
There was a problem hiding this comment.
🟡 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Motivation
Voice response answers were recorded with
recorderjs, a git submodule of an unmaintained library:ScriptProcessorNode, which is deprecated.postinstallscript on everyyarn installand cached separately in CI.93 MB.
The answering flow around it was the last JavaScript in that area:
That code also had bugs:
This PR replaces
recorderjswith the browser's nativeMediaRecorderand converts the flow toTypeScript. 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;
recorderjsremovedutils/recorderHelper.tskeeps the old module's path and itsstartRecord/stopRecord/isRecordingexports, so callers did not change.
Format.
and play back (
MediaRecorder.isTypeSupportedandcanPlayType). That keeps the student's own previewplayable.
<unix-seconds>.m4a(or.webm/.ogg) instead of.wav.Stereo, as before. The recorder asks for
channelCount: 2. Built-in mics typically deliver one channelanyway. 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
recorderHelpercalledclear()straight after exporting, so every take wasa fresh recording. Mapping that onto one paused-and-resumed MediaRecorder doesn't work:
into takes leaves every take after the first unplayable;
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.
dev-only remount, and a student leaving while the permission prompt is open.
The submodule is gone:
.gitmodulesentry and the checkout are removed;postinstallscript is removed fromclient/package.json;.circleci/config.yml;vendorentries are removed fromclient/.eslintignoreandclient/.prettierignore.checkout_with_submodulesstays, 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()callsgetUserMedia(), which does two separatethings:
browser may remember it.
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.
useMicrophoneAccessruns inSubmissionForm.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.
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:
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:
setRecording/setNotRecording;recording/recordingComponentId;onStartRecord/onStopRecord;checkVoiceResponseRecorded, and the existing translation IDs.Where things now live:
components/answers/VoiceResponse/index.tsxreplacescontainers/VoiceResponseAnswer.jsx. It is afunction component, in the one-folder-per-question-type layout used by the other answer components.
useVoiceRecorderis the only code that touches the recorder's Redux state.reducers/recorder.tsis acreateSlicethat owns its actions.actions/answers/voiceResponse.jsand the fourRECORDER_*action types are deleted.recorderComponentsCount.What
recorderComponentsCountdid, and where that went. Every voice question dispatched mount and unmountactions. 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:
question unmounted while another stayed, as on a tabbed assessment's step change, the count stayed above 0,
so nothing stopped the recorder. Meanwhile
recordingwas set tofalse, so the next Start failed with"Recorder has already started", and nothing could stop the take.
Its job is now split between two places:
useVoiceRecordercancels the take andclears 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.
useMicrophoneAccess's cleanup cancels any take stillrunning, then releases the mic. Voice answers are only rendered inside
SubmissionForm, so this is exactlywhen the old count reached 0.
Other fixes and changes in the component:
AudioPreviewis a new standalone preview inlib/components/form/fields/SingleFileInput/,next to
SpreadsheetPreview. It creates and revokes its object URL in an effect, which fixes the leak. Itisn't a
previewComponent, becauseSingleFileInputshows those only inside the dropzone and only fordropped files, so a
previewComponentwould miss recordings and disappear in read-only views.webm and flac. That fixes the old
'wav'entry, which was missing its dot, and the non-standard'audio/mp3'MIME type.the upload option.
en,zhandko; 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:
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:
covers the final chunk, which arrives after the stop.
start(1000)) and counts the bytes.80 seconds will hit this, but recorded answers are no longer WAV.
Answer::VoiceResponserejects a newly assigned file overMAX_FILE_SIZE(
15.megabytes), with:exceed_size_limiton:attachment.MAX_VOICE_RESPONSE_FILE_SIZE(15,000,000 bytes) is slightly stricter than the server's15 MiB, the same arrangement as the programming answer limit.
SingleFileInputchanges, for the dropzone:maxSizeandonDropRejected.onDropnow ignores a drop in which every file was rejected. Before, such a drop cleared the current fileand fired
onChange. That already happened for wrong-type drops in every form using it, and for voiceanswers it triggered a save with no new file.
What students will notice
.wav, about 10 MB/min.m4a(or.webm/.ogg), about 1 MB/minCommits
.ts/.tsxextensions, with no contentchanges, so git history follows them. This commit doesn't type-check on its own.
recorderHelperand the submodule removal;AudioPreviewand the translations.recorderslice; the old actions file and action types are deleted.SingleFileInput's newprops and rejected-drop fix, the model validation and the Rails error messages.
Tests
tscexits 0, and eslint and prettier are clean. The whole Jest suite passes, since a sharedmodule moved. The new and rewritten test files have 41 tests:
recorderHelper, 18 tests. jsdom has neitherMediaRecordernorgetUserMedia, so these use fakes thatbehave like the browser: the final chunk and
stoparrive asynchronously, and a recorder stops itself whenits track ends. They cover:
acquire–release–acquire;
useMicrophoneAccess, 5 tests. It asks only a student attempting their own submission that has a voicequestion, and always releases on unmount, including for graders.
AudioPreview, 4 tests, and the reducer, 3 tests.file that isn't re-checked. They stub
MAX_FILE_SIZEto 1 KB, so no large files are created. They assertthe error's kind, since
I18nreturns keys in tests.Deployment notes
git submodule syncafter pulling. The old checkout's metadata,.git/modules/vendor/assets/javascripts/recorderjs, can be deleted.yarn installno longer runs apostinstall.harmless: it only leaves an unused folder.
Follow-ups (not in this PR)
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.
AnswerDetailsrenderer for voice answers, which currently showAnswerNotImplemented.🤖 Generated with Claude Code