mirror of
https://github.com/zephrynis/nix-flake.git
synced 2026-08-18 05:55:53 +00:00
Compare commits
16 Commits
b65ad592f2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e224f7ea5b | |||
| 72fe33250d | |||
| 3a53f5bf89 | |||
| 12ad9d83d5 | |||
| 89d463e2cb | |||
| fc2c8b9cf8 | |||
| eed3db9f05 | |||
| fde84f60ff | |||
| 05a6d6cc69 | |||
| ff8196fc77 | |||
| 91053f9693 | |||
| 4b3983d895 | |||
| 3d8fec08e5 | |||
| 3a2145c076 | |||
| b57b5fea7c | |||
| b90e17b6a6 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
265
flake.lock
generated
Normal file
265
flake.lock
generated
Normal file
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"nodes": {
|
||||
"dotfiles": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1778966846,
|
||||
"narHash": "sha256-N7HUWD3dliyPDXA5qXhuma5segnnopgPo2nv5iFaeXc=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "c0706258b1ff22c70f6c0e51c4b812e63703f620",
|
||||
"revCount": 6244,
|
||||
"submodules": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/end-4/dots-hyprland"
|
||||
},
|
||||
"original": {
|
||||
"submodules": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/end-4/dots-hyprland"
|
||||
}
|
||||
},
|
||||
"firefox-addons": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1784402804,
|
||||
"narHash": "sha256-NKjHErNuyO2x+YBUNOJy/8uKXfuMX60SLFuvZiMYdA8=",
|
||||
"owner": "rycee",
|
||||
"repo": "nur-expressions",
|
||||
"rev": "775bd8e526823ab58efe5d1fc269f2a60c3de2c1",
|
||||
"type": "gitlab"
|
||||
},
|
||||
"original": {
|
||||
"owner": "rycee",
|
||||
"repo": "nur-expressions",
|
||||
"type": "gitlab"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"illogical-flake",
|
||||
"nur",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1733312601,
|
||||
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"google-sans-flex": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1763459024,
|
||||
"narHash": "sha256-HMAS0L/Tsqyl1xI16cyIzg9LEb6Dyq91JY4wqFQV9Vs=",
|
||||
"owner": "end-4",
|
||||
"repo": "google-sans-flex",
|
||||
"rev": "251aa5abd30496368f634e54ce2a508fe5a2fdfa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "end-4",
|
||||
"repo": "google-sans-flex",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1784407317,
|
||||
"narHash": "sha256-iZrxHToDJWnvt+5LGAtvuQMTy1NZYlNEKbKaVtBJNcc=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "39411a8e12a5526d992e65bc7e3dc9a4414d6713",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"hyprland-preview-share-picker": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782052593,
|
||||
"narHash": "sha256-XE6RD/4Mhw/ZRBj0v94kLOERElat5V+e/X0L9eUGf7M=",
|
||||
"ref": "refs/heads/master",
|
||||
"rev": "e2f30ff85486e557018523da45ccbc846e3a499c",
|
||||
"revCount": 55,
|
||||
"submodules": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/WhySoBad/hyprland-preview-share-picker"
|
||||
},
|
||||
"original": {
|
||||
"submodules": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/WhySoBad/hyprland-preview-share-picker"
|
||||
}
|
||||
},
|
||||
"illogical-flake": {
|
||||
"inputs": {
|
||||
"dotfiles": "dotfiles",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"nur": "nur",
|
||||
"quickshell": "quickshell"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1779068758,
|
||||
"narHash": "sha256-7osZ24TmUVDWZDxQ8Bi/P6GYiqTWw5azNWaVd+GfohY=",
|
||||
"owner": "soymou",
|
||||
"repo": "illogical-flake",
|
||||
"rev": "8629ef302b956cf6ab0089338a867dbe844156fd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "soymou",
|
||||
"repo": "illogical-flake",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-flatpak": {
|
||||
"locked": {
|
||||
"lastModified": 1783368811,
|
||||
"narHash": "sha256-0H8jDwR4Kegb3heaTrH1ftbgKfZVDT8JE+46uXxDy/Q=",
|
||||
"owner": "gmodena",
|
||||
"repo": "nix-flatpak",
|
||||
"rev": "20d42f0ee98c9fe9f85e8d1de474f1409ed10d05",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "gmodena",
|
||||
"repo": "nix-flatpak",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1784796856,
|
||||
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nur": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": [
|
||||
"illogical-flake",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1779066378,
|
||||
"narHash": "sha256-GqKhLVZTmZa68mLlCat57m54aeYPhrNAPVTLd2pGKYk=",
|
||||
"owner": "nix-community",
|
||||
"repo": "NUR",
|
||||
"rev": "ab477ab3a75a85a68a80d9d26a572f9de9742808",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "NUR",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"quickshell": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"illogical-flake",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778488696,
|
||||
"narHash": "sha256-QSWgYuZUCNUJ/cxmaq83WkcT7lHQDDfsPVgH+96kIl0=",
|
||||
"ref": "refs/heads/master",
|
||||
"rev": "7d1c9a9c6721606b129829134d6f614f015621e2",
|
||||
"revCount": 818,
|
||||
"type": "git",
|
||||
"url": "https://git.outfoxxed.me/outfoxxed/quickshell"
|
||||
},
|
||||
"original": {
|
||||
"type": "git",
|
||||
"url": "https://git.outfoxxed.me/outfoxxed/quickshell"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"firefox-addons": "firefox-addons",
|
||||
"google-sans-flex": "google-sans-flex",
|
||||
"home-manager": "home-manager",
|
||||
"hyprland-preview-share-picker": "hyprland-preview-share-picker",
|
||||
"illogical-flake": "illogical-flake",
|
||||
"nix-flatpak": "nix-flatpak",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"spicetify-nix": "spicetify-nix"
|
||||
}
|
||||
},
|
||||
"spicetify-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1785048222,
|
||||
"narHash": "sha256-LjFVfwxensz76SjnvofF1Jsw+xTLd8Qp31W0J6nh2XI=",
|
||||
"owner": "Gerg-L",
|
||||
"repo": "spicetify-nix",
|
||||
"rev": "17e524f330c282d31c32dfe076222d4a12277886",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "Gerg-L",
|
||||
"repo": "spicetify-nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
36
flake.nix
36
flake.nix
@@ -17,6 +17,42 @@
|
||||
# `dotfiles` input (this is the only source-override mechanism):
|
||||
# inputs.dotfiles.url = "git+https://github.com/end-4/dots-hyprland?submodules=1&rev=<sha>";
|
||||
};
|
||||
|
||||
# GTK4 screenshare picker with live window/monitor previews — replaces the
|
||||
# bare Qt hyprland-share-picker bundled with XDPH. Needs submodules.
|
||||
hyprland-preview-share-picker = {
|
||||
url = "git+https://github.com/WhySoBad/hyprland-preview-share-picker?submodules=1";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Declarative Firefox extensions (rycee's firefox-addons, the set behind
|
||||
# nur.repos.rycee.firefox-addons — avoids importing all of NUR). Imported
|
||||
# as a source tree via callPackage rather than its flake output so the
|
||||
# addons evaluate under the system nixpkgs (needed for allowUnfree, e.g.
|
||||
# the 1Password extension).
|
||||
firefox-addons = {
|
||||
url = "gitlab:rycee/nur-expressions";
|
||||
flake = false;
|
||||
};
|
||||
|
||||
# Main UI font of the current end-4 dots. Their installer clones this repo
|
||||
# directly (the font is Google-proprietary, so no distro packages it).
|
||||
google-sans-flex = {
|
||||
url = "github:end-4/google-sans-flex";
|
||||
flake = false;
|
||||
};
|
||||
|
||||
# Declarative Spicetify (Spotify client mods). The home-manager module
|
||||
# installs its own wrapped Spotify — pkgs.spotify must NOT be added anywhere.
|
||||
spicetify-nix = {
|
||||
url = "github:Gerg-L/spicetify-nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# Declarative Flatpak app/remote management (extends services.flatpak). Used
|
||||
# for the Minecraft Bedrock launcher, whose Flatpak build tracks newer,
|
||||
# pairip-protected Bedrock releases ahead of the nixpkgs package.
|
||||
nix-flatpak.url = "github:gmodena/nix-flatpak";
|
||||
};
|
||||
|
||||
outputs = { nixpkgs, home-manager, ... }@inputs: {
|
||||
|
||||
244
home/spotify-duck.py
Normal file
244
home/spotify-duck.py
Normal file
@@ -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 <serial>`. 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()
|
||||
53
home/spotify-ducking.nix
Normal file
53
home/spotify-ducking.nix
Normal file
@@ -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" ];
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,78 @@
|
||||
{ inputs, pkgs, ... }:
|
||||
{ config, inputs, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
share-picker = inputs.hyprland-preview-share-picker.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
# Substitutes the current wallpaper palette (matugen's colors.json) into the
|
||||
# picker stylesheet template on every launch, so the picker re-themes with
|
||||
# the wallpaper, then hands off to the real picker binary.
|
||||
share-picker-themed = pkgs.writeShellScript "share-picker-themed" ''
|
||||
dir="$HOME/.config/hyprland-preview-share-picker"
|
||||
colors="$HOME/.local/state/quickshell/user/generated/colors.json"
|
||||
if [ -f "$colors" ]; then
|
||||
${pkgs.jq}/bin/jq -r 'to_entries[] | "s|@\(.key)@|\(.value)|g"' "$colors" \
|
||||
| ${pkgs.gnused}/bin/sed -f - "$dir/style.css.in" > "$dir/generated.css" || true
|
||||
else
|
||||
cp -f "$dir/style.css.in" "$dir/generated.css"
|
||||
fi
|
||||
exec ${share-picker}/bin/hyprland-preview-share-picker "$@"
|
||||
'';
|
||||
|
||||
# Icon-only derivation: copies the real Discord logo (scalable SVG) out of the
|
||||
# discord package under the theme name `discord`, so the relabeled Vesktop
|
||||
# launcher entry below shows the genuine Discord logo. The output is the raw
|
||||
# SVG bytes with no store references, so `discord` itself stays OUT of the
|
||||
# runtime closure — it's fetched only at build time and freed by GC.
|
||||
discord-icon = pkgs.runCommand "discord-icon" { } ''
|
||||
install -Dm644 \
|
||||
"${pkgs.discord}/opt/Discord/modules/discord_desktop_core/app/images/discord.svg" \
|
||||
"$out/share/icons/hicolor/scalable/apps/discord.svg"
|
||||
'';
|
||||
|
||||
firefoxBin = "${config.programs.firefox.finalPackage}/bin/firefox";
|
||||
|
||||
# Redirects an app's outgoing links into the Firefox "work" profile, reusing
|
||||
# the same --name/WM class as the firefox-work desktop entry so links land in
|
||||
# the existing Work window. Linux has no per-app "use browser Y" mapping, so
|
||||
# instead we launch the app (below) with this bin dir first on PATH and
|
||||
# $BROWSER pointed here — covering both ways an app opens a link:
|
||||
# * `firefox-work` — the value we set for $BROWSER
|
||||
# * `xdg-open` — a shim that sends only http/https to the work profile
|
||||
# and delegates every other URI to the real xdg-open
|
||||
# (Apps that call the org.freedesktop.portal.OpenURI portal directly bypass
|
||||
# both and still use the global default — that can't be overridden per-app.)
|
||||
work-browser = pkgs.symlinkJoin {
|
||||
name = "firefox-work-browser-shim";
|
||||
paths = [
|
||||
(pkgs.writeShellScriptBin "firefox-work" ''
|
||||
exec ${firefoxBin} -P work --name firefox-work "$@"
|
||||
'')
|
||||
(pkgs.writeShellScriptBin "xdg-open" ''
|
||||
case "$1" in
|
||||
http://*|https://*)
|
||||
exec ${firefoxBin} -P work --name firefox-work "$@" ;;
|
||||
*)
|
||||
exec ${pkgs.xdg-utils}/bin/xdg-open "$@" ;;
|
||||
esac
|
||||
'')
|
||||
];
|
||||
};
|
||||
|
||||
# Wraps an app binary so its links open in the Firefox work profile.
|
||||
openLinksInWork = name: exe: pkgs.writeShellScriptBin name ''
|
||||
export PATH=${work-browser}/bin:$PATH
|
||||
export BROWSER=firefox-work
|
||||
exec ${exe} "$@"
|
||||
'';
|
||||
|
||||
slack-work-links = openLinksInWork "slack-work-links" "${pkgs.slack}/bin/slack";
|
||||
obsidian-work-links = openLinksInWork "obsidian-work-links" "${pkgs.obsidian}/bin/obsidian";
|
||||
in
|
||||
{
|
||||
imports = [ inputs.illogical-flake.homeManagerModules.default ];
|
||||
imports = [
|
||||
inputs.illogical-flake.homeManagerModules.default
|
||||
inputs.spicetify-nix.homeManagerModules.spicetify
|
||||
./spotify-ducking.nix
|
||||
];
|
||||
|
||||
home.username = "zephrynis";
|
||||
home.homeDirectory = "/home/zephrynis";
|
||||
@@ -19,12 +90,388 @@
|
||||
# hyprland.plugins = [ ]; # listOf package, loaded via hyprland's plugin mechanism
|
||||
};
|
||||
|
||||
# illogical-flake wipes and recopies ~/.config/hypr on every switch, so the
|
||||
# UK layout override must be re-appended after its copy step each time.
|
||||
# custom/general.lua is sourced after hyprland/general.lua (kb_layout = "us").
|
||||
home.activation.ukKeyboardLayout = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
|
||||
hyprCustomGeneral="$HOME/.config/hypr/custom/general.lua"
|
||||
if [ -f "$hyprCustomGeneral" ] && ! grep -q 'kb_layout = "gb"' "$hyprCustomGeneral"; then
|
||||
cat >> "$hyprCustomGeneral" << 'EOF'
|
||||
|
||||
-- UK keyboard layout (appended by nix-flake, overrides hyprland/general.lua)
|
||||
hl.config({ input = { kb_layout = "gb" } })
|
||||
EOF
|
||||
echo "Appended UK keyboard layout to hypr/custom/general.lua"
|
||||
fi
|
||||
'';
|
||||
|
||||
# Suppress the Hyprland update news popup (option lives under ecosystem
|
||||
# since Hyprland 0.46); re-appended after every recopy like the layout.
|
||||
home.activation.hyprlandNoUpdateNews = lib.hm.dag.entryAfter [ "ukKeyboardLayout" ] ''
|
||||
hyprCustomGeneral="$HOME/.config/hypr/custom/general.lua"
|
||||
if [ -f "$hyprCustomGeneral" ] && ! grep -q 'no_update_news' "$hyprCustomGeneral"; then
|
||||
cat >> "$hyprCustomGeneral" << 'EOF'
|
||||
|
||||
-- Suppress update news popup (appended by nix-flake)
|
||||
hl.config({ ecosystem = { no_update_news = true } })
|
||||
EOF
|
||||
echo "Appended no_update_news to hypr/custom/general.lua"
|
||||
fi
|
||||
'';
|
||||
|
||||
# Monitor layout (captured from nwg-displays output, 2026-07-18): AOC
|
||||
# CQ27G2S at 165Hz on the left, 4K TV at 1.5x scale to its right.
|
||||
# Overrides the mode=preferred/position=auto catch-all in hyprland/general.lua.
|
||||
home.activation.hyprlandMonitorLayout = lib.hm.dag.entryAfter [ "hyprlandNoUpdateNews" ] ''
|
||||
hyprCustomGeneral="$HOME/.config/hypr/custom/general.lua"
|
||||
if [ -f "$hyprCustomGeneral" ] && ! grep -q 'hl.monitor' "$hyprCustomGeneral"; then
|
||||
cat >> "$hyprCustomGeneral" << 'EOF'
|
||||
|
||||
-- Monitor layout (appended by nix-flake, from nwg-displays 2026-07-18)
|
||||
hl.monitor({ output = "DP-2", mode = "2560x1440@165.0", position = "0x0", scale = "1" })
|
||||
hl.monitor({ output = "DP-1", mode = "3840x2160@59.98", position = "2560x678", scale = "1.5" })
|
||||
|
||||
-- Workspace 1 stays on the 4K TV; cursor starts on the 165Hz AOC
|
||||
hl.workspace_rule({ workspace = "1", monitor = "DP-1", default = true })
|
||||
hl.on("hyprland.start", function ()
|
||||
hl.dispatch(hl.dsp.focus({ monitor = "DP-2" }))
|
||||
hl.dispatch(hl.dsp.cursor.move({ x = 1280, y = 720 }))
|
||||
end)
|
||||
EOF
|
||||
echo "Appended monitor layout to hypr/custom/general.lua"
|
||||
fi
|
||||
'';
|
||||
|
||||
# Tailscale tray icon in the bar's systray (daemon + package are system-side
|
||||
# in modules/tailscale.nix). custom/execs.lua is the dots' intended autostart
|
||||
# hook, but it's wiped and recopied every switch like the rest of ~/.config/hypr.
|
||||
home.activation.trayscaleAutostart = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
|
||||
hyprCustomExecs="$HOME/.config/hypr/custom/execs.lua"
|
||||
if [ -f "$hyprCustomExecs" ] && ! grep -q 'trayscale' "$hyprCustomExecs"; then
|
||||
cat >> "$hyprCustomExecs" << 'EOF'
|
||||
|
||||
-- Tailscale tray icon (appended by nix-flake)
|
||||
hl.on("hyprland.start", function ()
|
||||
hl.exec_cmd("trayscale --hide-window")
|
||||
end)
|
||||
EOF
|
||||
echo "Appended trayscale autostart to hypr/custom/execs.lua"
|
||||
fi
|
||||
'';
|
||||
|
||||
# illogical-flake's generated custom/env.lua forces QT_QPA_PLATFORMTHEME=qt6ct
|
||||
# (its workaround for plasma-integration not being installed). desktop.nix now
|
||||
# installs plasma-integration + Darkly, so restore upstream's "kde" — that
|
||||
# applies the matugen-generated kdeglobals (Material You colors, Darkly style)
|
||||
# to Qt apps like Prism Launcher. Appended after the regeneration each switch;
|
||||
# later hl.env calls win.
|
||||
home.activation.qtPlatformThemeKde = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
|
||||
hyprCustomEnv="$HOME/.config/hypr/custom/env.lua"
|
||||
if [ -f "$hyprCustomEnv" ] && ! grep -q 'kde-platform-theme' "$hyprCustomEnv"; then
|
||||
cat >> "$hyprCustomEnv" << 'EOF'
|
||||
|
||||
-- kde-platform-theme: restore upstream platform theme (appended by nix-flake);
|
||||
-- plasma-integration + Darkly are installed system-side (desktop.nix)
|
||||
hl.env("QT_QPA_PLATFORMTHEME", "kde")
|
||||
EOF
|
||||
echo "Restored QT_QPA_PLATFORMTHEME=kde in hypr/custom/env.lua"
|
||||
fi
|
||||
'';
|
||||
|
||||
# Same override for non-Hyprland entry points (illogical-flake's environment.nix
|
||||
# sets this to qt6ct)
|
||||
home.sessionVariables.QT_QPA_PLATFORMTHEME = lib.mkForce "kde";
|
||||
|
||||
# Super+W opens Firefox instead of Chrome. The dots' hyprland/keybinds.lua
|
||||
# binds it to $browser, which launch_first_available.sh resolves to the first
|
||||
# installed of google-chrome-stable, zen, firefox, ... — so with Chrome
|
||||
# installed it lands on Chrome. custom/keybinds.lua is sourced after the
|
||||
# default, so unbind + rebind wins; re-appended after every recopy.
|
||||
# Super+Shift+W is the "variant" pairing (Shift = variant throughout the dots)
|
||||
# for the Firefox work profile, matching the firefox-work shim/desktop entry.
|
||||
home.activation.browserKeybindFirefox = lib.hm.dag.entryAfter [ "hyprlandMonitorLayout" ] ''
|
||||
hyprCustomKeybinds="$HOME/.config/hypr/custom/keybinds.lua"
|
||||
if [ -f "$hyprCustomKeybinds" ] && ! grep -q 'App: Firefox' "$hyprCustomKeybinds"; then
|
||||
cat >> "$hyprCustomKeybinds" << 'EOF'
|
||||
|
||||
-- Super+W -> Firefox (appended by nix-flake, overrides the dots' browser bind)
|
||||
hl.unbind("SUPER + W")
|
||||
hl.bind("SUPER + W", hl.dsp.exec_cmd("firefox"), { description = "App: Firefox" })
|
||||
-- Super+Shift+W -> Firefox work profile (matches the firefox-work shim/WM class)
|
||||
hl.bind("SUPER + SHIFT + W", hl.dsp.exec_cmd("firefox -P work --name firefox-work"), { description = "App: Firefox (Work)" })
|
||||
EOF
|
||||
echo "Rebound Super+W to Firefox and Super+Shift+W to Firefox (Work) in hypr/custom/keybinds.lua"
|
||||
fi
|
||||
'';
|
||||
|
||||
# The illogical-flake copy step deletes/recreates ~/.config/hypr while
|
||||
# Hyprland is running; its mid-copy reload fails ("cannot open hyprland.lua")
|
||||
# and the error banner sticks. Reload once the configs are back in place.
|
||||
home.activation.reloadHyprland = lib.hm.dag.entryAfter [ "browserKeybindFirefox" ] ''
|
||||
for instance in /run/user/$(id -u)/hypr/*/; do
|
||||
[ -d "$instance" ] || continue
|
||||
HYPRLAND_INSTANCE_SIGNATURE="$(basename "$instance")" \
|
||||
/run/current-system/sw/bin/hyprctl reload >/dev/null 2>&1 || true
|
||||
done
|
||||
'';
|
||||
|
||||
# Disable the desktop background clock widget. config.json is only copied on
|
||||
# first install (user edits persist), so this mainly matters for fresh
|
||||
# installs; the guard keeps later manual edits to other keys untouched.
|
||||
home.activation.disableBackgroundClock = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
|
||||
iiConfig="$HOME/.config/illogical-impulse/config.json"
|
||||
if [ -f "$iiConfig" ] && [ "$(${pkgs.jq}/bin/jq '.background.widgets.clock.enable' "$iiConfig")" != "false" ]; then
|
||||
${pkgs.jq}/bin/jq '.background.widgets.clock.enable = false' "$iiConfig" > "$iiConfig.tmp" \
|
||||
&& mv "$iiConfig.tmp" "$iiConfig"
|
||||
echo "Disabled background clock widget in illogical-impulse config.json"
|
||||
fi
|
||||
'';
|
||||
|
||||
# The dots swap terminal copy/interrupt conventions: foot binds Ctrl+C to
|
||||
# clipboard-copy and moves SIGINT (\x03) to Ctrl+Shift+C; kitty maps Ctrl+C
|
||||
# to copy_or_interrupt (copies whenever text is selected); foot also puts
|
||||
# paste on Ctrl+V. Restore standard behavior — Ctrl+C interrupts, copy/paste
|
||||
# on Ctrl+Shift+C/V — re-applied after every recopy like the hypr overrides.
|
||||
home.activation.footCtrlC = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
|
||||
footIni="$HOME/.config/foot/foot.ini"
|
||||
if [ -f "$footIni" ] && grep -q '^clipboard-copy=Control+c$' "$footIni"; then
|
||||
${pkgs.gnused}/bin/sed -i \
|
||||
-e 's/^clipboard-copy=Control+c$/clipboard-copy=Control+Shift+c/' \
|
||||
-e 's/^clipboard-paste=Control+v$/clipboard-paste=Control+Shift+v/' \
|
||||
-e '/^\\x03=Control+Shift+c$/d' \
|
||||
"$footIni"
|
||||
echo "Restored default Ctrl+C/Ctrl+V in foot.ini"
|
||||
fi
|
||||
'';
|
||||
|
||||
home.activation.kittyCtrlC = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
|
||||
kittyConf="$HOME/.config/kitty/kitty.conf"
|
||||
if [ -f "$kittyConf" ] && ! grep -q 'Restore default Ctrl+C' "$kittyConf"; then
|
||||
cat >> "$kittyConf" << 'EOF'
|
||||
|
||||
# Restore default Ctrl+C (appended by nix-flake); copy is Ctrl+Shift+C
|
||||
map ctrl+c
|
||||
EOF
|
||||
echo "Unmapped Ctrl+C in kitty.conf"
|
||||
fi
|
||||
'';
|
||||
|
||||
# Point xdg-desktop-portal-hyprland at the preview share picker. xdph.conf
|
||||
# lives in ~/.config/hypr, which the recopy wipes, so recreate it each switch.
|
||||
home.activation.xdphSharePicker = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
|
||||
cat > "$HOME/.config/hypr/xdph.conf" << 'EOF'
|
||||
# Written by nix-flake (recreated every switch; the dots wipe this dir)
|
||||
screencopy {
|
||||
custom_picker_binary = ${share-picker-themed}
|
||||
}
|
||||
EOF
|
||||
'';
|
||||
|
||||
# GTK apps decide light/dark from this key (via the settings portal). The
|
||||
# dots' Arch installer sets it imperatively; the flake port never does, so
|
||||
# GTK apps came up light while the shell rendered dark.
|
||||
dconf.settings."org/gnome/desktop/interface".color-scheme = "prefer-dark";
|
||||
|
||||
# Share picker: single-click selection and Material-You styling. The colors
|
||||
# reference GTK named colors from ~/.config/gtk-4.0/gtk.css, which matugen
|
||||
# regenerates from the wallpaper — so the picker re-themes automatically.
|
||||
# (This dir isn't shipped by the dots, so plain xdg.configFile works.)
|
||||
xdg.configFile."hyprland-preview-share-picker/config.yaml".text = ''
|
||||
stylesheets:
|
||||
- generated.css
|
||||
windows:
|
||||
clicks: 1
|
||||
outputs:
|
||||
clicks: 1
|
||||
'';
|
||||
# Template for the wrapper above: @token@ placeholders are replaced with hex
|
||||
# values from matugen's colors.json (Material tokens, current light/dark set).
|
||||
xdg.configFile."hyprland-preview-share-picker/style.css.in".text = ''
|
||||
.window {
|
||||
background-color: @surface@;
|
||||
color: @on_surface@;
|
||||
border: 1px solid @outline_variant@;
|
||||
border-radius: 24px;
|
||||
padding: 10px;
|
||||
font-family: "Google Sans Flex", "Rubik", sans-serif;
|
||||
font-size: 15px;
|
||||
}
|
||||
.page,
|
||||
.notebook,
|
||||
.notebook stack {
|
||||
background: transparent;
|
||||
}
|
||||
.notebook header {
|
||||
background: transparent;
|
||||
border: none;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.notebook header tab {
|
||||
border-radius: 9999px;
|
||||
padding: 6px 18px;
|
||||
margin: 4px 3px;
|
||||
background: transparent;
|
||||
color: @on_surface_variant@;
|
||||
transition: background-color 150ms ease;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.notebook header tab:hover {
|
||||
background-color: @surface_container_high@;
|
||||
}
|
||||
.notebook header tab:checked {
|
||||
background-color: @primary_container@;
|
||||
color: @on_primary_container@;
|
||||
}
|
||||
.card {
|
||||
background-color: @surface_container@;
|
||||
color: @on_surface@;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 18px;
|
||||
padding: 6px;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
.card:hover {
|
||||
background-color: @surface_container_high@;
|
||||
border-color: @primary@;
|
||||
}
|
||||
.card-loading {
|
||||
background-color: @surface_container@;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.image {
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
.image-label {
|
||||
color: @on_surface_variant@;
|
||||
font-size: 0.92em;
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* The outputs view wraps each preview card in a classless GtkButton whose
|
||||
stock background shows as a white matte — neutralize it and let the
|
||||
inner .card carry the visuals. */
|
||||
.page button:not(.region-button) {
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
}
|
||||
.page button:not(.region-button):hover,
|
||||
.page button:not(.region-button):active {
|
||||
background: none;
|
||||
}
|
||||
/* `background` shorthand, not background-color: the theme's
|
||||
background-image gradient must be reset too. */
|
||||
.region-button {
|
||||
background: @primary_container@;
|
||||
color: @on_primary_container@;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
padding: 14px 32px;
|
||||
font-size: 1.1em;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.region-button:hover {
|
||||
background: @secondary_container@;
|
||||
}
|
||||
.restore-button {
|
||||
background-color: @surface_container@;
|
||||
color: @on_surface_variant@;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
padding: 6px 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.restore-button check {
|
||||
background-color: @surface_container_highest@;
|
||||
border: 1px solid @outline@;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.restore-button check:checked {
|
||||
background-color: @primary@;
|
||||
color: @on_primary@;
|
||||
border-color: @primary@;
|
||||
}
|
||||
'';
|
||||
|
||||
# The illogical-flake fish module generates its own config.fish, dropping the
|
||||
# dots' `set fish_greeting` — so the "Welcome to fish" banner came back.
|
||||
programs.fish.interactiveShellInit = "set -g fish_greeting";
|
||||
|
||||
programs.git = {
|
||||
enable = true;
|
||||
userName = "zephrynis";
|
||||
userEmail = "zephrynis.yt@gmail.com";
|
||||
};
|
||||
|
||||
# The dots run `hyprctl setcursor Bibata-Modern-Classic 24` but don't install
|
||||
# the theme (an assumed system dependency on Arch)
|
||||
home.pointerCursor = {
|
||||
name = "Bibata-Modern-Classic";
|
||||
package = pkgs.bibata-cursors;
|
||||
size = 24;
|
||||
gtk.enable = true;
|
||||
x11.enable = true;
|
||||
};
|
||||
|
||||
|
||||
# Two profiles: personal (default, purple) and work (blue). Firefox has no
|
||||
# native per-profile accent, so each profile tints its toolbars via
|
||||
# userChrome.css. Launch work with `firefox -P work` (desktop entry below).
|
||||
programs.firefox = {
|
||||
enable = true;
|
||||
profiles =
|
||||
let
|
||||
addons = pkgs.callPackage "${inputs.firefox-addons}/pkgs/firefox-addons" {
|
||||
# Same wiring as the upstream flake's overlay, but against the system
|
||||
# pkgs so nixpkgs.config (allowUnfree, for 1Password) applies
|
||||
buildMozillaXpiAddon =
|
||||
(import "${inputs.firefox-addons}/lib/mozilla.nix" { inherit lib; })
|
||||
.mkBuildMozillaXpiAddon { inherit (pkgs) fetchurl stdenv; };
|
||||
};
|
||||
settings = {
|
||||
# Load userChrome.css
|
||||
"toolkit.legacyUserProfileCustomizations.stylesheets" = true;
|
||||
# Auto-enable the declaratively installed extensions
|
||||
"extensions.autoDisableScopes" = 0;
|
||||
};
|
||||
accent = color: ''
|
||||
#navigator-toolbox {
|
||||
background-color: ${color} !important;
|
||||
color: #f4f2fa !important;
|
||||
}
|
||||
'';
|
||||
in
|
||||
{
|
||||
personal = {
|
||||
id = 0;
|
||||
isDefault = true;
|
||||
inherit settings;
|
||||
extensions.packages = [ addons.ublock-origin addons.proton-pass ];
|
||||
userChrome = accent "#45256e";
|
||||
};
|
||||
work = {
|
||||
id = 1;
|
||||
inherit settings;
|
||||
extensions.packages = [ addons.ublock-origin addons.onepassword-password-manager ];
|
||||
userChrome = accent "#1e3a6e";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Launcher for the work profile; plain `firefox` opens personal (the default)
|
||||
xdg.desktopEntries.firefox-work = {
|
||||
name = "Firefox (Work)";
|
||||
genericName = "Web Browser";
|
||||
exec = "firefox -P work --name firefox-work %U";
|
||||
icon = "firefox";
|
||||
terminal = false;
|
||||
categories = [ "Network" "WebBrowser" ];
|
||||
};
|
||||
|
||||
programs.direnv = {
|
||||
enable = true;
|
||||
nix-direnv.enable = true;
|
||||
@@ -32,7 +479,109 @@
|
||||
|
||||
programs.vscode.enable = true;
|
||||
|
||||
# Spotify + Spicetify. The module installs its own wrapped Spotify, so
|
||||
# pkgs.spotify is intentionally absent from home.packages. spicyLyrics is a
|
||||
# first-class packaged extension (word-by-word karaoke lyrics view).
|
||||
programs.spicetify =
|
||||
let
|
||||
spicePkgs = inputs.spicetify-nix.legacyPackages.${pkgs.stdenv.hostPlatform.system};
|
||||
in
|
||||
{
|
||||
enable = true;
|
||||
enabledExtensions = with spicePkgs.extensions; [
|
||||
spicyLyrics
|
||||
];
|
||||
};
|
||||
|
||||
# File manager; gvfs/udisks2 backends are enabled system-side in desktop.nix.
|
||||
# xdg-open and apps use this for directories. The claude-cli handler was
|
||||
# registered imperatively by claude-code — kept here since HM now owns
|
||||
# mimeapps.list.
|
||||
# Relabel Vesktop's launcher entry to "Discord" with the real Discord logo.
|
||||
# HM writes ~/.local/share/applications/vesktop.desktop, which shadows the
|
||||
# package's own copy (user data dir wins in XDG_DATA_DIRS). Exec stays
|
||||
# `vesktop`; StartupWMClass stays Vesktop so window matching still works.
|
||||
xdg.desktopEntries.vesktop = {
|
||||
name = "Discord";
|
||||
genericName = "Internet Messenger";
|
||||
exec = "vesktop %U";
|
||||
icon = "discord";
|
||||
categories = [ "Network" "InstantMessaging" "Chat" ];
|
||||
mimeType = [ "x-scheme-handler/discord" ];
|
||||
type = "Application";
|
||||
settings = {
|
||||
Keywords = "discord;vencord;electron;chat;";
|
||||
StartupWMClass = "Vesktop";
|
||||
};
|
||||
};
|
||||
|
||||
# Route Slack's and Obsidian's outgoing links into the Firefox work profile
|
||||
# by shadowing their package .desktop files (user data dir wins in
|
||||
# XDG_DATA_DIRS) with copies whose Exec points at the openLinksInWork wrapper.
|
||||
# Every other field is kept identical to the upstream entry.
|
||||
xdg.desktopEntries.slack = {
|
||||
name = "Slack";
|
||||
genericName = "Slack Client for Linux";
|
||||
comment = "Slack Desktop";
|
||||
exec = "${slack-work-links}/bin/slack-work-links -s %U";
|
||||
icon = "slack";
|
||||
type = "Application";
|
||||
startupNotify = true;
|
||||
categories = [ "GNOME" "GTK" "Network" "InstantMessaging" ];
|
||||
mimeType = [ "x-scheme-handler/slack" ];
|
||||
settings.StartupWMClass = "Slack";
|
||||
};
|
||||
xdg.desktopEntries.obsidian = {
|
||||
name = "Obsidian";
|
||||
comment = "Knowledge base";
|
||||
exec = "${obsidian-work-links}/bin/obsidian-work-links %u";
|
||||
icon = "obsidian";
|
||||
type = "Application";
|
||||
categories = [ "Office" ];
|
||||
mimeType = [ "x-scheme-handler/obsidian" ];
|
||||
};
|
||||
|
||||
xdg.mimeApps = {
|
||||
enable = true;
|
||||
defaultApplications = {
|
||||
"inode/directory" = "org.gnome.Nautilus.desktop";
|
||||
"x-scheme-handler/claude-cli" = "claude-code-url-handler.desktop";
|
||||
# Keep Firefox (personal profile) as the browser for all links. Pinned
|
||||
# explicitly so google-chrome — installed below as an occasional-use app —
|
||||
# can never register itself as the default link handler (HM owns
|
||||
# mimeapps.list as a read-only symlink, so Chrome's first-run "make
|
||||
# default" prompt physically can't rewrite these).
|
||||
"x-scheme-handler/http" = "firefox.desktop";
|
||||
"x-scheme-handler/https" = "firefox.desktop";
|
||||
"x-scheme-handler/about" = "firefox.desktop";
|
||||
"x-scheme-handler/unknown" = "firefox.desktop";
|
||||
"text/html" = "firefox.desktop";
|
||||
};
|
||||
};
|
||||
|
||||
home.packages = with pkgs; [
|
||||
# As a plain package, not programs.gh — the HM module symlinks a read-only
|
||||
# config.yml into the store, which breaks `gh auth login`'s first-run write.
|
||||
gh
|
||||
# sshm: TUI to manage and connect to SSH hosts (reads/writes ~/.ssh/config)
|
||||
sshm
|
||||
nautilus
|
||||
# Vesktop: Discord+Vencord with a real Wayland/PipeWire screenshare — a
|
||||
# sane WebRTC bitrate (the official `discord` client starved it into
|
||||
# macroblocks) and it streams desktop audio, which official Linux Discord
|
||||
# can't. Uses the same xdg-desktop-portal-hyprland picker wired up above.
|
||||
# Relabeled to "Discord" with the real Discord logo via the desktop-entry
|
||||
# override + discord-icon below.
|
||||
vesktop
|
||||
discord-icon
|
||||
slack
|
||||
# Obsidian: Markdown knowledge base / note-taking (unfree; allowUnfree
|
||||
# already enabled for the other proprietary apps above).
|
||||
obsidian
|
||||
# Chrome: occasional-use only. Firefox stays the default link handler —
|
||||
# the xdg.mimeApps http/https pins above keep Chrome from grabbing links.
|
||||
google-chrome
|
||||
claude-code
|
||||
ripgrep
|
||||
fd
|
||||
fzf
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
../../modules/desktop.nix
|
||||
../../modules/nvidia.nix
|
||||
../../modules/gaming.nix
|
||||
../../modules/tailscale.nix
|
||||
../../modules/noise-suppression.nix
|
||||
];
|
||||
|
||||
networking.hostName = "nixos-pc";
|
||||
@@ -31,8 +33,25 @@
|
||||
};
|
||||
programs.fish.enable = true;
|
||||
|
||||
# 1Password desktop app + CLI. The module (vs a plain package) sets up the
|
||||
# polkit policy for system-auth unlock and the setgid onepassword group the
|
||||
# browser integration needs.
|
||||
programs._1password-gui = {
|
||||
enable = true;
|
||||
polkitPolicyOwners = [ "zephrynis" ];
|
||||
};
|
||||
programs._1password.enable = true;
|
||||
# 1Password verifies the browser binary's name before connecting the
|
||||
# extension to the app; NixOS's wrapped Firefox isn't on its allowlist
|
||||
environment.etc."1password/custom_allowed_browsers" = {
|
||||
text = ".firefox-wrapped";
|
||||
mode = "0755";
|
||||
};
|
||||
|
||||
time.timeZone = "Europe/London";
|
||||
i18n.defaultLocale = "en_GB.UTF-8";
|
||||
console.keyMap = "uk"; # TTYs
|
||||
services.xserver.xkb.layout = "gb"; # SDDM greeter / X11; Hyprland is set in home/zephrynis.nix
|
||||
|
||||
# Set to the NixOS release of the ISO used at install time; never change afterwards.
|
||||
system.stateVersion = "26.05";
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
|
||||
networking.networkmanager.enable = true; # required by illogical-flake
|
||||
|
||||
# Firmware blobs for Realtek RTL8821CU WiFi/Bluetooth dongle (and other devices)
|
||||
hardware.enableRedistributableFirmware = true;
|
||||
|
||||
hardware.bluetooth = {
|
||||
enable = true;
|
||||
powerOnBoot = true;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{ pkgs, ... }:
|
||||
{ inputs, pkgs, ... }:
|
||||
|
||||
{
|
||||
# System-side requirements of illogical-flake (the HM module does the rest)
|
||||
programs.hyprland.enable = true;
|
||||
programs.hyprland = {
|
||||
enable = true;
|
||||
withUWSM = false; # UWSM session black-screens (NVIDIA env vars + non-UWSM-aware dots)
|
||||
};
|
||||
services.geoclue2.enable = true; # QtPositioning — weather / night light widgets
|
||||
|
||||
services.displayManager.sddm = {
|
||||
@@ -10,8 +13,29 @@
|
||||
wayland.enable = true;
|
||||
};
|
||||
|
||||
# Boot straight into Hyprland; the illogical-impulse lock screen acts as the
|
||||
# login page (lock.launchOnStartup in ~/.config/illogical-impulse/config.json)
|
||||
services.displayManager.autoLogin = {
|
||||
enable = true;
|
||||
user = "zephrynis";
|
||||
};
|
||||
services.displayManager.defaultSession = "hyprland";
|
||||
|
||||
xdg.portal.extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; # file pickers etc.
|
||||
|
||||
# Qt app theming (Prism Launcher, protonup-qt, …), matching upstream Arch:
|
||||
# the dots set QT_QPA_PLATFORMTHEME=kde and write wallpaper Material You
|
||||
# colors + widgetStyle=Darkly into ~/.config/kdeglobals, assuming
|
||||
# plasma-integration and Darkly are system-installed (like Bibata). The
|
||||
# illogical-flake port overrides the platform theme to qt6ct in
|
||||
# hypr/custom/env.lua — countered in home/zephrynis.nix. No qt.style here:
|
||||
# QT_STYLE_OVERRIDE would beat kdeglobals' widgetStyle.
|
||||
qt = {
|
||||
enable = true;
|
||||
platformTheme = "kde";
|
||||
};
|
||||
environment.systemPackages = [ pkgs.darkly ]; # Qt6 only; no qt5 variant in nixpkgs
|
||||
|
||||
security.polkit.enable = true;
|
||||
programs.dconf.enable = true;
|
||||
services.gvfs.enable = true;
|
||||
@@ -19,6 +43,16 @@
|
||||
services.upower.enable = true;
|
||||
|
||||
fonts.packages = with pkgs; [
|
||||
# Fonts the current dots actually use (config.json appearance.fonts):
|
||||
# main/title/numbers = Google Sans Flex, reading = Readex Pro,
|
||||
# expressive = Space Grotesk. Without these everything silently falls
|
||||
# back to DejaVu Sans.
|
||||
(runCommand "google-sans-flex-font" { } ''
|
||||
install -Dm644 ${inputs.google-sans-flex}/*.ttf -t $out/share/fonts/truetype
|
||||
'')
|
||||
readexpro
|
||||
(google-fonts.override { fonts = [ "SpaceGrotesk" ]; })
|
||||
|
||||
# named by the illogical-flake README
|
||||
rubik
|
||||
nerd-fonts.ubuntu
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{ pkgs, ... }:
|
||||
{ inputs, pkgs, ... }:
|
||||
|
||||
{
|
||||
imports = [ inputs.nix-flatpak.nixosModules.nix-flatpak ];
|
||||
|
||||
programs.steam = {
|
||||
enable = true;
|
||||
remotePlay.openFirewall = true;
|
||||
@@ -14,6 +16,48 @@
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
mangohud
|
||||
prismlauncher
|
||||
protonup-qt
|
||||
];
|
||||
|
||||
# Minecraft Bedrock via mcpelauncher (unofficial; runs the Android ARM build
|
||||
# through a libc shim — you must own Bedrock on Google Play). Installed as a
|
||||
# Flatpak (io.mrarm.mcpelauncher) rather than the nixpkgs package, whose
|
||||
# launcher lagged the newer, pairip-protected Bedrock releases. nix-flatpak
|
||||
# adds the Flathub remote (its default) and installs the app on activation.
|
||||
services.flatpak = {
|
||||
enable = true;
|
||||
packages = [ "io.mrarm.mcpelauncher" ];
|
||||
};
|
||||
|
||||
# Some devices expose a control interface that udev misdetects as a joystick,
|
||||
# so Steam/games pick up a phantom controller and grab input. Clear
|
||||
# ID_INPUT_JOYSTICK for them (extraRules → 99-local.rules is fine here, since
|
||||
# this only rewrites a udev property later consumers read):
|
||||
# 3434 = Keychron keyboard
|
||||
# 3151 = "2.4G Wireless Mouse" (X3PRO) receiver
|
||||
services.udev.extraRules = ''
|
||||
SUBSYSTEM=="input", ATTRS{idVendor}=="3434", ENV{ID_INPUT_JOYSTICK}=""
|
||||
SUBSYSTEM=="input", ATTRS{idVendor}=="3151", ENV{ID_INPUT_JOYSTICK}=""
|
||||
'';
|
||||
|
||||
# Browser-based (WebHID) configurators need the logged-in user to have access
|
||||
# to the device's raw HID nodes:
|
||||
# 3434 = Keychron keyboard (launcher.keychron.com)
|
||||
# 3151 = X3PRO mouse (its web editor)
|
||||
# TAG+="uaccess" makes systemd-logind grant the active session an ACL — but
|
||||
# that tag is CONSUMED by systemd's 73-seat-late.rules, so the rule must sort
|
||||
# BEFORE it. services.udev.extraRules lands in 99-local.rules (too late), so
|
||||
# ship this as a package-provided 60-*.rules file instead.
|
||||
# (WebHID is Chromium-only — use Chrome, not Firefox.)
|
||||
services.udev.packages = [
|
||||
(pkgs.writeTextFile {
|
||||
name = "hid-webconfig-uaccess";
|
||||
destination = "/etc/udev/rules.d/60-hid-webconfig-uaccess.rules";
|
||||
text = ''
|
||||
KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="3434", TAG+="uaccess"
|
||||
KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="3151", TAG+="uaccess"
|
||||
'';
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
74
modules/noise-suppression.nix
Normal file
74
modules/noise-suppression.nix
Normal file
@@ -0,0 +1,74 @@
|
||||
{ pkgs, ... }:
|
||||
|
||||
# App-independent microphone noise suppression via PipeWire + RNNoise.
|
||||
#
|
||||
# Replaces Discord's Krisp (which can't be enabled on current nixpkgs Discord:
|
||||
# the binary is patchelf'd, and even after bypassing Krisp's signature check the
|
||||
# newer "THz SDK" stage fails with KRISP_INIT_ERROR_GLOBAL_INIT). Instead we run
|
||||
# RNNoise as a PipeWire filter-chain that exposes a cleaned virtual microphone.
|
||||
#
|
||||
# This works in EVERY app (Discord, browser, OBS, games), survives Discord
|
||||
# updates, and touches no DRM. In each app's input settings, pick the source
|
||||
# named "Noise Canceling source" instead of the raw mic.
|
||||
|
||||
{
|
||||
# LADSPA plugin providing noise_suppressor_mono/stereo (werman RNNoise).
|
||||
environment.systemPackages = [ pkgs.rnnoise-plugin ];
|
||||
|
||||
services.pipewire.extraConfig.pipewire."99-input-denoising" = {
|
||||
"context.modules" = [
|
||||
{
|
||||
name = "libpipewire-module-filter-chain";
|
||||
args = {
|
||||
"node.description" = "Noise Canceling source";
|
||||
"media.name" = "Noise Canceling source";
|
||||
"filter.graph" = {
|
||||
nodes = [
|
||||
{
|
||||
type = "ladspa";
|
||||
name = "rnnoise";
|
||||
plugin = "${pkgs.rnnoise-plugin}/lib/ladspa/librnnoise_ladspa.so";
|
||||
label = "noise_suppressor_mono";
|
||||
control = {
|
||||
# Higher threshold = more aggressive gating of non-voice.
|
||||
# 50% is a balanced starting point; raise toward 90–95 if a
|
||||
# noisy background still leaks through, lower if speech clips.
|
||||
"VAD Threshold (%)" = 50.0;
|
||||
"VAD Grace Period (ms)" = 200.0;
|
||||
"Retroactive VAD Grace (ms)" = 0.0;
|
||||
};
|
||||
}
|
||||
{
|
||||
# Makeup gain baked into the graph so the source volume is
|
||||
# reproducible instead of living only in WirePlumber's runtime
|
||||
# state. Mult = 1.53 matches the 153% level set in the mixer.
|
||||
type = "builtin";
|
||||
name = "gain";
|
||||
label = "linear";
|
||||
control = {
|
||||
"Mult" = 1.53;
|
||||
"Add" = 0.0;
|
||||
};
|
||||
}
|
||||
];
|
||||
links = [
|
||||
{ output = "rnnoise:Output"; input = "gain:In"; }
|
||||
];
|
||||
};
|
||||
# The virtual mic apps select. media.class = Audio/Source makes it
|
||||
# appear as a capture device throughout the desktop.
|
||||
"capture.props" = {
|
||||
"node.name" = "capture.rnnoise_source";
|
||||
"node.passive" = true;
|
||||
"audio.rate" = 48000;
|
||||
};
|
||||
"playback.props" = {
|
||||
"node.name" = "rnnoise_source";
|
||||
"media.class" = "Audio/Source";
|
||||
"audio.rate" = 48000;
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
15
modules/tailscale.nix
Normal file
15
modules/tailscale.nix
Normal file
@@ -0,0 +1,15 @@
|
||||
{ pkgs, ... }:
|
||||
|
||||
{
|
||||
services.tailscale = {
|
||||
enable = true;
|
||||
# Let zephrynis drive the daemon without sudo — required for Trayscale,
|
||||
# and makes `tailscale switch` / `login` work from a plain shell too
|
||||
extraSetFlags = [ "--operator=zephrynis" ];
|
||||
};
|
||||
|
||||
# Tray app for the bar's systray: status toggle + account switcher
|
||||
# (work/personal via Tailscale fast user switching). Autostarted from
|
||||
# hypr/custom/execs.lua (appended in home/zephrynis.nix).
|
||||
environment.systemPackages = [ pkgs.trayscale ];
|
||||
}
|
||||
Reference in New Issue
Block a user