Seek and Resume
"Jump to minute 20" sounds trivial and is not, because compressed audio has no index from time to bytes. This chapter covers how Safi built one, and the persistence machinery that turns it into resume-after-power-loss.
Why seeking is hard, per format
WAV is the easy case: every sample occupies a fixed number of bytes, so byte = data_start + time x byte_rate, exactly. Safi's WAV seek is sample-accurate arithmetic.
MP3 has no table of contents and, worse, potentially variable bitrate (VBR): each frame declares its own bitrate, so equal time slices occupy unequal bytes. Three strategies exist:
| Strategy | Accuracy | Cost |
|---|---|---|
Assume constant bitrate: byte = time x avg_rate | Awful on VBR (minutes off) | Free |
| Walk every frame header from the start | Exact | Reading the whole file per seek |
| Read the encoder's TOC header once | ~1 percent of duration | One extra read at open |
Safi implements the third with the second's spirit as fallback, via a prescan.
The prescan
mp3_prescan.c runs once when a file opens, reading only the first few kilobytes. It is deliberately a pure function (bytes in, struct out, no filesystem, no RTOS) which is why it has the most thorough unit tests in the project; the same code runs on the target and in host tests against synthetic files.
What it parses, in order:
- ID3v2 skip. Most Quran files carry an ID3v2 tag (title, artist, artwork) at the front, sized by a syncsafe 28-bit integer in its header. The prescan computes where audio actually starts; a tag larger than the read buffer is reported so the caller can re-read at the right offset rather than fail.
- First frame header. Eleven set bits mark a sync word; version, layer, bitrate index, and sample rate follow. The prescan validates by checking that a second frame header appears exactly one frame length later, which filters the false syncs that plague MP3 parsing.
- Xing/Info header (LAME and friends): lives in the first frame's side data, declares total frame count, total bytes, and optionally a 100-entry table of contents: byte offsets, scaled to 0..255, at each percent of duration. This is the treasure. VBRI (Fraunhofer) is the rarer equivalent at a fixed offset; both are parsed.
- From frames x 1152 samples / sample rate falls out an exact duration, replacing the old file-size guess that VBR made a lie.
Seeking then interpolates: target time to percent, percent to two TOC entries, linear interpolation between them, landing within about 1 percent of the target, always on the correct side of it. Files with no TOC (constant bitrate, no header) fall back to bytes x fraction, which is exact for CBR by definition. After the jump, the decoder's input is reset and minimp3 resyncs on the next frame boundary; the position counter is rebased so the UI shows the target time.
Streams cannot seek and the API refuses rather than pretends: a live station has no meaningful byte 0.
Resume
Resume is seek plus persistence plus one race condition.
What is saved, and when
A small record (file path or stream URL, position in ms, volume) is written to NVS:
- every 30 seconds during playback (so power loss costs at most half a minute),
- at every pause and stop,
- and synchronously in the battery-critical shutdown path, before deep sleep.
Thirty seconds is a flash-wear decision: NVS wear-levels, but a per-second write pattern would still burn through cycles for no user-visible benefit.
The race, and why play_file_at exists
The obvious resume implementation is play(path) followed by seek(position). It worked in testing and failed in the field, classically: play is asynchronous (a queued command), so the seek could arrive before the codec knew the file's format, at which point time-to-byte mapping is impossible and the seek was dropped. A sleep between the calls "fixed" it, which is how you know it is a race.
The honest fix moved the start position into the play command: audio_player_play_file_at(path, start_ms) carries the offset, and the decode loop applies it at the exact moment the format becomes known (prescan done, first frame decoded). No second command, no window, no race. audio_player_resume_saved() is now a thin wrapper reading NVS and issuing one atomic command.
The general lesson: when step B depends on state produced asynchronously by step A, do not sequence B after A from outside; attach B to A's completion on the inside.
Auto-resume
With the setting enabled, boot finishes and the device continues where it died: files resume immediately; a saved stream arms a one-shot subscriber on EVENT_WIFI_CONNECTED and reconnects when the network arrives. Default is off: an unattended device deciding to make sound after a power blip is a feature only when explicitly chosen.
Ayah timing tracking and the ATB format
Beyond whole-track seek and resume, Safi maps playback positions to individual Ayahs via components/ayah_timings. This powers the on-device Arabic badge (آية ١٥), web dashboard navigation, and Hifz memorization repeat loops.
The binary format (.atb)
Rather than parsing ASCII JSON files with dynamic heap allocations (cJSON) during playback, Safi uses a fixed binary format (.atb):
- Header (16 bytes):
magic(0x53415442/SATB),version(1),surah(1..114),ayah_count,basmalah_ms, andsurah_end_ms. - Payload: An array of
uint32_tmillisecond start offsets for Ayahs 1 through $N$.
For Surah 2 (Al-Baqarah, 286 ayahs), the binary representation is 1,160 bytes (compared to 7.6 KB in JSON). A consolidated archive <reciter>.atb indexes all 114 surahs in ~27 KB, allowing single-read random access without per-surah FAT32 directory traversals.
Fast-path sequential tracking
The decode loop invokes position_callback() on every written PCM block. To prevent mutex contention and search overhead on the real-time audio task:
- Fast path: An atomic range check
(position >= cur_start && position < cur_end)returns immediately without taking a mutex. - Sequential advance: When crossing a boundary during normal playback, the tracker steps to
ayah + 1in $O(1)$. - Discontinuity fallback: Binary search over the loaded spans runs only after a manual seek or track jump.
- LRU caching: An in-memory 3-surah cache retains active and adjacent surah spans in RAM (~3.5 KB), eliminating SD card latency during A-B repeat loops.
Basmalah and Hifz repeat resolution
Ayah timings distinguish between the opening Basmalah (ayah = 0 / basmalah_ms) and the start of Ayah 1 (ayah = 1 / spans[0].start_ms). When entering a surah initially, playback begins at basmalah_ms so the listener hears the opening recitation. When repeating Ayah 1 in a Hifz loop, ayah_timings_find_start_ex() resolves directly to spans[0].start_ms, skipping the Basmalah on subsequent repetitions. Surah 1 (Al-Fatihah, where Ayah 1 is the Basmalah) and Surah 9 (At-Tawbah, no Basmalah) are handled consistently without hardcoded exceptions.