pyaudiohost 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,55 @@
1
+ """Host audio effects and instruments with NumPy arrays and native editors.
2
+
3
+ Use Plugin or load_plugin() for one effect, or PluginChain for ordered
4
+ effects, streaming processing, and offline rendering. AudioHost and Host
5
+ are aliases of PluginChain. Discovery functions inspect installed plugins
6
+ in isolated workers; AAX is available only through probe_aax().
7
+ Pass MidiNote events to Plugin.process() to play an instrument into an array.
8
+
9
+ Audio uses channel-first arrays with 1 through 64 channels. Plugin supports
10
+ independent native buses; chains route main buses. File I/O, audio devices,
11
+ and playback are handled by the calling application. Close plugin
12
+ handles and chains explicitly or use their context managers. Protect code
13
+ that creates workers with an ``if __name__ == "__main__":`` guard because
14
+ workers use multiprocessing spawn.
15
+
16
+ Public classes and functions are re-exported here. Their docstrings describe
17
+ arguments, return values, ownership, and operation-specific failures.
18
+ """
19
+
20
+ from .discovery import (
21
+ PluginDescription,
22
+ ScanResult,
23
+ capabilities,
24
+ default_search_paths,
25
+ find_plugin_paths,
26
+ inspect_plugin,
27
+ probe_aax,
28
+ scan_plugins,
29
+ )
30
+ from .errors import AudioHostError, HostCrashedError, HostTimeoutError, UnsupportedFormatError
31
+ from .host import AudioHost, Host, PluginChain
32
+ from .midi import MidiNote
33
+ from .plugin import Plugin, load_plugin
34
+
35
+ __version__ = "0.1.0"
36
+ __all__ = [
37
+ "AudioHost",
38
+ "AudioHostError",
39
+ "Host",
40
+ "HostCrashedError",
41
+ "HostTimeoutError",
42
+ "MidiNote",
43
+ "Plugin",
44
+ "PluginChain",
45
+ "PluginDescription",
46
+ "ScanResult",
47
+ "UnsupportedFormatError",
48
+ "capabilities",
49
+ "default_search_paths",
50
+ "find_plugin_paths",
51
+ "inspect_plugin",
52
+ "load_plugin",
53
+ "probe_aax",
54
+ "scan_plugins",
55
+ ]
@@ -0,0 +1,82 @@
1
+ """Channel and bus validation shared by public handles and native bridges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ import numpy as np
8
+
9
+ from .errors import AudioHostError
10
+
11
+ MAX_CHANNELS = 64
12
+ MAX_BUSES = 32
13
+
14
+
15
+ def channel_count(value):
16
+ if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= MAX_CHANNELS:
17
+ raise ValueError("channels must be an integer between 1 and 64")
18
+ return value
19
+
20
+
21
+ def audio_array(audio):
22
+ data = np.asarray(audio, dtype=np.float32)
23
+ if data.ndim == 1:
24
+ data = data[None, :]
25
+ if data.ndim != 2 or not 1 <= data.shape[0] <= MAX_CHANNELS:
26
+ raise ValueError("audio must have shape (channels, frames), with 1 through 64 channels")
27
+ if not np.isfinite(data).all():
28
+ raise ValueError("Audio samples must be finite")
29
+ return np.ascontiguousarray(data)
30
+
31
+
32
+ def bus_config(config, *, output=False):
33
+ if config is None:
34
+ return None
35
+ if not isinstance(config, Mapping):
36
+ raise TypeError(
37
+ "Bus configuration must map integer bus indices to channel counts or layouts"
38
+ )
39
+ if output and not config:
40
+ raise ValueError("At least one output bus must be enabled")
41
+ result = {}
42
+ for index, value in config.items():
43
+ if isinstance(index, bool) or not isinstance(index, int) or not 0 <= index < MAX_BUSES:
44
+ raise ValueError("Bus indices must be integers between 0 and 31")
45
+ layout = dict(value) if isinstance(value, Mapping) else {"channels": value}
46
+ count = channel_count(layout.get("channels"))
47
+ arrangement = layout.get("arrangement")
48
+ if arrangement is not None and (
49
+ isinstance(arrangement, bool)
50
+ or not isinstance(arrangement, int)
51
+ or not 0 < arrangement < 1 << 64
52
+ or arrangement.bit_count() != count
53
+ ):
54
+ raise ValueError("A VST3 arrangement must be a 64-bit mask with one bit per channel")
55
+ result[index] = {"channels": count, "arrangement": arrangement}
56
+ if sum(item["channels"] for item in result.values()) > MAX_CHANNELS:
57
+ raise ValueError("At most 64 channels can be enabled per input/output direction")
58
+ return dict(sorted(result.items()))
59
+
60
+
61
+ def adapt_channels(audio, channels):
62
+ """Preserve exact layouts; retain legacy mono/stereo conversion only."""
63
+ if audio.shape[0] == channels:
64
+ return np.ascontiguousarray(audio, dtype=np.float32)
65
+ if audio.shape[0] == 1 and channels == 2:
66
+ return np.repeat(audio, 2, axis=0)
67
+ if audio.shape[0] == 2 and channels == 1:
68
+ return audio.mean(axis=0, keepdims=True)
69
+ raise AudioHostError(
70
+ f"Cannot convert {audio.shape[0]} channels to {channels}; configure a matching layout or use process_buses()"
71
+ )
72
+
73
+
74
+ def requested_buses(config, counts, channels):
75
+ result = bus_config(config)
76
+ if result is None:
77
+ result = {0: {"channels": channels, "arrangement": None}} if counts else {}
78
+ if any(index >= len(counts) for index in result):
79
+ raise AudioHostError(
80
+ f"Requested audio bus does not exist; plugin exposes {len(counts)} bus(es)"
81
+ )
82
+ return result
pyaudiohost/_render.py ADDED
@@ -0,0 +1,58 @@
1
+ """Shared offline block rendering for effects and instruments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from .errors import AudioHostError
8
+
9
+
10
+ def check_cancel(cancel):
11
+ if cancel is not None and cancel.is_set():
12
+ raise AudioHostError("Render cancelled")
13
+
14
+
15
+ def render_blocks(
16
+ *,
17
+ channels,
18
+ block_size,
19
+ output_frames,
20
+ latency,
21
+ process_block,
22
+ progress=None,
23
+ cancel=None,
24
+ trim_tail_at=None,
25
+ minimum_frames=0,
26
+ ):
27
+ """Flush latency and tails, reporting processed frames in the caller's thread.
28
+
29
+ Callers emit initial progress before snapshot/setup. Extra minimum_frames
30
+ allow a final instrument note-off even when the output ends at that sample.
31
+ """
32
+ check_cancel(cancel)
33
+ output = np.empty((channels, output_frames), np.float32)
34
+ total = max(output_frames + latency, minimum_frames)
35
+ skip, written = latency, 0
36
+ last_audible = trim_tail_at
37
+ for position in range(0, total, block_size):
38
+ check_cancel(cancel)
39
+ count = min(block_size, total - position)
40
+ data = process_block(position, count)
41
+ if data.shape != (channels, count):
42
+ raise AudioHostError("A plugin returned an invalid audio block shape")
43
+ if not np.isfinite(data).all():
44
+ raise AudioHostError("A plugin produced non-finite audio samples")
45
+ ignored = min(skip, count)
46
+ skip -= ignored
47
+ data = data[:, ignored:][:, : output_frames - written]
48
+ output[:, written : written + data.shape[1]] = data
49
+ if trim_tail_at is not None:
50
+ audible = np.flatnonzero(np.any(np.abs(data) > 1e-6, axis=0))
51
+ if audible.size:
52
+ last_audible = max(last_audible, written + int(audible[-1]) + 1)
53
+ written += data.shape[1]
54
+ if progress:
55
+ progress(min(1.0, (position + count) / total))
56
+ if progress and total == 0:
57
+ progress(1.0)
58
+ return output[:, :last_audible].copy() if trim_tail_at is not None else output
pyaudiohost/_worker.py ADDED
@@ -0,0 +1,305 @@
1
+ """Isolated plugins: lifecycle/UI on the main thread, audio on a dedicated thread."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import multiprocessing as mp
6
+ import queue
7
+ import threading
8
+ import time
9
+ import weakref
10
+
11
+ from .errors import AudioHostError, HostCrashedError, HostTimeoutError
12
+
13
+
14
+ def _platform_init(audio=False):
15
+ import sys
16
+
17
+ if sys.platform == "win32":
18
+ import pythoncom
19
+
20
+ try:
21
+ pythoncom.CoInitializeEx(
22
+ pythoncom.COINIT_MULTITHREADED if audio else pythoncom.COINIT_APARTMENTTHREADED
23
+ )
24
+ except pythoncom.com_error:
25
+ return None
26
+ return pythoncom
27
+ if sys.platform == "darwin" and not audio:
28
+ from .backends._window_cocoa import application
29
+
30
+ application()
31
+ return None
32
+
33
+
34
+ def _backend(format, path, plugin_id):
35
+ if format == "vst2":
36
+ from .backends.vst2 import VST2Backend
37
+
38
+ return VST2Backend(path, plugin_id)
39
+ if format == "vst3":
40
+ from .backends.vst3 import VST3Backend
41
+
42
+ return VST3Backend(path, plugin_id)
43
+ if format == "au":
44
+ from .backends.au import AUBackend
45
+
46
+ return AUBackend(path, plugin_id)
47
+ raise AudioHostError(f"Unsupported processing format: {format}")
48
+
49
+
50
+ def _scan(format, path):
51
+ if format == "aax":
52
+ from .backends.aax import probe
53
+
54
+ return probe(path)
55
+ if format == "vst3":
56
+ from .backends.vst3 import Module
57
+
58
+ module = Module(path)
59
+ try:
60
+ return module.scan()
61
+ finally:
62
+ module.close()
63
+ if format == "au":
64
+ from .backends.au import scan
65
+
66
+ return scan(path)
67
+ instance = _backend(format, path, None)
68
+ try:
69
+ return instance.scan()
70
+ finally:
71
+ instance.close()
72
+
73
+
74
+ def _entry(connection, format, path, plugin_id, inspection):
75
+ import sys
76
+
77
+ _run_worker(connection, format, path, plugin_id, inspection)
78
+ if sys.platform == "win32":
79
+ import win32api
80
+
81
+ # SDK teardown and pipe closure have completed. Avoid a second round of
82
+ # DLL/global destructors after Python services have started shutting down.
83
+ # Ozone's GUI runtime otherwise aborts in ZeroMQ/Winsock during DLL detach.
84
+ win32api.TerminateProcess(win32api.GetCurrentProcess(), 0)
85
+
86
+
87
+ def _run_worker(connection, format, path, plugin_id, inspection):
88
+ import sys
89
+
90
+ instance = None
91
+ ole = None
92
+ audio_thread = None
93
+ stopped = threading.Event()
94
+ processing = threading.RLock()
95
+ jobs = queue.Queue()
96
+ sending = threading.Lock()
97
+ timing = {"latency": 0, "tail": 0}
98
+
99
+ def send(message):
100
+ with sending:
101
+ connection.send(message)
102
+
103
+ def invoke(operation, args, kwargs):
104
+ try:
105
+ with processing:
106
+ if operation == "editor_status":
107
+ window = instance.window
108
+ result = bool(window and window.open)
109
+ elif operation == "timing":
110
+ result = dict(timing)
111
+ else:
112
+ result = getattr(instance, operation)(*args, **kwargs)
113
+ if operation in (
114
+ "prepare",
115
+ "reset",
116
+ "load_state",
117
+ "save_state",
118
+ "set_parameter",
119
+ ):
120
+ timing.update(instance.timing())
121
+ if operation == "prepare" and hasattr(instance, "audio_buses"):
122
+ result = {**result, "buses": instance.audio_buses()}
123
+ if operation in ("process", "process_buses"):
124
+ result = {"audio": result, "timing": dict(timing)}
125
+ return {"ok": True, "result": result}
126
+ except Exception as error: # noqa: BLE001 -- serialize native errors across IPC.
127
+ return {"ok": False, "error": f"{type(error).__name__}: {error}"}
128
+
129
+ def serve_audio():
130
+ # This thread keeps receiving/processing audio even while a window manager
131
+ # runs a modal move/resize loop inside the main thread's GUI pump.
132
+ audio_ole = None
133
+ try:
134
+ audio_ole = _platform_init(audio=True)
135
+ while not stopped.is_set():
136
+ if not connection.poll(0.01):
137
+ continue
138
+ operation, args, kwargs = connection.recv()
139
+ if operation == "close":
140
+ break
141
+ # Reading editor state must not wait for a native modal dialog.
142
+ if operation in ("process", "process_buses", "timing", "editor_status"):
143
+ response = invoke(operation, args, kwargs)
144
+ else:
145
+ done, response = threading.Event(), []
146
+ jobs.put((operation, args, kwargs, done, response))
147
+ while not done.wait(0.05):
148
+ if stopped.is_set():
149
+ return
150
+ response = response[0]
151
+ send(response)
152
+ except (EOFError, BrokenPipeError, OSError):
153
+ pass
154
+ finally:
155
+ if audio_ole:
156
+ audio_ole.CoUninitialize()
157
+ stopped.set()
158
+
159
+ try:
160
+ ole = _platform_init()
161
+ if inspection:
162
+ send({"ok": True, "result": _scan(format, path)})
163
+ return
164
+ instance = _backend(format, path, plugin_id)
165
+ timing.update(instance.timing())
166
+ next_timing = time.monotonic() + 0.05
167
+ send({"ok": True, "result": instance.description})
168
+ audio_thread = threading.Thread(target=serve_audio, name="plugin-audio", daemon=True)
169
+ audio_thread.start()
170
+ while not stopped.is_set():
171
+ try:
172
+ operation, args, kwargs, done, response = jobs.get(timeout=0.002)
173
+ except queue.Empty:
174
+ pass
175
+ else:
176
+ response.append(invoke(operation, args, kwargs))
177
+ done.set()
178
+ # Lifecycle changes share a lock with processing. Never hold that lock
179
+ # across idle(): GUI dragging can keep idle() inside a modal loop.
180
+ service = getattr(instance, "service_main", None)
181
+ with processing:
182
+ if service:
183
+ service()
184
+ if time.monotonic() >= next_timing:
185
+ timing.update(instance.timing())
186
+ next_timing = time.monotonic() + 0.05
187
+ instance.idle()
188
+ if sys.platform == "darwin" and instance.window is None:
189
+ from .backends._window_cocoa import pump_events
190
+
191
+ pump_events()
192
+ except (EOFError, BrokenPipeError):
193
+ pass
194
+ except BaseException as error: # noqa: BLE001 -- report worker startup/UI failures.
195
+ try:
196
+ send({"ok": False, "error": f"{type(error).__name__}: {error}"})
197
+ except (EOFError, BrokenPipeError, OSError):
198
+ pass
199
+ finally:
200
+ stopped.set()
201
+ if audio_thread is not None:
202
+ audio_thread.join(1)
203
+ try:
204
+ with processing:
205
+ if instance is not None:
206
+ instance.close()
207
+ finally:
208
+ if ole:
209
+ ole.CoUninitialize()
210
+ connection.close()
211
+
212
+
213
+ def _terminate(process, connection):
214
+ if process.is_alive():
215
+ process.terminate()
216
+ process.join(2)
217
+ if process.is_alive():
218
+ process.kill()
219
+ process.join(2)
220
+ else:
221
+ process.join()
222
+ connection.close()
223
+
224
+
225
+ class Worker:
226
+ def __init__(self, format, path, plugin_id=None, *, timeout=30.0, inspection=False):
227
+ self.timeout = timeout
228
+ self._lock = threading.RLock()
229
+ self._closed = False
230
+ context = mp.get_context("spawn")
231
+ self._connection, child = context.Pipe()
232
+ self._process = context.Process(
233
+ target=_entry,
234
+ args=(child, format, str(path), plugin_id, inspection),
235
+ name="pyaudiohost-" + format,
236
+ daemon=True,
237
+ )
238
+ try:
239
+ self._process.start()
240
+ except RuntimeError as error:
241
+ self._connection.close()
242
+ child.close()
243
+ raise AudioHostError(
244
+ "Create plugin hosts under `if __name__ == '__main__':` when using spawn."
245
+ ) from error
246
+ child.close()
247
+ self._finalizer = weakref.finalize(self, _terminate, self._process, self._connection)
248
+ try:
249
+ self.initial = self._receive(timeout)
250
+ except BaseException:
251
+ self.close()
252
+ raise
253
+ if inspection:
254
+ self._process.join(3)
255
+ self.close()
256
+
257
+ @property
258
+ def closed(self):
259
+ return self._closed or not self._process.is_alive()
260
+
261
+ def _receive(self, timeout):
262
+ deadline = time.monotonic() + timeout
263
+ while True:
264
+ if self._connection.poll(min(0.05, max(0, deadline - time.monotonic()))):
265
+ try:
266
+ message = self._connection.recv()
267
+ except (EOFError, OSError) as error:
268
+ raise HostCrashedError(
269
+ f"Plugin worker exited (code {self._process.exitcode})"
270
+ ) from error
271
+ if not message["ok"]:
272
+ raise AudioHostError(message.get("error", "Plugin operation failed"))
273
+ return message.get("result")
274
+ if not self._process.is_alive():
275
+ raise HostCrashedError(f"Plugin worker exited (code {self._process.exitcode})")
276
+ if time.monotonic() >= deadline:
277
+ self._closed = True
278
+ self._finalizer()
279
+ raise HostTimeoutError(
280
+ f"Plugin operation timed out after {timeout:g}s; worker closed"
281
+ )
282
+
283
+ def request(self, operation, *args, timeout=None, **kwargs):
284
+ with self._lock:
285
+ if self.closed:
286
+ raise HostCrashedError("Plugin worker is closed")
287
+ try:
288
+ self._connection.send((operation, args, kwargs))
289
+ return self._receive(self.timeout if timeout is None else timeout)
290
+ except HostTimeoutError:
291
+ raise
292
+ except (OSError, EOFError) as error:
293
+ raise HostCrashedError("Cannot communicate with plugin worker") from error
294
+
295
+ def close(self):
296
+ with self._lock:
297
+ if not self._closed:
298
+ if self._process.is_alive():
299
+ try:
300
+ self._connection.send(("close", (), {}))
301
+ self._process.join(3)
302
+ except (OSError, EOFError):
303
+ pass
304
+ self._closed = True
305
+ self._finalizer()
@@ -0,0 +1 @@
1
+ """ctypes plugin ABIs; loaded only inside isolated worker processes."""