Files
Ravi ReddyandClaude Opus 5.5 3b371cdb28 Add VoiceNotes firmware and CDM customer service SQL project
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>
2026-10-03 22:22:10 +05:30

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);
}