Logs (WARNING level) when: multiple heartbeats arrive in one serial read
batch, a heartbeat gap exceeds 0.5s, or reapply_app_volumes takes >0.15s.
Suspected cause: synchronous playerctl/pulsectl calls in the reapply path
occasionally stalling the main loop long enough for backed-up heartbeats
to flush in a burst, each triggering an LED resend. To be removed once
confirmed/fixed.
- New active_window_volume/mute_active_window actions using kdotool to
resolve the focused window under KWin/Wayland.
- New mute_group button action for muting multiple apps at once.
- New "binary:<name>" needle syntax to disambiguate generic Electron apps
that share a PipeWire application.name with an unrelated app; fixes a bug
where two knobs/groups matching the same generic name would fight over
a stream's volume via the periodic reapply loop.
- Add kdotool to PKGBUILD depends, credit original author in README.
MPRIS set_volume returning True was causing an early return that
silently skipped the PulseAudio stream write. PA-only apps like
Brave and Vesktop (registered as Chromium/electron) never had their
volume updated when any MPRIS player coincidentally matched the
target name by substring.
Both set_app_volume and reapply_app_volumes now always apply the PA
correction regardless of MPRIS result, so PA-only and MPRIS-capable
apps are handled correctly in all cases.
vite.config.ts used defineConfig from 'vite' which doesn't accept the
'test' key — switch to 'vitest/config' which extends it. Also type the
onChange test helper as (knob: KnobConfig) => void instead of the overly
broad ReturnType<typeof vi.fn>. Run npm run build to ship the compiled
static assets.
- sink_volume and source_volume knobs now show a dropdown of active
PulseAudio/PipeWire output/input devices (monitors excluded), with
the system-default device marked in the label
- app_volume knob shows a dropdown of running apps (was already a
dropdown, previously had a textarea for group_volume)
- group_volume knob shows a <select multiple> listbox so several apps
can be selected at once with ctrl/shift-click
- Add GET /api/sinks and GET /api/sources backend endpoints
- Add fetchSinks() / fetchSources() in api.ts with AudioDevice type
- Load sinks and sources in App.tsx alongside running apps on refresh
- All 23 frontend tests pass
Set up Vitest (jsdom environment) and @testing-library/react.
api.test.ts — fetchRunningApps:
- returns parsed JSON on 2xx
- returns [] on non-ok HTTP status
- returns [] on network error (non-throwing contract)
KnobCard.test.tsx — app_volume:
- datalist is rendered with all supplied runningApps as options
- no datalist / list attr when runningApps is empty or omitted
- onChange called with the new value on input change
KnobCard.test.tsx — group_volume:
- app-picker select rendered with runningApps options
- no picker when runningApps is empty or omitted
- Add button disabled until an app is selected
- Add button enabled after selection
- clicking Add appends app to targets
- clicking Add skips duplicates (onChange not called)
- picker resets to placeholder after a successful Add
- Add GET /api/apps endpoint that queries pulsectl for active sink inputs
and returns deduplicated app names (application.name + process.binary)
- Add fetchRunningApps() API helper (returns [] on any failure, non-fatal)
- Fetch running apps on page load; expose a Refresh button in the Knobs
section header to re-query without a full page reload
- KnobCard app_volume: wire target input to an HTML5 <datalist> so the
browser shows a native autocomplete dropdown of running apps
- KnobCard group_volume: add a select + Add button beneath the textarea so
users can pick a running app and append it without typing
- CSS: style the app-picker row and the compact refresh button in h2
parse_messages() previously advanced past a 0xFE start byte whenever no
complete frame could be matched, silently consuming partial frames at the
end of each ser.read(64) call as garbage. When the device sends 20-50
rapid knob messages during a fast turn and one message straddles the
64-byte read boundary, the split message was discarded: the daemon skipped
a knob-position update, the LED color lagged, and when the next complete
message arrived the color jumped — producing a visible flicker.
Fix: break out of the parse loop when a 0xFE is followed by a known type
byte (0x02/0x03/0x06/0x07) but the buffer does not yet hold a complete
frame. The partial bytes are returned in the remainder and prepended to
the next ser.read() chunk, so no message is ever lost.
Also sync last_led_colors after the initial send_leds() on connect so the
dedup guard is accurate from the first knob event instead of from the
first heartbeat.
Adds 5 tests covering partial heartbeat, button, and knob frames, the
split-read round-trip, and unknown frame type skipping (61 pass total).
The workflow was generating a hardcoded .SRCINFO that didn't include
python-fastapi or uvicorn, overwriting the correct file on every
release. Now it patches version/checksum in the existing .SRCINFO.
The heartbeat path called send_leds but never updated last_led_colors,
so the dedup guard in handle_knob had no visibility into what was last
actually sent. Heartbeats arriving mid-knob-turn injected extra serial
writes that bypassed the guard and overwhelmed the device's LED renderer,
causing visible flicker.
- FastAPI + uvicorn server (turnup-ui) serving config API and static files
- React + TypeScript frontend: connection, global LEDs, per-knob/button
cards, preset save/load/apply, toast notifications, dark theme
- Vite builds into src/turnup/ui/static/ so no Node.js needed at install time
- PWA assets (manifest, service worker, icon) in ui/public/
- turnup-ui.service systemd user unit; enabled globally via turnupd.install
- PKGBUILD: add python-fastapi + python-uvicorn deps, install UI service file
- pyproject.toml: ui extras group, turnup-ui entry point
Two targeted fixes:
1. Deduplicate LED writes in handle_knob: compute new colours and only
call send_leds() when they differ from last_led_colors. A single
physical knob turn produces 20-50 ADC samples in quick succession;
without this guard each sample triggers a 47-byte write and the
firmware can't keep up, causing visible flicker.
2. Gate reapply_app_volumes on a 200 ms knob-quiet period. Calling
playerctl/pulsectl while the user is actively turning a knob stalls
the main loop, backing up serial data, missing heartbeats, and
causing the device LEDs to time out mid-turn.
Both state variables (last_led_colors, last_knob_event) are mutable
lists initialised in main() and threaded through handle_knob, keeping
module-level globals out of the picture.
Adds 6 new unit tests (56 total, all passing).
- Add MPRISController class: wraps playerctl with 3s player-list cache,
fuzzy app-name matching, and thread-safe get/set_volume via MPRIS2 D-Bus
- Add PulseController event watcher thread: separate pulsectl connection
listens for sink-input events and queues them so reapply fires within
~0.1s for PA-only apps instead of waiting up to 1s
- Route set_app_volume and get_app_volume_norm through MPRIS first,
falling back to PA stream for non-MPRIS apps (e.g. Brave/Chromium)
- Update reapply_app_volumes to prefer MPRIS path, preventing turnupd
from fighting Spotify on the PA layer during song transitions
- Wire MPRISController and start_watching() into main(); drain_events()
triggers immediate reapply on any PA sink-input event
- Switch config format from JSON to TOML (tomllib built-in on 3.11+,
tomli fallback for 3.10) so the generated config can include comments
- Write an annotated config.toml on first run with descriptions for every
knob/button and hints for all available actions
- Promote playerctl from optdepends to depends so media buttons work
out of the box
- Default buttons 1–3 now pre-configured for playerctl previous/play-pause/next
- Rename contrib/config.example.json → config.example.toml with full
TOML rewrite including per-knob LED colour override examples
- Log a migration hint when a legacy config.json is detected