14 KiB
Audio Logging Design
This document defines how the Hub writes the audio it collects from the
Nodes to the SD card. It is the contract that the future Node-collection task
(producer) and the existing StorageTask (consumer) are written against.
The deliverable for this design is:
src/storage/data_logger.h- the public interface (skeleton).src/storage/data_logger.cpp- stub bodies + fully implemented WAV metadata helpers. The chunk-pool/queue/file logic is markedTODOand is the remaining work.- The constants in
src/storage/storage_config.hunder the "Audio logging" section.
1. Data model
| Parameter | Value |
|---|---|
| Sample rate | 48 kHz |
| Bit depth | 16 bit (signed little-endian PCM) |
| Microphones per node | 4 |
| Max nodes | 10 |
| Total channels | 40 |
One microphone == one channel == one "track". A single file holds all of them as an interleaved multichannel WAV.
2. Throughput budget
raw stream rate = 40 ch x 48 kHz x 2 B = 3,840,000 B/s (3.66 MiB/s)
- SDIO 4-bit @ 40 MHz sustains roughly 8-12 MB/s -> the card write has ample headroom at less than half of its budget.
- WiFi (AP) carries the same 3.84 MB/s inbound as UDP payload, which the design already budgets for.
- The 128 KB chunk pool represents ~34 ms of audio. The write must be able to absorb bursts longer than that only by dropping (Section 8).
3. File format: single multichannel WAV
The stream is a RIFF/WAVE file with 40 interleaved channels. The 44-byte header is the only metadata; no per-chunk headers are written to the file.
Header layout (all little-endian):
| Offset | Size | Value |
|---|---|---|
| 0 | 4 | "RIFF" |
| 4 | 4 | chunk size = file size - 8 (patched) |
| 8 | 4 | "WAVE" |
| 12 | 4 | "fmt " |
| 16 | 4 | 16 (PCM fmt chunk size) |
| 20 | 2 | 1 (PCM) |
| 22 | 2 | 40 (channels) |
| 24 | 4 | 48000 (sample rate) |
| 28 | 4 | 3,840,000 (byte rate = rate x blockAlign) |
| 32 | 2 | 80 (block align = channels x 2) |
| 34 | 2 | 16 (bits per sample) |
| 36 | 4 | "data" |
| 40 | 4 | data size = file size - 44 (patched) |
buildWavHeader() and finalizeWavHeader() in data_logger.h/cpp produce
these bytes and the two 32-bit values patched at offsets 4 and 40.
Header finalization
The sizes at offsets 4 and 40 are unknown while the file is being written. They are patched when the file is closed (rotation or session end):
file.seek(4); file.write(riffSize, 4);
file.seek(40); file.write(dataSize, 4);
Crash tolerance
If power is lost mid-file, the header still contains the placeholder sizes.
PCM decoders that read to EOF (Audacity, ffmpeg, Python wave) play the valid
audio regardless; the last partial sector may be zero-padded. This satisfies
the design.md requirement that a mid-recording power loss still yields usable
audio.
Opening the file on a PC
- Audacity / ffmpeg / Python
wave: handle 40 channels. - VLC and stock Windows players generally will not render 40 channels even though the file is structurally valid. Convert with ffmpeg first if needed.
4. Architecture: producer / consumer with a chunk pool
Node collection task (core 1) StorageTask (core 0)
assembles one round of 40 channels | loop:
interleaved into frame order v
+----------------------+ +----------------------+
| c = acquireChunk(5ms)| <-- freeQ ---- | pool: 8 x 16 KB |
| fill c->data[...] | | (MALLOC_CAP_DMA) |
| submitChunk(c) | ---- filledQ -->| nextChunk(1000ms) |
+----------------------+ | writeChunk(c) |
| releaseChunk(c) |
| rotateIfNeeded() |
| updateStats() |
+----------------------+
DataLogger owns both queues and the pool. Producers and the consumer never
touch the file or the card directly - only DataLogger does.
- Pool: 8 chunks x 16 KB = 128 KB, allocated once in
begin()withheap_caps_malloc(MALLOC_CAP_DMA). - Queues:
freeQholds pointers to empty chunks,filledQholds pointers to chunks waiting to be written. Both are FreeRTOS queues ofAudioChunk*. - Cores: the producer runs on core 1 (network), the consumer on core 0
(the existing pinned
StorageTask). SD card work never touches core 1.
5. The interface
Types (from data_logger.h):
struct AudioChunk
{
uint8_t* data; // DMA-capable buffer owned by the pool
uint32_t capacity; // buffer size in bytes (STORAGE_LOG_CHUNK_SIZE)
uint32_t length; // valid bytes written by the producer
uint32_t sequence; // monotonic order, for diagnostics only
};
class DataLogger
{
public:
bool begin(fs::FS& files); // allocate pool, create log dir
void end(); // finalize current file, free pool
bool openSession(); // create rec_<uptime>_<n>.wav
bool closeSession(); // finalize + close current file
bool rotateIfNeeded(); // called by the consumer
// Producer (core 1)
AudioChunk* acquireChunk(TickType_t timeout);
void submitChunk(AudioChunk* chunk);
// Consumer (core 0)
AudioChunk* nextChunk(TickType_t timeout);
bool writeChunk(AudioChunk* chunk);
void releaseChunk(AudioChunk* chunk);
// Stats
uint32_t bytesWritten() const;
uint32_t chunksWritten() const;
uint32_t droppedChunks() const;
uint32_t writeSpeedBps() const;
bool overflowing() const;
};
Producer contract (Node collection task, not yet written)
AudioChunk* c = logger.acquireChunk(pdMS_TO_TICKS(5));
if (c == nullptr) { /* collection is behind; drop this round */ return; }
// Fill c->data[0..c->length) with interleaved PCM, frame order:
// frame0: ch0..ch39, frame1: ch0..ch39, ...
// 40 samples of 2 bytes per frame = 80 bytes per frame.
c->length = interleavedBytes;
c->sequence = nextSeq++;
logger.submitChunk(c);
- Chunks must be submitted in strict stream order (single producer, FIFO queue -> order is preserved automatically).
- A chunk is not required to end on a frame boundary, but
c->lengthshould stay a multiple of 80 bytes so the file is always frame-aligned. acquireChunkcan return a chunk even when the pool is momentarily empty; see the drop-oldest rule in Section 8.
Consumer contract (StorageTask on core 0, future)
while (true)
{
AudioChunk* c = logger.nextChunk(pdMS_TO_TICKS(1000));
if (c != nullptr)
{
logger.writeChunk(c); // one file.write(c->data, c->length)
logger.releaseChunk(c); // returns the chunk to freeQ
}
logger.rotateIfNeeded();
logger.updateStats(storageState); // bps + drops -> dashboard
}
- The 1 s timeout lets the loop pulse for stats/capacity updates even when no
audio is flowing (this replaces the current 1 s
vTaskDelayUntilheartbeat). writeChunkis the only place the card is touched. It appends the chunk's PCM directly to the WAV data section in onefile.write.
Ownership rules
- The pool owns the buffers; producers and the consumer borrow them.
- A borrowed chunk is in exactly one place at a time: a producer (between acquire and submit), a queue, or the consumer (between next and release).
releaseChunkreturns it tofreeQ.writeChunknever frees.
6. Upstream interleaving contract
The WAV must be written frame-interleaved, but the Nodes deliver one UDP packet per Node (4 contiguous mono channels). Frames cannot be interleaved until all Nodes in a round have been collected. That re-ordering is the job of the collection task, not the logger:
- Collect all 10 Node dumps for round
n. - A missing/offline Node contributes silence: zero-fill its 4 channels.
- Re-order into frame order:
frame j = node0[mic0..3], node1[mic0..3], ... - Feed the resulting bytes into chunks and submit them in order.
Consequences the collection task must honor:
- Round size: with the 128 KB pool, a round must stay <= ~96 KB (~25 ms of audio) so the pool can hold more than one round. See the sizing math in Section 7.
- Silence for offline Nodes comes from this zero-fill; the logger never invents data.
7. Chunk pool and RAM budget
chunk size = STORAGE_LOG_CHUNK_SIZE = 16 KiB
pool depth = STORAGE_LOG_POOL_SIZE = 8
pool total = 128 KiB (MALLOC_CAP_DMA)
buffering = 128 KiB / 3.84 MB/s ~= 34 ms
round size = 48 kHz x 2 B x 40 ch x round_s
25 ms round -> 96 KB (~6 chunks) [fits the pool with slack]
50 ms round -> 192 KB [does NOT fit - must shrink]
The collection round duration is therefore bounded by the pool unless the pool
grows. Keep rounds at <= 25 ms, or raise STORAGE_LOG_POOL_SIZE/chunk size and
re-run the math.
The round's interleave buffer lives in the collection task's own RAM (up to 96 KB), not in the logger pool; it is freed after the round is submitted.
8. Overflow: drop-oldest + warning
If the SD card cannot keep up, the pool drains and the producer has no chunk. Policy (chosen): drop the oldest buffered chunk until caught up - never block, never halt.
Mechanism inside acquireChunk: on timeout, the logger pops one chunk off the
back of filledQ (the oldest unwritten data), returns it to freeQ, hands it
to the producer, increments droppedChunks, and raises the overflow warning.
The stream keeps flowing with the newest data at the cost of a gap.
The warning is visible two ways:
- Serial: a rate-limited
[Logger] X chunks dropped, Y MB behindline. - LED on GPIO
STORAGE_WARN_LED_GPIO(default 4, active-high): on whileoverflowing(), off once the queue drains below a low-water mark again.
9. Rotation, naming, flush
- Rotate by size: when
bytesThisFile >= STORAGE_LOG_ROTATE_BYTES(default 1 GiB, ~4.6 min at 3.84 MB/s) the consumer callsrotateIfNeeded(): finalize + close the current file, open the next. - Files: created under
STORAGE_LOG_DIR(/sdcard/audio), namedrec_<uptimeSeconds>_<n>.wav.nincrements per rotation within a boot; the uptime prefix keeps names unique across boots. If a name already exists, skip forward until it does not (never overwrite). - Flush:
file.flush()(f_sync) everySTORAGE_LOG_FLUSH_BYTES(default 16 MiB, ~4.3 s) so an unclean power-off loses at most that window and never corrupts earlier data. - Card full: rotation cannot create a file -> report a fatal error, light the warning LED solid, and halt (matches design.md's SD-failure stance).
10. DMA requirements
The SDIO path (production) uses the SDMMC controller's internal IDMA engine: block data moves to the card without CPU cycles. Two rules make this work:
- Buffers must be in DMA-capable memory: allocate the pool with
heap_caps_malloc(MALLOC_CAP_DMA)(guarantees internal DRAM + alignment). - Never hand the card a buffer that lives in PSRAM or a stack array.
The SPI path (STORAGE_IFACE_SPI, current bring-up) does not use DMA -
the Arduino SPI driver busy-waits the FIFO. It is bring-up only and cannot
sustain the 3.84 MB/s target; production logging must run on SDIO.
11. Metering -> dashboard
writeChunk accumulates bytesWritten/chunksWritten; writeSpeedBps is
derived from a sliding 1 s window of real writes. The consumer publishes
writeSpeedBps and droppedChunks into StorageState each loop, replacing
the boot-time measureWriteSpeed() benchmark (whose interval is already 0).
Add a droppedChunks field to StorageSnapshot and surface it on the
dashboard when the logging loop lands.
12. Future integration points (StorageTask)
When the consumer loop is implemented, StorageTask::run():
- After mount:
logger.begin(storage.fs()),logger.openSession(). - Replace the 1 s heartbeat loop with the consumer loop in Section 5.
- Stop calling
measureWriteSpeed()(the logger provides real bps).
13. SPI -> SDIO migration checklist
Already documented in src/storage/storage_config.h. Restated for logging:
STORAGE_IFACESPI -> SDMMC.- Wire SD to
STORAGE_SDMMC_*pins (freely re-routable on classic ESP32). - Keep 4-bit mode (
STORAGE_SDMMC_MODE_1BIT == false) - 1-bit halves the throughput margin. MALLOC_CAP_DMAbuffers work unchanged; nothing else moves.
14. Open TODOs (to finish the feature)
data_logger.cpp: chunk pool + queue creation inbegin().data_logger.cpp:acquireChunkdrop-oldest path + overflow warning (Serial + LED onSTORAGE_WARN_LED_GPIO).data_logger.cpp:writeChunk/rotateIfNeeded/closeSessionfile handling + header finalization + flush cadence.data_logger.cpp:writeSpeedBpssliding window.StorageTask: consumer loop (Section 5).StorageState/dashboard:droppedChunksfield.- Node collection task: round assembly + interleave + zero-fill (Section 6).
Appendix: constants
| Constant | Default | Meaning |
|---|---|---|
STORAGE_AUDIO_SAMPLE_RATE_HZ |
48000 | WAV sample rate |
STORAGE_AUDIO_CHANNELS |
40 | = nodes x mics, WAV channels |
STORAGE_AUDIO_BITS |
16 | WAV bit depth |
STORAGE_LOG_CHUNK_SIZE |
16 * 1024 | pool chunk size (bytes) |
STORAGE_LOG_POOL_SIZE |
8 | pool chunk count |
STORAGE_LOG_DIR |
"/sdcard/audio" | recording directory |
STORAGE_LOG_ROTATE_BYTES |
1 GiB | rotate when a file reaches this |
STORAGE_LOG_FLUSH_BYTES |
16 MiB | f_sync cadence |
STORAGE_WARN_LED_GPIO |
4 | overflow warning LED |
STORAGE_WARN_LED_ACTIVE_HIGH |
true | LED polarity |