hyperframes-vst-host 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.
- hyperframes_vst/__init__.py +0 -0
- hyperframes_vst/__main__.py +78 -0
- hyperframes_vst/bounce.py +19 -0
- hyperframes_vst/carve.py +90 -0
- hyperframes_vst/chain.py +120 -0
- hyperframes_vst/scan.py +88 -0
- hyperframes_vst/server.py +417 -0
- hyperframes_vst/stream.py +101 -0
- hyperframes_vst_host-0.1.0.dist-info/METADATA +14 -0
- hyperframes_vst_host-0.1.0.dist-info/RECORD +13 -0
- hyperframes_vst_host-0.1.0.dist-info/WHEEL +4 -0
- hyperframes_vst_host-0.1.0.dist-info/entry_points.txt +2 -0
- hyperframes_vst_host-0.1.0.dist-info/licenses/LICENSE +675 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""WebSocket sidecar server: JSON control lane + binary PCM lane on one socket.
|
|
2
|
+
|
|
3
|
+
pedalboard enforces a single-thread affinity for ALL native VST3/AU work in
|
|
4
|
+
a process, not just editor windows: whichever thread first loads a plugin
|
|
5
|
+
becomes "the" thread for every later load, and showing a native editor
|
|
6
|
+
window additionally requires that thread to be the true OS main thread (a
|
|
7
|
+
hard Cocoa/AppKit constraint on macOS — windows can only be created there).
|
|
8
|
+
Loading a plugin from a second, different thread raises
|
|
9
|
+
`RuntimeError('... must be reloaded on the main thread ...')`; showing an
|
|
10
|
+
editor from a non-main thread raises `RuntimeError('Plugin UI windows can
|
|
11
|
+
only be shown from the main thread.')` or a JUCE-side ObjC exception,
|
|
12
|
+
depending on which check trips first.
|
|
13
|
+
|
|
14
|
+
So every pedalboard-native call in this process — both `build_chain`
|
|
15
|
+
(loading plugins) and `show_editor` (opening a window) — is funneled
|
|
16
|
+
through `VstServer.run_pedalboard_thread`, one dedicated loop that runs on
|
|
17
|
+
the process's real main thread (`serve()`, bottom of this file) and
|
|
18
|
+
processes requests from a thread-safe queue, one at a time. The asyncio
|
|
19
|
+
WebSocket server itself runs on a background thread instead.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import queue
|
|
27
|
+
import secrets
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
import traceback
|
|
33
|
+
from urllib.parse import parse_qs, urlsplit
|
|
34
|
+
|
|
35
|
+
import websockets
|
|
36
|
+
from websockets.datastructures import Headers
|
|
37
|
+
from websockets.http11 import Request, Response
|
|
38
|
+
|
|
39
|
+
from .chain import PluginMissingError, build_chain, enabled_plugins, load_chain_spec, serialize_states
|
|
40
|
+
from .scan import builtin_registry, default_plugin_dirs, scan_paths
|
|
41
|
+
from .stream import TrackStream, probe_chain_stability
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _raise_editor_window_macos() -> None:
|
|
45
|
+
"""Bring the sidecar process's native plugin-editor window to the front.
|
|
46
|
+
|
|
47
|
+
`plugin.show_editor()` opens a real VST3/AU editor window, but the sidecar
|
|
48
|
+
is a background (`uv run`) process, so on macOS the window opens BEHIND the
|
|
49
|
+
browser the user is looking at. There's no pedalboard API to raise it, so
|
|
50
|
+
activate this process via System Events by its pid. Fired on a short delay
|
|
51
|
+
(the window doesn't exist the instant show_editor is called) from a timer
|
|
52
|
+
thread, since show_editor blocks the pedalboard thread until the window
|
|
53
|
+
closes. Best-effort: any failure (osascript missing, permissions) is
|
|
54
|
+
swallowed — a window behind the browser is a worse-but-not-broken state.
|
|
55
|
+
"""
|
|
56
|
+
if sys.platform != "darwin":
|
|
57
|
+
return
|
|
58
|
+
script = (
|
|
59
|
+
'tell application "System Events" to set frontmost of '
|
|
60
|
+
f"(first process whose unix id is {os.getpid()}) to true"
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
subprocess.run(["osascript", "-e", script], capture_output=True, timeout=5)
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class VstServer:
|
|
69
|
+
# Seconds of audio to burst-send ahead of the real-time playhead when a
|
|
70
|
+
# transport starts, pre-filling the client's ring buffer as a jitter
|
|
71
|
+
# cushion. Must stay below the client ring's capacity (1s — see
|
|
72
|
+
# useVstPreview's per-track `driftTracker`/worklet ring) so the lead can
|
|
73
|
+
# never overflow it.
|
|
74
|
+
_PUMP_LEAD_SEC = 0.5
|
|
75
|
+
|
|
76
|
+
def __init__(self) -> None:
|
|
77
|
+
self._tracks: dict[str, TrackStream] = {}
|
|
78
|
+
self._plugins: dict[str, list] = {}
|
|
79
|
+
self._play_task: asyncio.Task | None = None
|
|
80
|
+
self._play_owner: object | None = None
|
|
81
|
+
self._server: websockets.WebSocketServer | None = None
|
|
82
|
+
self._rate = 1.0
|
|
83
|
+
# Shared-secret handshake (see `_authenticate`): the sidecar accepts
|
|
84
|
+
# native-plugin-loading and arbitrary-file-read commands over a plain
|
|
85
|
+
# loopback WebSocket, so without this any local process — or a
|
|
86
|
+
# webpage that guesses/scans the ephemeral port — could drive it.
|
|
87
|
+
# Generated once per process and printed alongside the ready line;
|
|
88
|
+
# only a client that already has it (relayed by studio-server's
|
|
89
|
+
# `/vst/start`, itself only reachable by the studio's own trusted
|
|
90
|
+
# HTTP server) can open a connection.
|
|
91
|
+
self._token = secrets.token_urlsafe(32)
|
|
92
|
+
# All pedalboard-native work (plugin loading + editor windows) hands
|
|
93
|
+
# off to the main thread through this queue (see module docstring).
|
|
94
|
+
# Each item is (fn, args, kwargs, future, loop); the main-thread loop
|
|
95
|
+
# calls fn(*args, **kwargs) and posts the result/exception back onto
|
|
96
|
+
# the asyncio loop that's awaiting it via call_soon_threadsafe.
|
|
97
|
+
self._pedalboard_queue: "queue.Queue[tuple | None]" = queue.Queue()
|
|
98
|
+
# Keyed by (trackId, pluginIndex) so a later `close-editor` can find
|
|
99
|
+
# and signal the right window before it's opened/while it's open.
|
|
100
|
+
self._editor_close_events: dict[tuple[str, int], threading.Event] = {}
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def token(self) -> str:
|
|
104
|
+
return self._token
|
|
105
|
+
|
|
106
|
+
async def start(self, port: int = 0) -> int:
|
|
107
|
+
self._server = await websockets.serve(
|
|
108
|
+
self._handle, "127.0.0.1", port, process_request=self._authenticate,
|
|
109
|
+
)
|
|
110
|
+
bound = self._server.sockets[0].getsockname()[1]
|
|
111
|
+
print(f"VST-HOST-LISTENING port={bound} token={self._token}", flush=True)
|
|
112
|
+
return bound
|
|
113
|
+
|
|
114
|
+
async def _authenticate(self, connection, request: Request) -> Response | None:
|
|
115
|
+
"""`process_request` hook: rejects the HTTP upgrade (before any
|
|
116
|
+
WebSocket connection — and so before `_handle`/`_dispatch` ever see a
|
|
117
|
+
message) unless the request carries the correct `?token=` query
|
|
118
|
+
param. Returning `None` lets the handshake proceed normally."""
|
|
119
|
+
query = parse_qs(urlsplit(request.path).query)
|
|
120
|
+
supplied = query.get("token", [None])[0]
|
|
121
|
+
if supplied != self._token:
|
|
122
|
+
body = b"Unauthorized: missing or invalid token\n"
|
|
123
|
+
return Response(401, "Unauthorized", Headers(), body)
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
async def stop(self) -> None:
|
|
127
|
+
if self._play_task:
|
|
128
|
+
self._play_task.cancel()
|
|
129
|
+
if self._server:
|
|
130
|
+
self._server.close()
|
|
131
|
+
await self._server.wait_closed()
|
|
132
|
+
|
|
133
|
+
async def _handle(self, ws) -> None:
|
|
134
|
+
try:
|
|
135
|
+
async for raw in ws:
|
|
136
|
+
if not isinstance(raw, str):
|
|
137
|
+
continue
|
|
138
|
+
msg = json.loads(raw)
|
|
139
|
+
await self._dispatch(ws, msg)
|
|
140
|
+
finally:
|
|
141
|
+
if self._play_task and self._play_owner is ws:
|
|
142
|
+
self._play_task.cancel()
|
|
143
|
+
|
|
144
|
+
async def _run_on_pedalboard_thread(self, fn, *args):
|
|
145
|
+
"""Runs fn(*args) on the dedicated pedalboard thread (see module
|
|
146
|
+
docstring) and awaits its result without blocking the event loop or
|
|
147
|
+
this connection's other message handling."""
|
|
148
|
+
loop = asyncio.get_running_loop()
|
|
149
|
+
future = loop.create_future()
|
|
150
|
+
|
|
151
|
+
def _call():
|
|
152
|
+
try:
|
|
153
|
+
result = fn(*args)
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
loop.call_soon_threadsafe(future.set_exception, exc)
|
|
156
|
+
else:
|
|
157
|
+
loop.call_soon_threadsafe(future.set_result, result)
|
|
158
|
+
|
|
159
|
+
self._pedalboard_queue.put(_call)
|
|
160
|
+
return await future
|
|
161
|
+
|
|
162
|
+
async def _dispatch(self, ws, msg: dict) -> None:
|
|
163
|
+
cmd = msg.get("cmd")
|
|
164
|
+
try:
|
|
165
|
+
if cmd == "scan":
|
|
166
|
+
discovered = await asyncio.to_thread(scan_paths, msg.get("paths") or default_plugin_dirs())
|
|
167
|
+
# Built-ins first: always-available, host-clean options that
|
|
168
|
+
# need no disk scan (see scan.py's BUILTIN_EFFECTS).
|
|
169
|
+
plugins = builtin_registry() + discovered
|
|
170
|
+
await ws.send(json.dumps({"event": "registry", "plugins": plugins}))
|
|
171
|
+
elif cmd == "load-chain":
|
|
172
|
+
track_id = msg["trackId"]
|
|
173
|
+
spec = load_chain_spec(json.dumps(msg["chainJson"]))
|
|
174
|
+
# Only chains with an external VST3/AU plugin need the
|
|
175
|
+
# dedicated pedalboard thread (see module docstring):
|
|
176
|
+
# builtins never touch JUCE's native plugin-loading
|
|
177
|
+
# machinery, so they're free to load off any thread pool
|
|
178
|
+
# worker, same as before this thread-affinity fix existed.
|
|
179
|
+
if any(p.format != "builtin" for p in spec.plugins):
|
|
180
|
+
plugins = await self._run_on_pedalboard_thread(build_chain, spec)
|
|
181
|
+
else:
|
|
182
|
+
plugins = await asyncio.to_thread(build_chain, spec)
|
|
183
|
+
old = self._tracks.pop(track_id, None)
|
|
184
|
+
if old:
|
|
185
|
+
old.close()
|
|
186
|
+
# The FULL constructed list keeps set-param/get-state indices
|
|
187
|
+
# aligned with the chain file; only the enabled subset
|
|
188
|
+
# processes audio (a disabled plugin is bypassed, not gone).
|
|
189
|
+
self._plugins[track_id] = plugins
|
|
190
|
+
active = enabled_plugins(spec, plugins)
|
|
191
|
+
# Probe whether pedalboard can host this chain without emitting
|
|
192
|
+
# NaN/Inf/runaway output (see probe_chain_stability). An
|
|
193
|
+
# unstable track is still registered — so its wire index stays
|
|
194
|
+
# in lockstep with the client's own counter — but flagged so it
|
|
195
|
+
# never streams; the client then keeps it on dry audio and warns
|
|
196
|
+
# instead of muting the original into NaN-driven silence.
|
|
197
|
+
stable = await asyncio.to_thread(probe_chain_stability, msg["wavPath"], active)
|
|
198
|
+
track = TrackStream(len(self._tracks), msg["wavPath"], active, stable=stable)
|
|
199
|
+
self._tracks[track_id] = track
|
|
200
|
+
params = [
|
|
201
|
+
[{"name": k, "value": float(v.raw_value) if hasattr(v, "raw_value") else None}
|
|
202
|
+
for k, v in getattr(p, "parameters", {}).items()]
|
|
203
|
+
for p in plugins
|
|
204
|
+
]
|
|
205
|
+
# The client hardcoding a sample rate (rather than reading the
|
|
206
|
+
# dry file's real one) plays streamed PCM at the wrong pitch
|
|
207
|
+
# and speed, and — since its drift-check compares elapsed
|
|
208
|
+
# wall-clock time against a WRONG samples-per-second
|
|
209
|
+
# assumption — measures genuine, ever-growing drift where
|
|
210
|
+
# none exists, repeatedly forcing a destructive reseek.
|
|
211
|
+
await ws.send(json.dumps({
|
|
212
|
+
"event": "chain-loaded", "trackId": track_id, "params": params,
|
|
213
|
+
"sampleRate": track.sample_rate, "stable": stable,
|
|
214
|
+
}))
|
|
215
|
+
elif cmd == "unload-chain":
|
|
216
|
+
track = self._tracks.pop(msg["trackId"], None)
|
|
217
|
+
self._plugins.pop(msg["trackId"], None)
|
|
218
|
+
if track:
|
|
219
|
+
track.close()
|
|
220
|
+
elif cmd == "set-param":
|
|
221
|
+
plugin = self._plugins[msg["trackId"]][msg["pluginIndex"]]
|
|
222
|
+
setattr(plugin, msg["param"], msg["value"])
|
|
223
|
+
elif cmd == "open-editor":
|
|
224
|
+
track_id, plugin_index = msg["trackId"], msg["pluginIndex"]
|
|
225
|
+
plugin = self._plugins[track_id][plugin_index]
|
|
226
|
+
close_event = threading.Event()
|
|
227
|
+
self._editor_close_events[(track_id, plugin_index)] = close_event
|
|
228
|
+
|
|
229
|
+
def _open(plugin=plugin, close_event=close_event):
|
|
230
|
+
try:
|
|
231
|
+
plugin.show_editor(close_event)
|
|
232
|
+
finally:
|
|
233
|
+
self._editor_close_events.pop((track_id, plugin_index), None)
|
|
234
|
+
|
|
235
|
+
# Fire-and-forget: don't await, so this connection's other
|
|
236
|
+
# messages (e.g. close-editor) keep being handled while the
|
|
237
|
+
# window is open (show_editor blocks the pedalboard thread,
|
|
238
|
+
# not this coroutine).
|
|
239
|
+
asyncio.create_task(self._run_on_pedalboard_thread(_open))
|
|
240
|
+
# The editor window opens BEHIND the browser (this sidecar is a
|
|
241
|
+
# background process). Raise it to the front shortly after it's
|
|
242
|
+
# created — on a timer thread, since show_editor above blocks
|
|
243
|
+
# the pedalboard thread until the window closes.
|
|
244
|
+
threading.Timer(0.4, _raise_editor_window_macos).start()
|
|
245
|
+
elif cmd == "close-editor":
|
|
246
|
+
close_event = self._editor_close_events.get((msg["trackId"], msg["pluginIndex"]))
|
|
247
|
+
if close_event:
|
|
248
|
+
close_event.set()
|
|
249
|
+
elif cmd == "get-state":
|
|
250
|
+
states = serialize_states(self._plugins[msg["trackId"]])
|
|
251
|
+
await ws.send(json.dumps({"event": "state", "trackId": msg["trackId"], "plugins": states}))
|
|
252
|
+
elif cmd == "transport":
|
|
253
|
+
await self._transport(ws, msg)
|
|
254
|
+
else:
|
|
255
|
+
await ws.send(json.dumps({"event": "error", "code": "bad_command"}))
|
|
256
|
+
except PluginMissingError as exc:
|
|
257
|
+
await ws.send(json.dumps({
|
|
258
|
+
"event": "error", "code": "plugin_missing",
|
|
259
|
+
"plugin": exc.plugin_name, "trackId": msg.get("trackId"),
|
|
260
|
+
}))
|
|
261
|
+
except Exception:
|
|
262
|
+
# Unlike PluginMissingError, this is unexpected — print it so a
|
|
263
|
+
# real bug doesn't hide behind the generic "bad_command" wire
|
|
264
|
+
# error (a silent one cost a long live-debugging session before
|
|
265
|
+
# this print existed).
|
|
266
|
+
traceback.print_exc()
|
|
267
|
+
await ws.send(json.dumps({
|
|
268
|
+
"event": "error", "code": "bad_command",
|
|
269
|
+
"trackId": msg.get("trackId"),
|
|
270
|
+
}))
|
|
271
|
+
|
|
272
|
+
async def _transport(self, ws, msg: dict) -> None:
|
|
273
|
+
action = msg.get("action")
|
|
274
|
+
if action == "seek":
|
|
275
|
+
for track in self._tracks.values():
|
|
276
|
+
track.seek(msg["timeSec"])
|
|
277
|
+
elif action == "play":
|
|
278
|
+
self._rate = msg.get("rate", 1.0)
|
|
279
|
+
for track in self._tracks.values():
|
|
280
|
+
track.seek(msg.get("timeSec", 0.0))
|
|
281
|
+
if self._play_task:
|
|
282
|
+
self._play_task.cancel()
|
|
283
|
+
self._play_task = asyncio.create_task(self._pump(ws))
|
|
284
|
+
self._play_owner = ws
|
|
285
|
+
elif action == "pause":
|
|
286
|
+
if self._play_task and self._play_owner is ws:
|
|
287
|
+
self._play_task.cancel()
|
|
288
|
+
self._play_task = None
|
|
289
|
+
self._play_owner = None
|
|
290
|
+
|
|
291
|
+
def run_pedalboard_thread(self) -> None:
|
|
292
|
+
"""Must run on the process's real main thread (see module
|
|
293
|
+
docstring). Blocks, processing one pedalboard-native call at a
|
|
294
|
+
time — a `show_editor` call itself blocks this loop until its
|
|
295
|
+
window is closed (by the user or `close-editor`), which is exactly
|
|
296
|
+
why plugin loading and editor windows must serialize through this
|
|
297
|
+
one thread rather than pedalboard's calls being split across
|
|
298
|
+
`asyncio.to_thread`'s pool. Returns when `stop_pedalboard_thread()`
|
|
299
|
+
enqueues the shutdown sentinel."""
|
|
300
|
+
while True:
|
|
301
|
+
call = self._pedalboard_queue.get()
|
|
302
|
+
if call is None:
|
|
303
|
+
return
|
|
304
|
+
call()
|
|
305
|
+
|
|
306
|
+
def stop_pedalboard_thread(self) -> None:
|
|
307
|
+
self._pedalboard_queue.put(None)
|
|
308
|
+
|
|
309
|
+
async def _pump(self, ws) -> None:
|
|
310
|
+
# Absolute-deadline pacing. `deadline` is when the NEXT block is due;
|
|
311
|
+
# it advances by one block's real duration each iteration. Because it's
|
|
312
|
+
# an absolute target (not a fresh `sleep(block)` each iteration), the
|
|
313
|
+
# time spent processing+sending a block and the sleep's own overshoot
|
|
314
|
+
# never accumulate into a production deficit — when we fall behind, the
|
|
315
|
+
# deadline is already in the past and the next block goes out
|
|
316
|
+
# immediately to catch up. The old open-loop `sleep(block/rate)`
|
|
317
|
+
# delivered only ~96% of real time, starving the client's ring buffer:
|
|
318
|
+
# the worklet zero-filled the gaps (degraded audio) and the shortfall
|
|
319
|
+
# accrued as genuine drift until the drift-check forced a destructive
|
|
320
|
+
# reseek ("cuts out"). See test_pump_keeps_up_with_real_time.
|
|
321
|
+
loop = asyncio.get_running_loop()
|
|
322
|
+
# Prime the clock `_PUMP_LEAD_SEC` in the past so the first blocks are
|
|
323
|
+
# sent back-to-back (no sleep) until the deadline catches up to now —
|
|
324
|
+
# that burst pre-fills the client ring as a jitter cushion.
|
|
325
|
+
deadline = loop.time() - self._PUMP_LEAD_SEC
|
|
326
|
+
while True:
|
|
327
|
+
sent_any = False
|
|
328
|
+
block_dur = 1024 / 48000
|
|
329
|
+
for track in list(self._tracks.values()):
|
|
330
|
+
frame = track.next_block()
|
|
331
|
+
if frame is None:
|
|
332
|
+
continue
|
|
333
|
+
block_dur = track.block_size / track.sample_rate
|
|
334
|
+
await ws.send(frame)
|
|
335
|
+
sent_any = True
|
|
336
|
+
if not sent_any:
|
|
337
|
+
return
|
|
338
|
+
deadline += block_dur / self._rate
|
|
339
|
+
# Cap how far behind real time the deadline may fall, so a
|
|
340
|
+
# transient stall (GC, a slow plugin block) re-primes the lead
|
|
341
|
+
# cushion instead of bursting an unbounded backlog into the
|
|
342
|
+
# client's fixed-size ring.
|
|
343
|
+
# ponytail: fixed lead cap; fine unless a plugin can't render real-time.
|
|
344
|
+
floor = loop.time() - self._PUMP_LEAD_SEC
|
|
345
|
+
if deadline < floor:
|
|
346
|
+
deadline = floor
|
|
347
|
+
sleep_for = deadline - loop.time()
|
|
348
|
+
if sleep_for > 0:
|
|
349
|
+
await asyncio.sleep(sleep_for)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _process_alive(pid: int) -> bool:
|
|
353
|
+
"""True if `pid` is a live process. `os.kill(pid, 0)` sends no signal but
|
|
354
|
+
raises ProcessLookupError once the process is gone (EPERM — a live process
|
|
355
|
+
we don't own — still counts as alive)."""
|
|
356
|
+
try:
|
|
357
|
+
os.kill(pid, 0)
|
|
358
|
+
except ProcessLookupError:
|
|
359
|
+
return False
|
|
360
|
+
except PermissionError:
|
|
361
|
+
return True
|
|
362
|
+
return True
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _watch_parent_and_exit(
|
|
366
|
+
watch_pid: int | None, initial_ppid: int, interval_sec: float = 1.0
|
|
367
|
+
) -> None:
|
|
368
|
+
"""Exit the process once the studio-server / `hyperframes preview` that
|
|
369
|
+
spawned us is gone. When it dies ungracefully (SIGKILL, crash) it can't run
|
|
370
|
+
its own child-teardown, so without this the sidecar is orphaned and lingers
|
|
371
|
+
— holding a port, showing up as a stale `hyperframes-vst serve` a later
|
|
372
|
+
studio has to `pkill` by hand.
|
|
373
|
+
|
|
374
|
+
`watch_pid` is the spawner's OWN pid, passed explicitly (`--parent-pid`),
|
|
375
|
+
and polled via `os.kill(pid, 0)`. This is necessary because the spawner
|
|
376
|
+
launches us through `uv run`, so our real `getppid()` is the intervening
|
|
377
|
+
`uv` process — which survives the spawner's death — making a `getppid()`
|
|
378
|
+
change useless on its own. Falls back to the `getppid()`-change heuristic
|
|
379
|
+
when no `watch_pid` was given (bare/standalone launch). Runs on a daemon
|
|
380
|
+
thread; `os._exit` because there's nothing to flush and the asyncio server
|
|
381
|
+
owns the normal shutdown path."""
|
|
382
|
+
while True:
|
|
383
|
+
time.sleep(interval_sec)
|
|
384
|
+
if watch_pid is not None:
|
|
385
|
+
if not _process_alive(watch_pid):
|
|
386
|
+
os._exit(0)
|
|
387
|
+
elif os.getppid() != initial_ppid:
|
|
388
|
+
os._exit(0)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def serve(port: int = 0, parent_pid: int | None = None) -> None:
|
|
392
|
+
server = VstServer()
|
|
393
|
+
started = threading.Event()
|
|
394
|
+
|
|
395
|
+
# Self-reap if the spawner dies (see _watch_parent_and_exit). initial_ppid
|
|
396
|
+
# is the getppid()-change fallback for a bare launch with no --parent-pid.
|
|
397
|
+
threading.Thread(
|
|
398
|
+
target=_watch_parent_and_exit,
|
|
399
|
+
args=(parent_pid, os.getppid()),
|
|
400
|
+
daemon=True,
|
|
401
|
+
).start()
|
|
402
|
+
|
|
403
|
+
def _run_asyncio_server() -> None:
|
|
404
|
+
async def _run() -> None:
|
|
405
|
+
await server.start(port)
|
|
406
|
+
started.set()
|
|
407
|
+
await asyncio.Future()
|
|
408
|
+
|
|
409
|
+
asyncio.run(_run())
|
|
410
|
+
|
|
411
|
+
thread = threading.Thread(target=_run_asyncio_server, daemon=True)
|
|
412
|
+
thread.start()
|
|
413
|
+
started.wait()
|
|
414
|
+
try:
|
|
415
|
+
server.run_pedalboard_thread()
|
|
416
|
+
except KeyboardInterrupt:
|
|
417
|
+
server.stop_pedalboard_thread()
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Per-track streaming: dry WAV -> chain -> wire frames.
|
|
2
|
+
|
|
3
|
+
Wire frame (little-endian): u32 track_index, f64 sample_pos, interleaved f32 stereo.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import struct
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from pedalboard import Pedalboard
|
|
11
|
+
from pedalboard.io import AudioFile
|
|
12
|
+
|
|
13
|
+
HEADER = struct.Struct("<Id")
|
|
14
|
+
|
|
15
|
+
# Any active audio effect on a normalized signal stays within a few multiples
|
|
16
|
+
# of unity; sustained output above this is a runaway (some plugins overflow to
|
|
17
|
+
# ~1e36 before hitting Inf/NaN — see `probe_chain_stability`).
|
|
18
|
+
MAX_STABLE_PEAK = 100.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def output_is_stable(out: np.ndarray) -> bool:
|
|
22
|
+
"""A chain's output is stable if every sample is finite and within a sane
|
|
23
|
+
magnitude. Split out from `probe_chain_stability` so the accept/reject
|
|
24
|
+
rule is unit-testable without a misbehaving plugin."""
|
|
25
|
+
if not np.all(np.isfinite(out)):
|
|
26
|
+
return False
|
|
27
|
+
return bool(np.max(np.abs(out)) <= MAX_STABLE_PEAK) if out.size else True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def probe_chain_stability(wav_path: str, plugins: list, seconds: float = 0.5) -> bool:
|
|
31
|
+
"""Bounce a short slice of the dry file through the chain offline and
|
|
32
|
+
report whether the output is finite and bounded.
|
|
33
|
+
|
|
34
|
+
pedalboard's headless VST3/AU host silently mis-initializes a real subset
|
|
35
|
+
of plugins: their DSP emits NaN/Inf from the first sample, or runs away to
|
|
36
|
+
astronomical magnitudes (~1e36). This is a known, unresolved pedalboard
|
|
37
|
+
limitation (github.com/spotify/pedalboard#390, closed "not planned"), and
|
|
38
|
+
pedalboard publishes no compatibility list — so the only reliable check is
|
|
39
|
+
to run the plugin and look at what comes out. An unstable chain is left
|
|
40
|
+
unregistered for streaming; the client keeps the track on its dry audio and
|
|
41
|
+
warns, rather than muting the original into NaN-driven silence.
|
|
42
|
+
"""
|
|
43
|
+
with AudioFile(wav_path) as f:
|
|
44
|
+
sr = f.samplerate
|
|
45
|
+
n = min(f.frames, max(1, int(sr * seconds)))
|
|
46
|
+
if f.frames == 0:
|
|
47
|
+
return True # nothing to stream — vacuously fine
|
|
48
|
+
chunk = f.read(n)
|
|
49
|
+
if chunk.shape[0] == 1:
|
|
50
|
+
chunk = np.vstack([chunk, chunk])
|
|
51
|
+
return output_is_stable(Pedalboard(plugins)(chunk, sr, reset=True))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def encode_frame(track_index: int, sample_pos: int, pcm: np.ndarray) -> bytes:
|
|
55
|
+
interleaved = np.ascontiguousarray(pcm.T, dtype=np.float32)
|
|
56
|
+
return HEADER.pack(track_index, float(sample_pos)) + interleaved.tobytes()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def decode_frame(data: bytes) -> tuple[int, int, np.ndarray]:
|
|
60
|
+
track_index, sample_pos = HEADER.unpack_from(data)
|
|
61
|
+
flat = np.frombuffer(data, dtype=np.float32, offset=HEADER.size)
|
|
62
|
+
return track_index, int(sample_pos), flat.reshape(-1, 2).T
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TrackStream:
|
|
66
|
+
def __init__(
|
|
67
|
+
self, track_index: int, wav_path: str, plugins: list, block_size: int = 1024, stable: bool = True
|
|
68
|
+
):
|
|
69
|
+
self.track_index = track_index
|
|
70
|
+
self.block_size = block_size
|
|
71
|
+
# An unstable chain (see `probe_chain_stability`) still occupies a
|
|
72
|
+
# track slot so wire indices stay in lockstep with the client's own
|
|
73
|
+
# counter, but never emits frames — the client keeps it dry.
|
|
74
|
+
self.stable = stable
|
|
75
|
+
self._board = Pedalboard(plugins)
|
|
76
|
+
self._file = AudioFile(wav_path)
|
|
77
|
+
self.sample_rate = self._file.samplerate
|
|
78
|
+
self._pos = 0
|
|
79
|
+
self._needs_reset = True
|
|
80
|
+
|
|
81
|
+
def seek(self, time_sec: float) -> None:
|
|
82
|
+
self._pos = int(time_sec * self.sample_rate)
|
|
83
|
+
self._needs_reset = True
|
|
84
|
+
|
|
85
|
+
def next_block(self) -> bytes | None:
|
|
86
|
+
if not self.stable:
|
|
87
|
+
return None
|
|
88
|
+
if self._pos >= self._file.frames:
|
|
89
|
+
return None
|
|
90
|
+
self._file.seek(self._pos)
|
|
91
|
+
chunk = self._file.read(min(self.block_size, self._file.frames - self._pos))
|
|
92
|
+
if chunk.shape[0] == 1:
|
|
93
|
+
chunk = np.vstack([chunk, chunk])
|
|
94
|
+
out = self._board(chunk, self.sample_rate, reset=self._needs_reset)
|
|
95
|
+
self._needs_reset = False
|
|
96
|
+
frame = encode_frame(self.track_index, self._pos, out.astype(np.float32))
|
|
97
|
+
self._pos += chunk.shape[1]
|
|
98
|
+
return frame
|
|
99
|
+
|
|
100
|
+
def close(self) -> None:
|
|
101
|
+
self._file.close()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hyperframes-vst-host
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: VST3/AU sidecar host for HyperFrames Studio
|
|
5
|
+
Project-URL: Homepage, https://github.com/heygen-com/hyperframes-vst-host
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: numpy>=1.26
|
|
13
|
+
Requires-Dist: pedalboard>=0.9.24
|
|
14
|
+
Requires-Dist: websockets>=12
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
hyperframes_vst/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
hyperframes_vst/__main__.py,sha256=lSvbMDAy4U5X7J_FUmL-cq_rGZwBngkdCMxL_KvJxlw,2299
|
|
3
|
+
hyperframes_vst/bounce.py,sha256=eEETwMGXr13HhSurSNbjq0SIaVT4LYECdi87RHnVtkk,904
|
|
4
|
+
hyperframes_vst/carve.py,sha256=9r_GYdjApDBYsri_zKsnHqakO_Inc3Q6XDQtyGAjEy8,3343
|
|
5
|
+
hyperframes_vst/chain.py,sha256=p5neHAfyTekKOXFwsptct2PXzP2PUmJttoWBJKXjuTo,3810
|
|
6
|
+
hyperframes_vst/scan.py,sha256=lS41FT5k7ym9UQOhxrqUwxrrq5KdwpDJyNjMxqmWOWw,3417
|
|
7
|
+
hyperframes_vst/server.py,sha256=rVdm5F2XCPl7rw-f0sTr51DZrEAN199v-cc7LPPYbvI,19883
|
|
8
|
+
hyperframes_vst/stream.py,sha256=zbErp9mibQi3gZ2opXCdTPuW-Jab-actmEv7lSk8L38,4114
|
|
9
|
+
hyperframes_vst_host-0.1.0.dist-info/METADATA,sha256=vulqM44-YsFkj36LsE6m_phEKTxEnVVwSjQ9Yt-ApGc,547
|
|
10
|
+
hyperframes_vst_host-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
hyperframes_vst_host-0.1.0.dist-info/entry_points.txt,sha256=8UIrBoe_mmiAtJMeiNsbk009EQJ0T5FALV-3A4h2YRI,66
|
|
12
|
+
hyperframes_vst_host-0.1.0.dist-info/licenses/LICENSE,sha256=5X8cMguM-HmKfS_4Om-eBqM6A1hfbgZf6pfx2G24QFI,35150
|
|
13
|
+
hyperframes_vst_host-0.1.0.dist-info/RECORD,,
|