diff --git a/Audiobook Player Guide.md b/Audiobook Player Guide.md index 7e930dd6..c0193ce3 100644 --- a/Audiobook Player Guide.md +++ b/Audiobook Player Guide.md @@ -1,7 +1,8 @@ -## Audiobook Player (Audio variant only) +## Audiobook Player -Press **P** from the home screen to open the audiobook player. -Place `.mp3`, `.m4b`, `.m4a`, or `.wav` files in `/audiobooks/` on the SD card. +The audiobook player is available on the T-Deck Pro audio variant and on all +T-Deck Max variants. Press **P** from the home screen to open it. +Place `.mp3`, `.m4a`, or `.wav` files in `/audiobooks/` on the SD card. Files can be organised into subfolders (e.g. by author) — use **Enter** to browse into folders and **.. (up)** to go back. @@ -11,13 +12,15 @@ browse into folders and **.. (up)** to go back. | Enter | Select book or folder / Play-Pause | | A | Seek back 30 seconds | | D | Seek forward 30 seconds | -| [ | Previous chapter (M4B only) | -| ] | Next chapter (M4B only) | +| [ | Previous chapter | +| ] | Next chapter | +| N | Next track | +| Z | Toggle 45-minute sleep timer | | Q | Leave player (audio continues) / Close book (when paused) / Exit (from file list) | ### Recommended Format -**MP3 is the recommended format.** M4B/M4A files are supported but currently +**MP3 is the recommended format.** M4A files are supported but currently have playback issues with the ESP32-audioI2S library — some files may fail to decode or produce silence. MP3 files play reliably and are the safest choice. @@ -25,17 +28,94 @@ MP3 files should be encoded at a **44100 Hz sample rate**. Lower sample rates (e.g. 22050 Hz) can cause distortion or playback failure due to ESP32-S3 I2S hardware limitations. +Check a file's sample rate with ffprobe: + +```bash +ffprobe -i "yourfile.mp3" 2>&1 | grep "Audio:" +# Audio: mp3, 48000 Hz, stereo, fltp, 192 kb/s ← won't play +# Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s ← will play +``` + +Re-encode in place if needed: + +```bash +ffmpeg -i input.mp3 -ar 44100 output.mp3 +``` + +For a whole album folder: + +```bash +cd "/path/to/Album" +mkdir converted +for f in *.mp3; do ffmpeg -i "$f" -ar 44100 "converted/$f"; done +``` + +Verify by re-running ffprobe on a converted file and confirming `44100 Hz`. Once +happy, move the contents of `converted/` up and delete the originals. + **Bookmarks** are saved automatically every 30 seconds during playback and when you stop or exit. Reopening a book resumes from your last position. -**Cover art** from M4B files is displayed as dithered monochrome on the e-ink -screen, along with title, author, and chapter information. - **Metadata caching** — the first time you open the audiobook player, it reads title and author tags from each file (which can take a few seconds with many files). This metadata is cached to the SD card so subsequent visits load near-instantly. If you add or remove files the cache updates automatically. +### WAV Files + +WAV playback works, but the player has a narrower compatibility window than MP3. +Files must be: + +- **Format code 0x0001** (linear PCM). Not 0xFFFE (`WAVE_FORMAT_EXTENSIBLE`), + which is what most DAWs export by default for 24-bit, 32-bit, or + multichannel sessions. +- **16-bit samples.** 24-bit and 32-bit PCM either produce silence or + distortion. +- **44.1 kHz sample rate.** Same constraint as MP3. +- **Mono or stereo.** Multichannel files fail the format check. + +A non-compliant WAV refuses to start. The decoder only accepts format code +0x0001, so anything in the `EXTENSIBLE` wrapper is rejected even when the +underlying samples are plain PCM. + +Inspect a WAV's actual format with ffprobe: + +```bash +ffprobe -i "yourfile.wav" 2>&1 | grep "Audio:" +# Audio: pcm_s24le, 48000 Hz, stereo, s32, 2304 kb/s ← won't play +# Audio: pcm_s16le, 44100 Hz, stereo, s16, 1411 kb/s ← will play +``` + +Re-encode a single file: + +```bash +ffmpeg -i "input.wav" -acodec pcm_s16le -ar 44100 "output.wav" +``` + +For a whole album folder: + +```bash +cd "/path/to/Album" +mkdir converted +for f in *.wav; do ffmpeg -i "$f" -acodec pcm_s16le -ar 44100 "converted/$f"; done +``` + +Verify with ffprobe and check the output line shows `pcm_s16le, 44100 Hz`. Once +you're happy, move the contents of `converted/` up to the album folder and +delete the originals. + +### Album Art + +Strip embedded album art (cover images) from your audio files before copying +them to the SD card. Large embedded artwork can make a track slow to start when +you open it, and in some cases fail or reboot the device, because the player has +to read past the embedded image data before it reaches the start of the audio. +The player does not currently display embedded cover art, so removing it costs +nothing and recovers SD card space. + +Most tag editors can strip embedded artwork — for example Mp3tag, Kid3, or +MusicBrainz Picard. Remove the cover / picture field from each file and save. + ### Background Playback Audio continues playing when you leave the audiobook player screen. Press **Q** @@ -51,10 +131,12 @@ and you're returned to the file list instead. The audiobook player uses the PCM5102A I2S DAC on the audio variant of the T-Deck Pro (I2S pins: BCLK=7, DOUT=8, LRC=9). Audio is output via the 3.5mm -headphone jack. +headphone jack. On the T-Deck Max, it uses the ES8311 audio codec and is +available on every Max variant. -> **Note:** The audiobook player is not available on the 4G modem variant -> due to I2S pin conflicts. +> **Note:** On the T-Deck Pro, the audiobook player is not available on the 4G +> modem variant due to I2S pin conflicts. The T-Deck Max runs both the modem +> and audio at once, so the player is available there regardless of variant. ### SD Card Folder Structure @@ -75,4 +157,20 @@ SD Card ├── books/ (existing — text reader) │ └── ... └── ... -``` \ No newline at end of file +``` + +### Troubleshooting + +**A track stalls for several seconds when you open it, then fails or reboots the +device.** The file has a large embedded album-art image and the player is +working through it before it reaches the audio. Strip the embedded art — see +Album Art. + +**An MP3 refuses to start.** Past the obvious "the file is corrupt" check, the +usual cause is a sample rate other than 44.1 kHz. Inspect it with ffprobe and +re-encode if needed — see Recommended Format. + +**A WAV refuses to start.** The file is outside the compatibility window — most +often `pcm_s24le` or `pcm_s32le` at 48 kHz (the default DAW export), or a +`WAVE_FORMAT_EXTENSIBLE` wrapper. Check it with ffprobe and convert — see WAV +Files. \ No newline at end of file diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 600e3279..18d2c703 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -42,7 +42,8 @@ public: void disableSerial() { _serial->disable(); } virtual void msgRead(int msgcount) = 0; virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, - const uint8_t* path = nullptr, int8_t snr = 0) = 0; + const uint8_t* path = nullptr, int8_t snr = 0, + uint8_t scope_idx = 0xFF) = 0; // 0xFF = unscoped virtual void notify(UIEventType t = UIEventType::none) = 0; virtual void loop() = 0; virtual void showAlert(const char* text, int duration_millis) {} diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a9f00e7a..9e543b27 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -670,6 +670,43 @@ const char* MyMesh::getChannelScopeName(const mesh::GroupChannel& channel) { return nullptr; } +// --- Region scope candidate list (display-only resolution of incoming channel msgs) --- +// Fixed set of nameable regions. Keys are precomputed once at boot in initScopeKeys(). +const char* const MyMesh::SCOPE_NAMES[MyMesh::SCOPE_COUNT] = { + "au", + "au-nsw", "au-vic", "au-act", "au-sa", "au-wa", "au-tas", "au-nt", "au-qld", + "au-nsw-syd", "au-nsw-bhs", "au-nsw-hun", "au-nsw-ntl", "au-nsw-wol", + "au-nsw-cw", "au-nsw-wsi", "au-nsw-syd-iwc", + "au-vic-mel", "au-vic-east", "au-vic-north", "au-vic-west", + "au-act-cbr", + "au-tas-hob", + "au-qld-bne", + "au-wa-per", "au-wa-fre", "au-wa-buy", + "au-hume" +}; + +void MyMesh::initScopeKeys() { + for (uint8_t i = 0; i < SCOPE_COUNT; i++) { + deriveScopeKey(SCOPE_NAMES[i], _scope_keys[i]); + } +} + +uint8_t MyMesh::resolveScopeIndex(const mesh::Packet* pkt) const { + if (!pkt || !pkt->hasTransportCodes()) return 0xFF; // unscoped + uint16_t code = pkt->transport_codes[0]; + for (uint8_t i = 0; i < SCOPE_COUNT; i++) { + if (_scope_keys[i].calcTransportCode(pkt) == code) return i; + } + return 0xFE; // scoped, but not one of the known regions +} + +const char* MyMesh::getScopeName(uint8_t idx) const { + if (idx == 0xFF) return nullptr; // unscoped -- no region line + if (idx == 0xFE) return "(reg unknown)"; // scoped but unmatched + if (idx < SCOPE_COUNT) return SCOPE_NAMES[idx]; + return nullptr; +} + 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 @@ -816,7 +853,8 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe } if (_ui) { const uint8_t* msg_path = (pkt->isRouteFlood() && pkt->path_len > 0) ? pkt->path : nullptr; - _ui->newMsg(path_len, channel_name, text, offline_queue_len, msg_path, pkt->_snr); + uint8_t scope_idx = resolveScopeIndex(pkt); + _ui->newMsg(path_len, channel_name, text, offline_queue_len, msg_path, pkt->_snr, scope_idx); if (!_prefs.buzzer_quiet) _ui->notify(UIEventType::channelMessage); //buzz if enabled } #endif @@ -1530,6 +1568,8 @@ void MyMesh::begin(bool has_display) { radio_set_params(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); radio_set_tx_power(_prefs.tx_power_dbm); + + initScopeKeys(); // precompute region-scope transport keys for incoming-message display } const char *MyMesh::getNodeName() { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 05ea03c0..470b0d0a 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -185,6 +185,10 @@ public: bool deriveScopeKey(const char* scopeName, TransportKey& keyOut); // Look up per-channel scope name by GroupChannel secret match. Returns nullptr if no scope set. const char* getChannelScopeName(const mesh::GroupChannel& channel); + // Resolve a region scope index (as produced by resolveScopeIndex during onChannelMessageRecv) + // to a display name. Returns nullptr for unscoped, "(reg unknown)" for scoped-but-unmatched, + // otherwise the candidate region name. Used by the channel path-detail overlay. + const char* getScopeName(uint8_t idx) const; protected: @@ -308,6 +312,18 @@ private: TransportKey send_scope; + // --- Region scope resolution for incoming channel messages (display only) --- + // A received scoped flood/direct packet carries a one-way transport code. We match + // it against this fixed candidate list, whose keys are precomputed once at boot in + // initScopeKeys(). resolveScopeIndex() returns an index into SCOPE_NAMES, 0xFF for + // unscoped packets, or 0xFE for scoped-but-unmatched. Result is held in RAM only + // (per-message), never persisted. + static const uint8_t SCOPE_COUNT = 28; + static const char* const SCOPE_NAMES[SCOPE_COUNT]; + TransportKey _scope_keys[SCOPE_COUNT]; + void initScopeKeys(); + uint8_t resolveScopeIndex(const mesh::Packet* pkt) const; + uint8_t cmd_frame[MAX_FRAME_SIZE + 1]; uint8_t out_frame[MAX_FRAME_SIZE + 1]; CayenneLPP telemetry; diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h index 800fba6a..278e6ede 100644 --- a/examples/companion_radio/ui-new/ChannelScreen.h +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -68,6 +68,7 @@ public: uint8_t path[MSG_PATH_MAX]; // Repeater hop hashes char text[CHANNEL_MSG_TEXT_LEN]; bool valid; + uint8_t scope_idx; // Region scope index for display (session only, 0xFF = unscoped). Not persisted. }; // Simple hash for DM peer matching @@ -139,6 +140,7 @@ public: _messages[i].valid = false; _messages[i].dm_peer_hash = 0; memset(_messages[i].path, 0, MSG_PATH_MAX); + _messages[i].scope_idx = 0xFF; } // Initialize unread counts memset(_unread, 0, sizeof(_unread)); @@ -151,7 +153,7 @@ public: // suppressUnread: if true, do not increment the unread counter for this message void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text, const uint8_t* path_bytes = nullptr, int8_t snr = 0, const char* peer_name = nullptr, - bool suppressUnread = false) { + bool suppressUnread = false, uint8_t scope_idx = 0xFF) { // Move to next slot in circular buffer _newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE; @@ -161,6 +163,7 @@ public: msg->channel_idx = channel_idx; msg->snr = snr; msg->valid = true; + msg->scope_idx = scope_idx; // Set DM peer hash for conversation filtering if (channel_idx == 0xFF) { @@ -545,6 +548,7 @@ public: _messages[i].dm_peer_hash = rec.dm_peer_hash; memcpy(_messages[i].path, rec.path, MSG_PATH_MAX); memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN); + _messages[i].scope_idx = 0xFF; // region scope is session-only, not stored on SD if (_messages[i].valid) loaded++; } @@ -845,11 +849,21 @@ public: display.print("Route: Local/Sent"); } else { display.setColor(DisplayDriver::GREEN); - sprintf(tmp, "Route: %d hop%s (%dB)", hopCount, hopCount == 1 ? "" : "s", bytesPerHop); + sprintf(tmp, "Route: %d hop%s (%d-byte)", hopCount, hopCount == 1 ? "" : "s", bytesPerHop); display.print(tmp); } y += lineH; + // Region (scoped channel messages only; session, not persisted) + const char* rgn = the_mesh.getScopeName(msg->scope_idx); + if (rgn) { + display.setCursor(0, y); + display.setColor(DisplayDriver::YELLOW); + sprintf(tmp, "Region: %s", rgn); + display.print(tmp); + y += lineH; + } + // SNR (if available — value is SNR×4) if (msg->snr != 0) { display.setCursor(0, y); @@ -1245,14 +1259,21 @@ public: sprintf(tmp, ">%dd ", age / 86400); } } else { + int hopsDisp = (msg->path_len == 0xFF) ? 0 : (msg->path_len & 63); + // Byte mode: flood packets encode it in the upper bits of path_len. + // The sentinels (0xFF direct-received, 0 locally-sent) do not encode it, + // so fall back to this device's configured path hash size. + int bphDisp = (msg->path_len == 0xFF || msg->path_len == 0) + ? (the_mesh.getNodePrefs()->path_hash_mode + 1) + : ((msg->path_len >> 6) + 1); if (age < 60) { - sprintf(tmp, "(%d) %ds ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age); + sprintf(tmp, "(%dh)(%db) %ds ", hopsDisp, bphDisp, age); } else if (age < 3600) { - sprintf(tmp, "(%d) %dm ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age / 60); + sprintf(tmp, "(%dh)(%db) %dm ", hopsDisp, bphDisp, age / 60); } else if (age < 86400) { - sprintf(tmp, "(%d) %dh ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age / 3600); + sprintf(tmp, "(%dh)(%db) %dh ", hopsDisp, bphDisp, age / 3600); } else { - sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : (msg->path_len & 63), age / 86400); + sprintf(tmp, "(%dh)(%db) %dd ", hopsDisp, bphDisp, age / 86400); } } display.print(tmp); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 51e0e942..272e1677 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1577,7 +1577,7 @@ void UITask::msgRead(int msgcount) { } void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, - const uint8_t* path, int8_t snr) { + const uint8_t* path, int8_t snr, uint8_t scope_idx) { _msgcount = msgcount; // --- Dedup: suppress retry spam (same sender + text within 60s) --- @@ -1725,7 +1725,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, dmFormatted, path, snr, nullptr, suppressNotif); } } else { - ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr, nullptr, suppressNotif); + ((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr, nullptr, suppressNotif, scope_idx); } // If user is currently viewing this channel on the device, or companion diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index f3b15230..89569d5d 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -366,7 +366,8 @@ public: // from AbstractUITask void msgRead(int msgcount) override; void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, - const uint8_t* path = nullptr, int8_t snr = 0) override; + const uint8_t* path = nullptr, int8_t snr = 0, + uint8_t scope_idx = 0xFF) override; void notify(UIEventType t = UIEventType::none) override; void loop() override;