Skip to content

Commit 23329d5

Browse files
authored
Merge pull request #432 from Integration-Automation/feat/system-volume-batch
Add system_volume: absolute read/set master volume and mute
2 parents 40b7e3d + 4950b37 commit 23329d5

12 files changed

Lines changed: 597 additions & 0 deletions

File tree

WHATS_NEW.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# What's New — AutoControl
22

3+
## What's new (2026-06-26)
4+
5+
### Read and Control the System Volume
6+
7+
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).
8+
9+
- **`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`.
10+
311
## What's new (2026-06-25)
412

513
### Resolve the App Registered for a File Type
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
Read and Control the System Volume
2+
==================================
3+
4+
Unattended runs often need a known audio baseline — mute before a noisy batch,
5+
restore a level afterwards, or assert the current volume — but the framework
6+
only had the blind media-key steps (``volume up`` / ``down`` nudge by an unknown
7+
amount with no read-back). ``system_volume`` adds absolute, read-backable
8+
control of the default output device.
9+
10+
* :func:`get_volume` / :func:`set_volume` / :func:`change_volume` — read and
11+
write the master level as an integer percent ``0..100`` (``set_volume`` and
12+
``change_volume`` clamp to that range).
13+
* :func:`is_muted` / :func:`set_mute` / :func:`mute` / :func:`unmute` /
14+
:func:`toggle_mute` — read and write the mute flag.
15+
16+
All logic (clamping, percent <-> scalar conversion, toggle) is pure and runs
17+
through an injectable :class:`VolumeDriver` seam, so it is fully testable without
18+
an audio device. The default driver drives the Windows Core Audio
19+
``IAudioEndpointVolume`` interface through the optional ``pycaw`` dependency
20+
(``pip install je_auto_control[audio]``); on a platform / install without it the
21+
default driver raises a clear error telling the caller to pass ``driver=``.
22+
Imports no ``PySide6``.
23+
24+
Headless API
25+
------------
26+
27+
.. code-block:: python
28+
29+
from je_auto_control import (
30+
get_volume, set_volume, change_volume, is_muted, mute, unmute,
31+
toggle_mute,
32+
)
33+
34+
get_volume() # e.g. 65 — current master volume percent
35+
set_volume(30) # set to 30 %, returns 30
36+
change_volume(-10) # lower by 10 %, returns the applied percent
37+
is_muted() # False
38+
mute() # True — silence the output
39+
unmute() # False — restore it
40+
toggle_mute() # flip and return the new state
41+
42+
For tests (or any non-Windows host) pass a ``driver`` — any object exposing
43+
``get_scalar`` / ``set_scalar`` / ``get_mute`` / ``set_mute`` over a ``0.0..1.0``
44+
scalar:
45+
46+
.. code-block:: python
47+
48+
class FakeVolume:
49+
def __init__(self, scalar=0.5, muted=False):
50+
self.scalar, self.muted = scalar, muted
51+
def get_scalar(self): return self.scalar
52+
def set_scalar(self, s): self.scalar = s
53+
def get_mute(self): return self.muted
54+
def set_mute(self, m): self.muted = m
55+
56+
drv = FakeVolume()
57+
set_volume(73, driver=drv) # 73, drv.scalar == 0.73
58+
59+
Executor commands
60+
-----------------
61+
62+
``AC_get_volume`` (→ ``{volume, muted}``), ``AC_set_volume`` (``level`` →
63+
``{volume}``), ``AC_change_volume`` (``delta`` → ``{volume}``), ``AC_set_mute``
64+
(``muted`` → ``{muted}``) and ``AC_toggle_mute`` (→ ``{muted}``). They are
65+
exposed as the matching ``ac_*`` MCP tools (the read is read-only, the writes
66+
side-effect-only) and as Script Builder commands under **Shell**. The executor
67+
and MCP layers use the default OS driver, so they require ``pycaw`` on Windows.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
讀取與控制系統音量
2+
==================
3+
4+
無人值守的執行常需要一個已知的音訊基準——在吵雜的批次前靜音、結束後還原音量,或斷言目前音量——但框架
5+
原本只有盲目的媒體鍵步驟(``volume up`` / ``down`` 以未知幅度推移,且無法讀回)。``system_volume``
6+
補上對預設輸出裝置的絕對、可讀回控制。
7+
8+
* :func:`get_volume` / :func:`set_volume` / :func:`change_volume` ——以整數百分比 ``0..100``
9+
讀寫主音量(``set_volume`` 與 ``change_volume`` 會夾到該範圍)。
10+
* :func:`is_muted` / :func:`set_mute` / :func:`mute` / :func:`unmute` /
11+
:func:`toggle_mute` ——讀寫靜音旗標。
12+
13+
所有邏輯(夾值、百分比 <-> 純量轉換、切換)皆為純函式,並透過可注入的 :class:`VolumeDriver` 接縫執行,
14+
故能在不需音訊裝置的情況下完整測試。預設 driver 透過選用相依套件 ``pycaw``
15+
(``pip install je_auto_control[audio]``)驅動 Windows Core Audio 的
16+
``IAudioEndpointVolume`` 介面;在沒有該套件 / 非 Windows 平台上,預設 driver 會丟出清楚的錯誤,
17+
提示呼叫端傳入 ``driver=``。不匯入 ``PySide6``。
18+
19+
無頭 API
20+
--------
21+
22+
.. code-block:: python
23+
24+
from je_auto_control import (
25+
get_volume, set_volume, change_volume, is_muted, mute, unmute,
26+
toggle_mute,
27+
)
28+
29+
get_volume() # 例如 65 ——目前主音量百分比
30+
set_volume(30) # 設為 30 %,回傳 30
31+
change_volume(-10) # 降低 10 %,回傳套用後的百分比
32+
is_muted() # False
33+
mute() # True ——使輸出靜音
34+
unmute() # False ——還原
35+
toggle_mute() # 切換並回傳新狀態
36+
37+
測試時(或任何非 Windows 主機)可傳入 ``driver`` ——任何以 ``0.0..1.0`` 純量提供
38+
``get_scalar`` / ``set_scalar`` / ``get_mute`` / ``set_mute`` 的物件:
39+
40+
.. code-block:: python
41+
42+
class FakeVolume:
43+
def __init__(self, scalar=0.5, muted=False):
44+
self.scalar, self.muted = scalar, muted
45+
def get_scalar(self): return self.scalar
46+
def set_scalar(self, s): self.scalar = s
47+
def get_mute(self): return self.muted
48+
def set_mute(self, m): self.muted = m
49+
50+
drv = FakeVolume()
51+
set_volume(73, driver=drv) # 73,drv.scalar == 0.73
52+
53+
執行器指令
54+
----------
55+
56+
``AC_get_volume``(→ ``{volume, muted}``)、``AC_set_volume``(``level`` →
57+
``{volume}``)、``AC_change_volume``(``delta`` → ``{volume}``)、``AC_set_mute``
58+
(``muted`` → ``{muted}``)與 ``AC_toggle_mute``(→ ``{muted}``)。皆以對應的 ``ac_*``
59+
MCP 工具(讀取為唯讀、寫入為僅副作用)及 Script Builder 指令(位於 **Shell** 分類下)形式提供。
60+
執行器與 MCP 層使用預設 OS driver,故在 Windows 上需要 ``pycaw``。

je_auto_control/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@
100100
)
101101
# Resolve which application is registered to open a given file type
102102
from je_auto_control.utils.file_assoc import file_association, normalize_ext
103+
# Read and control the system master volume and mute state
104+
from je_auto_control.utils.system_volume import (
105+
change_volume, get_volume, is_muted, mute, set_mute, set_volume,
106+
toggle_mute, unmute,
107+
)
103108
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
104109
from je_auto_control.utils.clipboard_rich_formats import (
105110
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1713,6 +1718,8 @@ def start_autocontrol_gui(*args, **kwargs):
17131718
"idle_seconds", "is_idle", "plan_keep_awake",
17141719
"keep_awake", "keep_awake_on", "allow_sleep",
17151720
"normalize_ext", "file_association",
1721+
"get_volume", "set_volume", "change_volume",
1722+
"is_muted", "set_mute", "mute", "unmute", "toggle_mute",
17161723
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
17171724
"set_clipboard_rtf", "get_clipboard_rtf",
17181725
"set_clipboard_csv", "get_clipboard_csv",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4312,6 +4312,39 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
43124312
fields=(),
43134313
description="Release a previously-started keep-awake.",
43144314
))
4315+
specs.append(CommandSpec(
4316+
"AC_get_volume", "Shell", "Get System Volume",
4317+
fields=(),
4318+
description="Read the master volume percent and mute state.",
4319+
))
4320+
specs.append(CommandSpec(
4321+
"AC_set_volume", "Shell", "Set System Volume",
4322+
fields=(
4323+
FieldSpec("level", FieldType.INT, default=50,
4324+
placeholder="volume percent 0-100"),
4325+
),
4326+
description="Set the master volume to level percent (clamped 0-100).",
4327+
))
4328+
specs.append(CommandSpec(
4329+
"AC_change_volume", "Shell", "Change System Volume",
4330+
fields=(
4331+
FieldSpec("delta", FieldType.INT, default=10,
4332+
placeholder="percent delta (may be negative)"),
4333+
),
4334+
description="Add delta percent to the master volume (clamped 0-100).",
4335+
))
4336+
specs.append(CommandSpec(
4337+
"AC_set_mute", "Shell", "Set Mute",
4338+
fields=(
4339+
FieldSpec("muted", FieldType.BOOL, optional=True, default=True),
4340+
),
4341+
description="Mute or unmute the master output.",
4342+
))
4343+
specs.append(CommandSpec(
4344+
"AC_toggle_mute", "Shell", "Toggle Mute",
4345+
fields=(),
4346+
description="Flip the master mute flag.",
4347+
))
43154348
specs.append(CommandSpec(
43164349
"AC_normalize_ext", "Shell", "Normalize Extension",
43174350
fields=(

je_auto_control/utils/executor/action_executor.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2653,6 +2653,36 @@ def _allow_sleep() -> Dict[str, Any]:
26532653
return {"released": bool(allow_sleep())}
26542654

26552655

2656+
def _get_volume() -> Dict[str, Any]:
2657+
"""Adapter: the system master volume as an integer percent."""
2658+
from je_auto_control.utils.system_volume import get_volume, is_muted
2659+
return {"volume": int(get_volume()), "muted": bool(is_muted())}
2660+
2661+
2662+
def _set_volume(level: Any) -> Dict[str, Any]:
2663+
"""Adapter: set the master volume to ``level`` percent."""
2664+
from je_auto_control.utils.system_volume import set_volume
2665+
return {"volume": int(set_volume(float(level)))}
2666+
2667+
2668+
def _change_volume(delta: Any) -> Dict[str, Any]:
2669+
"""Adapter: add ``delta`` percent to the master volume."""
2670+
from je_auto_control.utils.system_volume import change_volume
2671+
return {"volume": int(change_volume(float(delta)))}
2672+
2673+
2674+
def _set_mute(muted: Any = True) -> Dict[str, Any]:
2675+
"""Adapter: set the master mute flag."""
2676+
from je_auto_control.utils.system_volume import set_mute
2677+
return {"muted": bool(set_mute(bool(muted)))}
2678+
2679+
2680+
def _toggle_mute() -> Dict[str, Any]:
2681+
"""Adapter: flip the master mute flag."""
2682+
from je_auto_control.utils.system_volume import toggle_mute
2683+
return {"muted": bool(toggle_mute())}
2684+
2685+
26562686
def _normalize_ext(target: str) -> Dict[str, Any]:
26572687
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
26582688
from je_auto_control.utils.file_assoc import normalize_ext
@@ -6662,6 +6692,11 @@ def __init__(self):
66626692
"AC_plan_keep_awake": _plan_keep_awake,
66636693
"AC_keep_awake_on": _keep_awake_on,
66646694
"AC_allow_sleep": _allow_sleep,
6695+
"AC_get_volume": _get_volume,
6696+
"AC_set_volume": _set_volume,
6697+
"AC_change_volume": _change_volume,
6698+
"AC_set_mute": _set_mute,
6699+
"AC_toggle_mute": _toggle_mute,
66656700
"AC_normalize_ext": _normalize_ext,
66666701
"AC_file_association": _file_association,
66676702
"AC_get_control_text": _get_control_text,

je_auto_control/utils/mcp_server/tools/_factories.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2168,6 +2168,49 @@ def process_and_shell_tools() -> List[MCPTool]:
21682168
handler=h.file_association,
21692169
annotations=READ_ONLY,
21702170
),
2171+
MCPTool(
2172+
name="ac_get_volume",
2173+
description=("Read the system master volume as an integer percent "
2174+
"0..100. Returns {volume, muted} (Windows, needs "
2175+
"the optional 'pycaw' dependency)."),
2176+
input_schema=schema({}),
2177+
handler=h.get_volume,
2178+
annotations=READ_ONLY,
2179+
),
2180+
MCPTool(
2181+
name="ac_set_volume",
2182+
description=("Set the master volume to 'level' percent (clamped to "
2183+
"0..100). Returns the applied {volume}."),
2184+
input_schema=schema({"level": {"type": "number"}},
2185+
required=["level"]),
2186+
handler=h.set_volume,
2187+
annotations=SIDE_EFFECT_ONLY,
2188+
),
2189+
MCPTool(
2190+
name="ac_change_volume",
2191+
description=("Add 'delta' percent to the master volume (may be "
2192+
"negative; clamped to 0..100). Returns {volume}."),
2193+
input_schema=schema({"delta": {"type": "number"}},
2194+
required=["delta"]),
2195+
handler=h.change_volume,
2196+
annotations=SIDE_EFFECT_ONLY,
2197+
),
2198+
MCPTool(
2199+
name="ac_set_mute",
2200+
description=("Mute or unmute the master output. 'muted' defaults to "
2201+
"true. Returns the new {muted} state."),
2202+
input_schema=schema({"muted": {"type": "boolean"}}),
2203+
handler=h.set_mute,
2204+
annotations=SIDE_EFFECT_ONLY,
2205+
),
2206+
MCPTool(
2207+
name="ac_toggle_mute",
2208+
description=("Flip the master mute flag. Returns the new {muted} "
2209+
"state."),
2210+
input_schema=schema({}),
2211+
handler=h.toggle_mute,
2212+
annotations=SIDE_EFFECT_ONLY,
2213+
),
21712214
]
21722215

21732216

je_auto_control/utils/mcp_server/tools/_handlers.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,31 @@ def allow_sleep():
595595
return _allow_sleep()
596596

597597

598+
def get_volume():
599+
from je_auto_control.utils.executor.action_executor import _get_volume
600+
return _get_volume()
601+
602+
603+
def set_volume(level):
604+
from je_auto_control.utils.executor.action_executor import _set_volume
605+
return _set_volume(level)
606+
607+
608+
def change_volume(delta):
609+
from je_auto_control.utils.executor.action_executor import _change_volume
610+
return _change_volume(delta)
611+
612+
613+
def set_mute(muted=True):
614+
from je_auto_control.utils.executor.action_executor import _set_mute
615+
return _set_mute(muted)
616+
617+
618+
def toggle_mute():
619+
from je_auto_control.utils.executor.action_executor import _toggle_mute
620+
return _toggle_mute()
621+
622+
598623
def normalize_ext(target):
599624
from je_auto_control.utils.executor.action_executor import _normalize_ext
600625
return _normalize_ext(target)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"""Read and control the system master volume and mute state."""
2+
from je_auto_control.utils.system_volume.system_volume import (
3+
VolumeDriver, change_volume, clamp_percent, get_volume, is_muted, mute,
4+
percent_to_scalar, scalar_to_percent, set_mute, set_volume, toggle_mute,
5+
unmute,
6+
)
7+
8+
__all__ = [
9+
"VolumeDriver", "get_volume", "set_volume", "change_volume",
10+
"is_muted", "set_mute", "mute", "unmute", "toggle_mute",
11+
"clamp_percent", "percent_to_scalar", "scalar_to_percent",
12+
]

0 commit comments

Comments
 (0)