mirror of
https://github.com/pelgraine/Meck.git
synced 2026-06-11 00:34:50 +02:00
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:
+111
-13
@@ -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.
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user