T-Echo Card audio support initial stages - codec2 attempts

This commit is contained in:
pelgraine
2026-05-07 05:47:18 +10:00
parent 4b734f1bac
commit 708b96e0e8
109 changed files with 87144 additions and 24 deletions
@@ -71,7 +71,7 @@ void TechoCardBoard::begin() {
#if defined(HAS_RGB_LED)
pinMode(PIN_RGB_LED_1, OUTPUT);
digitalWrite(PIN_RGB_LED_1, LOW);
delayMicroseconds(300); // WS2812 reset pulse is ~280µs
delayMicroseconds(300); // WS2812 reset pulse is ~280us
_pixels.begin();
_pixels.clear();
_pixels.show();
+1 -1
View File
@@ -27,7 +27,7 @@ private:
#endif
public:
TechoCardBoard() : NRF52Board("TECHO_CARD_OTA") {}
TechoCardBoard() {}
void begin();
@@ -19,6 +19,37 @@
#include "MyMesh.h"
#include "UITask.h"
// =============================================================================
// Voice recording -- PDM mic -> Codec2 1200bps stream encoding
// =============================================================================
#if defined(HAS_MICROPHONE)
#include <PDM.h>
#include <codec2.h>
// VE3 protocol constants (wire-compatible with ESP32 VoiceMessageScreen)
#define VC_C2_MODE CODEC2_MODE_1200
#define VC_C2_MODE_ID 1 // Codec2 1200bps mode identifier
#define VC_C2_FRAME_MS 40 // 40ms per frame at 1200bps
#define VC_C2_FRAME_SAM 320 // 320 samples per frame at 8kHz
#define VC_C2_FRAME_BYTES 6 // 6 encoded bytes per frame
#define VC_MAX_SECONDS 5
#define VC_MAX_FRAMES (VC_MAX_SECONDS * 1000 / VC_C2_FRAME_MS) // 125
#define VC_MAX_BYTES (VC_MAX_FRAMES * VC_C2_FRAME_BYTES) // 750
#define VC_MESH_PAYLOAD 150 // Usable codec2 bytes per mesh packet
#define VC_PKT_MAGIC 0x56 // Voice packet magic byte
#define VC_PKT_HDR_SIZE 6 // magic(1) + sessionId(4) + pktIdx(1)
#define VC_PDM_RATE 16000
#define VC_PDM_FRAME (VC_C2_FRAME_SAM * 2) // 640 16kHz samples per codec frame
// PDM ring buffer -- 2 codec frames of headroom at 16kHz
#define VC_PDM_BUF_SAMPLES (VC_PDM_FRAME * 2) // 1280 samples = 2560 bytes
// Forward declaration for static callback
class TechoCardHomeScreen;
static TechoCardHomeScreen* _vcSelf = nullptr;
static void _vcPdmISR();
#endif
class TechoCardHomeScreen : public UIScreen {
enum Page {
STATUS,
@@ -27,6 +58,9 @@ class TechoCardHomeScreen : public UIScreen {
BLE,
#endif
ADVERT,
#if defined(HAS_MICROPHONE)
VOICE,
#endif
#if ENV_INCLUDE_GPS == 1
GPS,
#endif
@@ -61,6 +95,28 @@ class TechoCardHomeScreen : public UIScreen {
uint16_t _magOk;
uint16_t _magFail;
#if defined(HAS_MICROPHONE)
// Voice recording state
enum VoiceState { V_IDLE, V_RECORDING, V_REVIEW };
VoiceState _vState;
struct CODEC2* _vCodec;
// PDM sample accumulator (filled by ISR, consumed by poll)
int16_t _vPdmBuf[VC_PDM_BUF_SAMPLES];
volatile int _vPdmCount;
volatile uint32_t _vIsrCount;
// Codec2 encoded output
uint8_t _vEncoded[VC_MAX_BYTES]; // 750 bytes max
uint16_t _vEncBytes;
uint16_t _vEncFrames;
unsigned long _vRecStart;
// VE3 outgoing session
uint32_t _vSessionId;
bool _vSessionActive;
#endif
// Four lines at 9px spacing within 40px display.
// U8g2 handles panel offset natively -- y=0 is the true visible top.
static const int Y0 = 2;
@@ -98,10 +154,205 @@ public:
_calMinX(0), _calMaxX(0),
_calMinY(0), _calMaxY(0),
_calMinZ(0), _calMaxZ(0),
_magOk(0), _magFail(0) {}
_magOk(0), _magFail(0)
#if defined(HAS_MICROPHONE)
, _vState(V_IDLE), _vCodec(nullptr), _vPdmCount(0), _vIsrCount(0),
_vEncBytes(0), _vEncFrames(0), _vRecStart(0),
_vSessionId(0), _vSessionActive(false)
#endif
{}
void cancelEditing() { _shutdown_init = false; }
#if defined(HAS_MICROPHONE)
// --- Voice recording helpers ---
void onPDMData() {
int avail = PDM.available();
if (avail <= 0) return;
int samples = avail / (int)sizeof(int16_t);
int space = VC_PDM_BUF_SAMPLES - _vPdmCount;
if (samples > space) samples = space;
if (samples > 0) {
PDM.read(&_vPdmBuf[_vPdmCount], samples * sizeof(int16_t));
_vPdmCount += samples;
_vIsrCount++;
}
}
bool voiceStartRecording() {
if (_vState == V_RECORDING) return false;
// Codec2 created lazily on first VOICE page visit (see render)
if (!_vCodec) {
Serial.println("Voice: no codec2 instance!");
return false;
}
// Reset buffers
_vPdmCount = 0;
_vIsrCount = 0;
_vEncBytes = 0;
_vEncFrames = 0;
// Enable speaker amp power rail (RT9080 powers both speaker and mic)
Serial.println("Voice: enabling speaker rail...");
board.enableSpeaker(true);
delay(50);
// Start PDM capture
Serial.println("Voice: starting PDM...");
_vcSelf = this;
PDM.setPins(PIN_MIC_DATA, PIN_MIC_CLK, -1);
PDM.onReceive(_vcPdmISR);
if (!PDM.begin(1, VC_PDM_RATE)) {
Serial.println("Voice: PDM.begin failed");
codec2_destroy(_vCodec);
_vCodec = nullptr;
board.enableSpeaker(false);
return false;
}
PDM.setGain(80);
Serial.println("Voice: PDM started OK");
_vState = V_RECORDING;
_vRecStart = millis();
Serial.println("Voice: Recording started");
return true;
}
void voiceStopRecording() {
if (_vState != V_RECORDING) return;
// Stop PDM
PDM.end();
_vcSelf = nullptr;
// Encode any remaining samples
voiceProcessSamples();
// Keep Codec2 alive -- don't destroy between recordings
// (destroying and re-creating causes heap fragmentation)
// if (_vCodec) { codec2_destroy(_vCodec); _vCodec = nullptr; }
// Power down mic/speaker rail
board.enableSpeaker(false);
unsigned long dur = millis() - _vRecStart;
Serial.printf("Voice: Stopped -- %d frames, %d bytes, %lums\n",
_vEncFrames, _vEncBytes, dur);
_vState = (_vEncFrames > 0) ? V_REVIEW : V_IDLE;
}
// Process accumulated PDM samples into Codec2 frames.
// Called from poll() during recording.
void voiceProcessSamples() {
if (_vPdmCount < VC_PDM_FRAME) return;
if (_vEncBytes + VC_C2_FRAME_BYTES > VC_MAX_BYTES) return;
// TEST MODE: drain PDM buffer WITHOUT Codec2 encoding
// If this works, PDM pipeline is fine and issue is Codec2
int remaining = _vPdmCount - VC_PDM_FRAME;
if (remaining > 0) {
memmove((void*)_vPdmBuf, (const void*)&_vPdmBuf[VC_PDM_FRAME],
remaining * sizeof(int16_t));
}
_vPdmCount = remaining;
_vEncFrames++;
_vEncBytes += VC_C2_FRAME_BYTES; // fake it for display
}
// VE3 base36 encoding (compact wire format)
static int toBase36(uint32_t val, char* buf, int bufLen) {
static const char digits[] = "0123456789abcdefghijklmnopqrstuvwxyz";
if (bufLen < 2) return 0;
if (val == 0) { buf[0] = '0'; buf[1] = '\0'; return 1; }
char tmp[12]; int pos = 0;
while (val > 0 && pos < 11) { tmp[pos++] = digits[val % 36]; val /= 36; }
if (pos >= bufLen) pos = bufLen - 1;
for (int i = 0; i < pos; i++) buf[i] = tmp[pos - 1 - i];
buf[pos] = '\0';
return pos;
}
// --- Public voice API for main.cpp ---
public:
// Codec2 must be created from main loop (shallow stack).
// render() call chain is too deep for codec2_create's 3KB+ stack needs.
bool needsCodec2() const {
#if defined(HAS_MICROPHONE)
return _page == VOICE && !_vCodec;
#else
return false;
#endif
}
void setCodec2Instance(struct CODEC2* c2) {
#if defined(HAS_MICROPHONE)
_vCodec = c2;
#endif
}
bool isVoiceReview() const {
#if defined(HAS_MICROPHONE)
return _vState == V_REVIEW && _vEncFrames > 0;
#else
return false;
#endif
}
// Format VE3 envelope: "VE3:{sid}:{mode}:{total}:{dur}"
void voiceFormatEnvelope(char* buf, int bufLen, uint32_t sessionId) {
int payloadPerPkt = VC_MESH_PAYLOAD;
uint8_t totalPkts = (_vEncBytes + payloadPerPkt - 1) / payloadPerPkt;
uint8_t durSec = (uint8_t)(_vEncFrames * VC_C2_FRAME_MS / 1000);
char sid[12], mode[4], total[4], dur[4];
toBase36(sessionId, sid, sizeof(sid));
toBase36(VC_C2_MODE_ID, mode, sizeof(mode));
toBase36(totalPkts, total, sizeof(total));
toBase36(durSec, dur, sizeof(dur));
snprintf(buf, bufLen, "VE3:%s:%s:%s:%s", sid, mode, total, dur);
// Cache session
_vSessionId = sessionId;
_vSessionActive = true;
}
int voiceBuildPacket(uint8_t* buf, int bufLen, uint32_t sessionId, uint8_t pktIdx) {
if (!_vSessionActive || _vSessionId != sessionId) return 0;
uint32_t offset = (uint32_t)pktIdx * VC_MESH_PAYLOAD;
if (offset >= _vEncBytes) return 0;
uint32_t chunkLen = _vEncBytes - offset;
if (chunkLen > VC_MESH_PAYLOAD) chunkLen = VC_MESH_PAYLOAD;
if ((int)(VC_PKT_HDR_SIZE + chunkLen) > bufLen) return 0;
buf[0] = VC_PKT_MAGIC;
memcpy(&buf[1], &sessionId, 4);
buf[5] = pktIdx;
memcpy(&buf[6], &_vEncoded[offset], chunkLen);
return VC_PKT_HDR_SIZE + chunkLen;
}
uint8_t voiceGetPacketCount() const {
if (!_vSessionActive) return 0;
return (_vEncBytes + VC_MESH_PAYLOAD - 1) / VC_MESH_PAYLOAD;
}
void voiceOnSendComplete() {
_vSessionActive = false;
_vState = V_IDLE;
_vEncBytes = 0;
_vEncFrames = 0;
}
void voiceDiscard() {
_vSessionActive = false;
_vState = V_IDLE;
_vEncBytes = 0;
_vEncFrames = 0;
}
private:
#endif // HAS_MICROPHONE
int render(DisplayDriver& display) override {
char tmp[32];
display.setTextSize(1);
@@ -194,6 +445,73 @@ public:
break;
}
#if defined(HAS_MICROPHONE)
// ----- VOICE -----
case VOICE: {
switch (_vState) {
case V_IDLE:
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, Y0);
display.print("Voice");
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
display.print("Hold A: record");
break;
case V_RECORDING: {
unsigned long elapsed = (millis() - _vRecStart) / 1000;
int remaining = VC_MAX_SECONDS - (int)elapsed;
if (remaining < 0) remaining = 0;
display.setColor(DisplayDriver::RED);
display.setCursor(0, Y0);
display.print("RECORDING");
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, Y1);
snprintf(tmp, sizeof(tmp), "%ds left", remaining);
display.print(tmp);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
snprintf(tmp, sizeof(tmp), "Frames: %d", _vEncFrames);
display.print(tmp);
display.setCursor(0, Y3);
snprintf(tmp, sizeof(tmp), "%d bytes", _vEncBytes);
display.print(tmp);
return 200; // Fast refresh during recording
}
case V_REVIEW: {
float durSec = _vEncFrames * VC_C2_FRAME_MS / 1000.0f;
int packets = (_vEncBytes + VC_MESH_PAYLOAD - 1) / VC_MESH_PAYLOAD;
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, Y0);
display.print("Review");
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, Y1);
snprintf(tmp, sizeof(tmp), "%.1fs %d pkt", durSec, packets);
display.print(tmp);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, Y2);
snprintf(tmp, sizeof(tmp), "%d bytes", _vEncBytes);
display.print(tmp);
display.setCursor(0, Y3);
display.print("A:disc C:disc");
break;
}
} // voice state switch
break;
}
#endif
#if ENV_INCLUDE_GPS == 1
// ----- GPS -----
case GPS: {
@@ -444,6 +762,14 @@ public:
return true;
}
#if defined(HAS_MICROPHONE)
// During recording: any button press stops recording
if (_vState == V_RECORDING) {
voiceStopRecording();
return true;
}
#endif
if (c == KEY_NEXT || c == 'd') {
_page = (_page + 1) % PAGE_COUNT;
return true;
@@ -476,6 +802,25 @@ public:
}
return true;
#if defined(HAS_MICROPHONE)
case VOICE:
if (_vState == V_IDLE) {
if (voiceStartRecording()) {
return true;
} else {
_task->showAlert("Mic fail", 800);
return true;
}
} else if (_vState == V_RECORDING) {
voiceStopRecording();
return true;
} else if (_vState == V_REVIEW) {
voiceDiscard();
return true;
}
return false;
#endif
#if ENV_INCLUDE_GPS == 1
case GPS:
_task->toggleGPS();
@@ -508,5 +853,36 @@ public:
_task->shutdown();
}
}
#if defined(HAS_MICROPHONE)
// Stream-encode PDM samples during recording
if (_vState == V_RECORDING) {
// Periodic diagnostic — is PDM delivering data?
static unsigned long lastDiag = 0;
static uint32_t pollCount = 0;
pollCount++;
if (millis() - lastDiag > 500) {
lastDiag = millis();
Serial.printf("Voice poll #%lu: isr=%lu pdm=%d frames=%d\n",
(unsigned long)pollCount, (unsigned long)_vIsrCount,
_vPdmCount, _vEncFrames);
}
voiceProcessSamples();
// Auto-stop at max duration
if (_vEncFrames >= VC_MAX_FRAMES ||
(millis() - _vRecStart) >= (unsigned long)(VC_MAX_SECONDS * 1000 + 200)) {
voiceStopRecording();
}
}
#endif
}
};
};
// Static PDM callback -- must be defined after class so onPDMData() is visible
#if defined(HAS_MICROPHONE)
static void _vcPdmISR() {
if (_vcSelf) _vcSelf->onPDMData();
}
#endif
+17 -3
View File
@@ -7,6 +7,10 @@ extends = nrf52_base
board = lilygo_techo_card
platform_packages = framework-arduinoadafruitnrf52
board_build.ldscript = boards/nrf52840_s140_v6.ld
extra_scripts =
create-uf2.py
arch/nrf52/extra_scripts/patch_bluefruit.py
pre:patch_nrf52_bsp.py
; Point FrameworkArduinoVariant at a directory containing ONLY variant.h/cpp.
; Without this, PlatformIO tries to compile TechoCardBoard.cpp and target.cpp
; as part of the framework variant, which fails because MeshCore.h and
@@ -17,6 +21,8 @@ build_flags = ${nrf52_base.build_flags}
-I lib/nrf52/s140_nrf52_6.1.1_API/include
-I lib/nrf52/s140_nrf52_6.1.1_API/include/nrf52
-I variants/lilygo_techo_card
-I lib/PDM_nrf52/src
-I lib/codec2_nrf52/src
-D LILYGO_TECHO_CARD
-D RADIO_CLASS=CustomSX1262
-D WRAPPER_CLASS=CustomSX1262Wrapper
@@ -29,6 +35,7 @@ build_flags = ${nrf52_base.build_flags}
-D PIN_BOOT_BTN=24
-D ENV_INCLUDE_GPS=1
-D ENV_SKIP_GPS_DETECT
-D ps_calloc=calloc
-D DISABLE_DIAGNOSTIC_OUTPUT
-D AUTO_SHUTDOWN_MILLIVOLTS=2980
build_src_filter = ${nrf52_base.build_src_filter}
@@ -36,6 +43,11 @@ build_src_filter = ${nrf52_base.build_src_filter}
+<helpers/sensors/EnvironmentSensorManager.cpp>
+<helpers/ui/buzzer.cpp>
+<../variants/lilygo_techo_card>
+<../lib/PDM_nrf52/src>
+<../lib/PDM_nrf52/src/utility>
+<../lib/codec2_nrf52/src>
lib_compat_mode = off
lib_ldf_mode = deep+
lib_deps =
${nrf52_base.lib_deps}
stevemarple/MicroNMEA @ ^2.0.6
@@ -58,11 +70,13 @@ build_flags =
${lilygo_techo_card.build_flags}
-I examples/companion_radio
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=500
-D MAX_GROUP_CHANNELS=8
-D MAX_CONTACTS=250
-D MAX_GROUP_CHANNELS=4
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=64
-D OFFLINE_QUEUE_SIZE=16
-D CHANNEL_MSG_HISTORY_SIZE=1
-D AUTO_OFF_MILLIS=60000
-D ADVERT_PATH_TABLE_SIZE=200
; -D BLE_DEBUG_LOGGING=1
; -D MESH_PACKET_LOGGING=1
; -D MESH_DEBUG=1
+44
View File
@@ -0,0 +1,44 @@
#pragma once
// =============================================================================
// SD.h -- nRF52 shim for T-Echo Card
//
// Maps the Arduino SD library API to Adafruit InternalFileSystem so that
// screen headers (NotesScreen, MapScreen, TextReaderScreen, EpubZipReader)
// compile without modification. The T-Echo Card has no SD card slot --
// all file I/O goes to the 4MB QSPI flash via LittleFS.
//
// File class is already provided by the Adafruit LittleFS BSP.
// =============================================================================
#include <InternalFileSystem.h>
class SDClass {
public:
bool begin(int cs = -1) {
(void)cs;
return true; // InternalFS is initialised in main.cpp setup()
}
File open(const char* path, uint8_t mode = FILE_O_READ) {
return InternalFS.open(path, mode);
}
bool exists(const char* path) {
return InternalFS.exists(path);
}
bool mkdir(const char* path) {
return InternalFS.mkdir(path);
}
bool remove(const char* path) {
return InternalFS.remove(path);
}
bool rename(const char* from, const char* to) {
return InternalFS.rename(from, to);
}
};
static SDClass SD;
+6
View File
@@ -46,6 +46,12 @@ void radio_set_tx_power(int8_t dbm) {
radio.setOutputPower(dbm);
}
void radio_reset_agc() {
#ifdef SX126X_RX_BOOSTED_GAIN
radio.setRxBoostedGainMode(true);
#endif
}
mesh::LocalIdentity radio_new_identity() {
RadioNoiseListener rng(radio);
return mesh::LocalIdentity(&rng);
+1
View File
@@ -41,4 +41,5 @@ bool radio_init();
uint32_t radio_get_rng_seed();
void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr);
void radio_set_tx_power(int8_t dbm);
void radio_reset_agc();
mesh::LocalIdentity radio_new_identity();
+1
View File
@@ -139,6 +139,7 @@
// GPS -- L76K Multi-GNSS
#define HAS_GPS 1
#define GPS_EN_ACTIVE HIGH
#define GPS_BAUDRATE 9600
#define PIN_GPS_TX 21 // nRF TX -> GPS RX (vendor GPS_UART_RX / P0.21)
#define PIN_GPS_RX 19 // nRF RX <- GPS TX (vendor GPS_UART_TX / P0.19)