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.
Files changed (46) hide show
  1. trackwright/__init__.py +13 -0
  2. trackwright/__main__.py +7 -0
  3. trackwright/bridge.py +323 -0
  4. trackwright/cli.py +203 -0
  5. trackwright/commands.py +397 -0
  6. trackwright/config.py +149 -0
  7. trackwright/core/__init__.py +1 -0
  8. trackwright/core/automation.py +410 -0
  9. trackwright/core/base.py +313 -0
  10. trackwright/core/commanding.py +300 -0
  11. trackwright/core/exporting.py +143 -0
  12. trackwright/core/importing.py +127 -0
  13. trackwright/core/lifecycle.py +74 -0
  14. trackwright/core/models.py +467 -0
  15. trackwright/core/policy.py +58 -0
  16. trackwright/core/presets.py +88 -0
  17. trackwright/core/project.py +221 -0
  18. trackwright/core/ranges.py +355 -0
  19. trackwright/core/reading.py +229 -0
  20. trackwright/core/refs.py +88 -0
  21. trackwright/core/saving.py +72 -0
  22. trackwright/core/service.py +27 -0
  23. trackwright/core/settings.py +80 -0
  24. trackwright/core/writing.py +390 -0
  25. trackwright/coremidi.py +307 -0
  26. trackwright/cubase/Trackwright_Bridge.js +1130 -0
  27. trackwright/cubase/__init__.py +83 -0
  28. trackwright/doctor.py +69 -0
  29. trackwright/errors.py +120 -0
  30. trackwright/exports.py +54 -0
  31. trackwright/fake.py +1112 -0
  32. trackwright/host.py +238 -0
  33. trackwright/hostport.py +74 -0
  34. trackwright/installer.py +305 -0
  35. trackwright/launcher.py +150 -0
  36. trackwright/logs.py +50 -0
  37. trackwright/ops.py +46 -0
  38. trackwright/protocol.py +182 -0
  39. trackwright/py.typed +0 -0
  40. trackwright/server.py +472 -0
  41. trackwright/transport.py +130 -0
  42. trackwright-0.3.0.dist-info/METADATA +190 -0
  43. trackwright-0.3.0.dist-info/RECORD +46 -0
  44. trackwright-0.3.0.dist-info/WHEEL +4 -0
  45. trackwright-0.3.0.dist-info/entry_points.txt +3 -0
  46. 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