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/home/spotify-duck.py b/home/spotify-duck.py new file mode 100644 index 0000000..2e0d5f8 --- /dev/null +++ b/home/spotify-duck.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Voice-activity ducking: lower Spotify while you or others speak on Discord. + +Design (see home/spotify-ducking.nix for the wiring): + + * "Someone else is speaking" is measured by tapping the MONITOR of Discord's + (Vesktop's) own playback stream via `pw-record --target `. That + captures ONLY Vesktop's output, so Spotify's own audio can never leak into + the meter and cause a feedback duck. + * "You are speaking" is measured by tapping your denoised mic (rnnoise_source) + the same way -- but only counts while you're actually in a voice call, which + we detect by Vesktop holding an open capture (Stream/Input/Audio) stream. + So talking near your mic outside a call won't touch the music. + * When either crosses its threshold we ride ONLY Spotify's own stream volume + down to DUCK_LEVEL and back up after RELEASE_MS of silence. Nothing else on + the system is affected, and we never reroute audio, so switching output + devices (earbuds, headset, HDMI) needs no special handling. + +Everything is discovered dynamically from `pw-dump`, so it survives Discord and +Spotify restarts, leaving/rejoining calls, and node-id churn. All tuning is via +environment variables (set in the systemd unit). +""" + +import json +import math +import os +import signal +import struct +import subprocess +import threading +import time + +# ---- tunables (overridable from the environment) -------------------------- +RATE = 16000 # meter sample rate +FRAME_BYTES = int(RATE * 0.05) * 2 # 50 ms of s16 mono +DUCK_LEVEL = float(os.environ.get("DUCK_LEVEL", "0.2")) # ducked = base * this +MIC_TH = float(os.environ.get("MIC_THRESHOLD", "0.02")) # you-speaking RMS gate +DISC_TH = float(os.environ.get("DISC_THRESHOLD", "0.012")) # others-speaking gate +RELEASE = float(os.environ.get("RELEASE_MS", "700")) / 1000.0 # silence hold +MIC_TARGET = os.environ.get("MIC_TARGET", "rnnoise_source") # mic node.name +DISCORD_APP = os.environ.get("DISCORD_APP", "vesktop") # application.name +SPOTIFY_MATCH = os.environ.get("SPOTIFY_MATCH", "spotify").lower() +POLL = float(os.environ.get("POLL_SEC", "1.0")) # graph-discovery period + +PW_RECORD = "pw-record" +PW_DUMP = "pw-dump" +WPCTL = "wpctl" + + +def rms(buf): + n = len(buf) // 2 + if n == 0: + return 0.0 + s = struct.unpack("<%dh" % n, buf[: n * 2]) + return math.sqrt(sum(x * x for x in s) / n) / 32768.0 + + +class Meter(threading.Thread): + """Continuously reports the RMS level of one PipeWire node's monitor. + + `.target` is a node name or object.serial to capture, or None to pause. + Restarts its `pw-record` automatically when the target changes or the + captured stream goes away (e.g. Discord closes it on call end). + """ + + def __init__(self): + super().__init__(daemon=True) + self.level = 0.0 + self._target = None + self._cur = None + self._proc = None + self._lock = threading.Lock() + + def set_target(self, t): + with self._lock: + self._target = t + + def _start(self, tgt): + self._proc = subprocess.Popen( + [PW_RECORD, "--target", str(tgt), "--rate", str(RATE), + "--channels", "1", "--format", "s16", "--latency", "50ms", "-"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + ) + self._cur = tgt + + def _stop(self): + if self._proc: + self._proc.terminate() + try: + self._proc.wait(1) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc, self._cur = None, None + + def run(self): + while True: + with self._lock: + tgt = self._target + if tgt is None: + self._stop() + self.level = 0.0 + time.sleep(0.1) + continue + if tgt != self._cur: + self._stop() + self._start(tgt) + buf = b"" + while len(buf) < FRAME_BYTES: + chunk = self._proc.stdout.read(FRAME_BYTES - len(buf)) + if not chunk: + break + buf += chunk + if not buf: # stream ended -> respawn next loop + self._stop() + self.level = 0.0 + time.sleep(0.1) + continue + self.level = rms(buf) + + +# shared graph state, refreshed by the discovery thread +G = {"discord_serial": None, "in_call": False, "spotify_id": None} + + +def discover(): + while True: + try: + dump = json.loads(subprocess.check_output([PW_DUMP])) + except Exception: + time.sleep(POLL) + continue + dser = incall = spid = None + incall = False + for o in dump: + if o.get("type") != "PipeWire:Interface:Node": + continue + p = (o.get("info") or {}).get("props") or {} + mc = p.get("media.class", "") + app = (p.get("application.name") or "") + binn = (p.get("application.process.binary") or "").lower() + nn = (p.get("node.name") or "").lower() + if mc == "Stream/Output/Audio" and app == DISCORD_APP: + dser = p.get("object.serial") + elif mc == "Stream/Input/Audio" and app == DISCORD_APP: + incall = True + elif mc == "Stream/Output/Audio" and ( + SPOTIFY_MATCH in app.lower() or SPOTIFY_MATCH in binn + or SPOTIFY_MATCH in nn + ): + spid = o["id"] + G.update(discord_serial=dser, in_call=incall, spotify_id=spid) + time.sleep(POLL) + + +def get_vol(nid): + try: + out = subprocess.check_output( + [WPCTL, "get-volume", str(nid)], stderr=subprocess.DEVNULL + ).decode() + return float(out.split()[1]) # "Volume: 0.42 [MUTED]" -> 0.42 + except Exception: + return None + + +def set_vol(nid, v): + v = max(0.0, min(1.5, v)) + subprocess.run([WPCTL, "set-volume", str(nid), "%.3f" % v], + stderr=subprocess.DEVNULL, check=False) + + +# ducking state, shared so the SIGTERM handler can un-duck on shutdown. +# `base` is your genuine chosen volume; it is ONLY ever sampled while un-ducked +# and settled (see the control loop), so our own ducked writes can never feed +# back into it and ratchet the volume toward zero. +S = {"ducked": False, "base": 1.0, "spid": None} +_slock = threading.Lock() +SETTLE = 0.5 # seconds to let a wpctl write propagate before trusting a read + + +def restore_and_exit(*_): + with _slock: + if S["ducked"] and S["spid"] is not None: + set_vol(S["spid"], S["base"]) + os._exit(0) + + +def main(): + signal.signal(signal.SIGTERM, restore_and_exit) + signal.signal(signal.SIGINT, restore_and_exit) + + mic, disc = Meter(), Meter() + mic.start() + disc.start() + threading.Thread(target=discover, daemon=True).start() + + last_voice = 0.0 + known_spid = None # Spotify node we've already learned the base volume of + base_deadline = 0.0 # don't sample base again until monotonic() past this + while True: + in_call = G["in_call"] + spid = G["spotify_id"] + # only meter while in a call -> zero idle CPU otherwise + mic.set_target(MIC_TARGET if in_call else None) + disc.set_target(G["discord_serial"] if in_call else None) + + now = time.monotonic() + if in_call and (mic.level > MIC_TH or disc.level > DISC_TH): + last_voice = now + want_duck = in_call and (now - last_voice) < RELEASE + + with _slock: + S["spid"] = spid + if spid is None: + S["ducked"] = False # nothing to control + known_spid = None + else: + if spid != known_spid: # new Spotify stream: learn its volume + known_spid = spid + v = get_vol(spid) + if v is not None: + S["base"] = v + S["ducked"] = False + base_deadline = now + SETTLE + if want_duck and not S["ducked"]: + set_vol(spid, S["base"] * DUCK_LEVEL) + S["ducked"] = True + base_deadline = now + SETTLE + elif not want_duck and S["ducked"]: + set_vol(spid, S["base"]) + S["ducked"] = False + base_deadline = now + SETTLE + elif not S["ducked"] and now >= base_deadline: + # un-ducked and our last write has settled: this reading is + # your real volume, so adopt it (picks up manual changes). + v = get_vol(spid) + if v is not None: + S["base"] = v + base_deadline = now + SETTLE + + time.sleep(0.03) + + +if __name__ == "__main__": + main() diff --git a/home/spotify-ducking.nix b/home/spotify-ducking.nix new file mode 100644 index 0000000..d239af0 --- /dev/null +++ b/home/spotify-ducking.nix @@ -0,0 +1,53 @@ +{ pkgs, ... }: + +# Voice-activity ducking: while you OR someone else is speaking in a Discord +# (Vesktop) voice call, Spotify's volume is lowered; it returns to normal a +# beat after everyone goes quiet. Nothing else on the system is touched. +# +# The daemon (./spotify-duck.py) meters Discord's own output stream and your +# denoised mic (rnnoise_source, from modules/noise-suppression.nix) directly via +# `pw-record`, so it never reroutes audio and is independent of which output +# device is active. See that file's header for the full rationale. Tune the +# behaviour with the Environment entries below and `systemctl --user restart +# spotify-duck` (no rebuild needed to experiment; make it permanent here after). + +let + spotify-duck = pkgs.writeShellApplication { + name = "spotify-duck"; + # pw-record/pw-dump live in pipewire; wpctl in wireplumber; python3 to run it. + runtimeInputs = [ pkgs.python3 pkgs.pipewire pkgs.wireplumber ]; + text = ''exec python3 ${./spotify-duck.py} "$@"''; + }; +in +{ + systemd.user.services.spotify-duck = { + Unit = { + Description = "Duck Spotify while speaking on Discord (voice-activity)"; + After = [ "pipewire.service" "wireplumber.service" ]; + }; + + Service = { + ExecStart = "${spotify-duck}/bin/spotify-duck"; + Restart = "on-failure"; + RestartSec = 3; + # Tuning knobs — override and `systemctl --user restart spotify-duck`. + Environment = [ + "DUCK_LEVEL=0.35" # ducked volume = your current volume * this (35%) + "MIC_THRESHOLD=0.004" # you-speaking RMS gate (measured: speech 0.004-0.015, + # silence <0.0025 on rnnoise_source). Lower toward + # 0.003 if soft speech is missed; raise if it dips + # randomly. + "DISC_THRESHOLD=0.008" # others-speaking RMS gate on Discord's output + "RELEASE_MS=900" # restore this long after the last speech + "MIC_TARGET=rnnoise_source" # mic node; the denoised source you use in Discord + "DISCORD_APP=vesktop" # application.name of the Discord client + "SPOTIFY_MATCH=spotify" # substring identifying Spotify's stream + ]; + }; + + # default.target (not graphical-session.target) so it reliably starts on + # login regardless of how the Hyprland session activates targets; the daemon + # tolerates PipeWire not being up yet and self-heals. + Install.WantedBy = [ "default.target" ]; + }; +} diff --git a/home/zephrynis.nix b/home/zephrynis.nix index 74f2c75..96dc92e 100644 --- a/home/zephrynis.nix +++ b/home/zephrynis.nix @@ -71,6 +71,7 @@ in imports = [ inputs.illogical-flake.homeManagerModules.default inputs.spicetify-nix.homeManagerModules.spicetify + ./spotify-ducking.nix ]; home.username = "zephrynis";