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>
This commit is contained in:
Ravi Reddy
2026-10-03 22:22:10 +05:30
co-authored by Claude Opus 5.5
parent ef38a502d8
commit 3b371cdb28
24 changed files with 8700 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
#include "ui.h"
#include "app_state.h"
#include "audio.h"
#include "config.h"
#include <Arduino_GFX_Library.h>
#include <FastLED.h>
#include <U8g2lib.h>
#include <WiFi.h>
// ---------------------------------------------------------------------------
// Display: draw into a 128x128 off-screen canvas (32 KB) and push the whole
// frame at once, so animations don't flicker.
// ---------------------------------------------------------------------------
static Arduino_DataBus *bus = new Arduino_HWSPI(PIN_LCD_DC, PIN_LCD_CS, PIN_SPI_SCK, PIN_SPI_MOSI, PIN_SPI_MISO);
static Arduino_GFX *panel = new Arduino_ST7735(bus, PIN_LCD_RST, 0, true, 128, 128,
LCD_COL_OFFSET, LCD_ROW_OFFSET, LCD_COL_OFFSET, LCD_ROW_OFFSET);
static Arduino_Canvas *gfx = new Arduino_Canvas(128, 128, panel);
static CRGB leds[NUM_LEDS];
// Palette
static const uint16_t C_BG = RGB565(0, 0, 0);
static const uint16_t C_TEXT = RGB565(235, 235, 235);
static const uint16_t C_DIM = RGB565(110, 110, 120);
static const uint16_t C_FAINT = RGB565(40, 40, 48);
static const uint16_t C_RED = RGB565(255, 60, 60);
static const uint16_t C_GREEN = RGB565(60, 220, 110);
static const uint16_t C_BLUE = RGB565(60, 150, 255);
static const uint16_t C_CYAN = RGB565(40, 220, 230);
static const uint16_t C_PURPLE = RGB565(180, 110, 255);
static const uint16_t C_AMBER = RGB565(255, 170, 40);
// Fonts (U8g2 font set, rendered by Arduino_GFX)
#define F_SMALL u8g2_font_5x8_tr
#define F_MED u8g2_font_helvB10_tr
#define F_BIG u8g2_font_logisoso22_tn
static float levelHistory[32];
static uint8_t levelPos = 0;
bool uiBegin() {
FastLED.addLeds<WS2812B, PIN_LEDS, LED_COLOR_ORDER>(leds, NUM_LEDS);
FastLED.setBrightness(LED_BRIGHTNESS);
FastLED.clear(true);
SpiLock lock;
if (!gfx->begin()) return false;
gfx->fillScreen(C_BG);
gfx->flush();
pinMode(PIN_LCD_BL, OUTPUT);
digitalWrite(PIN_LCD_BL, HIGH);
return true;
}
// ---------- drawing helpers ----------
static void textCentered(const char *s, int y, const uint8_t *font, uint16_t color) {
int16_t x1, y1;
uint16_t w, h;
gfx->setFont(font);
gfx->getTextBounds(s, 0, y, &x1, &y1, &w, &h);
gfx->setTextColor(color);
gfx->setCursor((128 - (int)w) / 2 - x1, y);
gfx->print(s);
}
// Word-wrap into lines of the 5x8 font (24 chars across), centered.
static void textWrapped(const char *s, int y, uint16_t color, int maxLines) {
const int cols = 24;
char line[cols + 1];
int len = strlen(s), start = 0;
for (int n = 0; n < maxLines && start < len; n++) {
while (s[start] == ' ') start++;
int end = min(start + cols, len);
if (end < len) {
int sp = end;
while (sp > start && s[sp] != ' ') sp--;
if (sp > start) end = sp;
}
memcpy(line, s + start, end - start);
line[end - start] = 0;
textCentered(line, y + n * 10, F_SMALL, color);
start = end;
}
}
static void drawCheck(int cx, int cy, uint16_t color) {
gfx->fillCircle(cx, cy, 18, color);
for (int d = -1; d <= 1; d++) {
gfx->drawLine(cx - 9, cy + d, cx - 3, cy + 6 + d, C_BG);
gfx->drawLine(cx - 3, cy + 6 + d, cx + 9, cy - 6 + d, C_BG);
}
}
static void drawCross(int cx, int cy, uint16_t color) {
gfx->fillCircle(cx, cy, 18, color);
for (int d = -1; d <= 1; d++) {
gfx->drawLine(cx - 7 + d, cy - 7, cx + 7 + d, cy + 7, C_BG);
gfx->drawLine(cx + 7 + d, cy - 7, cx - 7 + d, cy + 7, C_BG);
}
}
static void drawMic(int cx, int cy, uint16_t color) {
gfx->fillRoundRect(cx - 7, cy - 16, 14, 24, 7, color);
gfx->fillArc(cx, cy - 2, 13, 11, 0, 180, color);
gfx->fillRect(cx - 1, cy + 11, 3, 6, color);
gfx->fillRect(cx - 7, cy + 16, 15, 2, color);
}
// 12 dots around a circle with a fading tail - mirrors the LED ring spinner.
static void drawSpinner(int cx, int cy, uint16_t color, uint32_t t) {
int head = (t / 80) % 12;
for (int i = 0; i < 12; i++) {
float a = i * (TWO_PI / 12) - HALF_PI;
int age = (head - i + 12) % 12;
uint16_t c = age == 0 ? color : age < 4 ? C_DIM : C_FAINT;
gfx->fillCircle(cx + cosf(a) * 20, cy + sinf(a) * 20, age == 0 ? 4 : 3, c);
}
}
static void drawProgress(int y, uint8_t pct, uint16_t color) {
gfx->drawRoundRect(8, y, 112, 12, 4, C_DIM);
int w = 108 * pct / 100;
if (w > 0) gfx->fillRoundRect(10, y + 2, w, 8, 3, color);
}
static void drawStatusBar() {
char buf[16];
gfx->setFont(F_SMALL);
gfx->setTextColor(C_DIM);
gfx->setCursor(3, 10);
if (timeIsValid()) {
time_t now = time(nullptr);
strftime(buf, sizeof(buf), "%H:%M", localtime(&now));
gfx->print(buf);
} else {
gfx->print("--:--");
}
// Wi-Fi bars
bool wifi = WiFi.status() == WL_CONNECTED;
for (int i = 0; i < 3; i++) {
int h = 3 + i * 2;
gfx->fillRect(108 + i * 4, 10 - h, 3, h, wifi ? C_GREEN : C_FAINT);
}
if (!wifi && (millis() / 500) % 2) gfx->fillRect(108, 8, 3, 2, C_AMBER);
// SD card
uint16_t sd = app.state == ST_NO_SD ? C_RED : C_DIM;
gfx->fillRect(97, 3, 7, 8, sd);
gfx->fillTriangle(102, 3, 104, 3, 104, 5, C_BG);
gfx->drawFastHLine(0, 14, 128, C_FAINT);
}
// "mm:ss" under an hour, "h:mm:ss" from then on.
static void formatDuration(char *buf, size_t len, uint32_t secs) {
if (secs >= 3600) snprintf(buf, len, "%lu:%02lu:%02lu", secs / 3600, secs / 60 % 60, secs % 60);
else snprintf(buf, len, "%02lu:%02lu", secs / 60, secs % 60);
}
static void footer(const char *s) { textCentered(s, 124, F_SMALL, C_DIM); }
// Short label for a recording path: "/recordings/REC_2026-10-02_14-30-05.wav" -> "10-02 14:30:05"
static String recLabel(const char *path) {
const char *p = strstr(path, "REC_");
if (!p || strlen(p) < 23) return String(path);
String s = String(p + 9, 5) + " " + String(p + 15, 8);
s.replace('-', ':');
s.setCharAt(2, '-');
return s;
}
// ---------- screens ----------
static void drawScreen(uint32_t t) {
char buf[48];
switch (app.state) {
case ST_BOOT:
drawMic(64, 52, C_BLUE);
textCentered("VoiceNotes", 92, F_MED, C_TEXT);
textCentered("Connecting Wi-Fi...", 108, F_SMALL, C_DIM);
break;
case ST_NO_SD:
gfx->fillRoundRect(50, 30, 28, 36, 4, C_RED);
gfx->fillTriangle(70, 30, 78, 30, 78, 38, C_BG);
textCentered("No microSD", 88, F_MED, C_TEXT);
textCentered("Insert a FAT32 card", 104, F_SMALL, C_DIM);
break;
case ST_IDLE:
drawMic(64, 46, C_DIM);
textCentered("Ready", 84, F_MED, C_TEXT);
if (app.lastRecording[0]) {
snprintf(buf, sizeof(buf), "Last %s", recLabel(app.lastRecording).c_str());
textCentered(buf, 100, F_SMALL, C_DIM);
}
footer("PLUS rec | hold: notes");
break;
case ST_RECORDING: {
if ((t / 500) % 2) gfx->fillCircle(46, 27, 5, C_RED);
else gfx->drawCircle(46, 27, 5, C_RED);
gfx->setFont(F_MED);
gfx->setTextColor(C_RED);
gfx->setCursor(56, 32);
gfx->print("REC");
uint32_t sec = recorderSeconds();
formatDuration(buf, sizeof(buf), sec);
textCentered(buf, 66, F_BIG, C_TEXT);
// Rolling level meter, one bar per frame.
levelHistory[levelPos] = app.micLevel;
levelPos = (levelPos + 1) % 32;
for (int i = 0; i < 32; i++) {
float v = levelHistory[(levelPos + i) % 32];
int h = max(1, (int)(v * 30));
gfx->fillRect(i * 4, 100 - h / 2 - 1, 3, h, v > 0.85f ? C_AMBER : C_RED);
}
uint32_t left = app.maxSeconds - min(sec, (uint32_t)app.maxSeconds);
if (left < 60) snprintf(buf, sizeof(buf), "BOOT stop | %lus left", left);
else snprintf(buf, sizeof(buf), "BOOT: stop");
footer(buf);
break;
}
case ST_SAVED:
drawCheck(64, 44, C_GREEN);
textCentered("Saved", 82, F_MED, C_TEXT);
textCentered(recLabel(app.lastRecording).c_str(), 96, F_SMALL, C_DIM);
formatDuration(buf, sizeof(buf), app.recordedBytes / (SAMPLE_RATE * 2));
snprintf(buf + strlen(buf), sizeof(buf) - strlen(buf), " %.1f MB", app.recordedBytes / 1048576.0f);
textCentered(buf, 106, F_SMALL, C_DIM);
footer("hold PLUS: notes");
break;
case ST_UPLOADING:
// Arrow-up-into-cloud
gfx->fillCircle(54, 42, 10, C_BLUE);
gfx->fillCircle(70, 38, 13, C_BLUE);
gfx->fillRoundRect(42, 40, 44, 14, 6, C_BLUE);
gfx->fillTriangle(64, 30, 57, 40, 71, 40, C_BG);
gfx->fillRect(62, 40, 5, 10, C_BG);
textCentered("Uploading", 76, F_MED, C_TEXT);
drawProgress(86, app.progress, C_BLUE);
if (app.partCount > 1) snprintf(buf, sizeof(buf), "Part %u of %u %u%%", app.partIndex, app.partCount, app.progress);
else snprintf(buf, sizeof(buf), "%u%%", app.progress);
textCentered(buf, 110, F_SMALL, C_DIM);
break;
case ST_TRANSCRIBING:
drawSpinner(64, 44, C_CYAN, t);
textCentered("Transcribing", 84, F_MED, C_TEXT);
if (app.partCount > 1) snprintf(buf, sizeof(buf), "Part %u of %u", app.partIndex, app.partCount);
else snprintf(buf, sizeof(buf), "speech -> text");
textCentered(buf, 100, F_SMALL, C_DIM);
break;
case ST_SUMMARIZING:
drawSpinner(64, 44, C_PURPLE, t);
textCentered("Summarizing", 84, F_MED, C_TEXT);
if (app.received) snprintf(buf, sizeof(buf), "Claude: %lu chars", app.received);
else snprintf(buf, sizeof(buf), "Claude is thinking...");
textCentered(buf, 100, F_SMALL, C_DIM);
break;
case ST_SYNCING:
drawSpinner(64, 44, C_GREEN, t);
textCentered("Saving", 84, F_MED, C_TEXT);
textCentered("to Nextcloud", 100, F_SMALL, C_DIM);
break;
case ST_DONE:
drawCheck(64, 44, C_GREEN);
textCentered("Notes saved", 82, F_MED, C_TEXT);
textWrapped(app.detail, 96, C_DIM, 2);
break;
case ST_ERROR:
drawCross(64, 40, C_RED);
textCentered("Error", 74, F_MED, C_TEXT);
textWrapped(app.detail, 88, C_DIM, 4);
break;
}
}
// ---------- LED ring ----------
static void spin(CRGB color, uint32_t t, uint16_t periodMs, int comets) {
fadeToBlackBy(leds, NUM_LEDS, 70);
int head = (t / (periodMs / NUM_LEDS)) % NUM_LEDS;
for (int c = 0; c < comets; c++) leds[(head + c * NUM_LEDS / comets) % NUM_LEDS] = color;
}
static void updateLeds(uint32_t t) {
uint32_t inState = millis() - app.stateSince;
switch (app.state) {
case ST_BOOT:
spin(CRGB(255, 120, 0), t, 800, 1);
break;
case ST_NO_SD:
fill_solid(leds, NUM_LEDS, (t / 700) % 2 ? CRGB(120, 0, 0) : CRGB::Black);
break;
case ST_IDLE: {
uint8_t b = beatsin8(12, 4, 28); // slow "breathing"
CRGB c = WiFi.status() == WL_CONNECTED ? CRGB(b / 2, b / 2, b) : CRGB(b, b / 2, 0);
fill_solid(leds, NUM_LEDS, c);
break;
}
case ST_RECORDING: {
// Dim red base, plus a VU meter that grows around the ring with the voice level.
fill_solid(leds, NUM_LEDS, CRGB(beatsin8(40, 10, 40), 0, 0));
float lit = app.micLevel * NUM_LEDS;
for (int i = 0; i < NUM_LEDS; i++) {
if (i < (int)lit) leds[i] = CRGB(255, 0, 0);
else if (i == (int)lit) leds[i] = CRGB((uint8_t)(255 * (lit - i)), 0, 0);
}
break;
}
case ST_SAVED: {
uint8_t b = inState < 1500 ? 255 - inState / 6 : 5;
fill_solid(leds, NUM_LEDS, CRGB(0, b, 0));
break;
}
case ST_UPLOADING: {
// Fill the ring with overall progress across all parts.
uint8_t parts = max((uint8_t)1, app.partCount);
uint8_t done = app.partIndex ? app.partIndex - 1 : 0;
int full = (done * 100 + app.progress) * NUM_LEDS / (parts * 100);
for (int i = 0; i < NUM_LEDS; i++)
leds[i] = i < full ? CRGB(0, 60, 255) : i == full && (t / 250) % 2 ? CRGB(0, 30, 120) : CRGB::Black;
break;
}
case ST_TRANSCRIBING:
spin(CRGB(0, 200, 220), t, 900, 1);
break;
case ST_SUMMARIZING:
spin(CRGB(150, 0, 255), t, 1200, 2);
break;
case ST_SYNCING:
spin(CRGB(0, 220, 80), t, 900, 1);
break;
case ST_DONE:
fill_solid(leds, NUM_LEDS, CRGB(0, beatsin8(30, 20, 200), 40));
break;
case ST_ERROR:
fill_solid(leds, NUM_LEDS, (t / 200) % 2 && inState < 2400 ? CRGB(255, 0, 0) : CRGB(40, 0, 0));
break;
}
FastLED.show();
}
void uiUpdate() {
static uint32_t lastFrame = 0;
uint32_t t = millis();
if (t - lastFrame < 50) return;
lastFrame = t;
updateLeds(t);
gfx->fillScreen(C_BG);
drawStatusBar();
drawScreen(t);
SpiLock lock;
gfx->flush();
}