M1: Firmware recording core #1

Merged
laurence merged 1 commit from feature/fw-recorder-core into main 2026-07-03 11:36:30 +01:00
14 changed files with 508 additions and 43 deletions

View file

@ -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

View file

@ -5,10 +5,17 @@
#if defined(OPENSCRIBE_BOARD_XIAO) #if defined(OPENSCRIBE_BOARD_XIAO)
// Seeed XIAO ESP32-S3 Sense: onboard PDM mic + microSD use the Sense board's fixed pins. // 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 // The audio driver selects the PDM path for this board.
// user-facing controls only. #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_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_VBUS_SENSE -1 // use USB-serial/VBUS API on XIAO
#define PIN_CHARGE_STAT -1 #define PIN_CHARGE_STAT -1
@ -26,7 +33,8 @@
// Controls / status // Controls / status
#define PIN_BUTTON 7 // active-low, internal pull-up #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_CHARGE_STAT 14 // TP4056 STAT (open-drain)
#define PIN_VBUS_SENSE 15 // USB 5V present -> on-charge / powered mode #define PIN_VBUS_SENSE 15 // USB 5V present -> on-charge / powered mode
#define PIN_MOTOR 16 // optional haptic, via transistor #define PIN_MOTOR 16 // optional haptic, via transistor

43
firmware/src/audio.cpp Normal file
View file

@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-3.0-only
#include "audio.h"
#include <ESP_I2S.h> // 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<char *>(dst), maxBytes);
return n < 0 ? 0 : static_cast<size_t>(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.

22
firmware/src/audio.h Normal file
View file

@ -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 <stddef.h>
#include <stdint.h>
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

View file

@ -2,50 +2,73 @@
// //
// OpenScribe firmware entry point. // OpenScribe firmware entry point.
// //
// This is the M0 scaffold: it boots, reports the board profile and PSRAM, and defines the // M1 (recording core): boots audio + storage, then records to a WAV file on the microSD
// module seams the later milestones fill in (see state/TODO.md and state/ARCHITECTURE.md). // card, toggled by the button, with the status LED showing idle/recording/error. Each
// No audio/WiFi/BLE yet - those land in M1-M4 so each is a reviewable feature branch. // recording gets a sidecar JSON metadata file matching the Recording schema in
// api/openapi.yaml.
// //
// Module seams (added incrementally): // Later milestones add the remaining module seams:
// audio - I2S/PDM mic capture -> PSRAM ring buffer -> WAV encoder (M1) // power - battery ADC, charge/VBUS detect -> mode switch (M3)
// storage - microSD FAT files + sidecar JSON metadata (M1) // config - NVS settings (WiFi, upload target, codec, token) (M2/M3)
// recorder - record session state machine, file naming (M1) // net_wifi - WiFi join/reconnect + mDNS (M2)
// ux - button + LED/haptic (M1) // api_http - on-device REST server (api/openapi.yaml) (M2)
// power - battery ADC, charge/VBUS detect -> mode switch (M3) // uploader - S3/WebDAV upload when powered (M3)
// config - NVS settings (WiFi, upload target, codec, token) (M2/M3) // ble - GATT control + WiFi provisioning (M4)
// net_wifi - WiFi join/reconnect + mDNS (M2) // ota - firmware update over HTTP (M9)
// 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)
#include <Arduino.h> #include <Arduino.h>
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() { void setup() {
Serial.begin(115200); Serial.begin(115200);
delay(300); delay(300);
Serial.println(); Serial.printf("\nOpenScribe firmware %s booting\n", FIRMWARE_VERSION);
Serial.printf("OpenScribe firmware %s booting\n", FIRMWARE_VERSION);
#if defined(OPENSCRIBE_BOARD_XIAO) #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 #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 #endif
if (psramFound()) { if (!psramFound()) {
Serial.printf("PSRAM: %u bytes free\n", (unsigned)ESP.getFreePsram());
} else {
Serial.println("WARNING: no PSRAM detected - audio buffering needs a PSRAM board"); Serial.println("WARNING: no PSRAM detected - audio buffering needs a PSRAM board");
} }
// M1 onwards wires the modules here: ux::begin();
// config::begin(); storage::begin(); audio::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() { void loop() {
// Placeholder heartbeat until the recorder state machine lands (M1). // Button: a short press toggles recording.
delay(1000); 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<unsigned long>(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();
} }

90
firmware/src/recorder.cpp Normal file
View file

@ -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<unsigned long>(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 &currentId() { return currentId_; }
uint32_t recordedBytes() { return wav.dataBytes(); }
} // namespace recorder

26
firmware/src/recorder.h Normal file
View file

@ -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 <Arduino.h>
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 &currentId(); // id of the in-progress/last recording
uint32_t recordedBytes(); // PCM bytes written in the current session
} // namespace recorder

97
firmware/src/storage.cpp Normal file
View 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

39
firmware/src/storage.h Normal file
View file

@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-only
// microSD storage: a streaming WAV writer plus sidecar JSON metadata.
#pragma once
#include <Arduino.h>
#include <FS.h>
#include <SD.h>
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

64
firmware/src/ux.cpp Normal file
View file

@ -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

19
firmware/src/ux.h Normal file
View file

@ -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 <Arduino.h>
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

View file

@ -33,11 +33,11 @@ Three sync paths, exactly as specified:
- Responsibility: capture audio, store it, manage power/controls, expose control + data - Responsibility: capture audio, store it, manage power/controls, expose control + data
over BLE and WiFi, and upload autonomously when powered. over BLE and WiFi, and upload autonomously when powered.
- Location: `firmware/` (PlatformIO, Arduino-ESP32, target ESP32-S3). - Location: `firmware/` (PlatformIO, Arduino-ESP32, target ESP32-S3).
- Modules (planned): - Modules (M1 landed: audio, storage, recorder, ux; rest planned per milestone):
- `audio` - I2S mic driver, DMA capture, PSRAM ring buffer, WAV encoder. - `audio` - I2S/PDM mic capture presenting 16-bit PCM mono (ESP_I2S). [M1]
- `storage`- microSD (FAT) recording files + sidecar JSON metadata. - `storage`- microSD (FAT) streaming WAV writer + sidecar JSON metadata. [M1]
- `recorder` - session state machine (idle/recording), file naming, metadata. - `recorder` - session state machine (idle/recording), file naming, metadata. [M1]
- `ux` - button (start/stop, long-press pair), LED/haptic status. - `ux` - button (short-press start/stop) + status LED (haptic later). [M1]
- `power` - battery read (ADC), charge/VBUS detect -> mode switch. - `power` - battery read (ADC), charge/VBUS detect -> mode switch.
- `config` - NVS-stored settings (WiFi creds, upload target + keys, codec). - `config` - NVS-stored settings (WiFi creds, upload target + keys, codec).
- `net_wifi` - WiFi manager (join, reconnect), mDNS. - `net_wifi` - WiFi manager (join, reconnect), mDNS.

View file

@ -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 - Do not put object-store credentials or WiFi passwords on the microSD in clear; they live
in ESP32 NVS. 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 ## Dead ends
- (none yet) - (none yet)

View file

@ -5,13 +5,13 @@
## In progress ## In progress
- [ ] M0 Scaffold - repo, licences, state docs, BOM, OpenAPI, component skeletons, CI - [ ] M1 Firmware recording core - code complete on `feature/fw-recorder-core`, in PR.
(branch: bootstrap on `main`; this is the setup commit) 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) ## 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 - [ ] M2 On-device WiFi + REST API: WiFi manager, mDNS, list/get/download(range)/delete
recordings, device status. Implements `api/openapi.yaml` device paths. recordings, device status. Implements `api/openapi.yaml` device paths.
(branch `feature/fw-rest-api`) (branch `feature/fw-rest-api`)
@ -34,12 +34,22 @@
## Done ## 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] Create Forgejo repo `laurence/openscribe` (public, main) - 2026-07-03
- [x] Decide hardware, sync model, AI stack, app platform, storage, licensing (see - [x] Decide hardware, sync model, AI stack, app platform, storage, licensing (see
DECISIONS.md) - 2026-07-03 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 ## Blocked / waiting
- [ ] Physical build + on-device verification - waiting on parts from `hardware/BOM.md`. - [ ] 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 Until parts arrive, firmware is validated by CI build (Forgejo Actions) of both
by build + (where possible) native/host unit tests, not on real hardware. board profiles, not on real hardware.