wildtrax-client 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wildtrax_client/__init__.py +30 -0
- wildtrax_client/client.py +475 -0
- wildtrax_client/errors.py +40 -0
- wildtrax_client-0.1.0.dist-info/METADATA +114 -0
- wildtrax_client-0.1.0.dist-info/RECORD +8 -0
- wildtrax_client-0.1.0.dist-info/WHEEL +5 -0
- wildtrax_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- wildtrax_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""wildtrax-client -- Python client for the WildTrax 2027 HTTP /api control surface."""
|
|
2
|
+
|
|
3
|
+
from .client import WildTrax, DEFAULT_PORT
|
|
4
|
+
from .errors import (
|
|
5
|
+
WildTraxError,
|
|
6
|
+
ConnectionFailed,
|
|
7
|
+
PendingApprovalError,
|
|
8
|
+
PermissionDenied,
|
|
9
|
+
NotFound,
|
|
10
|
+
RecordingInProgress,
|
|
11
|
+
RateLimited,
|
|
12
|
+
BadRequest,
|
|
13
|
+
ServerError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"WildTrax",
|
|
20
|
+
"DEFAULT_PORT",
|
|
21
|
+
"WildTraxError",
|
|
22
|
+
"ConnectionFailed",
|
|
23
|
+
"PendingApprovalError",
|
|
24
|
+
"PermissionDenied",
|
|
25
|
+
"NotFound",
|
|
26
|
+
"RecordingInProgress",
|
|
27
|
+
"RateLimited",
|
|
28
|
+
"BadRequest",
|
|
29
|
+
"ServerError",
|
|
30
|
+
]
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""WildTrax 2027 HTTP /api client.
|
|
2
|
+
|
|
3
|
+
A thin, dependency-free (stdlib urllib) client for the WildTrax desktop server's
|
|
4
|
+
HTTP control surface. Mirrors the same actions the OSC protocol exposes -- two
|
|
5
|
+
transports, one capability set.
|
|
6
|
+
|
|
7
|
+
from wildtrax_client import WildTrax
|
|
8
|
+
|
|
9
|
+
wt = WildTrax("192.168.1.155") # default port 5659
|
|
10
|
+
wt.wait_for_approval() # block until host approves this device
|
|
11
|
+
wt.transport.record()
|
|
12
|
+
wt.tracks.mute(1, True) # mute Track 1 (indices are 1-based)
|
|
13
|
+
wt.metadata.scene("SC07")
|
|
14
|
+
print(wt.session_state())
|
|
15
|
+
|
|
16
|
+
Indices are 1-BASED. Every index argument (track index, pad index, row index,
|
|
17
|
+
file index, matrix row/col, ...) is 1-based, matching the WildTrax UI (Track 1,
|
|
18
|
+
Input 1) and the OSC surface -- so `wt.tracks.mute(1)` mutes the first track, the
|
|
19
|
+
one shown as "Track 1". The server converts 1-based -> 0-based internally at the
|
|
20
|
+
HTTP seam; pass what the UI shows. (This is the v27 convention across both the
|
|
21
|
+
HTTP and OSC public surfaces; the desktop's internal models remain 0-based.)
|
|
22
|
+
|
|
23
|
+
Approval model: the first request from a new device lands in the desktop Session
|
|
24
|
+
Manager as *pending* and returns 202. A human approves it once (and grants Write
|
|
25
|
+
for write actions). Until then, calls raise PendingApprovalError; use
|
|
26
|
+
wait_for_approval() to poll, or catch the exception.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import time
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.parse
|
|
33
|
+
import urllib.request
|
|
34
|
+
|
|
35
|
+
from . import errors
|
|
36
|
+
|
|
37
|
+
DEFAULT_PORT = 5659
|
|
38
|
+
DEFAULT_TIMEOUT = 5.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WildTrax:
|
|
42
|
+
"""Connection to one WildTrax server's HTTP /api surface."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, host, port=DEFAULT_PORT, timeout=DEFAULT_TIMEOUT):
|
|
45
|
+
self.base_url = "http://{}:{}".format(host, port)
|
|
46
|
+
self.timeout = timeout
|
|
47
|
+
# Namespaced action groups (mirror the /api route groups).
|
|
48
|
+
self.transport = _Transport(self)
|
|
49
|
+
self.tracks = _Tracks(self)
|
|
50
|
+
self.metadata = _Metadata(self)
|
|
51
|
+
self.config = _Config(self)
|
|
52
|
+
self.soundboard = _Soundboard(self)
|
|
53
|
+
self.playlist = _Playlist(self)
|
|
54
|
+
self.export = _ExportFiles(self)
|
|
55
|
+
self.report = _SoundReport(self)
|
|
56
|
+
|
|
57
|
+
# ---- transport -------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _request(self, method, route, body=None):
|
|
60
|
+
"""Issue an HTTP request. Returns parsed JSON (dict) or {} for empty
|
|
61
|
+
bodies. Raises the mapped WildTraxError on a non-2xx status."""
|
|
62
|
+
url = self.base_url + route
|
|
63
|
+
data = None
|
|
64
|
+
headers = {}
|
|
65
|
+
if body is not None:
|
|
66
|
+
data = json.dumps(body).encode("utf-8")
|
|
67
|
+
headers["Content-Type"] = "application/json"
|
|
68
|
+
|
|
69
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
70
|
+
try:
|
|
71
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
72
|
+
return self._parse(resp.status, resp.read())
|
|
73
|
+
except urllib.error.HTTPError as e:
|
|
74
|
+
# urllib raises on 4xx/5xx; the body still carries our JSON error.
|
|
75
|
+
return self._parse(e.code, e.read())
|
|
76
|
+
except urllib.error.URLError as e:
|
|
77
|
+
raise errors.ConnectionFailed(
|
|
78
|
+
"could not reach {}: {}".format(url, e.reason)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _parse(self, status, raw):
|
|
82
|
+
try:
|
|
83
|
+
payload = json.loads(raw) if raw else {}
|
|
84
|
+
except (ValueError, TypeError):
|
|
85
|
+
payload = {}
|
|
86
|
+
|
|
87
|
+
# 202 is "accepted but not approved yet" -- urllib treats it as success
|
|
88
|
+
# (no HTTPError), so it must be intercepted here before the 2xx return.
|
|
89
|
+
if status == 202:
|
|
90
|
+
raise errors.PendingApprovalError(
|
|
91
|
+
"device awaiting approval in WildTrax Session Manager"
|
|
92
|
+
)
|
|
93
|
+
if 200 <= status < 300:
|
|
94
|
+
return payload
|
|
95
|
+
|
|
96
|
+
msg = payload.get("error") or payload.get("message") or "HTTP {}".format(status)
|
|
97
|
+
if status == 400:
|
|
98
|
+
raise errors.BadRequest(msg)
|
|
99
|
+
if status == 403:
|
|
100
|
+
raise errors.PermissionDenied(msg)
|
|
101
|
+
if status == 404:
|
|
102
|
+
raise errors.NotFound(msg)
|
|
103
|
+
if status == 409:
|
|
104
|
+
raise errors.RecordingInProgress(msg)
|
|
105
|
+
if status == 429:
|
|
106
|
+
raise errors.RateLimited(msg)
|
|
107
|
+
raise errors.ServerError("{} ({})".format(msg, status))
|
|
108
|
+
|
|
109
|
+
def _get(self, route):
|
|
110
|
+
return self._request("GET", route)
|
|
111
|
+
|
|
112
|
+
def _post(self, route, **body):
|
|
113
|
+
# Drop None values so optional fields are omitted, not sent as null.
|
|
114
|
+
clean = {k: v for k, v in body.items() if v is not None}
|
|
115
|
+
return self._request("POST", route, clean or None)
|
|
116
|
+
|
|
117
|
+
# ---- discovery / approval -------------------------------------------
|
|
118
|
+
|
|
119
|
+
def hello(self):
|
|
120
|
+
"""GET /api/hello -- {app, version, status}. Registers this device as a
|
|
121
|
+
pending client if it isn't known yet. Does not raise on pending."""
|
|
122
|
+
return self._get("/api/hello")
|
|
123
|
+
|
|
124
|
+
def is_approved(self):
|
|
125
|
+
"""True once the host has approved this device."""
|
|
126
|
+
status = self.hello().get("status", "")
|
|
127
|
+
return status.startswith("approved")
|
|
128
|
+
|
|
129
|
+
def can_write(self):
|
|
130
|
+
"""True once approved AND granted Write permission."""
|
|
131
|
+
return self.hello().get("status") == "approved"
|
|
132
|
+
|
|
133
|
+
def wait_for_approval(self, timeout=120.0, poll_interval=1.0):
|
|
134
|
+
"""Block until the host approves this device in Session Manager, or
|
|
135
|
+
raise PendingApprovalError after `timeout` seconds. Returns the hello
|
|
136
|
+
payload once approved."""
|
|
137
|
+
deadline = time.monotonic() + timeout
|
|
138
|
+
while True:
|
|
139
|
+
info = self.hello()
|
|
140
|
+
if str(info.get("status", "")).startswith("approved"):
|
|
141
|
+
return info
|
|
142
|
+
if time.monotonic() >= deadline:
|
|
143
|
+
raise errors.PendingApprovalError(
|
|
144
|
+
"still pending after {:.0f}s -- approve this device in the "
|
|
145
|
+
"WildTrax Session Manager".format(timeout)
|
|
146
|
+
)
|
|
147
|
+
time.sleep(poll_interval)
|
|
148
|
+
|
|
149
|
+
# ---- readback --------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def session_state(self):
|
|
152
|
+
"""GET /api/session/state -- the full session snapshot."""
|
|
153
|
+
return self._get("/api/session/state")
|
|
154
|
+
|
|
155
|
+
def recording(self):
|
|
156
|
+
"""GET /api/record/state -- {recording: bool}."""
|
|
157
|
+
return self._get("/api/record/state").get("recording", False)
|
|
158
|
+
|
|
159
|
+
def get_tracks(self):
|
|
160
|
+
return self._get("/api/tracks").get("tracks")
|
|
161
|
+
|
|
162
|
+
def get_metadata(self):
|
|
163
|
+
return self._get("/api/metadata")
|
|
164
|
+
|
|
165
|
+
# ---- per-element readback (HTTP mirror of the OSC flat read-side) -----
|
|
166
|
+
# All indices are 1-BASED (== the WildTrax UI and the OSC surface).
|
|
167
|
+
|
|
168
|
+
def get_pad(self, pad_index):
|
|
169
|
+
"""GET /api/soundboard/<i> -- one soundboard pad's JSON (1-based)."""
|
|
170
|
+
return self._get(f"/api/soundboard/{pad_index}")
|
|
171
|
+
|
|
172
|
+
def get_playlist_row(self, row_index):
|
|
173
|
+
"""GET /api/playlist/<i> -- one playlist row's JSON (1-based)."""
|
|
174
|
+
return self._get(f"/api/playlist/{row_index}")
|
|
175
|
+
|
|
176
|
+
def get_export_file(self, file_index):
|
|
177
|
+
"""GET /api/exportfiles/<i> -- one export file's JSON (1-based)."""
|
|
178
|
+
return self._get(f"/api/exportfiles/{file_index}")
|
|
179
|
+
|
|
180
|
+
def get_matrix(self, which):
|
|
181
|
+
"""GET /api/matrix/<which> -- full input/output matrix snapshot."""
|
|
182
|
+
return self._get(f"/api/matrix/{which}")
|
|
183
|
+
|
|
184
|
+
def get_soundreport_entry(self, row_index):
|
|
185
|
+
"""GET /api/soundreport/<i> -- one sound-report entry's JSON (1-based)."""
|
|
186
|
+
return self._get(f"/api/soundreport/{row_index}")
|
|
187
|
+
|
|
188
|
+
def get_session_client(self, client_index):
|
|
189
|
+
"""GET /api/session/clients/<i> -- one client record's JSON (1-based)."""
|
|
190
|
+
return self._get(f"/api/session/clients/{client_index}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --------------------------------------------------------------------------
|
|
194
|
+
# Namespaced action groups. Each mirrors the matching /api/<group>/* routes and
|
|
195
|
+
# the JSON body the server expects (same field names as the OSC command dicts).
|
|
196
|
+
# --------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class _Group:
|
|
200
|
+
def __init__(self, client):
|
|
201
|
+
self._c = client
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class _Transport(_Group):
|
|
205
|
+
def record(self):
|
|
206
|
+
"""Start recording / advance take (server decides based on state)."""
|
|
207
|
+
return self._c._post("/api/record/start")
|
|
208
|
+
|
|
209
|
+
def stop(self):
|
|
210
|
+
return self._c._post("/api/record/stop")
|
|
211
|
+
|
|
212
|
+
def false_start(self):
|
|
213
|
+
"""Request a false start (the desktop confirms)."""
|
|
214
|
+
return self._c._post("/api/record/falsestart")
|
|
215
|
+
|
|
216
|
+
def false_start_confirmed(self):
|
|
217
|
+
"""Pre-authorized false start (skip desktop confirmation)."""
|
|
218
|
+
return self._c._post("/api/record/falsestart/confirmed")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class _Tracks(_Group):
|
|
222
|
+
def arm(self, index, value=True):
|
|
223
|
+
return self._c._post("/api/track/arm", trackIndex=index, value=value)
|
|
224
|
+
|
|
225
|
+
def mute(self, index, value=True):
|
|
226
|
+
return self._c._post("/api/track/mute", trackIndex=index, value=value)
|
|
227
|
+
|
|
228
|
+
def monitor(self, index, value=True):
|
|
229
|
+
return self._c._post("/api/track/monitor", trackIndex=index, value=value)
|
|
230
|
+
|
|
231
|
+
def pan(self, index, pan):
|
|
232
|
+
"""pan: 0=Left, 1=Right, 2=Center (desktop PanState values)."""
|
|
233
|
+
return self._c._post("/api/track/pan", trackIndex=index, pan=pan)
|
|
234
|
+
|
|
235
|
+
def name(self, index, name):
|
|
236
|
+
return self._c._post("/api/track/name", trackIndex=index, name=name)
|
|
237
|
+
|
|
238
|
+
def add(self):
|
|
239
|
+
return self._c._post("/api/track/add")
|
|
240
|
+
|
|
241
|
+
def remove(self, index):
|
|
242
|
+
return self._c._post("/api/track/remove", trackIndex=index)
|
|
243
|
+
|
|
244
|
+
def set_count(self, count):
|
|
245
|
+
return self._c._post("/api/track/count", count=count)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class _Metadata(_Group):
|
|
249
|
+
def project(self, value):
|
|
250
|
+
return self._c._post("/api/metadata/project", value=value)
|
|
251
|
+
|
|
252
|
+
def scene(self, value):
|
|
253
|
+
return self._c._post("/api/metadata/scene", value=value)
|
|
254
|
+
|
|
255
|
+
def slate(self, value):
|
|
256
|
+
return self._c._post("/api/metadata/slate", value=value)
|
|
257
|
+
|
|
258
|
+
def take(self, value):
|
|
259
|
+
return self._c._post("/api/metadata/take", value=value)
|
|
260
|
+
|
|
261
|
+
def roll(self, value):
|
|
262
|
+
return self._c._post("/api/metadata/roll", value=value)
|
|
263
|
+
|
|
264
|
+
def note(self, value):
|
|
265
|
+
return self._c._post("/api/metadata/note", value=value)
|
|
266
|
+
|
|
267
|
+
def add_marker(self):
|
|
268
|
+
return self._c._post("/api/metadata/marker/add")
|
|
269
|
+
|
|
270
|
+
def add_note_to_bank(self, note):
|
|
271
|
+
return self._c._post("/api/metadata/notebank/add", note=note)
|
|
272
|
+
|
|
273
|
+
def remove_note_from_bank(self, index):
|
|
274
|
+
return self._c._post("/api/metadata/notebank/remove", index=index)
|
|
275
|
+
|
|
276
|
+
def insert_quick_note(self, note_index):
|
|
277
|
+
return self._c._post("/api/metadata/notebank/insert", noteIndex=note_index)
|
|
278
|
+
|
|
279
|
+
# next-take template
|
|
280
|
+
def next_project(self, value):
|
|
281
|
+
return self._c._post("/api/metadata/next/project", value=value)
|
|
282
|
+
|
|
283
|
+
def next_scene(self, value):
|
|
284
|
+
return self._c._post("/api/metadata/next/scene", value=value)
|
|
285
|
+
|
|
286
|
+
def next_slate(self, value):
|
|
287
|
+
return self._c._post("/api/metadata/next/slate", value=value)
|
|
288
|
+
|
|
289
|
+
def next_take(self, value):
|
|
290
|
+
return self._c._post("/api/metadata/next/take", value=value)
|
|
291
|
+
|
|
292
|
+
def next_roll(self, value):
|
|
293
|
+
return self._c._post("/api/metadata/next/roll", value=value)
|
|
294
|
+
|
|
295
|
+
def reset_next(self):
|
|
296
|
+
return self._c._post("/api/metadata/next/reset")
|
|
297
|
+
|
|
298
|
+
# auto-increment / auto-reset flags
|
|
299
|
+
def auto_increment(self, field, value):
|
|
300
|
+
"""field: 'slate' | 'take' | 'roll'."""
|
|
301
|
+
return self._c._post("/api/metadata/autoincrement/" + field, value=value)
|
|
302
|
+
|
|
303
|
+
def auto_reset(self, field, value):
|
|
304
|
+
"""field: 'slate' | 'take' | 'roll' -- reset on scene change."""
|
|
305
|
+
return self._c._post("/api/metadata/autoreset/" + field, value=value)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class _Config(_Group):
|
|
309
|
+
def preroll(self, enabled):
|
|
310
|
+
return self._c._post("/api/config/preroll", value=enabled)
|
|
311
|
+
|
|
312
|
+
def preroll_full(self, enabled, size_seconds):
|
|
313
|
+
return self._c._post(
|
|
314
|
+
"/api/config/preroll/full", prerollEnabled=enabled, prerollSize=size_seconds
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def meter_smoothness(self, value):
|
|
318
|
+
"""value: 0.0 .. 1.0."""
|
|
319
|
+
return self._c._post("/api/config/smoothness", value=value)
|
|
320
|
+
|
|
321
|
+
def global_monitor(self, enabled):
|
|
322
|
+
return self._c._post("/api/config/monitor", value=enabled)
|
|
323
|
+
|
|
324
|
+
def playback_volume(self, value):
|
|
325
|
+
"""value: 0 .. 100."""
|
|
326
|
+
return self._c._post("/api/playback/volume", value=value)
|
|
327
|
+
|
|
328
|
+
def matrix_cell(self, matrix, row, col, value=True):
|
|
329
|
+
"""matrix: 'input' | 'output'. Rejected while recording."""
|
|
330
|
+
return self._c._post("/api/matrix/cell", matrix=matrix, row=row, col=col, value=value)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class _Soundboard(_Group):
|
|
334
|
+
def play(self, pad_index):
|
|
335
|
+
return self._c._post("/api/soundboard/play", padIndex=pad_index)
|
|
336
|
+
|
|
337
|
+
def stop(self, pad_index):
|
|
338
|
+
return self._c._post("/api/soundboard/stop", padIndex=pad_index)
|
|
339
|
+
|
|
340
|
+
def add_pad(self):
|
|
341
|
+
return self._c._post("/api/soundboard/pad/add")
|
|
342
|
+
|
|
343
|
+
def remove_pad(self, pad_index):
|
|
344
|
+
return self._c._post("/api/soundboard/pad/remove", padIndex=pad_index)
|
|
345
|
+
|
|
346
|
+
def move_pad(self, from_index, to_index):
|
|
347
|
+
return self._c._post(
|
|
348
|
+
"/api/soundboard/pad/move", fromIndex=from_index, toIndex=to_index
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def title(self, pad_index, title):
|
|
352
|
+
return self._c._post("/api/soundboard/pad/title", padIndex=pad_index, title=title)
|
|
353
|
+
|
|
354
|
+
def color(self, pad_index, color):
|
|
355
|
+
return self._c._post("/api/soundboard/pad/color", padIndex=pad_index, color=color)
|
|
356
|
+
|
|
357
|
+
def fade_in(self, pad_index, value=True):
|
|
358
|
+
return self._c._post("/api/soundboard/pad/fadein", padIndex=pad_index, value=value)
|
|
359
|
+
|
|
360
|
+
def repeat(self, pad_index, value=True):
|
|
361
|
+
return self._c._post("/api/soundboard/pad/repeat", padIndex=pad_index, value=value)
|
|
362
|
+
|
|
363
|
+
def solo(self, pad_index, value=True):
|
|
364
|
+
return self._c._post("/api/soundboard/pad/solo", padIndex=pad_index, value=value)
|
|
365
|
+
|
|
366
|
+
def fade_out(self, pad_index, value=True):
|
|
367
|
+
return self._c._post("/api/soundboard/pad/fadeout", padIndex=pad_index, value=value)
|
|
368
|
+
|
|
369
|
+
def trigger_mode(self, pad_index, mode):
|
|
370
|
+
return self._c._post(
|
|
371
|
+
"/api/soundboard/pad/triggermode", padIndex=pad_index, triggerMode=mode
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def routing_mode(self, pad_index, mode):
|
|
375
|
+
"""mode: 0 auto, 1 stereo, 2 mono left, 3 mono right, 4 mono center, 5 custom."""
|
|
376
|
+
return self._c._post(
|
|
377
|
+
"/api/soundboard/pad/routingmode", padIndex=pad_index, routingMode=mode
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def routing_init(self, pad_index):
|
|
381
|
+
return self._c._post("/api/soundboard/pad/routing/init", padIndex=pad_index)
|
|
382
|
+
|
|
383
|
+
def routing_cell(self, pad_index, src_channel, dst_channel, gain):
|
|
384
|
+
return self._c._post(
|
|
385
|
+
"/api/soundboard/pad/routing/cell", padIndex=pad_index,
|
|
386
|
+
srcChannel=src_channel, dstChannel=dst_channel, value=gain,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class _Playlist(_Group):
|
|
391
|
+
def play(self):
|
|
392
|
+
return self._c._post("/api/playlist/play")
|
|
393
|
+
|
|
394
|
+
def stop(self):
|
|
395
|
+
return self._c._post("/api/playlist/stop")
|
|
396
|
+
|
|
397
|
+
def loop(self, value=True):
|
|
398
|
+
return self._c._post("/api/playlist/loop", value=value)
|
|
399
|
+
|
|
400
|
+
def index(self, index):
|
|
401
|
+
return self._c._post("/api/playlist/index", index=index)
|
|
402
|
+
|
|
403
|
+
def add_row(self):
|
|
404
|
+
return self._c._post("/api/playlist/row/add")
|
|
405
|
+
|
|
406
|
+
def remove_row(self, row_index):
|
|
407
|
+
return self._c._post("/api/playlist/row/remove", rowIndex=row_index)
|
|
408
|
+
|
|
409
|
+
def move_row(self, from_index, to_index):
|
|
410
|
+
return self._c._post(
|
|
411
|
+
"/api/playlist/row/move", fromIndex=from_index, toIndex=to_index
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def row_title(self, row_index, title):
|
|
415
|
+
return self._c._post("/api/playlist/row/title", rowIndex=row_index, title=title)
|
|
416
|
+
|
|
417
|
+
def row_color(self, row_index, color):
|
|
418
|
+
return self._c._post("/api/playlist/row/color", rowIndex=row_index, color=color)
|
|
419
|
+
|
|
420
|
+
def row_routing_mode(self, row_index, mode):
|
|
421
|
+
"""mode: 0 auto, 1 stereo, 2 mono left, 3 mono right, 4 mono center, 5 custom."""
|
|
422
|
+
return self._c._post(
|
|
423
|
+
"/api/playlist/row/routingmode", rowIndex=row_index, routingMode=mode
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def row_routing_init(self, row_index):
|
|
427
|
+
return self._c._post("/api/playlist/row/routing/init", rowIndex=row_index)
|
|
428
|
+
|
|
429
|
+
def row_routing_cell(self, row_index, src_channel, dst_channel, gain):
|
|
430
|
+
return self._c._post(
|
|
431
|
+
"/api/playlist/row/routing/cell", rowIndex=row_index,
|
|
432
|
+
srcChannel=src_channel, dstChannel=dst_channel, value=gain,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class _ExportFiles(_Group):
|
|
437
|
+
def add(self):
|
|
438
|
+
return self._c._post("/api/exportfiles/add")
|
|
439
|
+
|
|
440
|
+
def remove(self, file_index):
|
|
441
|
+
return self._c._post("/api/exportfiles/remove", fileIndex=file_index)
|
|
442
|
+
|
|
443
|
+
def toggle_track(self, file_index, track_index):
|
|
444
|
+
return self._c._post(
|
|
445
|
+
"/api/exportfiles/track/toggle", fileIndex=file_index, trackIndex=track_index
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
def display_resolved_names(self, value):
|
|
449
|
+
return self._c._post("/api/exportfiles/resolvednames", value=value)
|
|
450
|
+
|
|
451
|
+
def update(self, file_index, field, value):
|
|
452
|
+
"""field: 'name' | 'folder' | 'fileFormat' | 'trackExportType' | 'selectAllTracks'."""
|
|
453
|
+
return self._c._post(
|
|
454
|
+
"/api/exportfiles/update", fileIndex=file_index, field=field, value=value
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class _SoundReport(_Group):
|
|
459
|
+
def csv(self, folder=None):
|
|
460
|
+
"""GET the sound report as CSV. Returns {folder, csv}."""
|
|
461
|
+
route = "/api/soundreport/csv"
|
|
462
|
+
if folder:
|
|
463
|
+
route += "?folder=" + urllib.parse.quote(folder)
|
|
464
|
+
return self._c._get(route)
|
|
465
|
+
|
|
466
|
+
def auto_save(self, value):
|
|
467
|
+
return self._c._post("/api/soundreport/autosave", value=value)
|
|
468
|
+
|
|
469
|
+
def edit_entry(self, row, field, value, track_index=None):
|
|
470
|
+
"""field: 'timecode' | 'circled' | 'note' | 'project' | 'scene' | 'slate' |
|
|
471
|
+
'take' | 'roll' | 'fps' | 'trackName' (trackName also needs track_index)."""
|
|
472
|
+
return self._c._post(
|
|
473
|
+
"/api/soundreport/entry", row=row, field=field, value=value,
|
|
474
|
+
trackIndex=track_index,
|
|
475
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Exceptions for the WildTrax /api client, mapped from HTTP status codes."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WildTraxError(Exception):
|
|
5
|
+
"""Base class for all client errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConnectionFailed(WildTraxError):
|
|
9
|
+
"""Could not reach the server (host down, refused, timeout)."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PendingApprovalError(WildTraxError):
|
|
13
|
+
"""The server returned 202 -- this client/IP is awaiting approval in the
|
|
14
|
+
desktop Session Manager. Approve it (and grant Write for write actions),
|
|
15
|
+
then retry. ``WildTrax.wait_for_approval()`` polls until this clears."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PermissionDenied(WildTraxError):
|
|
19
|
+
"""403 -- the client is approved but lacks Write permission for this action."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NotFound(WildTraxError):
|
|
23
|
+
"""404 -- unknown route, or a bad index (e.g. trackIndex out of range)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RecordingInProgress(WildTraxError):
|
|
27
|
+
"""409 -- the action is blocked while the server is recording (e.g. arming
|
|
28
|
+
a track, changing track count, editing routing)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RateLimited(WildTraxError):
|
|
32
|
+
"""429 -- transport command debounced; sent too quickly after the last one."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BadRequest(WildTraxError):
|
|
36
|
+
"""400 -- malformed request body (invalid JSON)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ServerError(WildTraxError):
|
|
40
|
+
"""An unexpected status code from the server."""
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wildtrax-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the WildTrax 2027 HTTP /api control surface
|
|
5
|
+
Author: Halter Technical
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://wildtrax.io
|
|
8
|
+
Project-URL: Documentation, https://wildtrax.io/resources/python-client
|
|
9
|
+
Keywords: wildtrax,audio,recording,remote,osc,control
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# PyWildTrax
|
|
19
|
+
|
|
20
|
+
A small, dependency-free Python client for the **WildTrax 2027** HTTP `/api`
|
|
21
|
+
control surface. Drive recording, tracks, metadata, the soundboard, the playlist,
|
|
22
|
+
and more from scripts — the same actions the OSC protocol exposes, over plain HTTP.
|
|
23
|
+
|
|
24
|
+
Built on the Python standard library only (`urllib`) — no `requests`, no extra
|
|
25
|
+
installs, works anywhere Python 3.8+ runs.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install wildtrax-client
|
|
31
|
+
# or, from a checkout:
|
|
32
|
+
pip install -e .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
> Install as **`wildtrax-client`**, import as **`wildtrax_client`**:
|
|
36
|
+
> ```python
|
|
37
|
+
> from wildtrax_client import WildTrax
|
|
38
|
+
> ```
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from wildtrax_client import WildTrax
|
|
44
|
+
|
|
45
|
+
wt = WildTrax("192.168.1.155") # default HTTP port 5659
|
|
46
|
+
wt.wait_for_approval() # blocks until the host approves this device
|
|
47
|
+
|
|
48
|
+
wt.metadata.scene("SC07")
|
|
49
|
+
wt.transport.record()
|
|
50
|
+
# ...
|
|
51
|
+
wt.transport.stop()
|
|
52
|
+
|
|
53
|
+
print(wt.session_state())
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Approval model
|
|
57
|
+
|
|
58
|
+
WildTrax has no passwords — security is the host approving devices on the LAN.
|
|
59
|
+
The **first** request from a new device shows up as *pending* in the desktop
|
|
60
|
+
**Session Manager** and the client gets `202`. A human approves it once (and grants
|
|
61
|
+
**Write** for write actions like recording). After that, the device is remembered.
|
|
62
|
+
|
|
63
|
+
- `wt.wait_for_approval(timeout=120)` — poll until approved, or raise.
|
|
64
|
+
- Or catch `PendingApprovalError` yourself and retry.
|
|
65
|
+
- `wt.is_approved()` / `wt.can_write()` — check status without blocking.
|
|
66
|
+
|
|
67
|
+
The server must have a **fixed HTTP port** enabled (Session Manager → Advanced →
|
|
68
|
+
*Fixed network ports*) so the port is stable; otherwise it binds an ephemeral one.
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
Methods are grouped to mirror the `/api` route families:
|
|
73
|
+
|
|
74
|
+
| Namespace | Examples |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `wt.transport` | `record()`, `stop()`, `false_start()` |
|
|
77
|
+
| `wt.tracks` | `arm(i)`, `mute(i, True)`, `pan(i, 2)`, `name(i, "Boom")`, `add()`, `remove(i)`, `set_count(n)` |
|
|
78
|
+
| `wt.metadata` | `scene("SC07")`, `take("T001")`, `note(...)`, `add_marker()`, `next_scene(...)`, `reset_next()`, `auto_increment("take", True)` |
|
|
79
|
+
| `wt.config` | `preroll(True)`, `meter_smoothness(0.5)`, `global_monitor(True)`, `playback_volume(80)`, `matrix_cell("input", 1, 1)` |
|
|
80
|
+
| `wt.soundboard` | `play(1)`, `stop(1)`, `add_pad()`, `title(1, "FX")`, `repeat(1, True)`, `trigger_mode(1, 1)`, `routing_mode(1, 5)`, `routing_cell(1, 1, 2, 1.0)` |
|
|
81
|
+
| `wt.playlist` | `play()`, `stop()`, `loop(True)`, `index(2)`, `add_row()`, `row_title(1, "Cue")`, `row_routing_mode(1, 0)` |
|
|
82
|
+
| `wt.export` | `add()`, `remove(i)`, `update(i, "name", "Mix")`, `toggle_track(f, t)` |
|
|
83
|
+
| `wt.report` | `csv()`, `auto_save(True)`, `edit_entry(3, "circled", True)` |
|
|
84
|
+
|
|
85
|
+
> **Indices are 1-based.** Every index argument (`i`, pad index, row index, file
|
|
86
|
+
> index) is 1-based, matching the WildTrax UI (Track 1, Input 1) and the OSC
|
|
87
|
+
> surface — `wt.tracks.mute(1)` mutes the track shown as "Track 1". The server
|
|
88
|
+
> converts to its 0-based models at the HTTP seam.
|
|
89
|
+
|
|
90
|
+
Readback: `wt.session_state()`, `wt.recording()`, `wt.get_tracks()`,
|
|
91
|
+
`wt.get_metadata()`, `wt.report.csv()`. Per-element reads (1-based, mirror the OSC
|
|
92
|
+
flat read-side): `wt.get_pad(i)`, `wt.get_playlist_row(i)`, `wt.get_export_file(i)`,
|
|
93
|
+
`wt.get_matrix(which)`, `wt.get_soundreport_entry(i)`, `wt.get_session_client(i)`.
|
|
94
|
+
|
|
95
|
+
> **Session administration** (approving/kicking clients, permissions, renaming the
|
|
96
|
+
> session) is **not** available remotely — it's reserved for the desktop host.
|
|
97
|
+
|
|
98
|
+
## Errors
|
|
99
|
+
|
|
100
|
+
All raise subclasses of `WildTraxError`:
|
|
101
|
+
|
|
102
|
+
| Exception | Meaning (HTTP) |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `ConnectionFailed` | host unreachable |
|
|
105
|
+
| `PendingApprovalError` | `202` — awaiting approval |
|
|
106
|
+
| `PermissionDenied` | `403` — approved but no Write |
|
|
107
|
+
| `NotFound` | `404` — bad route or index |
|
|
108
|
+
| `RecordingInProgress` | `409` — blocked while recording |
|
|
109
|
+
| `RateLimited` | `429` — transport command debounced |
|
|
110
|
+
| `BadRequest` | `400` — malformed body |
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
wildtrax_client/__init__.py,sha256=al0iar9r6T9rBUA5Gsp3Uixe18xj9Sl0ikqSUdRCpHg,604
|
|
2
|
+
wildtrax_client/client.py,sha256=RfjP5Gi9lG05icydFtLj6Zx_8tPuMp39l3DQ2k4qsQc,17942
|
|
3
|
+
wildtrax_client/errors.py,sha256=KyHsEjMKUnjr6lA5dPdzaxjlMDIqAv8DraYUGF5RsMQ,1256
|
|
4
|
+
wildtrax_client-0.1.0.dist-info/licenses/LICENSE,sha256=FLBuiRlnxhLUv1a6jVh81kMGCXsRcoRW5GjbJxlAIR8,1073
|
|
5
|
+
wildtrax_client-0.1.0.dist-info/METADATA,sha256=gW6LTXjxi0LAB_KBhcdpSLzcqBPEmdb5KlMAm02lU9Q,4508
|
|
6
|
+
wildtrax_client-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
wildtrax_client-0.1.0.dist-info/top_level.txt,sha256=TJGlIN_ZAnVL6IKJ1fV4umQ-_Yh_eyV4V1E2WwpAkQw,16
|
|
8
|
+
wildtrax_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Halter Technical
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wildtrax_client
|