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.
- voicemeeter_mcp-0.1.0/.github/workflows/ci.yml +33 -0
- voicemeeter_mcp-0.1.0/.github/workflows/publish.yml +18 -0
- voicemeeter_mcp-0.1.0/.gitignore +7 -0
- voicemeeter_mcp-0.1.0/LICENSE +21 -0
- voicemeeter_mcp-0.1.0/PKG-INFO +82 -0
- voicemeeter_mcp-0.1.0/README.md +59 -0
- voicemeeter_mcp-0.1.0/pyproject.toml +48 -0
- voicemeeter_mcp-0.1.0/src/voicemeeter_mcp/__init__.py +1 -0
- voicemeeter_mcp-0.1.0/src/voicemeeter_mcp/__main__.py +13 -0
- voicemeeter_mcp-0.1.0/src/voicemeeter_mcp/client.py +83 -0
- voicemeeter_mcp-0.1.0/src/voicemeeter_mcp/server.py +277 -0
- voicemeeter_mcp-0.1.0/src/voicemeeter_mcp/snapshots.py +80 -0
- voicemeeter_mcp-0.1.0/tests/test_server.py +102 -0
- voicemeeter_mcp-0.1.0/tests/test_snapshots.py +81 -0
|
@@ -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,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,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()
|