Add opt-in per-channel/stereo-pair volume and meter display (multichannel-pairing) - #76
Draft
HoneyHazard wants to merge 45 commits into
Draft
Add opt-in per-channel/stereo-pair volume and meter display (multichannel-pairing)#76HoneyHazard wants to merge 45 commits into
HoneyHazard wants to merge 45 commits into
Conversation
Detects named left/right channel pairs (stereo, and other spa_audio_channel L/R pairs) from a node's real per-channel position/volume data via a new channel_pairing module - pairing is name-based (an explicit L/R suffix table), not index-adjacency, since PipeWire doesn't guarantee adjacent ordering and generic AUX channels carry no pairing information at all. When show_channel_volumes is enabled and a node has a detected pair, its volume bar splits into two independently-clickable bars radiating outward from a shared center marker - mirroring the existing stereo peak meter's own radiating layout, just for volume instead of peaks. Each channel's percentage and fill are computed independently, no averaging. A new Action::SetChannelAbsoluteVolume(usize, f32) and View::channel_volume() let mouse clicks on one radiating half set just that channel, leaving the other untouched - unlike the existing SetAbsoluteVolume/SetRelativeVolume, which still apply to every channel together via the existing .fill() behavior, unchanged. Off by default - nodes without a detected pair, and every node when the option is off, render exactly as before. Live-verified: default off is pixel-identical to the pre-existing single-bar display; with the option on, clicking one radiating half changes only that channel (confirmed via screenshot: left channel jumped to 80% while right stayed at 1%). Tested: cargo test --release (163/163 passing, including 10 new channel_pairing tests covering real hardware examples from this machine's own pw-dump output - a real 5.1 device pairing fronts/rears while leaving center/LFE single, and a real AUX0/AUX1 device correctly never pairing), cargo fmt --check / cargo clippy --release --all-targets -- -D warnings / cargo doc --no-deps all clean.
Closes the gap between the pure channel_pairing logic tests and manual screenshot verification: renders NodeWidget's volume area directly and inspects the resulting character buffer to confirm the option's default (off) behavior is unchanged, a detected pair renders two independent percentages rather than one masking average, and a node with no pairable channels (mono) falls back to the single bar even with the option on. Tested: cargo test --release (159/159), cargo fmt --check, cargo clippy --release --all-targets -- -D warnings, all clean.
…etChannelAbsoluteVolume Mirrors the existing SetAbsoluteVolume/SetRelativeVolume pair but scoped to a single channel index, so per-channel adjustments (e.g. custom keybindings for increasing/decreasing one channel of a stereo pair) can be relative as well as absolute. View::channel_volume now takes a VolumeAdjustment like View::volume already does, instead of a bare absolute f32. 162/162 tests passing, fmt/clippy/doc clean.
…ative keybinding examples
Confirms and locks down the TOML tuple-variant syntax for these two
actions ({ SetChannelAbsoluteVolume = [N, volume] }) with new config
parsing tests, then adds them to wiremix.toml's keybinding docs as a
commented-out example (Ctrl+Left/Right for channel 0, Ctrl+Up/Down for
channel 1) alongside a note that Ctrl+arrow isn't guaranteed to reach
wiremix on every terminal/multiplexer setup. Not enabled by default -
documentation only, same treatment as the existing SelectTab example.
164/164 tests passing, fmt/clippy/doc clean.
…er-channel targeting Per §7.3/§7.4 of the multichannel design notes: ObjectList gains channel_mode (a session-wide toggle) and selected_channel, and down()/up() fold channel cycling into the existing navigation stream - moving past a node's last channel advances to the next node, landing on its first channel if it has more than one. A node with 0 or 1 channels is never cycled into, so it behaves exactly as today. Action::ToggleChannelMode flips the mode via a new ObjectList::toggle_channel_mode(). The existing whole-node volume keys (SetAbsoluteVolume/SetRelativeVolume, bound to h/l, arrows, and 0-9) now redirect to the per-channel path when a channel is selected, so Channel mode reuses today's keybindings rather than needing new ones - only the channel-specific actions (SetChannelAbsoluteVolume/SetChannelRelativeVolume) still require their own explicit index. Not wired to rendering yet - toggling channel mode changes navigation and volume-key targeting correctly (unit tested), but there's no visual stacked-row display for it yet, so it has no observable effect in the running app until that lands. Deliberately left off wiremix.toml's keybinding docs for the same reason - no point documenting a toggle with nothing to see yet. 170/170 tests passing, fmt/clippy/doc clean.
Per §5 point 3 of the multichannel design notes: a node with more than one channel now actually expands into one header line plus one stacked row per channel when channel mode is on, instead of just changing navigation/volume-key targeting with nothing to look at (as it did after the previous commit). - NodeWidget::node_height() replaces the bare NodeWidget::height() constant for layout purposes - a node's real height now depends on channel_mode and its own channel count. - ObjectList::item_heights() and the render/visible_count walks in object_list.rs are rewritten to sum real, possibly heterogeneous per-object heights instead of assuming a uniform one (§4's traced plan), so scrolling, the "more above/below" indicators, and the Ratatui layout constraints all stay correct once some nodes are taller than others. - New ChannelRowWidget renders one channel's own volume bar/percentage per line, with click-to-set mouse areas mirroring StereoVolumeWidget's existing per-column pattern. Each row's marker glyph (reusing char_set.selector_middle/theme.selector, no new style) shows only on whichever channel is currently cursor-targeted for the selected node - §7.5 axis 4's "reuse existing mechanism" resolution, made trivial here because a stacked channel row is a real one-line row rather than a radiating bar with no row to attach a selector to. - ToggleChannelMode is now documented in wiremix.toml as a keybinding example (still not a default binding), since it finally has something visible to toggle. Deliberately out of scope for this slice (noted in NOTES-multichannel.md rather than silently skipped): no peak meter in channel-row mode, no channel name/position label beyond a raw index, and no row_selected background tinting on channel rows - the marker glyph is the only selection indicator. 178/178 tests passing, fmt/clippy/doc clean. Live-verified against this machine's real running PipeWire graph: toggling channel mode expands every 2-channel node into two rows, the marker correctly starts on channel 0 of the selected node, and Down steps to channel 1 before advancing to the next node's channel 0 - confirmed via screenshots at each step, not just unit tests.
Previously, arriving at a new node always landed on channel 0 regardless of navigation direction - so up() from a node's channel 1 could skip past the previous node's own channel 1 entirely (landing on its channel 0 instead), breaking the "up undoes down" expectation the rest of the list's navigation already has. select()/initial_channel() now take a from_end flag: down() lands on the first channel, up() lands on the last, so walking down N steps and back up N-1 steps retraces the exact same (node, channel) sequence in reverse. This closes the "reasonable follow-up" explicitly flagged in the previous commit's own doc comment, per NOTES-multichannel.md §9.1. 179/179 tests passing (added a symmetric round-trip test plus fixed the one existing up-navigation test that asserted the old, asymmetric landing), fmt/clippy/doc clean.
Closes §6's deferred "channel label stripping the group discussed" item
now that there's an actual investigation to report: no existing
name-lookup was available anywhere in the Rust bindings (checked
libspa-sys's type_info.rs and the pipewire/libspa crates directly), so
channel_pairing::channel_name() hand-maps every named enum spa_audio_channel
value straight from spa/param/audio/raw.h's own doc comments (FL, FR,
LFE, TFLC, ...), computes AUX{n} for the whole START_Aux..=LAST_Aux
range PipeWire itself defines as valid aux channels (not just the 64
that happen to have individual named constants), and falls back to "?"
for UNKNOWN/NA or anything future this list hasn't caught up to.
ChannelRowWidget now looks up node.positions[channel_index] and shows
that name instead of the bare index, falling back to the index itself
when a node has no positions data at all (some streams never send
SPA_FORMAT_AUDIO_position). Label column widened from 6 to 10 columns
to fit worst-case names like "AUX63 100%".
184/184 tests passing (3 new in channel_pairing.rs for the name
function itself, 2 new + 1 updated in node_widget.rs for the
fallback/AUX-numbering/named-channel row content), fmt/clippy/doc
clean. Live-verified against the real PipeWire graph: every real
stream's channel rows now show "FL"/"FR" instead of "0"/"1".
…ht rows Closes §6's last open question: whether lazy_capture/capture_hidden/ rotation logic (all keyed on ObjectId, one entry per node) need any changes now that a node can occupy multiple visual rows. Traced it rather than assuming: App::visible_objects (what update_capturing() diffs capture state against) comes entirely from ObjectList::visible_objects(), which already went through §9.2's heterogeneous-height rewrite - it slices the node list by object count, not by line count, so a taller channel-mode node correctly reduces how many *objects* fit without needing any per-channel-row capture concept. Capture itself is inherently node-scoped (one capture stream per node, peaks are stereo-pair based per §1) - there was never a "which channel row" question for it to answer. Added a regression test proving this rather than just asserting it: channel_mode_shrinks_visible_objects_for_lazy_capture confirms visible_objects() correctly excludes a node once channel mode makes it too tall to fit, at the exact API lazy_capture consumes (not just the lower-level visible_count() already covered in §9.2). 185/185 tests passing, fmt/clippy/doc clean.
Clicking a channel row's volume bar or label already dispatched Action::SelectObject (selecting the row's node), but nothing updated selected_channel to match the clicked channel - so subsequent h/l keypresses could target a stale channel index left over from whatever was last cursored via the keyboard, on a completely different node. New mouse-only Action::SelectChannel(usize) (marked #[serde(skip_deserializing)], like SelectObject/SetTarget - not user-bindable, only dispatched from mouse areas) sets ObjectList::selected_channel directly. Added to both of ChannelRowWidget's mouse areas (the label's mute-toggle click and the per-column volume click-to-set loop) alongside the existing SelectObject/SetChannelAbsoluteVolume actions, so clicking anywhere on a channel row now keeps keyboard and mouse targeting in sync. 186/186 tests passing, fmt/clippy/doc clean. Not live-click-verified: xdotool's synthetic clicks land at the right window coordinates (confirmed via getmouselocation) but don't reach wiremix's mouse-tracking mode in this environment - a tooling limitation, not something specific to this change. Relying on the new unit test (select_channel_sets_selected_channel_without_touching_selected) plus the fact this mirrors an already-shipped, already-verified pattern (SetChannelAbsoluteVolume's own click-to-set mouse areas, live-verified in §9.2) rather than claiming a live check that didn't actually happen.
…esting
Four independent corrections, all from direct feedback after trying the
build:
1. Channel mode no longer shows the selector marker on a node's header
row - only the individually-targeted channel row does, so there's
exactly one place to look for "which channel is this."
2. Channel row labels and percentages now render in independent
fixed-width right-aligned columns ("FL 100%" / "FR 0%") instead
of one right-aligned "{label} {percent}%" string, which shifted the
label itself left/right depending on the percentage's own digit
count ("FL 100%" vs " FL 50%" vs " FL 3%").
3. StereoVolumeWidget's two radiating bars now share one explicitly
computed width instead of two independent Fill(1) constraints, which
could differ by a column when the remaining space was odd -
numerically equal L/R volumes could render as visibly different bar
lengths purely from that width mismatch.
4. View::volume()'s Relative branch now applies the same delta to each
channel's own current value independently, instead of averaging
first and filling every channel to the new mean - preserves existing
imbalance the way pulsemixer does (+10 on a=30/b=50 gives a=40/b=60,
not a=b=50). Absolute/set operations are unchanged (still fill every
channel to one explicit target).
Extended the test mock (MockCommand::NodeVolumes) since proving tsowell#4
needed to actually observe dispatched volumes, which the existing mock
harness couldn't do (node_volumes was a hard no-op) - the established
workaround of only checking handle()'s bool return wasn't decisive
enough for a claim this specific.
190/190 tests passing (5 new, 3 updated for the new column-alignment
strings), fmt/clippy/doc clean.
…lance/split_style axis matrix Per direct feedback after trying the build: the earlier single show_channel_volumes bool only ever gave "always radiating" - there was no way to get a single unified bar that only splits when a node is actually imbalanced, no way to choose stacked over radiating for linked (non-Channel-mode) display, and no runtime toggle at all, only a static config value. New independent config axes (all default to today's stock behavior, byte-for-byte, with zero-visual-change as the baseline): - channel_display: "unified" (today, one bar/row always) | "always" (always split, per split_style). Toggle live with the new Action::CycleChannelDisplay (unbound by default) - cycles unified -> always -> unified. - unified_imbalance: only consulted when channel_display = "unified" - "none" (today, flat mean) | "cycle" (reserved for a future increment - temporal per-channel cycling per NOTES-multichannel.md §7.2) | "split" (just that one imbalanced node renders split while balanced nodes stay collapsed - implemented now, since it falls out of the same dispatch logic almost for free). - split_style: "radiating" (two bars sharing one row, only for a detected 2-channel pair) | "stacked" (one row per channel, the fallback for anything that isn't a pair regardless of this setting). - channel_mode (existing runtime toggle) gains a matching config default and CLI flag pair, for consistency with the other three. All four axes are independent: channel_mode (individual vs linked setting) always forces stacked display regardless of the other three, since radiating's marker-placement problem for an individually-cursored channel isn't solved yet. Implementation: a single volume_display() function in node_widget.rs resolves (ChannelState, &view::Node) -> Unified | Radiating | Stacked, used identically by both NodeWidget::render() (what to actually draw) and NodeWidget::node_height() (how tall the row needs to be), so the two can never disagree about a node's shape. ChannelState bundles all four axes so call sites don't need four separate parameters everywhere. channel_mode/channel_display live on ObjectList as runtime-mutable state, seeded from config at App::new(); unified_imbalance/split_style are read straight from Config for now (no runtime toggle built yet, config-only). 203/203 tests passing (13 new: 8 direct volume_display() precedence tests covering every axis combination including the channel_mode-always- wins rule and the pair/non-pair radiating fallback, plus config parsing/keybinding tests for the new fields and action), fmt/clippy/doc clean. Live-verified against the real PipeWire graph: pressing the CycleChannelDisplay key toggles every node between the ordinary single-bar display and split radiating bars (with symmetric widths, confirming the earlier bar-width fix holds under the new dispatch path too) and back again.
Point 4 of the feedback batch: "if volume bars are split per-channel, monitor is also split for channels." Each ChannelRowWidget row now gets its own mono-style meter reading node.peaks[channel_index] directly, instead of no meter at all (the previous, explicitly-documented gap). Honors the existing peaks config exactly as asked: peaks = "off" still shows no meter anywhere; "auto"/"mono" both render as a single mono gauge per row regardless, since one channel has nothing to show a left/right split of - "gauge as mono, but show it on each channel row." Reuses meter::render_mono() directly (not the whole-node MeterWidget, which averages multi-channel peaks together) and resets peaks_dirty to match MeterWidget's own bookkeeping. Real per-channel peak data was already available (node.peaks: Option< Arc<[AtomicF32]>>, one entry per channel - MeterWidget's existing fallback path for >2-channel nodes already averages this same array, it just never had anywhere to show the values individually before). 204/204 tests passing (1 new, exercising real per-channel peak values - one channel loud, one silent, confirming each row reads its own channel's peak rather than a shared or averaged one), fmt/clippy/doc clean. Live-verified against the real PipeWire graph: every channel row in Channel mode now shows its own meter alongside its volume bar.
Closes the last item from §7.2's original design and the feedback
batch's remaining deferred piece. When channel_display = "unified" and
unified_imbalance = "cycle", an imbalanced node's single bar now
temporally cycles through each channel's own label+percentage+bar
instead of silently showing the mean.
Stateless by design, per §7.2's own reasoning: cycling_channel() derives
which channel to show purely from elapsed_seconds (threaded fresh from
App::start_time through AppWidget -> ObjectListWidget -> NodeWidget every
render, no polling/ticking) and a deterministic per-node phase offset
from the node's own object ID - every imbalanced node cycles at the same
rate but lands on a different phase, so a list full of imbalanced nodes
doesn't flip in lockstep. No new stored per-node state anywhere, matching
how positions/volumes are already read fresh from view::Node each frame.
Fixed 1.5s interval for now (not user-configurable yet).
Label format keeps the original "no width cost" promise from the design
conversation: the channel index and percentage pack into the exact same
5-column budget the mean-only label already used ("0 79%" / "1100%"),
adapting whether a separator fits based on the percentage's own digit
count, rather than widening the row.
Also corrects a second stale reference this session ran into: the design
notes cited "App::rotate_capturing/frames_since_rotation" as an existing
precedent to build on - it doesn't exist anywhere in this branch either
(same kind of cross-branch mixup as the row_selected one from earlier),
so this was built from scratch using RenderPacer's own Instant-based
timing as the closest real precedent instead.
210/210 tests passing (7 new: precedence gating for every other axis
combination, real time-based advancement after one full
CYCLE_INTERVAL_SECONDS, and a hand-verified phase-offset test proving
two different object IDs land on different channels at the same instant
- object_id 1 -> channel 0, object_id 500 -> channel 1, computed by hand
against the actual formula before asserting), fmt/clippy/doc clean.
Live-verified partially, deliberately not fully: ran with
channel_display = "unified" + unified_imbalance = "cycle" against the
real PipeWire graph - confirmed clean startup, no panics, and every
currently-balanced real stream renders exactly as before (correctly
gated - cycling never activates without real imbalance). Did not
artificially imbalance a real stream to watch it animate, since that's
an audible change to a live, actively-playing stream for a check the
unit tests (specifically the time-advancement and phase-offset ones)
already cover decisively.
…dback fixes - Radiating display now uses one row per detected channel_pairing group (a pair renders as a radiating mini-row, a single as its own row) whenever a node has more than one simple pair or extra unpaired channels alongside a pair - previously stereo_pair() only ever looked at the first detected pair, silently dropping every other channel from both rendering and mouse interaction (confirmed against real "M-Audio Sonica Theater Analog Surround 5.1" hardware layout: FL,FR,RL,RR,FC,LFE). - Single-channel rows now share one bar_width with any radiating pair rows in the same block, so a maxed single channel and a maxed side of a pair fill the same number of characters instead of the single row stretching to its own full width. - Fixed a selector-marker bug: a selected node with no channel pair (e.g. AUX0/AUX1 hardware) rendered with no visible marker anywhere once display split, since the marker logic only checked selected_channel (Channel mode only) - now falls back to marking the header row whenever display is split but nothing is individually targeted. - Added meter_channel_active/inactive/overload char_set fields so a channel row's own peak meter uses a visually distinct (shorter/ thinner) glyph from a whole-node meter, keeping several stacked channel meters from blending into one solid block. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per user's explicit column spec: whenever split_style = "radiating", every row in a split display - a detected pair or a lone unpaired channel alike - now shares one column grid (label | left/unpaired % | left/unpaired bar | divider-or-blank | right bar-or-blank | right %-or-blank | meter, split the same way). An unpaired channel occupies just the left half and leaves the rest blank, instead of stretching its own bar/meter across the whole row - so its bar starts and ends at the same columns a paired row's left bar would, resolving the bar-alignment mismatch reported between AUX-style devices and real stereo devices for free, as a side effect of the shared layout rather than a separate fix. - New RadiatingRowWidget (replaces the old pair-only RadiatingChannelRowWidget) takes an Option<right_index> and handles both shapes; ChannelRowWidget goes back to being purely the plain Channel-mode/split_style="stacked" widget with no bar_width plumbing, since only the radiating context ever needs cross-row bar-width sharing now. - Radiating pair rows are labeled with a new group name (channel_pairing::pair_group_name), not either channel's own name - "FL" as a pair label was indistinguishable from a real FL row. New pair_label_style config (verbose "F L/R" / short "F") controls the format; defaults to verbose. - In Channel mode / split_style = "stacked", a node that's just one simple stereo pair and nothing else now labels its two rows "L"/"R" instead of "FL"/"FR" - there's no ambiguity to a real channel name away when it's the only pair on the node. - Fixed a real bug surfaced while rewriting the pair-row meter: it wasn't honoring peaks = "mono" the way MeterWidget does for a whole node, always attempting a stereo split even when mono was forced. Live-verified against real hardware (the user's actual 5.1 "Sonica Theater" device and the AUX0/AUX1 "Built-in Audio Pro" analog output): all six Sonica Theater channels now render as F L/R, R L/R, FC, LFE with consistent bar lengths and shared column alignment; the AUX0/AUX1 device's selector marker correctly falls back to the header row now that it renders through the same radiating-context path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lit block User clarified: they wanted the bar-start column consistent for *every* row containing a volume in the list - Unified single-bar nodes, the classic single-pair Radiating fast path, and Stacked/split-block rows all needed to match, not just rows within the same node's own split block (which tsowell#63 already handled). Widened the shared padding NodeWidget::render() reserves before a plain single-row node's volume content (2 -> 7 = RADIATING_LABEL_WIDTH, i.e. same value the Stacked block's own leading label column already uses) and StereoVolumeWidget's label_l/label_r (4 -> 5, the one extra column needed to close the remaining gap) so every row's bar lands at the same absolute column regardless of which widget renders it. Verified with exact character-position measurement (not screenshot eyeballing) both in a new unit test and against the real PipeWire graph via tmux capture-pane: every row - Sonica Theater's pair/single rows, HDA Intel PCH's AUX0/AUX1, SW_NULL_MIC's unpaired channels, and the plain single-pair fast path (PYLE_SOUTH, cmus-out streams) - now starts its bar at column 14. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four fixes from a live test pass:
- unified_imbalance = "cycle" label ("0 64%"/"1100%" alternating)
packed the channel index and percentage into a tight 5-column budget,
squeezing out the separating space once percent hit 100 ("1100%") -
genuinely read as malformed, not just tight. Always include the
space now ("1 100%") and give the label column the extra column it
needs (VOLUME_LABEL_WIDTH 5 -> 6), cascading the same +1 through
StereoVolumeWidget's label_l/label_r and NODE_VOLUME_PADDING so the
cross-node bar-start alignment from the previous commit still holds.
- Fixed a real bug: meter_channel_* (the per-channel monitor gauge
added for split rows) was set to the *exact same character* as
volume_empty for both compat ("─") and extracompat ("-"), making a
channel's monitor gauge visually indistinguishable from an empty
volume bar. Picked genuinely distinct glyphs for both, and added a
test (channel_bar_glyphs_never_collide_with_whole_node_glyphs) that
checks every built-in char_set for this class of collision going
forward.
- Added volume_channel_filled/volume_channel_empty - a channel row's
own volume bar now uses a distinct "disjoint, thin" glyph (small
squares for default, dashed rules for compat, o/O for extracompat)
instead of reusing the whole-node volume_filled/volume_empty,
mirroring the meter_channel_* treatment already done for monitors.
- The Fill(1) gap between a row's volume area and its meter area grew
proportionally with terminal width, wasting space in wide terminals
for what's just a divider. Replaced with a small fixed MIDSCREEN_GAP
in both the plain single-row layout and every Stacked-block row,
handing the reclaimed width back to the volume/meter areas.
Live-verified against the real PipeWire graph: JamesDSP Sink's cycling
indicator now reads "0 64%" / "1 100%" unambiguously; Sonica Theater/
HDA Intel PCH/SW_NULL_MIC's volume bars and monitor gauges render with
clearly distinct glyphs from each other; cross-node bar-start alignment
(column 14) still holds after the width changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lyphs The user's original ask was to fix the monitor-bar chars for split rows (meter_channel_*, already correct after the previous commit's collision fix) - not to introduce a separate distinct character style for the volume-setting bars themselves. Revert volume_channel_filled/ volume_channel_empty entirely: ChannelRowWidget/RadiatingRowWidget's volume bars go back to plain volume_filled/volume_empty, same as the whole-node bar. meter_channel_* (the actual fix requested) is untouched. Live-verified: Sonica Theater/HDA Intel PCH/SW_NULL_MIC's volume bars now render with the same character as PYLE_SOUTH/cmus-out's whole-node bars, while their monitor gauges keep the distinct meter_channel_* glyph. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…/right RadiatingRowWidget's paired-row meter called meter::render_stereo, which uses meter_left_active/meter_right_active - the exact same characters as a normal whole-node stereo meter. Its unpaired siblings in the same Stacked block use meter_channel_active instead. Within one split block this meant a pair row's monitor gauge and an unpaired row's monitor gauge used two visually unrelated character sets, which read as "completely different" rather than as two rows of the same kind of gauge. Added meter::render_stereo_channel (shares render_stereo's layout via a new render_stereo_with_chars core, parameterized on which glyphs to use per side) and wired it into RadiatingRowWidget's paired case. Every row in a Stacked block - paired or unpaired - now uses meter_channel_* consistently; the classic single-pair fast path (StereoVolumeWidget/MeterWidget) is untouched, since it's not part of a multi-row block that needs to agree with anything else. Live-verified against the real Sonica Theater device: F L/R, R L/R, FC, and LFE all show the same meter_channel glyph now. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Full revert of the meter_channel_* concept (task tsowell#58 onward). Every per-channel/per-row monitor gauge now calls the exact same meter::render_mono/render_stereo the whole-node MeterWidget always has, using meter_left/right/center_* - no distinct "channel" glyph, no new char_set fields. ChannelRowWidget's meter is meter::render_mono; RadiatingRowWidget's paired-row meter is meter::render_stereo, its unpaired-row meter is meter::render_mono. Removed meter_channel_inactive/active/overload from CharSet, CharSetOverlay, all three built-in defaults, and wiremix.toml. Removed render_mono_channel/render_stereo_channel/the *_with_chars core functions from meter.rs, back to meter.rs's original shape. Updated/removed tests whose premise was the distinct glyph: channel_mode_meter_shows_each_channels_own_peak and radiating_pair_row_honors_forced_mono_peaks now assert on stock meter_left/right_active glyphs (using meter_center_left_active, which only render_stereo ever emits, to distinguish "rendered as stereo" from "rendered as mono" now that the active glyph itself is identical either way in extracompat); paired_and_unpaired_rows_share_the_same_ monitor_glyph replaced with paired_row_meter_uses_the_same_stock_ glyphs_as_a_whole_node_meter, asserting stock reuse instead of a distinct shared glyph. Live-verified against the real Sonica Theater device: F L/R, R L/R, FC, and LFE all show plain "┃" - the same character the classic whole-node stereo meter has always used, nothing new anywhere near monitoring. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Simple cosmetic tweak per request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…optional meter_channel_* glyphs
Four fixes from a live-testing pass against real hardware (Sonica
Theater, HDA Intel PCH, SW_NULL_MIC):
- The monitor/meter section started 2 columns further right on
Stacked/Radiating rows than on classic single-row nodes, even
though their volume bars already lined up at the same column.
Root cause: 3 of 4 structurally-identical row/meter `Layout`s had
a stray `.spacing(1)` the 4th (classic) one lacked, and separately,
the classic layout's leading `NODE_VOLUME_PADDING` constraint
shrank its Fill budget relative to a split row's. Removed the
stray `.spacing(1)` calls and folded `NODE_VOLUME_PADDING` into
`VOLUME_LABEL_WIDTH`/`STEREO_LABEL_L_WIDTH` instead of keeping it
as an external constraint, so the outer constraint list is now
byte-for-byte identical between the two paths. Verified live: both
volume bar (col 14) and meter (col 34) now start at the same
column for every row, split or not.
- unified_imbalance = "cycle"'s label rendered the channel index and
percentage as one right-aligned string, so the index digit's own
column shifted depending on the percentage's digit count ("0 64%"
vs "1 100%"). Split into independently right-aligned index/percent
sub-columns, matching Channel mode's own label_col/percent_col
pattern.
- A selected node's marker only appeared on its header row in Linked
mode when split (Stacked/Radiating), leaving every row below it
looking unselected. Now spans every row with a top/middle/.../
bottom bracket, generalizing the classic 2-line node's own
SelectorWidget shape to however many rows the block has.
- Added optional meter_channel_* char_set fields (mirroring
meter_left/right/center_*) for per-row split monitors specifically.
Unset by default (falls back to the stock glyph, so out-of-the-box
rendering is unchanged) - a theme can opt in to a distinct
split-row monitor look without affecting the classic whole-node
MeterWidget, which never consults these fields.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The volume:meter split was hardcoded 50/50 (Fill(4)/Fill(4)) and every bar/meter row reserved an unconfigurable trailing Fill(1) - proportional to the row's own remaining width, so it grew into real wasted space on a wide terminal. Both are now config settings: - meter_width_percent (1-99, default 50): percentage of a row's combined volume+meter width given to the meter side, the rest to volume. Applies uniformly everywhere a meter is drawn - classic single-row nodes and every row of a Stacked/Radiating block alike, via a shared meter_split_weights() helper so they can't drift apart. - right_margin (default 0): blank columns reserved at the right edge of every bar/meter row, peaks on or off. Defaults to 0 so content uses the full available width instead of an implicit margin - configurable back up if a margin is wanted. Both plumbed through Config/ConfigFile the same way max_volume_percent already is (validated Option<f32> for the percent, resolved to a plain field), documented in wiremix.toml, and covered by new tests (config validation/defaults, and rendering tests confirming both settings actually move the meter column and shrink content on a real render). 237/237 tests. Live-verified: right_margin = 0 lets bars reach the pane's last column; right_margin = 10 reserves exactly 10 blank columns; meter_width_percent = 30 visibly widens the volume side and narrows the meter. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iew cycling Two features from this session's live-testing pass: - Theme gained meter_channel_inactive/active/overload/center_inactive/ center_active (Option<Style>, mirroring CharSet's meter_channel_* glyph fields) - a theme that gives split rows a distinct glyph via meter_channel_* char_set fields can now also compensate its color if needed (e.g. a thin Dingbats-block glyph reading lighter than the classic box-drawing one at an identical RGB value - a font-rendering effect, not a color bug, verified by inspecting the raw ANSI codes wiremix sends). Unset by default, so out-of-the-box rendering is unaffected; meter.rs's render_channel_stereo/render_channel_mono resolve each color the same way they already resolve glyphs (`.unwrap_or(stock)`). - New ChannelView enum (Unified/Linked/Channels) formalizes the three high-level ways the object list can display/target a node's volume, derived from channel_mode/channel_display rather than stored separately (ObjectList::channel_view) so it can't drift out of sync with them. Action::SelectView jumps directly to one; Action::CycleView steps through Config::view_cycle (default all three, in order), wrapping, landing on the cycle's first entry if the current view was reached via SelectView while excluded from the cycle. Space is now bound to CycleView by default (was ToggleChannelMode) - both ToggleChannelMode/CycleChannelDisplay remain available as the lower-level two-axis primitives CycleView/SelectView are built from. 244 tests (11 new: theme color fallback, config validation/defaults for view_cycle, ObjectList's four new methods). Live-verified: Space cycles Linked -> Channels -> Unified -> Linked in the live test session, matching view_cycle's default order. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Unified view (channel_mode off, channel_display = "unified") now uses the literal pre-fork upstream layout instead of this branch's alignment-driven one - checked directly against tsowell/wiremix's own node_widget.rs (merge-base cbdc90f): Length(2) leading padding, Fill(4)/ Fill(1)/Fill(4)/Fill(1) (proportional, not the fixed-column MIDSCREEN_GAP/right_margin Linked/Channels use), and VolumeWidget's own 5-wide "{percent}%" label - not the wider column-14-aligned one. Linked/Channels rows never appear in a stock-settings Unified-view list (no Stacked/Radiating row ever shows there under unified_imbalance = "none"), so there was nothing for Unified view's own column to stay aligned with in the first place - matching stock exactly costs nothing. unified_imbalance = "cycle" is the one place Unified is allowed to differ from true stock (label widens from 5 to 6 columns, to fit "{index} {percent}%" without truncating) - a deliberate fork feature, opted into explicitly, not something stock's own layout needs to accommodate by default. ChannelState::view() (used by ObjectList's own Action::CycleView this session already added) is the discriminator: Linked/Channels share ObjectList's existing aggressive/aligned layout unchanged; VolumeWidget/the classic bar_area split both switch on it, so a single-channel node picks the right layout depending on which of the three views the whole list is currently in, not just its own per-node display state. 246 tests (4 new: label width matches the stock formula exactly and widens by exactly one column for cycle, Unified/Linked bar columns are provably different, right_margin's test updated since it no longer applies to Unified view by design). Live-verified: Unified view's bar starts at column 10 (marker 1 + stock padding 2 + cycling-widened label 6 + spacing 1), matching the hand-derived formula exactly; Linked view unchanged at its existing aligned column. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the single global meter_width_percent/right_margin pair with three independent MeterLayout settings, one per ChannelView, each with its own optional meter_width_percent/gap/right_margin. Leaving a field unset reproduces stock wiremix's own proportional layout for that segment; setting it opts just that field, for just that view, into a fixed-column override - so factory defaults closely match classic spacing in all three views while keeping every prior customization option. Unset gap/margin weights are now derived from the current volume+meter weight sum rather than a hardcoded Fill(1), fixing a bug where a custom ratio away from 50/50 would silently shrink the gap to near nothing (ratatui's Fill distributes proportionally across the whole constraint list, not scoped pairs). Peaks::Off rows also now respect right_margin, which they previously ignored.
… settings gap/right_margin now default to small fixed values (1 and 3 columns, about half of stock's own proportional right_margin) rather than reproducing stock's wider proportional spacing, universally across all three views - still fully overridable per view. meter_width_percent is unaffected, still proportional by default. As a side effect, Unified view's literal byte-for-byte stock fast path is no longer reachable via any real config (every MeterLayout field now has a non-None default), though the mechanism is left in place rather than removed. Also builds out expand_unused_label_space and expand_unpaired_channel_bars for real (previously just reserved scaffolding): a lone stereo pair's row and a Unified-view row both reclaim label space they aren't using this render into their bar(s), and an unpaired channel's row in a radiating block can stretch across the row's full width instead of matching a would-be paired row's half width. Both remain opt-in, default off.
…from meter side
Five live-testing corrections:
- Unpaired channel rows in a radiating block now fill classic
left-to-right (matching every other bar in the app) instead of
reusing the paired branch's grow-from-center direction - was
harmless at the old half-width, became obviously wrong once
expand_unpaired_channel_bars widened the bar.
- Unified view's bar-start column no longer varies per row based on
whether that row happens to be actively cycling - expand_unused_label_space
no longer touches VolumeWidget/Unified view (only StereoVolumeWidget's
lone-pair rows, where every row reclaims the same fixed amount
unconditionally). Per-row column jitter while scrolling a list was
worse than the space it reclaimed.
- gap/right_margin are now carved out of the meter side alone via a
two-stage split (ratio applied first with nothing else in the
list, then gap/margin taken from within the meter side's own
share) rather than being Length siblings in the same Fill split as
the volume:meter ratio - growing either no longer costs the volume
side any width. Simplified from Option<u16> to plain u16 now that
there's no meaningful "proportional" fallback left for either.
gap's default bumped 1 -> 2. This also retires the now-semantically-
broken unified_classic literal-stock-array fast path - every view
uses the same generic split unconditionally now.
- Cycling display in Unified view shows a short channel label ("L"/
"R", a real channel name, or the raw index as a last resort)
instead of always showing the bare index.
- expand_unused_label_space now defaults to on.
…y visible Two corrections from live testing: - Unified view's cycling label width is decided per-row again (self.cycling_channel.is_some()) rather than by the config-level unified_imbalance flag - a balanced row's default width must stay untouched regardless of whether cycling is configured elsewhere in the list. This does reintroduce column variation between balanced and actively-cycling rows, a direct trade-off against the previous round's fix - the two pieces of feedback pull in opposite directions, and this is the more recent, more specific one. - expand_unused_label_space's StereoVolumeWidget reclaim was only ever 1 column per side (preserving alignment with RadiatingRowWidget), which turned out to be imperceptible in practice. Now drops both labels straight to their true minimum content width, discarding the alignment fold entirely - an 8-column combined reclaim instead of 2, split into +4 bar width per side.
Three fixes: - Real bug: a sub-Layout::split used to lay out the cycling channel label + percentage left ratatui's default flex to strand 2 blank columns between "%" and the bar instead of before the label where the alignment fold belongs. Now computed as explicit Rects anchored to the label area's own right edge instead of trusting flex behavior - deterministic, and the "%"-to-bar gap is always exactly 1 column. - gap is now a floor, not a fixed override: split_meter_row scales it up with the meter side's own available width (1-in-8, matching stock's own historical gap proportion) once there's room for that to exceed the configured minimum, rather than staying visually cramped in a wide terminal. right_margin is unaffected. - Mute placement rewritten across every widget. StereoVolumeWidget and RadiatingRowWidget both used to collapse their entire row into one centered "muted" string, blanking bars, labels, and dividers all at once; VolumeWidget's Unified-view mute overwrote the whole label area including a cycling channel's own short label. All three now render normally and substitute "muted" for just the percent text. Investigated first whether per-channel mute is possible at all (SPA_PROP_mute is a single node-level boolean, unlike SPA_PROP_channelVolumes' real per-channel array) before deciding scope - it isn't, so this only fixes placement, not per-channel targeting. Fixing mute's placement surfaced a second real bug: RadiatingRowWidget's label_l/label_r and VolumeWidget's cycling percent_col were both sized for a 4-character "100%" only, truncating "muted" (5 chars) to "uted" - invisible before since both widgets used to bypass these columns via the old centered-mute special case. Widened to 5 (RADIATING_PERCENT_WIDTH), which in turn required bumping STEREO_LABEL_L_WIDTH and VOLUME_LABEL_WIDTH by the same 1 column to keep the "every node's bar starts at the same column" alignment invariant intact.
…ing the row Every Unified-view row's bar now starts at exactly the same column regardless of balance state, without widening the balanced default at all. Simplified VolumeWidget back to a single label width (STOCK_VOLUME_LABEL_WIDTH, unconditional) and draw the channel label and percent text as two passes into the same Rect: label first, left-aligned; percent (or "muted") second, right-aligned, on top. The percent always wins overlapping cells, so a short label leaves a natural gap and a long one silently loses its trailing character(s) to whatever the percent needs - an accepted trade-off for holding the bar to a fixed column instead of reserving dedicated width for the label. STOCK_VOLUME_LABEL_WIDTH_CYCLING and CYCLING_CHANNEL_LABEL_WIDTH are gone - there's no longer a separate wider case to compute.
…b-area STOCK_VOLUME_LABEL_WIDTH grows from 7 to 9 (shifting the bar and percent/mute text 2 columns right, uniformly across every Unified row) to make room for a new dedicated UNIFIED_CHANNEL_LABEL_WIDTH (4) sub-area, anchored at volume_label's own left edge - the label's position doesn't move. The label now renders right-aligned within that sub-area instead of left-aligned across the whole row, so a short label sits closer to the percent instead of hugging the far left. The label and percent sub-areas can no longer overlap by construction (4 + 5 = 9, sized for the percent's own worst case, "muted") - the previous draw-then-overwrite approach could genuinely clip a label's trailing characters when a long label collided with a wide percent. The "every Unified row's bar lands on the same column regardless of balance state" guarantee is untouched.
…ault meter_channel_* is renamed to meter_split_* and its trigger moves from "which widget struct is drawing" to "is the active view Unified or not" - this closes a real gap where a lone stereo pair's classic single-row meter kept stock ┃/█ glyphs in Linked/Channels view even though its adjacent volume bar already looked split. render_stereo/ render_mono absorb render_channel_stereo/render_channel_mono entirely. meter_split_* now ships ❘/▇ as CharSet::default()/compat()'s real default (previously None, opt-in only); extracompat() stays None to preserve its ASCII-only guarantee. Theme's meter_split_* colors stay unset everywhere - only characters got a new default. Also documents a pulsemixer-inspired volume_filled/volume_empty example (▮/▯, no inserted spacing) in wiremix.toml, and drops the user's now-superseded meter_channel_* block from their live config. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This example was documented against volume_filled/volume_empty, but it was meant to illustrate the peak meter's inactive/active glyphs (hollow-vs-filled, same coloring split as the active portion already uses) for a planned, separate off-by-default meter feature - not this branch, and not the plain volume bar. Removing it here rather than leaving incorrect documentation in place; the real example belongs on that feature's own branch once it exists.
render_channel_rows built one Constraint::Length(1) per row (header + every channel group) regardless of how much vertical space the object list actually handed it. When the object list's own "partial node at the bottom of the viewport" mechanism gives a Stacked block less height than its full node_height (e.g. scrolling a 6-channel device like a 5.1 card into view a few rows short), ratatui's Layout::split can't satisfy every Length(1) constraint - and instead of dropping only the trailing rows, it spreads the shortfall across all of them. A 6-channel block squeezed 2 rows short rendered header, then channels 2/3/4/6 - channels 1 and 5 silently vanished, leaving a channel row with no header above it and a gap in the middle of the block. Fix: clamp the constraint count to how many whole rows actually fit (area.height - 1, capped at groups.len()) before building the Layout, so ratatui always has exactly enough space for what it's asked to render - a clean cut at the bottom instead of scattered gaps.
Shipping distinct "❘"/"▇" glyphs by default for Linked/Channels-view meters made them visually clash with a lone stereo pair's own volume bars (which already convey "this is split" via L|R layout) and, once meter-zone-preview is merged alongside this branch, with its always-on inactive-overload marker (which intentionally doesn't follow meter_split_*). Reverting to None (falling back to the same stock meter_left/right/center_* glyphs Unified view uses) keeps the volume-bar split and per-channel label as the only visual cue a view has changed, matching classic wiremix's own meter look everywhere. The meter_split_* fields/mechanism themselves are untouched - still fully overridable per-user, just no longer opinionated defaults. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The event loop only redraws in response to a real event (keypress or PipeWire state change) - when nothing else needs a redraw it blocks indefinitely on the next one. unified_imbalance = "cycle"'s display depends purely on wall-clock time, with no event of its own, so a node stayed pinned to whichever channel it happened to be showing the moment the screen last redrew for some unrelated reason, instead of advancing every ~1.5s as intended - reproduced live: a channel label stuck on the same value for 6+ seconds with cycle configured. Fix: whenever unified_imbalance = "cycle" is configured, cap the otherwise-indefinite wait with a short timer (250ms, well under the per-channel interval) and treat that wake-up itself as a reason to redraw - handle_events correctly reports "no event handled" for a timeout, so needs_render has to be set independently of its return value here. Live-verified: the same node now visibly alternates between channels every ~1.5s with no other activity in the terminal. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Channel mode deliberately never marked the header row - only the individually-targeted channel row got a marker, on the theory that one marker was less cluttered than two. Live testing showed the real effect: combined with row_selected_extend_above/below bleeding into the gaps around the block, the selection read as three disconnected highlighted segments (a sliver above, the one targeted row, a sliver below) with unmarked gaps in between - not "this item is selected." Fix: draw the same continuous top/middle/.../bottom bracket every other split display already uses (Linked mode's Stacked blocks, Radiating rows) regardless of channel_mode, so the whole block always reads as one selected item. The individually-targeted channel is still called out - reversed (fg/bg swapped) rather than a different glyph, so it stands out without breaking the bracket's visual continuity. Updated the two tests that encoded the old header-never- marked, single-row-only behavior; added a small style-inspection helper since the existing render_node_lines only returns glyphs, not the modifier needed to check the reversed cell specifically. Also fixes the meter_split_overrides_apply_wherever_view_is_not_ unified test, which needs meter_split_left/right_inactive and meter_split_center_left/right_active explicitly set now that none of the meter_split_* fields default to a real glyph (previous commit) - otherwise the parts it didn't override correctly fall back to the stock glyph, which the test's own assertion was written to reject. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CharSet::default()'s filled-block meter (already individually-inset
rectangles) correctly stays meter_split_* = None - Linked/Channels
render identically to Unified there, confirmed working as intended.
CharSet::compat()'s thin-bar/thick-center meter is different: its
Unified-view glyphs ("┃"/"█") render as a seamlessly connected line
with no gap between repeated cells, so reverting its meter_split_*
to None as well (067a90c) made split views visually indistinguishable
from Unified in that char_set specifically. Restore compat()'s
meter_split_* to the disjoint "❘"/"▇" pair - same thin-bar/block
family, but with a natural vertical gap between repeated cells,
so a split view still reads as visually distinct without introducing
an unrelated shape.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e segments" This reverts commit 6bf0ce7.
render_stereo_core used Constraint::Fill(2)/Fill(2) for the left/right meter halves - Ratatui's Fill remainder distribution isn't guaranteed symmetric at odd leftover widths, so identical L/R peaks could render a different number of characters on each side depending on terminal width. Same bug class already fixed for the volume bar (see StereoVolumeWidget's own bar_width). Fixed the same way: compute one shared bar_width via explicit division and give both sides the same Length(bar_width) instead of two independent Fill constraints. Since both MeterWidget (Unified) and RadiatingRowWidget (Linked) render through this same function, one fix covers both views. Added stereo_meter_bars_have_symmetric_width_for_equal_peaks, sweeping several widths - uses extracompat specifically, since default/compat both reuse the identical glyph across active/inactive (color-only distinction), which defeats counting by plain-text symbol. Also fixes two tests the previous commit's revert (of the Channel-mode selector's "continuous bracket" change) reintroduced from before that change existed: paired_row_meter_uses_the_shared_meter_split_glyph_by_ default assumed "default" char_set ships a distinct meter_split_* glyph, which is no longer true (only "compat" does, correctly - see a9aad7a) - pinned it to char_set = "compat" explicitly. meter_split_overrides_apply_wherever_view_is_not_unified only overrode 2 of the 10 meter_split_* fields in its own test char_set, so the unoverridden ones fell back to stock and broke its "no stock glyph anywhere" assertion once "default"'s own meter_split_* correctly went back to unset - added the missing overrides. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
HoneyHazard
added a commit
to HoneyHazard/wiremix
that referenced
this pull request
Aug 19, 2026
… binary Previous captures used the combined wiremix-extras binary, which also has meter-zone-preview and dark-theme merged in - their features (the dim-red overload-preview marker, the redshift color palette) were bleeding into screenshots for a PR that has nothing to do with either. Recaptured everything with multichannel-pairing's own build (confirmed via source grep to have no meter_inactive_overload at all), verified free of the marker, with genuine live peak activity throughout (confirmed via repeated frame diffing before each save, not static/idle captures). Also dropped pulsemixer-zone-preview.png -> pulsemixer.png (the zone-preview-specific framing didn't belong here - replaced with a clean capture of just the volume_filled/volume_empty theming idea, which is genuinely this branch's own content), and removed dither-zone-preview.png and both redshift-theme-*.png screenshots entirely (zone-preview and dark-theme content respectively, neither of which this PR touches).
…nelView The two lower-level booleans/enums (channel_mode, channel_display) predate the three named views (Unified/Linked/Channels) - they were the original config surface, and the view abstraction was bolted on top of them a day later without ever removing them. This left two overlapping, confusingly- named ways to say the same thing at the top level. Verified this collapse is lossless, not a trade-off: ChannelState::view() already showed channel_mode always wins over channel_display whenever it's true, meaning the two axes only ever produced 3 distinct outcomes (mapping exactly onto Unified/Linked/Channels), never 4 - the 'individual targeting with no visual split' combination some doc comments described was never actually reachable. - Config/wiremix.toml: channel_display + channel_mode -> initial_view. - ObjectList: channel_mode + channel_display -> view: ChannelView, stored directly instead of two fields a widget had to reconcile. - ChannelState: same collapse, channel_state.view() (a computed method) is now just channel_state.view (a plain field, matching every other field on the struct). - Removed Action::ToggleChannelMode/CycleChannelDisplay and their keybindings - both fully superseded by the existing SelectView/ CycleView, and neither had a default binding to begin with. - opt.rs: --channel-display/--channel-mode/--no-channel-mode CLI flags collapse to a single --initial-view. - wiremix.toml docs and the user's own multichannel-pairing-test.toml updated to match; the latter's 'v' keybinding (CycleChannelDisplay) is removed since that action no longer exists. cargo test --release: 257/257 passing. cargo fmt/clippy clean.
wiremix has no imbalance handling at all today, so defaulting to "none" (silently averaging an imbalanced node with no indication) just reproduced that gap rather than improving on it. "cycle" costs nothing when a node is actually balanced (the common case) and surfaces real information the rest of the time, so it's a better default than a config surface nobody would think to touch. Also promoted the cycle interval (previously a hardcoded 1.5s constant) to unified_imbalance_cycle_seconds, following the same Config/ConfigFile/ opt.rs/ChannelState plumbing as every other per-render setting here. Validated > 0 at parse time; ChannelState/ObjectList carry it alongside unified_imbalance so cycling_channel() reads it directly instead of a module constant. cargo test --release: 261/261 passing (new: interval TOML parse/ validation tests, a dedicated interval-respected test, plus a fix to unified_view_renders_single_averaged_bar, which needs unified_imbalance = "none" explicitly now that cycling is the default). fmt/clippy clean.
…docs NOTES-multichannel.md never existed in this repo's git history - those doc comments were citing a file that ships with nothing, misleading a reader into thinking deeper rationale exists elsewhere. Rewrote each to state its own rationale inline instead. Also clarified ObjectList::view's doc comment: it's easy to misread as related to the `view: &view::View` parameter several of this same struct's own methods take (the live PipeWire object-graph snapshot) - they're unrelated concepts that happen to share a name.
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.
That being said, I hope these can be helpful and useful additions that users could appreciate.
Heads up before anything else: this is a big, experimental branch - by far the largest thing I've sent your way. It touches volume/meter rendering, navigation, and config surface pretty broadly, and I want to be upfront that I'm not confident every corner of it is production-ready. I'm opening this as a draft specifically so it's visible and diffable, not because I think it's ready for review yet. Please treat it as "here's where this experiment landed" rather than "please merge this." Happy to split it into smaller pieces, drop parts of it, or close it outright if that's more useful to you - just let me know.
What this is
Today, a stereo (or multi-channel) node always collapses to one blended volume bar and one blended peak meter, even when its channels actually differ - you can't see or adjust them independently. This branch adds an opt-in way to see and control channels individually, while leaving the classic collapsed view as the default - near-identical to stock wiremix out of the box, with one deliberate exception covered under Backward compatibility below.
Default
unifiedview - identical to stock wiremix's own single-bar-per-node layout, including on a 5.1 device (M-Audio Sonica Theater) and a device with two unpaired AUX channels (HDA Intel PCH).Same devices with the split view toggled on (
Space) - a real 5.1 stream splits into its two stereo pairs (front/rear, radiating from a shared center marker, labeled "F L|R"/"R L|R") plus its two unpaired channels (FC, LFE); a device with two unrelated AUX channels (no detected pair) gets one row per channel instead of guessing a pairing that isn't there. Real, live peak meters throughout - not mocked.The mental model: three views, cycled with
Spaceunified(default) - exactly one bar/row per node, same as stock wiremix always has been.linked- every node's channels get their own row, but a volume key still applies to the whole node at once: a relative key (h/l, arrows) shifts every channel by the same delta, preserving whatever imbalance they already had (matches pulsemixer's own behavior: +10 on 30%/50% gives 40%/60%, not 50%/50%); an absolute key (0-9) sets every channel to that identical value.channels- same split display, but a volume key targets only the individually-cursored channel and leaves every other channel exactly as it was.Spacecyclesunified → linked → channels → unified, in the order given byview_cycle(also directly reachable viaSelectViewfor a dedicated key straight to one view, skipping the cycle).How a split row lays out
split_style = "radiating", the default).pair_label_style); an unpaired channel occupies just the left half of that same column grid (own label/bar/percentage, right side left blank) so every row in the block starts and ends at the same columns.split_style = "stacked"instead always gives one plain, left-aligned row per raw channel, regardless of pairing - useful if the radiating/grouped look isn't wanted.meter_split_*char_set/theme keys (shipped with real default glyphs -❘/▇- not just an opt-inNone) that fall back to the classicmeter_left/meter_right/meter_centerglyphs whenever nothing is split.What's new in config (all optional - see Backward compatibility below for exactly how close the defaults stay to today's behavior)
Backward compatibility
Almost every new setting defaults to reproducing stock wiremix's existing behavior exactly -
initial_view = "unified", nothing forced split, and a balanced node (all channels already equal) looks and behaves exactly as it always has, on real hardware included. The one deliberate exception isunified_imbalance, which defaults to"cycle"rather than a do-nothing"none": stock wiremix has never had any way to indicate an imbalanced node without splitting it, so"none"would just be reproducing a gap, not a stock behavior worth preserving by default."cycle"costs nothing for the common case (a balanced node never cycles - seeis_imbalanced) and surfaces real information the rest of the time."none"is still one config line away if you'd rather have the old silent-average behavior back.More examples
A few more angles on this that the two screenshots above don't cover on their own - individual per-channel targeting, all three
unified_imbalancebehaviors, andcompatvsdefaultchar_set.channelsview (third stop onSpace's cycle) - every row is independently addressable; the small marker on RR's row (not the header) shows which single channelh/l/0-9currently target.SW_VOCALS_PREfurther down happens to be a genuinely imbalanced real stream (75%/127%), useful context for the next few screenshots.unified_imbalance = "none"- the old default, still available as an explicit opt-out. Same Sonica Theater as theunified-view.pngscreenshot up top (also genuinely imbalanced, 93%/84% per its ownlinked-view breakdown below) - just a single static "90%", no indication anything differs channel to channel.unified_imbalance = "cycle"(the default) - two captures of the same still-unifiedlist, 1.5s apart.SW_VOCALS_PRE's single bar/label alternates between "L 75%" and "R 127%" - a way to notice an imbalance without ever leaving the collapsed view.unified_imbalance = "split"- same idea, different resolution:SW_VOCALS_PREauto-splits into a radiating two-bar row on its own, while every other, balanced node (including the 5.1 device) stays exactly as one collapsed bar. Nothing forces a session-wide view switch for this - it's purely a per-node reaction to that node's own current values.Same real devices, same
linkedview,compatvsdefaultchar_set -compat's thin┃meter reads as one continuous bar;default's▮reads as discrete lit segments. Both split the same way; only the glyph family differs.Tested
cargo test --release: all passing (261 tests as of the latest commit on this branch, including dedicated coverage for pair detection, radiating vs. stacked layout, per-channel navigation/volume routing, imbalance cycling/splitting, and a regression test for aStackedblock getting clipped mid-scroll - see below). Since first opening this PR, live day-to-day use turned up two more real bugs, both now fixed: Channel mode's own selector marker briefly grew into a whole-block bracket instead of staying localized to just the targeted channel's row, and the peak meter's left/right halves could render a different number of characters for numerically-equal levels at certain terminal widths (aratatuiFill-constraint rounding issue - fixed by giving both halves an explicit, equalLengthinstead, the same fix the volume bar needed for the identical failure mode). Also cleaned up the config surface itself:channel_display/channel_modewere two lower-level booleans/enums left over from before the three named views existed -Space's own view-cycling logic already showed thatchannel_mode = trueunconditionally wins regardless ofchannel_display, meaning the two never actually produced a 4th, distinct combination beyondunified/linked/channels. Collapsed both into the singleinitial_viewkey above; nothing was lost. Later still, changedunified_imbalance's own default from"none"to"cycle"(see "Backward compatibility" above) and promoted the cycle interval from a hardcoded 1.5s constant to the configurableunified_imbalance_cycle_secondsshown above.cargo fmt --check/cargo clippy --release --all-targets: clean.ratatui'sLayout::splitspreading a size shortfall across all equal-Lengthconstraints rather than just the trailing ones. Fixed by only ever asking for as many row constraints as can actually fit; covered by a new regression test.wiremix-extras' combinedmain(resolving conflicts against its other already-merged features) each time; the combined suite (328 tests, including this branch's own 261) passes there too, and the binary built from that combinedmainis what's actually rebuilt and redeployed for day-to-day use.Known caveats
This feature, together with other experimental changes, is included and should be ready to play with in the wiremix-extras experiment.