4.3 KiB
Audio Library
Workstream 13 — a flexible classroom-timer sound library. A row is one of three
kinds: an uploaded file, an external url, or a synthesized recipe.
Purpose
director / office_manager / teacher add library entries; any campus staff
(director, office_manager, teacher, support_staff) can pick one to play in
the classroom timer. The existing built-in timer sounds stay hardcoded global
defaults for every organization — they are served from the (global)
content_catalog (classroomTimerSounds) and synthesized client-side, so they
are not duplicated here. New library entries are campus-scoped.
The "Generate" button in the timer creates a recipe row: a JSON set of
synthesis parameters played purely via the Web Audio API (no file, no network).
Until an AI key is wired, the recipe is produced by a local stub
(business/audio-files/generate.ts); only that function's body changes when the
key lands — the persistence, playback and library wiring are the same.
Entity
audio_files: title, kind (file | url | recipe), url (nullable —
set for file/url), recipe (nullable JSONB — set for recipe), is_default
(false for campus rows; reserved for future platform-global rows), nullable
organizationId + campusId. A null organizationId denotes a global row
visible to everyone. Exactly one of url / recipe is populated, matching
kind (validated in the service).
For a file row, the binary is uploaded first through the JWT-authenticated file
subsystem (POST /api/file/upload/..., with the Workstream 7 per-file ownership
check) and url references it. A url row holds an external link. A recipe
row never touches the file subsystem.
Routes (/api/audio_files)
GET /— list the caller's campus rows plus global defaults (organizationIdnull). RequiresREAD_AUDIO_FILES.POST /— add afile/url/reciperow (campus-scoped). RequiresMANAGE_AUDIO_FILES. Body{ data: { kind, title, url? , recipe? } }.PUT /:id,DELETE /:id— edit/remove an own-organization row (never a global default). RequiresMANAGE_AUDIO_FILES.
Authorization
READ_AUDIO_FILES— all four campus roles (director via full access).MANAGE_AUDIO_FILES—director,office_manager,teacher(notsupport_staff, who is read/play-only).
Non-global users can only manage rows in their own organization; global defaults
(organizationId null) are read-only to them. List/scope is enforced in the
service via the shared access helpers.
Frontend wiring
The classroom-timer sound picker (business/classroom-timer) merges the
hardcoded built-ins with the audio_files library and groups them by origin —
Built-in / Generated / Uploaded — for clear structure. Playback
branches by kind: builtin → playBuiltInSound(id), recipe →
playRecipe(recipe) (business/classroom-timer/audio-recipe.ts), file/url →
new Audio(url). Managers (canManageAudioFiles) see a Generate button and
a delete affordance on their own rows; global defaults are read-only.
Tests
- Unit (
npm test):audio-access.test.ts(visibility/management rules) andshared/constants/audio-files.test.ts(thefile/url/recipekinds +isAudioFileKind). - Frontend unit (
vitest):business/audio-files/selectors.test.ts(canManageAudioFiles) andgenerate.test.ts(the local recipe stub shape). - Seeded e2e (
frontend/tests/e2e/audio-files.seeded.e2e.ts,npm run test:e2e:content): create/persist + same-campus read,support_staffread-only, and external-role lockout.
Open / deferred
- Binary
fileupload UI — the typed upload client is still to build, and the download check must record afilerow (or exempt audio) first: todayassertCanDownloadFiledenies anyprivateUrlwith no trackedfilerow, and the standalone/file/upload/:table/:fieldpath does not create one.recipeand externalurlrows are unaffected (no/file/download). - AI generation — swap the local
generateSoundRecipestub for a real model call once an AI key is available; the rest of the pipeline is unchanged. - If platform-global audio rows are later added, relax the file-download ownership check for null-organization files so the defaults stream to all.