# Atlas Reader Module — Specification

**Status:** draft for build. Firmware side (Reader mode, `.reel` player, encoder) exists
and ships in Atlas v6.4.0. The module and the web converter are unbuilt.

Print and Play Creative Manufacturing, 162 Locke Street South, Hamilton, Ontario.

---

## 1. What this is

A self-powered module that plugs into the Atlas **side data port** (USB-C) and turns it
into a media source. Atlas shows the picture and drives its three LEDs; the module holds
the storage, does the decoding, and plays the sound.

The split exists because of two hard facts about the silicon:

- **The ESP32-S3 in Atlas is BLE-only.** No Bluetooth Classic, so no A2DP — it can never
  send audio to a Bluetooth speaker, whatever the firmware does.
- **The classic ESP32 has no USB peripheral at all.** It cannot host or be a USB device
  without a bridge chip.

Put those together and the division of labour writes itself: the module owns audio and
files, Atlas owns the screen. Nothing then needs Classic Bluetooth, USB Audio Class, or a
second radio.

---

## 2. Roles

| | Atlas (ESP32-S3) | Module (classic ESP32) |
|---|---|---|
| USB role | **host** | device (via the devkit's CH340/CP2102) |
| USB class | CDC-ACM host | CDC device |
| Storage | — | microSD (SPI) or USB-A stick (CH376S) |
| Decoding | none | `.reel` unpack, MP3/WAV |
| Audio | none (no DAC on S3) | I2S → MAX98357A → speaker |
| Display | 128×64 1-bit | — |
| LEDs | 3× WS2812B | — |
| Input | dial + button | — |
| Power | own cell | own cell |

**Accepted trade-off:** with USB in host mode Atlas loses USB-CDC, so reel uploads and
flashing do not work while the module is attached. Flashing is an upgrade-time activity;
this is fine. Reflashing with the module attached needs DFU (hold BOOT, tap RST).

---

## 3. Electrical

The side data port is data only — it does **not** reach the TP4056 charge circuit, so the
module cannot and does not power Atlas. Both ends run from their own cells.

```
Module USB-C plug            Atlas side data port
  D+  ────────────────────────  D+
  D−  ────────────────────────  D−
  GND ────────────────────────  GND
  VBUS  (not connected)
```

**Tie the grounds.** USB signalling is referenced to a common ground; without it nothing
enumerates. This is the single most common failure in this kind of build.

VBUS is left unconnected because the module is self-powered and Atlas's port does not
supply it. The CH340 asserts its D+ pull-up as soon as the module has power, which is what
Atlas enumerates on.

---

## 4. Link protocol

Runs over the CDC-ACM pipe. Every message is framed and checksummed — a check that cannot
actually detect failure is worse than no check, because it reports success. (This is not
theoretical: the reel uploader in v6.2.1 reported byte counts it had *decoded* rather than
*written*, so a full filesystem verified perfectly while writing nothing.)

```
  0xA5 0x5A       magic
  uint8           type
  uint16          payload length, little-endian
  bytes[length]   payload
  uint16          CRC-16/CCITT-FALSE over type + length + payload
```

### Module → Atlas

| Type | Name | Payload |
|---|---|---|
| 0x01 | `HELLO` | protocol version, capability bits |
| 0x02 | `CATALOG_ITEM` | index (u8), kind (u8), runtime secs (u16), title (32B) |
| 0x03 | `CATALOG_END` | count (u8) |
| 0x10 | `FRAME` | 9 LED bytes + 1024 framebuffer bytes |
| 0x11 | `STATUS` | state (u8), shot (u8), shot count (u8), position secs (u16) |

`kind`: 0 = reel, 1 = audio, 2 = text, 3 = image.

### Atlas → Module

| Type | Name | Payload |
|---|---|---|
| 0x20 | `INPUT` | dial delta (int8), button event (u8: 0 none, 1 short, 2 long) |
| 0x21 | `SELECT` | item index (u8) |
| 0x22 | `TRANSPORT` | 0 stop, 1 play, 2 pause, 3 prev shot, 4 next shot |

### Frames go over the wire UNCOMPRESSED

1024 bytes per frame, in the display's native layout, so Atlas can `memcpy` straight into
the framebuffer.

At 24 fps that is 24.6 KB/s. Full-speed USB CDC moves hundreds of KB/s, so this is a few
percent of the link — there is no reason to compress it, and compressing would actively
hurt: delta frames each depend on the one before, so a single corrupted byte would smear
until the next keyframe. Uncompressed, a bad frame is one bad frame and it self-heals.

The module still stores reels compressed and unpacks them itself — the compression exists
to fit flash, not to fit the wire.

### Timing: audio is the master clock

The module decodes audio continuously and releases each video frame off the **audio sample
counter**. Atlas blits frames as they arrive; it does not run its own timer.

Getting this backwards — Atlas pacing and the module chasing — is the classic way these
builds end up with drift that cannot be tuned out. A dropped frame is invisible. An audio
gap is glaringly audible.

---

## 5. The `.reel` format (v4)

Written by `ReelEnc`, read by Atlas and by Library. The web and desktop converters must
emit exactly this. All multi-byte values are little-endian.

### Header — 128 bytes

| Offset | Size | Field |
|---|---|---|
| 0 | 8 | magic `ATLASRL4` |
| 8 | 1 | version = 4 |
| 9 | 1 | width = 128 |
| 10 | 1 | height = 64 |
| 11 | 1 | fps |
| 12 | 4 | frame count |
| 16 | 4 | keyframe count |
| 20 | 4 | runtime, seconds |
| 24 | 4 | flags — bit0: drive the LEDs |
| 28 | 4 | video offset (= 128) |
| 32 | 4 | video byte count |
| 36 | 4 | keyframe index offset |
| 40 | 24 | reserved |
| 64 | 64 | title, ASCII, NUL-padded |

Total file size = `128 + videoBytes + keyCount * 8`. Atlas checks this on open; a mismatch
means a truncated transfer and is reported rather than played.

**Why v4 exists.** v3 stored the frame count in a `uint16` — 65,535 frames, which is 68
minutes at 16 fps and 45 at 24. That was ample for the 34-second trailer cut this format
was written for and wrong the moment Library has to serve a feature. The index had the
same ceiling in its frame numbers. Both are 32-bit now, and the header grew to 128 bytes
so it can be extended again without another version bump.

The remaining ceiling is `videoBytes` at 4 GB — which is exactly FAT32's own maximum file
size, so the filesystem gives out before the format does, at roughly 15 hours of film. The
frame counter itself is good for about 74,000 hours.

### Packets — one per frame, sequential from offset 128

| Offset | Size | Field |
|---|---|---|
| 0 | 1 | type — 0 keyframe, 1 delta |
| 1 | 2 | payload length |
| 3 | 9 | LED colours: LED0 RGB, LED1 RGB, LED2 RGB |
| 12 | n | PackBits payload |

A delta payload expands to a 1024-byte buffer that is **XORed** into the previous frame. A
keyframe payload replaces it. Both expand to exactly 1024 bytes; anything else is corrupt.

### PackBits

```
control <  128 :  (control + 1) literal bytes follow      -> 1..128 literals
control >= 128 :  next byte repeats (control - 126) times -> 2..129 repeats
```

### Framebuffer layout — SH1106 page format

```
byte index = x + (y / 8) * 128
bit        = y & 7            (bit set = pixel lit)
```

8 pages of 128 bytes. This is the OLED's native order, which is why playback is a `memcpy`
rather than per-pixel work.

### Keyframe index — at `keyframe index offset`

`keyCount` entries, 8 bytes each:

| Offset | Size | Field |
|---|---|---|
| 0 | 4 | frame number |
| 4 | 4 | file offset of that frame's packet |

**One entry per shot, and the encoder forces a keyframe at each shot start.** Delta frames
depend on their predecessor, so keyframes are the only points playback can be dropped
into. Indexing by "whichever frames happened to encode as keys" instead put scrub points
wherever motion spiked — in runs of adjacent frames inside a single shot — so a dial click
moved a few milliseconds and looked broken, and busy reels overran the index entirely.

---

## 6. Encoder pipeline (for the web tool)

Per shot:

1. Decode frames, crop, scale to 128×64.
2. **Measure levels once across the WHOLE shot** — 0.5% and 99.5% luma percentiles — and
   hold them fixed for every frame in it.
3. Optional gamma. `>1` crushes shadows (use it to drop a midtone background to black),
   `<1` lifts them.
4. Dither: 8×8 ordered Bayer for pictures, or hard threshold at 0.5 for text and ink art.
5. Pack to SH1106 layout.
6. Emit key vs delta, whichever payload is smaller — except the first frame of each shot,
   which is always a key and gets an index entry.
7. LED track: sample the source frame at 3×1 RGB, saturate, then restore the frame's own
   luminance.

### Two things that are easy to lose in a rewrite

**Levels must be per-shot, never per-frame.** Per-frame auto-levels look better on a still
and are much worse in motion: the dither crawls, and because every frame then differs
everywhere, delta compression collapses and the file roughly triples.

**Use ordered dithering, not error diffusion.** With an ordered matrix a pixel's threshold
depends only on its coordinates, so a static picture dithers identically frame after frame
and the XOR delta is empty. Floyd–Steinberg looks marginally better per still and destroys
the compression.

```
Bayer 8×8, compared against luma × 64:
   0 32  8 40  2 34 10 42        48 16 56 24 50 18 58 26
  12 44  4 36 14 46  6 38        60 28 52 20 62 30 54 22
   3 35 11 43  1 33  9 41        51 19 59 27 49 17 57 25
  15 47  7 39 13 45  5 37        63 31 55 23 61 29 53 21
```
(rows interleave as 0,4,1,5,2,6,3,7 — see `ReelEnc.cs` for the literal array.)

### LED colour

Saturate the sampled colour so three small LEDs read as "firelight" or "cold sea" rather
than three greys, then put the **original luminance** back:

```
lum   = 0.2126R + 0.7152G + 0.0722B
sat   = 1.7 about the midpoint, clamped
peak-normalise to unit brightness
scale = pow(clamp(lum * 1.35), 0.75)
```

Normalising every frame to full brightness instead makes black frames glow — the first cut
opened on a black frame driving the LEDs saturated green.

### What does and does not survive 1-bit

Bright field behind a dark subject — fog, sky, sea, snow — reads well. Night interiors and
faces in low light turn to grey mush. Filmed text almost never survives: a caption set
across the full frame width lands about 7 px tall however tightly it is cropped, because
the width is the constraint. Re-typeset such captions for 128×64 instead.

Letterboxed sources must be cropped to their true picture area. The Odyssey trailer is
2.39:1 inside a 16:9 file — real picture 1920×804 at y=138 — and cropping to 1920×960
leaves 78 px of bar that shows on screen as black fragments.

---

## 7. Bill of materials

| Part | Purpose | Notes |
|---|---|---|
| ESP32-WROOM-32 devkit | module MCU | onboard CH340/CP2102 **is** the Atlas link — no extra bridge |
| microSD breakout (SPI) | storage | simplest; native to the ESP32 |
| *or* CH376S | storage | USB-A sticks, FAT handled in-chip, SPI/UART |
| MAX98357A + 4Ω 3W speaker | audio | I2S. The ESP32's built-in DAC needs no extra part but is 8-bit |
| 1S LiPo + TP4056 | power | same parts as Atlas |
| MT3608 or similar 5V boost | power | for the devkit 5V pin, and the stick if CH376S |
| USB-C plug / pigtail | link | D+, D−, GND only |

---

## 8. Firmware work

**Atlas** — Reader gains a second source alongside "file on LittleFS":

- USB host bring-up in **CDC-ACM** class. Note the existing `ATLAS_USB_HOST_KBD`
  scaffolding is **HID-only**; CDC-ACM is a different driver (`usb_host_cdc_acm`).
- Atlas's USB Serial/JTAG enumerates as a **composite** device, so a host talking to Atlas
  must select the CDC interface specifically rather than assuming interface 0.
- Catalogue list view (the existing reel shelf, populated from `CATALOG_ITEM`).
- Blit `FRAME` on arrival; forward dial and button as `INPUT`.

**Module** — all new:

- FAT read, file browse, `.reel` parse and PackBits unpack.
- MP3 (helix) and WAV decode, I2S out.
- Frame pacing off the audio sample counter, framing + CRC on the link.

---

## 9. Open questions

1. **microSD or USB-A stick** for module storage. microSD is meaningfully less work;
   USB-A is friendlier for customers who want drag-and-drop from any computer.
2. Powered **hub** vs single port — a hub would let a keyboard coexist with the module,
   which is what unlocks barcode scanners and on-device pack authoring later.
3. Text and ebook formats. `.txt`, `.md`, `.fb2` are easy; `.epub` is a ZIP of XHTML so it
   needs miniz plus a spine parser; `.pdf` and DRM'd `.mobi`/`.azw` are not worth it.
