feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) #39

Merged
mathias merged 299 commits from feat/33-audio-upload-transcript into main 2026-10-03 14:13:37 +00:00
Showing only changes of commit 1e192e53ef - Show all commits
+25 -11
View File
@@ -1363,11 +1363,16 @@ an hour of Swedish *was* an unknown here; it is now measured, see decision 8.)
**Decision.**
1. **Audio only; transcript is the only thing kept.** An authenticated user uploads an audio
file; Tapir transcribes it and stores the transcript. The audio itself is deleted after
transcription (temp file, removed in a `defer`, swept at start-up) — no custody problem to
solve, and nothing to hand to #31. Video is rejected with a message naming #28. YouTube
audio-download stays rejected. No public/anonymous upload (ADR-008).
1. **Audio is what is used; the transcript is the only thing kept.** An authenticated user
uploads a recording; Tapir transcribes its audio and stores the transcript. The audio itself
is deleted after transcription (temp file, removed in a `defer`, swept at start-up) — no
custody problem to solve, and nothing to hand to #31. **A file that also carries a video
track is accepted: its audio is extracted, the video is discarded, and the user is told so**
(see 4). *Amended 2026-10-01, the same day this ADR was drafted: the first draft rejected
video and named #28. The maintainer's first real recording — a Teams meeting with the camera
off — is still an `.mp4` with a video track, so rejecting video rejected the primary use
case.* This keeps no video and analyses none: playback, keyframes and what-is-shown analysis
remain #28. YouTube audio-download stays rejected. No public/anonymous upload (ADR-008).
2. **Private store: a separate RLS-scoped table, `uploaded_transcripts`** (resolves #28's
decision 1). `user_id NOT NULL REFERENCES users ON DELETE CASCADE`, `ENABLE` + `FORCE ROW LEVEL
@@ -1387,14 +1392,20 @@ an hour of Swedish *was* an unknown here; it is now measured, see decision 8.)
asserted exactly** — so a new table added without RLS fails CI instead of passing it. (c) is
new; the existing test only checks the named tables.
4. **Validate by probing, reject clearly.** Format and duration cannot be read from a filename
or MIME type — a Teams "voice-only" recording is typically an `.mp4` container with no video
stream, which is indistinguishable from video by extension. The upload is probed
(`ffprobe`): accept any container with an audio stream and **no** video stream; reject video
with "video is #28"; reject unreadable files. Limits, all configurable, all rejected
4. **Validate by probing, reject clearly, and say what was ignored.** Format, duration and
what a file contains cannot be read from a filename or MIME type — a Teams recording is an
`.mp4` whether or not the camera was on. The upload is probed (`ffprobe`): **accept any
container with an audio stream, whether or not it also has a video stream** (an mp3's cover
art counts as neither). Whether a real video track was present is recorded on the row
(`had_video`) and the transcript page says so plainly — *this file contained a video track;
only its audio was used and the video was discarded* — so the user is never left to wonder
what happened to the picture. Reject a file with **no** audio stream (including a screen
recording with no microphone) and an unreadable one. Limits, all configurable, all rejected
synchronously with a specific message rather than by timeout: **200 MB** upload and
**90 minutes** duration. 90 minutes is *the longest length measured*, not a ceiling that was
found; raising it is a measurement, not an edit.
found; raising it is a measurement, not an edit. The size cap now matters more: a video file
is much larger than its audio and is spooled whole before the audio is extracted, so a long
recording with screen sharing can exceed 200 MB while its audio is a few tens of MB.
5. **Transcode server-side, always, to 16 kHz mono PCM WAV** — even when the input already is,
rather than assuming. whisper-server's `--convert` is rejected (its `--tmp-dir` is created at
@@ -1483,6 +1494,9 @@ deferred. The scope cut — chat first, no rich analysis, no video — is what k
whisper-server permanently. Chatting with a transcript can therefore load the model that
wedges the transcriber. Not solved here; the berget fallback limits the blast radius.
- Tapir's own ingress body limit for a 200 MB upload is **unverified** (it lives in `infra`).
- Accepting video means large files are spooled before their audio is extracted, and ffmpeg
demuxes (but, with `-vn`, does not decode) video it then throws away. A client-side audio
extraction would avoid the upload cost; it is not built.
**Validation / resolution triggers** (each observable by someone other than the author):
- *Chunking*: build it when the duration cap is raised above 90 min, or when a measured