From e06b429db9a19b1eabea68dd0626f9ce439cc36a Mon Sep 17 00:00:00 2001 From: bionickatana Date: Sun, 9 Aug 2026 20:34:44 -0600 Subject: [PATCH] Working on SD and netowrking --- docs/audio_logging.md | 350 +++++++++++++++++++++++++++++++++++ src/storage/data_logger.cpp | 175 ++++++++++++++++++ src/storage/data_logger.h | 174 +++++++++++++++++ src/storage/storage_config.h | 31 ++++ 4 files changed, 730 insertions(+) create mode 100644 docs/audio_logging.md create mode 100644 src/storage/data_logger.cpp create mode 100644 src/storage/data_logger.h diff --git a/docs/audio_logging.md b/docs/audio_logging.md new file mode 100644 index 0000000..7c51490 --- /dev/null +++ b/docs/audio_logging.md @@ -0,0 +1,350 @@ +# 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 marked `TODO` and is the + remaining work. +* The constants in `src/storage/storage_config.h` under 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()` with + `heap_caps_malloc(MALLOC_CAP_DMA)`. +* **Queues**: `freeQ` holds pointers to empty chunks, `filledQ` holds pointers + to chunks waiting to be written. Both are FreeRTOS queues of + `AudioChunk*`. +* **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`): + +```cpp +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__.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) + +```cpp +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->length` should + stay a multiple of 80 bytes so the file is always frame-aligned. +* `acquireChunk` can 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) + +```cpp +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 `vTaskDelayUntil` heartbeat). +* `writeChunk` is the only place the card is touched. It appends the chunk's + PCM directly to the WAV data section in one `file.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). +* `releaseChunk` returns it to `freeQ`. `writeChunk` never 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: + +1. Collect all 10 Node dumps for round `n`. +2. A missing/offline Node contributes silence: zero-fill its 4 channels. +3. Re-order into frame order: `frame j = node0[mic0..3], node1[mic0..3], ...` +4. 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 behind` line. +* **LED on GPIO `STORAGE_WARN_LED_GPIO`** (default 4, active-high): on while + `overflowing()`, 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 calls `rotateIfNeeded()`: + finalize + close the current file, open the next. +* **Files**: created under `STORAGE_LOG_DIR` (`/sdcard/audio`), named + `rec__.wav`. `n` increments 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) every `STORAGE_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()`: + +1. After mount: `logger.begin(storage.fs())`, `logger.openSession()`. +2. Replace the 1 s heartbeat loop with the consumer loop in Section 5. +3. Stop calling `measureWriteSpeed()` (the logger provides real bps). + +## 13. SPI -> SDIO migration checklist + +Already documented in `src/storage/storage_config.h`. Restated for logging: + +1. `STORAGE_IFACE` SPI -> SDMMC. +2. Wire SD to `STORAGE_SDMMC_*` pins (freely re-routable on classic ESP32). +3. Keep 4-bit mode (`STORAGE_SDMMC_MODE_1BIT == false`) - 1-bit halves the + throughput margin. +4. `MALLOC_CAP_DMA` buffers work unchanged; nothing else moves. + +## 14. Open TODOs (to finish the feature) + +- [ ] `data_logger.cpp`: chunk pool + queue creation in `begin()`. +- [ ] `data_logger.cpp`: `acquireChunk` drop-oldest path + overflow warning + (Serial + LED on `STORAGE_WARN_LED_GPIO`). +- [ ] `data_logger.cpp`: `writeChunk`/`rotateIfNeeded`/`closeSession` file + handling + header finalization + flush cadence. +- [ ] `data_logger.cpp`: `writeSpeedBps` sliding window. +- [ ] `StorageTask`: consumer loop (Section 5). +- [ ] `StorageState`/dashboard: `droppedChunks` field. +- [ ] 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 | diff --git a/src/storage/data_logger.cpp b/src/storage/data_logger.cpp new file mode 100644 index 0000000..e67df49 --- /dev/null +++ b/src/storage/data_logger.cpp @@ -0,0 +1,175 @@ +#include "data_logger.h" + +#include + +// ============================================================================ +// Skeleton implementation. The WAV metadata helpers below are complete; +// everything else is stubbed with a TODO and the work is tracked in +// docs/audio_logging.md section 14. +// ============================================================================ + + +// --- WAV metadata helpers (complete) ----------------------------------------- + +void buildWavHeader(WavHeader& header, + uint16_t numChannels, + uint32_t sampleRate, + uint16_t bitsPerSample, + uint32_t dataSize) +{ + uint16_t blockAlign = numChannels * (bitsPerSample / 8); + + memset(&header, 0, sizeof(header)); + + memcpy(header.riff, "RIFF", 4); + header.riffSize = sizeof(header) + dataSize - 8; + memcpy(header.wave, "WAVE", 4); + + memcpy(header.fmt, "fmt ", 4); + header.fmtChunkSize = 16; + header.audioFormat = 1; // PCM + header.numChannels = numChannels; + header.sampleRate = sampleRate; + header.byteRate = sampleRate * blockAlign; + header.blockAlign = blockAlign; + header.bitsPerSample = bitsPerSample; + + memcpy(header.data, "data", 4); + header.dataSize = dataSize; +} + + +void finalizeWavHeader(uint32_t fileSize, + uint32_t& riffSizeOut, + uint32_t& dataSizeOut) +{ + riffSizeOut = fileSize - 8; + dataSizeOut = fileSize - sizeof(WavHeader); +} + + +// --- DataLogger --------------------------------------------------------------- + +DataLogger::DataLogger() +: +files(nullptr), +pool(nullptr), +freeQ(nullptr), +filledQ(nullptr), +sessionSeq(0), +bytesThisFile(0), +totalBytes(0), +totalChunks(0), +totalDropped(0), +lastDropPrintMs(0), +windowBytes(0), +windowStartMs(0), +bps(0), +active(false), +warning(false) +{ +} + + +bool DataLogger::begin(fs::FS& files) +{ + // TODO: allocate the chunk pool with heap_caps_malloc(MALLOC_CAP_DMA) + // (STORAGE_LOG_POOL_SIZE x STORAGE_LOG_CHUNK_SIZE), create freeQ with all + // chunks and filledQ empty (xQueueCreate), mkdir STORAGE_LOG_DIR. + this->files = &files; + return false; +} + + +void DataLogger::end() +{ + // TODO: closeSession(), free the pool (heap_caps_free), delete the queues. +} + + +bool DataLogger::openSession() +{ + // TODO: build "rec__.wav" under + // STORAGE_LOG_DIR (skip forward if the name exists), open FILE_WRITE, + // write the 44-byte header with buildWavHeader(dataSize=0). + return false; +} + + +bool DataLogger::closeSession() +{ + // TODO: finalizeWavHeader(file.size(), ...) -> patch offsets 4 and 40, + // file.flush(), file.close(). + return false; +} + + +bool DataLogger::rotateIfNeeded() +{ + // TODO: if active && bytesThisFile >= STORAGE_LOG_ROTATE_BYTES: + // closeSession(); sessionSeq++; openSession(). + return false; +} + + +AudioChunk* DataLogger::acquireChunk(TickType_t timeout) +{ + // TODO: xQueueReceive(freeQ, &chunk, timeout). On timeout, call + // dropOldestChunk() and return its chunk so the producer keeps streaming. + return nullptr; +} + + +void DataLogger::submitChunk(AudioChunk* chunk) +{ + // TODO: xQueueSend(filledQ, &chunk, ...). Must preserve stream order. +} + + +AudioChunk* DataLogger::nextChunk(TickType_t timeout) +{ + // TODO: xQueueReceive(filledQ, &chunk, timeout). + return nullptr; +} + + +bool DataLogger::writeChunk(AudioChunk* chunk) +{ + // TODO: if (!active) return false; + // n = file.write(chunk->data, chunk->length); + // bytesThisFile += n; totalBytes += n; totalChunks++; + // feed the writeSpeedBps window (windowBytes/windowStartMs); + // file.flush() every STORAGE_LOG_FLUSH_BYTES; + // return n == chunk->length; (card failure -> fatal) + return false; +} + + +void DataLogger::releaseChunk(AudioChunk* chunk) +{ + // TODO: chunk->length = 0; xQueueSend(freeQ, &chunk, ...). +} + + +AudioChunk* DataLogger::dropOldestChunk() +{ + // TODO: xQueueReceive from the BACK of filledQ without writing, count it, + // setOverflowWarning(true). Called by acquireChunk on timeout. + return nullptr; +} + + +void DataLogger::setOverflowWarning(bool overflowing) +{ + // TODO: rate-limited Serial line with totalDropped + bytes behind; + // digitalWrite(STORAGE_WARN_LED_GPIO, ...) using + // STORAGE_WARN_LED_ACTIVE_HIGH. + (void)overflowing; +} + + +uint32_t DataLogger::bytesWritten() const { return (uint32_t)totalBytes; } +uint32_t DataLogger::chunksWritten() const { return totalChunks; } +uint32_t DataLogger::droppedChunks() const { return totalDropped; } +uint32_t DataLogger::writeSpeedBps() const { return bps; } +bool DataLogger::overflowing() const { return warning; } diff --git a/src/storage/data_logger.h b/src/storage/data_logger.h new file mode 100644 index 0000000..4f27a4d --- /dev/null +++ b/src/storage/data_logger.h @@ -0,0 +1,174 @@ +#pragma once + +// ============================================================================ +// Audio logging interface (see docs/audio_logging.md for the full design). +// +// THIS FILE IS A SKELETON. The chunk-pool / queue / file logic in +// data_logger.cpp is stubbed with TODOs; only the WAV metadata helpers are +// implemented. The interface below is the contract the future Node +// collection task (producer) and the StorageTask (consumer) are written +// against. +// ============================================================================ + +#include +#include + +#include +#include + +#include "storage_config.h" + + +// ---------------------------------------------------------------------------- +// WAV (RIFF) metadata helpers - fully implemented in data_logger.cpp. +// ---------------------------------------------------------------------------- + +// 44-byte PCM WAVE header. Layout and field meaning are documented in +// docs/audio_logging.md section 3. +struct WavHeader +{ + uint8_t riff[4]; // "RIFF" + uint32_t riffSize; // file size - 8 + uint8_t wave[4]; // "WAVE" + uint8_t fmt[4]; // "fmt " + uint32_t fmtChunkSize; // 16 (PCM) + uint16_t audioFormat; // 1 (PCM) + uint16_t numChannels; // STORAGE_AUDIO_CHANNELS + uint32_t sampleRate; // STORAGE_AUDIO_SAMPLE_RATE_HZ + uint32_t byteRate; // sampleRate * blockAlign + uint16_t blockAlign; // numChannels * (bitsPerSample / 8) + uint16_t bitsPerSample; // STORAGE_AUDIO_BITS + uint8_t data[4]; // "data" + uint32_t dataSize; // file size - 44 +} __attribute__((packed)); + +static_assert(sizeof(WavHeader) == 44, "WAV header must be exactly 44 bytes"); + +// Fills the header for a PCM stream of the given channels/rate/depth. +// dataSize is typically 0 at file open and corrected on finalize. +void buildWavHeader(WavHeader& header, + uint16_t numChannels, + uint32_t sampleRate, + uint16_t bitsPerSample, + uint32_t dataSize); + +// Returns the two little-endian values to patch at offsets 4 and 40 when a +// file is closed/rotated: riffSize = fileSize - 8, dataSize = fileSize - 44. +void finalizeWavHeader(uint32_t fileSize, + uint32_t& riffSizeOut, + uint32_t& dataSizeOut); + + +// ---------------------------------------------------------------------------- +// Chunk pool +// ---------------------------------------------------------------------------- + +// One slot of the write pool. Producers borrow a chunk, fill `data[0..length)` +// with interleaved PCM (40 channels, frame order, 80 bytes/frame), and submit +// it. Chunks must be submitted in strict stream order. +struct AudioChunk +{ + uint8_t* data; // DMA-capable buffer owned by the pool + uint32_t capacity; // STORAGE_LOG_CHUNK_SIZE + uint32_t length; // valid bytes, multiple of the WAV frame size + uint32_t sequence; // monotonic order (diagnostics only) +}; + + +// ---------------------------------------------------------------------------- +// DataLogger +// ---------------------------------------------------------------------------- + +// Producer/consumer bridge between the Node collection task (core 1) and the +// StorageTask (core 0). Owns the chunk pool, the two FreeRTOS queues, and the +// open WAV file. Only the consumer touches the card. +class DataLogger +{ +public: + DataLogger(); + + // Lifecycle -------------------------------------------------------------- + + // Allocates the pool (MALLOC_CAP_DMA), creates the queues, creates + // STORAGE_LOG_DIR. Does not open a session file. + bool begin(fs::FS& files); + + // Finalizes and closes the current session file, frees the pool/queues. + void end(); + + // Session control -------------------------------------------------------- + + // Creates the next rec__.wav under STORAGE_LOG_DIR, + // writes the 44-byte WAV header. false if the card is full or unwritable. + bool openSession(); + + // Finalizes (patches RIFF/data sizes) and closes the current file. + bool closeSession(); + + // Closes/opens when bytesThisFile >= STORAGE_LOG_ROTATE_BYTES. Called by + // the consumer on every loop; must be a no-op when idle. + bool rotateIfNeeded(); + + // Producer API (Node collection task, core 1) ---------------------------- + + // Pops a free chunk, or on timeout drops the oldest queued chunk (Section + // 8) and returns it so the producer can keep streaming. nullptr only if + // there is nothing to drop (empty pool + empty queue). + AudioChunk* acquireChunk(TickType_t timeout); + + // Returns a filled chunk to the write queue. Must be called in order. + void submitChunk(AudioChunk* chunk); + + // Consumer API (StorageTask, core 0) -------------------------------------- + + // Blocks up to `timeout` for the next filled chunk. nullptr on timeout + // (lets the consumer pulse for stats even when idle). + AudioChunk* nextChunk(TickType_t timeout); + + // Appends chunk->data[0..length) to the WAV data section with one + // file.write(), updates the byte/chunk counters and the bps window. + // false on card failure (fatal per design.md). + bool writeChunk(AudioChunk* chunk); + + // Returns a chunk to the free pool after it has been written or dropped. + void releaseChunk(AudioChunk* chunk); + + // Stats ------------------------------------------------------------------- + + uint32_t bytesWritten() const; + uint32_t chunksWritten() const; + uint32_t droppedChunks() const; + uint32_t writeSpeedBps() const; + bool overflowing() const; + +private: + // Pops the oldest unwritten chunk off filledQ without writing it, + // increments droppedChunks, raises the warning. Returns it to the caller + // (used by acquireChunk on timeout). + AudioChunk* dropOldestChunk(); + + // Serial (rate-limited) + LED (STORAGE_WARN_LED_GPIO) overflow indication. + void setOverflowWarning(bool overflowing); + + fs::FS* files; // SD backend, injected by begin() + AudioChunk* pool; // STORAGE_LOG_POOL_SIZE chunks + QueueHandle_t freeQ; // empty chunks + QueueHandle_t filledQ; // chunks waiting to be written + File file; // open session file + char filePath[96]; + uint32_t sessionSeq; // rotation counter within this boot + + uint64_t bytesThisFile; + uint64_t totalBytes; + uint32_t totalChunks; + uint32_t totalDropped; + uint32_t lastDropPrintMs; + + // bps metering window (writeChunk is the only writer) + uint32_t windowBytes; + uint32_t windowStartMs; + uint32_t bps; + + bool active; + bool warning; +}; diff --git a/src/storage/storage_config.h b/src/storage/storage_config.h index c488828..b6c5e86 100644 --- a/src/storage/storage_config.h +++ b/src/storage/storage_config.h @@ -112,3 +112,34 @@ // logging exists, throughput should be derived from actual logging writes and // this benchmark disabled by setting the interval to 0 (measure once at boot). #define STORAGE_SPEED_MEASURE_INTERVAL_MS 0 + +// --- Audio logging ---------------------------------------------------------- +// +// The DataLogger (docs/audio_logging.md) writes one multichannel WAV file +// per rotation. The Node collection task feeds it interleaved 40-channel PCM +// as chunks; the logger appends them and only the consumer touches the card. +// +// Stream rate: 40 ch x 48 kHz x 2 B = 3,840,000 B/s. The 128 KB pool holds +// ~34 ms of audio, so collection rounds must stay <= ~25 ms (<= 96 KB) or +// the pool must grow (see docs/audio_logging.md section 7). + +// WAV parameters (must match what the Nodes produce). +#define STORAGE_AUDIO_SAMPLE_RATE_HZ 48000 +#define STORAGE_AUDIO_CHANNELS 40 // 10 nodes x 4 mics +#define STORAGE_AUDIO_BITS 16 + +// Chunk pool: STORAGE_LOG_POOL_SIZE chunks of STORAGE_LOG_CHUNK_SIZE bytes, +// allocated with MALLOC_CAP_DMA. 128 KB total. +#define STORAGE_LOG_CHUNK_SIZE (16 * 1024) +#define STORAGE_LOG_POOL_SIZE 8 + +// Recording directory and rotation policy. +#define STORAGE_LOG_DIR STORAGE_MOUNT_POINT "/audio" +#define STORAGE_LOG_ROTATE_BYTES (1024LL * 1024 * 1024) // 1 GiB / file +#define STORAGE_LOG_FLUSH_BYTES (16LL * 1024 * 1024) // f_sync cadence + +// Overflow warning LED: lights while chunks are being dropped because the SD +// card cannot keep up. GPIO 4 is unused by the SD card lines (SPI 5/18/19/23, +// SDMMC 6-11). Change both values if a different LED is wired. +#define STORAGE_WARN_LED_GPIO 4 +#define STORAGE_WARN_LED_ACTIVE_HIGH true