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
7 changes: 5 additions & 2 deletions moneo/Config.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ 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;

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.

That's too long IMO; but not a blocker.


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

// ─── AUDIO SETTINGS ──────────────────────────────────────────
const int SAMPLE_RATE = 16000;
Expand Down
297 changes: 297 additions & 0 deletions moneo/Recorder.cpp
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)) {

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.

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

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.

NTP sync should be in main INO file's setup section (as with any runtime blocking/stalling subroutine), and kept alongside the lastTimeSync as millis() value when it was sync'd, so that current time can always be calculated without needing to make another network request (by adding the elapsed time sinch the last sync, with the last sync'd timestamp).

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

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.

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);
}
73 changes: 73 additions & 0 deletions moneo/Recorder.h
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
Loading