Compare commits
2 commits
031074c9a9
...
cfa81a8526
| Author | SHA1 | Date | |
|---|---|---|---|
| cfa81a8526 | |||
| 054b2f6981 |
14 changed files with 508 additions and 43 deletions
14
firmware/include/audio_config.h
Normal file
14
firmware/include/audio_config.h
Normal 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
|
||||||
|
|
@ -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
43
firmware/src/audio.cpp
Normal 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
22
firmware/src/audio.h
Normal 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
|
||||||
|
|
@ -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
90
firmware/src/recorder.cpp
Normal 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 ¤tId() { return currentId_; }
|
||||||
|
uint32_t recordedBytes() { return wav.dataBytes(); }
|
||||||
|
|
||||||
|
} // namespace recorder
|
||||||
26
firmware/src/recorder.h
Normal file
26
firmware/src/recorder.h
Normal 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 ¤tId(); // 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
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
|
||||||
39
firmware/src/storage.h
Normal file
39
firmware/src/storage.h
Normal 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
64
firmware/src/ux.cpp
Normal 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
19
firmware/src/ux.h
Normal 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
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue