Firmware
Five components and one store. Specified here, written once the brain is chosen
— but the parts that are portable across every candidate brain are already written, as
JavaScript, in tape/lib/, and tested.
Components
CardWatcher poll the PN532 at 8 Hz → present / absent, debounced
· a card is only "gone" after 3 consecutive missed reads
· the RF field is pulsed 10 ms in every 125 — a continuous
field costs 50–80 mA and ends the battery version
· two or more targets in the field → refuse, blink, wait
(a deck of cards is a stack by nature)
Library manifest.json → binary index, cached until mtime changes
card id → playlist → track files → resume position
Player queue · decode · volume · save position every 15 s and on removal
ESP-ADF decodes MP3 · AAC · WAV · OGG · Opus · FLAC · AMR,
so whatever the parent's phone recorded simply plays
OutputStage begin() · write(pcm) · setVolume() ← one interface
I²S → MAX98357A → 4Ω driver ← one implementation
Net SoftAP always (192.168.4.1, captive portal) + station mode
once it knows your WiFi. Up when idle, down when playing.
Ingest HTTP: a file input, a name field, a progress bar, plus /api/*
— NOT the studio; the box cannot serve a microphone
The rule
Ingest is awake only when nothing is playing. WiFi comes up when the box is idle and goes down when a card lands. That removes the ESP32’s WiFi/Bluetooth coexistence problem, removes SD contention between the decoder and the uploader, and removes a whole class of audio glitch — at no cost anyone notices, because nobody uploads a story while their child is listening to one.
State machine
BOOT ──▶ IDLE ──card arrives──▶ PLAYING ──track ends, none left──▶ FINISHED
▲ │ │ ▲ │
│ │ │ └────── back / next ─────────────┘
│ └──enrol requested──▶ ENROLLING ──blank tag written──▶ IDLE
│
├──────────── card leaves (save position) ────────────────────┘
│
└──30 min quiet──▶ DEEP SLEEP ──button──▶ IDLE
IDLE WiFi up, ingest serving, field pulsing ~13 mA
PLAYING WiFi down, ingest asleep, audio out live ~129 mA
ENROLLING armed for 60 s; the next blank tag on the pad is written and bound
FINISHED card still on the pad, nothing left to play; LED off, no auto-repeat
CROWDED two or more tags in the field; blink, play nothing, wait
DEEP SLEEP ~0.02 mA — bounded by the cell's self-discharge, not the circuit
Draws come from the power budget. The rule that WiFi and audio
are never both awake is what stops PLAYING and IDLE ever adding up
— and it is why one 18650 gives about twenty hours of stories.
What is already written and tested
The model is shared between the firmware and the studio so the two cannot drift, and it lives in this repository as dependency-free ES modules that run in a browser and in node:
lib/tag.js— the card byte layout, Crockford base32 ids, the NDEF records, and the size assertion that keeps NTAG213 the targetlib/catalog.js— the manifest, SD paths, validation, orphan sweeplib/protocol.js— the HTTP API, the SSE events, the network modes, andoriginCapabilities(): the two browser rules that force the pipeline into two halves, written as a function so they are tested rather than rememberedlib/power.js— the power budget, per component and per statelib/pinmap.js— the GPIO assignment, with the reserved pins enforced. The schematic is drawn from it, so the picture and the firmware cannot disagree about a pin numberlib/tape.selftest.mjs— 37 known-answer checks, run by the repo’s preflight and again by the deploy workflow before anything ships
Porting the model to the box is then a transcription with a test suite to check it against, rather than a second design.
The API the box serves
GET /api/hello version handshake
GET /api/manifest the catalogue
GET /api/events SSE: card | playback | enrolled | ingest | error
POST /api/title upload a recording
DELETE /api/title/:titleId
PUT /api/card/:cardId bind a card to a playlist
DELETE /api/card/:cardId
POST /api/enroll write the next blank tag on the pad
POST /api/wifi join a home network (also enables station mode)
POST /api/say speak a line through the box
Plus the receiving page at / on the box: a file input, a name field and a progress
bar. It is deliberately not the studio —
the box cannot serve a microphone, so it does the half it is allowed
to do while the recording happens on the HTTPS side or in the phone’s
own voice-memo app.