// 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 #include #include #include #include #include #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 = 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); }