-
Notifications
You must be signed in to change notification settings - Fork 1
feat(moneo): recorder + main sketch (single-file WAV, reliable touch) #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
582cbf4
1818013
ccfba5c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,297 @@ | ||
| #include "Recorder.h" | ||
| #include <SD.h> | ||
| #include <FS.h> | ||
| #include <time.h> | ||
|
|
||
| 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) | ||
| {} | ||
|
|
||
| bool Recorder::begin() { | ||
| if (!SD.begin(SD_CARD_PIN)) { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would work as Not a blocker/must. |
||
| 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; | ||
| } | ||
| DLOGF("[Recorder] I2S initialized (%d Hz, %d-bit).\n", | ||
| SAMPLE_RATE, BITS_PER_SAMPLE); | ||
|
|
||
| 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; | ||
|
|
||
| // 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) { | ||
| _startRecording(); | ||
| } else { | ||
| _stopRecording(); | ||
| } | ||
| } | ||
|
|
||
| void IRAM_ATTR 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); | ||
| } | ||
|
|
||
| // ── 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). | ||
| // | ||
| // 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) { | ||
| DLOG("[Writer] ERROR: cannot open WAV to append segment."); | ||
| return false; | ||
| } | ||
|
|
||
| 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 ────────────────────────────────────────────────── | ||
| void Recorder::_startRecording() { | ||
| DLOG("[Recorder] Starting..."); | ||
| _recording = true; | ||
| _stopWriter = false; | ||
| _writeError = false; | ||
| _dataLength = 0; | ||
| _psramWritten = 0; | ||
|
|
||
| // Try NTP time sync | ||
| configTime(19800, 0, "pool.ntp.org"); // UTC+5:30 for IST | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NTP sync should be in main INO file's setup section (as with any runtime blocking/stalling subroutine), and kept alongside the As it currently is, nothing is even ensuring the WiFi is connected (most likely isn't) so the request would just fail. |
||
| delay(500); | ||
|
|
||
| _wavPath = _generateFilename(); | ||
|
Comment on lines
+162
to
+166
|
||
| 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. | ||
| 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(); | ||
| digitalWrite(LED_BUILTIN, HIGH); | ||
|
|
||
| xTaskCreatePinnedToCore(_captureTaskEntry, "Capture", 4096, | ||
| this, 5, &_captureTask_h, 1); | ||
| xTaskCreatePinnedToCore(_writerTaskEntry, "Writer", 8192, | ||
| this, 3, &_writerTask_h, 1); | ||
|
Comment on lines
+183
to
+186
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is nice to use the second core for compute heavy non-management task. Will have to check how it works out in terms of real world impact. |
||
|
|
||
| DLOG("[Recorder] Recording started."); | ||
| } | ||
|
|
||
| // ── Stop ─────────────────────────────────────────────────── | ||
| void Recorder::_stopRecording() { | ||
| DLOG("[Recorder] Stopping..."); | ||
| _recording = false; | ||
| _stopWriter = true; | ||
|
|
||
| digitalWrite(LED_BUILTIN, LOW); | ||
|
|
||
| // 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; | ||
| } | ||
|
|
||
| _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::_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, 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; | ||
| } | ||
| } | ||
|
|
||
| vTaskDelay(pdMS_TO_TICKS(100)); | ||
| } | ||
|
|
||
| DLOG("[Writer] Task finished."); | ||
| vTaskDelete(nullptr); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| #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 "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 | ||
| // | ||
| // 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 { | ||
| public: | ||
| Recorder(); | ||
| bool begin(); | ||
| void loop(); | ||
| 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 _finalizeHeader(); | ||
| bool _flushSegment(); | ||
| 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; | ||
|
|
||
| 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; | ||
| }; | ||
|
|
||
| #endif |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's too long IMO; but not a blocker.