The dominant garble was NOT the mic: T:RAWMIC/recorder ran full-duplex (start_loopback), so
the T-Deck played the decoded loopback round-trip out its OWN speaker, inches from the mic ->
acoustic feedback that built into "oscillating static that gets slowly louder". Gate the
playback ring-write off while the PSRAM recorder is active (i2s_playback.cpp) so raw-mic
captures are feedback-free -> speech becomes intelligible (Whisper recovers ~85% of words).
Also in this change:
- Recorder stores BOTH TDM channels (main.cpp) for offboard de-interleave; confirmed CH0 is
the mic, CH1 dead -> de-interleave was always correct.
- MCLK raised to an exact APLL-locked 12.288MHz (es7210.cpp MCLK_DIV_FRE=768 + the
{12288000,16000} coeff; i2s_capture.cpp use_apll=true/fixed_mclk) -- cleaner than the
fractional-N 4.096MHz, though the feedback (not jitter) was the real cause.
Residual (under investigation): signal-correlated distortion ("static reacts to speech") +
50Hz mains hum (USB-ground pickup, removed by the existing 300Hz HPF in the call path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
Drives the firmware T: hooks to capture raw mic audio (UDP tap or the reliable
PSRAM/serial recorder), plays TTS references out the Mac speaker, and scores the
round-trip with envelope/STOI metrics and Whisper transcription. venv + recorded
WAVs are gitignored.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
Confirmed fixes:
- LVGL hybrid-allocator threshold 1024->256 (lib/lv_mem_hybrid.h): moves ~45KB of
small LVGL objects to PSRAM, fixing internal-heap starvation that made the
call/loopback pipeline intermittently fail to allocate and reboot mid-call
(internal free 71->116KB, largest contiguous block 61->106KB).
- ES7210 mic PGA gain 21dB->12dB (lxst_audio.cpp): 21dB saturated the ADC -- an
rms-7003 noise floor in silence + 0x8000 negative-rail spikes + a spectral-peak
shift that masqueraded as a "+17% pitch warp". At 12dB idle silence is clean (rms 48).
Diagnostic harness (PYXIS_TEST_HOOKS, test-only): T:RAWMIC[stage], T:REG, and
T:RECORD/T:DUMPREC add a raw-mic UDP tap plus a reliable PSRAM recorder with a
checksummed serial transfer, and T:LOOPBACK wiring, for objective mic-capture
analysis (offboard Whisper STT scoring).
Exploratory / still unresolved: captured speech remains garbled (oscillating static,
likely an ES7210 sigma-delta capture instability -- under investigation). Includes a
16kHz/main-PLL clock path + MICBIAS exploration; the "warp" was the gain artifact, not
a clock issue, so the 16kHz path can later be simplified back to 8kHz.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
The pre-scripts now import their sibling _build_helpers; the build_scripts tests
load those scripts in-process, so the import needs the repo root on sys.path. CI
runs bare `pytest` (which, unlike `python -m pytest` locally, does NOT add the cwd
to sys.path), so the import failed only there. A conftest.py that puts the repo
root on sys.path fixes it for any invocation. (The standalone-exec subprocess test
got its own sys.path line in the prior commit.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
- test_patch_nimble.py: the refactor made the script import its sibling
_build_helpers; the standalone-exec test ran it with a temp PROJECT_DIR, so add
the script's real directory to the shim's sys.path (CI pytest failure fix).
- sync_file_libdeps.py: remove the now-dead ENV_NAME binding.
- _build_helpers.py: reword the docstring (the "never literal env" line conflicted
with the helper's own "tdeck" fallback) and note inline that "tdeck" is a
never-hit default (PlatformIO always injects PIOENV).
- main.cpp: DRY the OTA hostname into OTA_HOSTNAME used by both setHostname() and
the log line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
OTA hardening follow-up to the patch_nimble.py hardcoded-"tdeck" bug that broke
tdeck-ota linking (undefined reference to nimble_host_reset_reason):
- Add _build_helpers.env_libdeps_dir(env, *parts): the single per-environment
libdeps path resolver (.pio/libdeps/<PIOENV>/...). Converted all five pre-scripts
(patch_nimble/msgpack/filestore/littlefs_paths, sync_file_libdeps) to use it, so
the env component can no longer be hand-rolled/hardcoded wrong per script.
- "OTA: Ready" log -> "OTA: wireless flash service started (pyxis-tdeck:3232)":
ArduinoOTA.begin() is void and can't confirm a ready state, so log the target
instead of claiming readiness we can't verify.
Verified: both tdeck and tdeck-ota build, and the NimBLE patch lands in EACH env's
own libdeps tree (nimble_host_reset_reason count 2 in both).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
Addresses Greptile review on #37. SettingsScreen has its OWN settings load
(separate from main.cpp's app_settings), which still defaulted announce to 60s and
sync to 3600s and overrode the intended defaults -- so the UI still showed the old
values. Set them to 3600s (1h) and 14400s (4h) to match. Also updated two stale
block comments in update_ui_from_settings / update_settings_from_ui that still said
"minutes" after the sync field was promoted to hours.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
Settings/clock (user-reported):
- Announce interval: default 1h (was 60s); the field is now MINUTES (label
"Announce Interval (min):", populate /60, read *60).
- Prop sync interval: default 4h (was 1h); the field is now HOURS (label
"Prop Sync Interval (hrs):", populate /3600, read *3600); log prints hours.
- Settings clock now ticks live: SettingsScreen::tick() (throttled ~1s) is hooked
into UIManager::update() like the announce-list tick, so the Time/GPS/system
readouts refresh while the screen is open instead of being a static snapshot.
OTA repair (was broken two ways; diagnosed via an ultracode workflow):
- patch_nimble.py hardcoded the `tdeck` libdeps path, so building any OTHER env
(tdeck-ota gets its own libdeps tree) never received the NimBLE patch that
DEFINES nimble_host_reset_reason -> undefined-reference link error. Made it
env-aware (env.get("PIOENV",...)), matching the four sibling pre-scripts. Also
restores 3 NimBLE stability patches that were silently missing from OTA builds.
- ArduinoOTA onStart synchronously called TCPClientInterface::stop() (blocks up to
30s), overrunning espota's ~10s connect-back window -> "No response from device"
(phase 2), and its un-fed watchdog tripped reboots. Made onStart non-blocking.
- [env:tdeck-ota] upload_command invoked a non-existent tools/espota.py -> switched
to upload_protocol=espota.
- Do NOT WiFi.setSleep(false): the ESP32 requires WiFi modem-sleep when WiFi+BT
coexist (aborts at boot otherwise) -- documented inline.
Verified on device: tdeck-ota links; OTA over WiFi completes 100% (Result: OK) and
the device reboots clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
Addresses Greptile review on #36:
- Arizona is inside the Mountain longitude band but doesn't observe DST (and it
straddles the Mountain/Pacific boundary), so MST7MDT wrongly showed MDT in
summer. It now gets an explicit MST7 (no-DST) carve-out by lat/lon box before
the longitude bands. (The DST-observing Navajo Nation is not separately handled.)
- The Settings "Time:" display used a >= 2016 threshold, which let the ESP32's
~2016-01-01 first-boot default render as a real date instead of "not set".
Raised to >= 2024 so an unsynced clock is visibly unsynced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
- Add a live "Time:" line to Settings -> GPS showing the system clock (e.g.
"Time: 2026-06-22 16:32:12", or "not set" when unsynced), so the GPS time-sync
can be verified on-device at a glance -- a far-future year or "not set" flags a
rollover/unsynced clock without needing serial.
- Fix the HDOP readout: it divided by 100 a second time (hdop.hdop() already
returns the true value), so it always showed ~0.0 (Ideal). Now shows the real
geometry -- which matters since HDOP is the metric the new sync-gate keys off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
The device intermittently stamped messages ~19 years in the future. Root cause is
a GPS week-number rollover (1024 weeks ~= 19.6y): an un-resolved/cold-start fix
emits a rollover date (e.g. 2046), and sync_time_from_gps accepted any date with
year >= 2024 (no upper bound) and wrote it to the system clock, which microLXMF
then stamps onto every outgoing message.
- Quality+stability gate: set the clock only from a real 3D fix (>= 4 satellites,
HDOP <= 5) whose calendar date has been stable for >= 3s. Rollover/cold-start
bogus dates are weak and don't hold steady, so they're rejected. No hardcoded
future cutoff (which would itself age out). Date stability is tracked off the
main loop's continuous gps.encode() feed.
- Re-sync fixed: the periodic retry called sync_time_from_gps(0), which returned
immediately without syncing, and only ran until the first sync. It's now
genuinely non-blocking (sets the clock iff the current fix passes the gate) and
keeps retrying, so the clock self-corrects when a fix appears (e.g. basement ->
window) with no reboot.
- DST + double-mktime (ported from abandoned branch 8d4a047): TZ=UTC0 is set
before the single mktime() (the old double call corrupted the struct and skewed
the UTC clock by the DST offset), and continental-US timezones use POSIX strings
with DST rules so summer shows EDT/CDT/MDT/PDT instead of standard time.
Field-verified: from a window fix it synced to the correct 2026-06-22, no rollover.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
Bumps the microReticulum pin to 6054f6ba (a comment-only amend of the mirror
commit) and adds a pin-comment note: both _path_table and _new_path_table are
upstream (attermann/microReticulum is mid-migration to the microStore-backed
table and left path_table()'s in-memory source unpopulated). The mirror is a
stopgap to restore enumeration; the note + the long comment at the mirror site
say to DELETE it once upstream finishes the read-side migration, so a future pin
bump knows why the table can go.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
- AnnounceListScreen::tick(): drop the pre-sort 64-item gather cap so the sort
always sees the true newest destinations (the path table is bounded by
USTORE_DEFAULT_MAX_RECS=400 and items allocate in PSRAM, so the gather is
bounded); only the render stays capped at MAX_DISPLAY.
- tick() now re-adds the freshly rendered item containers to the focus group and
focuses the first. The deferred gather meant show() (which adds widgets to the
group) ran before the containers existed and lv_obj_clean dropped the old ones,
so trackball navigation couldn't reach the list items.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
Pins microReticulum to the fork's pyxis-fixes-on-0.4.1 @ b582986 (three 0.4.1-graft
regression fixes) and adds the pyxis-side pieces.
microReticulum fixes (via the pin):
- Identity::recall now accounts for the announce ratchet (matches validate_announce)
— it read app_data 32 bytes early for ratchet-bearing announces (Sideband/Columba),
garbling peer display names.
- DestinationEntry caps persisted random_blobs to PERSIST_RANDOM_BLOBS (16); the set
was never trimmed, growing path entries ~12B/re-announce past microStore's 1024B
ceiling so put() rejected them (empty path table) — and under a raised ceiling
bloated the FS until compaction crashed.
- Transport mirrors learned paths into _path_table so path_table() (the UI announce
list + T:PATHS) reflects them; the microStore migration stopped populating it.
pyxis pieces:
- -DUSTORE_DEFAULT_MAX_RECS=400 caps the live path-store record count (defense-in-depth).
- AnnounceListScreen::refresh() defers to a main-loop tick(): the gather (path-table
iteration + per-entry recall/app_data) runs off the LVGL lock, serialized with the
main-loop path-table writes, instead of racing them on the LVGL task and blocking
past the 5s lock timeout — which hung/crashed on open once the table was non-empty.
Verified on device: display names correct, T:PATHS 0 -> 11, no rejections, no
crashes, announce list opens cleanly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
- Backstop ARMS its timer on the first disconnected tick instead of firing
immediately, so it can't issue a redundant begin() ~1s into boot and reset the
in-progress association (or setAutoReconnect's own retry). Re-armed on each
(re)connect so every drop gets a fresh ~15s grace.
- WiFi.persistent(false): begin() no longer writes creds to NVS on every call, so
the periodic backstop during a long outage doesn't wear flash. Creds are
already persisted by the app's Preferences store; setAutoReconnect is in-RAM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
connect_wifi() never enabled auto-reconnect, and the loop's "WiFi reconnect
check" only re-attempted on the manual Settings -> Reconnect button. So a dropped
association left the device offline (no TCP, no AutoInterface, "no connection" in
the status bar) until a reboot. Enable WiFi.setAutoReconnect(true) + persistent,
and add a non-blocking backstop in the main loop that re-issues WiFi.begin()
every ~15s while disconnected -- setAutoReconnect alone doesn't cover every
disconnect reason. Verified the SSID/AP are fine (connects at boot); closes the
stay-offline-until-reboot gap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
on_full_message_close() runs from the Close button's own callback, and the button
is a descendant of the modal — so lv_obj_del(modal) freed the button mid-dispatch
(use-after-free). lv_obj_del_async defers the delete until the event completes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5
Long-press a bubble to open a scrollable full-message view with Copy/Close.
Bubbles render truncated for scroll performance, so the handler recovers the FULL
stored content (row -> hash -> item) for the view -- which also fixes Copy, which
had regressed to copying the truncated label text after the render cap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWZuYkHBRqNb6BZHV8sTG5