Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
67 changes: 67 additions & 0 deletions docs/source/Eng/doc/new_features/v206_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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.
60 changes: 60 additions & 0 deletions docs/source/Zh/doc/new_features/v206_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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``。
7 changes: 7 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand Down
35 changes: 35 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
]


Expand Down
25 changes: 25 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions je_auto_control/utils/system_volume/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading
Loading