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>
245 lines
8.5 KiB
C++
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;
|
|
}
|