Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions moneo/Config.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,18 @@ const int SD_CARD_PIN = 21;
const int I2S_BCLK_PIN = 42;
const int I2S_LRCLK_PIN = 41;

// ─── STATUS LED ──────────────────────────────────────────────
// The XIAO ESP32-S3 user LED is active-LOW: driving the pin LOW lights it,
// HIGH turns it off. Use these names so the polarity is never guessed again.
#define LED_ON LOW
#define LED_OFF HIGH

// ─── TOUCH SENSOR ────────────────────────────────────────────
const int TOUCH_THRESHOLD = 22000;
const unsigned long DEBOUNCE_DELAY = 2000;
const int TOUCH_THRESHOLD = 50000;
const unsigned long DEBOUNCE_DELAY = 1000;

// ─── STARTUP ─────────────────────────────────────────────────
const unsigned long SERIAL_WAIT_MS = 100; // max wait for USB serial at boot

// ─── AUDIO SETTINGS ──────────────────────────────────────────
const int SAMPLE_RATE = 16000;
Expand All @@ -22,10 +31,18 @@ const int NUM_CHANNELS = 1;
const int I2S_BUFFER_SIZE = 512;

// ─── RECORDING SETTINGS ──────────────────────────────────────
// Each segment is buffered in PSRAM then appended to the single WAV file
const unsigned long SEGMENT_DURATION_MS = 10000; // 10 seconds per segment
// Audio is captured into PSRAM buffers, then appended to the single WAV file.
// The buffer size sets how much audio each save holds (a full buffer = one
// save), so it also determines the segment length.
const size_t PSRAM_BUFFER_SIZE = 160000; // 10s * 16000Hz * 1 byte = 160KB

// ─── TIME / NTP ──────────────────────────────────────────────
// Clock is synced once at boot (after WiFi). The ESP32's internal RTC keeps
// time on its own afterwards, so no repeat NTP requests are needed.
#define NTP_SERVER "pool.ntp.org"
const long GMT_OFFSET_SEC = 19800; // UTC+5:30 (IST)
const int DAYLIGHT_OFFSET_SEC = 0;

// ─── WIFI — add as many networks as needed ───────────────────
// Format: { "SSID", "PASSWORD" }
// Device tries each in order, connects to first available
Expand Down
258 changes: 258 additions & 0 deletions moneo/Recorder.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
#include "Recorder.h"
#include <SD.h>
#include <FS.h>
#include <time.h>

// Sentinel index posted to _flushQueue to tell the writer "no more buffers,
// finish and exit." Real buffer indexes are only ever 0 or 1.
static const int FLUSH_STOP = -1;

Recorder::Recorder()
: _activeBuf(0), _dataLength(0),
_recording(false), _writeError(false),
_flushQueue(nullptr), _writerDone(nullptr)
{
_buf[0] = nullptr; _buf[1] = nullptr;
_bufFill[0] = 0; _bufFill[1] = 0;
}

bool Recorder::begin() {
if (!SD.begin(SD_CARD_PIN)) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would work as SD.h etc. are included in the Recorder.h file, but the best practice is to include the headers where they're being used... so in this file.

Not a blocker/must.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added #include <SD.h> and #include <FS.h> directly in Recorder.cpp since they're used here.

DLOG("[Recorder] SD card mount failed!");
return false;
}
DLOG("[Recorder] SD card mounted.");

// Two ping-pong buffers (2 x 160KB in PSRAM — trivial for the 8MB PSRAM).
_buf[0] = (uint8_t*)ps_malloc(PSRAM_BUFFER_SIZE);
_buf[1] = (uint8_t*)ps_malloc(PSRAM_BUFFER_SIZE);
if (!_buf[0] || !_buf[1]) {
DLOG("[Recorder] PSRAM allocation failed!");
return false;
}
DLOG("[Recorder] PSRAM buffers allocated (x2).");

_flushQueue = xQueueCreate(4, sizeof(int));
_writerDone = xSemaphoreCreateBinary();
if (!_flushQueue || !_writerDone) {
DLOG("[Recorder] Queue/semaphore creation failed!");
return false;
}

_i2s.setPinsPdmRx(I2S_BCLK_PIN, I2S_LRCLK_PIN);
if (!_i2s.begin(I2S_MODE_PDM_RX, SAMPLE_RATE,
I2S_DATA_BIT_WIDTH_8BIT, I2S_SLOT_MODE_MONO)) {
DLOG("[Recorder] I2S init failed!");
return false;
}
DLOGF("[Recorder] I2S initialized (%d Hz, %d-bit).\n",
SAMPLE_RATE, BITS_PER_SAMPLE);

DLOG("[Recorder] Ready. Touch pin to start.");
return true;
}

// The main sketch owns the clock (synced once at boot) and hands us the current
// time, so we make no network request here.
extern time_t currentEpoch();

// ── Generate datetime filename ─────────────────────────────
String Recorder::_generateFilename() {
time_t now = currentEpoch();
if (now > 0) {
struct tm* lt = localtime(&now);
char buf[32];
strftime(buf, sizeof(buf), "/rec_%Y%m%d_%H%M%S.wav", lt);
return String(buf);
}

// Fallback: clock never synced — use an uptime-based name.
unsigned long secs = millis() / 1000;
char buf[32];
snprintf(buf, sizeof(buf), "/rec_%05lu.wav", secs);
return String(buf);
}

// ── WAV header ─────────────────────────────────────────────
void Recorder::_writeWavHeader(File& f, uint32_t dataLen) {
uint32_t sampleRate = SAMPLE_RATE;
uint16_t bitsPerSamp = BITS_PER_SAMPLE;
uint16_t numChan = NUM_CHANNELS;
uint32_t byteRate = sampleRate * numChan * bitsPerSamp / 8;
uint16_t blockAlign = numChan * bitsPerSamp / 8;
uint32_t chunkSize = dataLen + 36;
uint32_t subChunk1 = 16;
uint16_t audioFormat = 1;

f.seek(0);
f.write((const uint8_t*)"RIFF", 4);
f.write((uint8_t*)&chunkSize, 4);
f.write((const uint8_t*)"WAVE", 4);
f.write((const uint8_t*)"fmt ", 4);
f.write((uint8_t*)&subChunk1, 4);
f.write((uint8_t*)&audioFormat, 2);
f.write((uint8_t*)&numChan, 2);
f.write((uint8_t*)&sampleRate, 4);
f.write((uint8_t*)&byteRate, 4);
f.write((uint8_t*)&blockAlign, 2);
f.write((uint8_t*)&bitsPerSamp, 2);
f.write((const uint8_t*)"data", 4);
f.write((uint8_t*)&dataLen, 4);
}

// ── Append one full buffer to the WAV file ─────────────────
// open → write → close, once per buffer. Keeping the file closed between
// writes means a power loss can corrupt at most the latest buffer.
//
// Returns true only if the whole buffer was written. Called only by the writer
// task, and only for a buffer the capture task is no longer touching, so no
// lock is needed.
bool Recorder::_writeBufferToSD(uint8_t* data, size_t len) {
if (len == 0) return true;

File f = SD.open(_wavPath.c_str(), FILE_APPEND);
if (!f) {
DLOG("[Writer] ERROR: cannot open WAV to append.");
return false;
}

size_t written = f.write(data, len);
f.close();

_dataLength += written;

if (written != len) {
DLOGF("[Writer] ERROR: short write (%u of %u bytes).\n", written, len);
return false;
}

DLOGF("[Writer] Saved %u bytes (total: %u)\n", written, _dataLength);
return true;
}

// ── Start ──────────────────────────────────────────────────
void Recorder::startRecording() {
DLOG("[Recorder] Starting...");
_writeError = false;
_dataLength = 0;
_activeBuf = 0;
_bufFill[0] = 0;
_bufFill[1] = 0;

// Clock was already synced once at boot (see _syncTime in the main sketch),
// so _generateFilename() reads the real time straight from the RTC here.
_wavPath = _generateFilename();
DLOGF("[Recorder] File: %s\n", _wavPath.c_str());

// Create the file and lay down a placeholder header, then close it.
// Buffers are appended afterwards; the real length is written on stop.
File f = SD.open(_wavPath.c_str(), FILE_WRITE);
if (!f) {
DLOG("[Recorder] Cannot create WAV file!");
return;
}
_writeWavHeader(f, 0);
f.close();

_recording = true;
digitalWrite(LED_BUILTIN, LED_ON);

xTaskCreatePinnedToCore([](void* arg){ ((Recorder*)arg)->_captureTask(); },
"Capture", 4096, this, 5, nullptr, 1);
xTaskCreatePinnedToCore([](void* arg){ ((Recorder*)arg)->_writerTask(); },
"Writer", 8192, this, 3, nullptr, 1);

DLOG("[Recorder] Recording started.");
}

// ── Stop ───────────────────────────────────────────────────
void Recorder::stopRecording() {
DLOG("[Recorder] Stopping...");
_recording = false; // capture finishes its current chunk, then exits
digitalWrite(LED_BUILTIN, LED_OFF);

// Capture hands over its last partial buffer and posts FLUSH_STOP; the
// writer drains the queue and gives _writerDone. Wait for that exact
// signal instead of a blind fixed delay.
if (xSemaphoreTake(_writerDone, pdMS_TO_TICKS(10000)) != pdTRUE) {
DLOG("[Recorder] Warning: writer did not finish in time.");
}

_finalizeHeader();

DLOG("[Recorder] Stopped.");
}

// ── Finalize the WAV header ─────────────────────────────────
// Reopen the file to write the real data length into the header. r+ keeps the
// existing audio; it only overwrites the 44-byte header at the start.
void Recorder::_finalizeHeader() {
File f = SD.open(_wavPath.c_str(), "r+");
if (f) {
_writeWavHeader(f, _dataLength);
f.close();
DLOGF("[Recorder] WAV saved: %s (%u bytes)\n",
_wavPath.c_str(), _dataLength);
} else {
DLOG("[Recorder] Could not reopen WAV to finalize header!");
}
}

// ── Capture task ───────────────────────────────────────────
void Recorder::_captureTask() {
DLOG("[Capture] Task started.");

while (_recording) {
size_t space = PSRAM_BUFFER_SIZE - _bufFill[_activeBuf];
size_t toRead = min((size_t)I2S_BUFFER_SIZE, space);

// Blocks (sleeps) inside the driver until this chunk of audio arrives.
// No polling, no busy-spin — the mic wakes us when data is ready.
int n = _i2s.readBytes((char*)(_buf[_activeBuf] + _bufFill[_activeBuf]), toRead);
if (n > 0) _bufFill[_activeBuf] += n;

// Buffer full → hand it to the writer and switch to the other buffer
// instantly, so capture never pauses while the full one is saved.
if (_bufFill[_activeBuf] >= PSRAM_BUFFER_SIZE) {
int full = _activeBuf;
_activeBuf = 1 - _activeBuf; // 0<->1
_bufFill[_activeBuf] = 0; // fresh buffer starts empty
xQueueSend(_flushQueue, &full, portMAX_DELAY);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ping-pong swap: capture flips to the other buffer instantly and hands this full one to the writer, so recording never pauses during the SD save.

}
}

// Recording stopped: hand over whatever is left in the current buffer,
// then tell the writer there are no more buffers and it can exit.
if (_bufFill[_activeBuf] > 0) {
int last = _activeBuf;
xQueueSend(_flushQueue, &last, portMAX_DELAY);
}
int stop = FLUSH_STOP;
xQueueSend(_flushQueue, &stop, portMAX_DELAY);

DLOG("[Capture] Task finished.");
vTaskDelete(nullptr);
}

// ── Writer task ────────────────────────────────────────────
void Recorder::_writerTask() {
DLOG("[Writer] Task started.");

while (true) {
int idx;
// Sleeps until capture posts a full buffer (or FLUSH_STOP). No polling.
xQueueReceive(_flushQueue, &idx, portMAX_DELAY);
if (idx == FLUSH_STOP) break;

// Capture is filling the OTHER buffer right now, so this one is ours
// alone — no lock. A failed write flags the error; we keep draining the
// queue so capture never blocks, and the header is still finalized.
if (!_writeBufferToSD(_buf[idx], _bufFill[idx])) {
_writeError = true;
}
}

DLOG("[Writer] Task finished.");
xSemaphoreGive(_writerDone); // let stopRecording() proceed
vTaskDelete(nullptr);
}
68 changes: 68 additions & 0 deletions moneo/Recorder.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#ifndef Recorder_h
#define Recorder_h

#include <Arduino.h>
#include <ESP_I2S.h>
#include <SD.h>
#include <FS.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include "Config.h"

// ============================================================
// Recorder — Records audio into a SINGLE WAV file.
//
// Capture never stops, even while saving (ping-pong / double buffer):
// - Two PSRAM buffers. Capture fills one; when it's full it INSTANTLY
// switches to the other and hands the full one to the writer.
// - The writer saves that buffer to SD while capture keeps filling the
// other. They never touch the same buffer, so there is NO lock.
// - Capture sleeps inside i2s.readBytes() until the mic delivers a chunk
// (no polling, no busy-spin).
//
// One file per session. The file is opened/appended/closed per buffer, so a
// power loss can corrupt at most the latest buffer, not the whole recording.
// ============================================================

class Recorder {
public:
Recorder();
bool begin();
void startRecording();
void stopRecording();

bool isRecording() const { return _recording; }
bool hasError() const { return _writeError; }
String lastRecordingPath() const { return _wavPath; }

private:
void _writeWavHeader(File& f, uint32_t dataLen);
void _finalizeHeader();
bool _writeBufferToSD(uint8_t* data, size_t len);
String _generateFilename();

void _captureTask();
void _writerTask();

I2SClass _i2s;

// Two ping-pong PSRAM buffers. Capture fills _buf[_activeBuf]; the writer
// saves whichever full buffer index arrives on _flushQueue. Never touched
// at the same time → no mutex needed.
uint8_t* _buf[2];
size_t _bufFill[2];
int _activeBuf;

String _wavPath;
uint32_t _dataLength;

volatile bool _recording;
volatile bool _writeError;

QueueHandle_t _flushQueue; // capture → writer: "buffer N is ready to save"
SemaphoreHandle_t _writerDone; // writer → stop path: "I've drained and exited"
};

#endif
Loading