How 'Journal' works, end to end
When I open this app and tap the round button, all I see is a waveform and a transcript. But underneath, it's a long loop through six or seven different services. Writing it down here in case anyone is curious — and in case I forget the shape of it six months from now.
The whole loop, in one picture
1. Press record
MediaRecorder opens the mic at audio/webm;codecs=opus and slices into 250ms chunks. A wake-lock keeps iOS from sleeping mid-thought. useRecorder keeps those chunks in a ref array — never the full Blob in state — so a twelve-minute take doesn't blow up the tab.
2. Upload to R2
The browser doesn't push audio through Vercel's function body — there's a 4.5 MB limit, RIP. Instead, /api/upload mints a presigned PUT url valid for five minutes, and the client uploads straight to Cloudflare R2 with exponential-backoff retries (1s, 2s, give up). Bucket key looks like audio/.webm.
3. Transcribe — ElevenLabs Scribe v2
The server hands ElevenLabs a signed read url (30-minute expiry) instead of streaming bytes through Node. Scribe returns:
{ text, language_code, audio_duration_secs }I leave tag_audio_events: true on, because I want the [laughter], [sigh], [breath] cues to come back inline. The polish prompt knows what to do with them — turn them into prose verbs ("I laughed") or parenthetical stage-directions ((sighs)) instead of leaving raw bracket tags.
4. Polish — the gentle editor
This is the whole point of the app. The raw transcript goes to Gemini 2.5 Flash by default. The system prompt lives at src/lib/ai/prompt.ts and is the single most important surface in this codebase — touch it and everything shifts.
The job is one sentence: prune fillers, preserve specifics, never summarize. The model returns structured JSON:
{ markdown: string, mood: string }mood is one of eleven words — clear, tender, restless, grateful, tired, curious, reflective, calm, anxious, inspired, content — each mapped to a color in the corner of the entry card.
If polish fails for any reason, the entry still saves with the raw text as a fallback. Better stale prose than a lost recording.
5. Persist
POST /api/entries writes to Turso (libSQL). Hand-written SQL, no ORM:
- —
id,created_at,updated_at - —
raw(transcript) +polished(markdown) — both kept, so I can re-polish later when the prompt drifts - —
mood,polish_tone - —
audio_key(R2 pointer) +audio_peaks— sixty-four floats, normalized 0..1, the wavy fingerprint on every card - —
is_public,pinned_at,seo_title,seo_description
The waveform on every entry card is just sixty-four numbers between zero and one, decoded once on the client at record time. Free.
6. Read
There's only one entry route: /e/. When is_public = 1, anyone can open it — no auth required. When it's private, the same URL is gated by an HMAC-signed cookie (aloud_session); strangers get a 404. Audio is always served through /api/audio/, which mints a fresh signed url every request.
When I tweak the polish prompt and old entries start to feel off, the re-polish modal re-runs step 4 against the saved raw, then overwrites polished and mood. The original recording is never touched.
That's the whole thing. Press → opus → R2 → Scribe → Gemini → libSQL → render. The pipeline is maybe eight hundred lines of glue across useRecorder, four route handlers, and polish.ts. The hard part isn't the wiring. It's the prompt.