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

245 lines
8.5 KiB
C++

#include "audio.h"
#include "app_state.h"
#include "config.h"
#include <ESP_I2S.h>
#include <SD.h>
#include <Wire.h>
#include <freertos/stream_buffer.h>
// ---------------------------------------------------------------------------
// ES7210 4-channel mic ADC. Register sequence follows Espressif's es7210
// driver (esp-bsp/components/es7210), configured as plain I2S slave,
// 16-bit, TDM off, so SDOUT1 carries MIC1 (left) and MIC2 (right).
// ---------------------------------------------------------------------------
static bool es7210Write(uint8_t reg, uint8_t val) {
Wire.beginTransmission(ES7210_ADDR);
Wire.write(reg);
Wire.write(val);
return Wire.endTransmission() == 0;
}
static bool es7210Init() {
// MCLK = 256 x 16 kHz = 4.096 MHz. Clock coefficients from Espressif's table:
// {4096000, 16000, ss_ds 0, adc_div 1, dll 1, doubler 1, osr 0x20, lrck 0x0100}
const uint8_t gain = 0x10 | MIC_GAIN_STEP;
const uint8_t seq[][2] = {
{0x00, 0xFF}, {0x00, 0x32}, // software reset
{0x09, 0x30}, {0x0A, 0x30}, // power-up timing
{0x23, 0x2A}, {0x22, 0x0A}, // ADC1/2 high-pass filter
{0x21, 0x2A}, {0x20, 0x0A}, // ADC3/4 high-pass filter
{0x11, 0x60}, {0x12, 0x00}, // I2S format, 16-bit, TDM off
{0x40, 0xC3}, // analog power, VMID
{0x41, 0x70}, {0x42, 0x70}, // mic bias 2.87 V
{0x43, gain}, {0x44, gain}, {0x45, gain}, {0x46, gain},
{0x47, 0x08}, {0x48, 0x08}, {0x49, 0x08}, {0x4A, 0x08}, // mic power
{0x07, 0x20}, // OSR
{0x02, 0xC1}, // adc_div=1 | doubler<<6 | dll<<7
{0x04, 0x01}, {0x05, 0x00}, // LRCK divider = 256
{0x06, 0x04}, // power down DLL
{0x4B, 0x0F}, {0x4C, 0x0F}, // mic bias, ADC, PGA on
{0x00, 0x71}, {0x00, 0x41}, // enable
};
for (auto &rw : seq) {
if (!es7210Write(rw[0], rw[1])) {
Serial.printf("ES7210: I2C write 0x%02X failed\n", rw[0]);
return false;
}
}
return true;
}
// ---------------------------------------------------------------------------
// Capture task: I2S -> mono PCM -> stream buffer. Runs at high priority and
// never touches SPI, so a slow SD write can't make it miss samples. The
// 64 KB stream buffer absorbs ~2 s of SD card latency.
// ---------------------------------------------------------------------------
static I2SClass i2s;
static StreamBufferHandle_t pcmBuffer;
static volatile bool capturing = false;
static const size_t PCM_BUFFER_BYTES = 64 * 1024;
// Per-microphone level statistics for audioLevelReport().
static volatile bool statsOn = false;
static uint64_t statSumSq[3]; // MIC1, MIC2, recorded output
static int32_t statPeak[3];
static uint32_t statFrames;
static void audioTask(void *) {
static int16_t stereo[512]; // 256 frames, L/R interleaved
static int16_t mono[256];
float level = 0;
const float gain = powf(10.0f, DIGITAL_GAIN_DB / 20.0f);
for (;;) {
size_t got = i2s.readBytes((char *)stereo, sizeof(stereo));
size_t frames = got / 4;
if (frames == 0) continue;
if (statsOn) {
for (size_t i = 0; i < frames; i++) {
for (int ch = 0; ch < 2; ch++) {
int32_t v = stereo[2 * i + ch];
statSumSq[ch] += (int64_t)v * v;
statPeak[ch] = max(statPeak[ch], abs(v));
}
}
statFrames += frames;
}
uint64_t sumSq = 0;
for (size_t i = 0; i < frames; i++) {
// Mix both mics, apply the digital gain, then a tanh soft limiter: nearly
// linear for normal speech, rounding off peaks instead of hard clipping.
float x = ((int32_t)stereo[2 * i] + (int32_t)stereo[2 * i + 1]) * 0.5f * gain;
int32_t s = DIGITAL_GAIN_DB > 0 ? (int32_t)(32767.0f * tanhf(x / 32767.0f)) : (int32_t)x;
mono[i] = (int16_t)s;
if (statsOn) {
statSumSq[2] += (int64_t)s * s;
statPeak[2] = max(statPeak[2], abs(s));
}
sumSq += (int64_t)s * s;
}
// Level meter: RMS mapped from -60..0 dBFS to 0..1, fast attack / slow decay.
float rms = sqrtf((float)sumSq / frames);
float db = 20.0f * log10f(rms / 32768.0f + 1e-9f);
float target = constrain((db + 60.0f) / 60.0f, 0.0f, 1.0f);
level = target > level ? target : level * 0.9f + target * 0.1f;
app.micLevel = level;
if (capturing) {
size_t bytes = frames * 2;
if (xStreamBufferSpacesAvailable(pcmBuffer) >= bytes) {
xStreamBufferSend(pcmBuffer, mono, bytes, 0);
} else {
app.droppedBytes += bytes;
}
}
}
}
void audioLevelReport(uint32_t ms) {
for (int ch = 0; ch < 3; ch++) {
statSumSq[ch] = 0;
statPeak[ch] = 0;
}
statFrames = 0;
statsOn = true;
delay(ms);
statsOn = false;
delay(20);
static const char *names[] = {"MIC1", "MIC2", "recorded"};
for (int ch = 0; ch < 3; ch++) {
float rms = sqrtf((float)statSumSq[ch] / max(statFrames, (uint32_t)1));
Serial.printf("[audio] %-8s RMS %6.1f dBFS, peak %6.1f dBFS\n", names[ch], 20 * log10f(rms / 32768 + 1e-9f),
20 * log10f(statPeak[ch] / 32768.0f + 1e-9f));
}
}
bool audioBegin() {
pinMode(PIN_PA_CTRL, OUTPUT);
digitalWrite(PIN_PA_CTRL, LOW); // speaker amp off; we only record
// Start I2S first: the ES7210 needs MCLK running while it powers up.
i2s.setPins(PIN_I2S_BCLK, PIN_I2S_WS, -1 /* dout */, PIN_I2S_DIN, PIN_I2S_MCLK);
if (!i2s.begin(I2S_MODE_STD, SAMPLE_RATE, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO)) {
Serial.println("I2S begin failed");
return false;
}
Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, 400000);
if (!es7210Init()) return false;
pcmBuffer = xStreamBufferCreate(PCM_BUFFER_BYTES, 1);
if (!pcmBuffer) return false;
xTaskCreate(audioTask, "audio", 4096, nullptr, configMAX_PRIORITIES - 2, nullptr);
return true;
}
// ---------------------------------------------------------------------------
// WAV recorder
// ---------------------------------------------------------------------------
static File wavFile;
static uint32_t recStartMs = 0;
static uint32_t lastFlushMs = 0;
void wavHeader(uint8_t h[44], uint32_t dataBytes) {
const uint32_t byteRate = SAMPLE_RATE * 2;
auto u32 = [&](int o, uint32_t v) { h[o] = v; h[o + 1] = v >> 8; h[o + 2] = v >> 16; h[o + 3] = v >> 24; };
auto u16 = [&](int o, uint16_t v) { h[o] = v; h[o + 1] = v >> 8; };
memcpy(h, "RIFF", 4); u32(4, 36 + dataBytes);
memcpy(h + 8, "WAVEfmt ", 8);
u32(16, 16); // fmt chunk size
u16(20, 1); // PCM
u16(22, 1); // mono
u32(24, SAMPLE_RATE);
u32(28, byteRate);
u16(32, 2); // block align
u16(34, 16); // bits per sample
memcpy(h + 36, "data", 4); u32(40, dataBytes);
}
static void writeWavHeader(File &f, uint32_t dataBytes) {
uint8_t h[44];
wavHeader(h, dataBytes);
f.seek(0);
f.write(h, sizeof(h));
}
bool recorderStart(const char *path) {
{
SpiLock lock;
wavFile = SD.open(path, FILE_WRITE);
if (!wavFile) return false;
writeWavHeader(wavFile, 0);
}
xStreamBufferReset(pcmBuffer);
app.recordedBytes = 0;
app.droppedBytes = 0;
recStartMs = lastFlushMs = millis();
capturing = true;
return true;
}
bool recorderActive() { return capturing; }
uint32_t recorderSeconds() { return app.recordedBytes / (SAMPLE_RATE * 2); }
static void drain(bool all) {
static uint8_t chunk[4096];
// While recording, write only full chunks (SD cards like 512-byte multiples)
// and cap the work per call so the UI stays responsive.
for (int i = 0; all || i < 8; i++) {
size_t avail = xStreamBufferBytesAvailable(pcmBuffer);
if (avail == 0 || (!all && avail < sizeof(chunk))) break;
size_t n = xStreamBufferReceive(pcmBuffer, chunk, sizeof(chunk), 0);
if (n == 0) break;
SpiLock lock;
wavFile.write(chunk, n);
app.recordedBytes += n;
}
}
void recorderService() {
if (!capturing) return;
drain(false);
// Every 10 s, update the header's length fields and flush, so a long
// recording interrupted by a power cut is still a valid, playable WAV.
if (millis() - lastFlushMs > 10000) {
SpiLock lock;
writeWavHeader(wavFile, app.recordedBytes);
wavFile.seek(44 + app.recordedBytes);
wavFile.flush();
lastFlushMs = millis();
}
}
bool recorderStop() {
if (!capturing) return false;
capturing = false;
delay(40); // let the capture task finish its current block
drain(true);
SpiLock lock;
writeWavHeader(wavFile, app.recordedBytes);
wavFile.close();
return true;
}