audiobook player guide updated.

Summary of what changed in each file, all confined to the change points shown in the diffs:
ChannelScreen.h — ChannelMessage gains a session-only scope_idx (initialised to 0xFF in the constructor and explicitly reset to 0xFF on SD load, since region is not persisted); addMessage gains a trailing defaulted scope_idx and stores it. The message-list line now renders (Xh)(Xb) Xm, with the byte figure taken from path_len's upper bits for floods and from the_mesh.getNodePrefs()->path_hash_mode + 1 for the 0xFF/0 sentinels. The path overlay shows Route: ... (N-byte) and a new Region: line (name, or (reg unknown), or nothing when unscoped).
MyMesh.h / .cpp — a fixed 28-entry SCOPE_NAMES table, a _scope_keys array precomputed once at the end of begin() via initScopeKeys(), resolveScopeIndex() (matches pkt->transport_codes[0] against the candidates; 0xFF unscoped, 0xFE unmatched), and the public getScopeName() accessor. onChannelMessageRecv resolves the index and passes it to newMsg.
AbstractUITask.h / UITask.h / UITask.cpp — newMsg gains a trailing uint8_t scope_idx = 0xFF; only the channel addMessage call forwards it. DMs and sent echoes keep the default, so they stay unscoped.
MsgFileRecord and the SD save/load format are untouched, so there's no version bump.
This commit is contained in:
pelgraine
2026-06-06 20:42:21 +10:00
parent 451f4b01f3
commit ea98eaead4
7 changed files with 201 additions and 24 deletions
+111 -13
View File
@@ -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)
│ └── ...
└── ...
```
```
### 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.
+2 -1
View File
@@ -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) {}
+41 -1
View File
@@ -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() {
+16
View File
@@ -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;
@@ -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);
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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;