86 lines
4.3 KiB
Markdown
86 lines
4.3 KiB
Markdown
# 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_FILES` — `director`, `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: `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) 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.
|