tape.card audio player

Design record

Product architecture, site architecture, and the four assumptions that do not survive contact with the datasheets. Written before any parts are bought, which is the only time writing it is worth anything.

Contents

  1. The brief, and what “done” means
  2. The landscape: what exists and what it costs
  3. Four corrections — the card holds no audio · RFID is the wrong radio · Arduino cannot, and Bluetooth probably should not · no origin can do the whole job · WiFi, not Bluetooth · the whole flow
  4. Product architecture
  5. Component selection → (chosen: the parts, the losers, the power budget)
  6. The interaction model
  7. Site architecture
  8. Open questions for component selection
  9. Decision log
  10. Sources

1. The brief, and what “done” means

A four-year-old has discovered books on tape. The cassette workflow it replaces was good in a way the modern products are not: the physical object was the content, you could hand it to a child, and a grandparent could make one.

Done, for version one: a child can take a card off a shelf, put it on a box, and hear a person they know reading a book; take the card off and it stops; put it back and it carries on. A parent can record a new book on a phone in under five minutes without reading any documentation, and turn it into a card without buying anything but the card.

Everything else — battery, buttons, enclosure, multiple boxes, remote grandparents — is version two, and the architecture below is arranged so that none of them require a rewrite.

2. The landscape

What you can buy

ProductPlayerPer storyHow the card works
Toniebox 2$129.99$19.99NFC figurine; audio downloads from the vendor cloud on first play, then cached
Yoto Player (3rd gen)$109.99$9.99–$14.99NFC card carrying a link; audio streams or caches from the vendor
Yoto Mini$79.99$9.99–$14.99as above

Note what both of them do with the card: the card is a pointer, not a container. Neither commercial product puts audio on the tag either. Over two years a Toniebox with a modest collection runs $610–$850, a Yoto $350–$470 — almost all of it content.

What has already been built by other people

ProjectBrainShapeWhat we take from it
TonUINOArduino NanoMFRC522 reader + DFPlayer Mini MP3 module + buttonsProof that the cheap, instant-on, no-OS version works. Boots in under a second, sips power. Its limit is that a DFPlayer is a sealed black box: no WiFi ingest, so new content means pulling the SD card.
Phoniebox (RPi Jukebox RFID)Raspberry PiUSB RFID reader, mpd, web UI, Spotify/streamsProof that the web-managed version is what parents actually want. Its limit is boot time — well over a minute from cold — and a filesystem a toddler can corrupt by pulling the plug.

The gap between them is the design space we are in: TonUINO’s instant-on with Phoniebox’s ingest story. That is the whole product thesis, and it is what pushes us toward an ESP32-class part rather than either an AVR or a full Linux box.

3. Four corrections

The obvious version of this project contains four assumptions. Each is wrong, and each one being wrong makes the design simpler, not harder.

3.1  The card holds no audio — and cannot, by three orders of magnitude

The intuition that audio is small and tags are big is exactly inverted. Here is the arithmetic laid side by side:

TagUser memorySpeech it could hold at 24 kbps
MIFARE Ultralight48 B0.016 s
NTAG213 (the 20¢ sticker)144 B0.048 s
NTAG215 (the Amiibo chip)504 B0.168 s
NTAG216 (biggest cheap tag)888 B0.30 s
MIFARE DESFire EV3 8K (expensive)8,192 B2.7 s
One minute of a parent reading183,600 B—
A 20-minute picture book3,672,000 B—

A picture book is 4,100× the biggest cheap tag. There is no clever codec that closes a gap that size; at the bitrate where 20 minutes fits in 888 bytes you have 6 bits per second, which is roughly enough to transmit the title in Morse.

So the card carries a 64-bit identifier, and the box owns the audio. Four things fall out of that, all of them good:

What actually goes on the tag is two NDEF records:

record 1  URL         https://tape.mino.mobi/c/<id>      ~35 bytes
          — so a card found under the sofa, tapped on any phone, with no app
            installed and the box switched off, says what it is.

record 2  external    minomobi.com:tape                  13–45 bytes
          0   'T' 'P'        magic
          2   version        1
          3   flags          shuffle | resume | locked
          4   card id        8 random bytes
          12  label length
          13  label          UTF-8, up to 32 bytes — a fallback name so an
                             unknown card can still announce itself

          total on tag, worst case: 103 of NTAG213's 144 bytes.

That last line is asserted by a test, not by hope — tape/lib/tape.selftest.mjs fails the build if a maximal card ever stops fitting an NTAG213, because the day it stops fitting is the day the project gets more expensive.

The id is rendered as 13 characters of Crockford base32: no I, L, O or U, and the decoder reads a hand-written O as 0. That is for the case where a parent writes the id on the back of the card with a biro.

3.2  “RFID” is the wrong radio — this is an NFC project

The two things sold as “RFID reader modules” are not interchangeable here:

125 kHz (EM4100 / T5577)13.56 MHz NFC (NTAG21x)
Tag cost~$0.10~$0.20
Reader cost~$3 (RDM6300)~$2–$6
A phone can read itnoyes
A phone can write itnoyes (Android)
Carries structured dataa fixed serial number onlyNDEF records
Tap-to-open-a-URLnoyes

Saving ten cents a card on 125 kHz costs the entire phone-side pipeline and the card-tells-you-what-it-is affordance. 13.56 MHz, ISO 14443A, NTAG213 or better. Not negotiable; it is the hinge the rest of the product hangs on.

One consequence to design for rather than discover: read range is short and a four-year-old aims badly. These tags work over roughly 1–4 cm and care about orientation. The enclosure needs a recessed nest that puts the card in the right place by geometry, not by the child being careful. That is an enclosure requirement derived from the radio, and it is the kind of thing that is free now and expensive after the case is printed.

3.3  Arduino cannot do Bluetooth audio, and Bluetooth is probably the wrong default anyway

The factual half. No classic Arduino board has a Bluetooth radio at all. Of the parts that do:

The judgement half. Bluetooth was the starting assumption for this build, and the concern is specific rather than aesthetic:

An I²S DAC, a 3 W class-D amp and a small full-range driver inside the box come to roughly $8, have no latency, no pairing, and one power switch, and make the box a single object a child can carry.

Recommendation, not a veto. Default the reference build to wired audio inside the box, and keep Bluetooth as a swappable output stage — one small interface (begin / write(pcm) / setVolume) with two implementations, selected at build time. Bluetooth then remains genuinely useful for the cases where it is the right answer: playing a story through the good speaker in the living room, and headphones in the car. If the wired default turns out to be wrong, the cost of changing our mind is one file. This is flagged for decision at component selection, not decided here.

3.4  Neither origin can do the whole job — so a file crosses between them

Correction. An earlier version of this section concluded “the box serves the studio, so recording and uploading share an origin.” That does not work, and the reason is a second rule pulling the opposite way from the first. Half the conclusion survives; the half about recording does not.

Rule one, which was right. A page served over HTTPS cannot fetch http://192.168.4.1. It is blocked as mixed content and no header on either side changes it. The box has no public name and no way to renew a certificate, so it serves plain HTTP. Uploading can only happen from the box’s own origin.

Rule two, which was missed. getUserMedia() — the microphone — is gated on a secure context. On a plain-HTTP page navigator.mediaDevices is not restricted, it is undefined; touching it throws a TypeError. A LAN IP over HTTP is not a secure context and never will be. Recording can only happen on https://tape.mino.mobi. Web NFC is gated the same way, so tag-writing from a phone lands on the same side of the line.

So no single origin has both. That is the whole shape of the pipeline.

 recordwrite a tagupload to the boxaccept a file
https://tape.mino.mobiyesAndroidnoyes
http://192.168.4.1 (the box)nonoyesyes

Look at the last column. A file input works on any origin — it needs no secure context, no permission and no network. So the two halves are joined by something inert: an audio file, which the phone is extremely good at holding on to.

This is asserted rather than remembered: originCapabilities() in lib/protocol.js is exhaustively tested over every combination, and the test fails if any of them ever claims both. The impossible combination — secure and on the box’s origin — throws, because the box serves plain HTTP; passing boxHasCertificate is the only way to get it, and that models the upgrade below.

The nice consequence: most parents never open our recorder

If a file has to cross anyway, then the phone’s own voice-memo app is a first class way to make one, and for most people it is the better one. It is already installed. It handles pausing, backgrounding and an incoming phone call in the middle of chapter three — all things we would otherwise implement badly. And the recording lands in the phone’s own storage, which means it inherits the phone’s backups for free. Given that the recordings are the only irreplaceable thing in this project, that is not a small point.

The box accepts what phones make: ESP-ADF decodes MP3, AAC, WAV, OGG, Opus, FLAC and AMR, which covers iOS Voice Memos (.m4a), Android Recorder (.m4a/.ogg), and the ripped audiobook MP3s a parent probably already owns. Our studio remains worth using for what it adds — splitting a book into tracks as you read it, so “go back a bit” lands on a chapter — but it is an option, not a gate.

The upgrade that would collapse this to one page

There is a known trick, and Plex ships it: publish a wildcard DNS record whose names resolve to private addresses (192-168-4-1.box.tape.mino.mobi → 192.168.4.1) and get a real certificate for it. The box’s own page is then HTTPS: secure context, no mixed content, record and upload together. It is genuinely the right long-term answer.

It is not in version one for three concrete reasons: the certificate’s private key would ship inside every firmware image and therefore be public; it expires every ninety days, which turns a toy into something that must be maintained; and on the box’s own access point there is no internet to resolve the name with, so the box would also have to run a DNS server answering for it. Each is solvable. None is solvable in the first build. It is recorded in TLS_UPGRADE so the next person does not have to rediscover it.

3.5  WiFi, not Bluetooth — and the box brings its own network

Bluetooth was one of the two candidates for getting the file across. It loses on two independent counts, either of which would be enough:

BLEBluetooth Classic (SPP)WiFi
Real throughput~15 kB/s~100 kB/s~1–3 MB/s
A 20-minute book4 minutes36 s2–4 s
A browser can speak itChrome/Android onlyno browser, everit is just HTTP
On iOSnever — Apple has said sonative app onlyyes

BLE is the only Bluetooth a browser can reach, it is four minutes a book, and Safari has never shipped Web Bluetooth and has stated it will not. SPP is fast enough but no browser speaks it at all, so it means a native app on two platforms. Neither is worth it when the alternative is an ordinary HTTP POST.

The specific WiFi shape is where the design earns something. The box runs SoftAP and station mode at the same time:

A literal IP rather than tape.local is deliberate for the AP path: Android does not resolve .local reliably, and this is not the moment to depend on mDNS. The name is offered as a convenience on the LAN path, where a failure is recoverable.

3.6  The whole flow, end to end

Once, ever: power the box, join tape-a4f2 on your phone, and the setup page opens itself. Optionally hand it your home WiFi.

  1. Record. Either the phone’s voice-memo app — nothing to install, and it is backed up — or the studio on https://tape.mino.mobi, which splits the book into tracks as you read.
  2. Open the box. Its address on your home WiFi, or join its access point.
  3. Choose the file, name the book, upload. The box writes /tape/audio/<title>/ and adds it to the manifest.
  4. Put a card on the pad. The box writes the sticker with a fresh id and binds it to the book. This is why the box needs a reader that can write.
  5. Put the card in the book. That is the filing system.

No app. No account. No cloud. No Bluetooth pairing. The only thing that ever needed the internet was loading our studio, and even that is optional.

The relay stays in the design as an opt-in extra, and it is the one path that touches a server: the studio encrypts a recording in the browser and parks the ciphertext in an inbox the box pulls over HTTPS. It exists for exactly one case — a grandparent in another city recording a story — and the server only ever holds a blob it cannot read.

4. Product architecture

INPUTS card on the padNFC reader, 8 Hz buttons + volumedebounced studio uploadHTTP, when idle THE BOX CardWatcherpresence, debounced Ingest serverstudio + /api/*, idle only Library — manifest.json → binary index card id → playlist → track files → resume position Player queue · Opus decode · volume · position save every 15 s OutputStage — one interface, two builds begin() · write(pcm) · setVolume() I²S → DAC → amp (default) A2DP source (optional) STORE SD card /tape/manifest.json /tape/audio/<title>/000.opus /tape/studio/ (served) /tape/state/ (resume)
Five components, one store. The dashed rule that makes it all work: the ingest server is only awake when nothing is playing.

The rule that keeps it simple

Ingest runs only when nothing is playing. WiFi comes up when the box is idle and goes down when a card lands. That single rule removes the WiFi/Bluetooth coexistence problem, removes SD contention between the decoder and the uploader, removes a class of audio glitches, and costs nothing a parent would notice — nobody uploads a story while their child is listening to one.

The data model, in three files

The architecture is written as code, not only as prose, so that it is testable and so the firmware and the studio cannot drift apart. All three are dependency-free ES modules that run in a browser and in node:

FileOwns
tape/lib/tag.jsWhat is on a card. Byte layout, Crockford base32 ids, the NDEF records, and the size check that keeps NTAG213 the target.
tape/lib/catalog.jsThe manifest: titles, cards, playlists, SD paths, validation, orphan sweep, and the size arithmetic quoted on this page.
tape/lib/protocol.jsThe box HTTP API, the SSE events, the network modes, and originCapabilities() — the two browser rules expressed as a function, so “no origin can do both” is tested rather than remembered.
tape/lib/pinmap.jsWhich ESP32-S3 pin does what, and the ones you may not have — GPIO33–37 carry the octal PSRAM and GPIO22–25 do not exist. The schematic is drawn from this, so the picture and the firmware cannot disagree.
tape/parts.jsonThe bill of materials, priced two ways, with a verified-link verdict per source. check-links.mjs refreshes them.
tape/lib/power.jsThe power budget: per-component draw in each state, runtime on a given cell, days between charges. Rendered live on the hardware page.
tape/lib/tape.selftest.mjs37 known-answer checks, run by scripts/preflight.mjs and by the deploy workflow before it ships.

The manifest is the box’s entire state, and it is one file:

/tape/manifest.json
{
  "schema": 1,
  "titles": {
    "wild-things": { "title": "Where the Wild Things Are", "reader": "Dad",
                     "tracks": [ { "file": "/tape/audio/wild-things/000.opus",
                                   "name": "The whole thing", "seconds": 380 } ] }
  },
  "cards": {
    "4S9NY0QP0TZEC": { "label": "bedtime", "mode": "sequence", "resume": true,
                       "playlist": [ "wild-things", "owl-babies" ] }
  }
}

Track file paths are derived from the title id and never author-supplied — the validator rejects a manifest where they disagree, which closes the obvious path traversal and the obvious way to end up with a card pointing at a file that is not there.

The box API

GET    /api/hello              version handshake; the studio refuses to write
                               to firmware it does not understand
GET    /api/manifest           the whole catalogue
GET    /api/events             SSE: card | playback | enrolled | ingest | error
POST   /api/title              upload a new recording
DELETE /api/title/:titleId
PUT    /api/card/:cardId       bind a card to a playlist
DELETE /api/card/:cardId
POST   /api/enroll             arm the box to write the next blank tag
POST   /api/say                speak a line through the box (for setup + testing)

5. The interaction model

Presence, not events

The reader is polled continuously, and the card being there is the play state. Card arrives → play. Card leaves → pause and remember. There is no play button and a four-year-old needs no instruction, because “take it off to stop it” is a thing hands already know.

This is a firmware requirement rather than a free lunch: RF reads are flaky, so “the card left” must survive three consecutive missed reads before it is believed, or a story will stutter every time the child leans on the table.

Cold start is a product feature

Power to “a card works” should be under two seconds. This is the single strongest argument against a Raspberry Pi and the reason TonUINO’s architecture is worth taking seriously: a child does not wait 25 seconds, they conclude it is broken and go and do something else.

Controls

The brief called these a bonus. Proposed set, and one of them is a safety feature:

Recording, on the phone

  1. Record it — the phone’s own voice-memo app, or open the studio for track splitting.
  2. Type the title, hit record, read the book. Pause between chapters if you like — each resume starts a new track, which is how “go back a bit” gets useful boundaries.
  3. The browser encodes Opus at 24 kbps mono, which is a good voice at 180 kB a minute.
  4. Upload. The box writes it under /tape/audio/<title-id>/ and adds it to the manifest.
  5. Put a blank card on the pad. The box writes it and binds it. Done — or, on Android, write the card from the phone directly and skip the trip to the box.

6. Site architecture

This surface is not a brochure for the project; it is part of the project. Three of its pages are the actual software.

PathWhat it isRuns where
/The pitch, the diagram, the arithmetichere
/design/This document — the decision recordhere
/hardware/Bill of materials with checked links, schematic, power chain, live power budgethere, rendered from parts.json and lib/pinmap.js
/firmware/Firmware architecture, the state machine, releaseshere
/studio/The recorder. Record a book in tracks, encode, hand back a filehere, and only here — the microphone needs HTTPS (§3.4). The box serves a separate, much simpler receiving page
/tags/, /c/<id>The card writer. The byte layout, a Web NFC writer, and the page every card’s URL points athere (Web NFC needs HTTPS)
/sim/The box simulator. The whole interaction, in a tabhere
/build/Assembly — eight stages, each ending at a test you can failhere
/first-story/The instructions. Fifteen minutes from a box on the table to a child hearing you readhere

Why the studio is not on the box

It cannot be. The microphone needs a secure context and the box cannot serve one (§3.4). So the surface splits along the capability line rather than by taste: this site records and writes tags; the box receives files and writes cards. The box’s page is a file input, a text field and a progress bar — small enough to live in the firmware image and never drift.

Why there is a worker at all

Everything here is static except one route. /c/<cardId> — the URL every card carries as its first NDEF record — is rewritten to the card page, so that tapping a stray card on any phone, with no app installed and the box switched off, tells you what it is. That affordance is worth 25 lines of worker.

Why the simulator exists

The sandbox this repo is developed in cannot touch hardware, and hardware has not been bought. The simulator is where the interaction gets designed and got wrong: presence-based playback, the debounce, resume behaviour, what a card with six books does when you take it off during book four. Those are the decisions that make the toy feel right, and none of them need a soldering iron to discover. It also stays useful afterwards, as the place a firmware behaviour change can be argued about before it is written.

On disk

tape/
  CLAUDE.md            instructions for this surface
  wrangler.jsonc       worker `tape`, custom_domain tape.mino.mobi
  worker.js            static assets + the /c/<id> rewrite
  index.html           the pitch
  styles.css           one stylesheet, no build step
  design/              this document
  hardware/  firmware/  build/       documentation pages
  studio/  tags/  sim/               the working software
  lib/
    tag.js  catalog.js  protocol.js  the model, shared with the firmware
    tape.selftest.mjs                19 checks, run by preflight and by deploy

7. Component selection — answered

All six are resolved. The parts, the candidates they beat, and the live power budget are on the hardware page; the short version:

#QuestionAnswerThe deciding fact
Q1Audio out: wired or Bluetooth?Wired — MAX98357A I²S amp + 4Ω driverSettled by you: the wireless speaker is not important. That one sentence is what unlocked Q2.
Q2The brainESP32-S3-WROOM-1 N16R8With A2DP gone, nothing holds us to 2016 silicon. ESP-ADF on the S3 decodes every format a phone produces, which is what makes the voice-memo path work.
Q3NFC readerPN532The box has to write tags, not just read them — on its own access point there is no internet to load a phone-side writer with. MFRC522 would mean writing the NDEF layer ourselves.
Q4Battery or cable?Battery — protected 18650, MCP73871 power path~20 h of stories, ~8 days between charges. The budget also disqualified the IP5306: its boost cuts out below ~45 mA and idle is 13 mA, so the box would switch itself off waiting for a card.
Q5CodecWhatever arrives; MP3 64k mono from the studioESP-ADF decodes MP3/AAC/WAV/OGG/Opus/FLAC/AMR. Deciding a codec turned out to be unnecessary, which is the best kind of answer.
Q6Enclosurestill openBut no longer unconstrained — five requirements have accumulated from decisions elsewhere. Listed.

8. Decision log

#DecisionStatusBecause
D1The card holds a 64-bit id, never audiosettled4,100× too small; and pointers give playlists and re-pointing for free (§3.1)
D213.56 MHz NFC, NTAG213 as the floorsettled125 kHz cannot be read or written by a phone at all (§3.2)
D3Two NDEF records: a URL for humans, an external record for the boxsettleda lost card should be able to say what it is; 103 of 144 bytes worst case
D4Many books per card is in the data model from day onesettledit is the length of an array; omitting it would be extra work
D5The box serves the studio → the site records, the box receives, a file crossesrevisedCorrected. The mixed-content half was right; the microphone half was wrong. getUserMedia needs a secure context, so recording cannot happen on the box (§3.4)
D6The box is the tag writer of record; Web NFC is a shortcutsettled, and strongerWeb NFC is Android-Chrome-only and needs HTTPS — but on the box’s access point there is no internet to load an HTTPS page with. The box is the only writer always present (§3.4)
D7Presence is the play state; no play buttonsettledhands already know it; needs a 3-read debounce in firmware (§5)
D8Ingest is awake only when nothing is playingsettledremoves radio coexistence and SD contention at zero user cost (§4)
D9Volume is a potentiometer with a physical end stopsettleda hard maximum a child cannot exceed — hearing safety (§5)
D10Audio output is wired; the swappable stage stays but has one implementationsettledQ1 answered: the wireless speaker is not important, which freed the brain choice entirely (§3.3)
D13WiFi, never Bluetooth, for moving filessettledBLE is ~4 minutes a book and Safari will never ship Web Bluetooth; SPP is fast but no browser speaks it (§3.5)
D14The box runs SoftAP and station mode togethersettledAP means it works with no router at all — a grandparent’s house, a car; STA means no network switching at home (§3.5)
D15The phone’s own voice-memo app is a first-class inputsettledNothing to install, handles interruptions properly, and the recording inherits the phone’s backups — which matters most for the one irreplaceable asset (§3.4)
D16The card goes in the booksettledThe pairing between book and recording is maintained by the object, in the place you next look. Cards are consumable at ~14¢; the recordings are the asset
D17The reader refuses to act on more than one tag in the fieldsettledA deck of cards is a stack by nature; two tags is an anticollision event, not a read
D18The NFC field is duty-cycled, 10 ms in every 125settledA continuous field costs 50–80 mA and would end the portable version on its own (budget)
D11Relay is opt-in, end-to-end encrypted, off by defaultsettleda child’s family recordings should not sit on anyone’s server in the clear
D12Cold start under two seconds is a requirement, not a preferencesettledit is the difference between a toy and a broken toy — constrains Q2

9. Sources


Next: component selection — the parts are chosen and the power budget is live. What is left before anything is bought: the enclosure (Q6), the wiring diagram, and a first board on a bench.