Working on SD and netowrking

This commit is contained in:
2026-08-09 20:34:44 -06:00
parent 0014e8697f
commit e06b429db9
4 changed files with 730 additions and 0 deletions
+350
View File
@@ -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_<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)
```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_<uptimeSeconds>_<n>.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 |
+175
View File
@@ -0,0 +1,175 @@
#include "data_logger.h"
#include <string.h>
// ============================================================================
// 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_<uptimeSeconds>_<sessionSeq>.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; }
+174
View File
@@ -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 <Arduino.h>
#include <FS.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#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_<uptimeSeconds>_<n>.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;
};
+31
View File
@@ -112,3 +112,34 @@
// logging exists, throughput should be derived from actual logging writes and // logging exists, throughput should be derived from actual logging writes and
// this benchmark disabled by setting the interval to 0 (measure once at boot). // this benchmark disabled by setting the interval to 0 (measure once at boot).
#define STORAGE_SPEED_MEASURE_INTERVAL_MS 0 #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