Repository navigation
Modernize build, add diagnostics and live stream-health status strip - #1
Merged
Merged
Conversation
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>
Owner
Author
|
Pushed two more commits to this branch (fast-forward, no rebase):
README updated to cover the profile behaviour and the secrets denylist. |
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>
Owner
Author
|
Pushed two more commits to this branch (fast-forward,
Observation only. No auto-recover, no transport changes,
CI run for this push: 33259021185. |
Tarrant64
force-pushed
the
feature/ui-fixes
branch
from
August 29, 2026 15:16
f871912 to
a52f5a8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)2. Build + CI (
b4bb728,d66ea07,93dde0b)build/main.specrewritten for PyInstaller 6: the olda.binaries - TOC([...])idiom,block_cipher,a.zipped_data,win_no_prefer_redirectsetc. were all removed in PyInstaller 6, so the previous spec could not run at all. Paths now derive fromSPECPATHrather than the caller's cwd. Addsdavey(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.modernleg (3.12 / PyInstaller 6 / PyQt6) and abaselinecontrol leg that pins the exact pre-modernization toolchain, spec and application sources (source_ref: b9ea7bfis checked back out over the tree), so there is always a known-good artifact to compare against when the modern leg misbehaves.fail-fast: falseso one leg failing still produces the other's artifact.pushonfeature/**(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) plusworkflow_dispatch, keeping the originalrelease: prereleasedasset upload.windows-latestbills at a 2x minute multiplier.build/version_info.py, modern leg only): the exe previously reported FileVersion/ProductVersion0.0.0.0with empty CompanyName/FileDescription, so a binary found on disk was unidentifiable. Now stamped2.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.setuptools<82pin: setuptools 82.0.0 removedpkg_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)DAP_errors.loganddiscord.log, plus a new INFO-levelDAP_session.loglifecycle trail with millisecond timestamps. Bot tokens are redacted from the logged argv line.-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 playbacksits immediately before a barereturnthat leavesAudioPlayer._endunset, soVoiceClient.is_playing()keeps returningTrueon 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'sdiscord.logstill shows the original DEBUG level.4. Instrumentation (
37424d4,0f7136f—instrumentation.py,--diagnose)InstrumentedPCMStreamsubclassessound.PCMStreamand overridesread()only —sound.pyis untouched. Per call it records:overflowedflag that upstream discards via[0],VoiceStatePollerpolls 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.loopsfrozen 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--diagnosegates 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)#131417/ panel#24262C/ inset control well#1B1D21) so inputs read as recessed; consistent 8px radii (was a mix of 2px/3px).:hover/:pressed/:focus/:disabledcoverage on every interactive control — focus and disabled were previously invisible, which made the connecting state unreadable.#5561E8so white label text clears 4.5:1; body text at 12.6:1 on panel.::hover→:hoverpseudo-state syntax and the specificity collision that let the titlebar rule override the red close-button hover.NoFocus(the new focus ring exposed that the app opened with a highlighted minimize button), titlebar pinned tosetFixedHeight(36), real gridsetSpacing(10)/setContentsMargins(20, 16, 20, 20)replacing the 3px per-widget QSS margins that were faking row rhythm, and pointing-hand cursors (QSScursoris unreliable in Qt).6. Live status strip + settings (
0f7136f—gui.py,config.py)● 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.#1B1D21: green 6.6:1, amber 7.8:1, red 6.1:1, idle 6.4:1. The strip's red is#F87171rather than the existing#D83C3E, which is only 3.7:1 as text.config.py: versioned JSON next totoken.txt. Atomicos.replacewrite, 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_KEYSrefuses 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:
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 — andsetMinimumWidth()meant a long guild name could push the window arbitrarily wide and never let it back down. Now usesAdjustToContents, caps the size hint, and elides with the full name on the tooltip.assets/shipped onlyRoboto-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 becauseaddApplicationFont()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.txtcarries the attribution.Reviewer notes
--diagnoseadds ~1 ns.b9ea7bf) so there is always a comparison artifact.build/main-baseline.specis intentionally untouched by the version-resource work for the same reason.--diagnose, capture a session that actually degrades, set amber near where each metric leaves its clean-minute range.GITHUB_TOKEN, and a Discord token must never be added to it.Known incomplete / follow-up work
config.pyis already versioned and extensible for exactly this.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.