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,229 @@
1
+ """Read-only tools (epic E05). None of them modifies the project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from trackwright.core.base import MIXER, JsonObject, ServiceBase, operation
6
+ from trackwright.core.models import (
7
+ ChangeEvent,
8
+ ChangesOut,
9
+ InsertsOut,
10
+ ParameterInfo,
11
+ ParameterOut,
12
+ ParametersOut,
13
+ PluginCatalogOut,
14
+ PluginEntry,
15
+ PluginInSlot,
16
+ Section,
17
+ SelectedChannelOut,
18
+ SlotInfo,
19
+ StatusOut,
20
+ TimePosition,
21
+ TrackListOut,
22
+ TransportOut,
23
+ )
24
+ from trackwright.core.refs import TrackSelector
25
+ from trackwright.errors import NotFoundError, TrackwrightError
26
+
27
+ MAX_FILTER_SCAN = 500
28
+ MAX_EVENT_LOOKUPS = 20
29
+
30
+
31
+ class ReadingMixin(ServiceBase):
32
+ @operation
33
+ def status(self) -> StatusOut:
34
+ hello = self.call("hello")
35
+ caps = self.call("capabilities")
36
+ capabilities: JsonObject = caps.get("capabilities") or {}
37
+ host: JsonObject = caps.get("host") or hello.get("host") or {}
38
+ return StatusOut(
39
+ connected=True,
40
+ simulated=bool(self.host.simulated or capabilities.get("simulated")),
41
+ session=self.host.session_id or hello.get("sessionId"),
42
+ script_version=hello.get("scriptVersion"),
43
+ host_app=host.get("appName"),
44
+ host_version=host.get("appVersion"),
45
+ insert_slots=host.get("insertSlots"),
46
+ mixer_access=bool(
47
+ capabilities.get("mixerDirectAccessActive", capabilities.get("mixerDirectAccessCreated"))
48
+ ),
49
+ transport_access=bool(capabilities.get("transportTimeDisplay")),
50
+ commands_bound=capabilities.get("commandsBound"),
51
+ writes_enabled=self.policy.enabled,
52
+ allowed_tracks=list(self.policy.allowed_tracks),
53
+ )
54
+
55
+ @operation
56
+ def list_tracks(self, offset: int = 0, limit: int = 32) -> TrackListOut:
57
+ channels = self.channels()
58
+ page = channels[offset : offset + limit]
59
+ return TrackListOut(total=len(channels), tracks=[self.track_info(c) for c in page])
60
+
61
+ @operation
62
+ def get_selected_channel(self) -> SelectedChannelOut:
63
+ base = self.call("da.base")
64
+ if int(base.get("base", -1)) < 0:
65
+ return SelectedChannelOut(track=None, sections=[])
66
+ track = self.resolve_track(TrackSelector("selected"))
67
+ sections = [
68
+ Section(kind=str(c.get("type")), title=str(c.get("title")), ref=self.ref_for(c))
69
+ for c in self.children(int(track["id"]))
70
+ ]
71
+ return SelectedChannelOut(track=self.track_info(track), sections=sections)
72
+
73
+ @operation
74
+ def list_inserts(self, track: str) -> InsertsOut:
75
+ item = self.resolve_track(TrackSelector.parse(track))
76
+ slots: list[SlotInfo] = []
77
+ for index, slot in enumerate(self.children(int(self.section(item, "Inserts")["id"])), start=1):
78
+ plugin = None
79
+ if slot.get("children"):
80
+ child = self.children(int(slot["id"]))[0]
81
+ plugin = PluginInSlot(
82
+ name=str(child.get("title")),
83
+ plugin_uid=self.plugin_uid(str(child.get("uniqueName"))),
84
+ ref=self.ref_for(child),
85
+ )
86
+ slots.append(SlotInfo(index=index, empty=plugin is None, ref=self.ref_for(slot), plugin=plugin))
87
+ return InsertsOut(track=self.track_info(item), slots=slots)
88
+
89
+ @operation
90
+ def list_available_plugins(
91
+ self, track: str, slot: int = 1, filter_text: str | None = None, offset: int = 0, limit: int = 20
92
+ ) -> PluginCatalogOut:
93
+ slot_obj = self.insert_slot(self.resolve_track(TrackSelector.parse(track)), slot)
94
+ collections = self.call("pm.collections", {"slot": slot_obj["id"], **MIXER})
95
+ index = int(collections.get("activeIndex", -1))
96
+ if int(collections.get("total", 0)) == 0 or index < 0:
97
+ raise NotFoundError("this slot offers no plugin collection", code="NO_COLLECTION")
98
+ entries = self.call(
99
+ "pm.entries",
100
+ {
101
+ "slot": slot_obj["id"],
102
+ "collection": index,
103
+ "offset": offset,
104
+ "limit": min(limit, 40),
105
+ "filter": filter_text or "",
106
+ **MIXER,
107
+ },
108
+ )
109
+ items = [
110
+ PluginEntry(
111
+ name=str(e["name"]),
112
+ vendor=str(e["vendor"]),
113
+ version=str(e["version"]),
114
+ uid=str(e["uid"]),
115
+ sub_categories=str(e["subCategories"]),
116
+ )
117
+ for e in entries.get("items", [])
118
+ ]
119
+ return PluginCatalogOut(
120
+ collection=entries.get("collectionName"),
121
+ total=int(entries.get("total", 0)),
122
+ matched=int(entries.get("matched", 0)),
123
+ offset=offset,
124
+ items=items,
125
+ )
126
+
127
+ @operation
128
+ def list_parameters(
129
+ self, track: str, target: str, offset: int = 0, limit: int = 25, filter_text: str | None = None
130
+ ) -> ParametersOut:
131
+ obj = self.resolve_target(self.resolve_track(TrackSelector.parse(track)), target)
132
+ needle = (filter_text or "").lower()
133
+ items: list[ParameterInfo] = []
134
+ next_offset, scanned, total = offset, 0, 0
135
+ while len(items) < limit and scanned < MAX_FILTER_SCAN:
136
+ page_size = 25 if needle else min(25, limit - len(items))
137
+ page = self.call("da.params", {"id": obj["id"], "offset": next_offset, "limit": page_size, **MIXER})
138
+ total = int(page.get("total", 0))
139
+ batch = page.get("items", [])
140
+ if not batch:
141
+ break
142
+ for raw in batch:
143
+ # Consume parameter by parameter so that no match is skipped when the limit is reached.
144
+ next_offset = int(raw.get("index", next_offset)) + 1
145
+ scanned += 1
146
+ if needle in str(raw.get("title", "")).lower():
147
+ items.append(self.parameter_info(raw))
148
+ if len(items) >= limit:
149
+ break
150
+ if next_offset >= total:
151
+ break
152
+ return ParametersOut(
153
+ target=target,
154
+ target_ref=self.ref_for(obj),
155
+ target_name=str(obj.get("title", "")),
156
+ total=total,
157
+ offset=offset,
158
+ scanned=scanned,
159
+ items=items,
160
+ next_offset=next_offset,
161
+ truncated=next_offset < total,
162
+ )
163
+
164
+ @operation
165
+ def get_parameter(self, track: str, target: str, tag: int) -> ParameterOut:
166
+ obj = self.resolve_target(self.resolve_track(TrackSelector.parse(track)), target)
167
+ raw = self.read_parameter(int(obj["id"]), tag)
168
+ return ParameterOut(
169
+ target=target,
170
+ target_ref=self.ref_for(obj),
171
+ target_name=str(obj.get("title", "")),
172
+ parameter=self.parameter_info(raw),
173
+ )
174
+
175
+ @operation
176
+ def get_transport(self) -> TransportOut:
177
+ data = self.call("transport.get")
178
+ primary: JsonObject = data.get("primary") or {}
179
+ if not data.get("available") or not primary.get("locator"):
180
+ raise TrackwrightError(
181
+ "transport values have not arrived yet; move the cursor once in Cubase and retry", code="UNAVAILABLE"
182
+ )
183
+
184
+ def position(key: str) -> TimePosition | None:
185
+ raw = primary.get(key)
186
+ return TimePosition(time=str(raw["time"]), format=raw.get("format")) if isinstance(raw, dict) else None
187
+
188
+ cursor = position("locator")
189
+ assert cursor is not None
190
+ return TransportOut(
191
+ cursor=cursor,
192
+ cycle_left=position("cycleLeft"),
193
+ cycle_right=position("cycleRight"),
194
+ tempo_bpm=data.get("tempoBPM"),
195
+ )
196
+
197
+ @operation
198
+ def poll_changes(self) -> ChangesOut:
199
+ data = self.call("events")
200
+ events: list[ChangeEvent] = []
201
+ described: dict[int, JsonObject | None] = {}
202
+ unchecked = 0
203
+ for raw in data.get("events", []):
204
+ oid = int(raw.get("id", -1))
205
+ if oid not in described and len(described) < MAX_EVENT_LOOKUPS:
206
+ try:
207
+ described[oid] = self.call("da.object", {"id": oid, **MIXER})
208
+ except TrackwrightError:
209
+ described[oid] = None
210
+ checked = oid in described
211
+ obj = described.get(oid)
212
+ unchecked += not checked
213
+ tag = int(raw.get("t", -1))
214
+ events.append(
215
+ ChangeEvent(
216
+ kind=str(raw.get("k")),
217
+ tag=tag if tag >= 0 else None,
218
+ ref=self.ref_for(obj) if obj else None,
219
+ title=obj.get("title") if obj else None,
220
+ still_valid=(obj is not None) if checked else None,
221
+ )
222
+ )
223
+ dropped = int(data.get("dropped", 0))
224
+ advice = (
225
+ "events were dropped: re-list tracks and objects"
226
+ if dropped
227
+ else "a 'removed' event is a hint to re-validate, not proof of removal (it also fires on selection changes)"
228
+ )
229
+ return ChangesOut(events=events, dropped=dropped, unchecked=unchecked, advice=advice)
@@ -0,0 +1,88 @@
1
+ """Track selectors and opaque object references.
2
+
3
+ Runtime object ids are only valid inside one script session and are **reused** for different
4
+ objects after a reload (verified: id 550 went from Compressor to Input Filter). They are therefore
5
+ never exposed alone: tools return an opaque ``ref`` that embeds the session and a fingerprint of
6
+ the object, and validate both before every use.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ from dataclasses import dataclass
14
+ from typing import Any, Literal
15
+
16
+ from trackwright.errors import ProtocolError, StaleReferenceError
17
+
18
+ REF_PREFIX = "tw1."
19
+ SELECTED = "selected"
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class TrackSelector:
24
+ """How a tool input designates a track.
25
+
26
+ Accepted forms: ``"selected"``; ``"uid:<UID>"`` (stable across Cubase restarts); ``"name:<exact name>"``;
27
+ or any other text, taken as an exact track name.
28
+ """
29
+
30
+ kind: Literal["selected", "uid", "name"]
31
+ value: str = ""
32
+
33
+ @classmethod
34
+ def parse(cls, text: str | None) -> TrackSelector:
35
+ raw = (text or SELECTED).strip()
36
+ if not raw or raw == SELECTED:
37
+ return cls("selected")
38
+ if raw.startswith("uid:") and raw[4:].strip():
39
+ return cls("uid", raw[4:].strip())
40
+ if raw.startswith("name:") and raw[5:]:
41
+ return cls("name", raw[5:])
42
+ return cls("name", raw)
43
+
44
+ def __str__(self) -> str:
45
+ return SELECTED if self.kind == "selected" else f"{self.kind}:{self.value}"
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class ObjectRef:
50
+ """Opaque handle on a Cubase object: session + runtime id + fingerprint."""
51
+
52
+ session: str
53
+ object_id: int
54
+ type_name: str
55
+ unique_name: str
56
+
57
+ def encode(self) -> str:
58
+ payload = {"s": self.session, "i": self.object_id, "t": self.type_name, "u": self.unique_name}
59
+ raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode()
60
+ return REF_PREFIX + base64.urlsafe_b64encode(raw).decode().rstrip("=")
61
+
62
+ @classmethod
63
+ def decode(cls, text: str) -> ObjectRef:
64
+ if not text.startswith(REF_PREFIX):
65
+ raise ProtocolError("not a Trackwright object reference", details={"ref": text[:40]})
66
+ body = text[len(REF_PREFIX) :]
67
+ try:
68
+ data: Any = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
69
+ return cls(str(data["s"]), int(data["i"]), str(data["t"]), str(data["u"]))
70
+ except (ValueError, KeyError, TypeError) as exc:
71
+ raise ProtocolError("malformed object reference", details={"ref": text[:40]}) from exc
72
+
73
+ def check_session(self, current: str | None) -> None:
74
+ if current is None or current != self.session:
75
+ raise StaleReferenceError(
76
+ "the reference belongs to a previous Cubase script session; list the objects again",
77
+ details={"ref_session": self.session, "current_session": current},
78
+ )
79
+
80
+ def check_fingerprint(self, described: dict[str, Any]) -> None:
81
+ if described.get("type") != self.type_name or described.get("uniqueName") != self.unique_name:
82
+ raise StaleReferenceError(
83
+ "the reference now points at a different object; list the objects again",
84
+ details={
85
+ "expected": {"type": self.type_name, "uniqueName": self.unique_name},
86
+ "found": {"type": described.get("type"), "uniqueName": described.get("uniqueName")},
87
+ },
88
+ )
@@ -0,0 +1,72 @@
1
+ """Saving the project (TW-1202). Only on an explicit request: separate policy switch, dry run by default.
2
+
3
+ Verified in Cubase Elements 15.0.30 (experiments/tw1202): ``File / Save`` rewrites the project's .cpr and
4
+ ``File / Save New Version`` writes ``<name>-01.cpr`` (and Cubase then works on it), both without a dialog
5
+ in a project that was saved before. The result is checked by the .cpr files' dates; nothing is opened.
6
+ Close, Revert and Quit are not offered.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Literal
14
+
15
+ from trackwright.core.base import ServiceBase, operation
16
+ from trackwright.core.models import ProjectFileOut, SaveOut
17
+ from trackwright.exports import PROJECT_SUFFIXES, ExportedFile
18
+
19
+ SAVE_KEYS = {"save": "file.save", "new_version": "file.save_new_version"}
20
+ SAVE_WAIT_SECONDS = 10.0
21
+ EFFECTS = {
22
+ "save": "overwrites the project file Cubase has open with the current state",
23
+ "new_version": "writes a new numbered project file (<name>-NN.cpr); Cubase then works on that file",
24
+ }
25
+
26
+
27
+ def _file(found: ExportedFile) -> ProjectFileOut:
28
+ return ProjectFileOut(name=Path(found.path).name, size_bytes=found.size_bytes,
29
+ modified=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(found.modified))) # fmt: skip
30
+
31
+
32
+ class SavingMixin(ServiceBase):
33
+ def _project_folder(self) -> Path | None:
34
+ folders = self.launcher.project_folders() if self.launcher is not None else []
35
+ return Path(folders[0]) if folders else None
36
+
37
+ def _project_files(self, folder: Path | None, since: float = 0.0) -> list[ExportedFile]:
38
+ return self.exports.find(folder, since, PROJECT_SUFFIXES) if folder else []
39
+
40
+ @operation
41
+ def save_project(self, mode: Literal["save", "new_version"] = "save", *, dry_run: bool = True) -> SaveOut:
42
+ folder = self._project_folder()
43
+ before = self._project_files(folder)
44
+ out = SaveOut(mode=mode, dry_run=dry_run, status="planned", folder=str(folder) if folder else None,
45
+ files_before=[_file(f) for f in before], notes=[EFFECTS[mode]]) # fmt: skip
46
+ if folder is None:
47
+ out.notes.append("the project folder is unknown (no audio file open): the save cannot be verified")
48
+ if dry_run:
49
+ return out
50
+ self.policy.check_save()
51
+ started = time.time()
52
+ self.call("cmd.run", {"key": SAVE_KEYS[mode]})
53
+ if folder is None:
54
+ out.status = "saved"
55
+ return out
56
+ names_before = {Path(f.path).name for f in before}
57
+ deadline = time.monotonic() + SAVE_WAIT_SECONDS
58
+ while True:
59
+ written = [f for f in self._project_files(folder, started)
60
+ if mode == "save" or Path(f.path).name not in names_before] # fmt: skip
61
+ if written or time.monotonic() >= deadline:
62
+ break
63
+ self._sleep(0.5)
64
+ if written:
65
+ out.status, out.saved, out.verified = "saved", _file(written[0]), True
66
+ else:
67
+ out.status, out.verified = "awaiting_user", False
68
+ out.instructions = (
69
+ "no project file was written: Cubase may be showing a dialog (for example 'Save As' for a "
70
+ "project that was never saved). Answer it in Cubase."
71
+ )
72
+ return out
@@ -0,0 +1,27 @@
1
+ """The Cubase service: every tool's logic, independent of MCP so it can be tested directly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from trackwright.core.automation import AutomationMixin
6
+ from trackwright.core.commanding import CommandingMixin
7
+ from trackwright.core.importing import ImportMixin
8
+ from trackwright.core.lifecycle import LifecycleMixin
9
+ from trackwright.core.presets import PresetMixin
10
+ from trackwright.core.reading import ReadingMixin
11
+ from trackwright.core.saving import SavingMixin
12
+ from trackwright.core.settings import SettingsMixin
13
+ from trackwright.core.writing import WritingMixin
14
+
15
+
16
+ class CubaseService(
17
+ PresetMixin,
18
+ SettingsMixin,
19
+ SavingMixin,
20
+ ImportMixin,
21
+ LifecycleMixin,
22
+ AutomationMixin,
23
+ CommandingMixin,
24
+ WritingMixin,
25
+ ReadingMixin,
26
+ ):
27
+ """Every tool: read, write, commands, automation, launch, project, ranges, export, import and save (E05-E12)."""
@@ -0,0 +1,80 @@
1
+ """Change project and transport settings (TW-1210) through Cubase's Transport and Metronome objects.
2
+
3
+ Verified in Cubase Elements 15.0.30 (experiments/tw1210): every switch below and the click level accept a
4
+ DirectAccess write and read back; the time signature does not (its value is outside [0, 1]).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from trackwright.core.base import MIXER, operation
12
+ from trackwright.core.models import ProjectSettingsOut, SettingChange
13
+ from trackwright.core.project import ProjectMixin
14
+ from trackwright.errors import NotFoundError, PreconditionFailedError
15
+
16
+ JsonObject = dict[str, Any]
17
+ SWITCHES: dict[str, tuple[str, str]] = { # setting -> (object type, parameter title)
18
+ "cycle": ("Transport", "cycle"),
19
+ "metronome": ("Transport", "metronomeOn"),
20
+ "precount": ("Transport", "precountOn"),
21
+ "punch_in": ("Transport", "punchIn"),
22
+ "punch_out": ("Transport", "punchOut"),
23
+ "pre_roll": ("Transport", "usePreRoll"),
24
+ "post_roll": ("Transport", "usePostRoll"),
25
+ "click_in_playback": ("Metronome", "tickInPlayback"),
26
+ "click_in_record": ("Metronome", "tickInRecord"),
27
+ }
28
+ CLICK_LEVEL = ("Metronome", "clickLevel")
29
+
30
+
31
+ class SettingsMixin(ProjectMixin):
32
+ @operation
33
+ def set_project(self, settings: dict[str, bool | int], *, dry_run: bool = True) -> ProjectSettingsOut:
34
+ if not settings:
35
+ raise PreconditionFailedError("give at least one setting to change", code="BAD_REQUEST")
36
+ unknown = sorted(set(settings) - set(SWITCHES) - {"click_level"})
37
+ if unknown:
38
+ raise PreconditionFailedError(f"unknown settings {unknown}", code="BAD_REQUEST",
39
+ details={"known": [*SWITCHES, "click_level"]}) # fmt: skip
40
+ level = settings.get("click_level")
41
+ if level is not None and (isinstance(level, bool) or not 0 <= int(level) <= 127):
42
+ raise PreconditionFailedError("click_level must be 0..127", code="OUT_OF_RANGE")
43
+ objects = self._studio_objects()
44
+ params = {kind: self._params_by_title(objects.get(kind)) for kind in ("Transport", "Metronome")}
45
+ plan: list[tuple[str, int, int, float, bool | int | None]] = [] # name, object, tag, value, before
46
+ for name, requested in settings.items():
47
+ kind, title = SWITCHES.get(name, CLICK_LEVEL)
48
+ param = params[kind].get(title)
49
+ if param is None or objects.get(kind) is None:
50
+ raise NotFoundError(f"Cubase does not expose {kind}/{title}", code="UNAVAILABLE")
51
+ value = int(requested) / 127 if name == "click_level" else (1.0 if requested else 0.0)
52
+ plan.append((name, int(objects[kind]), int(param["tag"]), value, self._setting_value(name, param)))
53
+ out = ProjectSettingsOut(
54
+ dry_run=dry_run,
55
+ changes=[
56
+ SettingChange(setting=n, before=b, requested=settings[n], after=None, verified=None)
57
+ for n, _o, _t, _v, b in plan
58
+ ],
59
+ verified=None,
60
+ )
61
+ if dry_run:
62
+ return out
63
+ self.policy.check_project_write()
64
+ for change, (name, oid, tag, value, _before) in zip(out.changes, plan, strict=True):
65
+ self.call("da.param.set", {"id": oid, "tag": tag, "value": value, **MIXER})
66
+ change.after = self._read_setting(name, oid, tag, change.requested)
67
+ change.verified = change.after == change.requested
68
+ out.verified = all(c.verified for c in out.changes)
69
+ return out
70
+
71
+ def _read_setting(self, name: str, oid: int, tag: int, wanted: bool | int) -> bool | int:
72
+ def read() -> bool | int:
73
+ return self._setting_value(name, self.call("da.param.get", {"id": oid, "tag": tag, **MIXER}))
74
+
75
+ return self.wait_until(read, wanted.__eq__)
76
+
77
+ @staticmethod
78
+ def _setting_value(name: str, param: JsonObject) -> bool | int:
79
+ process = float(param.get("process") or 0)
80
+ return round(process * 127) if name == "click_level" else process >= 0.5