Spec 11 Audio Listening Questions - SekiroKenjii/Quizzivy GitHub Wiki

Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next

11. Audio / listening questions

11.1 Admin: upload and attach

  • In the question editor, an audio question has: file upload, transcript textarea (admin-authored, optional), and the AudioPolicy controls (maxPlays default 2, allowSeek default false, showTranscriptAfterSubmit default true, allowPause default true).
  • allowPause is "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 because updateQuestion and updateQuestionGroup replace 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) and audio/mp4 / audio/aac (.m4a). Reject everything else with a plain message. These two cover every current browser without transcoding; supporting .ogg/.wav/.webm means 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-Type header 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 with MEDIA_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/media reports those bytes as usage.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 are usage.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_assets row; it never overwrites an existing key. This is what lets test_version_questions reference an asset without copying the file.
  • A library file can be replaced only by the same kind (415 MEDIA_KIND_MISMATCH otherwise). 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's previous names 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 with 400 VALIDATION_FAILED naming mediaAlt, 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 as mediaAlt beside media in 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 as alt or, when there is none, a generic label.

11.2 Storage and delivery

  • Bucket quizzivy-media, key audio/{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.url as expiring and refetches on 403.
  • 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 of replaceMedia and deleteMedia (previous and deleteAt, with a URL to download the file) and by usage (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: LockForVersionUse and Readable answer 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=off stops 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 counts heldBytes in the bytes used and lists the held and the pending files, with pendingDeleteAt as 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 from Retry-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.

11.3 Player component

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 allowSeek is 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 when allowSeek is 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 NotAllowedError says 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 not await anything before it.
  • allowPause: false (§11.1): while the recording plays the button is inert, marked aria-disabled (never the native disabled, 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: false implementation: no <input type="range">, and an onSeeking handler that resets currentTime to the last known position. Note that OS-level media controls can still seek in some browsers — record an audio_seek event rather than pretending it cannot happen.
  • Accessibility: real <button> elements, aria-label on each, keyboard-operable, aria-live announcement of plays remaining. When showTranscriptAfterSubmit is 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_play event; resuming a paused play sends none.

11.4 maxPlays — server-authoritative

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, the audio_play event 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 increments attempt_audio_plays (attempt_id, question_id, plays) and the count is returned in GET /app/attempts/:id as audioPlays. The audio_play event 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 sessionId it was started under, and a play from a session another tab has replaced answers 409 SESSION_SUPERSEDED, one on a closed attempt 409 ATTEMPT_CLOSED, and one past the deadline 409 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. sessionId is 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.

11.5 Word milestone shared recordings (approved, not yet enabled)

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.

⚠️ **GitHub.com Fallback** ⚠️