feat(firmware): M1 recording core - mic capture to WAV on SD
Implements the first working feature: the device records audio to the microSD
card, toggled by the button, with LED status and per-recording JSON metadata.
What changed:
- firmware/src/audio.{h,cpp}: mic capture via the core-bundled ESP_I2S. Dev board
uses I2S standard mode (INMP441/ICS-43434); XIAO uses PDM mode (onboard mic).
Presents 16-bit PCM mono to callers regardless of board.
- firmware/src/storage.{h,cpp}: microSD on a dedicated HSPI bus + a streaming
WavWriter that writes a 44-byte PCM header and patches RIFF/data sizes on close;
plus sidecar JSON metadata writer.
- firmware/src/recorder.{h,cpp}: idle/recording state machine; creates
/recordings/<id>.wav, pumps mic chunks in on update(), finalises + writes
<id>.json (Recording schema from api/openapi.yaml) on stop.
- firmware/src/ux.{h,cpp}: debounced button (short press toggles) + status LED
patterns (idle/recording/error), active-low aware.
- firmware/src/main.cpp: wires ux + recorder; loop toggles on button and drains
the mic while recording.
- firmware/include/audio_config.h: 16 kHz mono 16-bit, chunk size, rec dir.
- firmware/include/pins.h: added XIAO PDM mic + onboard SD pins, LED active-low flag.
- state/: TODO, ARCHITECTURE, NOTES updated for M1 and the deferred follow-ups.
Why:
- Recording is the foundation every later milestone (transfer, upload, transcription)
builds on. Kept dependency-free (only core-bundled ESP_I2S + SD) for simple CI builds.
Notes:
- Not compiled locally (no PlatformIO on the dev host) or hardware-verified; Forgejo
Actions CI builds both board profiles. Follow-ups tracked in state/TODO.md:
INMP441 16-bit level calibration, and real NTP timestamps (ids are uptime-based
until M2 brings WiFi/NTP).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
031074c9a9
commit
054b2f6981
14 changed files with 508 additions and 43 deletions
97
firmware/src/storage.cpp
Normal file
97
firmware/src/storage.cpp
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
#include "storage.h"
|
||||
|
||||
#include <SPI.h>
|
||||
|
||||
#include "audio_config.h"
|
||||
#include "pins.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// A dedicated SPI bus for the SD card so it does not clash with other peripherals.
|
||||
SPIClass sdSpi(HSPI);
|
||||
|
||||
// Write a 44-byte canonical PCM WAV header. dataBytes may be 0 at open (patched on close).
|
||||
void writeWavHeader(File &f, uint32_t sampleRate, uint16_t bits, uint16_t channels,
|
||||
uint32_t dataBytes) {
|
||||
const uint32_t byteRate = sampleRate * channels * (bits / 8);
|
||||
const uint16_t blockAlign = channels * (bits / 8);
|
||||
const uint32_t riffSize = 36 + dataBytes;
|
||||
|
||||
auto u32 = [&](uint32_t v) { f.write(reinterpret_cast<uint8_t *>(&v), 4); };
|
||||
auto u16 = [&](uint16_t v) { f.write(reinterpret_cast<uint8_t *>(&v), 2); };
|
||||
|
||||
f.write(reinterpret_cast<const uint8_t *>("RIFF"), 4);
|
||||
u32(riffSize);
|
||||
f.write(reinterpret_cast<const uint8_t *>("WAVE"), 4);
|
||||
f.write(reinterpret_cast<const uint8_t *>("fmt "), 4);
|
||||
u32(16); // PCM fmt chunk size
|
||||
u16(1); // audio format = PCM
|
||||
u16(channels);
|
||||
u32(sampleRate);
|
||||
u32(byteRate);
|
||||
u16(blockAlign);
|
||||
u16(bits);
|
||||
f.write(reinterpret_cast<const uint8_t *>("data"), 4);
|
||||
u32(dataBytes);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace storage {
|
||||
|
||||
bool begin() {
|
||||
sdSpi.begin(PIN_SD_SCK, PIN_SD_MISO, PIN_SD_MOSI, PIN_SD_CS);
|
||||
if (!SD.begin(PIN_SD_CS, sdSpi)) {
|
||||
return false;
|
||||
}
|
||||
if (!SD.exists(OSC_REC_DIR)) {
|
||||
SD.mkdir(OSC_REC_DIR);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint64_t totalBytes() { return SD.totalBytes(); }
|
||||
uint64_t freeBytes() { return SD.totalBytes() - SD.usedBytes(); }
|
||||
|
||||
bool WavWriter::open(const String &path, uint32_t sampleRate, uint16_t bitsPerSample,
|
||||
uint16_t channels) {
|
||||
file_ = SD.open(path, FILE_WRITE);
|
||||
if (!file_) return false;
|
||||
sampleRate_ = sampleRate;
|
||||
bits_ = bitsPerSample;
|
||||
channels_ = channels;
|
||||
dataBytes_ = 0;
|
||||
writeWavHeader(file_, sampleRate_, bits_, channels_, 0); // placeholder sizes
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t WavWriter::write(const uint8_t *data, size_t len) {
|
||||
if (!file_) return 0;
|
||||
size_t n = file_.write(data, len);
|
||||
dataBytes_ += n;
|
||||
return n;
|
||||
}
|
||||
|
||||
bool WavWriter::close() {
|
||||
if (!file_) return false;
|
||||
// Patch RIFF size (offset 4) and data size (offset 40) now that we know the length.
|
||||
file_.flush();
|
||||
file_.seek(4);
|
||||
uint32_t riffSize = 36 + dataBytes_;
|
||||
file_.write(reinterpret_cast<uint8_t *>(&riffSize), 4);
|
||||
file_.seek(40);
|
||||
file_.write(reinterpret_cast<uint8_t *>(&dataBytes_), 4);
|
||||
file_.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool writeMetadata(const String &path, const String &json) {
|
||||
File f = SD.open(path, FILE_WRITE);
|
||||
if (!f) return false;
|
||||
f.print(json);
|
||||
f.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace storage
|
||||
Loading…
Add table
Add a link
Reference in a new issue