From 546ce55c2b40386b9d281db9d740bca6cbd928a9 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:27:32 +1100 Subject: [PATCH] Gave up on trying to extract cover art from mp3 files and removed debug logs. Updated firmware version to match variant type --- examples/companion_radio/MyMesh.h | 2 +- .../ui-new/Audiobookplayerscreen.h | 52 ++-- examples/companion_radio/ui-new/M4BMetadata.h | 231 ++++++++++++++++++ variants/lilygo_tdeck_pro/platformio.ini | 2 +- 4 files changed, 251 insertions(+), 36 deletions(-) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 997595b..4a6ac59 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -12,7 +12,7 @@ #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "Meck v0.9.0" +#define FIRMWARE_VERSION "Meck v0.8.7A" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/companion_radio/ui-new/Audiobookplayerscreen.h b/examples/companion_radio/ui-new/Audiobookplayerscreen.h index b665666..fd05ddf 100644 --- a/examples/companion_radio/ui-new/Audiobookplayerscreen.h +++ b/examples/companion_radio/ui-new/Audiobookplayerscreen.h @@ -188,8 +188,7 @@ private: pinMode(41, OUTPUT); digitalWrite(41, HIGH); if (!_dacPowered) { - delay(50); // Let DAC power rail stabilise on first enable - Serial.println("AB: GPIO 41 (DAC power) -> HIGH"); + delay(50); } _dacPowered = true; } @@ -197,23 +196,15 @@ private: void disableDAC() { digitalWrite(41, LOW); _dacPowered = false; - Serial.println("AB: GPIO 41 (DAC power) -> LOW"); } void ensureI2SInit() { if (!_i2sInitialized && _audio) { - // Configure I2S output pins - // Try with MCLK=0 (ESP32-S3 may need explicit MCLK even if PCM5102A - // uses internal PLL — setting 0 lets the driver auto-assign or skip) bool ok = _audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT, 0); - Serial.printf("AB: setPinout(BCLK=%d, LRC=%d, DOUT=%d, MCLK=0) -> %s\n", - BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT, - ok ? "OK" : "FAILED"); if (!ok) { - // Retry without MCLK (original behavior) ok = _audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT); - Serial.printf("AB: setPinout retry without MCLK -> %s\n", ok ? "OK" : "FAILED"); } + if (!ok) Serial.println("AB: setPinout FAILED"); _i2sInitialized = true; } } @@ -449,7 +440,20 @@ private: yield(); // Feed WDT after metadata parse decodeCoverArt(file); yield(); // Feed WDT after cover decode + } else if (lower.endsWith(".mp3")) { + _metadata.parseID3v2(file); + yield(); // Feed WDT after metadata parse + decodeCoverArt(file); + yield(); // Feed WDT after cover decode + // Fall back to filename for title if ID3 had none + if (_metadata.title[0] == '\0') { + String base = filename; + int dot = base.lastIndexOf('.'); + if (dot > 0) base = base.substring(0, dot); + strncpy(_metadata.title, base.c_str(), M4B_MAX_TITLE - 1); + } } else { + // Other audio formats — use filename as title _metadata.clear(); String base = filename; int dot = base.lastIndexOf('.'); @@ -509,16 +513,11 @@ private: _audio->connecttoFS(SD, fullPath.c_str()); _audio->setVolume(_volume); - Serial.printf("AB: Volume=%d, isRunning=%d, getVolume=%d\n", - _volume, _audio->isRunning(), _audio->getVolume()); - // DON'T seek immediately — the library hasn't parsed headers yet. // Store pending seek; apply once stream reports ready (has duration). _streamReady = false; if (_currentPosSec > 5) { _pendingSeekSec = (_currentPosSec > 3) ? _currentPosSec - 3 : 0; - Serial.printf("AB: Deferred seek to %us (bookmark was %us)\n", - _pendingSeekSec, _currentPosSec); } else { _pendingSeekSec = 0; } @@ -527,7 +526,7 @@ private: _isPaused = false; _lastPositionSave = millis(); - Serial.println("AB: Playback started"); + Serial.printf("AB: Playing '%s'\n", _currentFile.c_str()); } void stopPlayback() { @@ -548,7 +547,7 @@ private: // Force I2S re-init for next file (sample rate may differ) _i2sInitialized = false; - Serial.println("AB: Playback stopped"); + Serial.println("AB: Stopped"); } void togglePause() { @@ -558,11 +557,9 @@ private: _audio->pauseResume(); _isPaused = true; saveBookmark(); - Serial.println("AB: Paused"); } else if (_isPaused) { _audio->pauseResume(); _isPaused = false; - Serial.println("AB: Resumed"); } else { // Not playing yet — start from bookmark startPlayback(); @@ -581,7 +578,6 @@ private: _audio->setTimeOffset((uint32_t)target); _currentPosSec = (uint32_t)target; - Serial.printf("AB: Seek %+ds -> %us\n", seconds, (uint32_t)target); } void seekToChapter(int chapterIdx) { @@ -595,8 +591,6 @@ private: } _currentPosSec = targetSec; _currentChapter = chapterIdx; - Serial.printf("AB: Jump to chapter %d '%s' at %us\n", - chapterIdx, _metadata.chapters[chapterIdx].name, targetSec); } // ---- Rendering Helpers ---- @@ -863,23 +857,15 @@ public: uint32_t pos = _audio->getAudioCurrentTime(); if (pos > 0) _currentPosSec = pos; - // Get duration from library once available if (_durationSec == 0) { uint32_t dur = _audio->getAudioFileDuration(); - if (dur > 0) { - _durationSec = dur; - Serial.printf("AB: Duration from library: %us\n", dur); - } + if (dur > 0) _durationSec = dur; } // Apply deferred seek once stream is ready - // Stream is ready when the library reports a valid duration if (!_streamReady && _durationSec > 0) { _streamReady = true; - Serial.printf("AB: Stream ready! isRunning=%d, duration=%us\n", - _audio->isRunning(), _durationSec); if (_pendingSeekSec > 0) { - Serial.printf("AB: Applying deferred seek to %us\n", _pendingSeekSec); _audio->setTimeOffset(_pendingSeekSec); _currentPosSec = _pendingSeekSec; _pendingSeekSec = 0; @@ -1019,7 +1005,6 @@ public: if (_volume < 21) { _volume++; if (_audio) _audio->setVolume(_volume); - Serial.printf("AB: Volume -> %d\n", _volume); } return true; // Always consume & refresh (show current volume) } @@ -1029,7 +1014,6 @@ public: if (_volume > 0) { _volume--; if (_audio) _audio->setVolume(_volume); - Serial.printf("AB: Volume -> %d\n", _volume); } return true; // Always consume & refresh } diff --git a/examples/companion_radio/ui-new/M4BMetadata.h b/examples/companion_radio/ui-new/M4BMetadata.h index f3a80a8..e41c0c7 100644 --- a/examples/companion_radio/ui-new/M4BMetadata.h +++ b/examples/companion_radio/ui-new/M4BMetadata.h @@ -371,6 +371,237 @@ private: } } + // ===================================================================== + // ID3v2 Parser for MP3 files + // ===================================================================== +public: + // Parse ID3v2 tags from an MP3 file. Extracts title (TIT2), artist + // (TPE1), and cover art (APIC). Fills the same metadata fields as + // the M4B parser so decodeCoverArt() works unchanged. + bool parseID3v2(File& file) { + clear(); + if (!file || file.size() < 10) return false; + + file.seek(0); + uint8_t hdr[10]; + if (file.read(hdr, 10) != 10) return false; + + // Verify "ID3" magic + if (hdr[0] != 'I' || hdr[1] != 'D' || hdr[2] != '3') { + Serial.println("ID3: No ID3v2 header found"); + return false; + } + + uint8_t versionMajor = hdr[3]; // 3 = ID3v2.3, 4 = ID3v2.4 + bool v24 = (versionMajor == 4); + bool hasExtHeader = (hdr[5] & 0x40) != 0; + + // Tag size is syncsafe integer (4 x 7-bit bytes) + uint32_t tagSize = ((uint32_t)(hdr[6] & 0x7F) << 21) | + ((uint32_t)(hdr[7] & 0x7F) << 14) | + ((uint32_t)(hdr[8] & 0x7F) << 7) | + (hdr[9] & 0x7F); + + uint32_t tagEnd = 10 + tagSize; + if (tagEnd > file.size()) tagEnd = file.size(); + + Serial.printf("ID3: v2.%d, %u bytes\n", versionMajor, tagSize); + + // Skip extended header if present + uint32_t pos = 10; + if (hasExtHeader && pos + 4 < tagEnd) { + file.seek(pos); + uint32_t extSize; + if (v24) { + uint8_t eb[4]; + file.read(eb, 4); + extSize = ((uint32_t)(eb[0] & 0x7F) << 21) | + ((uint32_t)(eb[1] & 0x7F) << 14) | + ((uint32_t)(eb[2] & 0x7F) << 7) | + (eb[3] & 0x7F); + } else { + extSize = readU32BE(file) + 4; + } + pos += extSize; + } + + // Walk ID3v2 frames + bool foundTitle = false, foundArtist = false, foundCover = false; + + while (pos + 10 < tagEnd) { + file.seek(pos); + uint8_t fhdr[10]; + if (file.read(fhdr, 10) != 10) break; + + if (fhdr[0] == 0) break; + + char frameId[5] = { (char)fhdr[0], (char)fhdr[1], + (char)fhdr[2], (char)fhdr[3], '\0' }; + + uint32_t frameSize; + if (v24) { + frameSize = ((uint32_t)(fhdr[4] & 0x7F) << 21) | + ((uint32_t)(fhdr[5] & 0x7F) << 14) | + ((uint32_t)(fhdr[6] & 0x7F) << 7) | + (fhdr[7] & 0x7F); + } else { + frameSize = ((uint32_t)fhdr[4] << 24) | ((uint32_t)fhdr[5] << 16) | + ((uint32_t)fhdr[6] << 8) | fhdr[7]; + } + + if (frameSize == 0 || pos + 10 + frameSize > tagEnd) break; + } + + uint32_t dataStart = pos + 10; + + // --- TIT2 (Title) --- + if (!foundTitle && strcmp(frameId, "TIT2") == 0 && frameSize > 1) { + id3ExtractText(file, dataStart, frameSize, title, M4B_MAX_TITLE); + foundTitle = (title[0] != '\0'); + } + // --- TPE1 (Artist/Author) --- + if (!foundArtist && strcmp(frameId, "TPE1") == 0 && frameSize > 1) { + id3ExtractText(file, dataStart, frameSize, author, M4B_MAX_AUTHOR); + foundArtist = (author[0] != '\0'); + } + // --- APIC (Attached Picture) --- + if (!foundCover && strcmp(frameId, "APIC") == 0 && frameSize > 20) { + id3ExtractAPIC(file, dataStart, frameSize); + foundCover = hasCoverArt; + } + + pos = dataStart + frameSize; + + // Early exit once we have everything + if (foundTitle && foundArtist && foundCover) break; + } + + if (foundTitle) Serial.printf("ID3: Title: %s\n", title); + if (foundArtist) Serial.printf("ID3: Author: %s\n", author); + return (foundTitle || foundCover); + } + +private: + // Extract text from a TIT2/TPE1 frame. + // Format: encoding(1) + text data + void id3ExtractText(File& file, uint32_t offset, uint32_t size, + char* dest, int maxLen) { + file.seek(offset); + uint8_t encoding = file.read(); + uint32_t textLen = size - 1; + if (textLen == 0) return; + + if (encoding == 0 || encoding == 3) { + // ISO-8859-1 or UTF-8 — read directly + uint32_t readLen = (textLen < (uint32_t)(maxLen - 1)) + ? textLen : (uint32_t)(maxLen - 1); + file.read((uint8_t*)dest, readLen); + dest[readLen] = '\0'; + // Strip trailing nulls + while (readLen > 0 && dest[readLen - 1] == '\0') readLen--; + dest[readLen] = '\0'; + } + else if (encoding == 1 || encoding == 2) { + // UTF-16 (with or without BOM) — crude ASCII extraction + // Static buffer to avoid stack overflow (loopTask has limited stack) + static uint8_t u16buf[128]; + uint32_t readLen = (textLen > sizeof(u16buf)) ? sizeof(u16buf) : textLen; + file.read(u16buf, readLen); + + uint32_t srcStart = 0; + // Skip BOM if present + if (readLen >= 2 && ((u16buf[0] == 0xFF && u16buf[1] == 0xFE) || + (u16buf[0] == 0xFE && u16buf[1] == 0xFF))) { + srcStart = 2; + } + bool littleEndian = (srcStart >= 2 && u16buf[0] == 0xFF); + + int dstIdx = 0; + for (uint32_t i = srcStart; i + 1 < readLen && dstIdx < maxLen - 1; i += 2) { + uint8_t lo = littleEndian ? u16buf[i] : u16buf[i + 1]; + uint8_t hi = littleEndian ? u16buf[i + 1] : u16buf[i]; + if (lo == 0 && hi == 0) break; // null terminator + if (hi == 0 && lo >= 0x20 && lo < 0x7F) { + dest[dstIdx++] = (char)lo; + } else { + dest[dstIdx++] = '?'; + } + } + dest[dstIdx] = '\0'; + } + } + + // Extract APIC (cover art) frame. + // Format: encoding(1) + MIME(null-term) + picType(1) + desc(null-term) + imageData + void id3ExtractAPIC(File& file, uint32_t offset, uint32_t frameSize) { + file.seek(offset); + uint8_t encoding = file.read(); + + // Read MIME type (null-terminated ASCII) + char mime[32] = {0}; + int mimeLen = 0; + while (mimeLen < 31) { + int b = file.read(); + if (b < 0) return; // Read error + if (b == 0) break; // Null terminator = end of MIME string + mime[mimeLen++] = (char)b; + } + mime[mimeLen] = '\0'; + + // Picture type (1 byte) + uint8_t picType = file.read(); + (void)picType; + + // Skip description (null-terminated, encoding-dependent) + if (encoding == 0 || encoding == 3) { + // Single-byte null terminator + while (true) { + int b = file.read(); + if (b < 0) return; // Read error + if (b == 0) break; // Null terminator + } + } else { + // UTF-16: double-null terminator + while (true) { + int b1 = file.read(); + int b2 = file.read(); + if (b1 < 0 || b2 < 0) return; // Read error + if (b1 == 0 && b2 == 0) break; // Double-null terminator + } + } + + // Everything from here to end of frame is image data + uint32_t imgOffset = file.position(); + uint32_t imgEnd = offset + frameSize; + if (imgOffset >= imgEnd) return; + + uint32_t imgSize = imgEnd - imgOffset; + + // Determine format from MIME type + bool isJpeg = (strstr(mime, "jpeg") || strstr(mime, "jpg")); + bool isPng = (strstr(mime, "png") != nullptr); + + // Also detect by magic bytes if MIME is generic + if (!isJpeg && !isPng && imgSize > 4) { + file.seek(imgOffset); + uint8_t magic[4]; + file.read(magic, 4); + if (magic[0] == 0xFF && magic[1] == 0xD8) isJpeg = true; + else if (magic[0] == 0x89 && magic[1] == 'P' && + magic[2] == 'N' && magic[3] == 'G') isPng = true; + } + + coverOffset = imgOffset; + coverSize = imgSize; + coverFormat = isJpeg ? 13 : (isPng ? 14 : 0); + hasCoverArt = (imgSize > 100 && (isJpeg || isPng)); + + if (hasCoverArt) { + Serial.printf("ID3: Cover %s, %u bytes\n", + isJpeg ? "JPEG" : "PNG", imgSize); + } + } + // Parse Nero-style chapter list (chpl atom). void parseChpl(File& file, uint32_t offset, uint32_t size) { if (size < 9) return; diff --git a/variants/lilygo_tdeck_pro/platformio.ini b/variants/lilygo_tdeck_pro/platformio.ini index 600eb88..6a6b418 100644 --- a/variants/lilygo_tdeck_pro/platformio.ini +++ b/variants/lilygo_tdeck_pro/platformio.ini @@ -80,7 +80,7 @@ build_flags = -D PIN_DISPLAY_BL=45 -D PIN_USER_BTN=0 -D CST328_PIN_RST=38 - -D FIRMWARE_VERSION='"Meck v0.9.0"' + -D FIRMWARE_VERSION='"Meck v0.8.7A"' -D ARDUINO_LOOP_STACK_SIZE=16384 build_src_filter = ${esp32_base.build_src_filter} +<../variants/LilyGo_TDeck_Pro>