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 +67 -0
- pyvmancer/cli.py +151 -0
- pyvmancer/const.py +279 -0
- pyvmancer/device.py +285 -0
- pyvmancer/discovery.py +132 -0
- pyvmancer/errors.py +39 -0
- pyvmancer/midi.py +281 -0
- pyvmancer/scaling.py +91 -0
- pyvmancer/shell.py +495 -0
- pyvmancer/transports/__init__.py +6 -0
- pyvmancer/transports/base.py +59 -0
- pyvmancer/transports/mido_port.py +70 -0
- pyvmancer/transports/rawmidi.py +70 -0
- pyvmancer/transports/serial_tty.py +57 -0
- pyvmancer/transports/usb_cdc.py +151 -0
- pyvmancer-0.1.0.dist-info/METADATA +115 -0
- pyvmancer-0.1.0.dist-info/RECORD +21 -0
- pyvmancer-0.1.0.dist-info/WHEEL +5 -0
- pyvmancer-0.1.0.dist-info/entry_points.txt +2 -0
- pyvmancer-0.1.0.dist-info/licenses/LICENSE +201 -0
- pyvmancer-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
)
|