mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 13:47:31 +01:00
Two spots blocked the alarm on a timer, guessing when the monitor's HDMI sink would be "ready": - _activate_all_outputs polled _list_real_sinks in a loop with time.sleep, waiting up to 20s for ALARM_AUDIO_SINK to appear. - main() slept DISPLAY_WAKE_WAIT_SECONDS (5s) after waking the display before audio setup. Both are gone. Activation now forces every card's output profile on and activates whatever sink is present at that instant; the beep loop's _play_on_all_sinks already re-lists real sinks every cycle, so a slow monitor is swept in the moment it actually enumerates — a real presence check, never a timed guess. main() wakes the display and proceeds immediately; the monitor powers on in parallel. Removes now-dead constants ALARM_AUDIO_CARD, ALARM_AUDIO_PROFILE, ALARM_AUDIO_SINK_WAIT_SECONDS, ALARM_AUDIO_SINK_POLL_SECONDS, DISPLAY_WAKE_WAIT_SECONDS. Activation returns in ~0.26s (was up to 20s); the test suite drops from ~12s to ~2s (two main() tests were each really sleeping 5s). 100% branch coverage retained. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SJ6HYXhBmYs3SiGBZv1rF
342 lines
12 KiB
Python
342 lines
12 KiB
Python
"""PipeWire sink/card enumeration, activation, and restore for the wake alarm.
|
|
|
|
At wake time the Bluetooth speaker is gone and WirePlumber leaves most card
|
|
profiles ``off``, so the machine is silent. This module forces every card's
|
|
output on, unmutes and maxes every sink, and captures the prior state so the
|
|
alarm can restore it on dismiss. The alarm plays on *all* sinks every beep
|
|
cycle (see :func:`wake_alarm._audio._play_on_all_sinks`) rather than betting on
|
|
a single chosen output.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
import logging
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
|
|
from wake_alarm._constants import (
|
|
ALARM_AUDIO_SINK,
|
|
)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _pactl_path() -> str | None:
|
|
"""Return the absolute path to pactl, or None when not installed."""
|
|
return shutil.which("pactl")
|
|
|
|
|
|
def _pactl_text(pactl: str, *args: str, timeout: float = 5.0) -> str | None:
|
|
"""Run ``pactl <args>`` and return decoded stdout, or None on failure."""
|
|
try:
|
|
result = subprocess.run(
|
|
[pactl, *args],
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
_logger.warning("pactl %s failed", " ".join(args), exc_info=True)
|
|
return None
|
|
return result.stdout.decode(errors="replace")
|
|
|
|
|
|
def _list_real_sinks(pactl: str) -> list[str]:
|
|
"""Return every non-``auto_null`` sink name from ``pactl list short sinks``."""
|
|
text = _pactl_text(pactl, "list", "short", "sinks")
|
|
if text is None:
|
|
return []
|
|
names = [line.split("\t")[1] for line in text.splitlines() if "\t" in line]
|
|
return [name for name in names if name != "auto_null"]
|
|
|
|
|
|
def _list_cards(pactl: str) -> list[str]:
|
|
"""Return every card name from ``pactl list short cards``."""
|
|
text = _pactl_text(pactl, "list", "short", "cards")
|
|
if text is None:
|
|
return []
|
|
return [line.split("\t")[1] for line in text.splitlines() if "\t" in line]
|
|
|
|
|
|
def _current_default_sink(pactl: str) -> str | None:
|
|
"""Return the current default sink name, or None on failure / empty."""
|
|
try:
|
|
result = subprocess.run(
|
|
[pactl, "get-default-sink"],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
_logger.warning("pactl get-default-sink failed", exc_info=True)
|
|
return None
|
|
name = result.stdout.decode(errors="replace").strip()
|
|
return name or None
|
|
|
|
|
|
def _ensure_sink_audible(pactl: str, sink: str) -> None:
|
|
"""Unmute *sink* and raise it to 100%, so playback on it is actually heard."""
|
|
for args in (
|
|
["set-sink-mute", sink, "0"],
|
|
["set-sink-volume", sink, "100%"],
|
|
):
|
|
subprocess.run(
|
|
[pactl, *args],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def _parse_profile_line(line: str) -> tuple[str, int] | None:
|
|
"""Parse one ``output:*`` profile line into ``(name, priority)``.
|
|
|
|
Returns None for non-output profiles and for profiles marked
|
|
``available: no`` (an output whose sink cannot currently be produced,
|
|
e.g. an HDMI port with nothing plugged in).
|
|
"""
|
|
if not line.startswith("output:"):
|
|
return None
|
|
name, sep, rest = line.partition(": ")
|
|
if not sep or "available: no" in rest:
|
|
return None
|
|
match = re.search(r"priority:\s*(\d+)", rest)
|
|
priority = int(match.group(1)) if match else 0
|
|
return name, priority
|
|
|
|
|
|
@dataclass
|
|
class _CardInfo:
|
|
"""A card's active profile and its available ``output:*`` profiles."""
|
|
|
|
active_profile: str | None
|
|
# Available output profiles, best first (stereo preferred, then priority).
|
|
output_profiles: list[str]
|
|
|
|
|
|
@dataclass
|
|
class _CardAccum:
|
|
"""Mutable accumulator for one card block while parsing ``list cards``."""
|
|
|
|
name: str | None = None
|
|
active: str | None = None
|
|
profiles: list[tuple[str, int]] = field(default_factory=list)
|
|
|
|
|
|
def _parse_cards(text: str) -> dict[str, _CardInfo]:
|
|
"""Parse ``pactl list cards`` (full) into ``{card_name: _CardInfo}``."""
|
|
cards: dict[str, _CardInfo] = {}
|
|
cur: _CardAccum | None = None
|
|
in_profiles = False
|
|
for raw in text.splitlines():
|
|
if raw.startswith("Card #"):
|
|
_store_card(cards, cur)
|
|
cur = _CardAccum()
|
|
in_profiles = False
|
|
continue
|
|
if cur is None:
|
|
continue
|
|
stripped = raw.strip()
|
|
if stripped.startswith("Name:"):
|
|
cur.name = stripped.split(":", 1)[1].strip()
|
|
in_profiles = False
|
|
elif stripped.startswith("Active Profile:"):
|
|
cur.active = stripped.split(":", 1)[1].strip()
|
|
in_profiles = False
|
|
elif stripped == "Profiles:":
|
|
in_profiles = True
|
|
elif in_profiles:
|
|
parsed = _parse_profile_line(stripped)
|
|
if parsed is not None:
|
|
cur.profiles.append(parsed)
|
|
_store_card(cards, cur)
|
|
return cards
|
|
|
|
|
|
def _store_card(cards: dict[str, _CardInfo], cur: _CardAccum | None) -> None:
|
|
"""Finalise one parsed card block and add it to *cards* (skip if unnamed)."""
|
|
if cur is None or cur.name is None:
|
|
return
|
|
# Prefer a stereo profile, then the highest priority.
|
|
ordered = sorted(
|
|
cur.profiles, key=lambda np: (0 if "stereo" in np[0] else 1, -np[1])
|
|
)
|
|
cards[cur.name] = _CardInfo(
|
|
active_profile=cur.active,
|
|
output_profiles=[profile for profile, _ in ordered],
|
|
)
|
|
|
|
|
|
def _parse_sinks(text: str) -> tuple[dict[str, str], dict[str, bool]]:
|
|
"""Parse ``pactl list sinks`` into ``({sink: raw_volume}, {sink: muted})``."""
|
|
volumes: dict[str, str] = {}
|
|
mutes: dict[str, bool] = {}
|
|
name: str | None = None
|
|
for raw in text.splitlines():
|
|
if raw.startswith("Sink #"):
|
|
name = None
|
|
continue
|
|
stripped = raw.strip()
|
|
if stripped.startswith("Name:"):
|
|
name = stripped.split(":", 1)[1].strip()
|
|
elif name is None:
|
|
continue
|
|
elif stripped.startswith("Mute:"):
|
|
mutes[name] = stripped.split(":", 1)[1].strip() == "yes"
|
|
elif stripped.startswith("Volume:"):
|
|
match = re.search(r"(\d+)", stripped)
|
|
if match:
|
|
volumes[name] = match.group(1)
|
|
return volumes, mutes
|
|
|
|
|
|
@dataclass
|
|
class _AudioState:
|
|
"""Snapshot of audio state to restore after the alarm is dismissed."""
|
|
|
|
default_sink: str | None
|
|
card_profiles: dict[str, str] # card name -> active profile
|
|
sink_volumes: dict[str, str] # sink name -> raw first-channel volume
|
|
sink_mutes: dict[str, bool] # sink name -> muted?
|
|
|
|
|
|
def _capture_audio_state(pactl: str) -> _AudioState:
|
|
"""Capture every card profile, sink volume/mute, and the default sink."""
|
|
cards = _parse_cards(_pactl_text(pactl, "list", "cards") or "")
|
|
card_profiles = {
|
|
name: info.active_profile
|
|
for name, info in cards.items()
|
|
if info.active_profile is not None
|
|
}
|
|
sink_volumes, sink_mutes = _parse_sinks(_pactl_text(pactl, "list", "sinks") or "")
|
|
return _AudioState(
|
|
default_sink=_current_default_sink(pactl),
|
|
card_profiles=card_profiles,
|
|
sink_volumes=sink_volumes,
|
|
sink_mutes=sink_mutes,
|
|
)
|
|
|
|
|
|
def _activate_all_outputs() -> _AudioState | None:
|
|
"""Turn on every card's output and make every sink audible for the alarm.
|
|
|
|
At wake time the Bluetooth speaker is gone and WirePlumber leaves most card
|
|
profiles ``off``, so the alarm is silent. This forces every card's best
|
|
output profile on, then unmutes and raises every sink to 100%. The alarm
|
|
then plays on *all* sinks every beep cycle
|
|
(:func:`wake_alarm._audio._play_on_all_sinks`) — it does not pick one output
|
|
and hope: a switched-off external speaker cannot be revived by software, so
|
|
every *other* output is tried in parallel instead.
|
|
|
|
It does not block waiting for the known-slow ``ALARM_AUDIO_SINK`` (the G27Q
|
|
monitor's HDMI speaker, ~15 s to enumerate); it activates whatever is present
|
|
now and lets the per-cycle presence check in ``_play_on_all_sinks`` sweep in
|
|
the rest. The default sink, when set, is only a convenience — per-cycle
|
|
all-sink playback is what guarantees audio.
|
|
|
|
Returns:
|
|
The prior audio state (to restore on close), or ``None`` only when
|
|
pactl is unavailable (nothing was changed, so nothing to restore).
|
|
"""
|
|
pactl = _pactl_path()
|
|
if pactl is None:
|
|
_logger.warning("pactl not on PATH; cannot activate alarm audio")
|
|
return None
|
|
state = _capture_audio_state(pactl)
|
|
for card, info in _parse_cards(_pactl_text(pactl, "list", "cards") or "").items():
|
|
if info.output_profiles:
|
|
subprocess.run(
|
|
[pactl, "set-card-profile", card, info.output_profiles[0]],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
# No sleep-and-hope wait. Forcing the profiles on above makes sinks appear
|
|
# asynchronously (the monitor's HDMI sink can take ~15 s), but we don't block
|
|
# on a timer guessing when it's "ready". We activate whatever is present
|
|
# right now; the beep loop's _play_on_all_sinks re-checks _list_real_sinks
|
|
# every cycle, so each output is made audible and played on the moment it
|
|
# actually exists — a real presence check, never a timed guess.
|
|
sinks = _list_real_sinks(pactl)
|
|
if not sinks:
|
|
_logger.warning(
|
|
"No real sink present yet; the beep loop will pick sinks up as they "
|
|
"enumerate (PC speaker covers the gap).",
|
|
)
|
|
return state
|
|
for sink in sinks:
|
|
_ensure_sink_audible(pactl, sink)
|
|
default = ALARM_AUDIO_SINK if ALARM_AUDIO_SINK in sinks else sinks[0]
|
|
subprocess.run(
|
|
[pactl, "set-default-sink", default],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
_logger.warning(
|
|
"Alarm audio activated on %d present sink(s) at 100%%, default=%s; more "
|
|
"are added by the beep loop as they enumerate.",
|
|
len(sinks),
|
|
default,
|
|
)
|
|
return state
|
|
|
|
|
|
def _restore_audio_state(state: _AudioState | None) -> None:
|
|
"""Restore card profiles, sink volumes/mutes, and default captured earlier."""
|
|
if state is None:
|
|
return
|
|
pactl = _pactl_path()
|
|
if pactl is None:
|
|
return
|
|
for card, profile in state.card_profiles.items():
|
|
subprocess.run(
|
|
[pactl, "set-card-profile", card, profile],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
for sink, volume in state.sink_volumes.items():
|
|
subprocess.run(
|
|
[pactl, "set-sink-volume", sink, volume],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
for sink, muted in state.sink_mutes.items():
|
|
subprocess.run(
|
|
[pactl, "set-sink-mute", sink, "1" if muted else "0"],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
if state.default_sink is not None:
|
|
subprocess.run(
|
|
[pactl, "set-default-sink", state.default_sink],
|
|
capture_output=True,
|
|
timeout=3,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def _warn_if_no_real_sink() -> None:
|
|
"""Log a warning when PipeWire has no real (non-auto_null) sink yet.
|
|
|
|
Informational only: :func:`_activate_all_outputs` runs afterwards and forces
|
|
card profiles on, so a missing sink here is not necessarily fatal.
|
|
"""
|
|
pactl = _pactl_path()
|
|
if pactl is None:
|
|
_logger.warning("pactl not on PATH; cannot verify audio sinks")
|
|
return
|
|
real_sinks = _list_real_sinks(pactl)
|
|
if not real_sinks:
|
|
_logger.warning(
|
|
"No real PipeWire sink yet — alarm will force card profiles on and "
|
|
"fall back to the PC speaker.",
|
|
)
|
|
else:
|
|
_logger.info("Audio sinks available: %s", real_sinks)
|