Skip to content

Modernize build, add diagnostics and live stream-health status strip - #1

Merged
Tarrant64 merged 14 commits into
masterfrom
feature/ui-fixes
Aug 29, 2026
Merged

Tarrant64 merged 14 commits into
masterfrom
feature/ui-fixes

Conversation

@Tarrant64

Copy link
Copy Markdown
Owner

Why

Upstream v2.5 works, but audio silently degrades and then stops after roughly 15-20 minutes while the process stays alive and the bot stays connected in voice — the only recovery is a manual restart (upstream issues #16 / #46).

This fork exists to own the build, instrument the failure so we can see which metric moves first, and add quality-of-life features along the way. Nothing here claims to fix the stall yet; the goal of this branch is to make the failure visible and measurable.

What changed

1. Dependency modernization (5fa9d5a)

  • PyQt5 5.15.9 → PyQt6 6.11.0
  • sounddevice 0.4.6 → 0.5.6
  • Python 3.10 → 3.12 (CI)
  • PyInstaller 5.1 → 6.22.2
  • discord.py[voice] 2.7.1 unchanged

2. Build + CI (b4bb728, d66ea07, 93dde0b)

  • build/main.spec rewritten for PyInstaller 6: the old a.binaries - TOC([...]) idiom, block_cipher, a.zipped_data, win_no_prefer_redirects etc. were all removed in PyInstaller 6, so the previous spec could not run at all. Paths now derive from SPECPATH rather than the caller's cwd. Adds davey (discord.py 2.7's DAVE/E2EE voice extension) as a hiddenimport — it is imported from inside the voice stack, so relying on the module graph to find it is a gamble.
  • Two-leg CI matrix: a modern leg (3.12 / PyInstaller 6 / PyQt6) and a baseline control leg that pins the exact pre-modernization toolchain, spec and application sources (source_ref: b9ea7bf is checked back out over the tree), so there is always a known-good artifact to compare against when the modern leg misbehaves. fail-fast: false so one leg failing still produces the other's artifact.
  • Triggers: push on feature/** (GitHub only shows the Run workflow button for workflows on the default branch, so a feature-branch workflow needs a push trigger to ever build) plus workflow_dispatch, keeping the original release: prereleased asset upload.
  • Concurrency cancellation — superseded runs are cancelled rather than billed; this is a private repo and windows-latest bills at a 2x minute multiplier.
  • Version resource stamping (build/version_info.py, modern leg only): the exe previously reported FileVersion/ProductVersion 0.0.0.0 with empty CompanyName/FileDescription, so a binary found on disk was unidentifiable. Now stamped 2.5.0.<run_number> with the branch ref and short sha carried in the version strings. Version derivation can never fail the build — every environment read falls back to a static default and the spec wraps the load in try/except.
  • The baseline leg's setuptools<82 pin: setuptools 82.0.0 removed pkg_resources, which PyInstaller 5.1 imports at the top of its own __init__.py. The pin belongs on the leg, not on PyInstaller — bumping PyInstaller would delete the thing being measured.

3. Diagnostic logging (37424d4 — logging_setup.py)

  • Rotating handlers (2 MB × 3) for the previously unbounded DAP_errors.log and discord.log, plus a new INFO-level DAP_session.log lifecycle trail with millisecond timestamps. Bot tokens are redacted from the logged argv line.
  • Promotes discord.py's two most diagnostic voice lines out of DEBUG obscurity to WARNING, even without -v: 'Aborting playback' (player.py:818) and 'Not connected, waiting for %ss...' (player.py:814). These are the load-bearing lines and were previously invisible. Aborting playback sits immediately before a bare return that leaves AudioPlayer._end unset, so VoiceClient.is_playing() keeps returning True on a dead thread — a single such line proves the silent-abort hypothesis outright. The re-badge is done on a copy of the record, so -v's discord.log still shows the original DEBUG level.

4. Instrumentation (37424d4, 0f7136f — instrumentation.py, --diagnose)

InstrumentedPCMStream subclasses sound.PCMStream and overrides read() only — sound.py is untouched. Per call it records:

  • read-block timing of the PortAudio read,
  • the PortAudio overflowed flag that upstream discards via [0],
  • returned length and input ring depth,
  • a drift ledger (frames read / 48000 vs elapsed wall clock, in seconds and ppm).

VoiceStatePoller polls the voice client every 5s and escalates the two silent-death signatures to WARNING: is_playing() True while _player.is_alive() is False (silent abort), and _player.loops frozen across a poll while the thread is alive (player parked). Every discord.py private access (_player, _connection, .loops) goes through a guard that logs one WARNING and then permanently retires that probe, so a discord.py upgrade cannot crash the app.

As of 0f7136f, metric collection runs on every session and --diagnose gates only the verbose per-5s log lines. The SILENT ABORT / PLAYER PARKED warnings are deliberately no longer behind the flag — they fire at most once per episode and are exactly the evidence we want from a user who hits the bug without thinking to pass a flag.

5. UI restyle (1e5fd4e, b10edb9, a75798e)

  • Layered surfaces (titlebar #131417 / panel #24262C / inset control well #1B1D21) so inputs read as recessed; consistent 8px radii (was a mix of 2px/3px).
  • Full :hover / :pressed / :focus / :disabled coverage on every interactive control — focus and disabled were previously invisible, which made the connecting state unreadable.
  • Accent retuned to #5561E8 so white label text clears 4.5:1; body text at 12.6:1 on panel.
  • Fixed ::hover → :hover pseudo-state syntax and the specificity collision that let the titlebar rule override the red close-button hover.
  • Code-side companions: titlebar buttons set to NoFocus (the new focus ring exposed that the app opened with a highlighted minimize button), titlebar pinned to setFixedHeight(36), real grid setSpacing(10) / setContentsMargins(20, 16, 20, 20) replacing the 3px per-widget QSS margins that were faking row rhythm, and pointing-hand cursors (QSS cursor is unreliable in Qt).

6. Live status strip + settings (0f7136f — gui.py, config.py)

  • One line at the bottom, refreshed every 2s: ● Live · 38ms · 0 drops · drift +12ppm · 18m. State is spelled out in words as well as coloured, so it survives colour-blindness and screenshots. Unknown values render --, and a stale value is never shown as if it were live — including the all-probes-retired case, which reports "No metrics" rather than a green "Live" it has not earned.
  • Metrics reach the GUI through a lock-free snapshot: producer threads build a dict and rebind one attribute; a rebind is atomic under the GIL, so the reader never sees a partial value and the audio thread never waits on the GUI.
  • Strip contrast on #1B1D21: green 6.6:1, amber 7.8:1, red 6.1:1, idle 6.4:1. The strip's red is #F87171 rather than the existing #D83C3E, which is only 3.7:1 as text.
  • config.py: versioned JSON next to token.txt. Atomic os.replace write, unknown keys preserved across downgrade/upgrade round trips, and every failure path (missing, corrupt, wrong-typed, unwritable, path-is-a-directory) falls back to defaults with one WARNING instead of raising. The token is never stored — FORBIDDEN_KEYS refuses it, and a hand-added one is ignored on load and stripped on the next write.

7. Windows-only rendering fixes (afec6c7)

Two problems from the first run on real Windows 11, neither of which reproduced on macOS:

  • Servers/Devices dropdown truncation ("A pack of autism" → "A pack of auti"): resize_combobox() sized combos as "widest item text + 30px", but the stylesheet spends 12px on left padding and 32px on the arrow well, so every dropdown was ~16px short of its own contents — and setMinimumWidth() meant a long guild name could push the window arbitrarily wide and never let it back down. Now uses AdjustToContents, caps the size hint, and elides with the full name on the tooltip.
  • Everything rendered bold: assets/ shipped only Roboto-Black.ttf, which registers as the sole face of family "Roboto", so every weight request — including the default 400 — snapped to 900. macOS hid this because addApplicationFont() returns -1 for a relative path there, silently falling back to the system font. Font paths are now absolute, failures are logged rather than swallowed, and Roboto Regular + Medium (v2.137, matching the bundled Black) join the family. Fonts are Apache-2.0; assets/Roboto-LICENSE.txt carries the attribution.

Reviewer notes

  • Instrumentation overhead is negligible: re-measured on CPython 3.14 against a fake PortAudio stream — 122 ns/call plain vs 344 ns/call instrumented, so ~222 ns of overhead against a 20 ms per-frame budget (0.001%). Enabling --diagnose adds ~1 ns.
  • The baseline CI leg is a control, not a build target. It reproduces the pre-modernization toolchain and sources (checked out at b9ea7bf) so there is always a comparison artifact. build/main-baseline.spec is intentionally untouched by the version-resource work for the same reason.
  • Status-strip thresholds are PROVISIONAL and not yet calibrated. We have two clean captures and none of a failing stream, so the green/amber/red boundaries are deliberately loose guesses — under-warning costs one missed warning, crying wolf costs the user's trust in the readout entirely. Recalibration procedure is in the README: run with --diagnose, capture a session that actually degrades, set amber near where each metric leaves its clean-minute range.
  • Auto-recover is a persisted setting only — the checkbox saves the preference and does nothing else. It is labelled "(soon)" with a matching tooltip, deliberately, so the UI does not imply behavior that is not implemented.
  • No secrets are used anywhere in CI beyond the auto-provided GITHUB_TOKEN, and a Discord token must never be added to it.

Known incomplete / follow-up work

  • Auto-recover behavior — persisted preference exists; the recovery logic is a separate change.
  • Threshold calibration against a real degradation capture.
  • Profile persistence (last-used device / server / channel / mute state) — config.py is already versioned and extensible for exactly this.
  • Connect / Disconnect controls — separate upcoming work.
  • macOS build spec — the PyInstaller hooks were made macOS-safe, but a macOS spec is a later phase.

Everything on this branch has been verified against a Windows build; the status strip was additionally verified by rendering the real GUI offscreen in eight states, with 29 config unit tests and 19 integration checks passing.

Tarrant64 and others added 12 commits August 29, 2026 06:31
Dependencies (requirements.txt):
- PyQt5 5.15.9 -> PyQt6 6.11.0 (current stable; 6.9.1 exists but is superseded)
- sounddevice 0.4.6 -> 0.5.6 (no breaking API changes in the surface we use)
- discord.py[voice] stays at 2.7.1 (already current, provides DAVE/E2EE)

Qt6 port (gui.py, main.pyw):
- QtSvg.QSvgWidget -> QtSvgWidgets.QSvgWidget
- Qt.FramelessWindowHint -> Qt.WindowType.FramelessWindowHint
- Qt.LeftButton -> Qt.MouseButton.LeftButton
- QEventLoop.AllEvents -> QEventLoop.ProcessEventsFlag.AllEvents
- QMessageBox.Information -> QMessageBox.Icon.Information
- QMouseEvent.pos() -> position().toPoint() (pos() deprecated in Qt6)

The asyncio-driven run_Qt()/processEvents() loop is preserved unchanged.
No behavior changes, no new features.

.gitignore: add venv/, .venv/, *.pyc, build/dist/, dist/
README.md: Python 3.8+ -> 3.10+ (PyQt6 floor); note libxcb-cursor0 for Qt6 on Linux

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build config only. The application sources are owned by the PyQt6 migration
in 5fa9d5a and are untouched here.

build/main.spec (PyInstaller 6.22.2 / PyQt6 6.11 / Python 3.12)
  - Drop the `a.binaries - TOC([...])` subtraction idiom. Analysis TOC members
    have been plain lists since PyInstaller 5.11 and the TOC class is
    deprecated, so the old spec could not run at all under PyInstaller 6.
    Replaced with list-comprehension filtering over the
    (dest_name, src_name, typecode) tuples.
  - Drop `block_cipher`/`cipher`, `a.zipped_data`, `a.zipfiles`,
    `win_no_prefer_redirects`, `win_private_assemblies` - all removed in
    PyInstaller 6.0. Add `hooksconfig` and `optimize`.
  - `import os` explicitly and derive every path from SPECPATH, so the spec no
    longer depends on the caller's working directory.
  - hiddenimports PyQt5 -> PyQt6; translations pruning PyQt5\Qt -> PyQt6\Qt6.
  - Add `davey` (discord.py 2.7 DAVE/E2EE voice extension, a transitive dep of
    discord.py[voice] 2.7.1) as a hiddenimport plus a defensive
    collect_dynamic_libs('davey'). It is imported from inside discord.py's
    voice stack, so relying on the module graph to find it is a gamble; an
    over-collection is free, a miss breaks voice at run time.
  - Shrink the exclusion list from 44 entries to the families this app
    provably cannot reach: the QML/Quick/Quick3D/Labs stack, Qt tooling
    (Test/Designer/Help), and the software-GL/ANGLE blobs. PyInstaller 6's Qt
    hooks collect far more precisely than the PyInstaller 5 ones the old list
    targeted, so the rest were dropped rather than risk a broken exe -
    notably Qt6Network, Qt6DBus, Qt6Multimedia(Widgets), Qt6PrintSupport,
    Qt6Sql, Qt6Xml and the connectivity/sensor family. Qt6Svg/Qt6SvgWidgets
    are never excluded: gui.py imports PyQt6.QtSvgWidgets.

build/hook-discord.py, build/hook-sounddevice.py
  - Guard against a missing package/directory and skip the bundled libopus
    collection off Windows, so the hooks no longer hard-fail on a macOS host
    (macOS spec itself is a later phase). Glob libopus*.dll instead of
    hardcoding the filename. Behaviour on Windows is unchanged, since these
    hooks are shared with the baseline control spec.

build/requirements.txt
  - Sync with the app pins: PyQt6==6.11.0, sounddevice==0.5.6.

build/main-baseline.spec, build/requirements-baseline.txt
  - Verbatim copies of the pre-modernization build inputs from b9ea7bf, so CI
    can still produce a known-good control artifact.

.github/workflows/main.yml
  - Add workflow_dispatch; keep the release/prereleased trigger and its
    asset upload.
  - Two-leg matrix with fail-fast: false. `modern` = py3.12 + PyInstaller
    6.22.2 + new pins + new spec. `baseline` = py3.10 + PyInstaller 5.1 +
    original pins + original spec, and it checks the pre-PyQt6 application
    sources back out of b9ea7bf so the control is a true control rather than
    PyQt6 code frozen against PyQt5.
  - Upload each leg's dap.exe via actions/upload-artifact@v4 on every run
    (dap-modern-win64 / dap-baseline-win64); release assets get a -baseline
    suffix on the control leg so the original asset name is preserved.
  - Bump actions/checkout@v2 -> v4, actions/setup-python@v3 -> v5.
  - Upgrade pip/setuptools and echo python/pyinstaller versions plus
    pip freeze into the log for diagnosis.
  - Only secret used is the auto-provided GITHUB_TOKEN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GitHub only surfaces the workflow_dispatch "Run workflow" button for
workflows present on the default branch. This workflow lives on a feature
branch, so a push trigger is required for it to ever build.

Adds a concurrency group so superseded runs are cancelled rather than
billed - this is a private repo and windows-latest bills at 2x minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Instrumentation only -- no fix, no watchdog, no auto-restart. Aimed at
upstream issues #16 / #46: audio stops after 15-20 minutes while the
process stays alive and the bot stays connected in voice.

New files (all the logic lives here, so upstream merges stay cheap):

* logging_setup.py -- owns every handler. RotatingFileHandler (2 MB x 3)
  for the previously unbounded DAP_errors.log and discord.log, plus a new
  INFO-level DAP_session.log carrying the lifecycle trail with
  millisecond timestamps. Bot tokens are redacted from the argv line.

  Most importantly it promotes discord.py's two most diagnostic voice
  lines out of DEBUG obscurity into DAP_session.log at WARNING, even
  without -v: 'Aborting playback' (player.py:818) and 'Not connected,
  waiting for %ss...' (player.py:814). The first sits immediately before
  a bare return that leaves AudioPlayer._end unset, so
  VoiceClient.is_playing() keeps returning True on a dead thread. One
  such line proves the silent-abort hypothesis outright. The re-badge is
  done on a copy of the record so the -v discord.log still shows the
  original DEBUG level.

* instrumentation.py -- the probes, behind the new --diagnose flag
  (default OFF).

  InstrumentedPCMStream subclasses sound.PCMStream and overrides read()
  only; sound.py is untouched. Per call it records the blocking time of
  the PortAudio read, the overflowed flag that upstream discards via
  [0], the returned length, and the input ring depth -- all O(1),
  allocation-free, no logging. One summary line is emitted per 5 s with
  max/mean read_block_ms, overflow count, ring depth and a drift ledger
  (frames read / 48000 vs wall clock, in seconds and ppm). Measured
  overhead is ~0.2 us per call against a 20 ms budget.

  VoiceStatePoller polls the voice client every 5 s and escalates two
  conditions to WARNING: is_playing() True while _player.is_alive() is
  False (the silent-abort signature), and _player.loops frozen across a
  poll while the thread is alive (the park signature, consistent with
  RawInputStream.read() blocking forever on the player thread).

  make_after() supplies an after= callback so the player thread announces
  its own death -- currently completely invisible, and expected to carry
  error=None on the silent paths.

  Every discord.py private access (_player, _connection, .loops) goes
  through a guard that logs one WARNING and then permanently retires the
  probe, so a discord.py upgrade cannot crash the app.

Upstream files changed minimally: main.pyw swaps its inline logging
config for logging_setup calls and gains --diagnose; gui.py and cli.py
each get an import plus a handful of one-line call-site changes.

Verified on macOS / Python 3.14 / discord.py 2.7.1: 41 checks pass,
including the mandatory 'Aborting playback' promotion test, rotation
under a forced small maxBytes, the drift-ledger and read_block_ms math
against a fake stream, graceful degradation on a renamed private, and
--diagnose OFF adding no handlers, no poller and after=None.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refresh the QSS only - no Python touched. Keeps the dark identity but
rebuilds the surface hierarchy, control shapes and interaction states.

- Layered surfaces: titlebar #131417 / panel #24262C / inset control
  well #1B1D21, so inputs read as recessed instead of floating on a
  single flat plane.
- Consistent 8px corner radii on comboboxes, buttons and the popup
  (was a mix of 2px/3px), plus 3px widget margins for vertical rhythm.
- Full :hover / :pressed / :focus / :disabled coverage on every
  interactive control. Focus and disabled were previously invisible,
  which made the connecting state unreadable.
- Styled the QComboBox popup: rounded padded list, pill-shaped
  selected/hover items, custom scrollbar, and the container frame
  painted to hide the square corner ring around the rounded view.
- Retuned accent to #5561E8 so white label text clears 4.5:1; body
  text sits at 12.6:1 on panel and 14.0:1 on controls.
- Fixed pseudo-state syntax (::hover -> :hover) and the specificity
  collision that let the titlebar rule override the red close hover.

Verified offscreen with PyQt6 6.11.0: zero QSS parse warnings on stderr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Companion code changes required by the restyled assets/style.qss. Cosmetic
and structural only -- no behavior changes.

- Titlebar minimize/close set to Qt.FocusPolicy.NoFocus. The new stylesheet
  has a visible :focus ring, which exposed that Qt handed initial focus to
  the first focusable widget, so the app opened with a highlighted minimize
  button. The titlebar QFrame is already NoFocus, so it needed no change.
- Titlebar pinned to setFixedHeight(36); it previously sized to its tallest
  child, so button padding silently changed chrome height.
- Removed the redundant first addWidget() pair for minimize/close. Qt
  reparents on the later add, so only the second pair was ever effective;
  layout order and rendering are unchanged.
- Named the status label "info" so the stylesheet no longer has to reach it
  with a bare QLabel selector.
- Real grid spacing (10) and content margins (20, 16, 20, 20) on the main
  layout, replacing the 3px per-widget QSS margins used to fake row rhythm.
- PointingHandCursor on the mute and add-connection buttons; QSS "cursor" is
  unreliable in Qt so this has to be code-side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pacing

The combo boxes, mute button and add-connection button each carried
`margin: 3px 2px`, added when the stylesheet could not rely on layout
spacing. gui.py now sets setSpacing(10) and setContentsMargins(20, 16, 20,
20) on the grid, so those margins stacked on top of the real spacing:
the visible vertical gap between connection rows was 16px (10 + 3 + 3)
and the horizontal gap between columns 14px (10 + 2 + 2).

Removing them lets the layout govern. Painted widget geometry is
unchanged (36px tall, same widths) — only the gaps tighten to a uniform
10px. Spacing only: no colour, radius, border, padding or state rule is
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fix A - baseline leg build failure
----------------------------------
The baseline leg died with `ModuleNotFoundError: No module named
'pkg_resources'` inside PyInstaller 5.1's own __init__.py. Cause: the
unconditional `pip install --upgrade setuptools` resolved to setuptools
>= 82.0.0, which removed the pkg_resources module outright ("pkg_resources
has been removed from Setuptools", setuptools changelog v82.0.0, released
2026-02-08, pypa/setuptools#3085). 81.x is the last series that ships it.

The setuptools requirement is now a matrix field: the baseline leg pins
`setuptools<82`, the modern leg stays unpinned. Fixing this on the
PyInstaller pin instead would defeat the leg's purpose - it exists to
reproduce the original toolchain as a control. A post-install guard step
asserts pkg_resources imports so a transitive bump fails with an obvious
message rather than an opaque traceback.

Fix B - Windows version resource (modern leg only)
--------------------------------------------------
The exe reported FileVersion/ProductVersion 0.0.0.0 with empty
CompanyName/FileDescription/OriginalFilename, so a binary found on disk
was unidentifiable. build/version_info.py builds a VSVersionInfo carrying
FileDescription/ProductName "Discord Audio Pipe", OriginalFilename
"dap.exe", and a LegalCopyright crediting QiCuiHub under the MIT license.
CompanyName is deliberately omitted rather than invented.

It is a module rather than a pyi-grab_version text file because
EXE(version=<path>) eval()s the file as a single expression and so cannot
read the environment; EXE(version=<VSVersionInfo>) is equally supported and
lets CI inject provenance. Numeric version is 2.5.0.<run_number> (2.5 is
the last upstream release tag) with DAP_VERSION able to override; the
version strings additionally carry "<ref> @ <short sha>". The workflow
passes github.run_number/sha/ref_name on the compile step.

Version derivation can never fail the build: every environment read falls
back to a static default, and main.spec wraps the whole load in try/except
so a missing, malformed or non-Windows-host case degrades to version=None.

Also
----
Adds logging_setup, instrumentation and logging.handlers to hiddenimports.
Static analysis already reaches all three via top-level imports; they are
listed as no-op insurance against a future refactor hiding one behind a
conditional or lazy import.

build/main-baseline.spec is intentionally untouched - it stays a faithful
control with no version resource.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reported bug is that audio cuts out after 15-20 minutes while the bot
stays connected, needing a manual restart. The instrumentation to spot that
already existed but was locked behind --diagnose and only ever reached a log
file. This surfaces it in the window, so degradation is visible before it is
audible and we can see which metric moves first.

Separate collection from logging (instrumentation.py)
  Metric collection now runs on every session; --diagnose controls only the
  verbose per-5s log lines. Re-measured on CPython 3.14 against a fake
  PortAudio stream: 122 ns/call plain vs 344 ns/call instrumented, so 222 ns
  of overhead against a 20 ms per-frame budget (0.001%). Enabling --diagnose
  adds ~1 ns, i.e. nothing measurable.

  The SILENT ABORT and PLAYER PARKED warnings are deliberately no longer
  behind the flag -- they fire at most once per episode and are the evidence
  we most want from a user who hits the bug without thinking to pass a flag.
  make_after() stays behind it, since that one changes what is handed to
  VoiceClient.play() and the poller already detects a dead player thread.

  Metrics reach the GUI through a lock-free snapshot: the player thread and
  the voice poller each build a dict and rebind one attribute, and the Qt
  side reads those already-built dicts. A rebind is atomic under the GIL, so
  the reader never sees a partial value and the audio thread never waits on
  the GUI.

Status strip (gui.py, assets/style.qss)
  One line at the bottom, refreshed every 2s:
      * Live . 38ms . 0 drops . drift +12ppm . 18m
  State is spelled out in words as well as coloured, so it survives
  colour-blindness and screenshots. Unknown values render as "--" and a
  stale value is never shown as if it were live -- including the case where
  every probe has retired, which reports "No metrics" rather than a green
  "Live" it has not earned.

  Thresholds are named constants in one place and marked provisional. We
  have two clean captures and none of a failing stream, so they are
  deliberately loose: under-warning costs one missed warning, crying wolf
  costs the user's trust in the readout entirely.

  Contrast on the #1B1D21 well: green 6.6:1, amber 7.8:1, red 6.1:1, idle
  6.4:1. The strip's red is #F87171 rather than the existing #D83C3E, which
  is only 3.7:1 as text.

Settings (config.py)
  Versioned JSON next to token.txt, holding auto_recover (default False).
  Atomic write, unknown keys preserved, and every failure path -- missing,
  corrupt, wrong-typed, unwritable, path-is-a-directory -- falls back to
  defaults with one warning instead of raising. The token is never stored,
  and a hand-added one is ignored and stripped on the next write.

  The auto-recover checkbox saves the preference and nothing else. Labelled
  "(soon)" with a tooltip saying so, because the recovery behaviour is a
  separate change and the UI must not imply it exists.

Verified by rendering the real GUI offscreen in eight states (idle,
healthy, degrading, stalled, disconnected, retired-probes, muted,
connecting) plus both checkbox states and a two-row layout; 29 config unit
tests and 19 integration checks pass, including that the 2s timer really
fires under run_Qt's processEvents pump. Zero QSS parse warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems reported from the first run on real Windows 11, neither of
which reproduced on macOS.

Dropdown clipping
-----------------
"A pack of autism" rendered as "A pack of auti" while the Devices column
sat on spare width. Connection.resize_combobox() sized a combo box as
"widest item text + 30px", but the stylesheet spends 12px on left padding
and 32px on the arrow well, so every dropdown was ~16px short of its own
contents. It also pinned that figure with setMinimumWidth(), which meant a
long guild name could push the window arbitrarily wide and never let it
back down.

Dropdown now sets AdjustToContents and lets Qt measure the real style
chrome, caps its size hint at MAX_CHARS so an arbitrarily long guild name
cannot drag the window off-screen, and elides anything that still does not
fit -- with the full name on the tooltip, so a shortened name stays
discoverable. The grid keeps no column stretch factors, so each column is
sized by its own content rather than all three being forced equal.

resize_combobox() survives as the "item list changed" hook and grows the
window to its hint, which the old minimum width used to do implicitly.

Over-bold text
--------------
assets/ shipped only Roboto-Black.ttf, which registers under family
"Roboto" as its sole face, so every weight request the stylesheet made --
including the default 400 -- snapped to 900 and the whole UI rendered at
display weight.

macOS hid this: QFontDatabase.addApplicationFont() returns -1 for a
relative path there even with QDir.setCurrent() pointing at the bundle, so
the dev machine silently fell back to the system UI font and looked fine.
Font paths are absolute now and a failed load is logged rather than
swallowed.

Roboto Regular and Medium (v2.137, matching the bundled Black) join the
family, so the sheet selects weight with font-weight alone: body at 400,
Medium 500 on the identity line, column headers, mute button and status
word. Black stays bundled for range but is no longer referenced -- at
10-12pt it blooms, and when everything is heavy nothing is emphasised.

Fonts are Apache-2.0; assets/Roboto-LICENSE.txt carries the attribution
and full licence text. build/main.spec bundles the whole assets directory
as one datas entry, so the new files are collected with no spec change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Saves each connection row's device, server, channel and mute state on
change and restores them on the next launch. Auto-connect is a separate,
default-off opt-in: the dropdowns are always pre-filled, but turning that
into a voice join is something the user has to ask for, because joining is
audible to everyone in the channel.

The audio device is persisted by NAME and re-resolved to an index at
startup. PortAudio's enumeration order moves when hardware is plugged in or
removed, when a driver updates, and sometimes across a plain reboot, so a
remembered index would eventually select a different device -- and the next
thing the app does with a device index is stream it into a voice channel.
Matching is exact or nothing: a device that is no longer present restores
nothing rather than a neighbour. Guild and channel ids are snowflakes and
are stable, so those are persisted as ids (as strings -- they exceed 2**53
and any JSON reader using doubles would mangle them).

config.py: DAP_config.json is now written with defaults on first launch.
It was created lazily, on the first setting change, so a user who changed
nothing had no file to find, inspect or hand-edit -- and no way to tell
"not written yet" from "written somewhere I did not expect". That was the
whole reason it appeared to be missing. The path resolution itself was
correct and is unchanged.

The token denylist now searches nested containers, since the profile
introduced the first non-scalar value in the file, and profile rows are
rebuilt from a field whitelist rather than round-tripped.

PyQt6 crash safety. Under PyQt5 an exception escaping a slot was printed
and execution continued; PyQt6 calls qFatal() instead, aborting the process
with no traceback in any log file. That makes every unguarded slot a silent
crash, and it is most dangerous exactly here: restore drives
setCurrentIndex() on devices, servers and channels that may all have
disappeared. Measured, slot raising RuntimeError from setCurrentIndex():
default excepthook -> exit 134 (SIGABRT); replaced excepthook -> exit 0.
main.pyw now installs such a hook before PyQt is imported, every slot body
reachable from restore is guarded, and restore sets values with signals
blocked and invokes the handlers itself rather than relying on slot side
effects firing in an order it did not design.

DAP_session.log lines now carry the writing process's pid. The file is
opened by path in the working directory, so two builds launched from the
same folder interleave into it, and an ambiguous stall record is a log that
cannot answer the question it exists for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to the previous commit, which relied on a replaced sys.excepthook
to stop PyQt6 aborting on an exception raised inside a Qt slot. That was
wrong as a general claim. pyqt6_err_print() runs the hook and then calls
qFatal() regardless on at least some builds; the observed "exit 0 with a
hook installed" result is a property of PyQt6 6.11.0 on this machine, not a
guarantee. Relying on it would have left the crash in place everywhere else.

The fix that works on every build is the exception not escaping. gui.py now
has a guarded_slot decorator -- try/except + logging.exception, returns
None -- applied to all 23 methods reachable from Qt: every one of the 12
named .connect() targets, plus the reimplemented virtuals C++ invokes
directly (paintEvent, resizeEvent, closeEvent, the mouse handlers).
sizeHint/minimumSizeHint must return a QSize to C++ so they keep a
hand-written guard with Qt's own hint as the fallback. A test walks the
module, collects every .connect(self.X) target and fails if any is
undecorated, so this does not depend on anyone remembering.

The excepthook stays, but is now described accurately in code and README:
it captures the traceback into DAP_errors.log for diagnosis, and it is not
what keeps the app alive.

The decorator has one sharp edge, found by the tests rather than by
reasoning: PyQt inspects a slot's arity and passes only as many signal
arguments as it will accept, and a *args wrapper defeats that, so every
argument is passed. change_device, toggle_mute, minimize, on_close_clicked
and add_connection are wired to signals that carry arguments and now accept
them. Getting this wrong raises TypeError, which the decorator swallows --
a control that silently stops working -- so a test fires every slot and
asserts nothing was logged.

The deliberate-abort test is gated behind DAP_ALLOW_ABORT_TEST=1. It
aborts a child process by design, which raises a crash-report dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Tarrant64

Copy link
Copy Markdown
Owner Author

Pushed two more commits to this branch (fast-forward, no rebase):

  • Profile persistence — the app now remembers the last-used setup (device, server/channel selection, window state) across restarts via a new config.py store. The store has a FORBIDDEN_KEYS denylist applied recursively, so the bot token and similar secrets can never be persisted; the token still lives only in token.txt.
  • Config eager-write fix — settings are flushed when they change rather than only at shutdown, so a crash or force-quit no longer loses the profile.
  • @guarded_slot crash safety — Qt slots are wrapped so an exception inside a slot is logged and contained instead of propagating through the C++ event loop and killing the app.
  • Font and column fixes — assets/style.qss and layout tweaks following on from the earlier dropdown-clipping/over-bold work.

README updated to cover the profile behaviour and the secrets denylist.

Tarrant64 and others added 2 commits August 29, 2026 09:32
A 26-minute user session was captured and analysed and yielded almost no
usable evidence, because the forensically valuable logging was all opt-in
and the user reasonably ran the app normally. Three changes so that the
next capture settles the question instead of raising it.

Always write discord.log, not just under -v. Without it a stall capture
has zero gateway evidence: no voice websocket close codes, no
"Disconnected from voice", no handshake or heartbeat trail. Measured cost
from a 45-minute real-audio capture is ~5.1 KB/min steady state after a
~41 KB connect burst, so 4 MB x 5 bounds the set at 20 MB while holding
~13 hours of continuous connection in the active file alone. It now
appends rather than truncating: the stall's signature is that audio dies
while the app survives, so the user restarts, and mode="w" would wipe the
failed session's trail at exactly that moment. A banner line keeps runs
separable. -v is not left a no-op -- it now echoes to the console and
lowers the dap namespace to DEBUG in both the logger and the filter.

Log why the app shut down. The bare "=== DAP shutdown (clean) ===" could
not answer the first question asked of it after a bad session: was that
the bug or just teardown? Call sites record the cause where it is
knowable and the finally reports it, first-writer-wins so the specific
trigger is not overwritten by the generic one behind it.

Make the abort-vs-teardown discriminator explicit. "Aborting playback"
alone proves nothing, because a deliberate disconnect reaches the same
line. The tell is the gap to the preceding "Not connected, waiting for":
wait_until_connected is Event.wait(timeout), and disconnect() pulses that
event, so a teardown returns in milliseconds while a real stall burns the
whole timeout. Measured 0.035s vs 10.005s. The verdict line reports the
gap and its cause, at ERROR for a real stall and INFO for a teardown so
it does not cry wolf. The timeout is read from the argument discord.py
logged rather than hardcoded, and the cut sits at half of it -- an empty
band, since a reconnect that succeeds partway through logs "Reconnected,
resuming playback" and never reaches the abort line.

Also ungate instrumentation.make_after(). It fires on every player exit
path including the silent bare return and is the only evidence of thread
death that is instantaneous rather than inferred five seconds later by
the poller. It was held back to keep the connect path byte-identical to
upstream; that cost is one elif in AudioPlayer._call_after, and the
callback re-logs the error with exc_info, so DAP_errors.log still gets
the traceback with more context than before.

Observation only. No auto-recover, no transport changes, sound.py
untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Selecting a channel used to join it. `change_channel` connected as a side
effect of the dropdown changing, which meant there was no way to leave voice
without clearing the selection, no way to rejoin without re-picking it, and
no moment at which the user said "now".

There is now a Connect button per row, and one small state machine behind it.

connection_state.py holds the machine: IDLE -> CONNECTING -> LIVE ->
DISCONNECTING -> IDLE, plus FAILED for a join that raises or times out. It
is Qt-free and discord-free, so its rules are unit-testable on their own.
Transitions outside the table are refused -- they return False and log, they
do not raise, because under PyQt6 an exception escaping a slot reaches
qFatal() and aborts the process. Two edges are deliberately absent:
CONNECTING -> DISCONNECTING (a join cannot be cancelled; discord.py's own
10s timeout bounds it) and DISCONNECTING -> FAILED (after a leave the honest
offer is "Connect", not "Retry").

Everything the user can see now reads that one value: the button label and
colour, which dropdowns are editable, whether mute does anything, what the
status strip says, and whether auto-connect may fire. Those used to be four
separate guesses -- `voice is not None`, `voice.is_connected()`,
`voice.is_playing()`, and the strip's own count of instrumentation samples
-- which could and did disagree. is_playing() in particular is no longer
consulted anywhere: it keeps returning True forever once the player thread
has died, so it cannot answer "are we connected" or "are we muted".

Double-clicks and mid-flight cancels are impossible rather than unlikely.
The button is disabled through both in-flight states (first line of
defence), and begin_connect() claims the state *synchronously* in the slot,
before the coroutine is scheduled, so a second click in the same event-loop
turn is refused by the machine (second line).

Disconnect keeps the selections and does not close the audio stream, so
Connect afterwards rejoins in one click. Auto-connect on launch calls
start_connect() -- literally the same function the button calls -- and
awaits the task it returns, so there is no second implementation of "join"
to drift.

One button rather than two: the label is the next action, the colour is the
current state. Two buttons would have widened every row to show a control
that is disabled in all but one state, and the label cannot flip under a
moving cursor because every transition passes through a disabled interval.

Also here:

* The mute button is checkable, so muted is a latched amber state and not
  only a word swap, and set_muted() is its single writer -- button, flag and
  player can no longer disagree. Disconnecting un-mutes.
* SVGButton's spinner is an explicit flag instead of "disabled". Mute is now
  disabled whenever the row is off air, so the old coupling would have left
  a loading animation spinning on every idle row forever.
* The status strip consults the machine first and the health metrics only
  once a row is actually LIVE. New words: Connecting, Disconnecting, Failed,
  and Starting for live-but-not-yet-measured.
* README documents the change, and flags the breaking part in a callout:
  existing users will otherwise think picking a channel is broken.

Verified with a stub bot, offscreen, no token and no network: 48 unit tests
on the machine itself (every legal edge, and every one of the 17 pairs the
table omits asserted refused), 135 assertions driving the real GUI through
connect / disconnect / rejoin / double-click / raise / timeout / auto-connect
/ mute / two independent rows, the existing 22-test profile suite and the
structural "every signal target is @guarded_slot" check still green, and the
window rendered in all five states plus muted, hover, focus and gated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Tarrant64

Copy link
Copy Markdown
Owner Author

Pushed two more commits to this branch (fast-forward, 376d1f8..f871912).

fe89241 — Make the next stall capture conclusive

  • discord.log is now written on every run, not only under -v. Without it a stall capture has no gateway evidence at all (no voice websocket close codes, no handshake/heartbeat trail). It appends rather than truncates, since the failure mode is "audio dies, app survives, user restarts" — mode="w" would wipe the failed session at exactly the wrong moment. Bounded at 4 MB x 5; measured ~5.1 KB/min steady state after a ~41 KB connect burst. -v still does something: console echo plus DEBUG on the dap namespace.
  • Abort-vs-teardown discriminator. "Aborting playback" alone proves nothing, because a deliberate disconnect hits the same line. The tell is the gap back to the preceding "Not connected, waiting for": disconnect() pulses the event, so a teardown returns in milliseconds while a real stall burns the whole timeout (measured 0.035 s vs 10.005 s). The verdict line reports the gap and its cause — ERROR for a real stall, INFO for a teardown. Timeout is read from the value discord.py logged rather than hardcoded; the cut sits at half of it.
  • Shutdown-cause attribution. The bare === DAP shutdown (clean) === could not answer "was that the bug or just teardown?". Call sites now record the cause where it is knowable, first-writer-wins so the specific trigger is not masked by the generic one behind it.
  • instrumentation.make_after() is ungated — it fires on every player exit path including the silent bare return, and is the only instantaneous evidence of thread death.

Observation only. No auto-recover, no transport changes, sound.py untouched.

f871912 — Make connecting an explicit act with a real state machine

⚠️ Breaking behaviour change: selecting a channel no longer auto-connects. Previously change_channel joined as a side effect of the dropdown changing. There is now a per-row Connect button, and joining happens only when it is pressed. Existing users who are not told this will think channel selection is broken.

  • connection_state.py holds the machine: IDLE → CONNECTING → LIVE → DISCONNECTING → IDLE, plus FAILED. Qt-free and discord-free, so its rules are testable standalone. Transitions outside the table are refused (return False + log) rather than raised, because under PyQt6 an exception escaping a slot reaches qFatal().
  • Every visible affordance now reads that one value — button label/colour, dropdown editability, mute, status strip, auto-connect gating. Those were four separate guesses before (voice is not None, is_connected(), is_playing(), the strip's own sample count) which could and did disagree. is_playing() is no longer consulted anywhere: it returns True forever once the player thread has died.
  • Double-click and mid-flight cancel are structurally impossible: the button is disabled through both in-flight states, and begin_connect() claims the state synchronously in the slot before the coroutine is scheduled.
  • Disconnect keeps selections and leaves the audio stream open, so Connect rejoins in one click. Auto-connect on launch calls the same start_connect() the button calls, so there is no second "join" implementation to drift.
  • Mute is checkable and latched, with set_muted() as its single writer; disconnecting un-mutes. Status strip gains Connecting / Disconnecting / Failed / Starting.
  • README documents the change and flags the breaking part in a callout.

CI run for this push: 33259021185.

@Tarrant64
Tarrant64 merged commit 6ea5d2f into master Aug 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant