trackwright 0.3.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.
- trackwright/__init__.py +13 -0
- trackwright/__main__.py +7 -0
- trackwright/bridge.py +323 -0
- trackwright/cli.py +203 -0
- trackwright/commands.py +397 -0
- trackwright/config.py +149 -0
- trackwright/core/__init__.py +1 -0
- trackwright/core/automation.py +410 -0
- trackwright/core/base.py +313 -0
- trackwright/core/commanding.py +300 -0
- trackwright/core/exporting.py +143 -0
- trackwright/core/importing.py +127 -0
- trackwright/core/lifecycle.py +74 -0
- trackwright/core/models.py +467 -0
- trackwright/core/policy.py +58 -0
- trackwright/core/presets.py +88 -0
- trackwright/core/project.py +221 -0
- trackwright/core/ranges.py +355 -0
- trackwright/core/reading.py +229 -0
- trackwright/core/refs.py +88 -0
- trackwright/core/saving.py +72 -0
- trackwright/core/service.py +27 -0
- trackwright/core/settings.py +80 -0
- trackwright/core/writing.py +390 -0
- trackwright/coremidi.py +307 -0
- trackwright/cubase/Trackwright_Bridge.js +1130 -0
- trackwright/cubase/__init__.py +83 -0
- trackwright/doctor.py +69 -0
- trackwright/errors.py +120 -0
- trackwright/exports.py +54 -0
- trackwright/fake.py +1112 -0
- trackwright/host.py +238 -0
- trackwright/hostport.py +74 -0
- trackwright/installer.py +305 -0
- trackwright/launcher.py +150 -0
- trackwright/logs.py +50 -0
- trackwright/ops.py +46 -0
- trackwright/protocol.py +182 -0
- trackwright/py.typed +0 -0
- trackwright/server.py +472 -0
- trackwright/transport.py +130 -0
- trackwright-0.3.0.dist-info/METADATA +190 -0
- trackwright-0.3.0.dist-info/RECORD +46 -0
- trackwright-0.3.0.dist-info/WHEEL +4 -0
- trackwright-0.3.0.dist-info/entry_points.txt +3 -0
- trackwright-0.3.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Structured outputs of the MCP tools (their JSON schemas are published to MCP clients)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
8
|
+
|
|
9
|
+
from trackwright.commands import CommandClass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _Out(BaseModel):
|
|
13
|
+
model_config = ConfigDict(extra="forbid")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------- shared pieces
|
|
17
|
+
class Value(_Out):
|
|
18
|
+
process: float | None = Field(description="normalized value as stored by Cubase")
|
|
19
|
+
display: str | None = Field(description="value as Cubase shows it (localized)")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TrackInfo(_Out):
|
|
23
|
+
track: str = Field(description='selector to reuse in other tools: "uid:<UID>" (stable across restarts)')
|
|
24
|
+
uid: str
|
|
25
|
+
name: str
|
|
26
|
+
type: str = Field(description="AudioChannel, Synth, GroupChannel, OutputChannel...")
|
|
27
|
+
ref: str = Field(description="opaque reference, valid only in the current script session")
|
|
28
|
+
mixer_index: int | None
|
|
29
|
+
selected: bool
|
|
30
|
+
volume: Value | None
|
|
31
|
+
mute: bool | None
|
|
32
|
+
solo: bool | None
|
|
33
|
+
input: str | None
|
|
34
|
+
output: str | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ParameterInfo(_Out):
|
|
38
|
+
tag: int = Field(description="stable identifier of the parameter for a given plugin/channel")
|
|
39
|
+
title: str = Field(description="localized title")
|
|
40
|
+
process: float | None
|
|
41
|
+
display: str | None
|
|
42
|
+
units: str | None
|
|
43
|
+
plain: float | None = Field(description="value in real units (dB, Hz...) when the plugin provides it")
|
|
44
|
+
value_type: str | None
|
|
45
|
+
automatable: bool | None
|
|
46
|
+
edit_locked: bool | None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Change(_Out):
|
|
50
|
+
tag: int
|
|
51
|
+
title: str
|
|
52
|
+
before: Value
|
|
53
|
+
requested: float | str
|
|
54
|
+
after: Value | None
|
|
55
|
+
verified: bool | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------- read tools
|
|
59
|
+
class StatusOut(_Out):
|
|
60
|
+
connected: bool
|
|
61
|
+
simulated: bool = Field(description="true when answering from the built-in simulator, not Cubase")
|
|
62
|
+
session: str | None
|
|
63
|
+
script_version: str | None
|
|
64
|
+
host_app: str | None
|
|
65
|
+
host_version: str | None
|
|
66
|
+
insert_slots: int | None
|
|
67
|
+
mixer_access: bool
|
|
68
|
+
transport_access: bool
|
|
69
|
+
commands_bound: int | None
|
|
70
|
+
writes_enabled: bool
|
|
71
|
+
allowed_tracks: list[str]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TrackListOut(_Out):
|
|
75
|
+
total: int
|
|
76
|
+
tracks: list[TrackInfo]
|
|
77
|
+
note: str = "tracks without a mixer channel (marker, folder) are not listed"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Section(_Out):
|
|
81
|
+
kind: str = Field(description="Inserts, Strips, Sends, Panner...")
|
|
82
|
+
title: str
|
|
83
|
+
ref: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SelectedChannelOut(_Out):
|
|
87
|
+
track: TrackInfo | None = Field(description="null when the selected track has no mixer channel or none is selected")
|
|
88
|
+
sections: list[Section]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PluginInSlot(_Out):
|
|
92
|
+
name: str
|
|
93
|
+
plugin_uid: str
|
|
94
|
+
ref: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SlotInfo(_Out):
|
|
98
|
+
index: int
|
|
99
|
+
empty: bool
|
|
100
|
+
ref: str
|
|
101
|
+
plugin: PluginInSlot | None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class InsertsOut(_Out):
|
|
105
|
+
track: TrackInfo
|
|
106
|
+
slots: list[SlotInfo]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PluginEntry(_Out):
|
|
110
|
+
name: str
|
|
111
|
+
vendor: str
|
|
112
|
+
version: str
|
|
113
|
+
uid: str
|
|
114
|
+
sub_categories: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PluginCatalogOut(_Out):
|
|
118
|
+
collection: str | None
|
|
119
|
+
total: int
|
|
120
|
+
matched: int
|
|
121
|
+
offset: int
|
|
122
|
+
items: list[PluginEntry]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ParametersOut(_Out):
|
|
126
|
+
target: str
|
|
127
|
+
target_ref: str
|
|
128
|
+
target_name: str
|
|
129
|
+
total: int
|
|
130
|
+
offset: int
|
|
131
|
+
scanned: int
|
|
132
|
+
items: list[ParameterInfo]
|
|
133
|
+
next_offset: int = Field(description="offset to pass to get the following parameters")
|
|
134
|
+
truncated: bool = Field(description="true when more parameters remain after next_offset")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ParameterOut(_Out):
|
|
138
|
+
target: str
|
|
139
|
+
target_ref: str
|
|
140
|
+
target_name: str
|
|
141
|
+
parameter: ParameterInfo
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TimePosition(_Out):
|
|
145
|
+
time: str
|
|
146
|
+
format: str | None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class TransportOut(_Out):
|
|
150
|
+
cursor: TimePosition
|
|
151
|
+
cycle_left: TimePosition | None
|
|
152
|
+
cycle_right: TimePosition | None
|
|
153
|
+
tempo_bpm: float | None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ChangeEvent(_Out):
|
|
157
|
+
kind: str
|
|
158
|
+
tag: int | None
|
|
159
|
+
ref: str | None
|
|
160
|
+
title: str | None
|
|
161
|
+
still_valid: bool | None = Field(description="null when the object was not checked (see unchecked)")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ChangesOut(_Out):
|
|
165
|
+
events: list[ChangeEvent]
|
|
166
|
+
dropped: int
|
|
167
|
+
unchecked: int = Field(description="events whose object was not looked up (only the first 20 objects are)")
|
|
168
|
+
advice: str
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------- write tools
|
|
172
|
+
class WriteOut(_Out):
|
|
173
|
+
dry_run: bool
|
|
174
|
+
target: str
|
|
175
|
+
changes: list[Change]
|
|
176
|
+
verified: bool | None = Field(description="all changes read back as requested (null in dry runs)")
|
|
177
|
+
note: str | None = None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class InsertPluginOut(_Out):
|
|
181
|
+
dry_run: bool
|
|
182
|
+
track: str
|
|
183
|
+
slot: int
|
|
184
|
+
plugin_uid: str
|
|
185
|
+
plugin_name: str
|
|
186
|
+
inserted: bool | None
|
|
187
|
+
verified: bool | None
|
|
188
|
+
plugin_ref: str | None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class SelectOut(_Out):
|
|
192
|
+
selected_before: str | None
|
|
193
|
+
selected_after: str | None
|
|
194
|
+
verified: bool
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class PositionOut(_Out):
|
|
198
|
+
dry_run: bool
|
|
199
|
+
which: str
|
|
200
|
+
requested: str
|
|
201
|
+
before: TimePosition | None
|
|
202
|
+
after: TimePosition | None
|
|
203
|
+
verified: bool | None
|
|
204
|
+
note: str | None = None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class TempoOut(_Out):
|
|
208
|
+
dry_run: bool
|
|
209
|
+
before: float | None
|
|
210
|
+
requested: float
|
|
211
|
+
after: float | None
|
|
212
|
+
verified: bool | None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
CommandStatus = Literal["planned", "fired", "awaiting_user", "pending_confirmation", "done", "not_found"]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class CommandOut(_Out):
|
|
219
|
+
dry_run: bool
|
|
220
|
+
key: str
|
|
221
|
+
category: str
|
|
222
|
+
name: str
|
|
223
|
+
command_class: CommandClass
|
|
224
|
+
can_perform: bool | None
|
|
225
|
+
status: CommandStatus
|
|
226
|
+
verified: bool | Literal["not_observable"] | None
|
|
227
|
+
instructions: str | None = None
|
|
228
|
+
details: dict[str, str | int | float | bool | None] = Field(default_factory=dict)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------- automation (TW-1207)
|
|
232
|
+
class AutomationPoint(BaseModel):
|
|
233
|
+
"""One point of an automation curve (input): give ``plain`` (real units) or ``value`` (normalized)."""
|
|
234
|
+
|
|
235
|
+
model_config = ConfigDict(extra="forbid")
|
|
236
|
+
position: str = Field(description="bar position, e.g. '9. 1. 1. 0' (bar. beat. 16th. tick); 4/4 assumed")
|
|
237
|
+
plain: float | None = Field(
|
|
238
|
+
default=None, description="value in real units (dB, Hz...), converted exactly by Cubase. Preferred."
|
|
239
|
+
)
|
|
240
|
+
value: float | None = Field(default=None, ge=0, le=1, description="normalized value 0..1 (use plain instead)")
|
|
241
|
+
|
|
242
|
+
@model_validator(mode="after")
|
|
243
|
+
def _one_value(self) -> AutomationPoint:
|
|
244
|
+
if (self.plain is None) == (self.value is None):
|
|
245
|
+
raise ValueError("give exactly one of plain or value")
|
|
246
|
+
return self
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class AutomationTarget(_Out):
|
|
250
|
+
parameter: str = Field(description="key to pass to cubase_record_automation, e.g. 'volume' or 'insert1.p1'")
|
|
251
|
+
title: str | None = Field(description="the Cubase parameter bound to it, e.g. 'Compressor / Threshold'")
|
|
252
|
+
value: float | None
|
|
253
|
+
display: str | None = Field(description="current value as Cubase shows it, in real units (dB, Hz, L/R...)")
|
|
254
|
+
available: bool = Field(description="false when nothing is bound (empty insert slot, unassigned quick control)")
|
|
255
|
+
converts_units: bool = Field(description="plain values (real units) are accepted for this parameter")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class AutomationTargetsOut(_Out):
|
|
259
|
+
track: str
|
|
260
|
+
targets: list[AutomationTarget]
|
|
261
|
+
note: str = "the track was selected: automation controls follow the selected track"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class ResolvedPoint(_Out):
|
|
265
|
+
position: str
|
|
266
|
+
value: float = Field(description="normalized value written")
|
|
267
|
+
plain: float | None = Field(description="the value in real units, when given or converted")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class ConvertOut(_Out):
|
|
271
|
+
track: str
|
|
272
|
+
parameter: str
|
|
273
|
+
title: str | None
|
|
274
|
+
plain: float
|
|
275
|
+
value: float = Field(description="normalized value 0..1")
|
|
276
|
+
note: str = "exact conversion by Cubase (convertParameterPlainToProcessValue / ProcessValueToPlain)"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class WrittenPoint(_Out):
|
|
280
|
+
position: str
|
|
281
|
+
value: float
|
|
282
|
+
display: str | None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class AutomationRecordOut(_Out):
|
|
286
|
+
dry_run: bool
|
|
287
|
+
track: str
|
|
288
|
+
parameter: str
|
|
289
|
+
title: str | None
|
|
290
|
+
value_before: float | None
|
|
291
|
+
display_before: str | None
|
|
292
|
+
start: str = Field(description="where playback starts (pre-roll before the first point)")
|
|
293
|
+
points: list[ResolvedPoint] = Field(description="the curve, with the normalized value used for each point")
|
|
294
|
+
written: list[WrittenPoint]
|
|
295
|
+
verified: bool | None = Field(description="a read-back pass followed the curve (null when not verified)")
|
|
296
|
+
mismatches: list[str] = Field(default_factory=list)
|
|
297
|
+
notes: list[str] = Field(default_factory=list)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------------- lifecycle (TW-1201)
|
|
301
|
+
class LaunchOut(_Out):
|
|
302
|
+
status: Literal["planned", "already_running", "running_without_script", "ready", "awaiting_user"]
|
|
303
|
+
app: str
|
|
304
|
+
dry_run: bool
|
|
305
|
+
process_running: bool
|
|
306
|
+
script_version: str | None
|
|
307
|
+
project: str | None
|
|
308
|
+
instructions: str | None = None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---------------------------------------------------------------------------- project properties (TW-1209)
|
|
312
|
+
class Signature(_Out):
|
|
313
|
+
text: str = Field(description="as Cubase shows it, e.g. '3/4'")
|
|
314
|
+
numerator: int | None
|
|
315
|
+
denominator: int | None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class Span(_Out):
|
|
319
|
+
active: bool | None
|
|
320
|
+
start: str | None
|
|
321
|
+
end: str | None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class Roll(_Out):
|
|
325
|
+
active: bool | None
|
|
326
|
+
length: str | None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class Metronome(_Out):
|
|
330
|
+
active: bool | None
|
|
331
|
+
click_level: str | None
|
|
332
|
+
in_record: bool | None
|
|
333
|
+
in_playback: bool | None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class ProjectOut(_Out):
|
|
337
|
+
tempo_bpm: float | None
|
|
338
|
+
signature: Signature | None = Field(description="time signature at the cursor (verified with 4/4 and 3/4)")
|
|
339
|
+
cycle: Span
|
|
340
|
+
punch_in: bool | None
|
|
341
|
+
punch_out: bool | None
|
|
342
|
+
punch_range: Span
|
|
343
|
+
pre_roll: Roll
|
|
344
|
+
post_roll: Roll
|
|
345
|
+
metronome: Metronome
|
|
346
|
+
precount: bool | None
|
|
347
|
+
transport: Literal["stopped", "playing", "recording", "unknown"]
|
|
348
|
+
time_formats: list[str | None] = Field(description="primary and secondary time displays")
|
|
349
|
+
modes: dict[str, str | None] = Field(description="record and playback modes, as Cubase shows them")
|
|
350
|
+
project_folders: list[str] | None = Field(
|
|
351
|
+
description="folders of the open project(s), deduced from the audio files Cubase has open; "
|
|
352
|
+
"null when not requested, empty when Cubase has no audio file open"
|
|
353
|
+
)
|
|
354
|
+
unavailable: dict[str, str] = Field(description="properties the Cubase API does not expose, and why")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ---------------------------------------------------------------------------- range editing (TW-1211)
|
|
358
|
+
class RangeEditOut(_Out):
|
|
359
|
+
dry_run: bool
|
|
360
|
+
action: str
|
|
361
|
+
start: str = Field(description="h:mm:ss.mmm")
|
|
362
|
+
end: str
|
|
363
|
+
length_seconds: float
|
|
364
|
+
effect: str
|
|
365
|
+
scope: str
|
|
366
|
+
track: str | None = Field(default=None, description="the only track edited (single-track delete)")
|
|
367
|
+
verify_track: str | None = Field(description="track whose event edges were probed to verify")
|
|
368
|
+
applied: bool = False
|
|
369
|
+
locators: list[str] = Field(default_factory=list, description="the range as bars, while editing")
|
|
370
|
+
edges_before: list[str] = Field(default_factory=list, description="event edges (starts and ends) probed")
|
|
371
|
+
edges_after: list[str] = Field(default_factory=list)
|
|
372
|
+
undo_steps: int = Field(default=0, description="Undo steps that revert the edit in Cubase")
|
|
373
|
+
verified: bool | None = None
|
|
374
|
+
mismatches: list[str] = Field(default_factory=list)
|
|
375
|
+
notes: list[str] = Field(default_factory=list)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------- export (TW-1204)
|
|
379
|
+
class ExportedFileOut(_Out):
|
|
380
|
+
path: str
|
|
381
|
+
size_bytes: int
|
|
382
|
+
duration_seconds: float | None
|
|
383
|
+
difference_seconds: float | None = Field(default=None, description="duration minus the expected one")
|
|
384
|
+
duration_matches: bool | None = Field(
|
|
385
|
+
description="the duration equals the range: ±0.15 s, or ±(0.5 s + 0.2 %) for MP3/AAC/OGG (estimated)"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class ExportOut(_Out):
|
|
390
|
+
phase: Literal["start", "confirm"]
|
|
391
|
+
status: Literal["planned", "awaiting_user", "done", "not_found"]
|
|
392
|
+
dry_run: bool
|
|
393
|
+
start: str | None = Field(description="range start (h:mm:ss.mmm)")
|
|
394
|
+
end: str | None
|
|
395
|
+
expected_seconds: float | None
|
|
396
|
+
folder: str | None = Field(description="where Cubase proposes to export (<project folder>/Mixdown)")
|
|
397
|
+
since: float | None = Field(description="pass it back to the confirm phase")
|
|
398
|
+
files: list[ExportedFileOut] = Field(default_factory=list)
|
|
399
|
+
verified: bool | None = None
|
|
400
|
+
instructions: str | None = None
|
|
401
|
+
notes: list[str] = Field(default_factory=list)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ---------------------------------------------------------------------------- save (TW-1202)
|
|
405
|
+
class ProjectFileOut(_Out):
|
|
406
|
+
name: str
|
|
407
|
+
size_bytes: int
|
|
408
|
+
modified: str = Field(description="local time, YYYY-MM-DD HH:MM:SS")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class SaveOut(_Out):
|
|
412
|
+
mode: Literal["save", "new_version"]
|
|
413
|
+
dry_run: bool
|
|
414
|
+
status: Literal["planned", "saved", "awaiting_user"]
|
|
415
|
+
folder: str | None = Field(description="project folder (deduced from the audio files Cubase has open)")
|
|
416
|
+
files_before: list[ProjectFileOut] = Field(default_factory=list, description=".cpr files, newest first")
|
|
417
|
+
saved: ProjectFileOut | None = Field(default=None, description="the .cpr written by this save")
|
|
418
|
+
verified: bool | None = None
|
|
419
|
+
instructions: str | None = None
|
|
420
|
+
notes: list[str] = Field(default_factory=list)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# ---------------------------------------------------------------------------- import (TW-1203)
|
|
424
|
+
class ImportOut(_Out):
|
|
425
|
+
phase: Literal["start", "confirm"]
|
|
426
|
+
status: Literal["planned", "awaiting_user", "done", "not_found"]
|
|
427
|
+
dry_run: bool
|
|
428
|
+
track: str
|
|
429
|
+
position: str = Field(description="where the audio goes (h:mm:ss.mmm)")
|
|
430
|
+
file: str | None = Field(description="the file to pick in the dialog, when given")
|
|
431
|
+
expected_seconds: float | None = Field(description="duration of that file (afinfo)")
|
|
432
|
+
since: float | None = Field(description="pass it back to the confirm phase")
|
|
433
|
+
edges_found: list[str] = Field(default_factory=list, description="event edges at the start and end")
|
|
434
|
+
copied_to_project: list[str] = Field(default_factory=list, description="new files in <project>/Audio")
|
|
435
|
+
verified: bool | None = None
|
|
436
|
+
instructions: str | None = None
|
|
437
|
+
notes: list[str] = Field(default_factory=list)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ---------------------------------------------------------------------------- project settings (TW-1210)
|
|
441
|
+
class SettingChange(_Out):
|
|
442
|
+
setting: str
|
|
443
|
+
before: bool | int | None
|
|
444
|
+
requested: bool | int
|
|
445
|
+
after: bool | int | None
|
|
446
|
+
verified: bool | None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class ProjectSettingsOut(_Out):
|
|
450
|
+
dry_run: bool
|
|
451
|
+
changes: list[SettingChange]
|
|
452
|
+
verified: bool | None
|
|
453
|
+
notes: list[str] = Field(default_factory=list)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ---------------------------------------------------------------------------- plugin presets (TW-1205)
|
|
457
|
+
class PresetStepOut(_Out):
|
|
458
|
+
dry_run: bool
|
|
459
|
+
track: str
|
|
460
|
+
slot: int
|
|
461
|
+
plugin: str
|
|
462
|
+
direction: Literal["next", "previous"]
|
|
463
|
+
status: Literal["planned", "done", "awaiting_user"] = "planned"
|
|
464
|
+
changed: dict[str, str] = Field(default_factory=dict, description="parameter: 'before -> after'")
|
|
465
|
+
instructions: str | None = None
|
|
466
|
+
verified: bool | None = Field(description="some parameter changed (the preset was applied)")
|
|
467
|
+
notes: list[str] = Field(default_factory=list)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Write policy: what the MCP server may change, configured by the user.
|
|
2
|
+
|
|
3
|
+
- ``[writes] enabled = false`` (default): every project modification fails with ``WRITES_DISABLED``.
|
|
4
|
+
Dry runs always work, and so does *navigation* (selecting a track, moving the cursor), which
|
|
5
|
+
does not modify the project.
|
|
6
|
+
- ``allowed_tracks``: when not empty, only those track names may be modified (``NOT_ALLOWED``).
|
|
7
|
+
- ``save``: saving the project file needs its own switch (``SAVE_DISABLED``), on top of ``enabled``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from trackwright.config import WritesConfig
|
|
15
|
+
from trackwright.errors import PolicyError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class WritePolicy:
|
|
20
|
+
enabled: bool = False
|
|
21
|
+
allowed_tracks: tuple[str, ...] = ()
|
|
22
|
+
save: bool = False
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_config(cls, config: WritesConfig) -> WritePolicy:
|
|
26
|
+
return cls(enabled=config.enabled, allowed_tracks=config.allowed_tracks, save=config.save)
|
|
27
|
+
|
|
28
|
+
def check_save(self) -> None:
|
|
29
|
+
"""Raise unless saving the project file is allowed."""
|
|
30
|
+
self.check_project_write()
|
|
31
|
+
if not self.save:
|
|
32
|
+
raise PolicyError(
|
|
33
|
+
"saving is disabled; set [writes] save = true in the Trackwright configuration "
|
|
34
|
+
"(dry runs work without it)",
|
|
35
|
+
code="SAVE_DISABLED",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def check_project_write(self) -> None:
|
|
39
|
+
"""Raise unless project modifications are enabled."""
|
|
40
|
+
if not self.enabled:
|
|
41
|
+
raise PolicyError(
|
|
42
|
+
"writes are disabled; set [writes] enabled = true in the Trackwright configuration "
|
|
43
|
+
"(dry runs work without it)",
|
|
44
|
+
code="WRITES_DISABLED",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def check_track(self, track_name: str | None) -> None:
|
|
48
|
+
"""Raise unless writes are enabled and ``track_name`` may be modified."""
|
|
49
|
+
self.check_project_write()
|
|
50
|
+
if self.allowed_tracks and track_name not in self.allowed_tracks:
|
|
51
|
+
raise PolicyError(
|
|
52
|
+
f"track {track_name!r} is not in [writes] allowed_tracks",
|
|
53
|
+
code="NOT_ALLOWED",
|
|
54
|
+
details={"track": track_name, "allowed_tracks": list(self.allowed_tracks)},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def describe(self) -> dict[str, object]:
|
|
58
|
+
return {"writes_enabled": self.enabled, "allowed_tracks": list(self.allowed_tracks), "save": self.save}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Step through a plugin's presets (TW-1205).
|
|
2
|
+
|
|
3
|
+
Verified in Cubase Elements 15.0.30 (experiments/tw1205): the slot's "Edición" parameter opens the plugin
|
|
4
|
+
editor; ``Preset / Next`` and ``Previous`` act on that editor only once the user has clicked on its window
|
|
5
|
+
(keyboard focus), and from then on also after closing and reopening it through the API; they do nothing
|
|
6
|
+
when the editor is closed or no preset is loaded. The result is checked by comparing the plugin's
|
|
7
|
+
parameters. Presets cannot be chosen or read by name.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from trackwright.core.base import MIXER, ServiceBase, operation
|
|
15
|
+
from trackwright.core.models import PresetStepOut
|
|
16
|
+
from trackwright.core.refs import TrackSelector
|
|
17
|
+
from trackwright.errors import PreconditionFailedError
|
|
18
|
+
|
|
19
|
+
JsonObject = dict[str, Any]
|
|
20
|
+
TAG_EDIT = 4101
|
|
21
|
+
METERS = ("Vu", "Gain Red", "Max", "Gain Reduction", " PH", "Meter")
|
|
22
|
+
KEYS = {"next": "preset.next", "previous": "preset.previous"}
|
|
23
|
+
EDITOR_SETTLE_SECONDS = 1.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PresetMixin(ServiceBase):
|
|
27
|
+
def _plugin_values(self, plugin: int) -> dict[str, tuple[float, str]]:
|
|
28
|
+
values: dict[str, tuple[float, str]] = {}
|
|
29
|
+
offset = 0
|
|
30
|
+
while True:
|
|
31
|
+
page = self.call("da.params", {"id": plugin, "offset": offset, "limit": 25, **MIXER})
|
|
32
|
+
for param in page.get("items", []):
|
|
33
|
+
title = str(param.get("title"))
|
|
34
|
+
if title and not any(m in title for m in METERS):
|
|
35
|
+
values[title] = (float(param.get("process") or 0), str(param.get("display")))
|
|
36
|
+
offset += len(page.get("items", []))
|
|
37
|
+
if not page.get("items") or offset >= int(page.get("total", 0)):
|
|
38
|
+
return values
|
|
39
|
+
|
|
40
|
+
def _editor_open(self, slot: int) -> bool:
|
|
41
|
+
return float(self.call("da.param.get", {"id": slot, "tag": TAG_EDIT, **MIXER}).get("process") or 0) >= 0.5
|
|
42
|
+
|
|
43
|
+
def _set_editor(self, slot: int, open_: bool) -> None:
|
|
44
|
+
self.call("da.param.set", {"id": slot, "tag": TAG_EDIT, "value": 1.0 if open_ else 0.0, **MIXER})
|
|
45
|
+
self.wait_until(lambda: self._editor_open(slot), open_.__eq__)
|
|
46
|
+
|
|
47
|
+
@operation
|
|
48
|
+
def plugin_preset(
|
|
49
|
+
self, track: str, slot: int, direction: Literal["next", "previous"], *, dry_run: bool = True
|
|
50
|
+
) -> PresetStepOut:
|
|
51
|
+
if direction not in KEYS:
|
|
52
|
+
raise PreconditionFailedError("direction must be 'next' or 'previous'", code="BAD_REQUEST")
|
|
53
|
+
item = self.resolve_track(TrackSelector.parse(track))
|
|
54
|
+
name = self.track_info(item).name
|
|
55
|
+
slot_obj = self.insert_slot(item, slot)
|
|
56
|
+
plugin = self.resolve_target(item, f"insert:{slot}")
|
|
57
|
+
out = PresetStepOut(dry_run=dry_run, track=name, slot=slot, plugin=str(plugin.get("title")),
|
|
58
|
+
direction=direction, verified=None) # fmt: skip
|
|
59
|
+
out.notes.append("the plugin's editor is opened for the command and closed again if it was closed")
|
|
60
|
+
if dry_run:
|
|
61
|
+
return out
|
|
62
|
+
self.policy.check_track(name)
|
|
63
|
+
slot_id, plugin_id = int(slot_obj["id"]), int(plugin["id"])
|
|
64
|
+
before = self._plugin_values(plugin_id)
|
|
65
|
+
was_open = self._editor_open(slot_id)
|
|
66
|
+
if not was_open:
|
|
67
|
+
self._set_editor(slot_id, True)
|
|
68
|
+
self._sleep(EDITOR_SETTLE_SECONDS)
|
|
69
|
+
self.call("cmd.run", {"key": KEYS[direction]})
|
|
70
|
+
after = self.wait_until(lambda: self._plugin_values(plugin_id), lambda now: now != before)
|
|
71
|
+
out.changed = {
|
|
72
|
+
t: f"{before[t][1]} -> {after[t][1]}" for t in before if t in after and after[t][0] != before[t][0]
|
|
73
|
+
}
|
|
74
|
+
out.verified = bool(out.changed)
|
|
75
|
+
if not out.changed:
|
|
76
|
+
out.status = "awaiting_user" # the editor stays open so that the user can click on it
|
|
77
|
+
out.instructions = (
|
|
78
|
+
f"The {out.plugin} window is open in Cubase. Click once on an empty area of it (Cubase needs it to "
|
|
79
|
+
"have the keyboard focus; only the first time), make sure a preset is loaded in its preset bar, "
|
|
80
|
+
"then call this tool again."
|
|
81
|
+
)
|
|
82
|
+
return out
|
|
83
|
+
out.status = "done"
|
|
84
|
+
if not was_open:
|
|
85
|
+
self._set_editor(slot_id, False)
|
|
86
|
+
other = "previous" if direction == "next" else "next"
|
|
87
|
+
out.notes.append(f"the opposite step ('{other}') returns to the previous values")
|
|
88
|
+
return out
|