VoiceNotes is an Arduino sketch for the Waveshare ESP32-C6-LCD-0.85 that records meetings and voice memos to microSD and turns them into notes: - Records 16 kHz mono WAV (up to 4 h) from the ES7210 dual mics, with a boosted preamp and soft-limited digital gain - Transcribes via an OpenAI-compatible speech-to-text API in ~10 min parts cut at quiet points, with retries and resume after failures - Summarizes the transcript with Claude into meeting notes (decisions, action items, topics with timestamps), streamed to and from the card - Copies the notes to Nextcloud over WebDAV - Shows recording/processing state on the 0.85" LCD and the LED ring - Works around the board's SD card failing multi-sector reads Credentials live in VoiceNotes/secrets.h, which is git-ignored; see secrets.example.h. Also adds the Microsoft CDM customer service SQL implementation (DDL, sample data, reporting queries and docs). Its README is renamed to CDM_README.md so the repository README stays at the top level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
9.9 KiB
Arduino
299 lines
9.9 KiB
Arduino
// VoiceNotes - voice recorder + AI notes for the Waveshare ESP32-C6-LCD-0.85
|
|
//
|
|
// PLUS click -> start recording (Button 1)
|
|
// BOOT click -> stop & save to microSD (Button 2)
|
|
// PLUS long-press -> transcribe the latest recording, summarize it with
|
|
// Claude, and save both to microSD (Button 3)
|
|
//
|
|
// Recordings: /recordings/REC_YYYY-MM-DD_HH-MM-SS.wav (16 kHz mono WAV)
|
|
// Notes: /notes/REC_..._transcript.txt and /notes/REC_..._summary.md
|
|
|
|
#include <OneButton.h>
|
|
#include <fcntl.h>
|
|
#include <unistd.h>
|
|
#include <SD.h>
|
|
#include <SPI.h>
|
|
#include <WiFi.h>
|
|
#include "app_state.h"
|
|
#include "audio.h"
|
|
#include "cloud.h"
|
|
#include "config.h"
|
|
#include "secrets.h"
|
|
#include "ui.h"
|
|
|
|
AppStatus app;
|
|
SemaphoreHandle_t spiMutex;
|
|
|
|
static OneButton btnPlus(PIN_BTN_PLUS, true, true);
|
|
static OneButton btnBoot(PIN_BTN_BOOT, true, true);
|
|
#if PIN_BTN_EXTRA >= 0
|
|
static OneButton btnExtra(PIN_BTN_EXTRA, true, true);
|
|
#endif
|
|
|
|
void setState(AppState s, const char *detail) {
|
|
if (detail) strlcpy(app.detail, detail, sizeof(app.detail));
|
|
app.stateSince = millis();
|
|
app.state = s;
|
|
static const char *names[] = {"BOOT", "NO_SD", "IDLE", "RECORDING", "SAVED", "UPLOADING",
|
|
"TRANSCRIBING", "SUMMARIZING", "SYNCING", "DONE", "ERROR"};
|
|
Serial.printf("[state] %s %s\n", names[s], detail ? detail : "");
|
|
}
|
|
|
|
bool timeIsValid() { return time(nullptr) > 1700000000; } // NTP has synced
|
|
|
|
// ---------------------------------------------------------------------------
|
|
static bool mountSD() {
|
|
SpiLock lock;
|
|
if (!SD.begin(PIN_SD_CS, SPI, SD_SPI_HZ) || SD.cardType() == CARD_NONE) {
|
|
SD.end();
|
|
return false;
|
|
}
|
|
SD.mkdir(DIR_RECORDINGS);
|
|
SD.mkdir(DIR_NOTES);
|
|
File f = SD.open(LAST_REC_FILE, FILE_READ);
|
|
if (f) {
|
|
String p = f.readStringUntil('\n');
|
|
p.trim();
|
|
f.close();
|
|
if (SD.exists(p)) strlcpy(app.lastRecording, p.c_str(), sizeof(app.lastRecording));
|
|
}
|
|
Serial.printf("SD mounted, %llu MB free\n", (SD.totalBytes() - SD.usedBytes()) / 1048576);
|
|
return true;
|
|
}
|
|
|
|
static bool canStartSomething() {
|
|
AppState s = app.state;
|
|
return s == ST_IDLE || s == ST_SAVED || s == ST_DONE || s == ST_ERROR;
|
|
}
|
|
|
|
// Button 1
|
|
static void startRecording() {
|
|
if (!canStartSomething()) return;
|
|
char path[64];
|
|
if (timeIsValid()) {
|
|
time_t now = time(nullptr);
|
|
strftime(path, sizeof(path), DIR_RECORDINGS "/REC_%Y-%m-%d_%H-%M-%S.wav", localtime(&now));
|
|
} else {
|
|
// No network time yet (the C6 has no battery-backed clock).
|
|
snprintf(path, sizeof(path), DIR_RECORDINGS "/REC_nodate_%08lx.wav", (unsigned long)esp_random());
|
|
}
|
|
// Limit the length to what fits on the card, keeping 50 MB spare.
|
|
uint64_t freeBytes;
|
|
{
|
|
SpiLock lock;
|
|
freeBytes = SD.totalBytes() - SD.usedBytes();
|
|
}
|
|
uint64_t fitSeconds = freeBytes > 50ULL * 1048576 ? (freeBytes - 50ULL * 1048576) / (SAMPLE_RATE * 2) : 0;
|
|
if (fitSeconds < 60) {
|
|
setState(ST_ERROR, "microSD card is full");
|
|
return;
|
|
}
|
|
app.maxSeconds = (uint32_t)min((uint64_t)MAX_RECORD_SECONDS, fitSeconds);
|
|
if (!recorderStart(path)) {
|
|
setState(ST_ERROR, "Can't create file on microSD");
|
|
return;
|
|
}
|
|
strlcpy(app.lastRecording, path, sizeof(app.lastRecording));
|
|
setState(ST_RECORDING, path);
|
|
}
|
|
|
|
// Button 2
|
|
static void stopRecording() {
|
|
if (app.state != ST_RECORDING) return;
|
|
recorderStop();
|
|
{
|
|
SpiLock lock;
|
|
File f = SD.open(LAST_REC_FILE, FILE_WRITE);
|
|
if (f) {
|
|
f.println(app.lastRecording);
|
|
f.close();
|
|
}
|
|
}
|
|
if (app.droppedBytes)
|
|
Serial.printf("Warning: %lu bytes of audio dropped (slow SD card?)\n", app.droppedBytes);
|
|
setState(ST_SAVED, strrchr(app.lastRecording, '/') + 1);
|
|
}
|
|
|
|
// Button 3
|
|
static void processLatest() {
|
|
if (!canStartSomething()) return;
|
|
if (!app.lastRecording[0]) {
|
|
setState(ST_ERROR, "No recording yet - press PLUS to record");
|
|
return;
|
|
}
|
|
if (WiFi.status() != WL_CONNECTED) {
|
|
setState(ST_ERROR, "Wi-Fi not connected - check secrets.h");
|
|
return;
|
|
}
|
|
if (!cloudStart(app.lastRecording)) setState(ST_ERROR, "Couldn't start network task");
|
|
}
|
|
|
|
// Runs on every (re)connect, after DHCP, so the DNS override survives lease renewals.
|
|
static void onGotIp(arduino_event_id_t, arduino_event_info_t) {
|
|
Serial.printf("[wifi] IP %s, DHCP DNS %s / %s\n", WiFi.localIP().toString().c_str(),
|
|
WiFi.dnsIP(0).toString().c_str(), WiFi.dnsIP(1).toString().c_str());
|
|
IPAddress d1, d2;
|
|
if (d1.fromString(DNS_PRIMARY)) {
|
|
if (!d2.fromString(DNS_SECONDARY)) d2 = IPAddress();
|
|
WiFi.setDNS(d1, d2);
|
|
Serial.printf("[wifi] using DNS %s / %s\n", WiFi.dnsIP(0).toString().c_str(), WiFi.dnsIP(1).toString().c_str());
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
void setup() {
|
|
Serial.begin(115200);
|
|
spiMutex = xSemaphoreCreateMutex();
|
|
SPI.begin(PIN_SPI_SCK, PIN_SPI_MISO, PIN_SPI_MOSI); // shared by LCD and microSD
|
|
|
|
if (!uiBegin()) Serial.println("LCD init failed");
|
|
setState(ST_BOOT);
|
|
uiUpdate();
|
|
|
|
WiFi.onEvent(onGotIp, ARDUINO_EVENT_WIFI_STA_GOT_IP);
|
|
WiFi.mode(WIFI_STA);
|
|
WiFi.setAutoReconnect(true);
|
|
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
|
|
configTzTime(TIMEZONE, "pool.ntp.org", "time.google.com");
|
|
|
|
btnPlus.attachClick(startRecording);
|
|
btnPlus.setPressMs(BTN3_LONG_PRESS_MS);
|
|
btnPlus.attachLongPressStart(processLatest);
|
|
btnBoot.attachClick(stopRecording);
|
|
#if PIN_BTN_EXTRA >= 0
|
|
btnExtra.attachClick(processLatest);
|
|
#endif
|
|
|
|
bool micOk = audioBegin();
|
|
bool sdOk = mountSD();
|
|
|
|
// Give Wi-Fi + NTP up to 10 s so the first recording gets a real timestamp.
|
|
uint32_t t0 = millis();
|
|
while (millis() - t0 < 10000 && !timeIsValid()) {
|
|
uiUpdate();
|
|
delay(10);
|
|
}
|
|
Serial.printf("Wi-Fi %s, clock %s\n", WiFi.status() == WL_CONNECTED ? "connected" : "offline",
|
|
timeIsValid() ? "synced" : "not set");
|
|
|
|
if (!micOk) setState(ST_ERROR, "Microphone init failed");
|
|
else if (!sdOk) setState(ST_NO_SD);
|
|
else setState(ST_IDLE);
|
|
}
|
|
|
|
// Serial commands (115200 baud), handy for testing without the buttons:
|
|
// p = transcribe + summarize the latest recording (same as holding PLUS)
|
|
// s = read the latest recording back from the SD card and report the result
|
|
// d = list /recordings
|
|
// w = show Wi-Fi and DNS status
|
|
// r / x = start / stop recording (same as PLUS / BOOT)
|
|
// a = measure each microphone's level for 2 s
|
|
// n = print the notes (transcript + summary) for the latest recording
|
|
// u<file name> = choose which recording p and n use, e.g. uREC_2026-10-03_15-31-12.wav
|
|
static void sdReadTest() {
|
|
if (!app.lastRecording[0]) { Serial.println("[test] no recording yet"); return; }
|
|
SpiLock lock;
|
|
uint32_t t0 = millis();
|
|
int fd = open((String("/sd") + app.lastRecording).c_str(), O_RDONLY);
|
|
if (fd < 0) { Serial.printf("[test] open %s failed (errno %d)\n", app.lastRecording, errno); return; }
|
|
static uint8_t buf[512]; // single-sector reads, see sdRead() in cloud.cpp
|
|
size_t total = 0;
|
|
int n;
|
|
while ((n = read(fd, buf, sizeof(buf))) > 0) total += n;
|
|
int e = n < 0 ? errno : 0;
|
|
close(fd);
|
|
Serial.printf("[test] read %u bytes of %s in %lu ms (errno %d %s)\n", (unsigned)total, app.lastRecording,
|
|
millis() - t0, e, strerror(e));
|
|
}
|
|
|
|
static void sdList() {
|
|
SpiLock lock;
|
|
File dir = SD.open(DIR_RECORDINGS);
|
|
for (File f = dir.openNextFile(); f; f = dir.openNextFile())
|
|
Serial.printf("[test] %-40s %8u bytes\n", f.name(), (unsigned)f.size());
|
|
dir.close();
|
|
}
|
|
|
|
static void printFile(const String &path) {
|
|
SpiLock lock;
|
|
int fd = open((String("/sd") + path).c_str(), O_RDONLY);
|
|
if (fd < 0) { Serial.printf("[test] %s not found\n", path.c_str()); return; }
|
|
Serial.printf("===== %s =====\n", path.c_str());
|
|
static uint8_t buf[512];
|
|
int n;
|
|
while ((n = read(fd, buf, sizeof(buf))) > 0) Serial.write(buf, n);
|
|
close(fd);
|
|
Serial.println("\n===== end =====");
|
|
}
|
|
|
|
static void printNotes() {
|
|
if (!app.lastRecording[0]) { Serial.println("[test] no recording yet"); return; }
|
|
String base = String(strrchr(app.lastRecording, '/') + 1);
|
|
base.replace(".wav", "");
|
|
printFile(String(DIR_NOTES "/") + base + "_transcript.txt");
|
|
printFile(String(DIR_NOTES "/") + base + "_summary.md");
|
|
}
|
|
|
|
static void serialCommands() {
|
|
while (Serial.available()) {
|
|
switch (Serial.read()) {
|
|
case 'p': Serial.println("[test] start pipeline"); processLatest(); break;
|
|
case 's': sdReadTest(); break;
|
|
case 'd': sdList(); break;
|
|
case 'n': printNotes(); break;
|
|
case 'r': startRecording(); break;
|
|
case 'a': audioLevelReport(2000); break;
|
|
case 'x': stopRecording(); break;
|
|
case 'w':
|
|
Serial.printf("[wifi] %s, IP %s, DNS %s / %s, RSSI %d dBm\n", WiFi.isConnected() ? "connected" : "offline",
|
|
WiFi.localIP().toString().c_str(), WiFi.dnsIP(0).toString().c_str(),
|
|
WiFi.dnsIP(1).toString().c_str(), WiFi.RSSI());
|
|
break;
|
|
case 'u': {
|
|
String name = Serial.readStringUntil('\n');
|
|
name.trim();
|
|
String path = String(DIR_RECORDINGS "/") + name;
|
|
bool found;
|
|
{
|
|
SpiLock lock;
|
|
found = SD.exists(path);
|
|
}
|
|
if (found) strlcpy(app.lastRecording, path.c_str(), sizeof(app.lastRecording));
|
|
Serial.printf("[test] %s %s\n", found ? "using" : "not found:", path.c_str());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void loop() {
|
|
serialCommands();
|
|
btnPlus.tick();
|
|
btnBoot.tick();
|
|
#if PIN_BTN_EXTRA >= 0
|
|
btnExtra.tick();
|
|
#endif
|
|
|
|
recorderService();
|
|
if (app.state == ST_RECORDING && recorderSeconds() >= app.maxSeconds) {
|
|
Serial.println("Max length reached, stopping");
|
|
stopRecording();
|
|
}
|
|
|
|
// Result screens fall back to the ready screen after a while.
|
|
uint32_t inState = millis() - app.stateSince;
|
|
if ((app.state == ST_SAVED && inState > 5000) ||
|
|
((app.state == ST_DONE || app.state == ST_ERROR) && inState > 15000)) {
|
|
setState(ST_IDLE);
|
|
}
|
|
|
|
static uint32_t lastSdTry = 0;
|
|
if (app.state == ST_NO_SD && millis() - lastSdTry > 3000) {
|
|
lastSdTry = millis();
|
|
if (mountSD()) setState(ST_IDLE);
|
|
}
|
|
|
|
uiUpdate();
|
|
delay(2);
|
|
}
|