From 054b2f6981d5317a9c97c547c6f88f841a260112 Mon Sep 17 00:00:00 2001 From: Laurence Date: Fri, 3 Jul 2026 11:35:34 +0100 Subject: [PATCH] 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/.wav, pumps mic chunks in on update(), finalises + writes .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 --- firmware/include/audio_config.h | 14 +++++ firmware/include/pins.h | 16 ++++-- firmware/src/audio.cpp | 43 +++++++++++++++ firmware/src/audio.h | 22 ++++++++ firmware/src/main.cpp | 79 +++++++++++++++++---------- firmware/src/recorder.cpp | 90 ++++++++++++++++++++++++++++++ firmware/src/recorder.h | 26 +++++++++ firmware/src/storage.cpp | 97 +++++++++++++++++++++++++++++++++ firmware/src/storage.h | 39 +++++++++++++ firmware/src/ux.cpp | 64 ++++++++++++++++++++++ firmware/src/ux.h | 19 +++++++ state/ARCHITECTURE.md | 10 ++-- state/NOTES.md | 10 ++++ state/TODO.md | 22 ++++++-- 14 files changed, 508 insertions(+), 43 deletions(-) create mode 100644 firmware/include/audio_config.h create mode 100644 firmware/src/audio.cpp create mode 100644 firmware/src/audio.h create mode 100644 firmware/src/recorder.cpp create mode 100644 firmware/src/recorder.h create mode 100644 firmware/src/storage.cpp create mode 100644 firmware/src/storage.h create mode 100644 firmware/src/ux.cpp create mode 100644 firmware/src/ux.h diff --git a/firmware/include/audio_config.h b/firmware/include/audio_config.h new file mode 100644 index 0000000..c6c7853 --- /dev/null +++ b/firmware/include/audio_config.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Recording parameters. Defaults: 16 kHz mono 16-bit PCM (see state/DECISIONS.md). +#pragma once + +#define OSC_SAMPLE_RATE 16000 +#define OSC_BITS_PER_SAMPLE 16 +#define OSC_CHANNELS 1 + +// Directory on the SD card for recordings + sidecar metadata. +#define OSC_REC_DIR "/recordings" + +// Capture chunk size (bytes) pumped from the mic to the WAV file each update(). +// 4096 bytes = 2048 samples = 128 ms at 16 kHz mono 16-bit. Sits in a stack buffer. +#define OSC_CHUNK_BYTES 4096 \ No newline at end of file diff --git a/firmware/include/pins.h b/firmware/include/pins.h index 3e882f7..24277a3 100644 --- a/firmware/include/pins.h +++ b/firmware/include/pins.h @@ -5,10 +5,17 @@ #if defined(OPENSCRIBE_BOARD_XIAO) // Seeed XIAO ESP32-S3 Sense: onboard PDM mic + microSD use the Sense board's fixed pins. -// The audio driver selects the PDM path for this board; these are placeholders for the -// user-facing controls only. +// The audio driver selects the PDM path for this board. +#define PIN_PDM_CLK 42 // XIAO ESP32-S3 Sense onboard PDM mic clock +#define PIN_PDM_DIN 41 // XIAO ESP32-S3 Sense onboard PDM mic data +// XIAO Sense onboard microSD (SDMMC-on-SPI fixed pins) +#define PIN_SD_CS 21 +#define PIN_SD_SCK 7 +#define PIN_SD_MOSI 9 +#define PIN_SD_MISO 8 #define PIN_BUTTON 2 // external tactile button to GND -#define PIN_LED 21 // XIAO onboard user LED +#define PIN_LED LED_BUILTIN // XIAO onboard user LED (active-low) +#define PIN_LED_ACTIVE_LOW 1 #define PIN_VBUS_SENSE -1 // use USB-serial/VBUS API on XIAO #define PIN_CHARGE_STAT -1 @@ -26,7 +33,8 @@ // Controls / status #define PIN_BUTTON 7 // active-low, internal pull-up -#define PIN_LED 48 // WS2812 data (or plain LED via 330R) +#define PIN_LED 48 // plain LED via 330R (WS2812 support added later) +#define PIN_LED_ACTIVE_LOW 0 #define PIN_CHARGE_STAT 14 // TP4056 STAT (open-drain) #define PIN_VBUS_SENSE 15 // USB 5V present -> on-charge / powered mode #define PIN_MOTOR 16 // optional haptic, via transistor diff --git a/firmware/src/audio.cpp b/firmware/src/audio.cpp new file mode 100644 index 0000000..4187348 --- /dev/null +++ b/firmware/src/audio.cpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "audio.h" + +#include // bundled with Arduino-ESP32 3.x + +#include "audio_config.h" +#include "pins.h" + +namespace { +I2SClass i2s; +} + +namespace audio { + +bool begin() { +#if defined(OPENSCRIBE_BOARD_XIAO) + // XIAO ESP32-S3 Sense onboard PDM microphone. + i2s.setPinsPdmRx(PIN_PDM_CLK, PIN_PDM_DIN); + return i2s.begin(I2S_MODE_PDM_RX, OSC_SAMPLE_RATE, + I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO); +#else + // I2S standard mode from an external MEMS mic (INMP441 / ICS-43434). + // Args: bclk, ws, dout(unused for RX = -1), din, mclk(unused = -1). + i2s.setPins(PIN_I2S_BCLK, PIN_I2S_WS, -1, PIN_I2S_DIN, -1); + return i2s.begin(I2S_MODE_STD, OSC_SAMPLE_RATE, + I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO); +#endif +} + +size_t audio::read(uint8_t *dst, size_t maxBytes) { + // I2SClass::readBytes blocks until data is available or the DMA times out. + int n = i2s.readBytes(reinterpret_cast(dst), maxBytes); + return n < 0 ? 0 : static_cast(n); +} + +void end() { i2s.end(); } + +} // namespace audio + +// NOTE (calibration, revisit with hardware): INMP441 emits 24-bit samples in a 32-bit +// slot. Reading in 16-bit mode captures the top bits and is usable for speech but may be +// quiet. If levels are low on the dev build, switch to 32-bit capture and right-shift into +// int16 here. Tracked in state/TODO.md. \ No newline at end of file diff --git a/firmware/src/audio.h b/firmware/src/audio.h new file mode 100644 index 0000000..4c03352 --- /dev/null +++ b/firmware/src/audio.h @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Microphone capture. Presents 16-bit PCM mono regardless of board: +// - Build B (dev): I2S standard mode from an INMP441 / ICS-43434. +// - Build A (XIAO): PDM mode from the onboard mic. +#pragma once + +#include +#include + +namespace audio { + +// Bring the mic up at OSC_SAMPLE_RATE. Returns false if the I2S peripheral fails to init. +bool begin(); + +// Read up to maxBytes of 16-bit little-endian PCM mono into dst. +// Returns the number of bytes actually read (may be less than requested). +size_t read(uint8_t *dst, size_t maxBytes); + +// Release the I2S peripheral. +void end(); + +} // namespace audio \ No newline at end of file diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index e3fa988..7b91684 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -2,50 +2,73 @@ // // OpenScribe firmware entry point. // -// This is the M0 scaffold: it boots, reports the board profile and PSRAM, and defines the -// module seams the later milestones fill in (see state/TODO.md and state/ARCHITECTURE.md). -// No audio/WiFi/BLE yet - those land in M1-M4 so each is a reviewable feature branch. +// M1 (recording core): boots audio + storage, then records to a WAV file on the microSD +// card, toggled by the button, with the status LED showing idle/recording/error. Each +// recording gets a sidecar JSON metadata file matching the Recording schema in +// api/openapi.yaml. // -// Module seams (added incrementally): -// audio - I2S/PDM mic capture -> PSRAM ring buffer -> WAV encoder (M1) -// storage - microSD FAT files + sidecar JSON metadata (M1) -// recorder - record session state machine, file naming (M1) -// ux - button + LED/haptic (M1) -// power - battery ADC, charge/VBUS detect -> mode switch (M3) -// config - NVS settings (WiFi, upload target, codec, token) (M2/M3) -// net_wifi - WiFi join/reconnect + mDNS (M2) -// api_http - on-device REST server (api/openapi.yaml device paths) (M2) -// uploader - S3/WebDAV upload when powered (M3) -// ble - GATT control + WiFi provisioning (M4) -// ota - firmware update over HTTP (M9) +// Later milestones add the remaining module seams: +// power - battery ADC, charge/VBUS detect -> mode switch (M3) +// config - NVS settings (WiFi, upload target, codec, token) (M2/M3) +// net_wifi - WiFi join/reconnect + mDNS (M2) +// api_http - on-device REST server (api/openapi.yaml) (M2) +// uploader - S3/WebDAV upload when powered (M3) +// ble - GATT control + WiFi provisioning (M4) +// ota - firmware update over HTTP (M9) #include -static const char *FIRMWARE_VERSION = "0.1.0-scaffold"; +#include "recorder.h" +#include "ux.h" + +static const char *FIRMWARE_VERSION = "0.1.0"; void setup() { Serial.begin(115200); delay(300); - Serial.println(); - Serial.printf("OpenScribe firmware %s booting\n", FIRMWARE_VERSION); + Serial.printf("\nOpenScribe firmware %s booting\n", FIRMWARE_VERSION); #if defined(OPENSCRIBE_BOARD_XIAO) - Serial.println("Board profile: XIAO ESP32-S3 (Build A, PDM mic + onboard SD)"); + Serial.println("Board: XIAO ESP32-S3 (PDM mic + onboard SD)"); #else - Serial.println("Board profile: ESP32-S3 dev (Build B, I2S mic + SD module)"); + Serial.println("Board: ESP32-S3 dev (I2S mic + SD module)"); #endif - if (psramFound()) { - Serial.printf("PSRAM: %u bytes free\n", (unsigned)ESP.getFreePsram()); - } else { + if (!psramFound()) { Serial.println("WARNING: no PSRAM detected - audio buffering needs a PSRAM board"); } - // M1 onwards wires the modules here: - // config::begin(); storage::begin(); audio::begin(); ux::begin(); ... + ux::begin(); + + if (!recorder::begin()) { + Serial.println("ERROR: audio or SD init failed - check the mic wiring and SD card"); + ux::setLed(ux::Led::Error); + return; // stay in error state; loop() still services the LED + } + + Serial.println("Ready. Short-press the button to start/stop recording."); + ux::setLed(ux::Led::Idle); } void loop() { - // Placeholder heartbeat until the recorder state machine lands (M1). - delay(1000); -} \ No newline at end of file + // Button: a short press toggles recording. + if (ux::poll()) { + if (recorder::toggle()) { + if (recorder::state() == recorder::State::Recording) { + Serial.printf("Recording started: %s\n", recorder::currentId().c_str()); + ux::setLed(ux::Led::Recording); + } else { + Serial.printf("Recording stopped: %s (%lu PCM bytes)\n", + recorder::currentId().c_str(), + static_cast(recorder::recordedBytes())); + ux::setLed(ux::Led::Idle); + } + } else { + Serial.println("ERROR: could not open recording file on SD"); + ux::setLed(ux::Led::Error); + } + } + + // While recording, drain the mic into the file. + recorder::update(); +} diff --git a/firmware/src/recorder.cpp b/firmware/src/recorder.cpp new file mode 100644 index 0000000..89f8401 --- /dev/null +++ b/firmware/src/recorder.cpp @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "recorder.h" + +#include "audio.h" +#include "audio_config.h" +#include "storage.h" + +namespace { + +recorder::State state_ = recorder::State::Idle; +storage::WavWriter wav; +String currentId_; +uint32_t startMillis_ = 0; + +// Generate a recording id. Without an RTC/NTP the device has no wall-clock time yet, so +// M1 uses an uptime-milliseconds stamp; M2 (WiFi/NTP) upgrades this to a real UTC time in +// the id and the started_at field. Kept sortable and filename-safe either way. +String makeId() { + char buf[40]; + snprintf(buf, sizeof(buf), "rec_up%010lu", static_cast(millis())); + return String(buf); +} + +String wavPath(const String &id) { return String(OSC_REC_DIR) + "/" + id + ".wav"; } +String metaPath(const String &id) { return String(OSC_REC_DIR) + "/" + id + ".json"; } + +// Minimal metadata JSON matching the Recording schema in api/openapi.yaml. Hand-written to +// avoid pulling in a JSON library on the device for a handful of fields. +String metadataJson(const String &id, uint32_t dataBytes, float durationS) { + String s = "{"; + s += "\"id\":\"" + id + "\","; + s += "\"started_at\":null,"; // set once the clock is known (M2/NTP) + s += "\"duration_s\":" + String(durationS, 2) + ","; + s += "\"sample_rate\":" + String(OSC_SAMPLE_RATE) + ","; + s += "\"channels\":" + String(OSC_CHANNELS) + ","; + s += "\"codec\":\"wav_pcm_s16le\","; + s += "\"size_bytes\":" + String(dataBytes + 44) + ","; + s += "\"source\":\"device\","; + s += "\"sync_state\":\"local\""; + s += "}"; + return s; +} + +} // namespace + +namespace recorder { + +bool begin() { + bool ok = storage::begin(); + ok = audio::begin() && ok; + return ok; +} + +bool startRecording() { + if (state_ == State::Recording) return false; + currentId_ = makeId(); + if (!wav.open(wavPath(currentId_), OSC_SAMPLE_RATE, OSC_BITS_PER_SAMPLE, OSC_CHANNELS)) { + return false; + } + startMillis_ = millis(); + state_ = State::Recording; + return true; +} + +bool stopRecording() { + if (state_ != State::Recording) return false; + state_ = State::Idle; + wav.close(); + const float durationS = (millis() - startMillis_) / 1000.0f; + storage::writeMetadata(metaPath(currentId_), + metadataJson(currentId_, wav.dataBytes(), durationS)); + return true; +} + +bool toggle() { return state_ == State::Recording ? stopRecording() : startRecording(); } + +void update() { + if (state_ != State::Recording) return; + static uint8_t buf[OSC_CHUNK_BYTES]; + size_t n = audio::read(buf, sizeof(buf)); + if (n > 0) { + wav.write(buf, n); + } +} + +State state() { return state_; } +const String ¤tId() { return currentId_; } +uint32_t recordedBytes() { return wav.dataBytes(); } + +} // namespace recorder diff --git a/firmware/src/recorder.h b/firmware/src/recorder.h new file mode 100644 index 0000000..5f1fdc9 --- /dev/null +++ b/firmware/src/recorder.h @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Recording session state machine: pumps mic audio into a WAV file on the SD card and +// writes sidecar JSON metadata when a session ends. +#pragma once + +#include + +namespace recorder { + +enum class State { Idle, Recording }; + +// Bring up audio + storage. Returns false if either fails (caller shows an error state). +bool begin(); + +// Call frequently from loop(): when recording, drains the mic into the file. +void update(); + +bool startRecording(); +bool stopRecording(); +bool toggle(); // start if idle, stop if recording + +State state(); +const String ¤tId(); // id of the in-progress/last recording +uint32_t recordedBytes(); // PCM bytes written in the current session + +} // namespace recorder diff --git a/firmware/src/storage.cpp b/firmware/src/storage.cpp new file mode 100644 index 0000000..0f0cbd4 --- /dev/null +++ b/firmware/src/storage.cpp @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "storage.h" + +#include + +#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(&v), 4); }; + auto u16 = [&](uint16_t v) { f.write(reinterpret_cast(&v), 2); }; + + f.write(reinterpret_cast("RIFF"), 4); + u32(riffSize); + f.write(reinterpret_cast("WAVE"), 4); + f.write(reinterpret_cast("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("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(&riffSize), 4); + file_.seek(40); + file_.write(reinterpret_cast(&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 diff --git a/firmware/src/storage.h b/firmware/src/storage.h new file mode 100644 index 0000000..af65659 --- /dev/null +++ b/firmware/src/storage.h @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-only +// microSD storage: a streaming WAV writer plus sidecar JSON metadata. +#pragma once + +#include +#include +#include + +namespace storage { + +// Mount the microSD card over SPI using the pins in pins.h. Creates OSC_REC_DIR. +bool begin(); + +uint64_t totalBytes(); +uint64_t freeBytes(); + +// Streaming writer for a canonical 16-bit PCM WAV file. Writes a placeholder header on +// open(), appends PCM with write(), and patches the RIFF/data sizes on close(). +class WavWriter { + public: + bool open(const String &path, uint32_t sampleRate, uint16_t bitsPerSample, + uint16_t channels); + size_t write(const uint8_t *data, size_t len); + bool close(); + uint32_t dataBytes() const { return dataBytes_; } + bool isOpen() const { return (bool)file_; } + + private: + File file_; + uint32_t dataBytes_ = 0; + uint32_t sampleRate_ = 0; + uint16_t bits_ = 0; + uint16_t channels_ = 0; +}; + +// Write a sidecar JSON metadata file next to a recording. +bool writeMetadata(const String &path, const String &json); + +} // namespace storage diff --git a/firmware/src/ux.cpp b/firmware/src/ux.cpp new file mode 100644 index 0000000..8b8629f --- /dev/null +++ b/firmware/src/ux.cpp @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "ux.h" + +#include "pins.h" + +namespace { + +ux::Led ledState = ux::Led::Off; + +// Button debounce. +const uint32_t DEBOUNCE_MS = 40; +int lastReading = HIGH; // active-low: released reads HIGH +int stableState = HIGH; +uint32_t lastChangeMs = 0; + +// Drive the status LED. A plain LED is on/off; recording blinks, error fast-blinks. +void applyLed() { + bool on = false; + switch (ledState) { + case ux::Led::Off: on = false; break; + case ux::Led::Idle: on = (millis() % 2000) < 60; break; // brief heartbeat + case ux::Led::Recording: on = (millis() % 1000) < 500; break; // slow blink + case ux::Led::Error: on = (millis() % 200) < 100; break; // fast blink + } +#if defined(PIN_LED_ACTIVE_LOW) && PIN_LED_ACTIVE_LOW + digitalWrite(PIN_LED, on ? LOW : HIGH); +#else + digitalWrite(PIN_LED, on ? HIGH : LOW); +#endif +} + +} // namespace + +namespace ux { + +void begin() { + pinMode(PIN_BUTTON, INPUT_PULLUP); + pinMode(PIN_LED, OUTPUT); + setLed(Led::Idle); +} + +bool poll() { + bool shortPress = false; + + int reading = digitalRead(PIN_BUTTON); + if (reading != lastReading) { + lastChangeMs = millis(); + lastReading = reading; + } + if ((millis() - lastChangeMs) > DEBOUNCE_MS && reading != stableState) { + stableState = reading; + // Fire on release (LOW -> HIGH) of a debounced press. + if (stableState == HIGH) { + shortPress = true; + } + } + + applyLed(); + return shortPress; +} + +void setLed(Led state) { ledState = state; } + +} // namespace ux diff --git a/firmware/src/ux.h b/firmware/src/ux.h new file mode 100644 index 0000000..f6c79b7 --- /dev/null +++ b/firmware/src/ux.h @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-only +// User controls: a single button (short press toggles recording) and a status LED. +#pragma once + +#include + +namespace ux { + +// LED patterns for the device states M1 cares about. +enum class Led { Off, Idle, Recording, Error }; + +void begin(); + +// Poll the button (debounced) and refresh the LED. Returns true on a short-press event. +bool poll(); + +void setLed(Led state); + +} // namespace ux diff --git a/state/ARCHITECTURE.md b/state/ARCHITECTURE.md index 98df99a..5fc97b4 100644 --- a/state/ARCHITECTURE.md +++ b/state/ARCHITECTURE.md @@ -33,11 +33,11 @@ Three sync paths, exactly as specified: - Responsibility: capture audio, store it, manage power/controls, expose control + data over BLE and WiFi, and upload autonomously when powered. - Location: `firmware/` (PlatformIO, Arduino-ESP32, target ESP32-S3). -- Modules (planned): - - `audio` - I2S mic driver, DMA capture, PSRAM ring buffer, WAV encoder. - - `storage`- microSD (FAT) recording files + sidecar JSON metadata. - - `recorder` - session state machine (idle/recording), file naming, metadata. - - `ux` - button (start/stop, long-press pair), LED/haptic status. +- Modules (M1 landed: audio, storage, recorder, ux; rest planned per milestone): + - `audio` - I2S/PDM mic capture presenting 16-bit PCM mono (ESP_I2S). [M1] + - `storage`- microSD (FAT) streaming WAV writer + sidecar JSON metadata. [M1] + - `recorder` - session state machine (idle/recording), file naming, metadata. [M1] + - `ux` - button (short-press start/stop) + status LED (haptic later). [M1] - `power` - battery read (ADC), charge/VBUS detect -> mode switch. - `config` - NVS-stored settings (WiFi creds, upload target + keys, codec). - `net_wifi` - WiFi manager (join, reconnect), mDNS. diff --git a/state/NOTES.md b/state/NOTES.md index 323f03f..843bde3 100644 --- a/state/NOTES.md +++ b/state/NOTES.md @@ -42,6 +42,16 @@ Per component (see each component's README for detail): - Do not put object-store credentials or WiFi passwords on the microSD in clear; they live in ESP32 NVS. +## Firmware specifics (M1) + +- I2S/PDM via the core-bundled `ESP_I2S` (`I2SClass`) - no extra lib_deps. Dev board uses + `I2S_MODE_STD` (INMP441/ICS-43434); XIAO uses `I2S_MODE_PDM_RX` (onboard mic). +- SD uses a dedicated HSPI bus (`SPIClass(HSPI)`) so it does not clash with other SPI use. +- WAV writer streams a 44-byte header then patches RIFF/data sizes on close by seeking. +- Recording ids are uptime-based until NTP lands (M2); metadata `started_at` is null until + then. See the follow-ups in TODO.md. +- Not yet compiled locally (no PlatformIO on the dev host) - CI builds both profiles. + ## Dead ends - (none yet) diff --git a/state/TODO.md b/state/TODO.md index 1145792..6823f2d 100644 --- a/state/TODO.md +++ b/state/TODO.md @@ -5,13 +5,13 @@ ## In progress -- [ ] M0 Scaffold - repo, licences, state docs, BOM, OpenAPI, component skeletons, CI - (branch: bootstrap on `main`; this is the setup commit) +- [ ] M1 Firmware recording core - code complete on `feature/fw-recorder-core`, in PR. + I2S/PDM mic capture -> WAV on microSD, button start/stop, LED status, sidecar JSON + metadata. Not yet compiled locally (no PlatformIO here) or hardware-verified; relies + on Forgejo Actions CI to build both board profiles. ## Pending (roughly in build order) -- [ ] M1 Firmware recording core: I2S mic capture -> PSRAM ring buffer -> WAV on microSD, - button start/stop, LED status, sidecar JSON metadata. (branch `feature/fw-recorder-core`) - [ ] M2 On-device WiFi + REST API: WiFi manager, mDNS, list/get/download(range)/delete recordings, device status. Implements `api/openapi.yaml` device paths. (branch `feature/fw-rest-api`) @@ -34,12 +34,22 @@ ## Done +- [x] M0 Scaffold - repo, licences, state docs, BOM, OpenAPI, skeletons, CI + (merged to `main`, 2026-07-03) - [x] Create Forgejo repo `laurence/openscribe` (public, main) - 2026-07-03 - [x] Decide hardware, sync model, AI stack, app platform, storage, licensing (see DECISIONS.md) - 2026-07-03 +## Follow-ups (deferred, do when convenient) + +- [ ] Audio calibration: INMP441 emits 24-bit samples in a 32-bit slot. M1 reads in + 16-bit mode (usable for speech, may be quiet). If levels are low on the dev build, + capture 32-bit and right-shift into int16 in `audio.cpp`. +- [ ] Real timestamps: M1 ids/`started_at` are uptime-based (no RTC/NTP yet). M2 adds NTP + over WiFi; switch ids and `started_at` to real UTC then. + ## Blocked / waiting - [ ] Physical build + on-device verification - waiting on parts from `hardware/BOM.md`. - Until parts arrive, firmware is developed against the ESP32-S3 target and validated - by build + (where possible) native/host unit tests, not on real hardware. + Until parts arrive, firmware is validated by CI build (Forgejo Actions) of both + board profiles, not on real hardware.