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.
- pyaudiohost/__init__.py +55 -0
- pyaudiohost/_channels.py +82 -0
- pyaudiohost/_render.py +58 -0
- pyaudiohost/_worker.py +305 -0
- pyaudiohost/backends/__init__.py +1 -0
- pyaudiohost/backends/_com.py +409 -0
- pyaudiohost/backends/_common.py +87 -0
- pyaudiohost/backends/_vst3_events.py +76 -0
- pyaudiohost/backends/_window.py +22 -0
- pyaudiohost/backends/_window_cocoa.py +80 -0
- pyaudiohost/backends/_window_win32.py +102 -0
- pyaudiohost/backends/_window_x11.py +85 -0
- pyaudiohost/backends/aax.py +43 -0
- pyaudiohost/backends/au.py +814 -0
- pyaudiohost/backends/vst2.py +495 -0
- pyaudiohost/backends/vst3.py +854 -0
- pyaudiohost/discovery.py +385 -0
- pyaudiohost/errors.py +39 -0
- pyaudiohost/host.py +576 -0
- pyaudiohost/midi.py +76 -0
- pyaudiohost/plugin.py +887 -0
- pyaudiohost-0.1.0.dist-info/METADATA +136 -0
- pyaudiohost-0.1.0.dist-info/RECORD +27 -0
- pyaudiohost-0.1.0.dist-info/WHEEL +5 -0
- pyaudiohost-0.1.0.dist-info/licenses/LICENSE +202 -0
- pyaudiohost-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +18 -0
- pyaudiohost-0.1.0.dist-info/top_level.txt +1 -0
pyaudiohost/__init__.py
ADDED
|
@@ -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
|
+
]
|
pyaudiohost/_channels.py
ADDED
|
@@ -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."""
|