""" modules/audio_control.py — Phase 4 (System Control): Audio controls (Windows). Master volume: Uses nircmd.exe (already bundled) for reliable behavior. Per-app volume: Optional (requires pycaw). If pycaw isn't installed, we return a clear message. """ from __future__ import annotations import os import re import subprocess from dataclasses import dataclass from config import BASE_DIR NIRCMD = os.path.join(BASE_DIR, "nircmd.exe") @dataclass(frozen=True, slots=True) class AudioResult: ok: bool message: str def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]: return subprocess.run(cmd, capture_output=True, text=True, shell=False) def set_master_volume(percent: int) -> AudioResult: p = max(0, min(100, int(percent))) if not os.path.exists(NIRCMD): return AudioResult(False, "nircmd.exe missing; master volume unavailable.") # nircmd setsysvolume expects 0..65535 val = int((p / 100.0) * 65535) r = _run([NIRCMD, "setsysvolume", str(val)]) if r.returncode != 0: err = (r.stderr or r.stdout or "").strip() return AudioResult(False, f"Master volume failed: {err[:160]}") return AudioResult(True, f"Master volume set to {p}%.") def change_master_volume(delta_percent: int) -> AudioResult: d = int(delta_percent) if not os.path.exists(NIRCMD): return AudioResult(False, "nircmd.exe missing; master volume unavailable.") val = int((d / 100.0) * 65535) r = _run([NIRCMD, "changesysvolume", str(val)]) if r.returncode != 0: err = (r.stderr or r.stdout or "").strip() return AudioResult(False, f"Master volume change failed: {err[:160]}") return AudioResult(True, "Master volume changed.") def mute(muted: bool = True) -> AudioResult: if not os.path.exists(NIRCMD): return AudioResult(False, "nircmd.exe missing; mute unavailable.") r = _run([NIRCMD, "mutesysvolume", "1" if muted else "0"]) if r.returncode != 0: err = (r.stderr or r.stdout or "").strip() return AudioResult(False, f"Mute failed: {err[:160]}") return AudioResult(True, "Muted." if muted else "Unmuted.") def set_app_volume(process_name: str, percent: int) -> AudioResult: """ Set volume for an app by process name, e.g. 'chrome.exe' or 'discord.exe'. Requires pycaw; otherwise returns a clear message. """ p = max(0, min(100, int(percent))) name = process_name.strip() if not name: return AudioResult(False, "Missing process name.") if not name.lower().endswith(".exe"): name += ".exe" try: from pycaw.pycaw import AudioUtilities # type: ignore from comtypes import CLSCTX_ALL # type: ignore from ctypes import POINTER, cast # type: ignore from pycaw.pycaw import ISimpleAudioVolume # type: ignore except Exception: return AudioResult(False, "Per-app volume requires pycaw (not installed).") sessions = AudioUtilities.GetAllSessions() found = False for s in sessions: try: if not s.Process: continue if s.Process.name().lower() != name.lower(): continue found = True vol = s._ctl.QueryInterface(ISimpleAudioVolume) # pylint: disable=protected-access vol.SetMasterVolume(p / 100.0, None) except Exception: continue if not found: return AudioResult(False, f"No active audio session for {name}.") return AudioResult(True, f"{name} volume set to {p}%.")