From 582cbf430b7efd56e303bb5b34c68419ced2be17 Mon Sep 17 00:00:00 2001 From: Ricky1207-sen Date: Sun, 21 Jun 2026 22:56:17 +0530 Subject: [PATCH 1/6] =?UTF-8?q?feat(moneo):=20add=20recorder=20=E2=80=94?= =?UTF-8?q?=20single-file=20WAV=20capture=20to=20SD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- moneo/Recorder.cpp | 242 +++++++++++++++++++++++++++++++++++++++++++++ moneo/Recorder.h | 68 +++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 moneo/Recorder.cpp create mode 100644 moneo/Recorder.h diff --git a/moneo/Recorder.cpp b/moneo/Recorder.cpp new file mode 100644 index 0000000..c59260b --- /dev/null +++ b/moneo/Recorder.cpp @@ -0,0 +1,242 @@ +#include "Recorder.h" +#include + +Recorder::Recorder() + : _psramBuf(nullptr), _psramWritten(0), + _dataLength(0), _recording(false), + _toggleRequested(false), _stopWriter(false), + _lastToggleTime(0), _segmentStartMs(0), + _captureTask_h(nullptr), _writerTask_h(nullptr), + _bufMutex(nullptr) +{} + +bool Recorder::begin() { + if (!SD.begin(SD_CARD_PIN)) { + DLOG("[Recorder] SD card mount failed!"); + return false; + } + DLOG("[Recorder] SD card mounted."); + + _psramBuf = (uint8_t*)ps_malloc(PSRAM_BUFFER_SIZE); + if (!_psramBuf) { + DLOG("[Recorder] PSRAM allocation failed!"); + return false; + } + DLOG("[Recorder] PSRAM buffer allocated."); + + _bufMutex = xSemaphoreCreateMutex(); + if (!_bufMutex) { + DLOG("[Recorder] Mutex 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; + } + DLOG("[Recorder] I2S initialized (16kHz, 8-bit)."); + + DLOG("[Recorder] Ready. Touch pin to start."); + return true; +} + +void Recorder::loop() { + if (!_toggleRequested) return; + _toggleRequested = false; + + unsigned long now = millis(); + if (now - _lastToggleTime < DEBOUNCE_DELAY) return; + _lastToggleTime = now; + + if (!_recording) { + _startRecording(); + } else { + _stopRecording(); + } +} + +void Recorder::requestToggle() { + _toggleRequested = true; +} + +// ── Generate datetime filename ───────────────────────────── +String Recorder::_generateFilename() { + // Try to get real time from NTP if available + // Fall back to millis-based name if no time sync + struct tm timeinfo; + if (getLocalTime(&timeinfo, 1000)) { + char buf[32]; + strftime(buf, sizeof(buf), "/rec_%Y%m%d_%H%M%S.wav", &timeinfo); + return String(buf); + } + + // Fallback: use millis + unsigned long ms = millis(); + unsigned long secs = ms / 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); +} + +// ── Start ────────────────────────────────────────────────── +void Recorder::_startRecording() { + DLOG("[Recorder] Starting..."); + _recording = true; + _stopWriter = false; + _dataLength = 0; + _psramWritten = 0; + + // Try NTP time sync + configTime(19800, 0, "pool.ntp.org"); // UTC+5:30 for IST + delay(500); + + _wavPath = _generateFilename(); + DLOGF("[Recorder] File: %s\n", _wavPath.c_str()); + + _wavFile = SD.open(_wavPath.c_str(), FILE_WRITE); + if (!_wavFile) { + DLOG("[Recorder] Cannot create WAV file!"); + _recording = false; + return; + } + + // Write placeholder header (updated on stop) + _writeWavHeader(_wavFile, 0); + + _segmentStartMs = millis(); + digitalWrite(LED_BUILTIN, HIGH); + + xTaskCreatePinnedToCore(_captureTaskEntry, "Capture", 4096, + this, 5, &_captureTask_h, 1); + xTaskCreatePinnedToCore(_writerTaskEntry, "Writer", 8192, + this, 3, &_writerTask_h, 1); + + DLOG("[Recorder] Recording started."); +} + +// ── Stop ─────────────────────────────────────────────────── +void Recorder::_stopRecording() { + DLOG("[Recorder] Stopping..."); + _recording = false; + _stopWriter = true; + + digitalWrite(LED_BUILTIN, LOW); + + // Give tasks time to finish naturally + vTaskDelay(pdMS_TO_TICKS(3000)); + + _captureTask_h = nullptr; + _writerTask_h = nullptr; + + // Flush remaining PSRAM buffer to SD + if (_psramWritten > 0 && _wavFile) { + _wavFile.write(_psramBuf, _psramWritten); + _dataLength += _psramWritten; + _psramWritten = 0; + DLOGF("[Recorder] Final flush: %u bytes\n", _dataLength); + } + + // Finalize WAV header + if (_wavFile) { + _wavFile.seek(0); + _writeWavHeader(_wavFile, _dataLength); + _wavFile.close(); + DLOGF("[Recorder] WAV saved: %s (%u bytes)\n", + _wavPath.c_str(), _dataLength); + } + + DLOG("[Recorder] Stopped."); +} + +// ── Capture task ─────────────────────────────────────────── +void Recorder::_captureTaskEntry(void* arg) { + ((Recorder*)arg)->_captureTask(); +} + +void Recorder::_captureTask() { + uint8_t buf[I2S_BUFFER_SIZE]; + DLOG("[Capture] Task started."); + + while (_recording) { + int avail = _i2s.available(); + if (avail > 0) { + int n = _i2s.readBytes((char*)buf, min(avail, I2S_BUFFER_SIZE)); + if (n > 0) { + xSemaphoreTake(_bufMutex, portMAX_DELAY); + size_t space = PSRAM_BUFFER_SIZE - _psramWritten; + size_t toWrite = min((size_t)n, space); + if (toWrite > 0) { + memcpy(_psramBuf + _psramWritten, buf, toWrite); + _psramWritten += toWrite; + } + xSemaphoreGive(_bufMutex); + } + } + taskYIELD(); + } + + DLOG("[Capture] Task finished."); + vTaskDelete(nullptr); +} + +// ── Writer task ──────────────────────────────────────────── +void Recorder::_writerTaskEntry(void* arg) { + ((Recorder*)arg)->_writerTask(); +} + +void Recorder::_writerTask() { + DLOG("[Writer] Task started."); + + while (!_stopWriter) { + // Every SEGMENT_DURATION_MS, flush PSRAM buffer to SD + if (millis() - _segmentStartMs >= SEGMENT_DURATION_MS) { + xSemaphoreTake(_bufMutex, portMAX_DELAY); + + if (_psramWritten > 0 && _wavFile) { + size_t written = _psramWritten; + _wavFile.write(_psramBuf, written); + _dataLength += written; + _psramWritten = 0; + DLOGF("[Writer] Flushed %u bytes to WAV (total: %u)\n", + written, _dataLength); + } + + xSemaphoreGive(_bufMutex); + _segmentStartMs = millis(); + } + + vTaskDelay(pdMS_TO_TICKS(100)); + } + + DLOG("[Writer] Task finished."); + vTaskDelete(nullptr); +} diff --git a/moneo/Recorder.h b/moneo/Recorder.h new file mode 100644 index 0000000..ee0afe8 --- /dev/null +++ b/moneo/Recorder.h @@ -0,0 +1,68 @@ +#ifndef Recorder_h +#define Recorder_h + +#include +#include +#include +#include +#include +#include +#include "Config.h" + +// ============================================================ +// Recorder — Records audio into a SINGLE WAV file. +// +// Flow: +// Touch start → create dated WAV file +// Every 10s: flush PSRAM buffer segment to WAV file +// Touch stop → finalize WAV header → send to AI +// +// The WAV file grows continuously — one file per session. +// No chunk files. No gateway. File sent directly to AI when done. +// ============================================================ + +class Recorder { +public: + Recorder(); + bool begin(); + void loop(); + void requestToggle(); + + bool isRecording() const { return _recording; } + String lastRecordingPath() const { return _wavPath; } + +private: + void _startRecording(); + void _stopRecording(); + void _writeWavHeader(File& f, uint32_t dataLen); + String _generateFilename(); + + static void _captureTaskEntry(void* arg); + static void _writerTaskEntry(void* arg); + void _captureTask(); + void _writerTask(); + + I2SClass _i2s; + + // PSRAM capture buffer: filled by the capture task, drained by the + // writer task every segment. Access is guarded by _bufMutex. + uint8_t* _psramBuf; + size_t _psramWritten; + + File _wavFile; + String _wavPath; + uint32_t _dataLength; + + volatile bool _recording; + volatile bool _toggleRequested; + volatile bool _stopWriter; + unsigned long _lastToggleTime; + unsigned long _segmentStartMs; + + TaskHandle_t _captureTask_h; + TaskHandle_t _writerTask_h; + + SemaphoreHandle_t _bufMutex; +}; + +#endif From 1818013b16c8428e6db44cd7b990c2fb6269e68b Mon Sep 17 00:00:00 2001 From: Ricky1207-sen Date: Thu, 25 Jun 2026 03:45:59 +0530 Subject: [PATCH 2/6] feat(moneo): open-close recorder, touch reliability, and main sketch --- moneo/Config.h | 4 +- moneo/Recorder.cpp | 77 ++++++++++++++++----------- moneo/Recorder.h | 14 ++--- moneo/moneo.ino | 126 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 37 deletions(-) create mode 100644 moneo/moneo.ino diff --git a/moneo/Config.h b/moneo/Config.h index ad00dcf..6e12bf1 100644 --- a/moneo/Config.h +++ b/moneo/Config.h @@ -12,8 +12,8 @@ const int I2S_BCLK_PIN = 42; const int I2S_LRCLK_PIN = 41; // ─── TOUCH SENSOR ──────────────────────────────────────────── -const int TOUCH_THRESHOLD = 22000; -const unsigned long DEBOUNCE_DELAY = 2000; +const int TOUCH_THRESHOLD = 50000; +const unsigned long DEBOUNCE_DELAY = 5000; // ─── AUDIO SETTINGS ────────────────────────────────────────── const int SAMPLE_RATE = 16000; diff --git a/moneo/Recorder.cpp b/moneo/Recorder.cpp index c59260b..903cadf 100644 --- a/moneo/Recorder.cpp +++ b/moneo/Recorder.cpp @@ -48,6 +48,12 @@ void Recorder::loop() { unsigned long now = millis(); if (now - _lastToggleTime < DEBOUNCE_DELAY) return; + + // Confirm a real touch is still present. The interrupt can fire on brief + // electrical noise; a genuine press is still held when we reach here (reads + // above threshold), while a noise spike has already decayed and is rejected. + if (touchRead(TOUCH_PIN) < TOUCH_THRESHOLD) return; + _lastToggleTime = now; if (!_recording) { @@ -107,6 +113,28 @@ void Recorder::_writeWavHeader(File& f, uint32_t dataLen) { f.write((uint8_t*)&dataLen, 4); } +// ── Append the buffered audio to the WAV file ────────────── +// open → write → close, every segment. Keeping the file closed between +// segments means a power loss can corrupt at most the latest segment, not +// the whole recording. Caller must hold _bufMutex while the capture task is +// running (the stop path calls this after the tasks have exited). +void Recorder::_flushSegment() { + if (_psramWritten == 0) return; + + File f = SD.open(_wavPath.c_str(), FILE_APPEND); + if (!f) { + // Keep the buffer and retry on the next cycle rather than dropping audio. + DLOG("[Writer] Append open failed; will retry next segment."); + return; + } + size_t written = f.write(_psramBuf, _psramWritten); + f.close(); + + _dataLength += written; + _psramWritten = 0; + DLOGF("[Writer] Appended %u bytes (total: %u)\n", written, _dataLength); +} + // ── Start ────────────────────────────────────────────────── void Recorder::_startRecording() { DLOG("[Recorder] Starting..."); @@ -122,15 +150,16 @@ void Recorder::_startRecording() { _wavPath = _generateFilename(); DLOGF("[Recorder] File: %s\n", _wavPath.c_str()); - _wavFile = SD.open(_wavPath.c_str(), FILE_WRITE); - if (!_wavFile) { + // Create the file and lay down a placeholder header, then close it. + // Segments 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!"); _recording = false; return; } - - // Write placeholder header (updated on stop) - _writeWavHeader(_wavFile, 0); + _writeWavHeader(f, 0); + f.close(); _segmentStartMs = millis(); digitalWrite(LED_BUILTIN, HIGH); @@ -151,27 +180,26 @@ void Recorder::_stopRecording() { digitalWrite(LED_BUILTIN, LOW); - // Give tasks time to finish naturally + // Give the capture + writer tasks time to exit. vTaskDelay(pdMS_TO_TICKS(3000)); _captureTask_h = nullptr; _writerTask_h = nullptr; - // Flush remaining PSRAM buffer to SD - if (_psramWritten > 0 && _wavFile) { - _wavFile.write(_psramBuf, _psramWritten); - _dataLength += _psramWritten; - _psramWritten = 0; - DLOGF("[Recorder] Final flush: %u bytes\n", _dataLength); - } + // Append whatever is left in the buffer. The tasks have stopped, so no + // mutex is needed here. + _flushSegment(); - // Finalize WAV header - if (_wavFile) { - _wavFile.seek(0); - _writeWavHeader(_wavFile, _dataLength); - _wavFile.close(); + // Reopen once to write the real data length into the header (r+ keeps the + // existing audio; it only overwrites the 44-byte header at the start). + 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!"); } DLOG("[Recorder] Stopped."); @@ -217,19 +245,10 @@ void Recorder::_writerTask() { DLOG("[Writer] Task started."); while (!_stopWriter) { - // Every SEGMENT_DURATION_MS, flush PSRAM buffer to SD + // Every SEGMENT_DURATION_MS, append the buffered audio to the WAV file. if (millis() - _segmentStartMs >= SEGMENT_DURATION_MS) { xSemaphoreTake(_bufMutex, portMAX_DELAY); - - if (_psramWritten > 0 && _wavFile) { - size_t written = _psramWritten; - _wavFile.write(_psramBuf, written); - _dataLength += written; - _psramWritten = 0; - DLOGF("[Writer] Flushed %u bytes to WAV (total: %u)\n", - written, _dataLength); - } - + _flushSegment(); xSemaphoreGive(_bufMutex); _segmentStartMs = millis(); } diff --git a/moneo/Recorder.h b/moneo/Recorder.h index ee0afe8..34305c5 100644 --- a/moneo/Recorder.h +++ b/moneo/Recorder.h @@ -13,12 +13,14 @@ // Recorder — Records audio into a SINGLE WAV file. // // Flow: -// Touch start → create dated WAV file -// Every 10s: flush PSRAM buffer segment to WAV file -// Touch stop → finalize WAV header → send to AI +// Touch start → create dated WAV file with a placeholder header, close it +// Every 10s: open (append) → write the buffered audio → close +// Touch stop: append the remainder, then reopen once to write the real +// length into the header → send to AI // -// The WAV file grows continuously — one file per session. -// No chunk files. No gateway. File sent directly to AI when done. +// One file per session, no chunk files. The file is kept closed between +// segments, so a power loss can corrupt at most the latest 10s, not the +// whole recording. // ============================================================ class Recorder { @@ -35,6 +37,7 @@ class Recorder { void _startRecording(); void _stopRecording(); void _writeWavHeader(File& f, uint32_t dataLen); + void _flushSegment(); String _generateFilename(); static void _captureTaskEntry(void* arg); @@ -49,7 +52,6 @@ class Recorder { uint8_t* _psramBuf; size_t _psramWritten; - File _wavFile; String _wavPath; uint32_t _dataLength; diff --git a/moneo/moneo.ino b/moneo/moneo.ino new file mode 100644 index 0000000..d95731c --- /dev/null +++ b/moneo/moneo.ino @@ -0,0 +1,126 @@ +// ============================================================ +// MONEO +// moneo.ino — Main Sketch +// +// Board: Seeed Studio XIAO ESP32S3 Sense +// PSRAM: Tools → PSRAM → OPI PSRAM ← MUST ENABLE +// +// Flow: +// Touch D1 → start recording (LED ON) +// Speak for as long as needed (10s segments flushed to SD) +// Touch D1 → stop recording (LED OFF) +// Device connects to WiFi (WiFiMulti picks the strongest known network) +// WAV file is saved on the SD card +// +// NOTE: AI note-generation (AIClient) is not part of this build yet. Those +// lines are commented out and marked TODO; they will be wired in once +// AIClient is added to the repo. +// ============================================================ + +#include "Config.h" +#include "Recorder.h" +#include "WiFiManager.h" +// #include "AIClient.h" // TODO: enable when AIClient is added + +Recorder recorder; +WiFiManager wifiMgr; +// AIClient aiClient; // TODO: enable when AIClient is added + +bool _wasRecording = false; + +void IRAM_ATTR onTouch() { + recorder.requestToggle(); +} + +void setup() { + Serial.begin(115200); + unsigned long t = millis(); + while (!Serial && millis() - t < 500) delay(10); + + Serial.println("╔══════════════════════════════════╗"); + Serial.println("║ MONEO — Starting ║"); + Serial.println("╚══════════════════════════════════╝"); + + pinMode(LED_BUILTIN, OUTPUT); + digitalWrite(LED_BUILTIN, LOW); + + // TODO: detect AI provider once AIClient is integrated + // if (!aiClient.begin()) { + // Serial.println("[FATAL] AI client init failed. Check API key in Config.h"); + // _errorBlink(); + // } + + // Init recorder + if (!recorder.begin()) { + Serial.println("[FATAL] Recorder init failed."); + _errorBlink(); + } + + touchAttachInterrupt(TOUCH_PIN, onTouch, TOUCH_THRESHOLD); + + Serial.println("[Moneo] Ready. Touch pin D1 to start recording."); +} + +void loop() { + recorder.loop(); + + // Detect transition: was recording → stopped + bool nowRecording = recorder.isRecording(); + if (_wasRecording && !nowRecording) { + // Recording just stopped — process it + String wavPath = recorder.lastRecordingPath(); + if (wavPath.length() > 0) { + _processRecording(wavPath); + } + } + _wasRecording = nowRecording; + + delay(10); +} + +void _processRecording(const String& wavPath) { + Serial.println("[Moneo] Recording complete. Connecting to WiFi..."); + + // Blink LED while connecting + digitalWrite(LED_BUILTIN, HIGH); + delay(200); + digitalWrite(LED_BUILTIN, LOW); + + if (!wifiMgr.connect()) { + Serial.println("[Moneo] WiFi failed."); + Serial.println("[Moneo] WAV file saved locally: " + wavPath); + // Graceful failure — the WAV is safe on the SD card + return; + } + + Serial.println("[Moneo] WiFi connected."); + Serial.printf("[Moneo] File saved: %s\n", wavPath.c_str()); + + // TODO: AIClient integration — send the WAV to the AI and save notes. + // Enabled once AIClient is added to the repo. + // + // String notes = aiClient.generateNotes(wavPath); + // if (notes.isEmpty()) { + // Serial.println("[Moneo] AI returned no notes."); + // return; + // } + // String notesPath = wavPath; + // notesPath.replace(".wav", ".md"); // e.g. rec_….wav → rec_….md + // File f = SD.open(notesPath.c_str(), FILE_WRITE); + // if (f) { + // f.print(notes); + // f.close(); + // Serial.println("[Moneo] ✓ Notes saved: " + notesPath); + // } else { + // Serial.println("[Moneo] Failed to save notes to SD."); + // } + + Serial.println("[Moneo] Done! Touch pin to record again."); +} + +void _errorBlink() { + while (true) { + digitalWrite(LED_BUILTIN, HIGH); delay(200); + digitalWrite(LED_BUILTIN, LOW); delay(200); + } +} From ccfba5cfe358fa745056b733762cd6c721eaa529 Mon Sep 17 00:00:00 2001 From: Ricky1207-sen Date: Mon, 29 Jun 2026 13:05:19 +0530 Subject: [PATCH 3/6] fix(moneo): fail-loud on write errors, IRAM_ATTR touch ISR, header includes, log/serial cleanups --- moneo/Config.h | 3 ++ moneo/Recorder.cpp | 68 +++++++++++++++++++++++++++++++++++----------- moneo/Recorder.h | 7 +++-- moneo/moneo.ino | 13 ++++++--- 4 files changed, 69 insertions(+), 22 deletions(-) diff --git a/moneo/Config.h b/moneo/Config.h index 6e12bf1..8a6faff 100644 --- a/moneo/Config.h +++ b/moneo/Config.h @@ -15,6 +15,9 @@ const int I2S_LRCLK_PIN = 41; const int TOUCH_THRESHOLD = 50000; const unsigned long DEBOUNCE_DELAY = 5000; +// ─── STARTUP ───────────────────────────────────────────────── +const unsigned long SERIAL_WAIT_MS = 100; // max wait for USB serial at boot + // ─── AUDIO SETTINGS ────────────────────────────────────────── const int SAMPLE_RATE = 16000; const int BITS_PER_SAMPLE = 8; // 8-bit audio diff --git a/moneo/Recorder.cpp b/moneo/Recorder.cpp index 903cadf..8af9c18 100644 --- a/moneo/Recorder.cpp +++ b/moneo/Recorder.cpp @@ -1,10 +1,12 @@ #include "Recorder.h" +#include +#include #include Recorder::Recorder() : _psramBuf(nullptr), _psramWritten(0), _dataLength(0), _recording(false), - _toggleRequested(false), _stopWriter(false), + _toggleRequested(false), _stopWriter(false), _writeError(false), _lastToggleTime(0), _segmentStartMs(0), _captureTask_h(nullptr), _writerTask_h(nullptr), _bufMutex(nullptr) @@ -36,7 +38,8 @@ bool Recorder::begin() { DLOG("[Recorder] I2S init failed!"); return false; } - DLOG("[Recorder] I2S initialized (16kHz, 8-bit)."); + DLOGF("[Recorder] I2S initialized (%d Hz, %d-bit).\n", + SAMPLE_RATE, BITS_PER_SAMPLE); DLOG("[Recorder] Ready. Touch pin to start."); return true; @@ -63,7 +66,7 @@ void Recorder::loop() { } } -void Recorder::requestToggle() { +void IRAM_ATTR Recorder::requestToggle() { _toggleRequested = true; } @@ -118,21 +121,33 @@ void Recorder::_writeWavHeader(File& f, uint32_t dataLen) { // segments means a power loss can corrupt at most the latest segment, not // the whole recording. Caller must hold _bufMutex while the capture task is // running (the stop path calls this after the tasks have exited). -void Recorder::_flushSegment() { - if (_psramWritten == 0) return; +// +// Returns true only if the whole buffer was written. On any failure the caller +// must abort the recording — a failed or partial append would leave gaps and +// produce a corrupt WAV, which is worse than a clean failure. +bool Recorder::_flushSegment() { + if (_psramWritten == 0) return true; File f = SD.open(_wavPath.c_str(), FILE_APPEND); if (!f) { - // Keep the buffer and retry on the next cycle rather than dropping audio. - DLOG("[Writer] Append open failed; will retry next segment."); - return; + DLOG("[Writer] ERROR: cannot open WAV to append segment."); + return false; } - size_t written = f.write(_psramBuf, _psramWritten); + + size_t toWrite = _psramWritten; + size_t written = f.write(_psramBuf, toWrite); f.close(); _dataLength += written; _psramWritten = 0; + + if (written != toWrite) { + DLOGF("[Writer] ERROR: short write (%u of %u bytes).\n", written, toWrite); + return false; + } + DLOGF("[Writer] Appended %u bytes (total: %u)\n", written, _dataLength); + return true; } // ── Start ────────────────────────────────────────────────── @@ -140,6 +155,7 @@ void Recorder::_startRecording() { DLOG("[Recorder] Starting..."); _recording = true; _stopWriter = false; + _writeError = false; _dataLength = 0; _psramWritten = 0; @@ -187,11 +203,21 @@ void Recorder::_stopRecording() { _writerTask_h = nullptr; // Append whatever is left in the buffer. The tasks have stopped, so no - // mutex is needed here. - _flushSegment(); + // mutex is needed here. If it fails, flag the error so the caller knows the + // file is incomplete. + if (!_flushSegment()) { + _writeError = true; + } - // Reopen once to write the real data length into the header (r+ keeps the - // existing audio; it only overwrites the 44-byte header at the start). + _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); @@ -201,8 +227,6 @@ void Recorder::_stopRecording() { } else { DLOG("[Recorder] Could not reopen WAV to finalize header!"); } - - DLOG("[Recorder] Stopped."); } // ── Capture task ─────────────────────────────────────────── @@ -248,9 +272,21 @@ void Recorder::_writerTask() { // Every SEGMENT_DURATION_MS, append the buffered audio to the WAV file. if (millis() - _segmentStartMs >= SEGMENT_DURATION_MS) { xSemaphoreTake(_bufMutex, portMAX_DELAY); - _flushSegment(); + bool ok = _flushSegment(); xSemaphoreGive(_bufMutex); _segmentStartMs = millis(); + + if (!ok) { + // A failed write would leave a corrupt/gappy file. Abort: stop + // capture, finalize what we have, and flag the error so the + // main sketch can signal it (blink). + DLOG("[Writer] Aborting recording due to write failure."); + _writeError = true; + _recording = false; + _stopWriter = true; + _finalizeHeader(); + break; + } } vTaskDelay(pdMS_TO_TICKS(100)); diff --git a/moneo/Recorder.h b/moneo/Recorder.h index 34305c5..fb782b2 100644 --- a/moneo/Recorder.h +++ b/moneo/Recorder.h @@ -28,16 +28,18 @@ class Recorder { Recorder(); bool begin(); void loop(); - void requestToggle(); + void IRAM_ATTR requestToggle(); bool isRecording() const { return _recording; } + bool hasError() const { return _writeError; } String lastRecordingPath() const { return _wavPath; } private: void _startRecording(); void _stopRecording(); void _writeWavHeader(File& f, uint32_t dataLen); - void _flushSegment(); + void _finalizeHeader(); + bool _flushSegment(); String _generateFilename(); static void _captureTaskEntry(void* arg); @@ -58,6 +60,7 @@ class Recorder { volatile bool _recording; volatile bool _toggleRequested; volatile bool _stopWriter; + volatile bool _writeError; unsigned long _lastToggleTime; unsigned long _segmentStartMs; diff --git a/moneo/moneo.ino b/moneo/moneo.ino index d95731c..5fdfb11 100644 --- a/moneo/moneo.ino +++ b/moneo/moneo.ino @@ -34,8 +34,8 @@ void IRAM_ATTR onTouch() { void setup() { Serial.begin(115200); - unsigned long t = millis(); - while (!Serial && millis() - t < 500) delay(10); + unsigned long serialTimeout = millis() + SERIAL_WAIT_MS; + while (!Serial && millis() < serialTimeout) { delay(10); } Serial.println("╔══════════════════════════════════╗"); Serial.println("║ MONEO — Starting ║"); @@ -57,8 +57,7 @@ void setup() { } touchAttachInterrupt(TOUCH_PIN, onTouch, TOUCH_THRESHOLD); - - Serial.println("[Moneo] Ready. Touch pin D1 to start recording."); + // recorder.begin() already logged the "ready / touch to start" message. } void loop() { @@ -67,6 +66,12 @@ void loop() { // Detect transition: was recording → stopped bool nowRecording = recorder.isRecording(); if (_wasRecording && !nowRecording) { + if (recorder.hasError()) { + // Recording failed mid-way (SD write error). The partial file was + // finalized; signal the failure instead of treating it as success. + Serial.println("[Moneo] Recording FAILED (SD write error). File may be incomplete."); + _errorBlink(); // halts here, blinking the LED + } // Recording just stopped — process it String wavPath = recorder.lastRecordingPath(); if (wavPath.length() > 0) { From d60851109dd97af6a652c2d138aa7d80aeb0e757 Mon Sep 17 00:00:00 2001 From: Ricky1207-sen Date: Wed, 8 Jul 2026 23:07:04 +0530 Subject: [PATCH 4/6] fix(moneo): correct active-low status LED polarity --- moneo/Config.h | 6 ++++++ moneo/Recorder.cpp | 4 ++-- moneo/moneo.ino | 10 +++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/moneo/Config.h b/moneo/Config.h index 8a6faff..8324ecc 100644 --- a/moneo/Config.h +++ b/moneo/Config.h @@ -11,6 +11,12 @@ 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 = 50000; const unsigned long DEBOUNCE_DELAY = 5000; diff --git a/moneo/Recorder.cpp b/moneo/Recorder.cpp index 8af9c18..a6a8a23 100644 --- a/moneo/Recorder.cpp +++ b/moneo/Recorder.cpp @@ -178,7 +178,7 @@ void Recorder::_startRecording() { f.close(); _segmentStartMs = millis(); - digitalWrite(LED_BUILTIN, HIGH); + digitalWrite(LED_BUILTIN, LED_ON); xTaskCreatePinnedToCore(_captureTaskEntry, "Capture", 4096, this, 5, &_captureTask_h, 1); @@ -194,7 +194,7 @@ void Recorder::_stopRecording() { _recording = false; _stopWriter = true; - digitalWrite(LED_BUILTIN, LOW); + digitalWrite(LED_BUILTIN, LED_OFF); // Give the capture + writer tasks time to exit. vTaskDelay(pdMS_TO_TICKS(3000)); diff --git a/moneo/moneo.ino b/moneo/moneo.ino index 5fdfb11..0443fa1 100644 --- a/moneo/moneo.ino +++ b/moneo/moneo.ino @@ -42,7 +42,7 @@ void setup() { Serial.println("╚══════════════════════════════════╝"); pinMode(LED_BUILTIN, OUTPUT); - digitalWrite(LED_BUILTIN, LOW); + digitalWrite(LED_BUILTIN, LED_OFF); // TODO: detect AI provider once AIClient is integrated // if (!aiClient.begin()) { @@ -87,9 +87,9 @@ void _processRecording(const String& wavPath) { Serial.println("[Moneo] Recording complete. Connecting to WiFi..."); // Blink LED while connecting - digitalWrite(LED_BUILTIN, HIGH); + digitalWrite(LED_BUILTIN, LED_ON); delay(200); - digitalWrite(LED_BUILTIN, LOW); + digitalWrite(LED_BUILTIN, LED_OFF); if (!wifiMgr.connect()) { Serial.println("[Moneo] WiFi failed."); @@ -125,7 +125,7 @@ void _processRecording(const String& wavPath) { void _errorBlink() { while (true) { - digitalWrite(LED_BUILTIN, HIGH); delay(200); - digitalWrite(LED_BUILTIN, LOW); delay(200); + digitalWrite(LED_BUILTIN, LED_ON); delay(200); + digitalWrite(LED_BUILTIN, LED_OFF); delay(200); } } From abc58a51929a1c4bcb19c491c89111af670531b2 Mon Sep 17 00:00:00 2001 From: Ricky1207-sen Date: Wed, 8 Jul 2026 23:07:25 +0530 Subject: [PATCH 5/6] refactor(moneo): gap-free capture via ping-pong double buffer --- moneo/Recorder.cpp | 181 +++++++++++++++++++++------------------------ moneo/Recorder.h | 45 +++++------ 2 files changed, 109 insertions(+), 117 deletions(-) diff --git a/moneo/Recorder.cpp b/moneo/Recorder.cpp index a6a8a23..8dd9494 100644 --- a/moneo/Recorder.cpp +++ b/moneo/Recorder.cpp @@ -1,16 +1,19 @@ #include "Recorder.h" -#include -#include #include +// 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() - : _psramBuf(nullptr), _psramWritten(0), - _dataLength(0), _recording(false), - _toggleRequested(false), _stopWriter(false), _writeError(false), - _lastToggleTime(0), _segmentStartMs(0), - _captureTask_h(nullptr), _writerTask_h(nullptr), - _bufMutex(nullptr) -{} + : _activeBuf(0), _dataLength(0), + _recording(false), _toggleRequested(false), _writeError(false), + _lastToggleTime(0), + _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)) { @@ -19,16 +22,19 @@ bool Recorder::begin() { } DLOG("[Recorder] SD card mounted."); - _psramBuf = (uint8_t*)ps_malloc(PSRAM_BUFFER_SIZE); - if (!_psramBuf) { + // 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 buffer allocated."); + DLOG("[Recorder] PSRAM buffers allocated (x2)."); - _bufMutex = xSemaphoreCreateMutex(); - if (!_bufMutex) { - DLOG("[Recorder] Mutex creation failed!"); + _flushQueue = xQueueCreate(4, sizeof(int)); + _writerDone = xSemaphoreCreateBinary(); + if (!_flushQueue || !_writerDone) { + DLOG("[Recorder] Queue/semaphore creation failed!"); return false; } @@ -116,48 +122,44 @@ void Recorder::_writeWavHeader(File& f, uint32_t dataLen) { f.write((uint8_t*)&dataLen, 4); } -// ── Append the buffered audio to the WAV file ────────────── -// open → write → close, every segment. Keeping the file closed between -// segments means a power loss can corrupt at most the latest segment, not -// the whole recording. Caller must hold _bufMutex while the capture task is -// running (the stop path calls this after the tasks have exited). +// ── 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. On any failure the caller -// must abort the recording — a failed or partial append would leave gaps and -// produce a corrupt WAV, which is worse than a clean failure. -bool Recorder::_flushSegment() { - if (_psramWritten == 0) return true; +// 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 segment."); + DLOG("[Writer] ERROR: cannot open WAV to append."); return false; } - size_t toWrite = _psramWritten; - size_t written = f.write(_psramBuf, toWrite); + size_t written = f.write(data, len); f.close(); - _dataLength += written; - _psramWritten = 0; + _dataLength += written; - if (written != toWrite) { - DLOGF("[Writer] ERROR: short write (%u of %u bytes).\n", written, toWrite); + if (written != len) { + DLOGF("[Writer] ERROR: short write (%u of %u bytes).\n", written, len); return false; } - DLOGF("[Writer] Appended %u bytes (total: %u)\n", written, _dataLength); + DLOGF("[Writer] Saved %u bytes (total: %u)\n", written, _dataLength); return true; } // ── Start ────────────────────────────────────────────────── void Recorder::_startRecording() { DLOG("[Recorder] Starting..."); - _recording = true; - _stopWriter = false; - _writeError = false; - _dataLength = 0; - _psramWritten = 0; + _writeError = false; + _dataLength = 0; + _activeBuf = 0; + _bufFill[0] = 0; + _bufFill[1] = 0; // Try NTP time sync configTime(19800, 0, "pool.ntp.org"); // UTC+5:30 for IST @@ -167,23 +169,20 @@ void Recorder::_startRecording() { DLOGF("[Recorder] File: %s\n", _wavPath.c_str()); // Create the file and lay down a placeholder header, then close it. - // Segments are appended afterwards; the real length is written on stop. + // 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!"); - _recording = false; return; } _writeWavHeader(f, 0); f.close(); - _segmentStartMs = millis(); + _recording = true; digitalWrite(LED_BUILTIN, LED_ON); - xTaskCreatePinnedToCore(_captureTaskEntry, "Capture", 4096, - this, 5, &_captureTask_h, 1); - xTaskCreatePinnedToCore(_writerTaskEntry, "Writer", 8192, - this, 3, &_writerTask_h, 1); + xTaskCreatePinnedToCore(_captureTaskEntry, "Capture", 4096, this, 5, nullptr, 1); + xTaskCreatePinnedToCore(_writerTaskEntry, "Writer", 8192, this, 3, nullptr, 1); DLOG("[Recorder] Recording started."); } @@ -191,22 +190,14 @@ void Recorder::_startRecording() { // ── Stop ─────────────────────────────────────────────────── void Recorder::_stopRecording() { DLOG("[Recorder] Stopping..."); - _recording = false; - _stopWriter = true; - + _recording = false; // capture finishes its current chunk, then exits digitalWrite(LED_BUILTIN, LED_OFF); - // Give the capture + writer tasks time to exit. - vTaskDelay(pdMS_TO_TICKS(3000)); - - _captureTask_h = nullptr; - _writerTask_h = nullptr; - - // Append whatever is left in the buffer. The tasks have stopped, so no - // mutex is needed here. If it fails, flag the error so the caller knows the - // file is incomplete. - if (!_flushSegment()) { - _writeError = true; + // 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(); @@ -235,27 +226,36 @@ void Recorder::_captureTaskEntry(void* arg) { } void Recorder::_captureTask() { - uint8_t buf[I2S_BUFFER_SIZE]; DLOG("[Capture] Task started."); while (_recording) { - int avail = _i2s.available(); - if (avail > 0) { - int n = _i2s.readBytes((char*)buf, min(avail, I2S_BUFFER_SIZE)); - if (n > 0) { - xSemaphoreTake(_bufMutex, portMAX_DELAY); - size_t space = PSRAM_BUFFER_SIZE - _psramWritten; - size_t toWrite = min((size_t)n, space); - if (toWrite > 0) { - memcpy(_psramBuf + _psramWritten, buf, toWrite); - _psramWritten += toWrite; - } - xSemaphoreGive(_bufMutex); - } + 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); } - taskYIELD(); } + // 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); } @@ -268,30 +268,21 @@ void Recorder::_writerTaskEntry(void* arg) { void Recorder::_writerTask() { DLOG("[Writer] Task started."); - while (!_stopWriter) { - // Every SEGMENT_DURATION_MS, append the buffered audio to the WAV file. - if (millis() - _segmentStartMs >= SEGMENT_DURATION_MS) { - xSemaphoreTake(_bufMutex, portMAX_DELAY); - bool ok = _flushSegment(); - xSemaphoreGive(_bufMutex); - _segmentStartMs = millis(); - - if (!ok) { - // A failed write would leave a corrupt/gappy file. Abort: stop - // capture, finalize what we have, and flag the error so the - // main sketch can signal it (blink). - DLOG("[Writer] Aborting recording due to write failure."); - _writeError = true; - _recording = false; - _stopWriter = true; - _finalizeHeader(); - break; - } + 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; } - - vTaskDelay(pdMS_TO_TICKS(100)); } DLOG("[Writer] Task finished."); + xSemaphoreGive(_writerDone); // let _stopRecording() proceed vTaskDelete(nullptr); } diff --git a/moneo/Recorder.h b/moneo/Recorder.h index fb782b2..2a6e861 100644 --- a/moneo/Recorder.h +++ b/moneo/Recorder.h @@ -7,20 +7,23 @@ #include #include #include +#include +#include #include "Config.h" // ============================================================ // Recorder — Records audio into a SINGLE WAV file. // -// Flow: -// Touch start → create dated WAV file with a placeholder header, close it -// Every 10s: open (append) → write the buffered audio → close -// Touch stop: append the remainder, then reopen once to write the real -// length into the header → send to AI +// 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, no chunk files. The file is kept closed between -// segments, so a power loss can corrupt at most the latest 10s, not the -// whole recording. +// 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 { @@ -39,7 +42,7 @@ class Recorder { void _stopRecording(); void _writeWavHeader(File& f, uint32_t dataLen); void _finalizeHeader(); - bool _flushSegment(); + bool _writeBufferToSD(uint8_t* data, size_t len); String _generateFilename(); static void _captureTaskEntry(void* arg); @@ -47,27 +50,25 @@ class Recorder { void _captureTask(); void _writerTask(); - I2SClass _i2s; + I2SClass _i2s; - // PSRAM capture buffer: filled by the capture task, drained by the - // writer task every segment. Access is guarded by _bufMutex. - uint8_t* _psramBuf; - size_t _psramWritten; + // 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; + String _wavPath; + uint32_t _dataLength; volatile bool _recording; volatile bool _toggleRequested; - volatile bool _stopWriter; volatile bool _writeError; unsigned long _lastToggleTime; - unsigned long _segmentStartMs; - TaskHandle_t _captureTask_h; - TaskHandle_t _writerTask_h; - - SemaphoreHandle_t _bufMutex; + QueueHandle_t _flushQueue; // capture → writer: "buffer N is ready to save" + SemaphoreHandle_t _writerDone; // writer → stop path: "I've drained and exited" }; #endif From bfb456991276226f94af52f6f85bc13efb72bf96 Mon Sep 17 00:00:00 2001 From: Ricky1207-sen Date: Wed, 8 Jul 2026 23:07:49 +0530 Subject: [PATCH 6/6] fix(moneo): shorten touch debounce 5s to 1s --- moneo/Config.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moneo/Config.h b/moneo/Config.h index 8324ecc..b55e9bb 100644 --- a/moneo/Config.h +++ b/moneo/Config.h @@ -19,7 +19,7 @@ const int I2S_LRCLK_PIN = 41; // ─── TOUCH SENSOR ──────────────────────────────────────────── const int TOUCH_THRESHOLD = 50000; -const unsigned long DEBOUNCE_DELAY = 5000; +const unsigned long DEBOUNCE_DELAY = 1000; // ─── STARTUP ───────────────────────────────────────────────── const unsigned long SERIAL_WAIT_MS = 100; // max wait for USB serial at boot