Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 26 additions & 8 deletions lib/audioinstruments/_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions tests/test_cpython_instrument_threads.py
Original file line number Diff line number Diff line change
@@ -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()
10 changes: 5 additions & 5 deletions tests/test_scheduling_seam.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading