7 Commits

Author SHA1 Message Date
61a37bcd0f patch bump 2026-02-28 15:48:28 +00:00
b62ee185c3 upd use section in README 2026-02-28 15:39:39 +00:00
c7365bfe4e add new help formatters for different kinds of commands 2026-02-28 15:39:26 +00:00
c660778698 if completion flag return early to avoid unnecessary VBAN connections
patch bump
2026-02-28 13:34:46 +00:00
5460965945 add to further notes 2026-02-27 23:06:00 +00:00
d03049e713 enable shell completion scripts 2026-02-27 22:59:20 +00:00
1dd518095a eq commands should target the right kind of eq
cell command group has now been attached to eq. this modifies the structure of CLI slightly.
2026-02-27 22:50:00 +00:00
11 changed files with 243 additions and 68 deletions

View File

@@ -5,6 +5,8 @@
---
This CLI is still in an early stage of development with many more things that could be implemented. However, the commands that are implemented should be working without issues.
## Install
#### With uv
@@ -47,24 +49,35 @@ export VBAN_CLI_STREAMNAME=Command1
## Use
```console
Usage: vban-cli COMMAND
### Strip Command
╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────╮
│ bus Control the bus parameters. │
│ strip Control the strip parameters. │
│ --help (-h) Display this message and exit. │
│ --version Display application version. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Parameters ─────────────────────────────────────────────────────────────────────────────────────╮
│ --kind Kind of Voicemeeter [env var: VBAN_CLI_KIND] [default: potato] │
│ --host VBAN host [env var: VBAN_CLI_HOST] [default: localhost] │
│ --port VBAN port [env var: VBAN_CLI_PORT] [default: 6980] │
│ --streamname VBAN stream name [env var: VBAN_CLI_STREAMNAME] [default: Command1] │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Usage: vban-cli strip <index> COMMAND [ARGS]
examples:
```console
vban-cli strip 0 mute true
vban-cli strip 1 A1 true
vban-cli strip 2 gain -18.7
```
For every command and subcommand there exists a `--help` flag for further usage information.
see `vban-cli strip --help` for more info.
### Bus Command
Usage: vban-cli bus <index> COMMAND [ARGS]
examples:
```console
vban-cli bus mode normal
vban-cli bus mute true
```
see `vban-cli bus --help` for more info.
---
@@ -74,13 +87,16 @@ For every command and subcommand there exists a `--help` flag for further usage
2. Packet structure with [ident:1][ident-1] is emitted by the VBAN server only on pdirty events. This means we do not receive the initial state of those parameters on initial subscription. Therefore any commands which are intended to fetch the value of parameters defined in packet [ident:1][ident-1] will not work in this CLI.
3. Packet structure with [ident:1][ident-1] defines parameteric EQ data only for the [first channel][ident-1-peq].
---
## Further Notes
I've made the effort to set up the basic skeletal structure of the CLI as well as demonstrate how to combine subcommand groups with subcommand groups so more can be implemented, it just needs doing. There may be restrictions on some things however, for example, retrieving values is only possible for parameters [defined in the protocol](https://github.com/onyx-and-iris/Voicemeeter-SDK/blob/3be2c1c36563afbd6df3da8436406c77d2cc1f10/VoicemeeterRemote.h#L787). Setting parameters can be done for anything possible by a string request.
Shell completion scripts are available (for zsh, bash and fish) but I haven't tested them
Some of the help output needs improving for commands that branch off positional arguments.
If there's something missing that you would like to see added the best bet is to submit a PR. You may raise an issue and if it's quick and simple to do I may (or may not) do it.
---

View File

@@ -1,11 +1,15 @@
[project]
name = "vban-cli"
version = "0.3.0"
version = "0.4.2"
description = "A command-line interface for Voicemeeter leveraging VBAN."
readme = "README.md"
license = { text = "LICENSE" }
requires-python = ">=3.13"
dependencies = ["cyclopts>=4.6.0", "vban-cmd>=2.6.0"]
dependencies = [
"cyclopts>=4.6.0",
"loguru>=0.7.3",
"vban-cmd>=2.6.0",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Programming Language :: Python",

View File

@@ -16,6 +16,7 @@ app = App(
)
app.command(strip.app.meta, name='strip')
app.command(bus.app.meta, name='bus')
app.register_install_completion_command()
@Parameter(name='*')
@@ -32,17 +33,17 @@ def launcher(
*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
vban_config: Annotated[VBANConfig, Parameter()] = VBANConfig(),
):
command, bound, _ = app.parse_args(tokens)
if tokens[0] == '--install-completion':
return command(*bound.args, **bound.kwargs)
with vban_cmd.api(
vban_config.kind,
ip=vban_config.host,
port=vban_config.port,
streamname=vban_config.streamname,
) as client:
additional_kwargs = {}
command, bound, _ = app.parse_args(tokens)
additional_kwargs['ctx'] = Context(client=client)
return command(*bound.args, **bound.kwargs, **additional_kwargs)
return command(*bound.args, **bound.kwargs, ctx=Context(client=client))
def run():

View File

@@ -4,9 +4,9 @@ from cyclopts import App, Argument, Parameter
from . import console
from .context import Context
from .help import CustomHelpFormatter
from .help import BusHelpFormatter
app = App(name='bus', help_formatter=CustomHelpFormatter())
app = App(name='bus', help_formatter=BusHelpFormatter())
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 1.
# app.command(eq.app.meta, name='eq')
@@ -20,6 +20,8 @@ def launcher(
"""Control the bus parameters."""
additional_kwargs = {}
command, bound, _ = app.parse_args(tokens)
if tokens[0] == 'eq':
additional_kwargs['eq_kind'] = app.name[0]
if index is not None:
additional_kwargs['index'] = index
if ctx is not None:

View File

@@ -3,9 +3,9 @@ from typing import Annotated
from cyclopts import App, Argument, Parameter
from .context import Context
from .help import CustomHelpFormatter
from .help import StripSubcommandHelpFormatter
app = App(name='comp', help_formatter=CustomHelpFormatter())
app = App(name='comp', help_formatter=StripSubcommandHelpFormatter())
@app.meta.default

View File

@@ -3,9 +3,9 @@ from typing import Annotated
from cyclopts import App, Argument, Parameter
from .context import Context
from .help import CustomHelpFormatter
from .help import StripSubcommandHelpFormatter
app = App(name='denoiser', help_formatter=CustomHelpFormatter())
app = App(name='denoiser', help_formatter=StripSubcommandHelpFormatter())
@app.meta.default

View File

@@ -3,30 +3,32 @@ from typing import Annotated
from cyclopts import App, Argument, Parameter
from .context import Context
from .help import CustomHelpFormatter
from .help import CellHelpFormatter, EqHelpFormatter
app = App(name='eq', help_formatter=CustomHelpFormatter())
cell_app = App(name='cell', help_formatter=CellHelpFormatter())
app = App(name='eq', help_formatter=EqHelpFormatter())
app.command(cell_app.meta, name='cell')
@app.meta.default
def launcher(
band: Annotated[int, Argument()] = None,
*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
eq_kind: Annotated[str, Parameter(show=False)] = None,
index: Annotated[int, Argument()] = None,
ctx: Annotated[Context, Parameter(show=False)] = None,
):
"""Control the EQ parameters.
Only channel 0 is supported, see https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 3.
"""
"""Control the EQ parameters."""
additional_kwargs = {}
command, bound, _ = app.parse_args(tokens)
if index is not None:
additional_kwargs['index'] = index
if band is not None:
additional_kwargs['band'] = band
if ctx is not None:
additional_kwargs['ctx'] = ctx
match eq_kind:
case 'strip':
target = ctx.client.strip[index].eq
case 'bus':
target = ctx.client.bus[index].eq
case _:
raise ValueError(f'Invalid eq_kind: {eq_kind}')
additional_kwargs['target'] = target
return command(*bound.args, **bound.kwargs, **additional_kwargs)
@@ -35,9 +37,7 @@ def launcher(
def on(
new_state: Annotated[bool, Argument()] = None,
*,
index: Annotated[int, Parameter(show=False)] = None,
band: Annotated[int, Parameter(show=False)] = None,
ctx: Annotated[Context, Parameter(show=False)] = None,
target: Annotated[object, Parameter(show=False)] = None,
):
"""Get or set the on state of the specified EQ band.
@@ -48,6 +48,43 @@ def on(
"""
if new_state is None:
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2.
# console.out.print(ctx.client.strip[index].eq.channel[0].cell[band].on)
# console.out.print(target.on)
return
ctx.client.strip[index].eq.channel[0].cell[band].on = new_state
target.on = new_state
@cell_app.meta.default
def cell_launcher(
band: Annotated[int, Argument()] = None,
*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
target: Annotated[object, Parameter(show=False)] = None,
):
"""Control the EQ Cell parameters.
Only channel 0 is supported, see https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 3.
"""
additional_kwargs = {}
command, bound, _ = app.parse_args(tokens)
additional_kwargs['target'] = target.channel[0].cell[band]
return command(*bound.args, **bound.kwargs, **additional_kwargs)
@cell_app.command(name='on')
def cell_on(
new_state: Annotated[bool, Argument()] = None,
*,
target: Annotated[object, Parameter(show=False)] = None,
):
"""Get or set the on state of the specified EQ cell.
Parameters
----------
new_state : bool
If provided, sets the on state to this value. If not provided, the current on state is printed.
"""
if new_state is None:
# See https://github.com/onyx-and-iris/vban-cli?tab=readme-ov-file#implementation-notes - 2.
# console.out.print(target.on)
return
target.on = new_state

View File

@@ -3,9 +3,9 @@ from typing import Annotated
from cyclopts import App, Argument, Parameter
from .context import Context
from .help import CustomHelpFormatter
from .help import StripSubcommandHelpFormatter
app = App(name='gate', help_formatter=CustomHelpFormatter())
app = App(name='gate', help_formatter=StripSubcommandHelpFormatter())
@app.meta.default

View File

@@ -4,31 +4,24 @@ from cyclopts.help import DefaultFormatter, HelpPanel
from rich.console import Console, ConsoleOptions
class CustomHelpFormatter(DefaultFormatter):
"""Custom help formatter that injects an index argument into the usage line and filters it out from the parameters list.
This formatter modifies the usage line to include an <index> argument after the 'strip' command,
and filters out any parameters related to 'index' from the Parameters section of the help output.
"""
def render_usage(self, console: Console, options: ConsoleOptions, usage) -> None:
"""Render the usage line with index argument injected."""
if usage:
modified_usage = re.sub(
r'(\S+\s+[a-z]+)\s+(COMMAND)', r'\1 <index> \2', str(usage)
)
console.print(f'[bold]Usage:[/bold] {modified_usage}')
class BaseHelpFormatter(DefaultFormatter):
"""Base help formatter that provides common functionality."""
def __call__(
self, console: Console, options: ConsoleOptions, panel: HelpPanel
) -> None:
"""Render a help panel, filtering out the index parameter from Parameters sections."""
"""Render a help panel, filtering out hidden parameters from Parameters sections."""
if panel.title == 'Parameters':
filtered_entries = [
entry
for entry in panel.entries
if not (
entry.names and any('index' in name.lower() for name in entry.names)
entry.names
and any(
param in name.lower()
for name in entry.names
for param in self.get_filtered_params()
)
)
]
@@ -41,3 +34,90 @@ class CustomHelpFormatter(DefaultFormatter):
super().__call__(console, options, filtered_panel)
else:
super().__call__(console, options, panel)
def get_filtered_params(self):
"""Return list of parameter names to filter out of help output."""
return ['index', 'band', 'ctx', 'target', 'eq_kind']
class StripHelpFormatter(BaseHelpFormatter):
"""Help formatter for strip commands that injects <index> after 'strip'."""
def render_usage(self, console: Console, options: ConsoleOptions, usage) -> None:
"""Render the usage line with index argument injected after 'strip'.
Handles both command groups (COMMAND) and individual commands (commandname [ARGS/OPTIONS]).
"""
if usage:
modified_usage = re.sub(
r'(\S+\s+strip)\s+(\w+\s+\[[^\]]+\]|\w+\s+\[[^\]]+\]|\w+(?:\s+\[[^\]]+\])*|COMMAND)',
r'\1 <index> \2',
str(usage),
)
if modified_usage == str(usage):
modified_usage = re.sub(
r'(\S+\s+strip)\s+(\w+)', r'\1 <index> \2', str(usage)
)
console.print(f'[bold]Usage:[/bold] {modified_usage}')
class StripSubcommandHelpFormatter(BaseHelpFormatter):
"""Help formatter for strip subcommands that injects <index> after 'strip'."""
def render_usage(self, console: Console, options: ConsoleOptions, usage) -> None:
"""Render the usage line with index argument injected after 'strip'."""
if usage:
modified_usage = re.sub(
r'(\S+\s+strip)\s+(\w+)\s+(COMMAND)', r'\1 <index> \2 \3', str(usage)
)
console.print(f'[bold]Usage:[/bold] {modified_usage}')
class BusHelpFormatter(BaseHelpFormatter):
"""Help formatter for bus commands that injects <index> after 'bus'."""
def render_usage(self, console: Console, options: ConsoleOptions, usage) -> None:
"""Render the usage line with index argument injected after 'bus'.
Handles both command groups (COMMAND) and individual commands (commandname [ARGS/OPTIONS])."""
if usage:
modified_usage = re.sub(
r'(\S+\s+bus)\s+(\w+\s+\[[^\]]+\]|\w+\s+\[[^\]]+\]|\w+(?:\s+\[[^\]]+\])*|COMMAND)',
r'\1 <index> \2',
str(usage),
)
if modified_usage == str(usage):
modified_usage = re.sub(
r'(\S+\s+bus)\s+(\w+)', r'\1 <index> \2', str(usage)
)
console.print(f'[bold]Usage:[/bold] {modified_usage}')
class EqHelpFormatter(BaseHelpFormatter):
"""Help formatter for eq commands that works with both strip and bus commands.
Injects <index> after 'strip' or 'bus' and <band> after 'cell'."""
def render_usage(self, console: Console, options: ConsoleOptions, usage) -> None:
"""Render the usage line with proper <index> placement for both strip and bus commands."""
if usage:
modified_usage = re.sub(
r'(\S+\s+)(\w+)(\s+eq\s+)(COMMAND)', r'\1\2 <index>\3\4', str(usage)
)
console.print(f'[bold]Usage:[/bold] {modified_usage}')
class CellHelpFormatter(BaseHelpFormatter):
"""Help formatter for cell commands that works with both strip and bus commands.
Injects <index> after 'strip' or 'bus' and <band> after 'cell'."""
def render_usage(self, console: Console, options: ConsoleOptions, usage) -> None:
"""Render the usage line with proper <index> and <band> placement."""
if usage:
modified_usage = re.sub(
r'(\S+\s+)(\w+)(\s+eq\s+cell\s+)(COMMAND)',
r'\1\2 <index>\3<band> \4',
str(usage),
)
console.print(f'[bold]Usage:[/bold] {modified_usage}')

View File

@@ -4,9 +4,9 @@ from cyclopts import App, Argument, Parameter
from . import comp, console, denoiser, eq, gate
from .context import Context
from .help import CustomHelpFormatter
from .help import StripHelpFormatter
app = App(name='strip', help_formatter=CustomHelpFormatter())
app = App(name='strip', help_formatter=StripHelpFormatter())
app.command(eq.app.meta, name='eq')
app.command(comp.app.meta, name='comp')
app.command(gate.app.meta, name='gate')
@@ -22,6 +22,8 @@ def launcher(
"""Control the strip parameters."""
additional_kwargs = {}
command, bound, _ = app.parse_args(tokens)
if tokens[0] == 'eq':
additional_kwargs['eq_kind'] = app.name[0]
if index is not None:
additional_kwargs['index'] = index
if ctx is not None:

35
uv.lock generated
View File

@@ -11,6 +11,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cyclopts"
version = "4.6.0"
@@ -44,6 +53,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
]
[[package]]
name = "loguru"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -102,16 +124,18 @@ wheels = [
[[package]]
name = "vban-cli"
version = "0.3.0"
version = "0.4.2"
source = { editable = "." }
dependencies = [
{ name = "cyclopts" },
{ name = "loguru" },
{ name = "vban-cmd" },
]
[package.metadata]
requires-dist = [
{ name = "cyclopts", specifier = ">=4.6.0" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "vban-cmd", directory = "../vban-cmd-python" },
]
@@ -122,3 +146,12 @@ source = { directory = "../vban-cmd-python" }
[package.metadata]
requires-dist = [{ name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.1,<3.0" }]
[[package]]
name = "win32-setctime"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
]