From 6b36b4f74251c26f8d7f19027cb00d0422c1773a Mon Sep 17 00:00:00 2001 From: Felitendo Date: Sun, 16 Aug 2026 03:10:23 +0200 Subject: [PATCH] feat: initial release Fixed volume steps for Bluetooth audio devices on PipeWire. Corrects the coarse internal volume grid of devices such as AirPods Pro (16 AVRCP steps, 6.25 % per swipe) to the desktop's own step size. The device grid is measured per device; the desktop step size is read from Plasma. English and German messages. --- .gitignore | 2 + LICENSE | 28 ++ Makefile | 21 ++ README.md | 159 +++++++++++ bt-volume-step | 475 +++++++++++++++++++++++++++++++++ systemd/bt-volume-step.service | 14 + tests/test_bt_volume_step.py | 142 ++++++++++ 7 files changed, 841 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100755 bt-volume-step create mode 100644 systemd/bt-volume-step.service create mode 100644 tests/test_bt_volume_step.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8b4ade9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, Felitendo + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8184e47 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +PREFIX ?= /usr +DESTDIR ?= + +BINDIR = $(DESTDIR)$(PREFIX)/bin +UNITDIR = $(DESTDIR)$(PREFIX)/lib/systemd/user + +.PHONY: install uninstall check + +install: + install -Dm755 bt-volume-step $(BINDIR)/bt-volume-step + install -Dm644 systemd/bt-volume-step.service \ + $(UNITDIR)/bt-volume-step.service + sed -i 's|^ExecStart=.*|ExecStart=$(PREFIX)/bin/bt-volume-step|' \ + $(UNITDIR)/bt-volume-step.service + +uninstall: + rm -f $(BINDIR)/bt-volume-step + rm -f $(UNITDIR)/bt-volume-step.service + +check: + python3 tests/test_bt_volume_step.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..eedb30f --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# bt-volume-step + +Fixed volume steps for Bluetooth audio devices on PipeWire. + +## The problem + +Many Bluetooth devices carry a coarse internal volume grid. AirPods Pro expose +only 16 AVRCP steps, so every swipe on the stem moves the system volume by +6.25 %, and the on-screen display walks through + +``` +6 · 13 · 19 · 25 · 31 · 38 · 44 · 50 · 56 · 63 · 69 · 75 · 81 · 88 · 94 · 100 +``` + +instead of clean multiples of five. A speaker's volume buttons do the same +thing with whatever grid that speaker happens to use. + +This is not a desktop misconfiguration — KDE's own volume step is already +exactly 5 %. The grid lives in the device firmware, and it cannot be changed: +the device sends *absolute* volume values over AVRCP, not increments. + +## What this does + +`bt-volume-step` watches for device-initiated volume changes, takes only their +*direction* into account, and applies a clean step of its own instead. One +press or swipe on the device becomes one step on your desktop's grid. + +It works because such devices adopt a volume written back over AVRCP silently, +without reporting it, so there is no feedback loop. As a side effect the +device's internal position follows the corrected value, which keeps it from +running into the end of its own scale. + +## Requirements + +- PipeWire with `pactl` (`libpulse`) +- Python 3.9 or newer +- Optional: `kreadconfig6` (KDE Plasma) to follow the desktop's own step size +- Optional: `bluez-utils` for device names in `--show` + +## Installation + +### Arch Linux + +```bash +yay -S bt-volume-step +``` + +### From source + +```bash +sudo make install +``` + +`make install` honours `PREFIX` and `DESTDIR`; for a per-user install use +`make install PREFIX=$HOME/.local`. + +## Usage + +Enable it for your user session: + +```bash +systemctl --user enable --now bt-volume-step +``` + +That is the whole setup. Connect a Bluetooth device and use its volume +control. + +## Calibration + +The daemon measures each device's grid on its own. **While a device's grid is +unknown it does not intervene at all** — it only watches. Once the same jump +has repeated three times, that jump is accepted as the device step and +remembered, and corrections start from then on. In practice: press volume-up +three or four times on a new device and it is set up. + +Show what has been measured: + +```bash +bt-volume-step --show +``` + +``` +file: /home/you/.local/state/bt-volume-step/devsteps.json + AirPods Pro (30_0E_43_04_52_19) = 6.25 % (16 steps) +``` + +Discard a measurement (all devices, or one MAC): + +```bash +bt-volume-step --reset +bt-volume-step --reset 30_0E_43_04_52_19 +``` + +To keep the daemon from calibrating itself onto keyboard input, jumps that +match the desktop's own step size are excluded from measurement, as are jumps +below 1.5 % or above 20 %. A device whose grid happens to equal the desktop +step therefore never calibrates, and is left alone. + +## Configuration + +On KDE Plasma the step size comes from *System Settings → Audio → Volume step* +(`plasmaparc [General] VolumeStep`, read through `kreadconfig6` so KDE's +configuration cascade applies). Changes take effect within two seconds, with +no restart. On other desktops, or without `kreadconfig6`, it falls back to +5 %. + +Everything can be overridden through the environment — put these in a drop-in +(`systemctl --user edit bt-volume-step`): + +| Variable | Effect | +|---|---| +| `BT_VOL_STEP` | fixed step size in percent; ignores the desktop setting | +| `BT_VOL_DEVSTEP` | fixed device grid in percent; skips measurement | +| `BT_VOL_MAC` | only watch this device (MAC with `_` instead of `:`) | +| `BT_VOL_MAX` | upper limit in percent (default 100) | + +The daemon handles several connected Bluetooth devices at once, keeping state +and calibration per device. + +## Localisation + +Messages follow `LC_ALL` / `LC_MESSAGES` / `LANG`. English and German are +included; other languages fall back to English. To add one, extend +`TRANSLATIONS` in the script — English strings are the keys. + +## Limitations + +**Foreign volume changes can be misread.** PipeWire events carry no +information about where a change came from, so a jump that happens to match +one or two device steps is indistinguishable from a button press. The daemon +accepts at most two steps per event to keep that window narrow, but it cannot +close it entirely. + +**Devices that report their volume back are not supported.** The approach +assumes a device accepts a written volume silently. One that echoes a snapped +value back would oscillate. To check, set a volume and watch it for a few +seconds: + +```bash +pactl set-sink-volume bluez_output.XX_XX_XX_XX_XX_XX.1 40% +sleep 5 && pactl get-sink-volume bluez_output.XX_XX_XX_XX_XX_XX.1 +``` + +If the value drifts on its own, this tool is the wrong approach; disabling +hardware volume (`bluez5.enable-hw-volume = false` in WirePlumber) is the +alternative, at the cost of the device's own control. + +## Tests + +```bash +make check +``` + +Covers the decision logic and the measurement, including simulated devices +with 8, 16 and 32 steps. + +## License + +BSD 3-Clause. See [LICENSE](LICENSE). diff --git a/bt-volume-step b/bt-volume-step new file mode 100755 index 0000000..a953f2c --- /dev/null +++ b/bt-volume-step @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Felitendo +"""bt-volume-step - fixed volume steps for Bluetooth audio devices. + +Many Bluetooth devices carry a coarse internal volume grid. AirPods Pro, for +example, expose only 16 AVRCP steps, so every swipe on the stem moves the +volume by 6.25 %: 6, 13, 19, 25, 31 ... Pressing volume-up on a speaker has +the same effect with whatever grid that speaker uses. + +This daemon watches for device-initiated volume changes, takes only their +*direction* into account and applies a clean step of its own instead. + +It works because such devices adopt a volume written over AVRCP silently, +without reporting it back, so there is no feedback loop. A device that does +report back would make the volume oscillate; check for that before use by +setting a volume and watching it for a few seconds. + +On KDE Plasma the step size is taken from the desktop's own setting (System +Settings > Audio > "Volume step", stored as plasmaparc [General] VolumeStep). +Changes there take effect immediately, without restarting the daemon. + +The *device* grid is measured per device: while it is unknown the daemon +keeps its hands off. Only once the same jump has repeated several times is it +accepted as the device step and remembered. +""" + +import argparse +import json +import math +import os +import subprocess +import sys +import time +from pathlib import Path + +__version__ = "1.0.0" + +# ---------------------------------------------------------------- i18n + +def _detect_language(): + for var in ("LC_ALL", "LC_MESSAGES", "LANG"): + value = os.environ.get(var) + if value: + return value.split(".")[0].split("_")[0].lower() + return "en" + + +LANG = _detect_language() + +# English strings are the keys. To add a language, add another table and +# extend TRANSLATIONS; untranslated strings fall back to English. +TRANSLATIONS = { + "de": { + "Fixed volume steps for Bluetooth audio devices.": + "Feste Lautstärkeschritte für Bluetooth-Audiogeräte.", + "show measured device steps and exit": + "gemessene Gerätestufen anzeigen und beenden", + "discard calibration (without MAC: all devices)": + "Kalibrierung verwerfen (ohne MAC: alle Geräte)", + "step size from Plasma": "Schrittweite aus Plasma", + "step size fixed at {step:g} %": "Schrittweite fest auf {step:g} %", + ", device step fixed at {devstep:g} %": + ", Gerätestufe fest auf {devstep:g} %", + "step size: {step:g} %": "Schrittweite: {step:g} %", + "cannot read the Plasma step size ({error}), using {fallback:g} %": + "Plasma-Schrittweite nicht lesbar ({error}), nutze {fallback:g} %", + "pactl list sinks failed: {error}": + "pactl list sinks fehlgeschlagen: {error}", + "cannot save the calibration: {error}": + "Kalibrierung nicht speicherbar: {error}", + "known device steps:": "bekannte Gerätestufen:", + "watching {device} at {volume:.1f} %": + "beobachte {device} bei {volume:.1f} %", + "{device}: measured device step = {step:.2f} % ({count} steps) - active from now on": + "{device}: Gerätestufe gemessen = {step:.2f} % ({count} Stufen) – ab jetzt aktiv", + "{device}: {last:.1f} % -> device reported {cur:.1f} % -> set {target:.1f} %": + "{device}: {last:.1f} % -> Gerät meldete {cur:.1f} % -> gesetzt {target:.1f} %", + "file: {path}": "Datei: {path}", + "no device step measured yet": "noch keine Gerätestufe gemessen", + "{step:g} % ({count} steps)": "{step:g} % ({count} Stufen)", + "all calibrations discarded": "alle Kalibrierungen verworfen", + "calibration for {device} discarded": + "Kalibrierung für {device} verworfen", + "no calibration for {mac}": "keine Kalibrierung für {mac}", + "restart the service: systemctl --user restart bt-volume-step": + "Dienst neu starten: systemctl --user restart bt-volume-step", + }, +} + +_TABLE = TRANSLATIONS.get(LANG, {}) + + +def _(text): + """Translate a string into the current language, English as fallback.""" + return _TABLE.get(text, text) + + +# ---------------------------------------------------------------- settings + +MAC_FILTER = os.environ.get("BT_VOL_MAC", "") +STEP_FIXED = os.environ.get("BT_VOL_STEP") +DEVSTEP_FORCED = os.environ.get("BT_VOL_DEVSTEP") +MAXV = float(os.environ.get("BT_VOL_MAX", "100")) + +NORM = 65536.0 # PA_VOLUME_NORM +NOISE = 1.0 # % below this: rounding noise, or our own write +# Higher values make foreign changes harder to tell apart: with a fine device +# grid (32 steps = 3.125 %) an ordinary 10 % jump already reads as three +# button presses. +MAXMULT = 2 # device steps a single event may combine +TOL_REL = 0.35 # share of the device step tolerated when matching +STEP_TTL = 2.0 # s the Plasma step size is cached for +STEP_FALLBACK = 5.0 # % Plasma's own default + +# --- measuring the device step --- +DEV_MIN = 1.5 # % below this the device is fine-grained; leave it alone +DEV_MAX = 20.0 # % above this it is not a volume button press +# 0.12 covers the spread seen in practice: on AirPods Pro the jumps ranged +# from 5.9 to 6.7 % around a step of 6.25 %. +CLUSTER_REL = 0.12 # relative tolerance for two samples to count as equal +NEED = 3 # matching samples required before locking the step +SAMPLE_CAP = 12 # older samples expire + +STORE = ( + Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state")) + / "bt-volume-step" / "devsteps.json" +) + +# C.UTF-8 rather than C: keeps pactl's messages English (we parse them) +# without Qt tools such as kreadconfig6 complaining about a non-UTF-8 locale. +ENV = {**os.environ, "LC_ALL": "C.UTF-8"} + +_step_cache = (0.0, None) # (expires_at, value); None = never read + + +def log(msg): + print(msg, file=sys.stderr, flush=True) + + +def half_up(x): + """Round half away from zero instead of Python's round-half-to-even. + + Otherwise snap(65, 10) would be 60 while snap(75, 10) is 80 - that is, + unpredictable for values sitting exactly between two grid points. + """ + return math.floor(x + 0.5) + + +# ---------------------------------------------------------------- Plasma + +def current_step(): + """The step size from Plasma, briefly cached. + + kreadconfig6 rather than parsing plasmaparc directly, so that KDE's + configuration cascade (/etc/xdg, locked-down settings) applies. On a + desktop without kreadconfig6 the fallback is Plasma's own default. + """ + global _step_cache + + if STEP_FIXED: + return float(STEP_FIXED) + + expires, value = _step_cache + now = time.monotonic() + if now < expires: + return value + + try: + out = subprocess.run( + ["kreadconfig6", "--file", "plasmaparc", "--group", "General", + "--key", "VolumeStep", "--default", str(STEP_FALLBACK)], + capture_output=True, text=True, env=ENV, timeout=5, + ).stdout.strip() + step = float(out) + if not 0 < step <= 100: + raise ValueError(f"implausible: {step}") + except (subprocess.SubprocessError, ValueError, OSError, FileNotFoundError) as exc: + log(_("cannot read the Plasma step size ({error}), using {fallback:g} %") + .format(error=exc, fallback=STEP_FALLBACK)) + step = STEP_FALLBACK + + if step != value: + log(_("step size: {step:g} %").format(step=step)) + _step_cache = (now + STEP_TTL, step) + return step + + +# ---------------------------------------------------------------- PipeWire + +def bt_sinks(): + """All Bluetooth outputs as (sink_name, mac, percent, description).""" + try: + out = subprocess.run( + ["pactl", "-f", "json", "list", "sinks"], + capture_output=True, text=True, env=ENV, timeout=5, + ).stdout + sinks = json.loads(out) + except (subprocess.SubprocessError, json.JSONDecodeError, OSError) as exc: + log(_("pactl list sinks failed: {error}").format(error=exc)) + return [] + + found = [] + for sink in sinks: + name = sink.get("name", "") + if not name.startswith("bluez_output."): + continue + parts = name.split(".") + if len(parts) < 2: + continue + mac = parts[1] + if MAC_FILTER and mac != MAC_FILTER: + continue + values = [ch["value"] for ch in sink.get("volume", {}).values()] + if not values: + continue + description = (sink.get("description") or "").strip() + found.append((name, mac, max(values) / NORM * 100.0, description)) + return found + + +def set_volume(name, pct): + raw = int(round(pct / 100.0 * NORM)) + subprocess.run(["pactl", "set-sink-volume", name, str(raw)], env=ENV, timeout=5) + + +# ---------------------------------------------------------------- device names + +def paired_names(): + """MAC (underscore form) -> name for every paired BlueZ device. + + Used by --show, which also lists devices that are not connected right now + and therefore have no sink to read a description from. + """ + try: + out = subprocess.run( + ["bluetoothctl", "devices"], + capture_output=True, text=True, env=ENV, timeout=5, + ).stdout + except (subprocess.SubprocessError, OSError): + return {} + + names = {} + for line in out.splitlines(): + parts = line.split(maxsplit=2) + if len(parts) == 3 and parts[0] == "Device": + names[parts[1].replace(":", "_")] = parts[2].strip() + return names + + +def label(mac, name=None): + """Human-readable device label: 'Name (MAC)', or just the MAC.""" + return f"{name} ({mac})" if name else mac + + +# ---------------------------------------------------------------- calibration + +def load_store(): + """Read the calibration store, tolerating the flat v1 format.""" + try: + raw = json.loads(STORE.read_text()) + except (OSError, json.JSONDecodeError): + return {} + + store = {} + for mac, value in raw.items(): + if isinstance(value, (int, float)): + store[mac] = {"step": float(value)} # v1: bare number + elif isinstance(value, dict) and "step" in value: + store[mac] = value + return store + + +def save_store(store): + try: + STORE.parent.mkdir(parents=True, exist_ok=True) + STORE.write_text(json.dumps(store, indent=2, sort_keys=True) + "\n") + except OSError as exc: + log(_("cannot save the calibration: {error}").format(error=exc)) + + +def calibrate(delta, samples, plasma_step): + """Record a sample; return the device step once it is certain, else None. + + A volume button press on the device always produces the same jump. Deltas + that match the desktop's own step size most likely come from the keyboard + and are skipped - otherwise the daemon would calibrate itself onto them. + """ + mag = abs(delta) + if not DEV_MIN <= mag <= DEV_MAX: + return None + if abs(mag - plasma_step) <= max(0.15, plasma_step * 0.02): + return None + + samples.append(mag) + del samples[:-SAMPLE_CAP] + + cluster = [s for s in samples if abs(s - mag) <= mag * CLUSTER_REL] + if len(cluster) < NEED: + return None + return sum(cluster) / len(cluster) + + +# ---------------------------------------------------------------- decision + +def snap(pct, step): + """Round to the nearest multiple of the step size, clamped to 0..MAXV.""" + return min(MAXV, max(0.0, half_up(pct / step) * step)) + + +def decide(last, cur, step, devstep): + """Target volume for an observed change last -> cur, or None. + + A button press on the device always moves the volume by a multiple of the + device step. Only then do we intervene and replace the movement with our + own grid; anything else is a foreign, absolute change and is accepted. + """ + delta = cur - last + if abs(delta) < NOISE: + return None + + mult = half_up(abs(delta) / devstep) + if 1 <= mult <= MAXMULT and abs(abs(delta) - mult * devstep) <= devstep * TOL_REL: + direction = 1 if delta > 0 else -1 + target = snap(last, step) + direction * mult * step + else: + target = snap(cur, step) + return min(MAXV, max(0.0, target)) + + +# ---------------------------------------------------------------- daemon + +def run(): + store = load_store() + + banner = (_("step size fixed at {step:g} %").format(step=float(STEP_FIXED)) + if STEP_FIXED else _("step size from Plasma")) + if DEVSTEP_FORCED: + banner += _(", device step fixed at {devstep:g} %").format( + devstep=float(DEVSTEP_FORCED)) + log(banner) + current_step() + + if store: + log(_("known device steps:") + " " + ", ".join( + f"{label(mac, entry.get('name'))} = {entry['step']:g} %" + for mac, entry in sorted(store.items()))) + + state = {} # mac -> {"last": float, "samples": [float]} + + proc = subprocess.Popen( + ["pactl", "subscribe"], + stdout=subprocess.PIPE, text=True, env=ENV, bufsize=1, + ) + + for line in proc.stdout: + if "on sink #" not in line: + continue + + plasma_step = current_step() + seen = set() + + for sink_name, mac, cur, description in bt_sinks(): + seen.add(mac) + entry = store.setdefault(mac, {}) + if description and entry.get("name") != description: + entry["name"] = description + if "step" in entry: + save_store(store) + device = label(mac, entry.get("name")) + + st = state.setdefault(mac, {"last": None, "samples": []}) + if st["last"] is None: + st["last"] = cur + log(_("watching {device} at {volume:.1f} %") + .format(device=device, volume=cur)) + continue + + devstep = float(DEVSTEP_FORCED) if DEVSTEP_FORCED else entry.get("step") + + if devstep is None: + # Not calibrated yet: let the change through, only measure. + delta = cur - st["last"] + if abs(delta) >= NOISE: + found = calibrate(delta, st["samples"], plasma_step) + if found: + entry["step"] = round(found, 3) + save_store(store) + log(_("{device}: measured device step = {step:.2f} % " + "({count} steps) - active from now on") + .format(device=device, step=found, + count=round(100 / found))) + st["last"] = cur + continue + + target = decide(st["last"], cur, plasma_step, devstep) + if target is None: + st["last"] = cur + continue + + if abs(target - cur) >= 0.05: + set_volume(sink_name, target) + log(_("{device}: {last:.1f} % -> device reported {cur:.1f} % " + "-> set {target:.1f} %") + .format(device=device, last=st["last"], cur=cur, target=target)) + st["last"] = target + + # Forget disconnected devices so they are re-read on reconnect. + for mac in set(state) - seen: + del state[mac] + + return proc.wait() + + +# ---------------------------------------------------------------- CLI + +def cmd_show(): + store = load_store() + names = paired_names() + print(_("file: {path}").format(path=STORE)) + if not store: + print(_("no device step measured yet")) + return 0 + for mac, entry in sorted(store.items()): + step = entry["step"] + name = entry.get("name") or names.get(mac) + print(f" {label(mac, name)} = " + + _("{step:g} % ({count} steps)").format( + step=step, count=round(100 / step))) + return 0 + + +def cmd_reset(target): + store = load_store() + if target == "*": + store = {} + print(_("all calibrations discarded")) + elif target in store: + name = store[target].get("name") + del store[target] + print(_("calibration for {device} discarded") + .format(device=label(target, name))) + else: + print(_("no calibration for {mac}").format(mac=target)) + return 1 + save_store(store) + print(_("restart the service: systemctl --user restart bt-volume-step")) + return 0 + + +def main(argv=None): + ap = argparse.ArgumentParser( + prog="bt-volume-step", + description=_("Fixed volume steps for Bluetooth audio devices."), + ) + ap.add_argument("--show", action="store_true", + help=_("show measured device steps and exit")) + ap.add_argument("--reset", metavar="MAC", nargs="?", const="*", + help=_("discard calibration (without MAC: all devices)")) + ap.add_argument("--version", action="version", + version=f"%(prog)s {__version__}") + args = ap.parse_args(argv) + + if args.show: + return cmd_show() + if args.reset: + return cmd_reset(args.reset) + return run() + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + pass diff --git a/systemd/bt-volume-step.service b/systemd/bt-volume-step.service new file mode 100644 index 0000000..bbd94b0 --- /dev/null +++ b/systemd/bt-volume-step.service @@ -0,0 +1,14 @@ +[Unit] +Description=Fixed volume steps for Bluetooth audio devices +Documentation=https://github.com/Felitendo/bt-volume-step +After=pipewire-pulse.service +Wants=pipewire-pulse.service + +[Service] +Type=simple +ExecStart=/usr/bin/bt-volume-step +Restart=always +RestartSec=3 + +[Install] +WantedBy=default.target diff --git a/tests/test_bt_volume_step.py b/tests/test_bt_volume_step.py new file mode 100644 index 0000000..eabce4d --- /dev/null +++ b/tests/test_bt_volume_step.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for the decision and measurement logic, without touching audio.""" + +from importlib.machinery import SourceFileLoader +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "bt-volume-step" +m = SourceFileLoader("btvs", str(SCRIPT)).load_module() + +AIRPODS = 6.25 # 16 steps +BOX32 = 3.125 # 32 steps +BOX8 = 12.5 # 8 steps + +fails = 0 + + +def check(ok, text): + global fails + if not ok: + fails += 1 + print(f"{'ok ' if ok else 'FAIL'} {text}") + + +# ---------------------------------------------------------------- decide() +# (description, step size, device step, last, cur, expected target) +CASES = [ + # --- AirPods, 5 % steps: deltas taken from a real capture + ("swipe up from 60", 5, AIRPODS, 60.0, 66.1, 65.0), + ("swipe up from 45", 5, AIRPODS, 45.0, 51.2, 50.0), + ("swipe down from 65", 5, AIRPODS, 65.0, 59.1, 60.0), + ("swipe down from 35", 5, AIRPODS, 35.0, 28.3, 30.0), + ("swipe from an off-grid value", 5, AIRPODS, 65.35, 59.1, 60.0), + # Keyboard: Plasma sets exactly +/-5 %, so target == cur, no double step + ("key up 55->60", 5, AIRPODS, 55.0, 60.0, 60.0), + ("key down 60->55", 5, AIRPODS, 60.0, 55.0, 55.0), + # Foreign absolute changes must pass through + ("foreign set 60->50", 5, AIRPODS, 60.0, 50.0, 50.0), + ("foreign set 60->20", 5, AIRPODS, 60.0, 20.0, 20.0), + # Ambiguous: -13 % is indistinguishable from two swipes (-12.5 %) + ("foreign jump 60->47 (ambiguous)", 5, AIRPODS, 60.0, 47.0, 50.0), + # Noise / our own echo + ("noise 65->65.35", 5, AIRPODS, 65.0, 65.35, None), + ("no change", 5, AIRPODS, 60.0, 60.0, None), + # Two fast swipes coalesced into one event + ("double swipe up", 5, AIRPODS, 60.0, 72.5, 70.0), + ("double swipe down", 5, AIRPODS, 60.0, 47.5, 50.0), + # Limits + ("swipe up at maximum", 5, AIRPODS, 100.0, 100.0, None), + ("swipe up near maximum", 5, AIRPODS, 96.9, 100.0, 100.0), + ("swipe down at minimum", 5, AIRPODS, 0.0, 0.0, None), + ("swipe down near zero", 5, AIRPODS, 3.1, 0.0, 0.0), + + # --- other desktop step sizes + ("10%: swipe up from 60", 10, AIRPODS, 60.0, 66.1, 70.0), + ("10%: swipe down from 60", 10, AIRPODS, 60.0, 53.5, 50.0), + ("10%: swipe from off-grid", 10, AIRPODS, 65.0, 71.1, 80.0), + ("10%: key up 60->70", 10, AIRPODS, 60.0, 70.0, 70.0), + # 61 sits exactly between; half_up locks onto 62, one step down -> 60 + ("2%: swipe down from 61", 2, AIRPODS, 61.0, 54.8, 60.0), + ("2%: swipe up from 60", 2, AIRPODS, 60.0, 66.1, 62.0), + + # --- speaker with 32 steps (3.125 %) + ("32-step: key up from 60", 5, BOX32, 60.0, 63.1, 65.0), + ("32-step: key down from 60", 5, BOX32, 60.0, 56.9, 55.0), + ("32-step: double press up", 5, BOX32, 60.0, 66.3, 70.0), + # A 10 % foreign jump is no multiple of 3.125 within MAXMULT -> pass through + ("32-step: foreign jump 60->50", 5, BOX32, 60.0, 50.0, 50.0), + + # --- speaker with 8 steps (12.5 %) + ("8-step: key up from 50", 5, BOX8, 50.0, 62.5, 55.0), + ("8-step: key down from 50", 5, BOX8, 50.0, 37.5, 45.0), +] + +for desc, step, devstep, last, cur, want in CASES: + got = m.decide(last, cur, float(step), devstep) + ok = (got is None and want is None) or ( + got is not None and want is not None and abs(got - want) < 0.01 + ) + check(ok, f"{desc:34s} {last:6.2f} -> {cur:6.2f} want {want} got {got}") + +print() + + +# ---------------------------------------------------------------- calibrate() + +def feed(deltas, plasma_step=5.0): + """Feed samples one by one; return the first locked-in device step.""" + samples = [] + for d in deltas: + found = m.calibrate(d, samples, plasma_step) + if found: + return found + return None + + +got = feed([6.1, -6.4, 6.2]) +check(got is not None and abs(got - 6.25) < 0.3, + f"AirPods spread calibrates -> {got}") + +got = feed([5.9, 6.7, 6.1, 6.4]) +check(got is not None and abs(got - 6.25) < 0.4, + f"wider spread calibrates -> {got}") + +got = feed([3.1, 3.2, 3.1]) +check(got is not None and abs(got - 3.125) < 0.2, + f"32-step speaker calibrates -> {got}") + +got = feed([5.0, 5.0, 5.0, 5.0, 5.0]) +check(got is None, f"keyboard deltas (= desktop step) do NOT calibrate -> {got}") + +got = feed([10.0, 10.0, 10.0], plasma_step=10.0) +check(got is None, f"keyboard at a 10 % desktop step does NOT calibrate -> {got}") + +got = feed([0.8, 0.79, 0.8, 0.8]) +check(got is None, f"fine-grained device (<1.5 %) is ignored -> {got}") + +got = feed([25.0, 25.0, 25.0]) +check(got is None, f"jumps that are too large (>20 %) are ignored -> {got}") + +got = feed([6.2, 13.0, 2.0, 6.1, 19.0, 6.3]) +check(got is not None and abs(got - 6.25) < 0.4, + f"measurement finds the cluster despite noise -> {got}") + +got = feed([6.2, 6.2]) +check(got is None, f"two samples are not enough yet -> {got}") + +print() + + +# ---------------------------------------------------------------- misc + +check(m.label("AA_BB", "Speaker") == "Speaker (AA_BB)", "label with name") +check(m.label("AA_BB", None) == "AA_BB", "label without name") +check(m.half_up(6.5) == 7 and m.half_up(7.5) == 8, "half_up rounds consistently") +check(abs(m.snap(65, 10) - 70) < 0.01 and abs(m.snap(75, 10) - 80) < 0.01, + "snap is consistent for exact midpoints") + +print() +total = len(CASES) + 9 + 4 +print(f"{total - fails}/{total} passed") +raise SystemExit(1 if fails else 0)