Part 6 of 8
LeXi: the card editor with custom voice
Recording the family's voice with MediaRecorder, long-press editing, offline audio cache and word-by-word playback.
The real value of an AAC communicator is the family’s voice: the “water” card should sound like mom, not like a robot. In milestone 6 we achieved it with MediaRecorder, 100% client-side.
The recorder
src/lib/recorder.ts wraps MediaRecorder with format auto-detection (WebM/Opus or MP4/AAC depending on the browser), microphone permission handling and a duration limit that the server then enforces.
RecorderButton.svelte is the record/stop button with a timer, test playback and discard. It emits the blob to the parent via an onrecorded callback; the editor is the one that uploads to R2 on save.
The editor and long-press
At first the editor had an “Add card” button, but the user rejected it: it looked wrong in demo mode. We replaced it with the natural AAC interaction:
- Long press (~500 ms) on a card → a floating context menu with “🎤 Add/Edit custom audio”.
- It also works with right-click on desktop.
- On save, the card shows a circular 🎤 badge and an accent tint.
- In demo mode the menu does not open.
CardEditor.svelte saves the audio to IndexedDB, uploads the blob to R2 and updates the card’s audio_key.
Lesson:
canEditcannot be computed only once on mount. Astro Svelte islands are independent; when the user redeems a code inClaimDialog, the grid did not find out. We solved it with a globaldeviceModestore in nanostores and a reactive$effect.
Limits and demo mode
Two consecutive bugs:
- On mobile, a 2-second recording said “exceeds the max duration” →
MAX_RECORDING_MSarrived as a string and broke the comparison. Fix:Number()with fallback and we raised the limit to 30s. - The client trusted
localStorageto know whether it could record. New endpointGET /api/device/statusthat returns the real mode (demo/full) from the server, andCardTilereceiveseditable.
Lesson: offline-first applies to edit mode too. If the device is locally in
fullmode but offline,refreshCanEditmust still allow recording (a therapist without coverage cannot stay blocked); the server only enforces when there is a connection.
Offline upload: the pending queue
If there is no network when recording, the blob is saved to IndexedDB (uploads) and the card ends up with audio_key: pending:.... When the network returns, flushPendingUploads() uploads to R2 and updates the card. And the audio is cached on save (cacheAudioBlob) so it plays offline from minute one.
Word-by-word playback
On Speak, each word in the sentence plays its recorded audio or falls back to TTS:
playCardAudioEnd()inaudio.ts— plays and resolves when finished.speakEnd()intts.ts— TTS that resolves at the end.SentenceBar.speakSentence()iterates the chunks with metadata (text, customVoice, audioKey).
What we learned the hard way:
pause()does not fire theendedevent. The Stop button did not stop the sentence because thefor...awaitloop kept queueing. Fix: aplaybackStopflag (nanostore) that the loop checks on every iteration, andstopActiveAudio()that resolves the pending promise ofplayCardAudioEnd.
Speed and physical keyboard
SpeedSelector.svelte: 1x / 1.5x (default) / 2x, applied live (setLiveRate).- Joining consecutive TTS: contiguous TTS chunks are grouped into a single utterance (removes the micro-pauses that sounded robotic).
- Physical keyboard on desktop: type directly into the sentence (Space separates, Backspace deletes, Enter speaks).
Lesson: duplicate keys in
{#each}break rendering. The word list usedtext + customVoiceas the key and tapping the same card twice made Svelte stop rendering the rest. Index keys are stable because the sentence only grows at the end.
Milestone wrap-up
Consolidated commits: feat: per-card custom voice + long-press editor + sentence sync + audio cache, feat: TTS speed selector, physical keyboard, join consecutive TTS, stop fix, fix: reactive device mode + docs (Hito 6). README and README.en.md updated to milestone 6 state.
In the next part, offline-first sync and usage stats.