Pikapiku, deployed at pikapiku.rominur.com, transcribes up to five hours of audio on a platform whose functions are capped at ninety seconds and whose request bodies are capped at roughly four and a half megabytes. Those two numbers make the obvious architecture impossible: a five-hour file cannot be uploaded in one request, and no single function invocation can transcribe it. The conventional answer is object storage plus a job queue plus a worker, which means the user's media comes to rest on someone else's disk. Pikapiku takes the other branch and moves the orchestration into the browser. ffmpeg.wasm cuts the file into roughly four-and-a-half-minute chunks inside the tab; each chunk is re-encoded to mono 16 kHz MP3, walked down a bitrate ladder until it fits the body limit, sent to a short-lived function that forwards it to a speech model, and then discarded. This paper describes four decisions that make that structure work. First, chunks overlap by two seconds and the overlap is removed on the way back rather than on the way out, so a word spoken across a cut is transcribed with context on both sides but appears exactly once. Second, non-retention is enforced by the database schema rather than promised in a privacy policy: the Transcript model has columns for text, SRT and timed cues and no column for media, so there is no code path that could store a recording, and the decoded chunk buffer is zeroed in a finally block. Third, quota is reserved before the upstream call and keyed on a client-supplied job identifier, so a rejected request costs nothing and a sixty-eight-chunk file consumes one job slot rather than sixty-eight. Fourth, transcription and copy-editing are separated into two models with different prices and different prompts, and the editing pass degrades block by block — a batch the editor cannot process is split in half, retried, and finally passed through unedited, so the transcript is never lost to a failure in the pass that was only supposed to fix its typos.
Transcription is one of the few AI features whose privacy stakes are obvious to everyone who uses it. The input is a recording of people talking — an interview, a meeting, a consultation — and the ordinary way to process it is to upload the file to a server, put it in a bucket, and hand a worker the object key. Every step of that pipeline is a place the recording persists, and every one of those places is something the user is asked to take on trust.
Pikapiku is built on the observation that the platform constraints and the privacy goal push in the same direction. A serverless function that must finish in ninety seconds and accept a body of no more than about four and a half megabytes cannot receive a five-hour recording at all. Rather than routing around that limit with storage and a queue, the application accepts it as a design brief: if the server can only ever see a few megabytes at a time, then let the browser own the file, cut it locally, and send the server nothing but short compressed slices that it forwards and forgets.
The result is an application in which the strongest privacy claim is also the cheapest one to make. There is no bucket to secure, no lifecycle rule to configure, no deletion job to verify. The recording exists in the tab's memory and in the transient body of a few dozen HTTPS requests, and nowhere else.
The constants below are the concrete edges of the problem. They live in src/lib/limits.ts and src/lib/config.ts, are overridable by environment variable, and are exposed to the client so that the interface can state them rather than discover them by failing.
| Limit | Default | Why this number |
|---|---|---|
| Function duration | 90 s per chunk | Platform ceiling; declared as maxDuration on the route handler. One chunk must fit comfortably inside it. |
| Chunk body | 2,516,582 bytes | Kept under the ~4.5 MB request-body ceiling after base64 expands the payload by four-thirds. |
| Chunk length | 270 s core + 2 s overlap | Long enough that a five-hour file is ~68 requests rather than hundreds; short enough to compress under the body limit. |
| Server chunk ceiling | 360 s | Independent server-side bound, so a hand-crafted request cannot claim an arbitrarily long chunk. |
| Total duration | 5 hours | Read from file metadata in the browser and rejected before any encoding work begins. |
| Upload size | 500 MB | Bounds what the tab must hold in memory. Stereo 44.1 kHz WAV exceeds it well before the duration cap does. |
| Daily audio | 5 hours per user, UTC day | Caps the per-minute upstream bill per account. |
| Jobs per hour | 4 | Caps concurrency and burst, independently of total minutes. |
The pipeline that results runs entirely left to right, and the only long-lived process is the browser tab:
Cutting audio at a fixed interval will eventually cut through the middle of a word, and a speech model given a fragment that begins mid-syllable will produce a plausible wrong word rather than an error. The standard mitigation is to overlap the chunks so that every boundary is covered twice. The cost of overlap is duplication: the same speech now appears in two transcripts.
planChunks() resolves this by deciding at plan time which copy will be discarded. Each chunk carries a core duration and a skipFirstSec field, which is zero for the first chunk and otherwise the overlap length — clamped to half the chunk so that a very short final chunk cannot be skipped out of existence. Successive chunks start at start + duration − overlap, and the loop terminates rather than looping forever when advancing would make no progress.
The discard itself happens on the return path, in shiftCues(), once the model has already had the benefit of the extra context. Any returned cue that ends inside the skipped region is dropped entirely; a cue that straddles the boundary has its start clamped forward to the skip point; every surviving cue is then translated by the chunk's offset into absolute time. A word spoken across a cut is therefore transcribed twice, with full context both times, and kept once.
Two defensive details are worth noting. Cues whose end is not strictly after their start are dropped rather than emitted as zero-length subtitles, and if the model returns no segments at all the client synthesises a single cue spanning the chunk from the plain text response — so a model that answers with text but no timestamps degrades to a paragraph rather than to nothing.
Each chunk is re-encoded before it is sent: video discarded, downmixed to mono, resampled to 16 kHz, and encoded as MP3. That transformation alone typically reduces a chunk by an order of magnitude, and 16 kHz mono is the sample rate speech models are trained for, so nothing useful is lost.
Because content varies, one bitrate cannot be correct for every chunk. cutChunkMp3() therefore tries 64 kbps, then 48, then 32, returning the first result that lands within the byte ceiling and is larger than a trivial floor. If even 32 kbps overshoots, it raises a specific error rather than sending an oversized body and collecting a 413 from the platform. A second fallback handles wasm builds compiled without libmp3lame, retrying with the container-inferred encoder.
The server does not take the client's word for any of this. The route recomputes the approximate decoded size from the base64 length before allocating, checks the real length after decoding, validates the format against an allow-list, requires the job identifier to match a character-class pattern, and bounds the claimed chunk duration by its own ceiling. Client-side validation is there to give a good error message; server-side validation is there because the client is not trusted.
The privacy claim in the interface — that media files are never stored on the server — is not implemented as a deletion step. It is implemented as an absence.
model Transcript {
id String @id @default(cuid())
userId String
source String
title String
language String
durationSec Int
speakerCount Int @default(0)
text String
srt String
cues Json
createdAt DateTime @default(now())
}
There is no audioUrl, no blobKey, no bytes column. A future change that tried to persist a recording would have to add a column first, which makes the intent visible in a migration rather than buried in a handler. The schema comments say the same thing in prose — only text, SRT and cue JSON; source media is never stored — but the guarantee is the shape of the table, not the comment.
Three shorter-lived surfaces are cleaned explicitly. The decoded chunk buffer is zeroed with buffer.fill(0) in the route's finally block, so it is overwritten on the error path as well as the success path. The ffmpeg virtual filesystem entry for the input file is deleted and the worker terminated when a job ends, whether it succeeded, failed, or was cancelled. And the interface's clear action aborts any in-flight request, empties the transcript state, and terminates ffmpeg — while telling the user plainly that server-side history is not affected, because silently leaving saved text behind after a button labelled “delete” would be the more damaging kind of surprise.
Rate limiting that runs after the expensive call is accounting, not limiting. In src/app/api/transcribe/route.ts the order is: authenticate, validate, decode, reserve quota, and only then call the speech model. A request that exceeds either limit returns 429 with the current quota state attached and never reaches the provider.
| Dimension | Key | Window | Protects against |
|---|---|---|---|
| Audio seconds | pp:sec:<email>:<UTC day> | Calendar day, 48 h TTL | Sustained per-minute spend on the speech model. |
| Jobs started | pp:jobs:<email>:<UTC hour> | Calendar hour, 2 h TTL | Burst and concurrency, independently of total minutes. |
| Job identity | pp:jobseen:<email>:<jobId> | 3 h TTL | Counting one long file as one job rather than as its chunk count. |
The third row carries the interesting subtlety. The client mints one jobId per file and sends it with every chunk. The first chunk to arrive under an unseen identifier consumes a slot from the hourly budget and marks the identifier as seen; the remaining sixty-seven chunks of a five-hour file find the marker and consume only seconds. Without it, the hourly job limit would be reached inside the first minute of any long file, and the limit would be measuring requests when what it means to measure is work started.
Two storage backends implement the same interface. When Upstash Redis credentials are present the counters are shared, and the increments are issued as one pipeline so a partially applied reservation cannot leave the day counter raised without the job counter. When they are absent an in-process Map with explicit expiry stands in, which is correct for local development and deliberately understated in the documentation as approximate on a platform that runs many instances — a limiter whose weakness is documented is safer than one whose weakness is assumed away.
Raw speech-to-text output and a readable transcript are different artefacts, and the application uses different models to produce them.
The first is a speech model, requested with response_format: "verbose_json", segment-level timestamp granularity and temperature zero. Two are configured: a default transcription model, and a separate model selected when speaker separation is enabled, because diarization support is a property of the model rather than a flag any model will honour. Speaker identifiers arrive under several spellings across providers, so the parser accepts speaker, speaker_id and speakerId, and accepts a numeric string as well as a number. Segments that fail validation are skipped individually rather than failing the chunk, and if the response carries word-level data but no segments, the word array is parsed with the same routine.
The second is an inexpensive text model used only as a copy editor, under a prompt written as a list of prohibitions rather than aspirations: fix typos, spelling, punctuation, capitalisation and obvious mishearings; do not change meaning, do not summarise, do not add sentences, do not translate; preserve colloquial register, terminology, abbreviations, names and numbers; and — the operationally important one — do not merge or move text between blocks, returning each block separately under the same identifier it was given. The response is constrained to a JSON object and parsed defensively: a non-JSON response, a missing blocks array, a non-integer identifier or an empty string all cause that block to keep its original text rather than to fail the request.
The editing pass is optional, runs after transcription is complete, and is engineered on the premise that it is the least important stage and must therefore be the one that fails most gracefully.
Blocks are grouped into batches bounded on two axes at once — at most eighteen blocks and at most six thousand characters — so that neither many short blocks nor few long ones can produce an oversized request. Each batch is sent with absolute block indices, so a response can be applied positionally without depending on ordering. When a batch fails, it is split in half and each half retried; blocks that fail again keep their original speech-to-text text and are counted, and the count is surfaced to the user as a note saying how many blocks were used unedited. The transcript is never lost to an error in the pass that was only supposed to fix its typos.
Progress is reported per batch and the partially edited document is rendered as it arrives, so the transcript visibly improves rather than freezing behind a spinner. Abort is propagated distinctly: an AbortError is rethrown rather than counted as a failure, because a user who cancelled does not need to be told that their blocks could not be edited.
Subtitle cues are the wrong reading unit for a transcript — they are short, they break mid-sentence, and a two-hour interview becomes thousands of them. cuesToBlocks() merges adjacent cues into paragraph-sized blocks and breaks on any of three conditions.
| Condition | Threshold | Reason |
|---|---|---|
| Speaker changes | any change | A paragraph belongs to one speaker. |
| Silence between cues | > 1.5 s | A pause is the natural paragraph break in speech. |
| Block duration | > 30 s | An uninterrupted monologue still needs paragraphs to stay readable. |
The rendered form places a bracketed time range on its own line, the speaker label beneath it, and the paragraph beneath that — the same layout the Word export produces, so what is read on screen and what is downloaded are the same document. Four outputs derive from the same cue array without re-running any model: plain text, a Word file with a title and a generated subtitle recording duration, speaker count, source and whether the AI editor ran, an SRT file with hour-padded comma-separated timestamps, and the cue array itself stored as JSON for the history view.
Recording takes a deliberately different route through the same endpoint. MediaRecorder already emits compressed audio, so the ffmpeg stage is unnecessary; five-second slices are sent directly as WebM/Opus, or as MP4/M4A where Opus is unavailable, with the container negotiated by feature detection rather than assumed. Timestamps accumulate against a running offset so that live cues land on the same absolute timeline as file-based ones, and the same completion path — block assembly, optional editing, history save — runs afterwards. Live transcription is therefore not a separate feature with its own output format; it is the same pipeline with a different first stage.
Access control is a Next.js proxy rather than a check inside each handler. Every path except /login and the authentication API is redirected to sign-in when no session exists, so /api/transcribe is unreachable anonymously; the matcher excludes static assets and images so that the login page renders correctly. The transcribe route repeats the session check anyway and derives the quota key from the session email rather than from anything the client sends, because a limiter keyed on a client-supplied identity is not a limiter.
Upstream failures are translated before they are shown. A 401 or 403 becomes a message telling the user the service is unavailable and to contact the site operator; a 402 becomes a message about exhausted service quota; 429 asks them to wait; 408 and 504 suggest a shorter file. The real status code and a truncated response body go to the server log. The user is told what to do, and is not told whether an API key was rejected, which provider was called, or what it returned — a distinction that matters because the transcription API key is a bearer credential and error text is the most commonly overlooked place it leaks.
Encoding is CPU-bound in the tab. ffmpeg.wasm is single-threaded, so on a long file the cutting stage, not the network or the model, usually dominates wall-clock time, and the tab must stay open for the duration. The core is fetched from a public CDN on first use, which keeps media binaries off the server but adds an external dependency to the critical path.
The two-second overlap is a heuristic. It covers ordinary word-length spans at a boundary; it does not guarantee that a long word or a run-together phrase is never split, and the de-duplication rule can drop a legitimate cue that happens to end inside the skipped window.
Speaker identifiers are per chunk. The model has no memory across requests, so Speaker 1 in the third chunk is not necessarily the same person as Speaker 1 in the first, and the labels should be read as turn markers rather than as stable identities across a long recording. Reconciling them would require cross-chunk voice embedding, which the current design does not attempt.
The upload cap binds earlier than the duration cap for uncompressed input: stereo 44.1 kHz WAV runs to roughly 1.9 GB over three hours and is rejected long before five hours, so such material must be compressed or exported as mono first — a limitation the interface states in the rejection message rather than leaving to be discovered. And the editing pass, being a language model, can still normalise a dialect form or an unusual name into something more common despite being instructed not to; it is therefore a checkbox, the unedited text remains the fallback, and the transcript can be re-edited or left alone.
Pikapiku's architecture follows from taking two platform limits literally instead of engineering around them. A ninety-second function and a four-and-a-half-megabyte body cannot process a five-hour recording, so the recording never goes to the server as a recording: it is cut in the browser, sent as short compressed slices, and forgotten. What began as a constraint produces the privacy property directly — there is no bucket, no lifecycle policy and no deletion job, because there is nothing stored to delete.
Three of its choices generalise past this application. Overlap on the way out and de-duplicate on the way back, so the model gets context and the user gets one copy. Encode guarantees into the schema rather than into a policy document, so violating them requires a migration someone has to review. And place the quota check before the expensive call rather than after it, keyed on something the client cannot forge, so that a limit that is exceeded costs nothing to enforce.