AI-generated Volume Daemon
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-03 14:22:14 +03:00
README.md first commit 2026-09-03 14:22:14 +03:00
volume-daemon.py first commit 2026-09-03 14:22:14 +03:00

volume-daemon

Event-driven управление громкостью PipeWire/PulseAudio для Sway + Waybar.

Демон запускается один раз за сессию и живёт постоянно. Горячие клавиши не запускают скрипт заново, а посылают уже работающему процессу realtime-сигналы, поэтому реакция мгновенная даже при удержании клавиши. Изменения громкости, сделанные другими клиентами (wiremix, микшер, само приложение), приходят напрямую через libpulse и сразу отражаются в баре.

Что он делает

  • Меняет громкость и mute стандартного sink'а, mute стандартного source'а (микрофона).
  • Пишет состояние одной JSON-строкой в файл, который читает модуль Waybar, и толкает бары сигналом.
  • Показывает OSD через dunstify (громкость с прогресс-баром, микрофон, смена устройства вывода).
  • При смене устройства вывода ставит на паузу все MPRIS-плееры через playerctl. Отключение наушников иначе перебрасывает звук на встроенные динамики посреди трека на той громкости, которая там оказалась.
  • Следит за собственной живучестью: модуль бара при каждом тике проверяет, жив ли демон, и поднимает его, если тот упал.

Зависимости

Python 3.9+ (используется from __future__ import annotations)
python3-pulsectl обязательно, без него скрипт выходит с кодом 1
PipeWire с pipewire-pulse или PulseAudio обязательно
dunstify опционально — без него просто не будет OSD
playerctl опционально — без него не будет паузы при смене вывода
Linux обязательно: используются prctl, /proc, flock, sigtimedwait

Отсутствие dunstify и playerctl проверяется по PATH при старте, ошибок это не вызывает.

Режимы запуска

volume-daemon.py            # демон (то, что запускает sway)
volume-daemon.py bar        # модуль Waybar: читает файл состояния + watchdog
volume-daemon.py render     # разово посчитать и напечатать состояние
volume-daemon.py --check    # диагностика

Повторный запуск демона безопасен: блокировка через flock не даёт подняться второму экземпляру, лишний процесс просто выходит с кодом 0.

Сигналы

Управляющие сигналы шлются самому демону. Имя процесса выставляется через prctl, поэтому pkill -x попадает именно в него.

Сигнал Действие
SIGRTMIN+1 громче
SIGRTMIN+2 тише
SIGRTMIN+3 mute вывода
SIGRTMIN+4 mute микрофона
SIGRTMIN+5 показать текущую громкость

Обратно демон шлёт барам SIGRTMIN+12 — это "signal": 12 в конфиге Waybar.

Сигналы блокируются маской до импорта pulsectl и до создания потоков, а читаются через sigtimedwait(). Благодаря этому каждое нажатие при автоповторе обрабатывается отдельно, а не схлопывается штатным коалесцированием сигналов в Python.

Настройка Sway

Демон запускает sway, а не Waybar. Модуль Waybar инстанцируется по одному на каждый монитор, так что запуск из бара дал бы несколько демонов, и все они реагировали бы на один и тот же pkill.

exec_always ~/.config/sway/scripts/volume-daemon.py

bindsym --locked XF86AudioRaiseVolume  exec pkill -x -RTMIN+1 volume-daemon
bindsym --locked XF86AudioLowerVolume  exec pkill -x -RTMIN+2 volume-daemon
bindsym --locked XF86AudioMute         exec pkill -x -RTMIN+3 volume-daemon
bindsym --locked XF86AudioMicMute      exec pkill -x -RTMIN+4 volume-daemon

Настройка Waybar

"custom/volume": {
    "exec": "~/.config/sway/scripts/volume-daemon.py bar",
    "return-type": "json",
    "interval": 30,
    "signal": 12,
    "on-click": "pkill -x -RTMIN+3 volume-daemon",
    "on-scroll-up": "pkill -x -RTMIN+1 volume-daemon",
    "on-scroll-down": "pkill -x -RTMIN+2 volume-daemon"
}

interval намеренно большой: обновления приходят сигналом, а тик нужен только как дешёвый watchdog для демона.

CSS-классы в выводе: on, muted, overamplified (выше 100%), off (звук недоступен), error (упал рендер).

Файлы

Путь Назначение
$XDG_RUNTIME_DIR/waybar-state/volume.json состояние для бара, пишется атомарно через os.replace
$XDG_RUNTIME_DIR/xff-volume-daemon.lock блокировка единственного экземпляра и она же проба живости
$XDG_RUNTIME_DIR/xff-logs/volume.log трейсбеки упавшего рендера

Если XDG_RUNTIME_DIR не задан, используется /tmp. Всё лежит в tmpfs и исчезает при перезагрузке; лог обнуляется по достижении 256 КБ.

Параметры внутри скрипта

Правятся константами в начале файла.

Константа По умолчанию Смысл
VOLUME_STEP 0.05 шаг на одно нажатие
VOLUME_LIMIT 1.0 потолок; поднять выше 1.0, чтобы разрешить усиление
SIGNAL_DEBOUNCE 0.3 не чаще одного сигнала барам за это время
PAUSE_ON_OUTPUT_CHANGE True пауза плееров при смене устройства вывода
WAYBAR_SIGNAL 12 номер сигнала модуля, должен совпадать с конфигом бара
NOTIFY_*_TIMEOUT 1500 / 5000 мс время жизни OSD
ICON_* Nerd Font иконки для 0 / <34% / <67% / выше

Debounce касается только цифры в баре: OSD рисуется напрямую из демона и остаётся мгновенным.

Отказоустойчивость

  • Потеря соединения с pulse не роняет демон: поток событий переподключается раз в секунду, ошибка логируется однократно, а не на каждой попытке.
  • Любое исключение в пути рендера бара перехватывается: на stdout всегда уходит разбираемый JSON, трейсбек уходит в лог. Пустой stdout при "return-type": "json" в зависимости от версии Waybar означает потерянное обновление или падение всего бара.
  • Сигнал бару не отправляется, пока тот не установил обработчик (проверяется по SigCgt в /proc/<pid>/status). Действие по умолчанию для SIGRTMIN+n — убить процесс, а sway поднимает демон раньше, чем Waybar успевает инициализировать GTK.

Диагностика

$ volume-daemon.py --check
state file:  /run/user/1000/waybar-state/volume.json
daemon:      running
waybar pids: [1234, 1235]
signal:      SIGRTMIN+12
pause on output change: enabled (playerctl found)

Логи демона идут в stderr. Если он запущен из sway, смотреть в журнале сессии.