feat(hyprdrive,hyprlua): vertical mixer meters with the live peak column inside the fill

The per-channel volume meters are now vertical faders (Gtk.Scale, inverted so
loud is up) instead of horizontal sliders, and the live peak indicator moved
out of its own bar above the card into the meter itself: a narrow violet
column Cairo-drawn inside the trough, so one glance reads both "what the
volume is set to" (the fill) and "what is actually reaching the mix" (the
column inside it).

  - ui/peakbar.py is a DrawingArea rather than a Gtk.LevelBar: it has to grow
    bottom-to-top over the same span as the scale's highlight, minus the
    half-slider inset, and it sits in a Gtk.Overlay with can_target=False so
    the scale underneath still gets every click and drag.
  - ui/meter.py draws the value as its own label and drops the 100% mark;
    either one would push the trough off-centre under the overlaid column.
    The channel label is always appended (empty for mono) so a lone meter and
    a split L/R pair keep the same trough height inside the card's stack.
  - ui/card_base.py fans one peak monitor out to every meter of the card —
    parec reports one mixed level per target, not a per-channel one.
  - The trough's empty portion is transparent now (an idle meter and a silent
    monitor both read as "nothing here"), and supersonic-booster's fill gets
    the suite's emitted-magenta glow. Both stylesheets re-assert the
    transparent background at class specificity: the CyberQueer theme's
    `* { background-color: #1a1a1a }` was otherwise painting a dark stripe
    straight through the fill where the peak column sits.

hyprlua's audio-panel is regenerated via regen-audio-panel.sh; its plain
stylesheet stays flat and glow-free by design, with @accent as the fill.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-07-29 18:22:52 +02:00
parent 55d443ecdb
commit f500dd550e
9 changed files with 401 additions and 154 deletions

View File

@ -68,7 +68,7 @@ drawingarea {
}
.sb-tab-page {
min-height: 190px;
min-height: 250px; /* tall enough for a card with vertical meters */
}
/* ---- horizontal scroll row of cards --------------------------------------- */
@ -101,49 +101,58 @@ drawingarea {
letter-spacing: 0.5px;
}
/* ---- live peak bar -------------------------------------------------------- */
.sb-peakbar { min-height: 6px; }
.sb-peakbar trough {
background-color: alpha(@bg, 0.6);
border-radius: 4px;
min-height: 6px;
}
.sb-peakbar block {
border-radius: 4px;
min-width: 2px;
}
.sb-peakbar block.filled {
background-color: #34E27A; /* signal present — distinct from the violet/accent UI chrome */
}
.sb-peakbar block.empty {
/* ---- vertical volume meter (Gtk.Scale) ------------------------------------ */
/* The magenta highlight is the volume you *selected*; the purple column the
* live peak indicator paints inside it (ui/peakbar.py, Cairo the only styling
* here is the muted dimming) is the signal actually reaching the mix. */
.sb-meter-row { margin: 2px 0; }
/* class-specificity re-assertion of the transparent-structure rule above: the
* CyberQueer theme's `* { background-color: #1a1a1a }` otherwise paints a slab
* behind the value label and, worse, behind the peak column overlaid on the
* trough which reads as a dark stripe punched through the magenta fill. */
.sb-meter-row,
.sb-meter-stack,
.sb-meter-value,
.sb-meter-label,
.sb-meter,
.sb-peakbar {
background-image: none;
background-color: transparent;
}
/* ---- volume meter (Gtk.Scale) --------------------------------------------- */
.sb-meter-row { margin: 2px 0; }
.sb-meter-label {
color: @text;
opacity: 0.75;
font-size: 9pt;
min-width: 12px;
}
.sb-meter-value {
color: @text;
opacity: 0.85;
font-size: 9pt;
letter-spacing: 0.5px;
}
/* empty portion stays see-through an idle meter and a silent monitor both
* read as "nothing here", with only the violet outline holding the shape */
.sb-meter trough {
background-color: alpha(@bg, 0.55);
border-radius: 6px;
min-height: 10px;
background-color: transparent;
border: 1px solid alpha(#8A5CFF, 0.45);
border-radius: 10px;
min-width: 20px;
}
.sb-meter highlight {
background-color: @accent;
border-radius: 6px;
background-color: @text; /* emitted magenta — the selected volume */
border-radius: 10px;
/* tight core + wide bloom: the fill emits, like the rest of the suite's chrome */
box-shadow: 0 0 6px 1px alpha(@text, 0.95), 0 0 22px 6px alpha(@text, 0.55);
}
.sb-meter slider {
background-color: @text;
border-radius: 50%;
min-width: 14px;
min-height: 14px;
background-color: #FFFFFF; /* the fader cap, riding on top of the fill */
border-radius: 3px;
min-width: 24px;
min-height: 10px; /* half of this is ui/meter.py's SLIDER_INSET */
}
.sb-meter.sb-meter-muted highlight { background-color: alpha(@text, 0.25); }
.sb-meter.sb-meter-muted slider { background-color: alpha(@text, 0.4); }
.sb-meter.sb-meter-muted slider { background-color: alpha(#FFFFFF, 0.4); }
.sb-meter-row.sb-meter-muted .sb-peakbar { opacity: 0.35; }
/* ---- controls (mute / L-R toggle / default) -------------------------------- */
.sb-card-controls { margin-top: 2px; }

View File

@ -1,6 +1,10 @@
"""Shared chrome for the Applications/Output/Input tabs' cards: icon+title, a
live peak bar (backend/peaklevel.py), a volume meter that becomes an
independent left/right pair via a toggle, and a mute button. Subclasses
vertical volume meter that becomes an independent left/right pair via a
toggle each with the live peak level (backend/peaklevel.py) drawn as a
purple column inside its magenta fill, see ui/meter.py and a mute button.
One peak monitor per card feeds every meter of that card: `parec` reports a
single mixed level per target, not a per-channel one, so the L and R columns
of a split pair show the same signal at different set volumes. Subclasses
(ApplicationCard, DeviceCard) bolt on their own routing controls (output-
device dropdown, profile dropdown, default-device button) via
`_build_extra()`/`_update_extra()`/`_peak_target()`.
@ -14,7 +18,6 @@ gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
from ui.meter import VolumeMeter
from ui.peakbar import PeakBar
def is_stereo(item: dict) -> bool:
@ -54,15 +57,13 @@ class MeterCard(Gtk.Box):
head.append(self._name_lbl)
self.append(head)
self._peakbar = PeakBar()
self.append(self._peakbar.widget)
self._meter_stack = Gtk.Stack()
self._meter_stack.add_css_class("sb-meter-stack")
self._meter_stack.set_halign(Gtk.Align.CENTER)
self._single = VolumeMeter("", avg_percent(item), self._on_single_change)
self._meter_stack.add_named(self._single, "single")
if self._stereo:
lr_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
lr_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14)
self._left = VolumeMeter("L", self._last_left, self._on_left_change)
self._right = VolumeMeter("R", self._last_right, self._on_right_change)
lr_box.append(self._left)
@ -167,10 +168,16 @@ class MeterCard(Gtk.Box):
if target is None:
return
key = f"{self._kind}:{self._id}"
self._peak_unwatch = self._peaks.watch(key, target, self._peakbar.set_level)
self._peak_unwatch = self._peaks.watch(key, target, self._set_peak_level)
def stop_peak(self) -> None:
if self._peak_unwatch is not None:
self._peak_unwatch()
self._peak_unwatch = None
self._peakbar.set_level(0.0)
self._set_peak_level(0.0)
def _set_peak_level(self, level: float) -> None:
"""One monitored level, fanned out to whichever meters this card has."""
for meter in (self._single, self._left, self._right):
if meter is not None:
meter.set_peak(level)

View File

@ -1,6 +1,19 @@
"""VolumeMeter — a single-channel volume control styled as a segmented meter
bar (a Gtk.Scale dressed up via CSS, see .sb-meter in style/style.css) rather
than a bare slider, since the user asked for "an audio meter" per channel.
"""VolumeMeter — a single-channel volume control styled as a *vertical* segmented
meter (a Gtk.Scale dressed up via CSS, see .sb-meter in style/style.css) rather
than a bare slider, since the user asked for "an audio meter" per channel a
mixer column, read bottom-to-top like every hardware fader.
The live peak indicator (ui/peakbar.py) is stacked *inside* the same trough via
a Gtk.Overlay: magenta fill = the volume you selected, the narrower purple
column inside it = the signal actually reaching the mix right now. The overlay
child is `can_target=False`, so the scale underneath still gets every click and
drag across its full width.
VolumeMeter (Gtk.Box, vertical)
value label ("100")
Gtk.Overlay main: Gtk.Scale (vertical, inverted)
over: PeakBar drawing area (narrow, centred)
channel label ("L"/"R", omitted for the single-channel meter)
Two guards keep it usable against a live, event-driven backend:
- commits are debounced (COMMIT_DEBOUNCE_MS) so dragging doesn't spawn a
@ -19,33 +32,55 @@ import gi
gi.require_version("Gtk", "4.0")
from gi.repository import GLib, Gtk # noqa: E402
from ui.peakbar import PeakBar
COMMIT_DEBOUNCE_MS = 80
METER_HEIGHT = 130
# half the scale slider's height (.sb-meter slider min-height in style.css): the
# span the highlight can actually cover is inset by that much at each end, so the
# peak column drawn inside it has to be inset to match.
SLIDER_INSET = 5
class VolumeMeter(Gtk.Box):
def __init__(self, label: str, value: int, on_change: Callable[[int], None],
muted: bool = False) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=3)
self.add_css_class("sb-meter-row")
self._on_change = on_change
self._dragging = False
self._commit_id: int | None = None
self._pending: int | None = None
if label:
tag = Gtk.Label(label=label)
tag.add_css_class("sb-meter-label")
self.append(tag)
self._value_lbl = Gtk.Label(label=str(int(value)))
self._value_lbl.add_css_class("sb-meter-value")
self.append(self._value_lbl)
self.scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 150, 1)
self.scale = Gtk.Scale.new_with_range(Gtk.Orientation.VERTICAL, 0, 150, 1)
self.scale.set_value(value)
self.scale.set_draw_value(True)
self.scale.set_value_pos(Gtk.PositionType.RIGHT)
self.scale.set_hexpand(True)
self.scale.set_size_request(90, -1)
self.scale.set_inverted(True) # loud at the top, like a hardware fader
# value drawn by _value_lbl above instead of by the scale, and no 100%
# mark: either one would push the trough off-centre inside the widget,
# and the peak column overlaid on it is centred.
self.scale.set_draw_value(False)
self.scale.set_vexpand(True)
self.scale.set_size_request(-1, METER_HEIGHT)
self.scale.add_css_class("sb-meter")
self.scale.add_mark(100, Gtk.PositionType.BOTTOM, None)
self.scale.connect("value-changed", self._on_value_changed)
self._peakbar = PeakBar(inset=SLIDER_INSET)
trough = Gtk.Overlay()
trough.set_child(self.scale)
trough.add_overlay(self._peakbar.widget)
self.append(trough)
# always appended, even unlabelled (single-channel) — an empty label
# still claims a line, so a lone meter and a split L/R pair keep the
# same trough height inside the card's Gtk.Stack
tag = Gtk.Label(label=label)
tag.add_css_class("sb-meter-label")
self.append(tag)
self.set_muted(muted)
click = Gtk.GestureClick()
@ -54,10 +89,9 @@ class VolumeMeter(Gtk.Box):
click.connect("released", lambda *_a: setattr(self, "_dragging", False))
self.scale.add_controller(click)
self.append(self.scale)
def _on_value_changed(self, _scale) -> None:
self._pending = int(self.scale.get_value())
self._value_lbl.set_label(str(self._pending))
if self._commit_id is not None:
GLib.source_remove(self._commit_id)
self._commit_id = GLib.timeout_add(COMMIT_DEBOUNCE_MS, self._commit)
@ -76,9 +110,17 @@ class VolumeMeter(Gtk.Box):
self.scale.handler_block_by_func(self._on_value_changed)
self.scale.set_value(value)
self.scale.handler_unblock_by_func(self._on_value_changed)
self._value_lbl.set_label(str(int(value)))
def set_muted(self, muted: bool) -> None:
if muted:
self.scale.add_css_class("sb-meter-muted")
self.add_css_class("sb-meter-muted")
else:
self.scale.remove_css_class("sb-meter-muted")
self.remove_css_class("sb-meter-muted")
def set_peak(self, level: float) -> None:
"""Feed the inner live-signal column (ui/card_base.py fans one peak
monitor's level out to every meter of the card)."""
self._peakbar.set_level(level)

View File

@ -1,25 +1,84 @@
"""PeakBar — a thin live amplitude indicator (see backend/peaklevel.py). Shows
whether audio is actually reaching the mix, independent of the volume slider
above it, so a silent app/device reads differently from one that's just quiet:
a stuck/crashed stream or a bad route shows a flat bar even at 100% volume."""
"""PeakBar — the live amplitude indicator (see backend/peaklevel.py), drawn as
a narrow purple column *inside* the magenta volume meter it belongs to rather
than as a separate bar above the card: one glance tells you both what the
volume is set to and how much signal is actually reaching the mix, so a silent
app/device reads differently from one that's just quiet (a stuck or mis-routed
stream shows an empty inner column at 100% volume).
Cairo-drawn instead of a Gtk.LevelBar because it has to line up with the
Gtk.Scale trough underneath it: the fill has to grow bottom-to-top over the
same span as the scale's highlight, minus the half-slider inset the scale
reserves at each end (`inset` below). It also sidesteps this GTK build's
reluctance to paint CSS backgrounds on non-drawing widgets. Owned by
ui/meter.py, which stacks it over the scale in a Gtk.Overlay.
"""
from __future__ import annotations
import math
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
# glow-violet, the suite's frame colour — deliberately *inside* the meter's
# emitted-magenta fill, so "set to" and "actually playing" never read alike
PEAK_COLOR = (0x8A / 255, 0x5C / 255, 0xFF / 255)
class PeakBar:
def __init__(self) -> None:
self.widget = Gtk.LevelBar()
WIDTH = 9 # narrower than the meter trough, so magenta frames it
MIN_VISIBLE = 0.01
def __init__(self, inset: int = 0) -> None:
self._inset = inset
self._level = 0.0
self.widget = Gtk.DrawingArea()
self.widget.add_css_class("sb-peakbar")
self.widget.set_min_value(0.0)
self.widget.set_max_value(1.0)
self.widget.set_value(0.0)
self.widget.set_size_request(-1, 6)
self.widget.set_hexpand(True)
self.widget.set_size_request(self.WIDTH, -1)
self.widget.set_halign(Gtk.Align.CENTER)
self.widget.set_valign(Gtk.Align.FILL)
self.widget.set_vexpand(True)
# the scale underneath owns every click/drag in this area
self.widget.set_can_target(False)
self.widget.set_draw_func(self._draw)
def set_level(self, level: float) -> None:
self.widget.set_value(max(0.0, min(1.0, level)))
level = max(0.0, min(1.0, level))
if abs(level - self._level) < 0.002:
return
self._level = level
self.widget.queue_draw()
# -- drawing ------------------------------------------------------------------
def _draw(self, _area, cr, width: int, height: int) -> None:
if self._level < self.MIN_VISIBLE:
return
top = self._inset
bottom = height - self._inset
span = bottom - top
if span <= 0:
return
w = min(width, self.WIDTH)
x = (width - w) / 2.0
h = span * self._level
y = bottom - h
self._rounded(cr, x, y, w, h, w / 2.0)
cr.set_source_rgba(*PEAK_COLOR, 0.95)
cr.fill_preserve()
cr.set_source_rgba(*PEAK_COLOR, 0.35)
cr.set_line_width(2.0)
cr.stroke()
@staticmethod
def _rounded(cr, x: float, y: float, w: float, h: float, r: float) -> None:
r = min(r, w / 2.0, h / 2.0)
cr.new_sub_path()
cr.arc(x + w - r, y + r, r, -math.pi / 2, 0)
cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2)
cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi)
cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2)
cr.close_path()

View File

@ -63,7 +63,7 @@ drawingarea {
color: @bg;
}
.sb-tab-page { min-height: 190px; }
.sb-tab-page { min-height: 250px; } /* tall enough for a card with vertical meters */
/* ---- horizontal scroll row of cards ---------------------------------------- */
.sb-scroll-row { padding: 4px 2px 10px 2px; }
@ -94,45 +94,56 @@ drawingarea {
font-size: 8.5pt;
}
/* ---- live peak bar ---------------------------------------------------------- */
.sb-peakbar { min-height: 6px; }
.sb-peakbar trough {
background-color: @bg;
border-radius: 4px;
min-height: 6px;
}
.sb-peakbar block {
border-radius: 4px;
min-width: 2px;
}
.sb-peakbar block.filled { background-color: #34E27A; }
.sb-peakbar block.empty { background-color: transparent; }
/* ---- volume meter (Gtk.Scale) ----------------------------------------------- */
/* ---- vertical volume meter (Gtk.Scale) --------------------------------------- */
/* The accent fill is the volume you *selected*; the violet column ui/peakbar.py
* paints inside it (Cairo nothing to style here beyond the muted dimming) is
* the live signal. No box-shadow glow on the fill, unlike hyprdrive's
* supersonic-booster: this variant stays flat, see the header. */
.sb-meter-row { margin: 2px 0; }
/* class-specificity re-assertion of the transparent-structure rule above: the
* CyberQueer theme's `* { background-color: #1a1a1a }` otherwise paints a slab
* behind the value label and behind the peak column overlaid on the trough,
* which reads as a dark stripe punched through the fill. */
.sb-meter-row,
.sb-meter-stack,
.sb-meter-value,
.sb-meter-label,
.sb-meter,
.sb-peakbar {
background-image: none;
background-color: transparent;
}
.sb-meter-label {
color: @text;
opacity: 0.75;
font-size: 9pt;
min-width: 12px;
}
.sb-meter-value {
color: @text;
opacity: 0.85;
font-size: 9pt;
}
/* empty portion stays see-through an idle meter and a silent monitor both
* read as "nothing here", with only the outline holding the shape */
.sb-meter trough {
background-color: @bg;
border-radius: 6px;
min-height: 10px;
background-color: transparent;
border: 1px solid @violet;
border-radius: 10px;
min-width: 20px;
}
.sb-meter highlight {
background-color: @accent;
border-radius: 6px;
border-radius: 10px;
}
.sb-meter slider {
background-color: @text;
border-radius: 50%;
min-width: 14px;
min-height: 14px;
background-color: @text; /* the fader cap, riding on top of the fill */
border-radius: 3px;
min-width: 24px;
min-height: 10px; /* half of this is ui/meter.py's SLIDER_INSET */
}
.sb-meter.sb-meter-muted highlight { background-color: alpha(@text, 0.3); }
.sb-meter.sb-meter-muted slider { background-color: alpha(@text, 0.4); }
.sb-meter-row.sb-meter-muted .sb-peakbar { opacity: 0.35; }
/* ---- controls (mute / L-R toggle / default) --------------------------------- */
.sb-card-controls { margin-top: 2px; }

View File

@ -63,7 +63,7 @@ drawingarea {
color: @bg;
}
.sb-tab-page { min-height: 190px; }
.sb-tab-page { min-height: 250px; } /* tall enough for a card with vertical meters */
/* ---- horizontal scroll row of cards ---------------------------------------- */
.sb-scroll-row { padding: 4px 2px 10px 2px; }
@ -94,45 +94,56 @@ drawingarea {
font-size: 8.5pt;
}
/* ---- live peak bar ---------------------------------------------------------- */
.sb-peakbar { min-height: 6px; }
.sb-peakbar trough {
background-color: @bg;
border-radius: 4px;
min-height: 6px;
}
.sb-peakbar block {
border-radius: 4px;
min-width: 2px;
}
.sb-peakbar block.filled { background-color: #34E27A; }
.sb-peakbar block.empty { background-color: transparent; }
/* ---- volume meter (Gtk.Scale) ----------------------------------------------- */
/* ---- vertical volume meter (Gtk.Scale) --------------------------------------- */
/* The accent fill is the volume you *selected*; the violet column ui/peakbar.py
* paints inside it (Cairo nothing to style here beyond the muted dimming) is
* the live signal. No box-shadow glow on the fill, unlike hyprlua's
* audio-panel: this variant stays flat, see the header. */
.sb-meter-row { margin: 2px 0; }
/* class-specificity re-assertion of the transparent-structure rule above: the
* CyberQueer theme's `* { background-color: #1a1a1a }` otherwise paints a slab
* behind the value label and behind the peak column overlaid on the trough,
* which reads as a dark stripe punched through the fill. */
.sb-meter-row,
.sb-meter-stack,
.sb-meter-value,
.sb-meter-label,
.sb-meter,
.sb-peakbar {
background-image: none;
background-color: transparent;
}
.sb-meter-label {
color: @text;
opacity: 0.75;
font-size: 9pt;
min-width: 12px;
}
.sb-meter-value {
color: @text;
opacity: 0.85;
font-size: 9pt;
}
/* empty portion stays see-through an idle meter and a silent monitor both
* read as "nothing here", with only the outline holding the shape */
.sb-meter trough {
background-color: @bg;
border-radius: 6px;
min-height: 10px;
background-color: transparent;
border: 1px solid @violet;
border-radius: 10px;
min-width: 20px;
}
.sb-meter highlight {
background-color: @accent;
border-radius: 6px;
border-radius: 10px;
}
.sb-meter slider {
background-color: @text;
border-radius: 50%;
min-width: 14px;
min-height: 14px;
background-color: @text; /* the fader cap, riding on top of the fill */
border-radius: 3px;
min-width: 24px;
min-height: 10px; /* half of this is ui/meter.py's SLIDER_INSET */
}
.sb-meter.sb-meter-muted highlight { background-color: alpha(@text, 0.3); }
.sb-meter.sb-meter-muted slider { background-color: alpha(@text, 0.4); }
.sb-meter-row.sb-meter-muted .sb-peakbar { opacity: 0.35; }
/* ---- controls (mute / L-R toggle / default) --------------------------------- */
.sb-card-controls { margin-top: 2px; }

View File

@ -1,6 +1,10 @@
"""Shared chrome for the Applications/Output/Input tabs' cards: icon+title, a
live peak bar (backend/peaklevel.py), a volume meter that becomes an
independent left/right pair via a toggle, and a mute button. Subclasses
vertical volume meter that becomes an independent left/right pair via a
toggle each with the live peak level (backend/peaklevel.py) drawn as a
purple column inside its magenta fill, see ui/meter.py and a mute button.
One peak monitor per card feeds every meter of that card: `parec` reports a
single mixed level per target, not a per-channel one, so the L and R columns
of a split pair show the same signal at different set volumes. Subclasses
(ApplicationCard, DeviceCard) bolt on their own routing controls (output-
device dropdown, profile dropdown, default-device button) via
`_build_extra()`/`_update_extra()`/`_peak_target()`.
@ -14,7 +18,6 @@ gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
from ui.meter import VolumeMeter
from ui.peakbar import PeakBar
def is_stereo(item: dict) -> bool:
@ -54,15 +57,13 @@ class MeterCard(Gtk.Box):
head.append(self._name_lbl)
self.append(head)
self._peakbar = PeakBar()
self.append(self._peakbar.widget)
self._meter_stack = Gtk.Stack()
self._meter_stack.add_css_class("sb-meter-stack")
self._meter_stack.set_halign(Gtk.Align.CENTER)
self._single = VolumeMeter("", avg_percent(item), self._on_single_change)
self._meter_stack.add_named(self._single, "single")
if self._stereo:
lr_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
lr_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14)
self._left = VolumeMeter("L", self._last_left, self._on_left_change)
self._right = VolumeMeter("R", self._last_right, self._on_right_change)
lr_box.append(self._left)
@ -167,10 +168,16 @@ class MeterCard(Gtk.Box):
if target is None:
return
key = f"{self._kind}:{self._id}"
self._peak_unwatch = self._peaks.watch(key, target, self._peakbar.set_level)
self._peak_unwatch = self._peaks.watch(key, target, self._set_peak_level)
def stop_peak(self) -> None:
if self._peak_unwatch is not None:
self._peak_unwatch()
self._peak_unwatch = None
self._peakbar.set_level(0.0)
self._set_peak_level(0.0)
def _set_peak_level(self, level: float) -> None:
"""One monitored level, fanned out to whichever meters this card has."""
for meter in (self._single, self._left, self._right):
if meter is not None:
meter.set_peak(level)

View File

@ -1,6 +1,19 @@
"""VolumeMeter — a single-channel volume control styled as a segmented meter
bar (a Gtk.Scale dressed up via CSS, see .sb-meter in style/style.css) rather
than a bare slider, since the user asked for "an audio meter" per channel.
"""VolumeMeter — a single-channel volume control styled as a *vertical* segmented
meter (a Gtk.Scale dressed up via CSS, see .sb-meter in style/style.css) rather
than a bare slider, since the user asked for "an audio meter" per channel a
mixer column, read bottom-to-top like every hardware fader.
The live peak indicator (ui/peakbar.py) is stacked *inside* the same trough via
a Gtk.Overlay: magenta fill = the volume you selected, the narrower purple
column inside it = the signal actually reaching the mix right now. The overlay
child is `can_target=False`, so the scale underneath still gets every click and
drag across its full width.
VolumeMeter (Gtk.Box, vertical)
value label ("100")
Gtk.Overlay main: Gtk.Scale (vertical, inverted)
over: PeakBar drawing area (narrow, centred)
channel label ("L"/"R", omitted for the single-channel meter)
Two guards keep it usable against a live, event-driven backend:
- commits are debounced (COMMIT_DEBOUNCE_MS) so dragging doesn't spawn a
@ -19,33 +32,55 @@ import gi
gi.require_version("Gtk", "4.0")
from gi.repository import GLib, Gtk # noqa: E402
from ui.peakbar import PeakBar
COMMIT_DEBOUNCE_MS = 80
METER_HEIGHT = 130
# half the scale slider's height (.sb-meter slider min-height in style.css): the
# span the highlight can actually cover is inset by that much at each end, so the
# peak column drawn inside it has to be inset to match.
SLIDER_INSET = 5
class VolumeMeter(Gtk.Box):
def __init__(self, label: str, value: int, on_change: Callable[[int], None],
muted: bool = False) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=3)
self.add_css_class("sb-meter-row")
self._on_change = on_change
self._dragging = False
self._commit_id: int | None = None
self._pending: int | None = None
if label:
tag = Gtk.Label(label=label)
tag.add_css_class("sb-meter-label")
self.append(tag)
self._value_lbl = Gtk.Label(label=str(int(value)))
self._value_lbl.add_css_class("sb-meter-value")
self.append(self._value_lbl)
self.scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 150, 1)
self.scale = Gtk.Scale.new_with_range(Gtk.Orientation.VERTICAL, 0, 150, 1)
self.scale.set_value(value)
self.scale.set_draw_value(True)
self.scale.set_value_pos(Gtk.PositionType.RIGHT)
self.scale.set_hexpand(True)
self.scale.set_size_request(90, -1)
self.scale.set_inverted(True) # loud at the top, like a hardware fader
# value drawn by _value_lbl above instead of by the scale, and no 100%
# mark: either one would push the trough off-centre inside the widget,
# and the peak column overlaid on it is centred.
self.scale.set_draw_value(False)
self.scale.set_vexpand(True)
self.scale.set_size_request(-1, METER_HEIGHT)
self.scale.add_css_class("sb-meter")
self.scale.add_mark(100, Gtk.PositionType.BOTTOM, None)
self.scale.connect("value-changed", self._on_value_changed)
self._peakbar = PeakBar(inset=SLIDER_INSET)
trough = Gtk.Overlay()
trough.set_child(self.scale)
trough.add_overlay(self._peakbar.widget)
self.append(trough)
# always appended, even unlabelled (single-channel) — an empty label
# still claims a line, so a lone meter and a split L/R pair keep the
# same trough height inside the card's Gtk.Stack
tag = Gtk.Label(label=label)
tag.add_css_class("sb-meter-label")
self.append(tag)
self.set_muted(muted)
click = Gtk.GestureClick()
@ -54,10 +89,9 @@ class VolumeMeter(Gtk.Box):
click.connect("released", lambda *_a: setattr(self, "_dragging", False))
self.scale.add_controller(click)
self.append(self.scale)
def _on_value_changed(self, _scale) -> None:
self._pending = int(self.scale.get_value())
self._value_lbl.set_label(str(self._pending))
if self._commit_id is not None:
GLib.source_remove(self._commit_id)
self._commit_id = GLib.timeout_add(COMMIT_DEBOUNCE_MS, self._commit)
@ -76,9 +110,17 @@ class VolumeMeter(Gtk.Box):
self.scale.handler_block_by_func(self._on_value_changed)
self.scale.set_value(value)
self.scale.handler_unblock_by_func(self._on_value_changed)
self._value_lbl.set_label(str(int(value)))
def set_muted(self, muted: bool) -> None:
if muted:
self.scale.add_css_class("sb-meter-muted")
self.add_css_class("sb-meter-muted")
else:
self.scale.remove_css_class("sb-meter-muted")
self.remove_css_class("sb-meter-muted")
def set_peak(self, level: float) -> None:
"""Feed the inner live-signal column (ui/card_base.py fans one peak
monitor's level out to every meter of the card)."""
self._peakbar.set_level(level)

View File

@ -1,25 +1,84 @@
"""PeakBar — a thin live amplitude indicator (see backend/peaklevel.py). Shows
whether audio is actually reaching the mix, independent of the volume slider
above it, so a silent app/device reads differently from one that's just quiet:
a stuck/crashed stream or a bad route shows a flat bar even at 100% volume."""
"""PeakBar — the live amplitude indicator (see backend/peaklevel.py), drawn as
a narrow purple column *inside* the magenta volume meter it belongs to rather
than as a separate bar above the card: one glance tells you both what the
volume is set to and how much signal is actually reaching the mix, so a silent
app/device reads differently from one that's just quiet (a stuck or mis-routed
stream shows an empty inner column at 100% volume).
Cairo-drawn instead of a Gtk.LevelBar because it has to line up with the
Gtk.Scale trough underneath it: the fill has to grow bottom-to-top over the
same span as the scale's highlight, minus the half-slider inset the scale
reserves at each end (`inset` below). It also sidesteps this GTK build's
reluctance to paint CSS backgrounds on non-drawing widgets. Owned by
ui/meter.py, which stacks it over the scale in a Gtk.Overlay.
"""
from __future__ import annotations
import math
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
# glow-violet, the suite's frame colour — deliberately *inside* the meter's
# emitted-magenta fill, so "set to" and "actually playing" never read alike
PEAK_COLOR = (0x8A / 255, 0x5C / 255, 0xFF / 255)
class PeakBar:
def __init__(self) -> None:
self.widget = Gtk.LevelBar()
WIDTH = 9 # narrower than the meter trough, so magenta frames it
MIN_VISIBLE = 0.01
def __init__(self, inset: int = 0) -> None:
self._inset = inset
self._level = 0.0
self.widget = Gtk.DrawingArea()
self.widget.add_css_class("sb-peakbar")
self.widget.set_min_value(0.0)
self.widget.set_max_value(1.0)
self.widget.set_value(0.0)
self.widget.set_size_request(-1, 6)
self.widget.set_hexpand(True)
self.widget.set_size_request(self.WIDTH, -1)
self.widget.set_halign(Gtk.Align.CENTER)
self.widget.set_valign(Gtk.Align.FILL)
self.widget.set_vexpand(True)
# the scale underneath owns every click/drag in this area
self.widget.set_can_target(False)
self.widget.set_draw_func(self._draw)
def set_level(self, level: float) -> None:
self.widget.set_value(max(0.0, min(1.0, level)))
level = max(0.0, min(1.0, level))
if abs(level - self._level) < 0.002:
return
self._level = level
self.widget.queue_draw()
# -- drawing ------------------------------------------------------------------
def _draw(self, _area, cr, width: int, height: int) -> None:
if self._level < self.MIN_VISIBLE:
return
top = self._inset
bottom = height - self._inset
span = bottom - top
if span <= 0:
return
w = min(width, self.WIDTH)
x = (width - w) / 2.0
h = span * self._level
y = bottom - h
self._rounded(cr, x, y, w, h, w / 2.0)
cr.set_source_rgba(*PEAK_COLOR, 0.95)
cr.fill_preserve()
cr.set_source_rgba(*PEAK_COLOR, 0.35)
cr.set_line_width(2.0)
cr.stroke()
@staticmethod
def _rounded(cr, x: float, y: float, w: float, h: float, r: float) -> None:
r = min(r, w / 2.0, h / 2.0)
cr.new_sub_path()
cr.arc(x + w - r, y + r, r, -math.pi / 2, 0)
cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2)
cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi)
cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2)
cr.close_path()