Compare commits

..

10 Commits

Author SHA1 Message Date
3a98bc08e9 shell: document why UseQApplication is required
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:42:03 +02:00
f6796f3726 sysstats: refresh cpuFreq every 3rd tick instead of every tick
/proc/cpuinfo is regenerated in full by the kernel on every read and
its cost grows with core count, making it the priciest per-tick read.
Bar frequency display doesn't need 1 Hz precision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:42:02 +02:00
6655c9c500 bar: wrap onto two rows when one row doesn't fit
On narrow outputs (portrait rotation) the single-row layout overflowed.
Compare the needed width (implicit sizes of all three clusters) against
the window width; when it doesn't fit, double the bar height and move
the metric pills to a second row, keeping workspaces + clock/tray on
the first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:41:13 +02:00
b7ea18c751 volume: reduce scroll sensitivity
Use 2% steps and accumulate wheel deltas so high-res/inertial scrolling
steps once per notch instead of firing per micro-event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:41:02 +02:00
796be77341 fix wifi disconnected icon 2026-06-06 23:37:44 +02:00
7a95288f81 theme: increase contrast 2026-06-04 21:39:52 +02:00
fa3e41ecc2 clock: move near end of bar 2026-06-04 21:39:16 +02:00
e97a2b65c2 pkgs.system -> pkgs.stdenv.hostPlatform.system 2026-06-04 14:00:55 +02:00
c62e8f10ea set wayland as default platform 2026-06-02 19:02:05 +02:00
dfb16cce4e Add per-process network drawer to the Network widget
Clicking the Network pill now toggles a drawer listing processes ranked
by network throughput, refreshed live only while open — mirroring the
CPU widget's top-processes drawer.

services/topnet.sh (single-shot, like topproc.sh) takes two `ss` snapshots
procInterval apart, diffs each TCP socket's cumulative rx/tx bytes by inode,
sums positive deltas per owning PID, and prints ranked "N <rx/s> <tx/s>
<name>" frames. SysStats re-runs it while netProcPollEnabled (drawer open)
using the same SplitParser-accumulate / parse-on-exit / re-arm plumbing as
procScan. Network.qml wraps its Pill in an Item and hangs a fixed-size
PopupWindow drawer off it.

ss (iproute2) is added to runtimeInputs since per-process byte accounting
has no virtual-file equivalent. Caveat: ss only exposes byte counters for
TCP sockets, so UDP/QUIC traffic is not attributed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:40:26 +02:00
9 changed files with 471 additions and 70 deletions

View File

@@ -11,7 +11,8 @@ Singleton {
readonly property string thermo: String.fromCharCode(0xf2c9) // thermometer-half
readonly property string disk: String.fromCharCode(0xf0a0) // hdd-o
readonly property string wifi: String.fromCharCode(0xf1eb) // wifi
readonly property string ethernet: String.fromCharCode(0xf6ff) // network-wired
readonly property string ethernet: String.fromCodePoint(0xf0200) // md-ethernet (FA5 network-wired 0xf6ff is absent from the bundled Nerd Fonts)
readonly property string netOff: String.fromCodePoint(0xf05aa) // md-wifi-off (shown when there is no active interface)
readonly property string down: String.fromCharCode(0xf063) // arrow-down
readonly property string up: String.fromCharCode(0xf062) // arrow-up
readonly property string clock: String.fromCharCode(0xf017) // clock-o

View File

@@ -11,28 +11,28 @@ Singleton {
readonly property int padding: 10
readonly property int gap: 4
// --- palette (Catppuccin Mocha, darkened toward OLED black) ---
readonly property color base: "#0d0d12"
readonly property color mantle: "#08080b"
readonly property color surface0: "#1c1c28"
readonly property color surface1: "#2a2a3a"
readonly property color overlay: "#52526a"
readonly property color text: "#cdd6f4"
readonly property color subtext: "#a6adc8"
// --- palette (deep near-black base, high-contrast foreground) ---
readonly property color base: "#05060a"
readonly property color mantle: "#000000"
readonly property color surface0: "#15161f"
readonly property color surface1: "#232532"
readonly property color overlay: "#4a4d63"
readonly property color text: "#eef1f8"
readonly property color subtext: "#b4bbd0"
readonly property color rosewater: "#f5e0dc"
readonly property color red: "#f38ba8"
readonly property color peach: "#fab387"
readonly property color yellow: "#f9e2af"
readonly property color green: "#a6e3a1"
readonly property color teal: "#94e2d5"
readonly property color sky: "#89dceb"
readonly property color blue: "#89b4fa"
readonly property color mauve: "#cba6f7"
readonly property color lavender: "#b4befe"
readonly property color rosewater: "#ffdfd6"
readonly property color red: "#ff7a93"
readonly property color peach: "#ffb074"
readonly property color yellow: "#ffe07a"
readonly property color green: "#8ff09a"
readonly property color teal: "#7ff0df"
readonly property color sky: "#74d9ff"
readonly property color blue: "#7aa9ff"
readonly property color mauve: "#c799ff"
readonly property color lavender: "#aebcff"
// bar background, slightly translucent
readonly property color barColor: Qt.rgba(0.051, 0.051, 0.071, 0.94)
// bar background, near-opaque true black
readonly property color barColor: Qt.rgba(0.02, 0.024, 0.039, 0.96)
// --- typography ---
readonly property string font: "Inter, sans-serif"

View File

@@ -21,15 +21,17 @@
packages = forAllSystems (pkgs:
let
# Quickshell from the upstream flake rather than nixpkgs.
qsPkg = quickshell.packages.${pkgs.system}.default;
qsPkg = quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default;
# Runtime tools the bar relies on: discover.sh (one-shot hardware
# probe) and the periodic `df` for disk usage. Everything else is
# read from /proc and /sys directly in QML.
# probe), the periodic `df` for disk usage, and `ss` (iproute2) for
# the per-process network drawer (topnet.sh). Everything else is read
# from /proc and /sys directly in QML.
runtimeInputs = [
qsPkg
pkgs.bash
pkgs.coreutils # df, cat
pkgs.iproute2 # ss, for topnet.sh per-process network usage
];
# Self-contained font set so Nerd Font glyphs always render,
@@ -47,6 +49,10 @@
inherit runtimeInputs;
text = ''
export FONTCONFIG_FILE=${fontsConf}
# Force the Wayland QPA plugin: Qt defaults to xcb (X11) on Linux,
# so without this the bar comes up under XWayland. Honour an
# explicit override if the caller already set one.
export QT_QPA_PLATFORM="''${QT_QPA_PLATFORM:-wayland}"
exec quickshell --path ${self} "$@"
'';
};
@@ -59,16 +65,17 @@
apps = forAllSystems (pkgs: {
default = {
type = "app";
program = "${self.packages.${pkgs.system}.default}/bin/quickshell-bar";
program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.default}/bin/quickshell-bar";
};
});
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
packages = [
quickshell.packages.${pkgs.system}.default
quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default
pkgs.bash
pkgs.coreutils
pkgs.iproute2 # ss, for topnet.sh
pkgs.nerd-fonts.jetbrains-mono
pkgs.inter
pkgs.qt6.qtdeclarative # qmlls / qmlformat for editing

View File

@@ -37,11 +37,27 @@ Singleton {
property bool procPollEnabled: false
readonly property int procInterval: 2 // seconds between frames
// --- top network processes (same opt-in streaming model as topProcs) ---
// Each entry: { name, rx (bytes/sec down), tx (bytes/sec up) }, highest
// combined throughput first. Set netProcPollEnabled true (Network drawer
// open) to start the streamer; only TCP traffic is attributable (see
// topnet.sh), so UDP/QUIC-heavy apps may underreport.
property var topNetProcs: []
readonly property int netProcCount: 6
property bool netProcPollEnabled: false
readonly property int netProcInterval: 2 // seconds between frames
readonly property bool hasBattery: battery >= 0
// poll interval in seconds
readonly property int interval: 1
// cpuFreq comes from /proc/cpuinfo, which the kernel regenerates in full on
// every read (cost grows with core count) — the priciest per-tick read here.
// Frequency on a status bar doesn't need 1 Hz precision, so only refresh it
// every Nth tick instead of on the CPU%/mem hot path.
readonly property int freqEveryTicks: 3
// discovered sysfs paths (filled once by discover.sh)
property string tempPath: ""
property string batPath: ""
@@ -242,16 +258,36 @@ Singleton {
root.topProcs = out;
}
// Build topNetProcs from one run of topnet.sh: lines of
// "N <rx_bytes/s> <tx_bytes/s> <name>", already ranked by the script.
function _parseNetProcFrame(text) {
if (!text) return;
const lines = text.split("\n");
const out = [];
for (let i = 0; i < lines.length; i++) {
const p = lines[i].split(" ");
if (p[0] !== "N" || p.length < 4) continue;
out.push({
rx: Number(p[1]),
tx: Number(p[2]),
name: p.slice(3).join(" ")
});
}
root.topNetProcs = out;
}
function _tickOnce() {
const doFreq = root._tick % root.freqEveryTicks === 0;
statView.reload();
cpuInfoView.reload();
if (doFreq) cpuInfoView.reload();
memView.reload();
routeView.reload();
if (root.tempPath) tempView.reload();
if (root.batPath) { batCapView.reload(); batStatusView.reload(); }
_parseCpu();
_parseFreq();
if (doFreq) _parseFreq();
_parseMem();
_parseTemp();
_parseBattery();
@@ -343,6 +379,35 @@ Singleton {
else root.topProcs = [];
}
// ---- top network processes (opt-in, drawer-driven) --------------------
// Mirror of procScan: services/topnet.sh prints one ranked frame and exits;
// we re-run it every ~netProcInterval while netProcPollEnabled (the Network
// drawer is open). Each run sleeps netProcInterval between its two ss
// snapshots, so the bar pays nothing at rest.
property var _netFrame: [] // stdout lines accumulated for the current run
Process {
id: netScan
command: ["bash", Quickshell.shellPath("services/topnet.sh"),
String(root.netProcInterval), String(root.netProcCount)]
onStarted: root._netFrame = [];
onExited: {
root._parseNetProcFrame(root._netFrame.join("\n"));
if (root.netProcPollEnabled) // re-arm for the next frame
Qt.callLater(function () { netScan.running = true; });
}
stdout: SplitParser {
splitMarker: "\n"
onRead: line => { if (line) root._netFrame.push(line); }
}
}
onNetProcPollEnabledChanged: {
if (netProcPollEnabled) netScan.running = true;
else root.topNetProcs = [];
}
// Human-readable byte rate with kB as the smallest unit, e.g. "1.2M", "8K".
function fmtRate(bytes) {
const u = ["K", "M", "G"];

97
services/topnet.sh Executable file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Print one frame of the top network-consuming processes, then exit.
#
# Single-shot by design, exactly like topproc.sh: SysStats re-runs it every
# netProcInterval while the Network drawer is open, and exiting per frame
# guarantees stdout is flushed (a long-running loop would block-buffer its
# frame and the QML side would never see it). topproc.sh is the reference for
# the bash-script + single-shot + SplitParser-parse pattern.
#
# How per-process bandwidth is derived: `ss` reports, per TCP socket, the
# cumulative bytes_sent / bytes_received plus the owning pid (users:) and a
# stable socket inode (ino:). We take two snapshots netProcInterval apart, diff
# each socket's byte counters by inode, sum the positive deltas per process,
# and divide by the interval to get bytes/sec. Only TCP carries these counters,
# so UDP/QUIC traffic is not attributed — that is a kernel/ss limitation, not a
# bug here.
#
# IMPORTANT: needs `ss` (iproute2) on PATH — added to runtimeInputs in flake.nix
# alongside bash + coreutils. No awk/getconf (absent at runtime); bash builtins
# and coreutils (sort, head, sleep) only.
#
# Output: one "N <rx_bytes_per_sec> <tx_bytes_per_sec> <name>" line per process,
# highest combined throughput first.
intv="${1:-2}"
n="${2:-6}"
# Snapshot every TCP socket's cumulative rx/tx bytes into the rx/tx assoc
# arrays keyed by socket inode, and remember each inode's owning process in
# pidOf/nameOf. `ss -O` prints one socket per line so a single read loop works.
# -t TCP -i info(byte counters) -n numeric -H no header
# -O oneline -p process -e extended(ino:)
snapshot() {
local -n _rx="$1" _tx="$2" _pid="$3" _name="$4"
local line w ino bs br pid name tmp
set -f # no globbing when word-splitting $line below
while IFS= read -r line; do
ino=""; bs=0; br=0
# ino: / bytes_*: tokens never contain spaces, so word-splitting is safe
# even when a process name does (e.g. "Web Content").
for w in $line; do
case "$w" in
ino:*) ino="${w#ino:}" ;;
bytes_sent:*) bs="${w#bytes_sent:}" ;;
bytes_received:*) br="${w#bytes_received:}" ;;
esac
done
[ -n "$ino" ] || continue
_rx["$ino"]="$br"
_tx["$ino"]="$bs"
# name + pid from the users:(("name",pid=N,fd=M)) token; parse off the
# whole line so a space inside the quoted name can't break it.
tmp="${line#*users:((\"}"; name="${tmp%%\"*}"
tmp="${line#*pid=}"; pid="${tmp%%,*}"
_pid["$ino"]="$pid"
_name["$ino"]="$name"
done < <(ss -tinHOpe 2>/dev/null)
set +f
}
declare -A rx1 tx1 rx2 tx2 pidOf nameOf
snapshot rx1 tx1 pidOf nameOf
sleep "$intv"
snapshot rx2 tx2 pidOf nameOf
# Per-process byte deltas over the interval. A socket present in both snapshots
# contributes its counter delta; a socket new in the second snapshot contributes
# its full byte count (all of it landed within the window). Aggregate by pid.
declare -A prx ptx pname
for ino in "${!rx2[@]}"; do
pid="${pidOf[$ino]}"
[ -n "$pid" ] || continue
if [ -n "${rx1[$ino]:-}" ]; then
drx=$(( ${rx2[$ino]} - ${rx1[$ino]} ))
dtx=$(( ${tx2[$ino]} - ${tx1[$ino]} ))
else
drx="${rx2[$ino]}"
dtx="${tx2[$ino]}"
fi
[ "$drx" -gt 0 ] || drx=0
[ "$dtx" -gt 0 ] || dtx=0
prx["$pid"]=$(( ${prx[$pid]:-0} + drx ))
ptx["$pid"]=$(( ${ptx[$pid]:-0} + dtx ))
pname["$pid"]="${nameOf[$ino]}"
done
# Emit "<total/s> <rx/s> <tx/s> <name>" per process, rank by total throughput,
# take the top n, then reformat to the N-line protocol the QML side parses.
for pid in "${!prx[@]}"; do
r=$(( ${prx[$pid]} / intv ))
t=$(( ${ptx[$pid]} / intv ))
tot=$(( r + t ))
[ "$tot" -gt 0 ] || continue
printf '%d %d %d %s\n' "$tot" "$r" "$t" "${pname[$pid]}"
done | sort -rn | head -n "$n" | while read -r tot r t name; do
printf 'N %s %s %s\n' "$r" "$t" "$name"
done

View File

@@ -1,4 +1,7 @@
//@ pragma UseQApplication
// Required: Quickshell renders the system-tray context menu (right-click in
// Tray.qml -> item.display()) via QtWidgets' QMenu. Dropping this loads the
// lighter QGuiApplication but breaks tray menus. Verified 2026-06-08.
import Quickshell
import "widgets"

View File

@@ -29,7 +29,20 @@ PanelWindow {
left: true
right: true
}
implicitHeight: Theme.barHeight
// Wrap onto two rows when a single row can't hold everything (portrait
// outputs). Needed width is computed from implicit sizes, which don't
// depend on which row anything sits in, so this can't bind-loop.
readonly property real neededWidth:
(Theme.gap + 2) * 2 // outer margins
+ workspaces.implicitWidth
+ Theme.gap * 4 // breathing room between clusters
+ metricsRow.implicitWidth
+ Theme.gap
+ endRow.implicitWidth
readonly property bool twoRows: width > 0 && neededWidth > width
implicitHeight: twoRows ? Theme.barHeight * 2 : Theme.barHeight
// background strip
Rectangle {
@@ -37,24 +50,35 @@ PanelWindow {
color: Theme.barColor
}
// left: workspaces
// row 1 left: workspaces
Workspaces {
id: workspaces
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.gap + 2
y: (Theme.barHeight - height) / 2
screen: panel.modelData
}
// center: clock
Clock {
anchors.centerIn: parent
// row 1 right: clock, tray
RowLayout {
id: endRow
anchors.right: parent.right
anchors.rightMargin: Theme.gap + 2
y: (Theme.barHeight - height) / 2
spacing: Theme.gap
Clock {}
Tray { panelWindow: panel }
}
// right: battery, system metrics, volume, tray
// metrics: left of the clock in one-row mode, own second row when narrow
RowLayout {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: Theme.gap + 2
id: metricsRow
anchors.right: panel.twoRows ? parent.right : endRow.left
anchors.rightMargin: panel.twoRows ? Theme.gap + 2 : Theme.gap
y: panel.twoRows
? Theme.barHeight + (Theme.barHeight - height) / 2
: (Theme.barHeight - height) / 2
spacing: Theme.gap
Battery {}
@@ -64,6 +88,5 @@ PanelWindow {
Disk {}
Network {}
Volume {}
Tray { panelWindow: panel }
}
}

View File

@@ -1,15 +1,30 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import "../config"
import "../services"
// Down/up throughput on the active interface, with a wifi/ethernet icon.
// Clicking the pill toggles a drawer listing the top network-consuming
// processes, refreshed live (SysStats only scans sockets while the drawer is
// open). CpuGraph.qml is the reference for the drawer pattern.
Item {
id: root
implicitWidth: pill.implicitWidth
implicitHeight: pill.implicitHeight
Pill {
id: pill
anchors.fill: parent
spacing: Theme.spacing
Text {
text: SysStats.iface.startsWith("wl") ? Icons.wifi : Icons.ethernet
text: SysStats.iface === "" ? Icons.netOff
: SysStats.iface.startsWith("wl") ? Icons.wifi : Icons.ethernet
font.family: Theme.monoFont
font.pixelSize: Theme.fontSize + 1
color: Theme.sky
color: SysStats.iface === "" ? Theme.overlay : Theme.sky
Layout.alignment: Qt.AlignVCenter
}
@@ -41,3 +56,184 @@ Pill {
Layout.preferredWidth: rateMetrics.advanceWidth
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: drawer.visible = !drawer.visible
}
// ---- top network-processes drawer -------------------------------------
PopupWindow {
id: drawer
visible: false
color: "transparent"
implicitWidth: panel.implicitWidth
implicitHeight: panel.implicitHeight
// Hang below the pill, top-right corner aligned to the pill's
// bottom-right (same anchoring rationale as CpuGraph's drawer): anchor
// to the item so the rect is in the pill's own coordinates and tracks
// it wherever the RowLayout places it.
anchor {
item: root
rect.x: 0
rect.y: root.height + Theme.gap
rect.width: root.width
rect.height: 1
edges: Edges.Bottom | Edges.Right
gravity: Edges.Bottom | Edges.Left
}
// Only pay for the socket scan while the drawer is actually showing.
onVisibleChanged: SysStats.netProcPollEnabled = visible
Rectangle {
id: panel
readonly property int rowH: 22
implicitWidth: 340
// Fixed height (header + netProcCount rows): the panel must NOT
// resize when rows replace "sampling…", or the popup's layer surface
// shows a stale/blank buffer under Sway on first open (same class of
// bug as Bar.qml's geomKey remap). A constant size sidesteps it.
implicitHeight: col.implicitHeight + Theme.padding * 2
radius: Theme.radius
color: Theme.barColor
border.color: Theme.surface1
border.width: 1
// Largest combined rate in the current frame, used to scale the
// per-row background bar (network has no fixed ceiling like CPU%).
readonly property real maxRate: {
let m = 1;
const ps = SysStats.topNetProcs;
for (let i = 0; i < ps.length; i++)
m = Math.max(m, ps[i].rx + ps[i].tx);
return m;
}
ColumnLayout {
id: col
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.padding
spacing: 3
// header: icon + title + total throughput
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing
Text {
text: SysStats.iface === "" ? Icons.netOff
: SysStats.iface.startsWith("wl") ? Icons.wifi : Icons.ethernet
font.family: Theme.monoFont
font.pixelSize: Theme.fontSize + 1
color: SysStats.iface === "" ? Theme.overlay : Theme.sky
}
Text {
text: "Network by process"
font.family: Theme.font
font.pixelSize: Theme.fontSize
color: Theme.text
Layout.fillWidth: true
}
Text {
text: Icons.down + SysStats.fmtRate(SysStats.rxRate)
+ " " + Icons.up + SysStats.fmtRate(SysStats.txRate)
font.family: Theme.monoFont
font.pixelSize: Theme.fontSize - 3
color: Theme.subtext
}
}
Rectangle { // divider
Layout.fillWidth: true
Layout.topMargin: 2
Layout.bottomMargin: 2
implicitHeight: 1
color: Theme.surface1
}
// Fixed-height body so the panel stays one constant size whether
// it shows the placeholder or the rows (see panel.implicitHeight).
Item {
Layout.fillWidth: true
Layout.topMargin: 2
implicitHeight: SysStats.netProcCount * panel.rowH
Text {
anchors.centerIn: parent
visible: SysStats.topNetProcs.length === 0
text: "sampling…"
font.family: Theme.font
font.pixelSize: Theme.fontSize - 2
color: Theme.subtext
}
Column {
anchors.fill: parent
Repeater {
model: SysStats.topNetProcs
delegate: Item {
id: rowItem
required property var modelData
width: parent.width
height: panel.rowH
// throughput bar behind the text, scaled to the
// busiest process in the frame.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
height: parent.height - 4
radius: 3
width: parent.width *
Math.min(1, (rowItem.modelData.rx + rowItem.modelData.tx) / panel.maxRate)
color: Qt.rgba(Theme.sky.r, Theme.sky.g, Theme.sky.b, 0.16)
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
spacing: Theme.spacing
Text {
text: rowItem.modelData.name
font.family: Theme.font
font.pixelSize: Theme.fontSize - 1
color: Theme.text
elide: Text.ElideRight
Layout.fillWidth: true
}
Text {
text: Icons.down + " " + SysStats.fmtRate(rowItem.modelData.rx)
font.family: Theme.monoFont
font.pixelSize: Theme.fontSize - 2
color: Theme.green
horizontalAlignment: Text.AlignRight
Layout.preferredWidth: 62
}
Text {
text: Icons.up + " " + SysStats.fmtRate(rowItem.modelData.tx)
font.family: Theme.monoFont
font.pixelSize: Theme.fontSize - 2
color: Theme.peach
horizontalAlignment: Text.AlignRight
Layout.preferredWidth: 62
}
}
}
}
}
}
}
}
}
}

View File

@@ -17,11 +17,20 @@ MouseArea {
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton
// accumulate raw wheel deltas so high-res / inertial scrolling doesn't
// fire a full step per micro-event; one notch (120 units) == one step
property real _wheelAccum: 0
readonly property real _wheelStep: 0.02
onClicked: { if (audio) audio.muted = !audio.muted; }
onWheel: wheel => {
if (!audio) return;
_wheelAccum += wheel.angleDelta.y;
const notches = Math.trunc(_wheelAccum / 120);
if (notches === 0) return;
_wheelAccum -= notches * 120;
audio.volume = Math.max(0, Math.min(1,
audio.volume + (wheel.angleDelta.y > 0 ? 0.05 : -0.05)));
audio.volume + notches * _wheelStep));
}
// keep the sink's audio properties live