From 582cbf430b7efd56e303bb5b34c68419ced2be17 Mon Sep 17 00:00:00 2001 From: Ricky1207-sen Date: Sun, 21 Jun 2026 22:56:17 +0530 Subject: [PATCH 1/3] =?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/3] 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/3] 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) {