voicemeeter-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.12"
15
+ - run: pip install -e '.[dev]'
16
+ - name: Lint
17
+ run: ruff check src tests
18
+ - name: Tests
19
+ run: pytest
20
+ - name: Forbidden terms check
21
+ env:
22
+ FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }}
23
+ run: |
24
+ if [ -n "$FORBIDDEN_TERMS" ] && git grep -qiE "$FORBIDDEN_TERMS" -- . ; then
25
+ echo "::error::forbidden terms found in repository"
26
+ exit 1
27
+ fi
28
+ - name: Secret shape check
29
+ run: |
30
+ if git grep -nE '(password|refresh_token|client_secret)"?\s*[:=]\s*"[A-Za-z0-9_/-]{20,}' -- ':!tests' ; then
31
+ echo "::error::possible hardcoded credential"
32
+ exit 1
33
+ fi
@@ -0,0 +1,18 @@
1
+ name: Publish
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+
6
+ jobs:
7
+ pypi:
8
+ runs-on: ubuntu-latest
9
+ environment: pypi
10
+ permissions:
11
+ id-token: write
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.12"
17
+ - run: pip install build && python -m build
18
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ docs/superpowers/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .venv/
5
+ dist/
6
+ .pytest_cache/
7
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aaronckj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: voicemeeter-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for controlling Voicemeeter (Basic/Banana/Potato) on Windows: strip and bus gains, mutes, routing, levels, macro buttons, and presets.
5
+ Project-URL: Homepage, https://github.com/aaronckj/voicemeeter-mcp
6
+ Project-URL: Issues, https://github.com/aaronckj/voicemeeter-mcp/issues
7
+ Author: aaronckj
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: audio,mcp,mixer,model-context-protocol,streaming,voicemeeter
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Operating System :: Microsoft :: Windows
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: mcp>=1.2
18
+ Requires-Dist: voicemeeter-api>=2.5; sys_platform == 'win32'
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8; extra == 'dev'
21
+ Requires-Dist: ruff>=0.6; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # voicemeeter-mcp
25
+
26
+ An MCP (Model Context Protocol) server for controlling
27
+ [Voicemeeter](https://vb-audio.com/Voicemeeter/) (Basic, Banana, or Potato)
28
+ on Windows: strip and bus gains, mutes, output routing, live levels, macro
29
+ buttons, presets, and audio-engine restarts.
30
+
31
+ - Uses the official Voicemeeter Remote API (via
32
+ [voicemeeter-api](https://pypi.org/project/voicemeeter-api/)).
33
+ - **Windows-only, local-only** — the Remote API is an in-process DLL, so this
34
+ server must run on the PC where Voicemeeter runs. From another machine,
35
+ connect over SSH stdio (example below).
36
+ - Every mutating tool accepts `dry_run=true` and returns a preview instead of
37
+ acting.
38
+
39
+ ## Quick start (on the Voicemeeter PC)
40
+
41
+ ```bash
42
+ # select your edition: basic | banana | potato (default: banana)
43
+ set VM_MCP_KIND=banana
44
+
45
+ claude mcp add voicemeeter -s user -- uvx voicemeeter-mcp
46
+ ```
47
+
48
+ From a different machine (SSH stdio):
49
+
50
+ ```bash
51
+ claude mcp add voicemeeter -s user -- ssh streampc "uvx voicemeeter-mcp"
52
+ ```
53
+
54
+ ## Tools
55
+
56
+ | Tool | Purpose |
57
+ |---|---|
58
+ | `mixer_state` | full snapshot: every strip/bus with label, gain, mute, routing |
59
+ | `set_strip_gain` / `mute_strip` | input fader control |
60
+ | `set_strip_routing` | send a strip to A1/A2/A3/B1/B2 outputs |
61
+ | `set_bus_gain` / `mute_bus` | output control |
62
+ | `get_levels` | live post-fader levels — find silent or clipping paths |
63
+ | `trigger_macro_button` | press a Voicemeeter macro button |
64
+ | `load_preset` | load a settings XML |
65
+ | `restart_audio_engine` | the classic crackle fix |
66
+ | `health_check` | connection + version |
67
+
68
+ A typical agent ask: *"the game audio is too loud on stream"* →
69
+ `mixer_state` to find the strip by label → `set_strip_gain(strip, -12)`.
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ pip install -e '.[dev]'
75
+ ruff check src tests && pytest
76
+ ```
77
+
78
+ Tests run anywhere (fake Remote API); only live use requires Windows.
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,59 @@
1
+ # voicemeeter-mcp
2
+
3
+ An MCP (Model Context Protocol) server for controlling
4
+ [Voicemeeter](https://vb-audio.com/Voicemeeter/) (Basic, Banana, or Potato)
5
+ on Windows: strip and bus gains, mutes, output routing, live levels, macro
6
+ buttons, presets, and audio-engine restarts.
7
+
8
+ - Uses the official Voicemeeter Remote API (via
9
+ [voicemeeter-api](https://pypi.org/project/voicemeeter-api/)).
10
+ - **Windows-only, local-only** — the Remote API is an in-process DLL, so this
11
+ server must run on the PC where Voicemeeter runs. From another machine,
12
+ connect over SSH stdio (example below).
13
+ - Every mutating tool accepts `dry_run=true` and returns a preview instead of
14
+ acting.
15
+
16
+ ## Quick start (on the Voicemeeter PC)
17
+
18
+ ```bash
19
+ # select your edition: basic | banana | potato (default: banana)
20
+ set VM_MCP_KIND=banana
21
+
22
+ claude mcp add voicemeeter -s user -- uvx voicemeeter-mcp
23
+ ```
24
+
25
+ From a different machine (SSH stdio):
26
+
27
+ ```bash
28
+ claude mcp add voicemeeter -s user -- ssh streampc "uvx voicemeeter-mcp"
29
+ ```
30
+
31
+ ## Tools
32
+
33
+ | Tool | Purpose |
34
+ |---|---|
35
+ | `mixer_state` | full snapshot: every strip/bus with label, gain, mute, routing |
36
+ | `set_strip_gain` / `mute_strip` | input fader control |
37
+ | `set_strip_routing` | send a strip to A1/A2/A3/B1/B2 outputs |
38
+ | `set_bus_gain` / `mute_bus` | output control |
39
+ | `get_levels` | live post-fader levels — find silent or clipping paths |
40
+ | `trigger_macro_button` | press a Voicemeeter macro button |
41
+ | `load_preset` | load a settings XML |
42
+ | `restart_audio_engine` | the classic crackle fix |
43
+ | `health_check` | connection + version |
44
+
45
+ A typical agent ask: *"the game audio is too loud on stream"* →
46
+ `mixer_state` to find the strip by label → `set_strip_gain(strip, -12)`.
47
+
48
+ ## Development
49
+
50
+ ```bash
51
+ pip install -e '.[dev]'
52
+ ruff check src tests && pytest
53
+ ```
54
+
55
+ Tests run anywhere (fake Remote API); only live use requires Windows.
56
+
57
+ ## License
58
+
59
+ MIT
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "voicemeeter-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server for controlling Voicemeeter (Basic/Banana/Potato) on Windows: strip and bus gains, mutes, routing, levels, macro buttons, and presets."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [{ name = "aaronckj" }]
13
+ keywords = ["mcp", "voicemeeter", "audio", "mixer", "streaming", "model-context-protocol"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Operating System :: Microsoft :: Windows",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ ]
21
+ dependencies = [
22
+ "mcp>=1.2",
23
+ "voicemeeter-api>=2.5; sys_platform == 'win32'",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=8", "ruff>=0.6"]
28
+
29
+ [project.scripts]
30
+ voicemeeter-mcp = "voicemeeter_mcp.__main__:main"
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/aaronckj/voicemeeter-mcp"
34
+ Issues = "https://github.com/aaronckj/voicemeeter-mcp/issues"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/voicemeeter_mcp"]
38
+
39
+ [tool.ruff]
40
+ line-length = 100
41
+ target-version = "py311"
42
+
43
+ [tool.ruff.lint]
44
+ select = ["E", "F", "I", "UP", "B"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ addopts = "-q"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,13 @@
1
+ """CLI entry: run the MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def main() -> None:
7
+ from .server import build_app
8
+
9
+ build_app().run()
10
+
11
+
12
+ if __name__ == "__main__":
13
+ main()
@@ -0,0 +1,83 @@
1
+ """Voicemeeter Remote API connection wrapper (via voicemeeter-api / voicemeeterlib)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from typing import Any
8
+
9
+ logger = logging.getLogger("voicemeeter_mcp")
10
+ if not logger.handlers:
11
+ handler = logging.StreamHandler()
12
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
13
+ logger.addHandler(handler)
14
+ logger.setLevel(logging.INFO)
15
+
16
+ # Strip/bus counts per Voicemeeter kind: (hardware strips, virtual strips, buses)
17
+ KINDS = {
18
+ "basic": (2, 1, 2),
19
+ "banana": (3, 2, 5),
20
+ "potato": (5, 3, 8),
21
+ }
22
+
23
+
24
+ class VmError(RuntimeError):
25
+ pass
26
+
27
+
28
+ class VM:
29
+ """Lazy holder for a voicemeeterlib remote (injectable for tests)."""
30
+
31
+ def __init__(self, remote: Any = None, kind: str | None = None):
32
+ self._remote = remote
33
+ self.kind = kind or os.environ.get("VM_MCP_KIND", "banana")
34
+ if self.kind not in KINDS:
35
+ raise VmError(f"unknown Voicemeeter kind: {self.kind!r} (basic|banana|potato)")
36
+
37
+ @property
38
+ def remote(self) -> Any:
39
+ if self._remote is None:
40
+ try:
41
+ import voicemeeterlib
42
+ except ImportError as exc:
43
+ raise VmError(
44
+ "voicemeeter-api is Windows-only; run this server on the PC "
45
+ "where Voicemeeter is installed"
46
+ ) from exc
47
+ try:
48
+ self._remote = voicemeeterlib.api(self.kind)
49
+ self._remote.login()
50
+ except Exception as exc:
51
+ raise VmError(
52
+ f"cannot connect to Voicemeeter {self.kind} — is it running? ({exc})"
53
+ ) from exc
54
+ return self._remote
55
+
56
+ def counts(self) -> tuple[int, int, int]:
57
+ return KINDS[self.kind]
58
+
59
+ def strip(self, i: int) -> Any:
60
+ hw, virt, _ = self.counts()
61
+ if not 0 <= i < hw + virt:
62
+ raise VmError(f"strip index {i} out of range for {self.kind} (0..{hw + virt - 1})")
63
+ return self.remote.strip[i]
64
+
65
+ def bus(self, i: int) -> Any:
66
+ _, _, buses = self.counts()
67
+ if not 0 <= i < buses:
68
+ raise VmError(f"bus index {i} out of range for {self.kind} (0..{buses - 1})")
69
+ return self.remote.bus[i]
70
+
71
+
72
+ def preview(action: str, would: dict) -> dict:
73
+ return {"preview": True, "action": action, "would": would}
74
+
75
+
76
+ _vm: VM | None = None
77
+
78
+
79
+ def get_vm() -> VM:
80
+ global _vm
81
+ if _vm is None:
82
+ _vm = VM()
83
+ return _vm
@@ -0,0 +1,277 @@
1
+ """FastMCP app: Voicemeeter strips, buses, macro buttons, presets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from .client import get_vm, preview
8
+
9
+ ROUTES = ("A1", "A2", "A3", "B1", "B2", "B3")
10
+
11
+
12
+ def build_app() -> FastMCP:
13
+ mcp = FastMCP(
14
+ "voicemeeter",
15
+ instructions=(
16
+ "Control Voicemeeter (Basic/Banana/Potato) on this Windows machine: "
17
+ "strip/bus gains and mutes, output routing, levels, macro buttons, "
18
+ "and presets. Strips are inputs (hardware first, then virtual); "
19
+ "buses are outputs (A = hardware, B = virtual). Every mutating tool "
20
+ "accepts dry_run=True."
21
+ ),
22
+ )
23
+
24
+ @mcp.tool()
25
+ def mixer_state() -> dict:
26
+ """Full mixer snapshot: every strip and bus with label, gain, mute, routing."""
27
+ vm = get_vm()
28
+ hw, virt, buses = vm.counts()
29
+ strips = []
30
+ for i in range(hw + virt):
31
+ s = vm.strip(i)
32
+ routing = {r: bool(getattr(s, r, False)) for r in ROUTES if hasattr(s, r)}
33
+ strips.append(
34
+ {
35
+ "index": i,
36
+ "label": s.label,
37
+ "kind": "hardware" if i < hw else "virtual",
38
+ "gain_db": round(s.gain, 1),
39
+ "muted": bool(s.mute),
40
+ "routing": routing,
41
+ }
42
+ )
43
+ bus_list = []
44
+ for i in range(buses):
45
+ b = vm.bus(i)
46
+ bus_list.append(
47
+ {
48
+ "index": i,
49
+ "label": b.label,
50
+ "gain_db": round(b.gain, 1),
51
+ "muted": bool(b.mute),
52
+ }
53
+ )
54
+ return {"kind": vm.kind, "strips": strips, "buses": bus_list}
55
+
56
+ @mcp.tool()
57
+ def set_strip_gain(strip: int, gain_db: float, dry_run: bool = False) -> dict:
58
+ """Set a strip's fader gain in dB (0 = unity; range -60..+12)."""
59
+ if dry_run:
60
+ return preview("set_strip_gain", {"strip": strip, "gain_db": gain_db})
61
+ vm = get_vm()
62
+ vm.strip(strip).gain = max(-60.0, min(12.0, gain_db))
63
+ return {"strip": strip, "gain_db": gain_db}
64
+
65
+ @mcp.tool()
66
+ def mute_strip(strip: int, muted: bool, dry_run: bool = False) -> dict:
67
+ """Mute or unmute an input strip."""
68
+ if dry_run:
69
+ return preview("mute_strip", {"strip": strip, "muted": muted})
70
+ vm = get_vm()
71
+ vm.strip(strip).mute = muted
72
+ return {"strip": strip, "muted": muted}
73
+
74
+ @mcp.tool()
75
+ def set_strip_routing(strip: int, route: str, enabled: bool, dry_run: bool = False) -> dict:
76
+ """Send or stop sending a strip to an output. route: A1|A2|A3|B1|B2 (kind-dependent)."""
77
+ route = route.upper()
78
+ if route not in ROUTES:
79
+ return {"error": f"route must be one of {ROUTES}"}
80
+ if dry_run:
81
+ return preview(
82
+ "set_strip_routing", {"strip": strip, "route": route, "enabled": enabled}
83
+ )
84
+ vm = get_vm()
85
+ s = vm.strip(strip)
86
+ if not hasattr(s, route):
87
+ return {"error": f"{vm.kind} has no {route} routing on strip {strip}"}
88
+ setattr(s, route, enabled)
89
+ return {"strip": strip, "route": route, "enabled": enabled}
90
+
91
+ @mcp.tool()
92
+ def set_bus_gain(bus: int, gain_db: float, dry_run: bool = False) -> dict:
93
+ """Set a bus (output) gain in dB."""
94
+ if dry_run:
95
+ return preview("set_bus_gain", {"bus": bus, "gain_db": gain_db})
96
+ vm = get_vm()
97
+ vm.bus(bus).gain = max(-60.0, min(12.0, gain_db))
98
+ return {"bus": bus, "gain_db": gain_db}
99
+
100
+ @mcp.tool()
101
+ def mute_bus(bus: int, muted: bool, dry_run: bool = False) -> dict:
102
+ """Mute or unmute an output bus."""
103
+ if dry_run:
104
+ return preview("mute_bus", {"bus": bus, "muted": muted})
105
+ vm = get_vm()
106
+ vm.bus(bus).mute = muted
107
+ return {"bus": bus, "muted": muted}
108
+
109
+ @mcp.tool()
110
+ def get_levels() -> dict:
111
+ """Current audio levels (post-fader) per strip and bus — spot silent or clipping paths."""
112
+ vm = get_vm()
113
+ hw, virt, buses = vm.counts()
114
+ return {
115
+ "strips": [
116
+ {
117
+ "index": i,
118
+ "label": vm.strip(i).label,
119
+ "levels": list(vm.strip(i).levels.postfader),
120
+ }
121
+ for i in range(hw + virt)
122
+ ],
123
+ "buses": [
124
+ {"index": i, "label": vm.bus(i).label, "levels": list(vm.bus(i).levels.all)}
125
+ for i in range(buses)
126
+ ],
127
+ }
128
+
129
+ @mcp.tool()
130
+ def trigger_macro_button(button: int, dry_run: bool = False) -> dict:
131
+ """Press-and-release a Voicemeeter macro button by index."""
132
+ if dry_run:
133
+ return preview("trigger_macro_button", {"button": button})
134
+ vm = get_vm()
135
+ vm.remote.button[button].trigger = True
136
+ vm.remote.button[button].trigger = False
137
+ return {"triggered": button}
138
+
139
+ @mcp.tool()
140
+ def load_preset(xml_path: str, dry_run: bool = False) -> dict:
141
+ """Load a Voicemeeter settings XML preset from the local filesystem."""
142
+ if dry_run:
143
+ return preview("load_preset", {"xml_path": xml_path})
144
+ vm = get_vm()
145
+ vm.remote.set("command.load", xml_path)
146
+ return {"loaded": xml_path}
147
+
148
+ @mcp.tool()
149
+ def restart_audio_engine(dry_run: bool = False) -> dict:
150
+ """Restart the Voicemeeter audio engine (fixes crackle/desync; brief dropout)."""
151
+ if dry_run:
152
+ return preview("restart_audio_engine", {"note": "brief audio dropout"})
153
+ vm = get_vm()
154
+ vm.remote.command.restart()
155
+ return {"restarted": True}
156
+
157
+ @mcp.tool()
158
+ def snapshot_state(name: str = "baseline") -> dict:
159
+ """Save the full mixer state (gains, mutes, routing, comp/gate, EQ) to a
160
+ named snapshot file (letters/digits/_/- only). Take one when the stream
161
+ sounds RIGHT."""
162
+ from .snapshots import capture_state, save_snapshot
163
+
164
+ vm = get_vm()
165
+ state = capture_state(vm)
166
+ try:
167
+ path = save_snapshot(state, name)
168
+ except ValueError as exc:
169
+ return {"error": str(exc)}
170
+ return {"snapshot_path": str(path), "kind": state["kind"]}
171
+
172
+ @mcp.tool()
173
+ def diff_vs_snapshot(snapshot_path: str) -> dict:
174
+ """Compare the LIVE mixer state against a saved snapshot — answers
175
+ 'what changed since the known-good config'. Snapshot must live in the
176
+ snapshot directory (as written by snapshot_state)."""
177
+ import json as _json
178
+
179
+ from .snapshots import capture_state, diff_states, resolve_snapshot_path
180
+
181
+ vm = get_vm()
182
+ try:
183
+ path = resolve_snapshot_path(snapshot_path)
184
+ except ValueError as exc:
185
+ return {"error": str(exc)}
186
+ baseline = _json.loads(path.read_text())
187
+ changes = diff_states(baseline, capture_state(vm))
188
+ return {"changes": changes, "in_sync": not changes}
189
+
190
+ @mcp.tool()
191
+ def set_strip_comp(strip: int, amount: float, dry_run: bool = False) -> dict:
192
+ """Set a strip's compressor knob (0.0 = off … 10.0 = max)."""
193
+ if dry_run:
194
+ return preview("set_strip_comp", {"strip": strip, "amount": amount})
195
+ vm = get_vm()
196
+ s = vm.strip(strip)
197
+ target = getattr(s, "comp", None)
198
+ if target is None:
199
+ return {"error": f"strip {strip} has no compressor on {vm.kind}"}
200
+ amount = max(0.0, min(10.0, amount))
201
+ if hasattr(target, "knob"):
202
+ target.knob = amount
203
+ else:
204
+ s.comp = amount
205
+ return {"strip": strip, "comp": amount}
206
+
207
+ @mcp.tool()
208
+ def set_strip_gate(strip: int, amount: float, dry_run: bool = False) -> dict:
209
+ """Set a strip's noise-gate knob (0.0 = off … 10.0 = max)."""
210
+ if dry_run:
211
+ return preview("set_strip_gate", {"strip": strip, "amount": amount})
212
+ vm = get_vm()
213
+ s = vm.strip(strip)
214
+ target = getattr(s, "gate", None)
215
+ if target is None:
216
+ return {"error": f"strip {strip} has no gate on {vm.kind}"}
217
+ amount = max(0.0, min(10.0, amount))
218
+ if hasattr(target, "knob"):
219
+ target.knob = amount
220
+ else:
221
+ s.gate = amount
222
+ return {"strip": strip, "gate": amount}
223
+
224
+ @mcp.tool()
225
+ def set_bus_eq(bus: int, enabled: bool, dry_run: bool = False) -> dict:
226
+ """Enable or disable the parametric EQ on a bus."""
227
+ if dry_run:
228
+ return preview("set_bus_eq", {"bus": bus, "enabled": enabled})
229
+ vm = get_vm()
230
+ b = vm.bus(bus)
231
+ target = getattr(b, "eq", None)
232
+ if target is None:
233
+ return {"error": f"bus {bus} has no EQ on {vm.kind}"}
234
+ if hasattr(target, "on"):
235
+ target.on = enabled
236
+ else:
237
+ b.eq = enabled
238
+ return {"bus": bus, "eq": enabled}
239
+
240
+ @mcp.tool()
241
+ def set_strip_device(
242
+ strip: int, device_name: str, driver: str = "wdm", dry_run: bool = False
243
+ ) -> dict:
244
+ """Bind a hardware strip to a physical input device.
245
+ driver: wdm|mme|ks|asio. Hardware strips only (virtual strips have no device)."""
246
+ if driver not in ("wdm", "mme", "ks", "asio"):
247
+ return {"error": "driver must be wdm|mme|ks|asio"}
248
+ if dry_run:
249
+ return preview(
250
+ "set_strip_device", {"strip": strip, "device": device_name, "driver": driver}
251
+ )
252
+ vm = get_vm()
253
+ hw, _, _ = vm.counts()
254
+ if strip >= hw:
255
+ return {"error": f"strip {strip} is virtual; only strips 0..{hw - 1} take devices"}
256
+ s = vm.strip(strip)
257
+ setattr(s.device, driver, device_name)
258
+ return {"strip": strip, "device": device_name, "driver": driver}
259
+
260
+ @mcp.tool()
261
+ def health_check() -> dict:
262
+ """Verify connection to Voicemeeter and report kind/version."""
263
+ try:
264
+ vm = get_vm()
265
+ hw, virt, buses = vm.counts()
266
+ _ = vm.strip(0).label
267
+ return {
268
+ "status": "ok",
269
+ "kind": vm.kind,
270
+ "strips": hw + virt,
271
+ "buses": buses,
272
+ "version": getattr(vm.remote, "version", "unknown"),
273
+ }
274
+ except Exception as exc: # noqa: BLE001 - health check reports, never raises
275
+ return {"status": f"error: {exc}"}
276
+
277
+ return mcp
@@ -0,0 +1,80 @@
1
+ """Mixer state snapshots and diffs — "what changed since the known-good config"."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ SNAPSHOT_DIR = Path.home() / ".config" / "voicemeeter-mcp" / "snapshots"
11
+ _NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
12
+
13
+
14
+ def resolve_snapshot_path(path: str) -> Path:
15
+ """Resolve a snapshot path, refusing anything outside SNAPSHOT_DIR."""
16
+ rp = Path(path).expanduser().resolve()
17
+ base = SNAPSHOT_DIR.resolve()
18
+ if not rp.is_relative_to(base):
19
+ raise ValueError(f"snapshot path must be inside {base}")
20
+ return rp
21
+
22
+ STRIP_PARAMS = ("gain", "mute", "A1", "A2", "A3", "B1", "B2", "B3", "comp", "gate")
23
+ BUS_PARAMS = ("gain", "mute", "eq")
24
+
25
+
26
+ def capture_state(vm) -> dict:
27
+ hw, virt, buses = vm.counts()
28
+ state: dict = {"kind": vm.kind, "strips": {}, "buses": {}}
29
+ for i in range(hw + virt):
30
+ s = vm.strip(i)
31
+ entry = {"label": s.label}
32
+ for p in STRIP_PARAMS:
33
+ if hasattr(s, p):
34
+ v = getattr(s, p)
35
+ # comp/gate are sub-objects (knob) in some lib versions
36
+ v = getattr(v, "knob", v)
37
+ entry[p] = round(v, 2) if isinstance(v, float) else v
38
+ state["strips"][str(i)] = entry
39
+ for i in range(buses):
40
+ b = vm.bus(i)
41
+ entry = {"label": b.label}
42
+ for p in BUS_PARAMS:
43
+ if hasattr(b, p):
44
+ v = getattr(b, p)
45
+ v = getattr(v, "on", v)
46
+ entry[p] = round(v, 2) if isinstance(v, float) else v
47
+ state["buses"][str(i)] = entry
48
+ return state
49
+
50
+
51
+ def diff_states(baseline: dict, current: dict) -> list[dict]:
52
+ """List every parameter that differs between two snapshots."""
53
+ changes: list[dict] = []
54
+ for section, singular in (("strips", "strip"), ("buses", "bus")):
55
+ base_sec = baseline.get(section, {})
56
+ cur_sec = current.get(section, {})
57
+ for idx in sorted(set(base_sec) | set(cur_sec), key=lambda x: int(x)):
58
+ b = base_sec.get(idx, {})
59
+ c = cur_sec.get(idx, {})
60
+ for key in sorted(set(b) | set(c)):
61
+ if b.get(key) != c.get(key):
62
+ changes.append(
63
+ {
64
+ "where": f"{singular} {idx} ({c.get('label') or b.get('label')})",
65
+ "param": key,
66
+ "baseline": b.get(key),
67
+ "current": c.get(key),
68
+ }
69
+ )
70
+ return changes
71
+
72
+
73
+ def save_snapshot(state: dict, name: str) -> Path:
74
+ if not _NAME_RE.match(name):
75
+ raise ValueError("snapshot name must be 1-64 chars of letters, digits, _ or -")
76
+ SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
77
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
78
+ path = SNAPSHOT_DIR / f"{name}-{stamp}.json"
79
+ path.write_text(json.dumps(state, indent=2))
80
+ return path
@@ -0,0 +1,102 @@
1
+ import asyncio
2
+ import json
3
+ from types import SimpleNamespace
4
+
5
+ import voicemeeter_mcp.client as client_mod
6
+ from voicemeeter_mcp.client import VM, VmError
7
+ from voicemeeter_mcp.server import build_app
8
+
9
+ EXPECTED = {
10
+ "mixer_state", "set_strip_gain", "mute_strip", "set_strip_routing",
11
+ "set_bus_gain", "mute_bus", "get_levels", "trigger_macro_button",
12
+ "load_preset", "restart_audio_engine", "health_check",
13
+ "snapshot_state", "diff_vs_snapshot", "set_strip_comp", "set_strip_gate",
14
+ "set_bus_eq", "set_strip_device",
15
+ }
16
+
17
+
18
+ class FakeStrip:
19
+ def __init__(self, label):
20
+ self.label = label
21
+ self.gain = 0.0
22
+ self.mute = False
23
+ self.A1 = True
24
+ self.A2 = False
25
+ self.B1 = False
26
+ self.levels = SimpleNamespace(postfader=[-20.0, -20.0])
27
+
28
+
29
+ class FakeBus:
30
+ def __init__(self, label):
31
+ self.label = label
32
+ self.gain = 0.0
33
+ self.mute = False
34
+ self.levels = SimpleNamespace(all=[-18.0, -18.0])
35
+
36
+
37
+ class FakeRemote:
38
+ def __init__(self):
39
+ self.strip = [FakeStrip(f"strip{i}") for i in range(5)]
40
+ self.bus = [FakeBus(f"bus{i}") for i in range(5)]
41
+ self.version = "banana 2.1.1.4"
42
+
43
+
44
+ def make_app():
45
+ client_mod._vm = VM(remote=FakeRemote(), kind="banana")
46
+ return build_app()
47
+
48
+
49
+ def teardown_function():
50
+ client_mod._vm = None
51
+
52
+
53
+ def call_tool(app, name, args=None):
54
+ result = asyncio.run(app.call_tool(name, args or {}))
55
+ blocks = result[0] if isinstance(result, tuple) else result
56
+ return json.loads(blocks[0].text)
57
+
58
+
59
+ def test_all_tools_registered():
60
+ app = make_app()
61
+ tools = asyncio.run(app.list_tools())
62
+ assert {t.name for t in tools} == EXPECTED
63
+
64
+
65
+ def test_mixer_state_counts_banana():
66
+ app = make_app()
67
+ out = call_tool(app, "mixer_state")
68
+ assert out["kind"] == "banana"
69
+ assert len(out["strips"]) == 5 # 3 hardware + 2 virtual
70
+ assert len(out["buses"]) == 5
71
+ assert out["strips"][0]["routing"]["A1"] is True
72
+
73
+
74
+ def test_set_strip_gain_clamps():
75
+ app = make_app()
76
+ call_tool(app, "set_strip_gain", {"strip": 0, "gain_db": -100})
77
+ assert client_mod._vm.strip(0).gain == -60.0
78
+
79
+
80
+ def test_mute_strip_dry_run_no_change():
81
+ app = make_app()
82
+ out = call_tool(app, "mute_strip", {"strip": 1, "muted": True, "dry_run": True})
83
+ assert out["preview"] is True
84
+ assert client_mod._vm.strip(1).mute is False
85
+
86
+
87
+ def test_routing_validation():
88
+ app = make_app()
89
+ out = call_tool(app, "set_strip_routing", {"strip": 0, "route": "Z9", "enabled": True})
90
+ assert "error" in out
91
+ out = call_tool(app, "set_strip_routing", {"strip": 0, "route": "b1", "enabled": True})
92
+ assert out["enabled"] is True
93
+ assert client_mod._vm.strip(0).B1 is True
94
+
95
+
96
+ def test_strip_index_out_of_range():
97
+ client_mod._vm = VM(remote=FakeRemote(), kind="banana")
98
+ try:
99
+ client_mod._vm.strip(9)
100
+ raise AssertionError("expected VmError")
101
+ except VmError as exc:
102
+ assert "out of range" in str(exc)
@@ -0,0 +1,81 @@
1
+ from types import SimpleNamespace
2
+
3
+ from voicemeeter_mcp.client import VM
4
+ from voicemeeter_mcp.snapshots import capture_state, diff_states
5
+
6
+
7
+ class Knob:
8
+ def __init__(self, v):
9
+ self.knob = v
10
+
11
+
12
+ class FakeStrip:
13
+ def __init__(self, label, gain=0.0):
14
+ self.label = label
15
+ self.gain = gain
16
+ self.mute = False
17
+ self.A1 = True
18
+ self.B1 = False
19
+ self.comp = Knob(0.0)
20
+ self.gate = Knob(2.5)
21
+ self.levels = SimpleNamespace(postfader=[-20.0])
22
+
23
+
24
+ class FakeBus:
25
+ def __init__(self, label):
26
+ self.label = label
27
+ self.gain = 0.0
28
+ self.mute = False
29
+ self.eq = SimpleNamespace(on=False)
30
+ self.levels = SimpleNamespace(all=[-18.0])
31
+
32
+
33
+ class FakeRemote:
34
+ def __init__(self):
35
+ self.strip = [FakeStrip(f"s{i}") for i in range(5)]
36
+ self.bus = [FakeBus(f"b{i}") for i in range(5)]
37
+
38
+
39
+ def test_capture_and_diff_roundtrip():
40
+ vm = VM(remote=FakeRemote(), kind="banana")
41
+ baseline = capture_state(vm)
42
+ assert baseline["strips"]["0"]["gate"] == 2.5
43
+ assert diff_states(baseline, capture_state(vm)) == []
44
+
45
+
46
+ def test_diff_detects_changes():
47
+ vm = VM(remote=FakeRemote(), kind="banana")
48
+ baseline = capture_state(vm)
49
+ vm.remote.strip[1].gain = -12.0
50
+ vm.remote.strip[1].mute = True
51
+ vm.remote.bus[0].eq = SimpleNamespace(on=True)
52
+ changes = diff_states(baseline, capture_state(vm))
53
+ params = {(c["where"], c["param"]) for c in changes}
54
+ assert ("strip 1 (s1)", "gain") in params
55
+ assert ("strip 1 (s1)", "mute") in params
56
+ assert ("bus 0 (b0)", "eq") in params
57
+ assert len(changes) == 3
58
+
59
+
60
+ def test_snapshot_name_rejects_traversal():
61
+ import pytest
62
+
63
+ from voicemeeter_mcp.snapshots import save_snapshot
64
+
65
+ with pytest.raises(ValueError):
66
+ save_snapshot({}, "../../evil")
67
+ with pytest.raises(ValueError):
68
+ save_snapshot({}, "a/b")
69
+
70
+
71
+ def test_resolve_snapshot_path_confined(tmp_path):
72
+ import pytest
73
+
74
+ from voicemeeter_mcp import snapshots
75
+
76
+ with pytest.raises(ValueError):
77
+ snapshots.resolve_snapshot_path("/etc/passwd")
78
+ with pytest.raises(ValueError):
79
+ snapshots.resolve_snapshot_path(str(snapshots.SNAPSHOT_DIR / ".." / "x.json"))
80
+ ok = snapshots.SNAPSHOT_DIR / "good.json"
81
+ assert snapshots.resolve_snapshot_path(str(ok)) == ok.resolve()