Spec 11 Audio Listening Questions - SekiroKenjii/Quizzivy GitHub Wiki
Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next
- In the question editor, an audio question has: file upload, transcript textarea (admin-authored, optional), and the
AudioPolicycontrols (maxPlaysdefault 2,allowSeekdefault false,showTranscriptAfterSubmitdefault true,allowPausedefault true). -
allowPauseis "Students can pause" (T-D4.13, DG-183). It is a rule a student is meant to read, not a key, and it applies to a question's audio and to a group's recording alike. A create that leaves it out stores true. An update that leaves it out keeps the stored value, for a question by its id, for a member question of a group by its id in the bundle and for a recording by its id; an audio policy that is new in the request (the question had none, or an image) and a recording under a new id are creates and read true. The rule exists becauseupdateQuestionandupdateQuestionGroupreplace the whole object, and a tab loaded before this release, or a recovery draft saved on the device, sends none. Publish freezes the value in both snapshots; restoring a version as a draft, duplicating a test and copying a group carry it; the teacher's preview, the student's paper and the teacher's review read it. A question or version from before the control reads true. - Accepted:
audio/mpeg(.mp3) andaudio/mp4/audio/aac(.m4a). Reject everything else with a plain message. These two cover every current browser without transcoding; supporting.ogg/.wav/.webmmeans either transcoding or a Safari support matrix, and neither is worth it in v1. - Limits: audio 50 MB and 5 minutes; an image (PNG, JPEG or WebP) 10 MB (DG-63). Validate server-side by sniffing magic bytes and probing duration — never trust the
Content-Typeheader or the file extension. The file is streamed to disk, never held in memory, and an image's size in pixels is read from its header. - A teacher's library holds at most 5 GB unless the deployment sets another quota (
MEDIA_OWNER_QUOTA_MIB). An upload that would take it past the quota is refused withMEDIA_QUOTA_EXCEEDED. A file that is deleted or replaced leaves the library at once and counts toward the quota only while something still names it: a published version, another owner's content, or a question that attaches it outside any group.GET /teacher/mediareports those bytes asusage.heldBytes, so a replacement needs room for the new file and, while a published version holds the old one, for the old one too. The files nothing names areusage.pendingBytes, and count toward nothing (§11.2). - In the library a file can be renamed, and audio can be given a default play limit: unlimited, or 1 to 3. The stored file and its original filename never change. The limit is stored with the file for the editor to start a question's own limit from; it changes no question by itself.
- Upload goes through the Go backend in v1 (low volume, and the backend must validate anyway). Presigned direct-to-R2 upload is the P1 optimisation; design the API so switching does not change the client contract beyond the upload call.
- Assets are immutable. Re-uploading creates a new
media_assetsrow; it never overwrites an existing key. This is what letstest_version_questionsreference an asset without copying the file. - A library file can be replaced only by the same kind (
415 MEDIA_KIND_MISMATCHotherwise). Replacement creates a new immutable asset owned by the old file's owner, with its current display name and default play limit. In one database transaction, the retained current graph of live editable questions and bank or draft-owned group media of that owner points to the new asset; each changed group gains one revision. Published versions, import review drafts and other owners' bindings retain the old readable asset. The response counts distinct changed questions/groups and other owners' live editable questions/groups left on the old asset; historical versions and imports are excluded. Late same-owner bindings may retain the old asset and are excluded from the left counts. The old file leaves the library, and its owner's quota once nothing names it, but remains available to existing bindings and later publication/import materialization. The answer'spreviousnames the published versions that still use it, a URL to download it and the time its object goes. An internal error can follow a commit, or its outcome can be unknown; both retain the new object. A retry against the replaced old library ID can answer 404. Replacement does not promise idempotent retries or an atomic transaction with object storage. - Client-side pre-check before upload: read duration via an
<audio>element and reject early, so a student's teacher does not wait 50 MB to be told no. -
A question's media is one audio file or one image, attached from the library ("From Media") or uploaded in the question editor, which shows the limits above before it uploads. The audio policy and the transcript exist only for audio. An image carries optional alt text of 1 to 1000 characters,
mediaAlt, that the editor asks for as a description for screen readers. It belongs to the question and not to the file, so two questions that share one image describe it in their own words, and a file replacement (which never changes the kind) keeps it. A write replaces the stored value, so an update that leaves it out clears it. The server refuses a value for a question whose media is not an image with400 VALIDATION_FAILEDnamingmediaAlt, and clears it when the media is removed or changed to audio. Publish copies it into the version with the rest of the question, so a later edit of the bank question never reaches a version; restoring a version as a draft and duplicating a group carry it, and the version diff counts a changed alt text as a change to the question's media. Students receive it asmediaAltbesidemediain the paper, the session and the result. It is a description of the picture, not part of the answer key, and the engine, the result and the builder's preview draw the image with it asaltor, when there is none, a generic label.
- Bucket
quizzivy-media, keyaudio/{asset_id}.{ext}. Bucket is private; no public listing, no public read. - Served via short-lived signed URLs (10 min), minted per request by the backend. The frontend treats
MediaAsset.urlas expiring and refetches on403. -
Cache-Control: private, max-age=600— long enough to survive a replay, short enough that the signed URL does not outlive its cache entry. -
A file that left the library is kept, then removed (F-68). The object of a deleted or replaced file stays in storage until seven days after nothing names it: no question, group, material or published version, whoever owns it. The sweep then deletes the object and marks the row
purged_at. The row stays, the file cannot be restored, and no notification follows: the teacher is told when they act, by the answers ofreplaceMediaanddeleteMedia(previousanddeleteAt, with a URL to download the file) and byusage(heldBytes,pendingBytes,pendingDeleteAt). The API runs the sweep at start-up and every hour on every machine (jobs.SweepRetiredMedia). It dates the files nothing names yet, cancels the wait of a file something names again, and removes the due ones one at a time under the row's lock. A row that a second sweep or a writer holds is skipped, so no object is deleted twice, and a publish that freezes a file again before the sweep takes it keeps it. A delete that fails leaves the row for the next run, ten minutes later. A purged file can be named by nothing:LockForVersionUseandReadableanswer not found, and the database refuses the row. An import review that still names a file the sweep removed fails at its commit as one that is gone.MEDIA_RETENTION_SWEEP=offstops the sweep on the machines that read it; the quota and the dating by replace and delete go on. -
The library says it before the action and after it (F-68, DG-206). The Replace dialog says what becomes of the old file: kept for the N published tests that use it, each test counted once however many versions hold the file, and deleted seven days after none does; or, when none does, deleted for good seven days after the replacement. The Delete dialog says the file is deleted for good seven days later and cannot be restored; a file a published version uses is still refused. Both offer the file's download from the row's URL. After the action the toast names the date the file goes (
previous.deleteAt,deleteAt), or that it is kept for the tests that hold it. The Storage card countsheldBytesin the bytes used and lists the held and the pending files, withpendingDeleteAtas the earliest day. A replacement refused for room (409) while published tests hold the file says the library needs room for both files, and a 429 says how long to wait fromRetry-After, which the upload transport reads. Nothing is announced by the bell and nothing restores a file. - Honest note:
controlsList="nodownload"and hiding the URL are not protection. A determined student can save the file. Signed short-lived URLs raise the cost slightly; do not invest further. If a listening file must never leak, do not put it online.
web/src/features/media/components/AudioPlayer.tsx. Custom controls over a native <audio>; no third-party audio library.
- Controls: play/pause, elapsed/total time, a progress bar that is display-only when
allowSeekis false, and a plays-remaining indicator. The total shown is the server's probed length when it is known; the file's own length drives the track and the seek. - The engine draws it as the deck's recording card (
layout="card"), for a question's own audio and for a shared recording alike: a 52px round button named Play, Pause, Resume (a paused play in progress) or Play again (after an end), a 6px track in the brand accent with the seek control over it whenallowSeekis on, the time and a counter under it ("Play 1 of 2", which reads 1 before the first play; "Play n" with no limit; "Played n/N times" at and past the limit), and a status line: "Press play when you are ready.", "Playing", "Paused", "Finished · 1 play left" (or "Finished" with no limit) and, at the limit, "You have used all N plays." with "You can keep listening. Additional plays are recorded for your teacher to review." The button stays live at the limit (§11.4). A refused play's sentence takes the status line's place. -
preload="metadata"so duration renders without downloading the file. -
Autoplay is impossible — browsers block audio without a user gesture. The first play is always a tap. Do not attempt to auto-start; do not treat the block as an error state.
A play refused with
NotAllowedErrorsays so under the player, which stays in place.AbortError, including a pause before playback started, says nothing. Other playback rejections or a file-load failure show the expired-link card. - iOS Safari: only one audio element plays at a time, and playback must originate from a gesture handler (not an async continuation). Call
.play()synchronously in the click handler; do notawaitanything before it. -
allowPause: false(§11.1): while the recording plays the button is inert, markedaria-disabled(never the nativedisabled, so it keeps its focus and its name), at 45% opacity, and a press does nothing; the status reads "Playing · this recording can’t be paused", and before the first play "Press play when you are ready. It can’t be paused once it starts." It cannot stop playback stopping: a headset or OS media button, another recording starting, leaving the question or the group, or a lock still pauses it. The card then reads "Paused", and Resume continues the play in progress without spending a play. Nothing new is recorded: the product reports what it can see and does not pretend the pause could not happen. A payload without the field, from an API older than the tab, pauses freely. -
allowSeek: falseimplementation: no<input type="range">, and anonSeekinghandler that resetscurrentTimeto the last known position. Note that OS-level media controls can still seek in some browsers — record anaudio_seekevent rather than pretending it cannot happen. - Accessibility: real
<button>elements,aria-labelon each, keyboard-operable,aria-liveannouncement of plays remaining. WhenshowTranscriptAfterSubmitis on, the transcript appears on the result page — this is also the accessibility fallback for hard-of-hearing students. - One player instance per question, and one per shared recording, which stays mounted while the student moves between its group's questions. Leaving the question or the group, by any means, pauses and releases it. A play in progress and its position are remembered in memory, per question or recording (
media/playback.ts), so coming back to the question or the group resumes there without spending a play. Leaving the test page, by any means, forgets every place, as a reload does: coming back to the same attempt, or starting another, spends a play. Starting one player pauses any other that is playing. - Each play that starts the recording sends an
audio_playevent; resuming a paused play sends none.
The obvious client-side counter resets on reload, which makes the limit meaningless. So:
- A play is spent when playback starts and no play is in progress: from 0:00, or after the recording ended. A resume after a pause spends none. With seeking allowed, moving inside a play in progress spends nothing. A remembered place (§11.3) is put back once the element has its metadata, and the no-seek guard is told the new position first, so a restore is never reported as a blocked seek. If playback then runs more than 0.5s short of the remembered point (the browser refused the seek, or the metadata never came), the start is counted as a play after all and the place forgotten: a place that cannot be put back is never free.
- A play is counted once
play()resolves, not when the button is pressed: the call itself still runs in the click's own tick (§11.3), and the count, theaudio_playevent and the request wait for it. A play the browser refuses (NotAllowedError) or that is paused before it starts (AbortError) spends nothing, here or on the server. - Each play calls
POST /app/attempts/:id/audio-play; the server incrementsattempt_audio_plays (attempt_id, question_id, plays)and the count is returned inGET /app/attempts/:idasaudioPlays. Theaudio_playevent the player records beside it (§10.6) only reports the play and changes no count. - A play is written like an answer: it is counted only while the attempt is in progress, before its deadline, and from its current session. The request names the
sessionIdit was started under, and a play from a session another tab has replaced answers409 SESSION_SUPERSEDED, one on a closed attempt409 ATTEMPT_CLOSED, and one past the deadline409 DEADLINE_PASSED; none is counted, and the client neither counts nor retries on them: it takes back the optimistic decrement and seals the paper the way a refused save does.sessionIdis optional for one release, and a play without it is checked for status and deadline only. Review playback after submit stays unlimited and uncounted. - Client renders remaining plays from the server value, optimistically decrements on play, and reconciles on the next fetch.
- Playback is optimistic: a failed event POST does not block the audio. A student who goes offline to farm replays will show a gap in the event log, which is exactly what the integrity timeline is for. Blocking playback on a network round-trip would punish bad wifi far more often than it would catch anyone.
- On submit, the server rejects nothing based on play count. Over-limit plays are reported to the teacher, not enforced retroactively.
D-03 extends the existing audio contract to a recording shared by a group. All child questions consume the same configured play allowance. Navigating between children, reloading or resuming the same attempt on another device never grants a fresh allowance. Two groups may reference the same immutable file and still have independent counters. The counter identity therefore includes the versioned group recording binding; the asset ID alone is insufficient.
Freeze that binding and its audio policy in the published version, and keep its counter scoped to the attempt. Preserve synchronous gesture playback, optimistic accounting, retry deduplication, teacher reporting and transcript visibility rules from §11.3–11.4. The future shared player must use this scope consistently in navigation, event writes and state reconciliation. Its API, relational references, concurrency tests and student-payload tests must precede enabling shared audio. Existing per-question audio remains a separately supported historical contract.
W-09c adds a separate shared-play endpoint with recordingId, current sessionId
and a stable per-gesture playId. It follows the existing autosave writable-session
checks and verifies the recording's material binding belongs to the attempt's
version before writing. Retrying the same gesture/recording returns its current
counter; reusing the gesture ID for another recording is a conflict. The counter,
append-only receipt and server audio_play event commit together. Counts survive
reload/takeover and are returned as groupAudioPlays, keyed by recording ID.
Teacher monitor and timeline excess-play totals include this ledger. New attempts
start with their own allowance. Network acknowledgements and maxPlays never gate
the actual browser play call. The browser persists unconfirmed gesture IDs per
learner/attempt until the attempt deadline, clears them on logout and serializes
bounded retries. Confirmed extensions update the recovery deadline. A pending
sync label is distinct from answer save state. A response lost after commit does
not consume a second allowance on retry; late responses cannot mutate another
session. Counts remain monotonic during refetch. Submission attempts a bounded
three-second telemetry flush but remains available during network accounting
failure. Closed/expired attempts reject late telemetry; offline closure cannot
guarantee complete listening evidence. Results and teacher paper review report
the confirmed counts without creating new playback receipts.
The assignment introduction derives listening presence, transcript permission and the strictest finite allowance from both question audio and shared recordings on the assigned version. A newer default does not change these facts. The displayed minimum is not presented as every recording's allowance; individual players show their own limits. A shared-audio notice explains that member navigation and reload do not grant a new allowance.
Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next
Generated from docs/quizzivy-spec-v0.3.md. Edit that file in the repository; changes made on this page are overwritten.