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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hyperframes-vst = hyperframes_vst.__main__:main