From 530b9502e4add8f5a7ee817db7d0660fdb5826f2 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:33:07 -0500 Subject: [PATCH] Instruments built on two threads at once keep their own synthesizers Each Instrument adopts the Keys its factory made through a list that was module-wide; two factories on two threads could take each other's. The list is per thread now (one shared holder where there's no threading.local, as on MicroPython and CircuitPython, which build on one thread). --- CHANGELOG.md | 8 +++++ lib/audioinstruments/_support.py | 34 +++++++++++++----- tests/test_cpython_instrument_threads.py | 44 ++++++++++++++++++++++++ tests/test_scheduling_seam.py | 10 +++--- 4 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 tests/test_cpython_instrument_threads.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cb72667..6996528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ here. The two packages version and release together, from this repository; Releases up to and including audiodsp's v0.1.1 shipped both packages from there, and are recorded in its changelog. +## Unreleased + +### Fixed + +- Instruments built on two threads at the same time each keep their own + synthesizer. Before, an instrument could take the voice engine another + thread's factory had just made. + ## v0.4.1.dev1 (2026-10-15) ### Fixed diff --git a/lib/audioinstruments/_support.py b/lib/audioinstruments/_support.py index 0270525..d979505 100644 --- a/lib/audioinstruments/_support.py +++ b/lib/audioinstruments/_support.py @@ -567,7 +567,24 @@ def apply_patch(handle_event, patches, index, channel=0, note_id=-1, # `pressed`, which is everything the instruments ask a synthesizer for, and # it carries the real node for the graph. See `node()`. -_PENDING = [] +try: + from threading import local as _local +except ImportError: + # MicroPython and CircuitPython build instruments on the one interpreter + # thread, so one shared holder is per-thread already. + class _local: + pass + +# The `Keys` made since this thread's last `Instrument`: each thread's own, +# so factories running on two threads at once can't take each other's. +_BUILDING = _local() + + +def _pending(): + keys = getattr(_BUILDING, "keys", None) + if keys is None: + keys = _BUILDING.keys = [] + return keys _OPS = None @@ -960,7 +977,7 @@ def synthesizer(sample_rate, channel_count): """ keys = Keys(synthio.Synthesizer(sample_rate=sample_rate, channel_count=channel_count)) - _PENDING.append(keys) + _pending().append(keys) return keys @@ -1019,12 +1036,13 @@ def __init__(self, synth, handle_event, patches, macro_labels, capabilities=(), latency_samples=0, tail_samples=0, schedulable=True): self.synth = synth - # Every Keys built since the last Instrument belongs to this one. An - # instrument factory runs start to finish on one thread and ends by - # constructing exactly one Instrument, so the list is that factory's - # and nobody else's. - self._keys = tuple(_PENDING) - del _PENDING[:] + # Every Keys built on this thread since its last Instrument belongs + # to this one. An instrument factory runs start to finish on one + # thread and ends by constructing exactly one Instrument, so the + # thread's list is that factory's and nobody else's. + pending = _pending() + self._keys = tuple(pending) + del pending[:] # Declared separately from `_keys`, which stay reachable either way: # a probe that wants to SHOW an instrument is not schedulable has to # be able to arm the keyboards this flag refuses. diff --git a/tests/test_cpython_instrument_threads.py b/tests/test_cpython_instrument_threads.py new file mode 100644 index 0000000..c62fd45 --- /dev/null +++ b/tests/test_cpython_instrument_threads.py @@ -0,0 +1,44 @@ +"""Instruments built on two threads at once each keep their own keyboards. + +An instrument factory hands its synthesizer's `Keys` to the `Instrument` it +ends with. When two threads run factories at the same time, a `Keys` made on +one thread must not end up in the other thread's instrument. +""" +import threading +import unittest + +from audioinstruments import _support + + +def _instrument(synth): + return _support.Instrument(synth, lambda *a: None, {0: ("Default", ())}, ()) + + +class InstrumentThreadsTest(unittest.TestCase): + def test_factories_on_two_threads_keep_their_own_keyboards(self): + made_a = threading.Event() + b_done = threading.Event() + got = {} + + def factory_a(): + synth = _support.synthesizer(48000, 1) + made_a.set() + b_done.wait(5) # thread B builds a whole instrument now + got["a"] = (synth, _instrument(synth)) + + def factory_b(): + made_a.wait(5) + synth = _support.synthesizer(48000, 1) + got["b"] = (synth, _instrument(synth)) + b_done.set() + + ta = threading.Thread(target=factory_a) + tb = threading.Thread(target=factory_b) + ta.start(); tb.start(); ta.join(10); tb.join(10) + for name in ("a", "b"): + synth, inst = got[name] + self.assertEqual(inst._keys, (synth,), "instrument %s took another thread's keyboard" % name) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_scheduling_seam.py b/tests/test_scheduling_seam.py index a96d67f..06dc093 100644 --- a/tests/test_scheduling_seam.py +++ b/tests/test_scheduling_seam.py @@ -372,13 +372,13 @@ class ARefusedNoteIsCounted(unittest.TestCase): def instrument(self, *counts): """An `Instrument` over one keyboard per entry in ``counts``. - `Instrument` adopts every `Keys` built since the last one, through - `_support._PENDING`. Reaching into it is what building a two-keyboard + `Instrument` adopts every `Keys` built on this thread since the last + one, through `_support._pending()`. Reaching into it is what building a two-keyboard instrument by hand costs, and this file is that module's test. """ - del _support._PENDING[:] + del _support._pending()[:] keyboards = [_support.Keys(_Counting(count)) for count in counts] - _support._PENDING.extend(keyboards) + _support._pending().extend(keyboards) inst = _support.Instrument(keyboards[0], lambda *a: None, {}, ()) self.addCleanup(inst.deinit) return inst @@ -495,7 +495,7 @@ def plain(sample_rate, channel_count): finally: if bare: _support.synthesizer = real - del _support._PENDING[:] + del _support._pending()[:] try: out = bytearray() for index in range(self.BLOCKS):