diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b1417f86..f69fa075 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -604,6 +604,13 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) { markConnectionActive(from); // in case this is from a server, and we have a connection + + // Detect VE3 voice envelope and notify voice handler + if (_voiceEnvHandler && text && strncmp(text, "VE3:", 4) == 0) { + MESH_DEBUG_PRINTLN("Voice: VE3 envelope from %s: %s", from.name, text); + _voiceEnvHandler(from.name, text); + } + queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text); } @@ -746,6 +753,31 @@ bool MyMesh::uiSendDirectMessage(uint32_t contact_idx, const char* text) { return true; } +bool MyMesh::uiSendRawToContact(uint32_t contact_idx, const uint8_t* data, uint8_t len) { + ContactInfo contact; + if (!getContactByIdx(contact_idx, contact)) return false; + + ContactInfo* recipient = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE); + if (!recipient) return false; + + // Raw custom packets are direct-route only — cannot flood + if (recipient->out_path_len == OUT_PATH_UNKNOWN) { + MESH_DEBUG_PRINTLN("UI: Raw send to %s failed — no direct path", recipient->name); + return false; + } + + mesh::Packet* pkt = createRawData(data, len); + if (!pkt) { + MESH_DEBUG_PRINTLN("UI: Raw send to %s failed — packet pool empty", recipient->name); + return false; + } + + sendDirect(pkt, recipient->out_path, recipient->out_path_len); + MESH_DEBUG_PRINTLN("UI: Raw sent %d bytes to %s (direct, path_len=0x%02X)", + len, recipient->name, recipient->out_path_len); + return true; +} + bool MyMesh::uiLoginToRepeater(uint32_t contact_idx, const char* password, uint32_t& est_timeout_ms) { ContactInfo contact; if (!getContactByIdx(contact_idx, contact)) { @@ -1095,6 +1127,32 @@ void MyMesh::onRawDataRecv(mesh::Packet *packet) { MESH_DEBUG_PRINTLN("onRawDataRecv(), payload_len too long: %d", packet->payload_len); return; } + + // Log ALL incoming raw packets for diagnosis + Serial.printf("onRawDataRecv: len=%d, magic=0x%02X, route=%s\n", + packet->payload_len, + packet->payload_len > 0 ? packet->payload[0] : 0, + packet->isRouteDirect() ? "direct" : "flood"); + + // Voice-over-LoRa (dz0ny VE3 protocol): intercept voice packets and fetch requests + // before forwarding to BLE companion. In standalone mode (no BLE), this is the + // only way to handle them. In BLE mode, we still intercept so on-device voice works. + if (packet->payload_len > 1 && _voiceHandler) { + uint8_t magic = packet->payload[0]; + if (magic == 0x56 || magic == 0x72) { // Voice data (V) or fetch request (r) + Serial.printf("onRawDataRecv: voice %s, payload_len=%d, first6=[%02X %02X %02X %02X %02X %02X]\n", + magic == 0x56 ? "PKT" : "FETCH", packet->payload_len, + packet->payload[0], + packet->payload_len > 1 ? packet->payload[1] : 0, + packet->payload_len > 2 ? packet->payload[2] : 0, + packet->payload_len > 3 ? packet->payload[3] : 0, + packet->payload_len > 4 ? packet->payload[4] : 0, + packet->payload_len > 5 ? packet->payload[5] : 0); + _voiceHandler(magic, packet->payload, packet->payload_len); + // Don't return — still forward to BLE companion if connected + } + } + int i = 0; out_frame[i++] = PUSH_CODE_RAW_DATA; out_frame[i++] = (int8_t)(_radio->getLastSNR() * 4); @@ -3073,14 +3131,17 @@ void MyMesh::loop() { // is there are pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { - if (!_store->isSaveInProgress()) { + if (_deferSaves) { + // Voice session receiving — push save forward to avoid SPI contention + dirty_contacts_expiry = futureMillis(2000); + } else if (!_store->isSaveInProgress()) { _store->beginSaveContacts(this); + dirty_contacts_expiry = 0; } - dirty_contacts_expiry = 0; } // Drive chunked contact save — write a batch each loop iteration - if (_store->isSaveInProgress()) { + if (_store->isSaveInProgress() && !_deferSaves) { if (!_store->saveContactsChunk(20)) { // 20 contacts per chunk (~3KB, ~30ms) _store->finishSaveContacts(); // Done or error — verify and commit } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 1ad8917d..3ffea0e3 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,11 +8,11 @@ #define FIRMWARE_VER_CODE 10 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "28 March 2026" +#define FIRMWARE_BUILD_DATE "29 March 2026" #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "Meck v1.5" +#define FIRMWARE_VERSION "Meck v1.6" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) @@ -133,6 +133,25 @@ public: // Send a direct message from the UI (no BLE dependency) bool uiSendDirectMessage(uint32_t contact_idx, const char* text); + // Send raw binary data to a contact (PAYLOAD_TYPE_RAW_CUSTOM, direct route only) + // Used for dz0ny VE3 voice protocol: voice packets (0x56) and fetch requests (0x72) + bool uiSendRawToContact(uint32_t contact_idx, const uint8_t* data, uint8_t len); + + // Voice-over-LoRa: callback for incoming raw voice packets (dz0ny VE3 protocol) + // magic 0x56 = voice data packet, 0x72 = fetch request + typedef void (*VoiceRawHandler)(uint8_t magic, const uint8_t* payload, uint8_t len); + void setVoiceHandler(VoiceRawHandler h) { _voiceHandler = h; } + + // Voice-over-LoRa: callback for incoming VE3 envelope in a DM + // Called with sender name and the VE3 text (e.g. "VE3:a:1:3:2") + typedef void (*VoiceEnvelopeHandler)(const char* senderName, const char* ve3Text); + void setVoiceEnvelopeHandler(VoiceEnvelopeHandler h) { _voiceEnvHandler = h; } + + // Defer contact saves while voice packets are being received + // (SD writes block SPI bus shared with LoRa radio) + void setDeferSaves(bool defer) { _deferSaves = defer; } + bool isDeferSaves() const { return _deferSaves; } + // Repeater admin - UI-initiated operations bool uiLoginToRepeater(uint32_t contact_idx, const char* password, uint32_t& est_timeout_ms); bool uiSendCliCommand(uint32_t contact_idx, const char* command); @@ -230,6 +249,9 @@ private: DataStore* _store; NodePrefs _prefs; + VoiceRawHandler _voiceHandler = nullptr; + VoiceEnvelopeHandler _voiceEnvHandler = nullptr; + bool _deferSaves = false; uint32_t pending_login; uint32_t pending_status; uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 02e5e4eb..51e92caa 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -74,7 +74,11 @@ #include "Audio.h" Audio* audio = nullptr; #endif + #ifdef MECK_AUDIO_VARIANT + #include "VoiceMessageScreen.h" + #endif static bool audiobookMode = false; + static bool voiceMode = false; #ifdef HAS_4G_MODEM #include "ModemManager.h" @@ -651,6 +655,78 @@ MyMesh the_mesh(radio_driver, fast_rng, rtc_clock, tables, store /* END GLOBAL OBJECTS */ +// --------------------------------------------------------------------------- +// Voice-over-LoRa: incoming raw packet handler (dz0ny VE3 protocol) +// Registered with the_mesh.setVoiceHandler() when voice screen is created. +// Called from onRawDataRecv for magic 0x56 (voice data) and 0x72 (fetch req). +// --------------------------------------------------------------------------- +#ifdef MECK_AUDIO_VARIANT +// Helper: ensure voice screen exists (lazy-init for incoming voice) +static VoiceMessageScreen* ensureVoiceScreen() { + VoiceMessageScreen* voiceScr = (VoiceMessageScreen*)ui_task.getVoiceScreen(); + if (!voiceScr) { + Serial.println("Voice: Auto-creating voice screen for incoming message"); + if (!audio) audio = new Audio(); + voiceScr = new VoiceMessageScreen(&ui_task, audio); + voiceScr->setSDReady(sdCardReady); + ui_task.setVoiceScreen(voiceScr); + } + return voiceScr; +} + +static void voiceRawCallback(uint8_t magic, const uint8_t* payload, uint8_t len) { + VoiceMessageScreen* voiceScr = ensureVoiceScreen(); + + if (magic == 0x72 && len >= 6) { + // Fetch request: [0x72][sessionId:4B][flags:1B][requesterKey6:6B][missingCount:1B][indices...] + uint32_t sessionId; + memcpy(&sessionId, &payload[1], 4); + Serial.printf("Voice: Fetch request for session 0x%08X\n", sessionId); + + if (voiceScr->getOutSessionId() == sessionId && voiceScr->hasValidOutSession()) { + uint8_t pktBuf[184]; + int totalPkts = voiceScr->getOutSessionPacketCount(); + Serial.printf("Voice: Serving %d packets for session 0x%08X\n", totalPkts, sessionId); + + // Requester's 6-byte key prefix is at payload[6..11]. + // Look them up to get their path for sendDirect. + if (len >= 12) { + ContactInfo* requester = the_mesh.lookupContactByPubKey(&payload[6], 6); + if (requester && requester->out_path_len != OUT_PATH_UNKNOWN) { + for (int p = 0; p < totalPkts; p++) { + int pktLen = voiceScr->buildVoicePacket(pktBuf, sizeof(pktBuf), sessionId, p); + if (pktLen > 0) { + mesh::Packet* raw = the_mesh.createRawData(pktBuf, pktLen); + if (raw) { + the_mesh.sendDirect(raw, requester->out_path, requester->out_path_len, p * 100); + } + } + } + Serial.printf("Voice: Served %d packets to %s\n", totalPkts, requester->name); + } else { + Serial.println("Voice: Fetch requester not found or no direct path"); + } + } + } else { + Serial.printf("Voice: No cached session 0x%08X for fetch\n", sessionId); + } + } else if (magic == 0x56 && len > 6) { + // Incoming voice data packet — feed to incoming session accumulator + voiceScr->onVoicePacketReceived(payload, len); + } +} + +// Voice envelope callback — called from MyMesh::onMessageRecv when a VE3: DM arrives +static void voiceEnvelopeCallback(const char* senderName, const char* ve3Text) { + VoiceMessageScreen* voiceScr = ensureVoiceScreen(); + voiceScr->onVE3Received(senderName, ve3Text); + // Defer SD contact saves while voice packets are arriving — + // SD writes block the SPI bus shared with LoRa radio + the_mesh.setDeferSaves(true); + Serial.println("Voice: Deferring contact saves during voice receive"); +} +#endif + // Last Heard: add/remove contact for selected entry. // Called from both touch double-tap (mapTouchTap) and keyboard Enter handler. #ifdef DISPLAY_CLASS @@ -1800,6 +1876,14 @@ void setup() { } #endif + // Register voice-over-LoRa callbacks early so incoming VE3 envelopes and + // raw voice packets are handled even before user opens the voice screen. + // The callbacks null-check the voice screen pointer, so they're safe at boot. + #ifdef MECK_AUDIO_VARIANT + the_mesh.setVoiceHandler(voiceRawCallback); + the_mesh.setVoiceEnvelopeHandler(voiceEnvelopeCallback); + #endif + Serial.printf("setup() complete — free heap: %d, largest block: %d\n", ESP.getFreeHeap(), ESP.getMaxAllocHeap()); MESH_DEBUG_PRINTLN("=== setup() - COMPLETE ==="); @@ -1991,6 +2075,159 @@ void loop() { } #endif + // Voice message: service mic DMA capture + playback audio decode + #if defined(LilyGo_TDeck_Pro) && defined(MECK_AUDIO_VARIANT) + { + VoiceMessageScreen* voiceScr = (VoiceMessageScreen*)ui_task.getVoiceScreen(); + if (voiceScr) { + voiceScr->voiceTick(); + + // Sync shared audio pointer — playFile() may have recreated Audio* + Audio* voiceAudio = voiceScr->getAudio(); + if (voiceAudio != audio) { + Serial.println("Voice: Syncing shared Audio* after recreation"); + audio = voiceAudio; + AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen(); + if (alarmScr) alarmScr->setAudio(audio); + } + + // Service audio decode for voice playback (shared Audio* object) + if (voiceScr->isAudioActive()) { + if (audio) audio->loop(); + cpuPower.setBoost(); + } + + // Detect end-of-playback and refresh UI + voiceScr->checkPlaybackFinished(); + if (voiceScr->consumePlaybackFinished()) { + ui_task.forceRefresh(); + } + + // --- Contact picker: load contacts when mode transitions to CONTACT_PICK --- + static bool pickContactsLoaded = false; + if (voiceScr->getMode() == VoiceMessageScreen::CONTACT_PICK) { + if (!pickContactsLoaded) { + // Build list of chat contacts with direct paths + VoiceMessageScreen::PickContact pickBuf[40]; + int pickCount = 0; + ContactInfo ci; + for (int idx = 0; idx < the_mesh.getNumContacts() && pickCount < 40; idx++) { + if (!the_mesh.getContactByIdx(idx, ci)) continue; + if (ci.type != ADV_TYPE_CHAT) continue; // Only chat nodes + if (ci.name[0] == '\0') continue; + pickBuf[pickCount].meshIdx = idx; + strncpy(pickBuf[pickCount].name, ci.name, 31); + pickBuf[pickCount].name[31] = '\0'; + pickBuf[pickCount].type = ci.type; + pickBuf[pickCount].hasDirect = (ci.out_path_len != OUT_PATH_UNKNOWN); + pickCount++; + } + // Sort: direct-path contacts first, then alphabetical within each group + std::sort(pickBuf, pickBuf + pickCount, + [](const VoiceMessageScreen::PickContact& a, const VoiceMessageScreen::PickContact& b) { + if (a.hasDirect != b.hasDirect) return a.hasDirect > b.hasDirect; + return strcasecmp(a.name, b.name) < 0; + }); + voiceScr->loadPickContacts(pickBuf, pickCount); + pickContactsLoaded = true; + ui_task.forceRefresh(); + } + } else { + pickContactsLoaded = false; + } + + // --- Detect confirmed send from contact picker --- + // Queue all packets at once using sendDirect's built-in delay parameter. + // This lets the Mesh Dispatcher handle timing internally, spacing + // transmissions so the SPI bus (shared with SD card) isn't contended. + int sendIdx = voiceScr->consumePendingSend(); + if (sendIdx >= 0 && voiceScr->hasCodec2Data()) { + cpuPower.setBoost(); + uint32_t sessionId = (uint32_t)(millis() & 0xFFFFFFFF); + + char envelope[64]; + voiceScr->formatEnvelope(envelope, sizeof(envelope), sessionId); + + ui_task.showAlert("Sending voice...", 10000); + bool dmOk = the_mesh.uiSendDirectMessage(sendIdx, envelope); + Serial.printf("Voice: VE3 DM '%s' to idx %d: %s\n", + envelope, sendIdx, dmOk ? "OK" : "FAIL"); + + if (dmOk) { + // Look up recipient for direct sendDirect calls + ContactInfo ci; + the_mesh.getContactByIdx(sendIdx, ci); + ContactInfo* recipient = the_mesh.lookupContactByPubKey(ci.id.pub_key, PUB_KEY_SIZE); + + int totalPkts = voiceScr->getOutSessionPacketCount(); + int sentPkts = 0; + + if (recipient && recipient->out_path_len != OUT_PATH_UNKNOWN) { + for (int p = 0; p < totalPkts; p++) { + uint8_t pktBuf[184]; + int pktLen = voiceScr->buildVoicePacket(pktBuf, sizeof(pktBuf), sessionId, p); + if (pktLen > 0) { + mesh::Packet* raw = the_mesh.createRawData(pktBuf, pktLen); + if (raw) { + // Stagger packets: first at 3s (after VE3 + ACK + contact save), + // each subsequent 3s apart. The Dispatcher queues them all now + // and transmits at the specified delay offsets. + uint32_t delayMs = 3000 + (uint32_t)p * 3000; + the_mesh.sendDirect(raw, recipient->out_path, recipient->out_path_len, delayMs); + sentPkts++; + Serial.printf("Voice: Queued packet %d/%d (delay %dms)\n", + p + 1, totalPkts, delayMs); + } + } + } + } + Serial.printf("Voice: Queued %d/%d voice packets to %s\n", + sentPkts, totalPkts, recipient ? recipient->name : "?"); + voiceScr->onSendComplete(sentPkts == totalPkts); + ui_task.showAlert(sentPkts == totalPkts ? "Voice sent!" : "Send partial", 2000); + } else { + voiceScr->onSendComplete(false); + ui_task.showAlert("Send failed!", 1500); + } + ui_task.forceRefresh(); + } + + // --- Auto-play incoming voice session when all packets received --- + if (voiceScr->isIncomingReady()) { + the_mesh.setDeferSaves(false); // Resume contact saves + Serial.println("Voice: Incoming session complete — auto-playing"); + cpuPower.setBoost(); + if (voiceScr->playIncoming()) { + ui_task.showAlert("Voice msg received!", 2000); + ui_task.gotoVoiceScreen(); + } else { + ui_task.showAlert("Voice decode failed", 1500); + } + ui_task.forceRefresh(); + } + + // Safety timeout: if saves are deferred for more than 15s, resume them + // (in case voice packets never arrive or session is abandoned) + static unsigned long deferStarted = 0; + if (the_mesh.isDeferSaves()) { + if (deferStarted == 0) deferStarted = millis(); + if (millis() - deferStarted > 15000) { + the_mesh.setDeferSaves(false); + deferStarted = 0; + Serial.println("Voice: Save defer timeout — resuming saves"); + } + } else { + deferStarted = 0; + } + + // During recording: keep CPU fast for DMA reads + if (voiceScr->isRecording()) { + cpuPower.setBoost(); + } + } + } + #endif + // SMS: poll for incoming messages from modem #ifdef HAS_4G_MODEM { @@ -2168,6 +2405,9 @@ void loop() { readerMode = ui_task.isOnTextReader(); notesMode = ui_task.isOnNotesScreen(); audiobookMode = ui_task.isOnAudiobookPlayer(); + #ifdef MECK_AUDIO_VARIANT + voiceMode = ui_task.isOnVoiceScreen(); + #endif #ifdef HAS_4G_MODEM smsMode = ui_task.isOnSMSScreen(); #endif @@ -2648,6 +2888,10 @@ void handleKeyboardInput() { } // In compose mode - handle text input + + // Ignore mic key press/release while composing text + if (key == KB_KEY_MIC || key == KB_KEY_MIC_RELEASE) return; + if (key == '\r') { // Enter - send the message Serial.println("Compose: Enter pressed, sending..."); @@ -2838,6 +3082,38 @@ void handleKeyboardInput() { } #endif // !HAS_4G_MODEM + // *** VOICE MESSAGE MODE *** + #ifdef MECK_AUDIO_VARIANT + if (voiceMode) { + VoiceMessageScreen* voiceScr = (VoiceMessageScreen*)ui_task.getVoiceScreen(); + if (!voiceScr) { voiceMode = false; } + else { + // Mic key press starts recording (PTT) + if (key == KB_KEY_MIC) { + voiceScr->onMicPress(); + ui_task.forceRefresh(); + return; + } + // Mic key release stops recording + if (key == KB_KEY_MIC_RELEASE) { + voiceScr->onMicRelease(); + ui_task.forceRefresh(); + return; + } + // Q from message list exits voice screen + if (key == 'q' && voiceScr->getMode() == VoiceMessageScreen::MESSAGE_LIST) { + Serial.println("Exiting voice message screen"); + ui_task.gotoHomeScreen(); + return; + } + // All other keys pass through to voice screen + voiceScr->handleInput(key); + ui_task.forceRefresh(); + return; + } + } + #endif // MECK_AUDIO_VARIANT + // *** TEXT READER MODE *** if (readerMode) { TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen(); @@ -3195,6 +3471,35 @@ void handleKeyboardInput() { #endif // Normal mode - not composing + + // Mic key release outside voice screen — ignore (PTT only matters on voice screen) + if (key == KB_KEY_MIC_RELEASE) return; + + // Mic key press from any non-modal screen — open voice message screen + #ifdef MECK_AUDIO_VARIANT + if (key == KB_KEY_MIC) { + Serial.println("Opening voice message screen (mic key)"); + if (!ui_task.getVoiceScreen()) { + Serial.printf("Voice: lazy init - free heap: %d, largest block: %d\n", + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + if (!audio) audio = new Audio(); + VoiceMessageScreen* voiceScr = new VoiceMessageScreen(&ui_task, audio); + voiceScr->setSDReady(sdCardReady); + ui_task.setVoiceScreen(voiceScr); + Serial.printf("Voice: init complete - free heap: %d\n", ESP.getFreeHeap()); + } else { + // Ensure Audio* is shared (may have been created by audiobook/alarm) + VoiceMessageScreen* voiceScr = (VoiceMessageScreen*)ui_task.getVoiceScreen(); + if (!audio) audio = new Audio(); + voiceScr->setAudio(audio); + } + ui_task.gotoVoiceScreen(); + // Don't start recording here — user tapped mic to navigate. + // Recording starts on mic press when already on voice screen. + return; + } + #endif + switch (key) { case 'c': // Open contacts list diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index e58948d7..d4af83d4 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -58,6 +58,7 @@ #include "SettingsScreen.h" #ifdef MECK_AUDIO_VARIANT #include "AudiobookPlayerScreen.h" +#include "VoiceMessageScreen.h" #endif #ifdef HAS_4G_MODEM #include "SMSScreen.h" @@ -1298,6 +1299,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no audiobook_screen = nullptr; // Created and assigned from main.cpp if audio hardware present #ifdef MECK_AUDIO_VARIANT alarm_screen = nullptr; // Created and assigned from main.cpp if audio hardware present + voice_screen = nullptr; // Created and assigned from main.cpp on first mic key press #endif #ifdef HAS_4G_MODEM sms_screen = new SMSScreen(this, node_prefs); @@ -2654,6 +2656,20 @@ void UITask::gotoAlarmScreen() { _auto_off = millis() + AUTO_OFF_MILLIS; _next_refresh = 100; } + +void UITask::gotoVoiceScreen() { + if (voice_screen == nullptr) return; + VoiceMessageScreen* voiceScr = (VoiceMessageScreen*)voice_screen; + if (_display != NULL) { + voiceScr->enter(*_display); + } + setCurrScreen(voice_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} #endif #ifdef HAS_4G_MODEM diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 876c852e..8bcbcb7c 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -88,6 +88,7 @@ class UITask : public AbstractUITask { UIScreen* audiobook_screen; // Audiobook player screen (null if not available) #ifdef MECK_AUDIO_VARIANT UIScreen* alarm_screen; // Alarm clock screen (audio variant only) + UIScreen* voice_screen; // Voice message screen (audio variant only) #endif #ifdef HAS_4G_MODEM UIScreen* sms_screen; // SMS messaging screen (4G variant only) @@ -188,6 +189,7 @@ public: void gotoAudiobookPlayer(); // Navigate to audiobook player #ifdef MECK_AUDIO_VARIANT void gotoAlarmScreen(); // Navigate to alarm clock + void gotoVoiceScreen(); // Navigate to voice message recorder #endif void gotoRepeaterAdmin(int contactIdx); // Navigate to repeater admin void gotoRepeaterAdminDirect(int contactIdx); // Auto-login admin (L key from conversation) @@ -240,6 +242,7 @@ public: bool isOnAudiobookPlayer() const { return curr == audiobook_screen; } #ifdef MECK_AUDIO_VARIANT bool isOnAlarmScreen() const { return curr == alarm_screen; } + bool isOnVoiceScreen() const { return curr == voice_screen; } #endif bool isOnRepeaterAdmin() const { return curr == repeater_admin; } bool isOnDiscoveryScreen() const { return curr == discovery_screen; } @@ -312,6 +315,8 @@ public: #ifdef MECK_AUDIO_VARIANT UIScreen* getAlarmScreen() const { return alarm_screen; } void setAlarmScreen(UIScreen* s) { alarm_screen = s; } + UIScreen* getVoiceScreen() const { return voice_screen; } + void setVoiceScreen(UIScreen* s) { voice_screen = s; } #endif UIScreen* getRepeaterAdminScreen() const { return repeater_admin; } UIScreen* getDiscoveryScreen() const { return discovery_screen; } diff --git a/examples/companion_radio/ui-new/Voicemessagescreen.h b/examples/companion_radio/ui-new/Voicemessagescreen.h new file mode 100644 index 00000000..4b743327 --- /dev/null +++ b/examples/companion_radio/ui-new/Voicemessagescreen.h @@ -0,0 +1,1531 @@ +#pragma once + +// ============================================================================= +// VoiceMessageScreen.h — Voice message recorder for LilyGo T-Deck Pro +// +// PROTOTYPE: Proves the PDM mic → PSRAM → WAV/SD → DAC playback pipeline. +// Codec2 encoding and LoRa transmission will be added in a later phase. +// +// Features: +// - PDM microphone capture via I2S_NUM_0 (time-shared with DAC) +// - 16kHz / 16-bit mono recording to PSRAM ring buffer +// - Save as WAV files on SD card (/voice/ directory) +// - Playback through PCM5102A DAC via shared Audio* object +// - Hold-to-talk: hold mic key to record, release to stop +// - 5-second max recording with progress bar +// - Review before send: play / re-record / delete +// +// Keyboard controls: +// MESSAGE_LIST: W/S = scroll, Enter = play selected, D = delete, Q = exit +// RECORDING: Mic release or 5s timeout stops recording +// REVIEW: Enter = play, Mic = re-record, D = delete, Q = back to list +// +// Guard: MECK_AUDIO_VARIANT (audio variant only — needs I2S DAC + PDM mic) +// ============================================================================= + +#ifdef MECK_AUDIO_VARIANT + +#include +#include +#include +#include +#include "Audio.h" +#include "variant.h" + +// Codec2 low-bitrate voice codec +#include + +// Forward declarations +class UITask; +class MyMesh; + +// --------------------------------------------------------------------------- +// dz0ny VE3 voice protocol constants +// --------------------------------------------------------------------------- +#define VOICE_PKT_MAGIC 0x56 // 'V' — voice data packet +#define VOICE_FETCH_MAGIC 0x72 // 'r' — voice fetch request +#define VOICE_PKT_HDR_SIZE 6 // magic(1) + sessionID(4) + index(1) +#define VOICE_SESSION_TTL_MS 900000 // 15 minutes cache TTL +#define VOICE_C2_MODE_ID 1 // Codec2 1200bps mode ID for VE3 protocol + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- +#define VOICE_FOLDER "/voice" +#define VOICE_MAX_SECONDS 5 +#define VOICE_SAMPLE_RATE 16000 +#define VOICE_BITS 16 +#define VOICE_CHANNELS 1 + +// Codec2 encoding config +#define VOICE_C2_MODE CODEC2_MODE_1200 // 1200bps — AM radio quality +#define VOICE_C2_RATE 8000 // Codec2 native sample rate +#define VOICE_C2_FRAME_MS 40 // Frame duration at 1200bps +#define VOICE_C2_FRAME_SAM 320 // Samples per frame (8kHz × 40ms) +#define VOICE_C2_FRAME_BYTES 6 // Encoded bytes per frame (48 bits) +// Max encoded size: 5 seconds = 125 frames × 6 bytes = 750 bytes +#define VOICE_C2_MAX_BYTES ((VOICE_MAX_SECONDS * 1000 / VOICE_C2_FRAME_MS) * VOICE_C2_FRAME_BYTES) +// Usable codec2 data per raw voice packet. +// Keep under ~150 to avoid hitting MAX_PACKET_PAYLOAD (184) boundary issues +// with radio/SPI. Total packet = VOICE_PKT_HDR_SIZE(6) + data. +#define VOICE_MESH_PAYLOAD 150 + +// Buffer: 16kHz × 16-bit × 5s = 160,000 bytes — fits easily in PSRAM +#define VOICE_BUF_SAMPLES (VOICE_SAMPLE_RATE * VOICE_MAX_SECONDS) +#define VOICE_BUF_BYTES (VOICE_BUF_SAMPLES * sizeof(int16_t)) + +// I2S port for PDM mic — ESP32-S3 only supports PDM RX on I2S_NUM_0. +// This conflicts with ESP32-audioI2S (DAC output), so we time-share: +// stop audio before recording, uninstall driver after, let audio lib reclaim. +#define VOICE_I2S_PORT I2S_NUM_0 + +// DMA buffer config for mic capture +// E-ink refreshes block the CPU for ~650ms. At 16kHz, that's 10,400 samples. +// We need enough DMA buffer to hold audio during those blocks. +// 16 × 1024 = 16,384 samples ≈ 1 second — survives one full refresh cycle. +#define VOICE_DMA_BUF_COUNT 16 +#define VOICE_DMA_BUF_LEN 1024 + +// Max files shown in the list +#define VOICE_MAX_FILES 50 + +// --------------------------------------------------------------------------- +// WAV header writer (44-byte RIFF/WAVE PCM header) +// --------------------------------------------------------------------------- +static void writeWavHeader(File& f, uint32_t dataBytes, uint32_t sampleRate, + uint16_t bitsPerSample, uint16_t channels) { + uint32_t byteRate = sampleRate * channels * (bitsPerSample / 8); + uint16_t blockAlign = channels * (bitsPerSample / 8); + uint32_t chunkSize = 36 + dataBytes; + + f.write((const uint8_t*)"RIFF", 4); + f.write((const uint8_t*)&chunkSize, 4); + f.write((const uint8_t*)"WAVE", 4); + f.write((const uint8_t*)"fmt ", 4); + uint32_t fmtSize = 16; + f.write((const uint8_t*)&fmtSize, 4); + uint16_t audioFmt = 1; // PCM + f.write((const uint8_t*)&audioFmt, 2); + f.write((const uint8_t*)&channels, 2); + f.write((const uint8_t*)&sampleRate, 4); + f.write((const uint8_t*)&byteRate, 4); + f.write((const uint8_t*)&blockAlign, 2); + f.write((const uint8_t*)&bitsPerSample, 2); + f.write((const uint8_t*)"data", 4); + f.write((const uint8_t*)&dataBytes, 4); +} + +// --------------------------------------------------------------------------- +// VoiceFileEntry — one file in the /voice/ directory +// --------------------------------------------------------------------------- +struct VoiceFileEntry { + char name[64]; // Filename only (no path) + uint32_t sizeBytes; // File size + float durationSec; +}; + +// --------------------------------------------------------------------------- +// VoiceMessageScreen +// --------------------------------------------------------------------------- +class VoiceMessageScreen : public UIScreen { +public: + enum Mode { MESSAGE_LIST, RECORDING, REVIEW, CONTACT_PICK }; + + // Contact picker entry — populated by main.cpp from the_mesh contacts + struct PickContact { + int meshIdx; // Index in the_mesh contacts array + char name[32]; + uint8_t type; // ADV_TYPE_* + bool hasDirect; // Has direct path (not OUT_PATH_UNKNOWN) + }; + +private: + UITask* _task; + Audio* _audio; // Shared Audio* for playback (set from main.cpp) + Mode _mode; + bool _sdReady; + bool _i2sInitialized; // DAC I2S init (via Audio*) + bool _micInitialized; // PDM mic I2S init + bool _dacPowered; + DisplayDriver* _displayRef; + + // File browser + std::vector _fileList; + int _selectedFile; + int _scrollOffset; + + // Recording state + int16_t* _recBuffer; // PSRAM-allocated capture buffer + uint32_t _recSamples; // Samples captured so far + bool _recording; // Currently capturing + unsigned long _recStartMillis; // When recording started + + // Review state — just-recorded file + char _reviewFilename[64]; // Filename of the just-recorded WAV + bool _reviewPlaying; // Currently playing back the review file + bool _reviewDirty; // Screen needs redraw after playback state change + + // Playback from list + bool _listPlaying; + int _listPlayIdx; + + // Playback finished detection (for UI refresh) + bool _playbackJustFinished; + + // Codec2 encoded data (from last recording) + uint8_t _c2Data[VOICE_C2_MAX_BYTES]; // Encoded Codec2 frames + uint32_t _c2Bytes; // Total encoded bytes + uint32_t _c2Frames; // Number of encoded frames + bool _c2Valid; // Encoding succeeded + + // --- VE3 voice session cache (outgoing) --- + // Cached after send so we can serve fetch requests from the receiver. + struct VoiceSession { + uint32_t sessionId; + uint8_t data[VOICE_C2_MAX_BYTES]; + uint32_t dataBytes; + uint8_t totalPackets; + uint8_t durationSec; + unsigned long cachedAt; // millis() when cached + bool active; + }; + VoiceSession _outSession; // Single outgoing session (most recent send) + + // --- Incoming voice session (received from another device) --- + struct IncomingSession { + uint32_t sessionId; + uint8_t data[VOICE_C2_MAX_BYTES]; // Accumulated Codec2 data + uint16_t pktOffset[8]; // Byte offset for each packet's data + uint16_t pktSize[8]; // Byte count for each packet's codec2 chunk + uint8_t totalPackets; // Expected total (from VE3 envelope) + uint8_t receivedBitmap; // Bitmask of received packet indices + uint8_t receivedCount; // Number of distinct packets received + uint32_t dataBytes; // Total accumulated bytes + uint8_t durationSec; + char senderName[32]; + unsigned long startedAt; + bool active; + bool complete; // All packets received + bool playTriggered; // Auto-play already fired + }; + IncomingSession _inSession; + + // --- Contact picker state --- + std::vector _pickList; + int _pickSelected; + int _pickScroll; + int _pendingSendIdx; // Contact idx for pending send (-1 = none) + + // DAC power control (same as AudiobookPlayerScreen) + void enableDAC() { + pinMode(41, OUTPUT); + digitalWrite(41, HIGH); + if (!_dacPowered) delay(50); + _dacPowered = true; + } + + void disableDAC() { + digitalWrite(41, LOW); + _dacPowered = false; + } + + // --------------------------------------------------------------------------- + // PDM Microphone — I2S_NUM_0 in PDM RX mode + // ESP32-S3 only supports PDM on I2S_NUM_0, which ESP32-audioI2S also uses. + // We must stop audio playback and tear down the existing driver first. + // --------------------------------------------------------------------------- + bool initMic() { + if (_micInitialized) return true; + + // Stop any active audio playback — we're about to take over I2S_NUM_0 + if (_audio) { + if (_audio->isRunning()) { + _audio->stopSong(); + Serial.println("Voice: Stopped audio playback to free I2S_NUM_0"); + } + } + + // Tear down any existing I2S driver on port 0 (ESP32-audioI2S leaves it installed). + // Ignore errors — it might not be installed yet. + i2s_driver_uninstall(VOICE_I2S_PORT); + delay(10); // Let hardware settle + + // After we release I2S_NUM_0 back, ESP32-audioI2S will need to reconfigure + _i2sInitialized = false; + + i2s_config_t mic_cfg = {}; + mic_cfg.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX | I2S_MODE_PDM); + mic_cfg.sample_rate = VOICE_SAMPLE_RATE; + mic_cfg.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT; + mic_cfg.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT; + mic_cfg.communication_format = I2S_COMM_FORMAT_STAND_I2S; + mic_cfg.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1; + mic_cfg.dma_buf_count = VOICE_DMA_BUF_COUNT; + mic_cfg.dma_buf_len = VOICE_DMA_BUF_LEN; + mic_cfg.use_apll = false; + mic_cfg.tx_desc_auto_clear = false; + mic_cfg.fixed_mclk = 0; + + esp_err_t err = i2s_driver_install(VOICE_I2S_PORT, &mic_cfg, 0, NULL); + if (err != ESP_OK) { + Serial.printf("Voice: i2s_driver_install failed: %d\n", err); + return false; + } + + i2s_pin_config_t mic_pins = {}; + mic_pins.bck_io_num = I2S_PIN_NO_CHANGE; + mic_pins.ws_io_num = BOARD_MIC_CLOCK; // GPIO 18 — PDM CLK + mic_pins.data_out_num = I2S_PIN_NO_CHANGE; + mic_pins.data_in_num = BOARD_MIC_DATA; // GPIO 17 — PDM DATA + + err = i2s_set_pin(VOICE_I2S_PORT, &mic_pins); + if (err != ESP_OK) { + Serial.printf("Voice: i2s_set_pin failed: %d\n", err); + i2s_driver_uninstall(VOICE_I2S_PORT); + return false; + } + + _micInitialized = true; + Serial.println("Voice: PDM mic initialised on I2S_NUM_0"); + return true; + } + + void deinitMic() { + if (!_micInitialized) return; + i2s_driver_uninstall(VOICE_I2S_PORT); + _micInitialized = false; + // _i2sInitialized already cleared in initMic() — ESP32-audioI2S + // will reconfigure I2S_NUM_0 on next connecttoFS() call. + Serial.println("Voice: PDM mic deinitialised, I2S_NUM_0 released"); + } + + // Allocate PSRAM capture buffer (once, reused across recordings) + bool ensureRecBuffer() { + if (_recBuffer) return true; + _recBuffer = (int16_t*)ps_calloc(VOICE_BUF_SAMPLES, sizeof(int16_t)); + if (!_recBuffer) { + Serial.println("Voice: PSRAM alloc failed for rec buffer"); + return false; + } + Serial.printf("Voice: Allocated %d bytes PSRAM for recording buffer\n", VOICE_BUF_BYTES); + return true; + } + + // --------------------------------------------------------------------------- + // Recording + // --------------------------------------------------------------------------- + bool startRecording() { + if (!ensureRecBuffer()) return false; + if (!initMic()) return false; + + // Flush any stale DMA data + uint8_t flush[512]; + size_t bytesRead; + for (int i = 0; i < 10; i++) { + i2s_read(VOICE_I2S_PORT, flush, sizeof(flush), &bytesRead, 0); + } + + _recSamples = 0; + _recording = true; + _recStartMillis = millis(); + Serial.println("Voice: Recording started"); + return true; + } + + // Called from voiceTick() — drains all accumulated DMA data into PSRAM buffer. + // After e-ink refreshes (~650ms blocking), the DMA may hold thousands of + // samples. We loop until the read times out to drain everything. + void captureChunk() { + if (!_recording) return; + + for (;;) { + uint32_t remaining = VOICE_BUF_SAMPLES - _recSamples; + if (remaining == 0) break; + + size_t bytesRead = 0; + uint32_t toRead = remaining < 2048 ? remaining : 2048; + esp_err_t err = i2s_read(VOICE_I2S_PORT, + &_recBuffer[_recSamples], + toRead * sizeof(int16_t), + &bytesRead, + 5); // 5ms timeout — short so we yield quickly when empty + if (err != ESP_OK || bytesRead == 0) break; // DMA empty, done for now + _recSamples += bytesRead / sizeof(int16_t); + } + + // Auto-stop at max duration — save immediately and enter review + if (_recSamples >= VOICE_BUF_SAMPLES) { + stopRecording(); + if (saveRecordingToSD()) { + _mode = REVIEW; + } else { + _mode = MESSAGE_LIST; + } + } + } + + void stopRecording() { + if (!_recording) return; + _recording = false; + + unsigned long elapsed = millis() - _recStartMillis; + float secs = _recSamples / (float)VOICE_SAMPLE_RATE; + Serial.printf("Voice: Recording stopped — %d samples (%.1fs, %lums elapsed)\n", + _recSamples, secs, elapsed); + + // Deinit mic to free I2S port while not recording + deinitMic(); + } + + // --------------------------------------------------------------------------- + // Save to SD as WAV + // --------------------------------------------------------------------------- + + // Normalize recorded audio to near-maximum amplitude. + // PDM mics often capture at low levels; this brings the signal up + // so playback is audible through the line-level PCM5102A DAC. + void normalizeRecording() { + if (_recSamples == 0) return; + + // Find peak absolute value + int16_t peak = 0; + for (uint32_t i = 0; i < _recSamples; i++) { + int16_t s = _recBuffer[i]; + int16_t absS = (s < 0) ? -s : s; + if (absS > peak) peak = absS; + } + + if (peak < 100) { + Serial.println("Voice: Recording is near-silent, skipping normalization"); + return; + } + + // Target peak at 90% of max to avoid clipping artefacts + // Use fixed-point: gain = (29491 << 16) / peak, apply as (sample * gain) >> 16 + int32_t target = 29491; // 0.9 * 32767 + int32_t gain16 = (target << 16) / peak; // Fixed-point 16.16 + + Serial.printf("Voice: Normalizing — peak=%d, gain=%.1fx\n", + peak, gain16 / 65536.0f); + + for (uint32_t i = 0; i < _recSamples; i++) { + int32_t amplified = ((int32_t)_recBuffer[i] * gain16) >> 16; + // Clamp to int16_t range (shouldn't be needed with 90% target, but safe) + if (amplified > 32767) amplified = 32767; + if (amplified < -32768) amplified = -32768; + _recBuffer[i] = (int16_t)amplified; + } + } + + // --------------------------------------------------------------------------- + // Codec2 encoding — downsample 16kHz→8kHz, encode at 1200bps + // Processes one frame at a time to avoid needing a large scratch buffer. + // --------------------------------------------------------------------------- + void encodeCodec2() { + _c2Bytes = 0; + _c2Frames = 0; + _c2Valid = false; + + if (_recSamples < VOICE_C2_FRAME_SAM * 2) { + Serial.println("Voice: Too few samples for Codec2 encoding"); + return; + } + + // Create Codec2 encoder + struct CODEC2* c2 = codec2_create(VOICE_C2_MODE); + if (!c2) { + Serial.println("Voice: codec2_create failed"); + return; + } + + int frameSamples = codec2_samples_per_frame(c2); // 320 at 8kHz + int frameBytes = (codec2_bits_per_frame(c2) + 7) / 8; // 6 at 1200bps + // Each 8kHz frame needs 2× as many 16kHz source samples + int srcSamplesPerFrame = frameSamples * 2; // 640 at 16kHz + + // Pad to complete frame boundary so the last few hundred ms aren't lost. + // PSRAM buffer was allocated with ps_calloc (zero-filled), so any samples + // beyond _recSamples are already silence. + uint32_t remainder = _recSamples % srcSamplesPerFrame; + if (remainder > 0 && _recSamples + (srcSamplesPerFrame - remainder) <= VOICE_BUF_SAMPLES) { + _recSamples += (srcSamplesPerFrame - remainder); + } + + Serial.printf("Voice: Codec2 1200bps — %d samples/frame (8kHz), %d bytes/frame\n", + frameSamples, frameBytes); + + // Downsample + encode one frame at a time + int16_t frameBuf[VOICE_C2_FRAME_SAM]; // 320 × 2 = 640 bytes on stack — fine + uint32_t srcPos = 0; + + while (srcPos + srcSamplesPerFrame <= _recSamples && + _c2Bytes + frameBytes <= VOICE_C2_MAX_BYTES) { + // Downsample this frame: average pairs of 16kHz samples → 8kHz + for (int i = 0; i < frameSamples; i++) { + int32_t sum = (int32_t)_recBuffer[srcPos + i * 2] + + (int32_t)_recBuffer[srcPos + i * 2 + 1]; + frameBuf[i] = (int16_t)(sum / 2); + } + + // Encode this frame + codec2_encode(c2, &_c2Data[_c2Bytes], frameBuf); + _c2Bytes += frameBytes; + _c2Frames++; + srcPos += srcSamplesPerFrame; + } + + codec2_destroy(c2); + _c2Valid = (_c2Frames > 0); + + int packets = (_c2Bytes + VOICE_MESH_PAYLOAD - 1) / VOICE_MESH_PAYLOAD; + Serial.printf("Voice: Codec2 encoded — %d frames, %d bytes (%.1fs, %d mesh packets)\n", + _c2Frames, _c2Bytes, + _c2Frames * VOICE_C2_FRAME_MS / 1000.0f, + packets); + } + + bool saveRecordingToSD() { + if (_recSamples < VOICE_SAMPLE_RATE / 4) { + // Less than 250ms — too short, discard + Serial.println("Voice: Recording too short, discarding"); + return false; + } + + // Ensure /voice/ directory exists + if (!SD.exists(VOICE_FOLDER)) { + SD.mkdir(VOICE_FOLDER); + } + + // Generate filename: voice_YYYYMMDD_HHMMSS.wav + // (we don't have strftime, so use millis-based counter as fallback) + uint32_t ts = millis() / 1000; + snprintf(_reviewFilename, sizeof(_reviewFilename), + "voice_%06lu.wav", ts % 1000000UL); + + char fullPath[96]; + snprintf(fullPath, sizeof(fullPath), "%s/%s", VOICE_FOLDER, _reviewFilename); + + File f = SD.open(fullPath, FILE_WRITE); + if (!f) { + Serial.printf("Voice: Failed to create %s\n", fullPath); + return false; + } + + // Normalize audio levels before saving + normalizeRecording(); + + // Encode to Codec2 (must happen before WAV write, uses PSRAM scratch space) + encodeCodec2(); + + uint32_t dataBytes = _recSamples * sizeof(int16_t); + writeWavHeader(f, dataBytes, VOICE_SAMPLE_RATE, VOICE_BITS, VOICE_CHANNELS); + f.write((const uint8_t*)_recBuffer, dataBytes); + f.close(); + + Serial.printf("Voice: Saved %s (%d bytes, %.1fs)\n", + fullPath, 44 + dataBytes, _recSamples / (float)VOICE_SAMPLE_RATE); + return true; + } + + // --------------------------------------------------------------------------- + // Playback via shared Audio* (ESP32-audioI2S) + // After recording, deinitMic() uninstalled the I2S driver on port 0. + // The Audio object still holds internal state expecting it to be there. + // We must reinstall the I2S driver in TX mode BEFORE calling any Audio + // methods (including stopSong, setVolume, connecttoFS) because they all + // eventually call i2s_zero_dma_buffer() which crashes on a missing driver. + // --------------------------------------------------------------------------- + void reinstallI2SForPlayback() { + // Ensure I2S_NUM_0 has a valid TX driver so Audio destructor won't crash + i2s_driver_uninstall(VOICE_I2S_PORT); // May fail — that's OK + delay(5); + + i2s_config_t tx_cfg = {}; + tx_cfg.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX); + tx_cfg.sample_rate = 44100; + tx_cfg.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT; + tx_cfg.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT; + tx_cfg.communication_format = I2S_COMM_FORMAT_STAND_I2S; + tx_cfg.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1; + tx_cfg.dma_buf_count = 8; + tx_cfg.dma_buf_len = 1024; + tx_cfg.use_apll = false; + tx_cfg.tx_desc_auto_clear = true; + + esp_err_t err = i2s_driver_install(VOICE_I2S_PORT, &tx_cfg, 0, NULL); + if (err != ESP_OK) { + Serial.printf("Voice: reinstall I2S TX failed: %d\n", err); + } + Serial.println("Voice: Reinstalled I2S_NUM_0 in TX mode"); + } + + bool playFile(const char* filename) { + if (!_audio) { + Serial.println("Voice: No Audio* object for playback"); + return false; + } + + enableDAC(); + + // If mic recording tore down I2S_NUM_0, the Audio object's internal I2S + // state is stale — it can't reconfigure sample rate for the WAV file. + // Fix: reinstall a valid TX driver first (so Audio destructor won't crash + // on i2s_zero_dma_buffer), then delete and recreate the Audio object + // to get fresh internal state. This matches the audiobook's pattern + // where new Audio() after voice recording works correctly. + if (!_i2sInitialized) { + reinstallI2SForPlayback(); + Serial.println("Voice: Recreating Audio object for clean I2S state"); + delete _audio; + _audio = new Audio(); + bool ok = _audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT, 0); + if (!ok) ok = _audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT); + if (!ok) Serial.println("Voice: DAC setPinout FAILED"); + _i2sInitialized = true; + } + + char fullPath[96]; + snprintf(fullPath, sizeof(fullPath), "%s/%s", VOICE_FOLDER, filename); + + _audio->setVolume(21); // Max volume for voice playback + bool ok = _audio->connecttoFS(SD, fullPath); + if (!ok) { + Serial.printf("Voice: Failed to open %s for playback\n", fullPath); + return false; + } + + Serial.printf("Voice: Playing %s\n", fullPath); + return true; + } + + void stopPlayback() { + if (_audio && _i2sInitialized) { + _audio->stopSong(); + } + _reviewPlaying = false; + _listPlaying = false; + } + + // --------------------------------------------------------------------------- + // File list scanning + // --------------------------------------------------------------------------- + void scanVoiceFolder() { + _fileList.clear(); + _selectedFile = 0; + _scrollOffset = 0; + + if (!SD.exists(VOICE_FOLDER)) { + SD.mkdir(VOICE_FOLDER); + return; + } + + File dir = SD.open(VOICE_FOLDER); + if (!dir || !dir.isDirectory()) return; + + File entry; + while ((entry = dir.openNextFile()) && _fileList.size() < VOICE_MAX_FILES) { + String name = entry.name(); + // Only show .wav files + if (!name.endsWith(".wav") && !name.endsWith(".WAV")) { + entry.close(); + continue; + } + + VoiceFileEntry vfe; + strncpy(vfe.name, name.c_str(), sizeof(vfe.name) - 1); + vfe.name[sizeof(vfe.name) - 1] = '\0'; + vfe.sizeBytes = entry.size(); + // Estimate duration from file size (subtract 44-byte header) + uint32_t dataBytes = (vfe.sizeBytes > 44) ? (vfe.sizeBytes - 44) : 0; + vfe.durationSec = dataBytes / (float)(VOICE_SAMPLE_RATE * sizeof(int16_t)); + + _fileList.push_back(vfe); + entry.close(); + } + dir.close(); + + // Sort by name (newest first, since filenames are timestamp-based) + std::sort(_fileList.begin(), _fileList.end(), + [](const VoiceFileEntry& a, const VoiceFileEntry& b) { + return strcmp(a.name, b.name) > 0; // Descending + }); + + Serial.printf("Voice: Scanned %d files in %s\n", _fileList.size(), VOICE_FOLDER); + } + + bool deleteFile(const char* filename) { + char fullPath[96]; + snprintf(fullPath, sizeof(fullPath), "%s/%s", VOICE_FOLDER, filename); + if (SD.remove(fullPath)) { + Serial.printf("Voice: Deleted %s\n", fullPath); + return true; + } + Serial.printf("Voice: Failed to delete %s\n", fullPath); + return false; + } + + // --------------------------------------------------------------------------- + // VE3 Voice Protocol (dz0ny) — helpers + // --------------------------------------------------------------------------- + + // Base36 encode a uint32 into a string buffer (compact wire format) + static int toBase36(uint32_t val, char* buf, int bufLen) { + if (bufLen < 2) return 0; + if (val == 0) { buf[0] = '0'; buf[1] = '\0'; return 1; } + char tmp[16]; + int pos = 0; + while (val > 0 && pos < 15) { + uint8_t d = val % 36; + tmp[pos++] = d < 10 ? ('0' + d) : ('a' + d - 10); + val /= 36; + } + // Reverse + int len = pos < bufLen - 1 ? pos : bufLen - 1; + for (int i = 0; i < len; i++) buf[i] = tmp[pos - 1 - i]; + buf[len] = '\0'; + return len; + } + + // Format VE3 envelope string: VE3:{sid}:{mode}:{total}:{durS} + void formatVE3(char* buf, int bufLen, uint32_t sessionId, + uint8_t totalPackets, uint8_t durationSec) { + char sid[12], mode[4], total[4], dur[4]; + toBase36(sessionId, sid, sizeof(sid)); + toBase36(VOICE_C2_MODE_ID, mode, sizeof(mode)); + toBase36(totalPackets, total, sizeof(total)); + toBase36(durationSec, dur, sizeof(dur)); + snprintf(buf, bufLen, "VE3:%s:%s:%s:%s", sid, mode, total, dur); + } + + // Cache outgoing session for serving fetch requests + void cacheOutSession(uint32_t sessionId) { + _outSession.sessionId = sessionId; + memcpy(_outSession.data, _c2Data, _c2Bytes); + _outSession.dataBytes = _c2Bytes; + // Calculate packet count + int payloadPerPkt = VOICE_MESH_PAYLOAD; + _outSession.totalPackets = (_c2Bytes + payloadPerPkt - 1) / payloadPerPkt; + _outSession.durationSec = (uint8_t)(_c2Frames * VOICE_C2_FRAME_MS / 1000); + _outSession.cachedAt = millis(); + _outSession.active = true; + Serial.printf("Voice: Session 0x%08X cached — %d bytes, %d packets, %ds\n", + sessionId, _c2Bytes, _outSession.totalPackets, _outSession.durationSec); + } + + // Base36 decode (compact wire format from VE3 envelope) + static uint32_t fromBase36(const char* s) { + uint32_t val = 0; + while (*s) { + val *= 36; + char c = *s++; + if (c >= '0' && c <= '9') val += c - '0'; + else if (c >= 'a' && c <= 'z') val += 10 + c - 'a'; + else if (c >= 'A' && c <= 'Z') val += 10 + c - 'A'; + } + return val; + } + + // --------------------------------------------------------------------------- + // Incoming voice session — accumulate packets, decode when complete + // --------------------------------------------------------------------------- + + // Decode Codec2 data into WAV and play through DAC + bool decodeAndPlayIncoming() { + if (!_inSession.complete || _inSession.dataBytes == 0) return false; + + Serial.printf("Voice: Decoding incoming session 0x%08X — %d bytes from %s\n", + _inSession.sessionId, _inSession.dataBytes, _inSession.senderName); + + // Reassemble codec2 data in packet order (packets may arrive out of order) + uint8_t ordered[VOICE_C2_MAX_BYTES]; + uint32_t orderedLen = 0; + for (int p = 0; p < _inSession.totalPackets && p < 8; p++) { + if (_inSession.pktSize[p] > 0 && orderedLen + _inSession.pktSize[p] <= VOICE_C2_MAX_BYTES) { + memcpy(&ordered[orderedLen], &_inSession.data[_inSession.pktOffset[p]], _inSession.pktSize[p]); + orderedLen += _inSession.pktSize[p]; + } + } + + Serial.printf("Voice: Reassembled %d bytes in order from %d packets\n", + orderedLen, _inSession.totalPackets); + + // Create Codec2 decoder + struct CODEC2* c2 = codec2_create(VOICE_C2_MODE); + if (!c2) { + Serial.println("Voice: codec2_create failed for decode"); + return false; + } + + int frameSamples = codec2_samples_per_frame(c2); // 320 at 8kHz + int frameBytes = (codec2_bits_per_frame(c2) + 7) / 8; // 6 at 1200bps + + // Decode all frames into PSRAM buffer (reuse _recBuffer, upsampled to 16kHz) + if (!ensureRecBuffer()) { + codec2_destroy(c2); + return false; + } + + uint32_t srcPos = 0; + uint32_t dstPos = 0; + int16_t frameBuf[VOICE_C2_FRAME_SAM]; // 320 samples at 8kHz + + while (srcPos + frameBytes <= orderedLen && + dstPos + frameSamples * 2 <= VOICE_BUF_SAMPLES) { + // Decode one frame to 8kHz + codec2_decode(c2, frameBuf, &ordered[srcPos]); + srcPos += frameBytes; + + // Upsample 8kHz → 16kHz by duplicating each sample + for (int i = 0; i < frameSamples; i++) { + _recBuffer[dstPos++] = frameBuf[i]; + _recBuffer[dstPos++] = frameBuf[i]; + } + } + + codec2_destroy(c2); + _recSamples = dstPos; + + float secs = _recSamples / (float)VOICE_SAMPLE_RATE; + Serial.printf("Voice: Decoded %d frames → %d samples (%.1fs at 16kHz)\n", + (int)(srcPos / frameBytes), _recSamples, secs); + + // Save as WAV for playback + uint32_t ts = millis() / 1000; + snprintf(_reviewFilename, sizeof(_reviewFilename), + "voice_rx_%06lu.wav", ts % 1000000UL); + + char fullPath[96]; + snprintf(fullPath, sizeof(fullPath), "%s/%s", VOICE_FOLDER, _reviewFilename); + + if (!SD.exists(VOICE_FOLDER)) SD.mkdir(VOICE_FOLDER); + File f = SD.open(fullPath, FILE_WRITE); + if (!f) { + Serial.printf("Voice: Failed to create %s\n", fullPath); + return false; + } + + uint32_t dataBytes = _recSamples * sizeof(int16_t); + writeWavHeader(f, dataBytes, VOICE_SAMPLE_RATE, VOICE_BITS, VOICE_CHANNELS); + f.write((const uint8_t*)_recBuffer, dataBytes); + f.close(); + Serial.printf("Voice: Saved decoded voice: %s\n", fullPath); + + // Play it + if (playFile(_reviewFilename)) { + _reviewPlaying = true; + _mode = REVIEW; + return true; + } + return false; + } + + // --------------------------------------------------------------------------- + // Contact picker — populate from main.cpp (avoids MyMesh dependency) + // --------------------------------------------------------------------------- + void enterContactPick() { + _mode = CONTACT_PICK; + _pickSelected = 0; + _pickScroll = 0; + _pendingSendIdx = -1; + } + + // --------------------------------------------------------------------------- + // Contact picker rendering + // --------------------------------------------------------------------------- + void renderContactPick(DisplayDriver& display) { + display.setColor(DisplayDriver::GREEN); + display.setTextSize(1); + + display.setCursor(0, 0); + display.print("Send Voice To:"); + display.fillRect(0, 11, display.width(), 1); + + if (_pickList.empty()) { + display.setCursor(10, 50); + display.print("No contacts with"); + display.setCursor(10, 65); + display.print("direct path."); + } else { + int y = 14; + int lineH = 12; + int visibleLines = (display.height() - 14 - 14) / lineH; + + if (_pickSelected < _pickScroll) _pickScroll = _pickSelected; + if (_pickSelected >= _pickScroll + visibleLines) + _pickScroll = _pickSelected - visibleLines + 1; + + for (int i = _pickScroll; + i < (int)_pickList.size() && i < _pickScroll + visibleLines; + i++) { + int yy = y + (i - _pickScroll) * lineH; + + if (i == _pickSelected) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, yy - 1, display.width(), lineH); + display.setColor(DisplayDriver::DARK); + } + + // Type indicator + name + char line[40]; + snprintf(line, sizeof(line), "%c %s", + _pickList[i].hasDirect ? '>' : ' ', + _pickList[i].name); + display.setCursor(2, yy); + display.print(line); + + if (i == _pickSelected) { + display.setColor(DisplayDriver::GREEN); + } + } + } + + // Footer + int footerY = display.height() - 12; + display.setTextSize(1); + display.setCursor(0, footerY); + display.print("Ent:Send Q:Cancel"); + } + + // Contact picker input + void handlePickInput(char key) { + switch (key) { + case 'w': case 'W': case 0xF0: + if (_pickSelected > 0) _pickSelected--; + break; + case 's': case 'S': case 0xF1: + if (_pickSelected < (int)_pickList.size() - 1) _pickSelected++; + break; + case '\r': // Enter — confirm send + if (!_pickList.empty() && _pickSelected < (int)_pickList.size()) { + if (_pickList[_pickSelected].hasDirect) { + _pendingSendIdx = _pickList[_pickSelected].meshIdx; + _mode = REVIEW; // Dismiss picker immediately + Serial.printf("Voice: Send confirmed to contact idx %d (%s)\n", + _pendingSendIdx, _pickList[_pickSelected].name); + } else { + Serial.println("Voice: Contact has no direct path — cannot send"); + } + } + break; + case 'q': case 'Q': + _mode = REVIEW; + break; + } + } + + // --------------------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------------------- + void renderMessageList(DisplayDriver& display) { + display.setColor(DisplayDriver::GREEN); + display.setTextSize(1); + + // Title bar + display.setCursor(0, 0); + display.print("Voice Messages"); + + char countStr[16]; + snprintf(countStr, sizeof(countStr), "%d files", (int)_fileList.size()); + display.setCursor(display.width() - display.getTextWidth(countStr) - 2, 0); + display.print(countStr); + + display.fillRect(0, 11, display.width(), 1); // horizontal rule + + if (_fileList.empty()) { + display.setCursor(10, 60); + display.print("No voice messages."); + display.setCursor(10, 80); + display.print("Hold Mic key to record."); + } else { + // File list + int y = 14; + int lineH = 12; + int visibleLines = (display.height() - 14 - 14) / lineH; // header + footer + + // Ensure selection is visible + if (_selectedFile < _scrollOffset) _scrollOffset = _selectedFile; + if (_selectedFile >= _scrollOffset + visibleLines) + _scrollOffset = _selectedFile - visibleLines + 1; + + for (int i = _scrollOffset; + i < (int)_fileList.size() && i < _scrollOffset + visibleLines; + i++) { + int yy = y + (i - _scrollOffset) * lineH; + + if (i == _selectedFile) { + // Highlight: fill with LIGHT, draw DARK text + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, yy - 1, display.width(), lineH); + display.setColor(DisplayDriver::DARK); + } + + display.setCursor(2, yy); + display.print(_fileList[i].name); + + // Duration on right + char dur[16]; + snprintf(dur, sizeof(dur), "%.1fs", _fileList[i].durationSec); + display.setCursor(display.width() - display.getTextWidth(dur) - 4, yy); + display.print(dur); + + if (i == _selectedFile) { + display.setColor(DisplayDriver::GREEN); + } + } + } + + // Footer + int footerY = display.height() - 12; + display.setTextSize(1); + display.setCursor(0, footerY); + if (_listPlaying) { + display.print("Playing... Q:Stop"); + } else { + display.print("Mic:Rec Ent:Play D:Del Q:Exit"); + } + } + + void renderRecording(DisplayDriver& display) { + display.setColor(DisplayDriver::GREEN); + display.setTextSize(1); + + display.setCursor(0, 0); + display.print("RECORDING"); + + // Elapsed time + float elapsed = _recSamples / (float)VOICE_SAMPLE_RATE; + char timeStr[16]; + snprintf(timeStr, sizeof(timeStr), "%.1f / %ds", elapsed, VOICE_MAX_SECONDS); + display.setCursor(display.width() - display.getTextWidth(timeStr) - 2, 0); + display.print(timeStr); + + display.fillRect(0, 11, display.width(), 1); // horizontal rule + + // Large centred "recording" indicator + display.setTextSize(2); + const char* recLabel = "REC"; + int labelW = display.getTextWidth(recLabel); + display.setCursor((display.width() - labelW) / 2, 50); + display.print(recLabel); + display.setTextSize(1); + + // Progress bar + int barX = 10; + int barY = 90; + int barW = display.width() - 20; + int barH = 12; + float progress = (float)_recSamples / VOICE_BUF_SAMPLES; + if (progress > 1.0f) progress = 1.0f; + + display.drawRect(barX, barY, barW, barH); + int fillW = (int)(progress * (barW - 2)); + if (fillW > 0) { + display.fillRect(barX + 1, barY + 1, fillW, barH - 2); + } + + // Simple level meter — average of last 256 samples + if (_recSamples > 256) { + int32_t sum = 0; + for (uint32_t i = _recSamples - 256; i < _recSamples; i++) { + int16_t s = _recBuffer[i]; + sum += (s < 0) ? -s : s; + } + int avgLevel = sum / 256; + int meterW = (avgLevel * (barW - 2)) / 16384; // Scale to bar width + if (meterW > barW - 2) meterW = barW - 2; + + int meterY = barY + barH + 8; + display.drawRect(barX, meterY, barW, 8); + if (meterW > 0) { + display.fillRect(barX + 1, meterY + 1, meterW, 6); + } + } + + // Footer + int footerY = display.height() - 12; + display.setTextSize(1); + display.setCursor(0, footerY); + display.print("Release Mic to stop"); + } + + void renderReview(DisplayDriver& display) { + display.setColor(DisplayDriver::GREEN); + display.setTextSize(1); + + display.setCursor(0, 0); + display.print("Review Recording"); + display.fillRect(0, 11, display.width(), 1); // horizontal rule + + // Filename + display.setCursor(10, 30); + display.print(_reviewFilename); + + // Duration + float secs = _recSamples / (float)VOICE_SAMPLE_RATE; + char durStr[32]; + snprintf(durStr, sizeof(durStr), "Duration: %.1f seconds", secs); + display.setCursor(10, 50); + display.print(durStr); + + // Size + uint32_t sizeBytes = 44 + _recSamples * sizeof(int16_t); + char sizeStr[32]; + if (sizeBytes > 1024) { + snprintf(sizeStr, sizeof(sizeStr), "Size: %.1f KB", sizeBytes / 1024.0f); + } else { + snprintf(sizeStr, sizeof(sizeStr), "Size: %d bytes", sizeBytes); + } + display.setCursor(10, 70); + display.print(sizeStr); + + // Codec2 encoding results + if (_c2Valid) { + int packets = (_c2Bytes + VOICE_MESH_PAYLOAD - 1) / VOICE_MESH_PAYLOAD; + char c2Str[48]; + snprintf(c2Str, sizeof(c2Str), "Codec2: %d bytes (%d pkt%s)", + _c2Bytes, packets, packets == 1 ? "" : "s"); + display.setCursor(10, 90); + display.print(c2Str); + } else { + display.setCursor(10, 90); + display.print("Codec2: encode failed"); + } + + // Status + display.setCursor(10, 110); + if (_reviewPlaying) { + display.print("Playing..."); + } else { + display.print("Ready"); + } + + // Footer + int footerY = display.height() - 12; + display.setTextSize(1); + display.setCursor(0, footerY); + if (_reviewPlaying) { + display.print("Q:Stop"); + } else if (_c2Valid) { + display.print("S:Send Ent:Play Mic:Redo Q:List"); + } else { + display.print("Ent:Play Mic:Redo D:Del Q:List"); + } + } + +public: + VoiceMessageScreen(UITask* task, Audio* audioObj) + : _task(task), _audio(audioObj), _mode(MESSAGE_LIST), + _sdReady(false), _i2sInitialized(false), _micInitialized(false), + _dacPowered(false), _displayRef(nullptr), + _selectedFile(0), _scrollOffset(0), + _recBuffer(nullptr), _recSamples(0), _recording(false), _recStartMillis(0), + _reviewPlaying(false), _reviewDirty(false), + _listPlaying(false), _listPlayIdx(-1), + _playbackJustFinished(false), + _c2Bytes(0), _c2Frames(0), _c2Valid(false), + _pickSelected(0), _pickScroll(0), _pendingSendIdx(-1) { + _reviewFilename[0] = '\0'; + _outSession.active = false; + _inSession.active = false; + _inSession.complete = false; + _inSession.playTriggered = false; + } + + void setSDReady(bool v) { _sdReady = v; } + void setAudio(Audio* a) { _audio = a; } + Audio* getAudio() const { return _audio; } + bool isRecording() const { return _recording; } + Mode getMode() const { return _mode; } + + // Codec2 encoded data access + bool hasCodec2Data() const { return _c2Valid; } + const uint8_t* getCodec2Data() const { return _c2Data; } + uint32_t getCodec2Bytes() const { return _c2Bytes; } + uint32_t getCodec2Frames() const { return _c2Frames; } + + // --- VE3 send protocol: main.cpp polls these to drive the send --- + + // Check if user confirmed a send target in contact picker + // Returns contact mesh index, or -1 if no pending send. + // Consuming clears the pending state. + int consumePendingSend() { + int idx = _pendingSendIdx; + _pendingSendIdx = -1; + return idx; + } + + // Format the VE3 envelope text for a DM (called by main.cpp before send) + void formatEnvelope(char* buf, int bufLen, uint32_t sessionId) { + if (!_c2Valid) { buf[0] = '\0'; return; } + int payloadPerPkt = VOICE_MESH_PAYLOAD; + uint8_t totalPkts = (_c2Bytes + payloadPerPkt - 1) / payloadPerPkt; + uint8_t durSec = (uint8_t)(_c2Frames * VOICE_C2_FRAME_MS / 1000); + formatVE3(buf, bufLen, sessionId, totalPkts, durSec); + // Cache session for serving fetch requests + cacheOutSession(sessionId); + } + + // Build a single raw voice packet for transmission + // Returns packet length, or 0 if index is out of range + int buildVoicePacket(uint8_t* buf, int bufLen, uint32_t sessionId, uint8_t pktIdx) { + if (!_outSession.active || _outSession.sessionId != sessionId) return 0; + int payloadPerPkt = VOICE_MESH_PAYLOAD; + uint32_t offset = (uint32_t)pktIdx * payloadPerPkt; + if (offset >= _outSession.dataBytes) return 0; + uint32_t chunkLen = _outSession.dataBytes - offset; + if (chunkLen > (uint32_t)payloadPerPkt) chunkLen = payloadPerPkt; + if ((int)(VOICE_PKT_HDR_SIZE + chunkLen) > bufLen) return 0; + + buf[0] = VOICE_PKT_MAGIC; // 0x56 + memcpy(&buf[1], &sessionId, 4); + buf[5] = pktIdx; + memcpy(&buf[6], &_outSession.data[offset], chunkLen); + return VOICE_PKT_HDR_SIZE + chunkLen; + } + + uint8_t getOutSessionPacketCount() const { + return _outSession.active ? _outSession.totalPackets : 0; + } + uint32_t getOutSessionId() const { + return _outSession.active ? _outSession.sessionId : 0; + } + bool hasValidOutSession() const { + return _outSession.active && + (millis() - _outSession.cachedAt) < VOICE_SESSION_TTL_MS; + } + + // Called when send completes — return to review with status + void onSendComplete(bool success) { + _mode = REVIEW; + Serial.printf("Voice: Send %s\n", success ? "complete" : "failed"); + } + + // --- Incoming voice session API (called from main.cpp callbacks) --- + + // Parse a VE3 envelope and set up incoming session + // Format: VE3:{sid}:{mode}:{total}:{durS} (base36 fields) + void onVE3Received(const char* senderName, const char* ve3Text) { + // Parse: skip "VE3:" prefix, split on ':' + const char* p = ve3Text + 4; // skip "VE3:" + char fields[4][16]; + int fieldIdx = 0; + int charIdx = 0; + memset(fields, 0, sizeof(fields)); + while (*p && fieldIdx < 4) { + if (*p == ':') { + fields[fieldIdx][charIdx] = '\0'; + fieldIdx++; + charIdx = 0; + } else if (charIdx < 15) { + fields[fieldIdx][charIdx++] = *p; + } + p++; + } + if (fieldIdx < 3) { + Serial.printf("Voice: VE3 parse failed — only %d fields\n", fieldIdx + 1); + return; + } + fields[fieldIdx][charIdx] = '\0'; // terminate last field + + uint32_t sessionId = fromBase36(fields[0]); + // uint8_t codecMode = (uint8_t)fromBase36(fields[1]); // not used yet + uint8_t totalPkts = (uint8_t)fromBase36(fields[2]); + uint8_t durSec = (fieldIdx >= 3) ? (uint8_t)fromBase36(fields[3]) : 0; + + if (totalPkts == 0 || totalPkts > 8) { + Serial.printf("Voice: VE3 invalid packet count: %d\n", totalPkts); + return; + } + + // Set up incoming session + _inSession.sessionId = sessionId; + _inSession.totalPackets = totalPkts; + _inSession.receivedBitmap = 0; + _inSession.receivedCount = 0; + _inSession.dataBytes = 0; + _inSession.durationSec = durSec; + strncpy(_inSession.senderName, senderName, 31); + _inSession.senderName[31] = '\0'; + _inSession.startedAt = millis(); + _inSession.active = true; + _inSession.complete = false; + _inSession.playTriggered = false; + memset(_inSession.pktOffset, 0, sizeof(_inSession.pktOffset)); + memset(_inSession.pktSize, 0, sizeof(_inSession.pktSize)); + + Serial.printf("Voice: Incoming session 0x%08X from %s — expecting %d packets (%ds)\n", + sessionId, senderName, totalPkts, durSec); + } + + // Add a received voice data packet to the incoming session + // payload: [0x56][sessionId:4B][index:1B][codec2 data...] + void onVoicePacketReceived(const uint8_t* payload, uint8_t len) { + if (len < 7) return; // Need at least header + 1 byte data + uint32_t sessionId; + memcpy(&sessionId, &payload[1], 4); + uint8_t pktIdx = payload[5]; + uint8_t dataLen = len - VOICE_PKT_HDR_SIZE; + + if (!_inSession.active || _inSession.sessionId != sessionId) { + Serial.printf("Voice: Ignoring packet for unknown session 0x%08X\n", sessionId); + return; + } + if (pktIdx >= _inSession.totalPackets || pktIdx >= 8) { + Serial.printf("Voice: Packet index %d out of range (total=%d)\n", + pktIdx, _inSession.totalPackets); + return; + } + if (_inSession.receivedBitmap & (1 << pktIdx)) { + // Already have this packet (duplicate) + return; + } + if (_inSession.dataBytes + dataLen > VOICE_C2_MAX_BYTES) { + Serial.println("Voice: Incoming session data overflow"); + return; + } + + // Store the codec2 data at the next available offset + _inSession.pktOffset[pktIdx] = _inSession.dataBytes; + _inSession.pktSize[pktIdx] = dataLen; + memcpy(&_inSession.data[_inSession.dataBytes], &payload[VOICE_PKT_HDR_SIZE], dataLen); + _inSession.dataBytes += dataLen; + _inSession.receivedBitmap |= (1 << pktIdx); + _inSession.receivedCount++; + + Serial.printf("Voice: Received packet %d/%d (%d bytes, total %d bytes)\n", + _inSession.receivedCount, _inSession.totalPackets, + dataLen, _inSession.dataBytes); + + // Check if all packets received + if (_inSession.receivedCount >= _inSession.totalPackets) { + _inSession.complete = true; + Serial.printf("Voice: Session 0x%08X complete — %d bytes from %s\n", + sessionId, _inSession.dataBytes, _inSession.senderName); + } + } + + // Check if incoming session is complete and ready for playback + bool isIncomingReady() const { + return _inSession.active && _inSession.complete && !_inSession.playTriggered; + } + + // Trigger decode + playback of completed incoming session + bool playIncoming() { + if (!isIncomingReady()) return false; + _inSession.playTriggered = true; + return decodeAndPlayIncoming(); + } + + // Load contacts for the picker (called from main.cpp) + void loadPickContacts(const PickContact* contacts, int count) { + _pickList.clear(); + for (int i = 0; i < count; i++) { + _pickList.push_back(contacts[i]); + } + _pickSelected = 0; + _pickScroll = 0; + Serial.printf("Voice: Contact picker loaded %d contacts\n", count); + } + + // Called by main.cpp loop to detect end-of-playback and refresh UI + void checkPlaybackFinished() { + if (!_i2sInitialized) return; // I2S torn down by mic, no playback possible + if ((_reviewPlaying || _listPlaying) && _audio && !_audio->isRunning()) { + Serial.println("Voice: Playback finished"); + _reviewPlaying = false; + _listPlaying = false; + _playbackJustFinished = true; + } + } + + // Check and clear the playback-finished flag (for main.cpp refresh trigger) + bool consumePlaybackFinished() { + if (_playbackJustFinished) { + _playbackJustFinished = false; + return true; + } + return false; + } + + // Called from main.cpp loop — services mic DMA reads during recording + // and audio decode during playback (like audiobook audioTick) + void voiceTick() { + if (_recording) { + captureChunk(); + } + // Audio playback is serviced by audio->loop() in the shared audioTick path + } + + // Check if DAC audio is active (for CPU boost in main loop) + bool isAudioActive() const { + return _i2sInitialized && _audio && _audio->isRunning(); + } + + // --------------------------------------------------------------------------- + // UIScreen interface + // --------------------------------------------------------------------------- + void enter(DisplayDriver& display) { + _displayRef = &display; + _mode = MESSAGE_LIST; + _reviewPlaying = false; + _listPlaying = false; + scanVoiceFolder(); + } + + int render(DisplayDriver& display) override { + switch (_mode) { + case MESSAGE_LIST: renderMessageList(display); break; + case RECORDING: renderRecording(display); break; + case REVIEW: renderReview(display); break; + case CONTACT_PICK: renderContactPick(display); break; + } + return 0; + } + + // --------------------------------------------------------------------------- + // Mic key press — start recording (called from main.cpp on KB_KEY_MIC) + // --------------------------------------------------------------------------- + void onMicPress() { + if (_mode == MESSAGE_LIST || _mode == REVIEW) { + // Stop any playback first + stopPlayback(); + + // Start recording + _mode = RECORDING; + if (!startRecording()) { + Serial.println("Voice: Failed to start recording"); + _mode = MESSAGE_LIST; + } + } + // If already recording, ignore (release will stop it) + } + + // --------------------------------------------------------------------------- + // Mic key release — stop recording, save, enter review + // --------------------------------------------------------------------------- + void onMicRelease() { + if (_mode == RECORDING && _recording) { + stopRecording(); + + if (_recSamples < VOICE_SAMPLE_RATE / 4) { + // Too short — discard and return to list + Serial.println("Voice: Recording too short, discarding"); + _mode = MESSAGE_LIST; + return; + } + + // Save to SD + if (saveRecordingToSD()) { + _mode = REVIEW; + } else { + _mode = MESSAGE_LIST; + } + } + // If mode is already REVIEW (auto-stop filled buffer), mic release is a no-op + } + + // --------------------------------------------------------------------------- + // Key input handler (UIScreen interface + direct calls from main.cpp) + // --------------------------------------------------------------------------- + bool handleInput(char key) override { + switch (_mode) { + case MESSAGE_LIST: + handleListInput(key); + return true; + case RECORDING: + if (key == 'q' || key == 'Q') { + stopRecording(); + _mode = MESSAGE_LIST; + } + return true; + case REVIEW: + handleReviewInput(key); + return true; + case CONTACT_PICK: + handlePickInput(key); + return true; + } + return false; + } + +private: + void handleListInput(char key) { + switch (key) { + case 'w': case 'W': + case 0xF0: // KEY_PREV + if (_selectedFile > 0) _selectedFile--; + break; + + case 's': case 'S': + case 0xF1: // KEY_NEXT + if (_selectedFile < (int)_fileList.size() - 1) _selectedFile++; + break; + + case '\r': // Enter — play selected + if (!_fileList.empty() && _selectedFile < (int)_fileList.size()) { + if (_listPlaying) { + stopPlayback(); + } else { + if (playFile(_fileList[_selectedFile].name)) { + _listPlaying = true; + _listPlayIdx = _selectedFile; + } + } + } + break; + + case 'd': case 'D': // Delete selected + if (!_fileList.empty() && _selectedFile < (int)_fileList.size()) { + stopPlayback(); + deleteFile(_fileList[_selectedFile].name); + scanVoiceFolder(); + if (_selectedFile >= (int)_fileList.size() && _selectedFile > 0) { + _selectedFile--; + } + } + break; + + // q/Q handled by main.cpp (exits voice screen) + } + } + + void handleReviewInput(char key) { + switch (key) { + case '\r': // Enter — play/stop review + if (_reviewPlaying) { + stopPlayback(); + } else { + if (playFile(_reviewFilename)) { + _reviewPlaying = true; + } + } + break; + + case 'd': case 'D': // Delete and go back to list + stopPlayback(); + deleteFile(_reviewFilename); + _reviewFilename[0] = '\0'; + _mode = MESSAGE_LIST; + scanVoiceFolder(); + break; + + case 'q': case 'Q': // Back to list (keep the file) + stopPlayback(); + _mode = MESSAGE_LIST; + scanVoiceFolder(); + break; + + case 's': case 'S': // Send — enter contact picker + if (_c2Valid) { + stopPlayback(); + enterContactPick(); + // main.cpp will detect CONTACT_PICK mode and call loadPickContacts() + } + break; + + // Mic key re-record is handled via onMicPress() from main.cpp + } + } +}; + +#endif // MECK_AUDIO_VARIANT \ No newline at end of file diff --git a/variants/lilygo_tdeck_pro/Tca8418keyboard.h b/variants/lilygo_tdeck_pro/Tca8418keyboard.h index fb0cfef0..a8456f74 100644 --- a/variants/lilygo_tdeck_pro/Tca8418keyboard.h +++ b/variants/lilygo_tdeck_pro/Tca8418keyboard.h @@ -21,7 +21,9 @@ #define KB_KEY_BACKSPACE '\b' #define KB_KEY_ENTER '\r' #define KB_KEY_SPACE ' ' -#define KB_KEY_EMOJI 0x01 // Non-printable code for $ key (emoji picker) +#define KB_KEY_EMOJI 0x01 // Non-printable code for $ key (emoji picker) +#define KB_KEY_MIC 0x02 // Mic key press (PTT start / voice screen open) +#define KB_KEY_MIC_RELEASE 0x03 // Mic key release (PTT stop) class TCA8418Keyboard { private: @@ -34,6 +36,7 @@ private: bool _shiftUsedWhileHeld; // Was shift consumed by any key while held bool _altActive; // Sticky alt (one-shot) bool _symActive; // Sticky sym (one-shot) + bool _micHeld; // Mic key physically held down (for PTT release detection) unsigned long _lastShiftTime; // For Shift+key combos uint8_t readReg(uint8_t reg) { @@ -151,7 +154,7 @@ private: public: TCA8418Keyboard(uint8_t addr = 0x34, TwoWire* wire = &Wire) : _addr(addr), _wire(wire), _initialized(false), - _shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _lastShiftTime(0) {} + _shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _micHeld(false), _lastShiftTime(0) {} bool begin() { // Check if device responds @@ -242,6 +245,16 @@ public: return 0; } + // Track mic key release — return KB_KEY_MIC_RELEASE for PTT stop + if (!pressed && keyCode == 34) { + if (_micHeld) { + _micHeld = false; + Serial.println("KB: Mic released -> KB_KEY_MIC_RELEASE"); + return KB_KEY_MIC_RELEASE; + } + return 0; + } + // Only act on key press, not release if (!pressed || keyCode == 0) { return 0; @@ -279,12 +292,17 @@ public: return KB_KEY_EMOJI; } - // Handle Mic key - always produces '0' (silk-screened on key) - // Sym+Mic also produces '0' (consumes sym so it doesn't leak) + // Handle Mic key — bare press returns KB_KEY_MIC for PTT / voice screen + // Sym+Mic produces '0' (silk-screened on key) for text input if (keyCode == 34) { - _symActive = false; - Serial.println("KB: Mic -> '0'"); - return '0'; + if (_symActive) { + _symActive = false; + Serial.println("KB: Sym+Mic -> '0'"); + return '0'; + } + _micHeld = true; + Serial.println("KB: Mic -> KB_KEY_MIC"); + return KB_KEY_MIC; } // Get the character @@ -338,6 +356,7 @@ public: } bool isReady() const { return _initialized; } + bool isMicHeld() const { return _micHeld; } // Check if shift was pressed within the last N milliseconds bool wasShiftRecentlyPressed(unsigned long withinMs = 500) const { diff --git a/variants/lilygo_tdeck_pro/platformio.ini b/variants/lilygo_tdeck_pro/platformio.ini index 095b2901..bfdde4d0 100644 --- a/variants/lilygo_tdeck_pro/platformio.ini +++ b/variants/lilygo_tdeck_pro/platformio.ini @@ -129,6 +129,7 @@ lib_deps = densaugeo/base64 @ ~1.4.0 https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6 bitbank2/JPEGDEC + https://github.com/sh123/esp32_codec2_arduino.git ; Audio + WiFi companion (audio-player hardware with WiFi app bridging) ; No BLE — WiFi companion uses SerialWifiInterface (TCP socket on port 5000). @@ -151,7 +152,7 @@ build_flags = -D MECK_AUDIO_VARIANT -D MECK_WEB_READER=1 -D MECK_OTA_UPDATE=1 - -D FIRMWARE_VERSION='"Meck v1.5.WiFi"' + -D FIRMWARE_VERSION='"Meck v1.6.WiFi"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + + @@ -163,6 +164,7 @@ lib_deps = densaugeo/base64 @ ~1.4.0 https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6 bitbank2/JPEGDEC + https://github.com/sh123/esp32_codec2_arduino.git ; Audio standalone (audio-player hardware, no BLE/WiFi — maximum battery life) ; No MECK_WEB_READER: WiFi power draw conflicts with zero-radio-power design. @@ -189,6 +191,7 @@ lib_deps = densaugeo/base64 @ ~1.4.0 https://github.com/schreibfaul1/ESP32-audioI2S.git#2.0.6 bitbank2/JPEGDEC + https://github.com/sh123/esp32_codec2_arduino.git ; 4G + BLE companion (4G modem hardware, no audio — GPIO conflict with PCM5102A) ; MAX_CONTACTS=500 is near BLE protocol ceiling (MAX_CONTACTS/2 sent as uint8_t, max 510) @@ -204,7 +207,7 @@ build_flags = -D HAS_4G_MODEM=1 -D MECK_WEB_READER=1 -D MECK_OTA_UPDATE=1 - -D FIRMWARE_VERSION='"Meck v1.5.4G"' + -D FIRMWARE_VERSION='"Meck v1.6.4G"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + + @@ -235,7 +238,7 @@ build_flags = -D HAS_4G_MODEM=1 -D MECK_WEB_READER=1 -D MECK_OTA_UPDATE=1 - -D FIRMWARE_VERSION='"Meck v1.5.4G.WiFi"' + -D FIRMWARE_VERSION='"Meck v1.6.4G.WiFi"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + + @@ -262,7 +265,7 @@ build_flags = -D HAS_4G_MODEM=1 -D MECK_WEB_READER=1 -D MECK_OTA_UPDATE=1 - -D FIRMWARE_VERSION='"Meck v1.5.4G.SA"' + -D FIRMWARE_VERSION='"Meck v1.6.4G.SA"' build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter} + +