laive-mcp 0.1.0
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.
- package/AGENTS.md +48 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +674 -0
- package/README.md +219 -0
- package/bin/laive.mjs +340 -0
- package/package.json +66 -0
- package/packages/als-parser/src/index.js +2 -0
- package/packages/als-parser/src/read.js +16 -0
- package/packages/als-parser/src/summarize.js +116 -0
- package/packages/common/src/index.js +3 -0
- package/packages/common/src/jsonl.js +41 -0
- package/packages/common/src/protocol.js +94 -0
- package/packages/common/src/validation.js +121 -0
- package/packages/live-bridge-remote-script/README.md +22 -0
- package/packages/live-bridge-remote-script/python/laive/__init__.py +7 -0
- package/packages/live-bridge-remote-script/python/laive/control_surface.py +208 -0
- package/packages/live-bridge-remote-script/python/laive/fake_live.py +168 -0
- package/packages/live-bridge-remote-script/python/laive/listeners.py +46 -0
- package/packages/live-bridge-remote-script/python/laive/live_access.py +272 -0
- package/packages/live-bridge-remote-script/python/laive/protocol.py +87 -0
- package/packages/live-bridge-remote-script/python/laive/server.py +130 -0
- package/packages/live-bridge-remote-script/python/laive/task_queue.py +47 -0
- package/packages/live-bridge-remote-script/src/bridge/client.js +113 -0
- package/packages/live-bridge-remote-script/src/bridge/server.js +189 -0
- package/packages/live-bridge-remote-script/src/cli/client.js +75 -0
- package/packages/live-bridge-remote-script/src/cli/server.js +51 -0
- package/packages/live-bridge-remote-script/src/fixtures/default-live-set.json +113 -0
- package/packages/live-bridge-remote-script/src/index.js +3 -0
- package/packages/live-bridge-remote-script/src/runtime/fixture-runtime.js +356 -0
- package/packages/live-sidecar-m4l/README.md +45 -0
- package/packages/live-sidecar-m4l/device/laive-sidecar.amxd +0 -0
- package/packages/live-sidecar-m4l/project/code/laive-sidecar-node.js +149 -0
- package/packages/live-sidecar-m4l/project/data/laive-sidecar.manifest.json +8 -0
- package/packages/live-sidecar-m4l/project/laive-sidecar.maxproj +36 -0
- package/packages/live-sidecar-m4l/project/patchers/laive-sidecar.maxpat +172 -0
- package/packages/live-sidecar-m4l/src/contracts.js +35 -0
- package/packages/live-sidecar-m4l/src/index.js +19 -0
- package/packages/live-sidecar-m4l/src/install-sidecar-device.js +15 -0
- package/packages/live-sidecar-m4l/src/package-sidecar.js +5 -0
- package/packages/live-sidecar-m4l/src/project.js +132 -0
- package/packages/live-sidecar-m4l/src/runtime.js +96 -0
- package/packages/live-sidecar-m4l/src/workflows.js +95 -0
- package/packages/mcp-server/src/cli.js +113 -0
- package/packages/mcp-server/src/default-tools.js +253 -0
- package/packages/mcp-server/src/errors.js +24 -0
- package/packages/mcp-server/src/index.js +10 -0
- package/packages/mcp-server/src/server.js +96 -0
- package/packages/mcp-server/src/session.js +475 -0
- package/packages/mcp-server/src/tool-registry.js +41 -0
- package/packages/state-engine/src/engine.js +566 -0
- package/packages/state-engine/src/ids.js +57 -0
- package/packages/state-engine/src/index.js +40 -0
- package/packages/state-engine/src/normalize.js +357 -0
- package/packages/state-engine/src/queries.js +154 -0
- package/packages/state-engine/src/replay.js +60 -0
- package/packages/ui-automation/src/executor.js +87 -0
- package/packages/ui-automation/src/guards.js +21 -0
- package/packages/ui-automation/src/helper.js +186 -0
- package/packages/ui-automation/src/index.js +20 -0
- package/packages/ui-automation/src/macos.js +82 -0
- package/packages/ui-automation/src/package-ui-helper.js +5 -0
- package/packages/ui-automation/src/workflows.js +72 -0
- package/scripts/install-remote-script.py +7 -0
- package/scripts/install-ui-helper.mjs +14 -0
- package/scripts/package-remote-script.py +7 -0
- package/scripts/package-ui-helper.mjs +4 -0
- package/scripts/remote_script_tooling.py +253 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import absolute_import, print_function, unicode_literals
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ListenerMixin(object):
|
|
5
|
+
def __init__(self):
|
|
6
|
+
self._listeners = {}
|
|
7
|
+
|
|
8
|
+
def _add_listener(self, name, callback):
|
|
9
|
+
self._listeners.setdefault(name, []).append(callback)
|
|
10
|
+
|
|
11
|
+
def _remove_listener(self, name, callback):
|
|
12
|
+
self._listeners[name] = [item for item in self._listeners.get(name, []) if item != callback]
|
|
13
|
+
|
|
14
|
+
def _notify(self, name):
|
|
15
|
+
for callback in list(self._listeners.get(name, [])):
|
|
16
|
+
callback()
|
|
17
|
+
|
|
18
|
+
def __getattr__(self, item):
|
|
19
|
+
if item.startswith("add_") and item.endswith("_listener"):
|
|
20
|
+
name = item[4:-9]
|
|
21
|
+
return lambda callback: self._add_listener(name, callback)
|
|
22
|
+
if item.startswith("remove_") and item.endswith("_listener"):
|
|
23
|
+
name = item[7:-9]
|
|
24
|
+
return lambda callback: self._remove_listener(name, callback)
|
|
25
|
+
raise AttributeError(item)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FakeParameter(object):
|
|
29
|
+
def __init__(self, name, value, minimum=0.0, maximum=1.0):
|
|
30
|
+
self.name = name
|
|
31
|
+
self._value = value
|
|
32
|
+
self.min = minimum
|
|
33
|
+
self.max = maximum
|
|
34
|
+
self.display_value = str(value)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def value(self):
|
|
38
|
+
return self._value
|
|
39
|
+
|
|
40
|
+
@value.setter
|
|
41
|
+
def value(self, next_value):
|
|
42
|
+
self._value = next_value
|
|
43
|
+
self.display_value = str(next_value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FakeDevice(object):
|
|
47
|
+
def __init__(self, name, parameters):
|
|
48
|
+
self.name = name
|
|
49
|
+
self.class_name = name
|
|
50
|
+
self.parameters = parameters
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FakeClip(object):
|
|
54
|
+
def __init__(self, name="Clip 1", length=4):
|
|
55
|
+
self.name = name
|
|
56
|
+
self.length = length
|
|
57
|
+
self.is_playing = False
|
|
58
|
+
self.notes = []
|
|
59
|
+
|
|
60
|
+
def add_new_notes(self, notes):
|
|
61
|
+
self.notes.extend(notes)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FakeClipSlot(object):
|
|
65
|
+
def __init__(self):
|
|
66
|
+
self.clip = None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def has_clip(self):
|
|
70
|
+
return self.clip is not None
|
|
71
|
+
|
|
72
|
+
def create_clip(self, length):
|
|
73
|
+
self.clip = FakeClip(length=length)
|
|
74
|
+
|
|
75
|
+
def preview_clip(self, length_beats, name=None):
|
|
76
|
+
preview = FakeClip(name=name or "Preview Clip", length=length_beats)
|
|
77
|
+
return preview
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class FakeTrack(object):
|
|
81
|
+
def __init__(self, name):
|
|
82
|
+
self.name = name
|
|
83
|
+
self.type = "midi"
|
|
84
|
+
self.arm = False
|
|
85
|
+
self.mute = False
|
|
86
|
+
self.solo = False
|
|
87
|
+
self.clip_slots = [FakeClipSlot() for _ in range(4)]
|
|
88
|
+
self.devices = [FakeDevice("Instrument", [FakeParameter("Macro 1", 0.5)])]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FakeScene(object):
|
|
92
|
+
def __init__(self, name):
|
|
93
|
+
self.name = name
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class FakeSong(ListenerMixin):
|
|
97
|
+
def __init__(self):
|
|
98
|
+
super(FakeSong, self).__init__()
|
|
99
|
+
self.name = "Fake Set"
|
|
100
|
+
self.live_version = "12.1.10"
|
|
101
|
+
self.signature_numerator = 4
|
|
102
|
+
self.signature_denominator = 4
|
|
103
|
+
self._tempo = 124.0
|
|
104
|
+
self._is_playing = False
|
|
105
|
+
self.is_recording = False
|
|
106
|
+
self.metronome = False
|
|
107
|
+
self.tracks = [FakeTrack("Drums"), FakeTrack("Bass")]
|
|
108
|
+
self.scenes = [FakeScene("Intro"), FakeScene("Drop")]
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def tempo(self):
|
|
112
|
+
return self._tempo
|
|
113
|
+
|
|
114
|
+
@tempo.setter
|
|
115
|
+
def tempo(self, next_value):
|
|
116
|
+
self._tempo = next_value
|
|
117
|
+
self._notify("tempo")
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def is_playing(self):
|
|
121
|
+
return self._is_playing
|
|
122
|
+
|
|
123
|
+
@is_playing.setter
|
|
124
|
+
def is_playing(self, next_value):
|
|
125
|
+
self._is_playing = bool(next_value)
|
|
126
|
+
self._notify("is_playing")
|
|
127
|
+
|
|
128
|
+
def start_playing(self):
|
|
129
|
+
self.is_playing = True
|
|
130
|
+
|
|
131
|
+
def stop_playing(self):
|
|
132
|
+
self.is_playing = False
|
|
133
|
+
|
|
134
|
+
def create_midi_track(self, index):
|
|
135
|
+
self.tracks.insert(index, FakeTrack("Track {0}".format(index + 1)))
|
|
136
|
+
self._notify("tracks")
|
|
137
|
+
|
|
138
|
+
def create_scene(self, index):
|
|
139
|
+
self.scenes.insert(index, FakeScene("Scene {0}".format(index + 1)))
|
|
140
|
+
self._notify("scenes")
|
|
141
|
+
|
|
142
|
+
def preview_track(self, index, name=None):
|
|
143
|
+
track = FakeTrack(name or "Track {0}".format(index + 1))
|
|
144
|
+
return track
|
|
145
|
+
|
|
146
|
+
def preview_scene(self, index, name=None):
|
|
147
|
+
return FakeScene(name or "Scene {0}".format(index + 1))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class FakeCInstance(object):
|
|
151
|
+
def __init__(self, song=None):
|
|
152
|
+
self._song = song or FakeSong()
|
|
153
|
+
self.scheduled = []
|
|
154
|
+
self.logged = []
|
|
155
|
+
self.messages = []
|
|
156
|
+
|
|
157
|
+
def song(self):
|
|
158
|
+
return self._song
|
|
159
|
+
|
|
160
|
+
def schedule_message(self, _delay, callback):
|
|
161
|
+
self.scheduled.append(callback)
|
|
162
|
+
callback()
|
|
163
|
+
|
|
164
|
+
def log_message(self, message):
|
|
165
|
+
self.logged.append(message)
|
|
166
|
+
|
|
167
|
+
def show_message(self, message):
|
|
168
|
+
self.messages.append(message)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import absolute_import, print_function, unicode_literals
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _safe_call(logger, message):
|
|
5
|
+
if logger:
|
|
6
|
+
logger(message)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ListenerHub(object):
|
|
10
|
+
def __init__(self, live_access, event_sink, logger=None):
|
|
11
|
+
self._live_access = live_access
|
|
12
|
+
self._song = live_access.song
|
|
13
|
+
self._event_sink = event_sink
|
|
14
|
+
self._logger = logger
|
|
15
|
+
self._detach_steps = []
|
|
16
|
+
|
|
17
|
+
def attach(self):
|
|
18
|
+
self._attach_listener(self._song, "tempo", self._emit_transport_changed)
|
|
19
|
+
self._attach_listener(self._song, "is_playing", self._emit_transport_changed)
|
|
20
|
+
self._attach_listener(self._song, "tracks", self._emit_tracks_changed)
|
|
21
|
+
self._attach_listener(self._song, "scenes", self._emit_scenes_changed)
|
|
22
|
+
|
|
23
|
+
def detach(self):
|
|
24
|
+
while self._detach_steps:
|
|
25
|
+
callback = self._detach_steps.pop()
|
|
26
|
+
try:
|
|
27
|
+
callback()
|
|
28
|
+
except Exception as error: # pragma: no cover - defensive cleanup
|
|
29
|
+
_safe_call(self._logger, "Listener detach failed: {0}".format(error))
|
|
30
|
+
|
|
31
|
+
def _attach_listener(self, subject, name, callback):
|
|
32
|
+
add_method = getattr(subject, "add_{0}_listener".format(name), None)
|
|
33
|
+
remove_method = getattr(subject, "remove_{0}_listener".format(name), None)
|
|
34
|
+
if not add_method or not remove_method:
|
|
35
|
+
return
|
|
36
|
+
add_method(callback)
|
|
37
|
+
self._detach_steps.append(lambda: remove_method(callback))
|
|
38
|
+
|
|
39
|
+
def _emit_transport_changed(self):
|
|
40
|
+
self._event_sink("transport.changed", self._live_access.get_song_state())
|
|
41
|
+
|
|
42
|
+
def _emit_tracks_changed(self):
|
|
43
|
+
self._event_sink("tracks.changed", {"tracks": self._live_access.get_tracks()})
|
|
44
|
+
|
|
45
|
+
def _emit_scenes_changed(self):
|
|
46
|
+
self._event_sink("state.changed", {"scenes": self._live_access.get_scenes()})
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
from __future__ import absolute_import, print_function, unicode_literals
|
|
2
|
+
|
|
3
|
+
from .protocol import RequestError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _track_id(index):
|
|
7
|
+
return "track:{0}".format(index + 1)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _scene_id(index):
|
|
11
|
+
return "scene:{0}".format(index + 1)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _clip_id(track_id, slot_index):
|
|
15
|
+
return "clip:session:{0}:slot:{1}".format(track_id, slot_index + 1)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _device_id(track_id, device_index):
|
|
19
|
+
return "device:{0}:{1}".format(track_id, device_index + 1)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parameter_id(device_id, parameter_index):
|
|
23
|
+
return "parameter:{0}:{1}".format(device_id, parameter_index + 1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LiveSetAdapter(object):
|
|
27
|
+
def __init__(self, song):
|
|
28
|
+
self.song = song
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def live_version(self):
|
|
32
|
+
return getattr(self.song, "live_version", "unknown")
|
|
33
|
+
|
|
34
|
+
def capabilities(self):
|
|
35
|
+
return {
|
|
36
|
+
"read_state": True,
|
|
37
|
+
"set_transport": True,
|
|
38
|
+
"create_track": hasattr(self.song, "create_midi_track"),
|
|
39
|
+
"create_scene": hasattr(self.song, "create_scene"),
|
|
40
|
+
"create_clip": True,
|
|
41
|
+
"insert_notes": True,
|
|
42
|
+
"set_parameter": True,
|
|
43
|
+
"subscribe": True,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def get_song_state(self):
|
|
47
|
+
return {
|
|
48
|
+
"id": "song:current",
|
|
49
|
+
"name": getattr(self.song, "name", "Untitled Set"),
|
|
50
|
+
"tempo": getattr(self.song, "tempo", None),
|
|
51
|
+
"time_signature_numerator": getattr(self.song, "signature_numerator", None),
|
|
52
|
+
"time_signature_denominator": getattr(self.song, "signature_denominator", None),
|
|
53
|
+
"is_playing": bool(getattr(self.song, "is_playing", False)),
|
|
54
|
+
"is_recording": bool(getattr(self.song, "is_recording", False)),
|
|
55
|
+
"metronome": bool(getattr(self.song, "metronome", False)),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def get_tracks(self):
|
|
59
|
+
return [self._serialize_track(track, index) for index, track in enumerate(getattr(self.song, "tracks", []))]
|
|
60
|
+
|
|
61
|
+
def get_scenes(self):
|
|
62
|
+
return [self._serialize_scene(scene, index) for index, scene in enumerate(getattr(self.song, "scenes", []))]
|
|
63
|
+
|
|
64
|
+
def get_track(self, track_id):
|
|
65
|
+
track, index = self._find_track(track_id)
|
|
66
|
+
return self._serialize_track(track, index)
|
|
67
|
+
|
|
68
|
+
def get_clip(self, clip_id):
|
|
69
|
+
clip, track_id, slot_index = self._find_clip(clip_id)
|
|
70
|
+
return self._serialize_clip(clip, track_id, slot_index)
|
|
71
|
+
|
|
72
|
+
def get_device(self, device_id):
|
|
73
|
+
device, track_id, device_index = self._find_device(device_id)
|
|
74
|
+
return self._serialize_device(device, track_id, device_index)
|
|
75
|
+
|
|
76
|
+
def get_parameter(self, parameter_id):
|
|
77
|
+
parameter, device_id, parameter_index = self._find_parameter(parameter_id)
|
|
78
|
+
return self._serialize_parameter(parameter, device_id, parameter_index)
|
|
79
|
+
|
|
80
|
+
def set_tempo(self, value, dry_run=False):
|
|
81
|
+
tempo = float(value)
|
|
82
|
+
if tempo <= 0:
|
|
83
|
+
raise RequestError("invalid_argument", "tempo must be positive")
|
|
84
|
+
if not dry_run:
|
|
85
|
+
self.song.tempo = tempo
|
|
86
|
+
return {"target": "song.tempo", "applied": not dry_run, "value": tempo}
|
|
87
|
+
|
|
88
|
+
def set_parameter(self, parameter_id, value, dry_run=False):
|
|
89
|
+
parameter, device_id, parameter_index = self._find_parameter(parameter_id)
|
|
90
|
+
next_value = float(value)
|
|
91
|
+
if not dry_run:
|
|
92
|
+
parameter.value = next_value
|
|
93
|
+
return {
|
|
94
|
+
"target": parameter_id,
|
|
95
|
+
"applied": not dry_run,
|
|
96
|
+
"parameter": self._serialize_parameter(parameter, device_id, parameter_index),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def play(self, dry_run=False):
|
|
100
|
+
if not dry_run:
|
|
101
|
+
self.song.start_playing()
|
|
102
|
+
return {"target": "transport.play", "applied": not dry_run, "is_playing": True}
|
|
103
|
+
|
|
104
|
+
def stop(self, dry_run=False):
|
|
105
|
+
if not dry_run:
|
|
106
|
+
self.song.stop_playing()
|
|
107
|
+
return {"target": "transport.stop", "applied": not dry_run, "is_playing": False}
|
|
108
|
+
|
|
109
|
+
def create_track(self, kind="midi", name=None, dry_run=False):
|
|
110
|
+
if kind != "midi":
|
|
111
|
+
raise RequestError("unsupported_argument", "Only midi tracks are supported in the first pass")
|
|
112
|
+
index = len(getattr(self.song, "tracks", []))
|
|
113
|
+
if dry_run:
|
|
114
|
+
track = self.song.preview_track(index=index, name=name)
|
|
115
|
+
else:
|
|
116
|
+
self.song.create_midi_track(index)
|
|
117
|
+
track = self.song.tracks[index]
|
|
118
|
+
if name:
|
|
119
|
+
track.name = name
|
|
120
|
+
return {"applied": not dry_run, "track": self._serialize_track(track, index)}
|
|
121
|
+
|
|
122
|
+
def create_scene(self, name=None, dry_run=False):
|
|
123
|
+
index = len(getattr(self.song, "scenes", []))
|
|
124
|
+
if dry_run:
|
|
125
|
+
scene = self.song.preview_scene(index=index, name=name)
|
|
126
|
+
else:
|
|
127
|
+
self.song.create_scene(index)
|
|
128
|
+
scene = self.song.scenes[index]
|
|
129
|
+
if name:
|
|
130
|
+
scene.name = name
|
|
131
|
+
return {"applied": not dry_run, "scene": self._serialize_scene(scene, index)}
|
|
132
|
+
|
|
133
|
+
def create_clip(self, track_id, slot_index, length_beats=4, name=None, dry_run=False):
|
|
134
|
+
track, _track_index = self._find_track(track_id)
|
|
135
|
+
slot = self._find_clip_slot(track, slot_index)
|
|
136
|
+
if dry_run:
|
|
137
|
+
clip = slot.preview_clip(length_beats=length_beats, name=name)
|
|
138
|
+
else:
|
|
139
|
+
slot.create_clip(length_beats)
|
|
140
|
+
clip = slot.clip
|
|
141
|
+
if name:
|
|
142
|
+
clip.name = name
|
|
143
|
+
return {"applied": not dry_run, "clip": self._serialize_clip(clip, track_id, slot_index)}
|
|
144
|
+
|
|
145
|
+
def insert_notes(self, clip_id, notes, dry_run=False):
|
|
146
|
+
clip, track_id, slot_index = self._find_clip(clip_id)
|
|
147
|
+
if not dry_run:
|
|
148
|
+
if hasattr(clip, "add_new_notes"):
|
|
149
|
+
clip.add_new_notes(notes)
|
|
150
|
+
elif hasattr(clip, "set_notes"):
|
|
151
|
+
clip.set_notes(tuple(self._tuple_note(note) for note in notes))
|
|
152
|
+
else:
|
|
153
|
+
clip.notes.extend(notes)
|
|
154
|
+
note_count = len(notes)
|
|
155
|
+
clip_state = self._serialize_clip(clip, track_id, slot_index)
|
|
156
|
+
clip_state["note_count"] = len(getattr(clip, "notes", [])) if not dry_run else note_count
|
|
157
|
+
return {"applied": not dry_run, "clip": clip_state, "note_count": note_count}
|
|
158
|
+
|
|
159
|
+
def _serialize_track(self, track, index):
|
|
160
|
+
track_id = getattr(track, "id", None) or _track_id(index)
|
|
161
|
+
clip_slots = getattr(track, "clip_slots", [])
|
|
162
|
+
devices = getattr(track, "devices", [])
|
|
163
|
+
return {
|
|
164
|
+
"id": track_id,
|
|
165
|
+
"index": index,
|
|
166
|
+
"name": getattr(track, "name", "Track {0}".format(index + 1)),
|
|
167
|
+
"type": getattr(track, "type", "midi"),
|
|
168
|
+
"arm": bool(getattr(track, "arm", False)),
|
|
169
|
+
"mute": bool(getattr(track, "mute", False)),
|
|
170
|
+
"solo": bool(getattr(track, "solo", False)),
|
|
171
|
+
"session_clips": [
|
|
172
|
+
self._serialize_clip(slot.clip, track_id, slot_index)
|
|
173
|
+
for slot_index, slot in enumerate(clip_slots)
|
|
174
|
+
if getattr(slot, "has_clip", False)
|
|
175
|
+
],
|
|
176
|
+
"devices": [self._serialize_device(device, track_id, device_index) for device_index, device in enumerate(devices)],
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
def _serialize_scene(self, scene, index):
|
|
180
|
+
return {
|
|
181
|
+
"id": getattr(scene, "id", None) or _scene_id(index),
|
|
182
|
+
"index": index,
|
|
183
|
+
"name": getattr(scene, "name", "Scene {0}".format(index + 1)),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
def _serialize_clip(self, clip, track_id, slot_index):
|
|
187
|
+
return {
|
|
188
|
+
"id": getattr(clip, "id", None) or _clip_id(track_id, slot_index),
|
|
189
|
+
"slot_index": slot_index,
|
|
190
|
+
"name": getattr(clip, "name", "Clip {0}".format(slot_index + 1)),
|
|
191
|
+
"length_beats": getattr(clip, "length", None),
|
|
192
|
+
"is_playing": bool(getattr(clip, "is_playing", False)),
|
|
193
|
+
"notes": list(getattr(clip, "notes", [])),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def _serialize_device(self, device, track_id, device_index):
|
|
197
|
+
device_id = getattr(device, "id", None) or _device_id(track_id, device_index)
|
|
198
|
+
return {
|
|
199
|
+
"id": device_id,
|
|
200
|
+
"name": getattr(device, "name", "Device {0}".format(device_index + 1)),
|
|
201
|
+
"class_name": getattr(device, "class_name", "Device"),
|
|
202
|
+
"parameters": [
|
|
203
|
+
self._serialize_parameter(parameter, device_id, parameter_index)
|
|
204
|
+
for parameter_index, parameter in enumerate(getattr(device, "parameters", []))
|
|
205
|
+
],
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
def _serialize_parameter(self, parameter, device_id, parameter_index):
|
|
209
|
+
return {
|
|
210
|
+
"id": getattr(parameter, "id", None) or _parameter_id(device_id, parameter_index),
|
|
211
|
+
"name": getattr(parameter, "name", "Parameter {0}".format(parameter_index + 1)),
|
|
212
|
+
"value": getattr(parameter, "value", None),
|
|
213
|
+
"min": getattr(parameter, "min", 0.0),
|
|
214
|
+
"max": getattr(parameter, "max", 1.0),
|
|
215
|
+
"display_value": getattr(parameter, "display_value", str(getattr(parameter, "value", ""))),
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
def _find_track(self, track_id):
|
|
219
|
+
for index, track in enumerate(getattr(self.song, "tracks", [])):
|
|
220
|
+
candidate = getattr(track, "id", None) or _track_id(index)
|
|
221
|
+
if candidate == track_id:
|
|
222
|
+
return track, index
|
|
223
|
+
raise RequestError("not_found", "Track not found: {0}".format(track_id))
|
|
224
|
+
|
|
225
|
+
def _find_clip_slot(self, track, slot_index):
|
|
226
|
+
if slot_index is None:
|
|
227
|
+
raise RequestError("invalid_argument", "slot_index is required")
|
|
228
|
+
clip_slots = getattr(track, "clip_slots", [])
|
|
229
|
+
if slot_index < 0 or slot_index >= len(clip_slots):
|
|
230
|
+
raise RequestError("not_found", "Clip slot not found: {0}".format(slot_index))
|
|
231
|
+
return clip_slots[slot_index]
|
|
232
|
+
|
|
233
|
+
def _find_clip(self, clip_id):
|
|
234
|
+
for track_index, track in enumerate(getattr(self.song, "tracks", [])):
|
|
235
|
+
track_id = getattr(track, "id", None) or _track_id(track_index)
|
|
236
|
+
for slot_index, slot in enumerate(getattr(track, "clip_slots", [])):
|
|
237
|
+
if not getattr(slot, "has_clip", False):
|
|
238
|
+
continue
|
|
239
|
+
current_clip = slot.clip
|
|
240
|
+
candidate = getattr(current_clip, "id", None) or _clip_id(track_id, slot_index)
|
|
241
|
+
if candidate == clip_id:
|
|
242
|
+
return current_clip, track_id, slot_index
|
|
243
|
+
raise RequestError("not_found", "Clip not found: {0}".format(clip_id))
|
|
244
|
+
|
|
245
|
+
def _find_device(self, device_id):
|
|
246
|
+
for track_index, track in enumerate(getattr(self.song, "tracks", [])):
|
|
247
|
+
track_id = getattr(track, "id", None) or _track_id(track_index)
|
|
248
|
+
for device_index, device in enumerate(getattr(track, "devices", [])):
|
|
249
|
+
candidate = getattr(device, "id", None) or _device_id(track_id, device_index)
|
|
250
|
+
if candidate == device_id:
|
|
251
|
+
return device, track_id, device_index
|
|
252
|
+
raise RequestError("not_found", "Device not found: {0}".format(device_id))
|
|
253
|
+
|
|
254
|
+
def _find_parameter(self, parameter_id):
|
|
255
|
+
for track_index, track in enumerate(getattr(self.song, "tracks", [])):
|
|
256
|
+
track_id = getattr(track, "id", None) or _track_id(track_index)
|
|
257
|
+
for device_index, device in enumerate(getattr(track, "devices", [])):
|
|
258
|
+
device_id = getattr(device, "id", None) or _device_id(track_id, device_index)
|
|
259
|
+
for parameter_index, parameter in enumerate(getattr(device, "parameters", [])):
|
|
260
|
+
candidate = getattr(parameter, "id", None) or _parameter_id(device_id, parameter_index)
|
|
261
|
+
if candidate == parameter_id:
|
|
262
|
+
return parameter, device_id, parameter_index
|
|
263
|
+
raise RequestError("not_found", "Parameter not found: {0}".format(parameter_id))
|
|
264
|
+
|
|
265
|
+
def _tuple_note(self, note):
|
|
266
|
+
return (
|
|
267
|
+
note.get("pitch", 60),
|
|
268
|
+
note.get("start_beats", 0.0),
|
|
269
|
+
note.get("duration_beats", 0.25),
|
|
270
|
+
note.get("velocity", 100),
|
|
271
|
+
bool(note.get("mute", False)),
|
|
272
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import absolute_import, print_function, unicode_literals
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
PROTOCOL_VERSION = "0.1.0"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RequestError(Exception):
|
|
12
|
+
def __init__(self, code, message):
|
|
13
|
+
super(RequestError, self).__init__(message)
|
|
14
|
+
self.code = code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_request(operation, target=None, arguments=None, dry_run=False, client_id="laive-client", request_id=None):
|
|
18
|
+
return {
|
|
19
|
+
"type": "request",
|
|
20
|
+
"request_id": request_id or str(uuid.uuid4()),
|
|
21
|
+
"timestamp": iso_now(),
|
|
22
|
+
"client_id": client_id,
|
|
23
|
+
"operation": operation,
|
|
24
|
+
"target": target,
|
|
25
|
+
"arguments": arguments or {},
|
|
26
|
+
"dry_run": bool(dry_run),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def make_response(request_id, ok=True, result=None, error_code=None, error_message=None, live_version=None):
|
|
31
|
+
return {
|
|
32
|
+
"type": "response",
|
|
33
|
+
"request_id": request_id,
|
|
34
|
+
"timestamp": iso_now(),
|
|
35
|
+
"ok": bool(ok),
|
|
36
|
+
"result": result,
|
|
37
|
+
"error_code": error_code,
|
|
38
|
+
"error_message": error_message,
|
|
39
|
+
"bridge_version": PROTOCOL_VERSION,
|
|
40
|
+
"live_version": live_version,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_error_response(request_id, error_code, error_message, live_version=None):
|
|
45
|
+
return make_response(
|
|
46
|
+
request_id=request_id,
|
|
47
|
+
ok=False,
|
|
48
|
+
result=None,
|
|
49
|
+
error_code=error_code,
|
|
50
|
+
error_message=error_message,
|
|
51
|
+
live_version=live_version,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def make_event(topic, payload=None):
|
|
56
|
+
return {
|
|
57
|
+
"type": "event",
|
|
58
|
+
"topic": topic,
|
|
59
|
+
"payload": payload or {},
|
|
60
|
+
"timestamp": iso_now(),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def encode_json_line(payload):
|
|
65
|
+
return json.dumps(payload, separators=(",", ":")) + "\n"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class JsonLineParser(object):
|
|
69
|
+
def __init__(self):
|
|
70
|
+
self._buffer = ""
|
|
71
|
+
|
|
72
|
+
def push(self, chunk):
|
|
73
|
+
if isinstance(chunk, bytes):
|
|
74
|
+
chunk = chunk.decode("utf-8")
|
|
75
|
+
self._buffer += chunk
|
|
76
|
+
messages = []
|
|
77
|
+
while "\n" in self._buffer:
|
|
78
|
+
line, self._buffer = self._buffer.split("\n", 1)
|
|
79
|
+
line = line.strip()
|
|
80
|
+
if not line:
|
|
81
|
+
continue
|
|
82
|
+
messages.append(json.loads(line))
|
|
83
|
+
return messages
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def iso_now():
|
|
87
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|