pyvmancer 0.1.0__py3-none-any.whl

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.
pyvmancer/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ """Automate the LZX Industries Videomancer over USB MIDI and USB serial."""
2
+
3
+ from .const import (
4
+ EMBEDDED_PROGRAMS,
5
+ OPERATORS,
6
+ PARAM_COUNT,
7
+ PARAM_DEFAULTS,
8
+ PARAM_MAX,
9
+ PARAM_MIN,
10
+ LogLevel,
11
+ Operator,
12
+ OperatorCategory,
13
+ ParamKind,
14
+ PresetBank,
15
+ TransportState,
16
+ param_kind,
17
+ resolve_operator,
18
+ )
19
+ from .device import Videomancer, open_midi, open_shell
20
+ from .discovery import DeviceInfo, find_device, find_devices
21
+ from .errors import (
22
+ DeviceNotFoundError,
23
+ ShellError,
24
+ ShellTimeoutError,
25
+ TransportError,
26
+ TransportUnavailableError,
27
+ VmancerError,
28
+ )
29
+ from .midi import MidiController
30
+ from .shell import Reply, ShellClient, encode_preset, parse_line
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = [
35
+ "DeviceInfo",
36
+ "DeviceNotFoundError",
37
+ "EMBEDDED_PROGRAMS",
38
+ "LogLevel",
39
+ "MidiController",
40
+ "OPERATORS",
41
+ "Operator",
42
+ "OperatorCategory",
43
+ "PARAM_COUNT",
44
+ "PARAM_DEFAULTS",
45
+ "PARAM_MAX",
46
+ "PARAM_MIN",
47
+ "ParamKind",
48
+ "PresetBank",
49
+ "Reply",
50
+ "ShellClient",
51
+ "ShellError",
52
+ "ShellTimeoutError",
53
+ "TransportError",
54
+ "TransportState",
55
+ "TransportUnavailableError",
56
+ "Videomancer",
57
+ "VmancerError",
58
+ "__version__",
59
+ "encode_preset",
60
+ "find_device",
61
+ "find_devices",
62
+ "open_midi",
63
+ "open_shell",
64
+ "param_kind",
65
+ "parse_line",
66
+ "resolve_operator",
67
+ ]
pyvmancer/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """Command-line interface: ``vmancer <subcommand>``."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from . import __version__
8
+ from .const import OPERATORS, PARAM_COUNT
9
+ from .device import open_midi, open_shell
10
+ from .discovery import find_devices
11
+ from .errors import VmancerError
12
+
13
+
14
+ def _param_value(text):
15
+ """Parse a CLI parameter value as bool, float or device-unit int."""
16
+ lowered = text.strip().lower()
17
+ if lowered in ("on", "true", "yes"):
18
+ return True
19
+ if lowered in ("off", "false", "no"):
20
+ return False
21
+ if "." in lowered:
22
+ return float(lowered)
23
+ return int(lowered)
24
+
25
+
26
+ def _assignment(text):
27
+ """Parse a ``P=V`` assignment into ``(param, value)``."""
28
+ key, sep, value = text.partition("=")
29
+ if not sep:
30
+ raise argparse.ArgumentTypeError(f"expected PARAM=VALUE, got {text!r}")
31
+ param = int(key)
32
+ if not 1 <= param <= PARAM_COUNT:
33
+ raise argparse.ArgumentTypeError(f"parameter must be 1..{PARAM_COUNT}, got {param}")
34
+ return param, _param_value(value)
35
+
36
+
37
+ def _print_json(data):
38
+ """Emit a JSON document on stdout."""
39
+ print(json.dumps(data, indent=2, default=str))
40
+
41
+
42
+ def build_parser():
43
+ """Construct the argument parser."""
44
+ parser = argparse.ArgumentParser(prog="vmancer", description=__doc__)
45
+ parser.add_argument("--version", action="version", version=f"pyvmancer {__version__}")
46
+ parser.add_argument("--serial", dest="serial_number", help="target a specific device serial")
47
+ parser.add_argument("--channel", type=int, help="MIDI channel 1-16 (default: omni/1)")
48
+ sub = parser.add_subparsers(dest="command", required=True)
49
+
50
+ sub.add_parser("devices", help="list attached Videomancers and their device nodes")
51
+ sub.add_parser("operators", help="list modulation operators and their ids")
52
+
53
+ setter = sub.add_parser("set", help="set parameters, e.g. set 1=0.5 7=on")
54
+ setter.add_argument("assignments", nargs="+", type=_assignment)
55
+
56
+ trigger = sub.add_parser("trigger", help="send a note trigger to a parameter")
57
+ trigger.add_argument("param", type=int)
58
+ trigger.add_argument("--velocity", type=int, default=127)
59
+
60
+ preset = sub.add_parser("preset", help="recall a preset by MIDI program change")
61
+ preset.add_argument("program", type=int)
62
+
63
+ clock = sub.add_parser("clock", help="emit MIDI clock at a tempo")
64
+ clock.add_argument("bpm", type=float)
65
+ clock.add_argument("--beats", type=float, default=4.0)
66
+
67
+ sub.add_parser("status", help="query device status over serial")
68
+ sub.add_parser("programs", help="list installed FPGA programs over serial")
69
+ sub.add_parser("presets", help="list factory and user presets over serial")
70
+
71
+ load = sub.add_parser("load", help="load an FPGA program by name over serial")
72
+ load.add_argument("name")
73
+
74
+ shell_cmd = sub.add_parser("shell", help="run a raw serial shell command")
75
+ shell_cmd.add_argument("words", nargs="+")
76
+ return parser
77
+
78
+
79
+ def _run_midi(args):
80
+ """Handle subcommands that only need the MIDI link."""
81
+ with open_midi(serial_number=args.serial_number, channel=args.channel) as midi:
82
+ if args.command == "set":
83
+ midi.set_params(dict(args.assignments))
84
+ _print_json({"state": midi.state.tolist()})
85
+ elif args.command == "trigger":
86
+ midi.pulse(args.param, velocity=args.velocity)
87
+ elif args.command == "preset":
88
+ midi.select_preset(args.program)
89
+ elif args.command == "clock":
90
+ midi.run_clock(args.bpm, args.beats)
91
+ return 0
92
+
93
+
94
+ def _run_shell(args):
95
+ """Handle subcommands that need the serial link."""
96
+ with open_shell(serial_number=args.serial_number) as shell:
97
+ if args.command == "status":
98
+ _print_json(shell.status())
99
+ elif args.command == "programs":
100
+ _print_json(shell.programs())
101
+ elif args.command == "presets":
102
+ _print_json(shell.presets())
103
+ elif args.command == "load":
104
+ shell.load_program(args.name)
105
+ elif args.command == "shell":
106
+ print(shell.command(*args.words).payload)
107
+ return 0
108
+
109
+
110
+ def main(argv=None):
111
+ """Entry point for the ``vmancer`` console script."""
112
+ args = build_parser().parse_args(argv)
113
+ try:
114
+ if args.command == "devices":
115
+ _print_json([vars_of(d) for d in find_devices()])
116
+ return 0
117
+ if args.command == "operators":
118
+ _print_json(
119
+ {
120
+ info.name: {
121
+ "id": int(info.operator),
122
+ "glyph": info.glyph,
123
+ "category": info.category.value,
124
+ "per_line": info.per_line,
125
+ "transport": info.transport.value,
126
+ }
127
+ for info in OPERATORS.values()
128
+ }
129
+ )
130
+ return 0
131
+ if args.command in ("set", "trigger", "preset", "clock"):
132
+ return _run_midi(args)
133
+ return _run_shell(args)
134
+ except (VmancerError, ValueError) as err:
135
+ print(f"error: {err}", file=sys.stderr)
136
+ return 1
137
+
138
+
139
+ def vars_of(info):
140
+ """Render a :class:`~pyvmancer.discovery.DeviceInfo` as a plain dict."""
141
+ return {
142
+ "serial": info.serial_number,
143
+ "rawmidi": info.rawmidi,
144
+ "tty": info.tty,
145
+ "block": info.block,
146
+ "usbfs": info.usbfs_path,
147
+ }
148
+
149
+
150
+ if __name__ == "__main__":
151
+ sys.exit(main())
pyvmancer/const.py ADDED
@@ -0,0 +1,279 @@
1
+ """Videomancer protocol constants.
2
+
3
+ Sourced from the LZX Industries Videomancer user manual, modulation guide and
4
+ serial command guide. See ``docs/protocol.md`` for provenance of each table.
5
+ """
6
+
7
+ from enum import Enum, IntEnum
8
+
9
+ USB_VID = 0x16D0
10
+ USB_PID = 0x14DB
11
+ USB_MANUFACTURER = "LZX Industries"
12
+ USB_PRODUCT = "Videomancer"
13
+
14
+ PARAM_COUNT = 12
15
+ PARAM_MIN = 0
16
+ PARAM_MAX = 1023
17
+ PARAM_CENTER = 512
18
+ #: A toggle reads as "on" when its combined value is >= this midpoint.
19
+ BOOL_THRESHOLD = 512
20
+
21
+ #: Per-parameter values on a freshly loaded program.
22
+ PARAM_DEFAULTS = (512, 512, 512, 512, 512, 512, 0, 0, 0, 0, 0, 512)
23
+
24
+ #: Default CC (MSB) for parameters P1..P12.
25
+ DEFAULT_CC_MSB = tuple(range(PARAM_COUNT))
26
+ #: 14-bit CC pairing offset: the LSB controller is always MSB + 32.
27
+ CC_LSB_OFFSET = 32
28
+ CC_MIN = 0
29
+ CC_MAX = 127
30
+
31
+ #: MIDI notes 0..11 trigger modulators P1..P12.
32
+ NOTE_BASE = 0
33
+
34
+ MIDI_CLOCK_PPQN = 24
35
+ BPM_MIN = 10.0
36
+ BPM_MAX = 400.0
37
+ #: ``transport bpm`` takes hundredths of a BPM.
38
+ BPM_SCALE = 100
39
+
40
+ SHELL_MAX_LINE = 511
41
+ SHELL_OK = "ok"
42
+ SHELL_PATH_PREFIX = "sd:/"
43
+
44
+
45
+ class ParamKind(Enum):
46
+ """Physical control backing each parameter slot."""
47
+
48
+ KNOB = "knob"
49
+ TOGGLE = "toggle"
50
+ FADER = "fader"
51
+
52
+
53
+ def param_kind(param):
54
+ """Return the :class:`ParamKind` for a 1-based parameter number."""
55
+ if not 1 <= param <= PARAM_COUNT:
56
+ raise ValueError(f"parameter must be 1..{PARAM_COUNT}, got {param}")
57
+ if param <= 6:
58
+ return ParamKind.KNOB
59
+ if param <= 11:
60
+ return ParamKind.TOGGLE
61
+ return ParamKind.FADER
62
+
63
+
64
+ #: Parameters that render per-scanline when driven by a per-line operator.
65
+ PER_LINE_PARAMS = frozenset({1, 2, 3, 4, 5, 6, 12})
66
+
67
+
68
+ class Operator(IntEnum):
69
+ """Modulation operators, by the numeric id used in preset ``sr:`` fields.
70
+
71
+ Ids 23..28 are not documented and are intentionally absent; resolve those
72
+ from the device with ``program state`` rather than guessing.
73
+ """
74
+
75
+ DISABLED = 0
76
+ FREE_LFO = 1
77
+ RANDOM = 2
78
+ TURING_MACHINE = 3
79
+ BOUNCING_BALL = 4
80
+ LOGISTIC_MAP = 5
81
+ EUCLIDEAN_RHYTHM = 6
82
+ SYNC_LFO = 7
83
+ AUDIO_INPUT = 8
84
+ COMPARATOR = 9
85
+ PENDULUM = 10
86
+ DRIFT = 11
87
+ RING_MOD = 12
88
+ CELLULAR = 13
89
+ PULSE_WIDTH = 14
90
+ PEAK_HOLD = 15
91
+ FIELD_ACCUM = 16
92
+ SLEW_LIMITER = 17
93
+ PERLIN_NOISE = 18
94
+ WAVEFOLDER = 19
95
+ CLOCK_DIV = 20
96
+ PROB_GATE = 21
97
+ QUANTIZER = 22
98
+ MIDI_TURING = 29
99
+ CV_INPUT = 30
100
+
101
+
102
+ class OperatorCategory(Enum):
103
+ """Grouping used by the modulation guide."""
104
+
105
+ NONE = "none"
106
+ OSCILLATORS = "oscillators"
107
+ RANDOM_CHAOS = "random & chaos"
108
+ EXTERNAL_INPUT = "external input"
109
+ PHYSICS = "physics"
110
+ SEQUENCING = "sequencing & rhythm"
111
+
112
+
113
+ class Transport(Enum):
114
+ """Operator clocking: free-running, or gated by transport playback."""
115
+
116
+ FREE_RUN = "free-run"
117
+ TEMPO = "tempo"
118
+
119
+
120
+ _OPS = (
121
+ # (operator, display glyph, category, per-line capable, transport)
122
+ (Operator.DISABLED, "·", OperatorCategory.NONE, False, Transport.FREE_RUN),
123
+ (Operator.FREE_LFO, "L", OperatorCategory.OSCILLATORS, False, Transport.FREE_RUN),
124
+ (Operator.RANDOM, "R", OperatorCategory.RANDOM_CHAOS, False, Transport.FREE_RUN),
125
+ (Operator.TURING_MACHINE, "U", OperatorCategory.RANDOM_CHAOS, False, Transport.FREE_RUN),
126
+ (Operator.BOUNCING_BALL, "B", OperatorCategory.PHYSICS, False, Transport.FREE_RUN),
127
+ (Operator.LOGISTIC_MAP, "X", OperatorCategory.RANDOM_CHAOS, False, Transport.FREE_RUN),
128
+ (Operator.EUCLIDEAN_RHYTHM, "Y", OperatorCategory.SEQUENCING, False, Transport.FREE_RUN),
129
+ (Operator.SYNC_LFO, "M", OperatorCategory.OSCILLATORS, False, Transport.TEMPO),
130
+ (Operator.AUDIO_INPUT, "A", OperatorCategory.EXTERNAL_INPUT, True, Transport.FREE_RUN),
131
+ (Operator.COMPARATOR, "K", OperatorCategory.EXTERNAL_INPUT, True, Transport.FREE_RUN),
132
+ (Operator.PENDULUM, "N", OperatorCategory.PHYSICS, False, Transport.FREE_RUN),
133
+ (Operator.DRIFT, "W", OperatorCategory.RANDOM_CHAOS, False, Transport.FREE_RUN),
134
+ (Operator.RING_MOD, "*", OperatorCategory.EXTERNAL_INPUT, True, Transport.FREE_RUN),
135
+ (Operator.CELLULAR, "#", OperatorCategory.RANDOM_CHAOS, False, Transport.FREE_RUN),
136
+ (Operator.PULSE_WIDTH, "P", OperatorCategory.OSCILLATORS, False, Transport.FREE_RUN),
137
+ (Operator.PEAK_HOLD, "J", OperatorCategory.EXTERNAL_INPUT, True, Transport.FREE_RUN),
138
+ (Operator.FIELD_ACCUM, "I", OperatorCategory.EXTERNAL_INPUT, False, Transport.FREE_RUN),
139
+ (Operator.SLEW_LIMITER, "/", OperatorCategory.EXTERNAL_INPUT, False, Transport.FREE_RUN),
140
+ (Operator.PERLIN_NOISE, "~", OperatorCategory.RANDOM_CHAOS, False, Transport.FREE_RUN),
141
+ (Operator.WAVEFOLDER, "Z", OperatorCategory.OSCILLATORS, False, Transport.FREE_RUN),
142
+ (Operator.CLOCK_DIV, "V", OperatorCategory.SEQUENCING, False, Transport.TEMPO),
143
+ (Operator.PROB_GATE, "p", OperatorCategory.SEQUENCING, False, Transport.FREE_RUN),
144
+ (Operator.QUANTIZER, "O", OperatorCategory.EXTERNAL_INPUT, True, Transport.FREE_RUN),
145
+ (Operator.MIDI_TURING, "T", OperatorCategory.RANDOM_CHAOS, False, Transport.FREE_RUN),
146
+ (Operator.CV_INPUT, "C", OperatorCategory.EXTERNAL_INPUT, True, Transport.FREE_RUN),
147
+ )
148
+
149
+
150
+ #: Words that keep their casing in operator display names.
151
+ _ACRONYMS = {"LFO": "LFO", "CV": "CV", "MIDI": "MIDI", "ACCUM": "Accum", "PROB": "Prob"}
152
+
153
+
154
+ class OperatorInfo:
155
+ """Static metadata for one modulation operator."""
156
+
157
+ __slots__ = ("operator", "glyph", "category", "per_line", "transport")
158
+
159
+ def __init__(self, operator, glyph, category, per_line, transport):
160
+ self.operator = operator
161
+ self.glyph = glyph
162
+ self.category = category
163
+ self.per_line = per_line
164
+ self.transport = transport
165
+
166
+ @property
167
+ def name(self):
168
+ """Human-readable operator name, e.g. ``"Free LFO"``."""
169
+ words = self.operator.name.split("_")
170
+ return " ".join(_ACRONYMS.get(word, word.title()) for word in words)
171
+
172
+ def __repr__(self):
173
+ return f"OperatorInfo({self.name!r}, id={int(self.operator)})"
174
+
175
+
176
+ OPERATORS = {row[0]: OperatorInfo(*row) for row in _OPS}
177
+
178
+ _OPERATOR_BY_NAME = {}
179
+ for _info in OPERATORS.values():
180
+ _OPERATOR_BY_NAME[_info.operator.name.lower()] = _info.operator
181
+ _OPERATOR_BY_NAME[_info.name.lower()] = _info.operator
182
+
183
+
184
+ def resolve_operator(value):
185
+ """Coerce an int, :class:`Operator` or name string to an :class:`Operator`."""
186
+ if isinstance(value, Operator):
187
+ return value
188
+ if isinstance(value, int):
189
+ return Operator(value)
190
+ key = str(value).strip().lower().replace("-", " ")
191
+ try:
192
+ return _OPERATOR_BY_NAME[key]
193
+ except KeyError:
194
+ raise ValueError(f"unknown operator {value!r}") from None
195
+
196
+
197
+ #: Operators triggered by MIDI note-on for their parameter slot.
198
+ NOTE_TRIGGERED = frozenset({Operator.BOUNCING_BALL, Operator.PENDULUM, Operator.MIDI_TURING})
199
+
200
+ #: Modulation knobs shared by every operator.
201
+ MOD_KNOBS = ("time", "space", "slope")
202
+
203
+ #: Preset payload field names used by ``program presets save``.
204
+ PRESET_FIELDS = {
205
+ "manual": "m",
206
+ "time": "t",
207
+ "space": "sp",
208
+ "slope": "sl",
209
+ "source": "sr",
210
+ }
211
+
212
+
213
+ class PresetBank(Enum):
214
+ """Preset storage bank."""
215
+
216
+ FACTORY = "factory"
217
+ USER = "user"
218
+
219
+
220
+ class TransportState(Enum):
221
+ """Values reported by ``transport status``."""
222
+
223
+ STOPPED = "stopped"
224
+ PLAYING = "playing"
225
+ PAUSED = "paused"
226
+
227
+
228
+ class LogLevel(Enum):
229
+ """Argument to the ``log level`` command."""
230
+
231
+ TRACE = "trace"
232
+ DEBUG = "debug"
233
+ INFO = "info"
234
+ WARN = "warn"
235
+ ERROR = "error"
236
+ NONE = "none"
237
+
238
+
239
+ class ShellErrorCode(IntEnum):
240
+ """Numeric error codes returned as ``!<code>:<message>``."""
241
+
242
+ UNKNOWN_COMMAND = 1
243
+ PARSE_ERROR = 2
244
+ SERVICE_UNAVAILABLE = 3
245
+ BUFFER_OVERFLOW = 4
246
+ NOT_CONNECTED = 5
247
+ FILE_NOT_FOUND = 6
248
+ IO_ERROR = 7
249
+ SD_NOT_MOUNTED = 8
250
+ PATH_TOO_LONG = 9
251
+ INVALID_PATH = 10
252
+
253
+
254
+ #: Firmware-embedded programs; query ``programs list`` for the full SD-backed set.
255
+ EMBEDDED_PROGRAMS = (
256
+ "bitcullis",
257
+ "corollas",
258
+ "delirium",
259
+ "elastica",
260
+ "faultplane",
261
+ "fauxtress",
262
+ "glorious",
263
+ "howler",
264
+ "isotherm",
265
+ "kintsugi",
266
+ "lumarian",
267
+ "moire",
268
+ "mycelium",
269
+ "perlin",
270
+ "pinwheel",
271
+ "pong",
272
+ "sabattier",
273
+ "scramble",
274
+ "shadebob",
275
+ "stic",
276
+ "stipple",
277
+ "yuv_amplifier",
278
+ "yuv_phaser",
279
+ )