40227-vm/backend/docs/audio-files.md
2026-06-12 06:55:35 +02:00

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 (organizationId null). Requires READ_AUDIO_FILES.
  • POST / — add a file / url / recipe row (campus-scoped). Requires MANAGE_AUDIO_FILES. Body { data: { kind, title, url? , recipe? } }.
  • PUT /:id, DELETE /:id — edit/remove an own-organization row (never a global default). Requires MANAGE_AUDIO_FILES.

Authorization

  • READ_AUDIO_FILES — all four campus roles (director via full access).
  • MANAGE_AUDIO_FILESdirector, office_manager, teacher (not support_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: builtinplayBuiltInSound(id), recipeplayRecipe(recipe) (business/classroom-timer/audio-recipe.ts), file/urlnew 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) and shared/constants/audio-files.test.ts (the file/url/recipe kinds + isAudioFileKind).
  • Frontend unit (vitest): business/audio-files/selectors.test.ts (canManageAudioFiles) and generate.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_staff read-only, and external-role lockout.

Open / deferred

  • Binary file upload UI — the typed upload client is still to build, and the download check must record a file row (or exempt audio) first: today assertCanDownloadFile denies any privateUrl with no tracked file row, and the standalone /file/upload/:table/:field path does not create one. recipe and external url rows are unaffected (no /file/download).
  • AI generation — swap the local generateSoundRecipe stub 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.