From 4950b3700d208d6dc28c48e9d94c6dbce0255015 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 26 Jun 2026 04:47:53 +0800 Subject: [PATCH] Add system_volume: absolute read/set master volume and mute Replace the blind media-key nudges with read-backable, absolute control of the default output device (percent 0-100 + mute), behind an injectable VolumeDriver seam so all logic is unit-tested without an audio device. The default driver uses Windows Core Audio via the optional pycaw extra. --- WHATS_NEW.md | 8 + .../doc/new_features/v206_features_doc.rst | 67 +++++++ .../Zh/doc/new_features/v206_features_doc.rst | 60 ++++++ je_auto_control/__init__.py | 7 + .../gui/script_builder/command_schema.py | 33 ++++ .../utils/executor/action_executor.py | 35 ++++ .../utils/mcp_server/tools/_factories.py | 43 +++++ .../utils/mcp_server/tools/_handlers.py | 25 +++ .../utils/system_volume/__init__.py | 12 ++ .../utils/system_volume/system_volume.py | 182 ++++++++++++++++++ pyproject.toml | 1 + .../headless/test_system_volume_batch.py | 124 ++++++++++++ 12 files changed, 597 insertions(+) create mode 100644 docs/source/Eng/doc/new_features/v206_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v206_features_doc.rst create mode 100644 je_auto_control/utils/system_volume/__init__.py create mode 100644 je_auto_control/utils/system_volume/system_volume.py create mode 100644 test/unit_test/headless/test_system_volume_batch.py diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 3be380b4..04b77bb1 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,13 @@ # What's New — AutoControl +## What's new (2026-06-26) + +### Read and Control the System Volume + +Set a known audio baseline before a run — mute, set 30%, or assert the level. Full reference: [`docs/source/Eng/doc/new_features/v206_features_doc.rst`](docs/source/Eng/doc/new_features/v206_features_doc.rst). + +- **`get_volume` / `set_volume` / `change_volume` / `is_muted` / `set_mute` / `mute` / `unmute` / `toggle_mute`** (`AC_get_volume`, `AC_set_volume`, `AC_change_volume`, `AC_set_mute`, `AC_toggle_mute`): the framework only had the blind media-key steps (`volume up` / `down` nudge by an unknown amount with no read-back). This adds absolute, read-backable control of the default output device — read or set the master level as an integer percent `0..100`, and read / set / toggle the mute flag. All logic (clamping, percent↔scalar conversion, toggle) is pure and runs through an injectable `VolumeDriver` seam, so it is fully unit-tested without an audio device; the default driver uses the Windows Core Audio `IAudioEndpointVolume` interface through the optional `pycaw` dependency (`pip install je_auto_control[audio]`), degrading with a clear error when absent. Fourth feature of the ROUND-15 cross-app OS lane. No `PySide6`. + ## What's new (2026-06-25) ### Resolve the App Registered for a File Type diff --git a/docs/source/Eng/doc/new_features/v206_features_doc.rst b/docs/source/Eng/doc/new_features/v206_features_doc.rst new file mode 100644 index 00000000..30feb059 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v206_features_doc.rst @@ -0,0 +1,67 @@ +Read and Control the System Volume +================================== + +Unattended runs often need a known audio baseline — mute before a noisy batch, +restore a level afterwards, or assert the current volume — but the framework +only had the blind media-key steps (``volume up`` / ``down`` nudge by an unknown +amount with no read-back). ``system_volume`` adds absolute, read-backable +control of the default output device. + +* :func:`get_volume` / :func:`set_volume` / :func:`change_volume` — read and + write the master level as an integer percent ``0..100`` (``set_volume`` and + ``change_volume`` clamp to that range). +* :func:`is_muted` / :func:`set_mute` / :func:`mute` / :func:`unmute` / + :func:`toggle_mute` — read and write the mute flag. + +All logic (clamping, percent <-> scalar conversion, toggle) is pure and runs +through an injectable :class:`VolumeDriver` seam, so it is fully testable without +an audio device. The default driver drives the Windows Core Audio +``IAudioEndpointVolume`` interface through the optional ``pycaw`` dependency +(``pip install je_auto_control[audio]``); on a platform / install without it the +default driver raises a clear error telling the caller to pass ``driver=``. +Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + get_volume, set_volume, change_volume, is_muted, mute, unmute, + toggle_mute, + ) + + get_volume() # e.g. 65 — current master volume percent + set_volume(30) # set to 30 %, returns 30 + change_volume(-10) # lower by 10 %, returns the applied percent + is_muted() # False + mute() # True — silence the output + unmute() # False — restore it + toggle_mute() # flip and return the new state + +For tests (or any non-Windows host) pass a ``driver`` — any object exposing +``get_scalar`` / ``set_scalar`` / ``get_mute`` / ``set_mute`` over a ``0.0..1.0`` +scalar: + +.. code-block:: python + + class FakeVolume: + def __init__(self, scalar=0.5, muted=False): + self.scalar, self.muted = scalar, muted + def get_scalar(self): return self.scalar + def set_scalar(self, s): self.scalar = s + def get_mute(self): return self.muted + def set_mute(self, m): self.muted = m + + drv = FakeVolume() + set_volume(73, driver=drv) # 73, drv.scalar == 0.73 + +Executor commands +----------------- + +``AC_get_volume`` (→ ``{volume, muted}``), ``AC_set_volume`` (``level`` → +``{volume}``), ``AC_change_volume`` (``delta`` → ``{volume}``), ``AC_set_mute`` +(``muted`` → ``{muted}``) and ``AC_toggle_mute`` (→ ``{muted}``). They are +exposed as the matching ``ac_*`` MCP tools (the read is read-only, the writes +side-effect-only) and as Script Builder commands under **Shell**. The executor +and MCP layers use the default OS driver, so they require ``pycaw`` on Windows. diff --git a/docs/source/Zh/doc/new_features/v206_features_doc.rst b/docs/source/Zh/doc/new_features/v206_features_doc.rst new file mode 100644 index 00000000..a6617bbd --- /dev/null +++ b/docs/source/Zh/doc/new_features/v206_features_doc.rst @@ -0,0 +1,60 @@ +讀取與控制系統音量 +================== + +無人值守的執行常需要一個已知的音訊基準——在吵雜的批次前靜音、結束後還原音量,或斷言目前音量——但框架 +原本只有盲目的媒體鍵步驟(``volume up`` / ``down`` 以未知幅度推移,且無法讀回)。``system_volume`` +補上對預設輸出裝置的絕對、可讀回控制。 + +* :func:`get_volume` / :func:`set_volume` / :func:`change_volume` ——以整數百分比 ``0..100`` + 讀寫主音量(``set_volume`` 與 ``change_volume`` 會夾到該範圍)。 +* :func:`is_muted` / :func:`set_mute` / :func:`mute` / :func:`unmute` / + :func:`toggle_mute` ——讀寫靜音旗標。 + +所有邏輯(夾值、百分比 <-> 純量轉換、切換)皆為純函式,並透過可注入的 :class:`VolumeDriver` 接縫執行, +故能在不需音訊裝置的情況下完整測試。預設 driver 透過選用相依套件 ``pycaw`` +(``pip install je_auto_control[audio]``)驅動 Windows Core Audio 的 +``IAudioEndpointVolume`` 介面;在沒有該套件 / 非 Windows 平台上,預設 driver 會丟出清楚的錯誤, +提示呼叫端傳入 ``driver=``。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + get_volume, set_volume, change_volume, is_muted, mute, unmute, + toggle_mute, + ) + + get_volume() # 例如 65 ——目前主音量百分比 + set_volume(30) # 設為 30 %,回傳 30 + change_volume(-10) # 降低 10 %,回傳套用後的百分比 + is_muted() # False + mute() # True ——使輸出靜音 + unmute() # False ——還原 + toggle_mute() # 切換並回傳新狀態 + +測試時(或任何非 Windows 主機)可傳入 ``driver`` ——任何以 ``0.0..1.0`` 純量提供 +``get_scalar`` / ``set_scalar`` / ``get_mute`` / ``set_mute`` 的物件: + +.. code-block:: python + + class FakeVolume: + def __init__(self, scalar=0.5, muted=False): + self.scalar, self.muted = scalar, muted + def get_scalar(self): return self.scalar + def set_scalar(self, s): self.scalar = s + def get_mute(self): return self.muted + def set_mute(self, m): self.muted = m + + drv = FakeVolume() + set_volume(73, driver=drv) # 73,drv.scalar == 0.73 + +執行器指令 +---------- + +``AC_get_volume``(→ ``{volume, muted}``)、``AC_set_volume``(``level`` → +``{volume}``)、``AC_change_volume``(``delta`` → ``{volume}``)、``AC_set_mute`` +(``muted`` → ``{muted}``)與 ``AC_toggle_mute``(→ ``{muted}``)。皆以對應的 ``ac_*`` +MCP 工具(讀取為唯讀、寫入為僅副作用)及 Script Builder 指令(位於 **Shell** 分類下)形式提供。 +執行器與 MCP 層使用預設 OS driver,故在 Windows 上需要 ``pycaw``。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index c360611f..6d6bb3f4 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -100,6 +100,11 @@ ) # Resolve which application is registered to open a given file type from je_auto_control.utils.file_assoc import file_association, normalize_ext +# Read and control the system master volume and mute state +from je_auto_control.utils.system_volume import ( + change_volume, get_volume, is_muted, mute, set_mute, set_volume, + toggle_mute, unmute, +) # Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set from je_auto_control.utils.clipboard_rich_formats import ( build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv, @@ -1713,6 +1718,8 @@ def start_autocontrol_gui(*args, **kwargs): "idle_seconds", "is_idle", "plan_keep_awake", "keep_awake", "keep_awake_on", "allow_sleep", "normalize_ext", "file_association", + "get_volume", "set_volume", "change_volume", + "is_muted", "set_mute", "mute", "unmute", "toggle_mute", "build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", "set_clipboard_rtf", "get_clipboard_rtf", "set_clipboard_csv", "get_clipboard_csv", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 3c8c93fd..cf7dc7ac 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -4312,6 +4312,39 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None: fields=(), description="Release a previously-started keep-awake.", )) + specs.append(CommandSpec( + "AC_get_volume", "Shell", "Get System Volume", + fields=(), + description="Read the master volume percent and mute state.", + )) + specs.append(CommandSpec( + "AC_set_volume", "Shell", "Set System Volume", + fields=( + FieldSpec("level", FieldType.INT, default=50, + placeholder="volume percent 0-100"), + ), + description="Set the master volume to level percent (clamped 0-100).", + )) + specs.append(CommandSpec( + "AC_change_volume", "Shell", "Change System Volume", + fields=( + FieldSpec("delta", FieldType.INT, default=10, + placeholder="percent delta (may be negative)"), + ), + description="Add delta percent to the master volume (clamped 0-100).", + )) + specs.append(CommandSpec( + "AC_set_mute", "Shell", "Set Mute", + fields=( + FieldSpec("muted", FieldType.BOOL, optional=True, default=True), + ), + description="Mute or unmute the master output.", + )) + specs.append(CommandSpec( + "AC_toggle_mute", "Shell", "Toggle Mute", + fields=(), + description="Flip the master mute flag.", + )) specs.append(CommandSpec( "AC_normalize_ext", "Shell", "Normalize Extension", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 0fcd3a21..457389da 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2653,6 +2653,36 @@ def _allow_sleep() -> Dict[str, Any]: return {"released": bool(allow_sleep())} +def _get_volume() -> Dict[str, Any]: + """Adapter: the system master volume as an integer percent.""" + from je_auto_control.utils.system_volume import get_volume, is_muted + return {"volume": int(get_volume()), "muted": bool(is_muted())} + + +def _set_volume(level: Any) -> Dict[str, Any]: + """Adapter: set the master volume to ``level`` percent.""" + from je_auto_control.utils.system_volume import set_volume + return {"volume": int(set_volume(float(level)))} + + +def _change_volume(delta: Any) -> Dict[str, Any]: + """Adapter: add ``delta`` percent to the master volume.""" + from je_auto_control.utils.system_volume import change_volume + return {"volume": int(change_volume(float(delta)))} + + +def _set_mute(muted: Any = True) -> Dict[str, Any]: + """Adapter: set the master mute flag.""" + from je_auto_control.utils.system_volume import set_mute + return {"muted": bool(set_mute(bool(muted)))} + + +def _toggle_mute() -> Dict[str, Any]: + """Adapter: flip the master mute flag.""" + from je_auto_control.utils.system_volume import toggle_mute + return {"muted": bool(toggle_mute())} + + def _normalize_ext(target: str) -> Dict[str, Any]: """Adapter: the lowercased extension of a path / bare ext (pure).""" from je_auto_control.utils.file_assoc import normalize_ext @@ -6662,6 +6692,11 @@ def __init__(self): "AC_plan_keep_awake": _plan_keep_awake, "AC_keep_awake_on": _keep_awake_on, "AC_allow_sleep": _allow_sleep, + "AC_get_volume": _get_volume, + "AC_set_volume": _set_volume, + "AC_change_volume": _change_volume, + "AC_set_mute": _set_mute, + "AC_toggle_mute": _toggle_mute, "AC_normalize_ext": _normalize_ext, "AC_file_association": _file_association, "AC_get_control_text": _get_control_text, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 7a9b95cd..5ef00f30 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2168,6 +2168,49 @@ def process_and_shell_tools() -> List[MCPTool]: handler=h.file_association, annotations=READ_ONLY, ), + MCPTool( + name="ac_get_volume", + description=("Read the system master volume as an integer percent " + "0..100. Returns {volume, muted} (Windows, needs " + "the optional 'pycaw' dependency)."), + input_schema=schema({}), + handler=h.get_volume, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_set_volume", + description=("Set the master volume to 'level' percent (clamped to " + "0..100). Returns the applied {volume}."), + input_schema=schema({"level": {"type": "number"}}, + required=["level"]), + handler=h.set_volume, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_change_volume", + description=("Add 'delta' percent to the master volume (may be " + "negative; clamped to 0..100). Returns {volume}."), + input_schema=schema({"delta": {"type": "number"}}, + required=["delta"]), + handler=h.change_volume, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_set_mute", + description=("Mute or unmute the master output. 'muted' defaults to " + "true. Returns the new {muted} state."), + input_schema=schema({"muted": {"type": "boolean"}}), + handler=h.set_mute, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_toggle_mute", + description=("Flip the master mute flag. Returns the new {muted} " + "state."), + input_schema=schema({}), + handler=h.toggle_mute, + annotations=SIDE_EFFECT_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 4128e73d..41fc8c7a 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -595,6 +595,31 @@ def allow_sleep(): return _allow_sleep() +def get_volume(): + from je_auto_control.utils.executor.action_executor import _get_volume + return _get_volume() + + +def set_volume(level): + from je_auto_control.utils.executor.action_executor import _set_volume + return _set_volume(level) + + +def change_volume(delta): + from je_auto_control.utils.executor.action_executor import _change_volume + return _change_volume(delta) + + +def set_mute(muted=True): + from je_auto_control.utils.executor.action_executor import _set_mute + return _set_mute(muted) + + +def toggle_mute(): + from je_auto_control.utils.executor.action_executor import _toggle_mute + return _toggle_mute() + + def normalize_ext(target): from je_auto_control.utils.executor.action_executor import _normalize_ext return _normalize_ext(target) diff --git a/je_auto_control/utils/system_volume/__init__.py b/je_auto_control/utils/system_volume/__init__.py new file mode 100644 index 00000000..1100c5e3 --- /dev/null +++ b/je_auto_control/utils/system_volume/__init__.py @@ -0,0 +1,12 @@ +"""Read and control the system master volume and mute state.""" +from je_auto_control.utils.system_volume.system_volume import ( + VolumeDriver, change_volume, clamp_percent, get_volume, is_muted, mute, + percent_to_scalar, scalar_to_percent, set_mute, set_volume, toggle_mute, + unmute, +) + +__all__ = [ + "VolumeDriver", "get_volume", "set_volume", "change_volume", + "is_muted", "set_mute", "mute", "unmute", "toggle_mute", + "clamp_percent", "percent_to_scalar", "scalar_to_percent", +] diff --git a/je_auto_control/utils/system_volume/system_volume.py b/je_auto_control/utils/system_volume/system_volume.py new file mode 100644 index 00000000..9879d66d --- /dev/null +++ b/je_auto_control/utils/system_volume/system_volume.py @@ -0,0 +1,182 @@ +"""Read and control the system master volume and mute state. + +Unattended runs often need to set a known audio baseline — mute before a noisy +batch, restore a level afterwards, or assert the current volume — but the +framework only had the blind media-key steps (``volume up`` / ``down`` nudge by +an unknown amount with no read-back). ``system_volume`` adds absolute, +read-backable control of the default output device: + +* :func:`get_volume` / :func:`set_volume` / :func:`change_volume` read and write + the master level as an integer percent ``0..100``. +* :func:`is_muted` / :func:`set_mute` / :func:`mute` / :func:`unmute` / + :func:`toggle_mute` read and write the mute flag. + +All logic (clamping, percent <-> scalar conversion, toggle) is pure and runs +through an injectable :class:`VolumeDriver` seam, so it is fully testable without +an audio device. The default driver drives the Windows Core Audio +``IAudioEndpointVolume`` interface through the optional ``pycaw`` dependency +(``pip install je_auto_control[audio]``); on a platform / install without it the +default driver raises a clear error telling the caller to pass ``driver=``. + +Imports no ``PySide6``. +""" +import sys +from typing import Optional, Protocol + +# A normalized master-volume scalar runs 0.0 (silent) .. 1.0 (full). +_MIN_SCALAR = 0.0 +_MAX_SCALAR = 1.0 +_PERCENT_MAX = 100 + + +class VolumeDriver(Protocol): + """The OS seam used by every public function. + + Implementations expose the master output volume as a ``0.0 .. 1.0`` scalar + and a boolean mute flag. Pass a fake implementing these four methods to test + the pure logic without an audio device. + """ + + def get_scalar(self) -> float: + """Return the current master volume as a ``0.0 .. 1.0`` scalar.""" + + def set_scalar(self, scalar: float) -> None: + """Set the master volume from a ``0.0 .. 1.0`` scalar.""" + + def get_mute(self) -> bool: + """Return whether the master output is muted.""" + + def set_mute(self, muted: bool) -> None: + """Mute or unmute the master output.""" + + +def clamp_percent(level: float) -> int: + """Clamp ``level`` to an integer percent in ``[0, 100]`` (pure).""" + rounded = int(round(float(level))) + return max(0, min(_PERCENT_MAX, rounded)) + + +def percent_to_scalar(level: float) -> float: + """Convert a ``0..100`` percent to a clamped ``0.0 .. 1.0`` scalar (pure).""" + return clamp_percent(level) / float(_PERCENT_MAX) + + +def scalar_to_percent(scalar: float) -> int: + """Convert a ``0.0 .. 1.0`` scalar to a clamped integer percent (pure).""" + bounded = max(_MIN_SCALAR, min(_MAX_SCALAR, float(scalar))) + return clamp_percent(bounded * _PERCENT_MAX) + + +def _resolve(driver: Optional[VolumeDriver]) -> VolumeDriver: + """Return the supplied driver, or the default OS driver.""" + return driver if driver is not None else _default_driver() + + +def get_volume(*, driver: Optional[VolumeDriver] = None) -> int: + """Return the system master volume as an integer percent ``0..100``. + + Pass ``driver`` (any :class:`VolumeDriver`) to read from a fake in tests; the + default queries the OS through ``pycaw`` (Windows). + """ + return scalar_to_percent(_resolve(driver).get_scalar()) + + +def set_volume(level: float, *, driver: Optional[VolumeDriver] = None) -> int: + """Set the master volume to ``level`` percent; return the applied percent. + + ``level`` is clamped to ``[0, 100]`` before it is applied. + """ + target = clamp_percent(level) + _resolve(driver).set_scalar(percent_to_scalar(target)) + return target + + +def change_volume(delta: float, *, + driver: Optional[VolumeDriver] = None) -> int: + """Add ``delta`` percent to the current volume; return the applied percent. + + The result is clamped to ``[0, 100]``. ``delta`` may be negative. + """ + source = _resolve(driver) + current = scalar_to_percent(source.get_scalar()) + target = clamp_percent(current + float(delta)) + source.set_scalar(percent_to_scalar(target)) + return target + + +def is_muted(*, driver: Optional[VolumeDriver] = None) -> bool: + """Return whether the master output is currently muted.""" + return bool(_resolve(driver).get_mute()) + + +def set_mute(muted: bool, *, driver: Optional[VolumeDriver] = None) -> bool: + """Set the mute flag to ``muted``; return the new mute state.""" + state = bool(muted) + _resolve(driver).set_mute(state) + return state + + +def mute(*, driver: Optional[VolumeDriver] = None) -> bool: + """Mute the master output; return ``True``.""" + return set_mute(True, driver=driver) + + +def unmute(*, driver: Optional[VolumeDriver] = None) -> bool: + """Unmute the master output; return ``False``.""" + return set_mute(False, driver=driver) + + +def toggle_mute(*, driver: Optional[VolumeDriver] = None) -> bool: + """Flip the mute flag; return the new mute state.""" + source = _resolve(driver) + new_state = not bool(source.get_mute()) + source.set_mute(new_state) + return new_state + + +class _PycawDriver: + """Default :class:`VolumeDriver` over Windows Core Audio via ``pycaw``.""" + + def __init__(self) -> None: + """Activate the default-output ``IAudioEndpointVolume`` interface.""" + from ctypes import POINTER, cast # local: optional Windows path + from comtypes import CLSCTX_ALL + from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume + speakers = AudioUtilities.GetSpeakers() + interface = speakers.Activate( + IAudioEndpointVolume._iid_, CLSCTX_ALL, None) + self._volume = cast(interface, POINTER(IAudioEndpointVolume)) + + def get_scalar(self) -> float: + """Read the master volume scalar from Core Audio.""" + return float(self._volume.GetMasterVolumeLevelScalar()) + + def set_scalar(self, scalar: float) -> None: + """Write the master volume scalar to Core Audio.""" + self._volume.SetMasterVolumeLevelScalar(float(scalar), None) + + def get_mute(self) -> bool: + """Read the master mute flag from Core Audio.""" + return bool(self._volume.GetMute()) + + def set_mute(self, muted: bool) -> None: + """Write the master mute flag to Core Audio.""" + self._volume.SetMute(bool(muted), None) + + +def _default_driver() -> VolumeDriver: + """Return the OS volume driver, or raise if unavailable. + + Uses Windows Core Audio through the optional ``pycaw`` dependency. Raises + ``RuntimeError`` on a non-Windows platform or when ``pycaw`` is not + installed, instructing the caller to pass ``driver=``. + """ + if not sys.platform.startswith("win"): + raise RuntimeError( + "system volume has no OS driver on this platform; pass driver=") + try: + return _PycawDriver() + except ImportError as exc: + raise RuntimeError( + "system volume needs the 'pycaw' package on Windows " + "(pip install je_auto_control[audio]); or pass driver=") from exc diff --git a/pyproject.toml b/pyproject.toml index 6111fecf..43c8c12b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ office = ["openpyxl>=3.1", "python-docx>=1.1", "python-pptx>=0.6"] fuzzy = ["rapidfuzz>=3.0"] s3 = ["boto3>=1.34"] locale = ["babel>=2.12"] +audio = ["pycaw>=20240210"] [tool.bandit] exclude_dirs = [ diff --git a/test/unit_test/headless/test_system_volume_batch.py b/test/unit_test/headless/test_system_volume_batch.py new file mode 100644 index 00000000..61d38bf8 --- /dev/null +++ b/test/unit_test/headless/test_system_volume_batch.py @@ -0,0 +1,124 @@ +"""Headless tests for system volume / mute control (injected fake driver).""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.system_volume import ( + change_volume, clamp_percent, get_volume, is_muted, mute, + percent_to_scalar, scalar_to_percent, set_mute, set_volume, toggle_mute, + unmute, +) + + +class FakeVolume: + """In-memory VolumeDriver: stores a 0..1 scalar and a mute flag.""" + + def __init__(self, scalar=0.5, muted=False): + self.scalar = scalar + self.muted = muted + + def get_scalar(self): + return self.scalar + + def set_scalar(self, scalar): + self.scalar = scalar + + def get_mute(self): + return self.muted + + def set_mute(self, muted): + self.muted = muted + + +# --- pure conversion ------------------------------------------------------ + +def test_clamp_percent_bounds(): + assert clamp_percent(-20) == 0 + assert clamp_percent(150) == 100 + assert clamp_percent(37.6) == 38 + + +def test_percent_scalar_round_trip(): + assert percent_to_scalar(50) == pytest.approx(0.5) + assert scalar_to_percent(0.5) == 50 + assert scalar_to_percent(1.5) == 100 # clamped + assert scalar_to_percent(-0.2) == 0 + + +# --- volume read / write -------------------------------------------------- + +def test_get_volume_reads_driver(): + assert get_volume(driver=FakeVolume(scalar=0.4)) == 40 + + +def test_set_volume_clamps_and_applies(): + drv = FakeVolume(scalar=0.0) + assert set_volume(73, driver=drv) == 73 + assert drv.scalar == pytest.approx(0.73) + assert set_volume(250, driver=drv) == 100 + assert set_volume(-5, driver=drv) == 0 + + +def test_change_volume_relative(): + drv = FakeVolume(scalar=0.5) + assert change_volume(20, driver=drv) == 70 + assert change_volume(-100, driver=drv) == 0 + assert change_volume(10, driver=drv) == 10 + + +# --- mute ----------------------------------------------------------------- + +def test_is_muted_and_set_mute(): + drv = FakeVolume(muted=False) + assert is_muted(driver=drv) is False + assert set_mute(True, driver=drv) is True + assert drv.muted is True + + +def test_mute_unmute_helpers(): + drv = FakeVolume(muted=False) + assert mute(driver=drv) is True + assert is_muted(driver=drv) is True + assert unmute(driver=drv) is False + assert is_muted(driver=drv) is False + + +def test_toggle_mute_flips(): + drv = FakeVolume(muted=False) + assert toggle_mute(driver=drv) is True + assert toggle_mute(driver=drv) is False + + +# --- wiring --------------------------------------------------------------- + +def test_executor_pure_path_with_default_driver_absent_on_linux(): + # The executor adapters use the OS default driver; on a non-Windows CI box + # that raises a clear RuntimeError rather than silently passing. + from je_auto_control.utils.system_volume.system_volume import ( + _default_driver, + ) + import sys + if not sys.platform.startswith("win"): + with pytest.raises(RuntimeError): + _default_driver() + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_get_volume", "AC_set_volume", "AC_change_volume", + "AC_set_mute", "AC_toggle_mute"} <= known + from je_auto_control.utils.mcp_server.tools import ( + build_default_tool_registry, + ) + names = {t.name for t in build_default_tool_registry()} + assert {"ac_get_volume", "ac_set_volume", "ac_change_volume", + "ac_set_mute", "ac_toggle_mute"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_get_volume", "AC_set_volume", "AC_change_volume", + "AC_set_mute", "AC_toggle_mute"} <= specs + + +def test_facade_exports(): + for name in ("get_volume", "set_volume", "change_volume", "is_muted", + "set_mute", "mute", "unmute", "toggle_mute"): + assert hasattr(ac, name) and name in ac.__all__