diff --git a/docs/sequencing.md b/docs/sequencing.md index f815726..2a4e18d 100644 --- a/docs/sequencing.md +++ b/docs/sequencing.md @@ -123,14 +123,24 @@ in the table above and a table with no story in it. ## What cannot be scheduled -**`acoustickit`, and only it.** A strike there is `ModalBank.set_mode()` on a -bank that is already running, plus a press on a second synthesizer to excite -it. The retune is a C state change with no frame on it and it is shared by -every hit of that voice, so a bar scheduled ahead would retune the bank to the -last hit before the first one sounded. It declares `schedulable = False`, it -still plays live, and `Sequencer` refuses it rather than playing its part -early — which is the failure nobody would hear as a failure. All 54 others -take a frame. +**No instrument in this package.** All 55 take a frame, `acoustickit` +included — which it did not until +[#94](https://github.com/PyDevices/audiocomponents/issues/94). A strike there +is `Bank.set_mode()` on a bank that is already running: a C state change with +no frame on it, shared by every hit of that voice, so a bar scheduled ahead +retuned the bank to the last hit before the first one sounded. Measured with +the retune left on the interpreter thread and everything else scheduled, a +loud tom and a soft one laid together came out at 666 and 808 — the bar +inside out, and both ten times down from the 6981 and 2072 they should be. +The fix was an engine one: +[audiodsp#138](https://github.com/PyDevices/audiodsp/issues/138) put +`STRIKE` and `CHOKE` on the pump's queue, and the kit lays its whole mode +table on them. + +`schedulable = False` is still part of the `Instrument` contract, for a +provider outside this package whose note-on reaches the audio outside a +press. `Sequencer` refuses one rather than playing its part live and early — +the failure nobody would hear as a failure. **A macro move.** `set_macro` reaches code that builds new node objects with delay lines and filter state in them; that is not a value to store, and the @@ -193,7 +203,7 @@ with inst.scheduled(q, audiopump.now() + 24000) as tokens: inst.note_on(42, 100) inst.at(q, frame).note_on(38, 90) # the same thing, for one call -inst.schedulable # False for acoustickit, and only it +inst.schedulable # True for all 55 that ship here ``` Inside the block the instrument does **all** of its usual Python on the diff --git a/lib/audioinstruments/_support.py b/lib/audioinstruments/_support.py index 13a2334..7794556 100644 --- a/lib/audioinstruments/_support.py +++ b/lib/audioinstruments/_support.py @@ -523,6 +523,8 @@ def apply_patch(handle_event, patches, index, channel=0, note_id=-1, _OPS = None +_BANK_OPS = None + def _ops(): """(PRESS, RELEASE, RELEASE_ALL), imported the first time one is needed. @@ -536,6 +538,33 @@ def _ops(): return _OPS +def _bank_ops(): + """(STRIKE, CHOKE), imported the first time one is needed. + + Separate from `_ops` on purpose: these two arrived later (audiodsp#138) + and an engine can have the first three without them -- the pump shipped + in 0.5.1 and these did not. Asking for them only when a modal instrument + is actually armed means a kit still plays live on such a build, which is + every bit of what it could do before. + + The `AttributeError` is turned into a sentence deliberately. It surfaces + at the seam, a bar ahead of the audio, where a caller can still choose to + play live; a bare AttributeError out of a timer callback is two lines of + traceback and a bar that stops. + """ + global _BANK_OPS + if _BANK_OPS is None: + import audiopump + try: + _BANK_OPS = (audiopump.STRIKE, audiopump.CHOKE) + except AttributeError: + raise RuntimeError( + "this audiopump has no STRIKE/CHOKE, so a modal instrument " + "cannot be put on a frame (audiodsp#138). It still plays " + "live; scheduling it needs a newer audiodsp.") + return _BANK_OPS + + def node(obj): """The audio node behind ``obj`` - itself, unless it is a `Keys`.""" return obj.node if isinstance(obj, Keys) else obj @@ -606,6 +635,59 @@ def disarm(self): self._staged = () self._choked = None + # -- a modal bank, which is not pressed but retuned -------------------- + # + # A struck `audiomodal.Bank` is the one thing in this package that reaches + # the audio outside a press: `set_mode()` writes a running node's gains + # and the energy goes in on that line. It is why `acoustickit` was not + # schedulable. audiodsp#138 gave the pump a frame-stamped version of both + # halves, and these are where an instrument reaches them. + # + # They live on `Keys` because `Keys` is what knows the arm state -- the + # queue, the frame, the token list. The target is the bank, not this + # keyboard; the keyboard is the clock. + + def strike_bank(self, bank, table): + """Retune ``bank`` from ``table`` at the frame this is armed at. + + Returns False when nothing is armed, and then the caller does its own + `set_mode()` sweep -- the live path stays exactly the C calls it + always was, with no array handed across the seam and no allocation. + + ``table`` is an `array('f')` of frequency, decay and gain per mode and + **must cover every mode in the bank**. The engine silences whatever a + short table does not reach by writing frequency 0, which takes the + recursion with it and stops every other drum dead -- where this + package's own "off" keeps the pole and zeroes only the gain. A table + that stops short is a kit where one strike cuts the ringing crash. + + The copy is the same rule as `_shadow`, one layer down: the event + holds the buffer object, so a second strike laid before the first one + lands would otherwise rewrite the first one's modes. Copying here, on + the interpreter thread, is where allocating is allowed. + """ + q = self._q + if q is None: + return False + strike, _choke_op = _bank_ops() + self._tokens.append( + q.at(self._frame, strike, bank, array.array('f', table))) + return True + + def choke_bank(self, bank): + """Silence ``bank`` at the frame this is armed at -- the closing hat. + + Returns False when nothing is armed, and then the caller calls + `bank.clear()` itself. Schedule this before the `strike_bank` that + follows it: events at one frame apply in schedule order. + """ + q = self._q + if q is None: + return False + _strike, choke = _bank_ops() + self._tokens.append(q.at(self._frame, choke, bank)) + return True + @property def shadows(self): """How many scheduled notes this keyboard is still standing in for.""" @@ -793,6 +875,18 @@ def samples_signed(self): def note_info(self, note): return self.node.note_info(note) + @property + def refused(self): + """Presses this engine had no channel for -- or None if it cannot say. + + `synthio.Synthesizer.refused` (audiodsp#137), and **None rather than + 0 when it is absent**, because an engine that cannot count is not an + engine that lost nothing. An older audiodsp has no such property, and + a 0 read off it would say "your bar is fine" about a bar that dropped + nineteen hundred notes. See `Instrument.refused`. + """ + return getattr(self.node, "refused", None) + def _choke(note): """Make ``note``'s release instant, leaving the rest of its shape alone. @@ -1057,6 +1151,48 @@ def all_notes_off(self): # -- scheduling --------------------------------------------------------- + @property + def refused(self): + """Notes this instrument's engine had nowhere to put -- or None. + + A press that reaches a synthesizer with every channel held is + dropped. That is deliberate and it is the right behaviour: stealing + cannot be decided at schedule time, because the occupancy at a + *future* frame is unknowable and every number available to guess with + lies. `max_polyphony` is the wrong denominator once notes-per-key + varies with a macro, and `len(synth.pressed)` has been measured + reading 0 while a fresh press was refused. A Python-side prediction + would be that same lie one layer further from the truth. + + What was wrong was that the drop was **silent**, on both sides of the + seam (audiocomponents#96). Measured through the sequencer at the + 64-voice ceiling, two bars: `tr808` with every circuit on every + sixteenth loses nothing, `juno106` in four-note chords loses nothing, + `juno106` in eight-note chords loses **488** notes and `solina` -- + an ensemble voice -- loses **1976**. So a part one step richer than + the drum machine's own bar drops hundreds of notes and says nothing. + + Now it says. This is the sum over every keyboard the instrument + plays, so `acoustickit`'s two synthesizers count as one instrument. + + **It is not `Sequencer.health()['refused']`,** and adding the two + together would be meaningless. That one is the queue turning an + event away at the door, for want of capacity: nothing was applied. + This one is an event that applied perfectly and had no channel for + its note. Only the second is a note the player would have heard. + + None means the engine cannot answer -- an audiodsp older than + audiodsp#137 -- and it is None rather than 0 on purpose. + """ + self._check_live() + total = 0 + for keys in self._keys: + count = keys.refused + if count is None: + return None + total += count + return total + @property def schedulable(self): """Whether this instrument's notes can be put on a frame. diff --git a/lib/audioinstruments/acoustickit.py b/lib/audioinstruments/acoustickit.py index e64ddc6..c88f97f 100644 --- a/lib/audioinstruments/acoustickit.py +++ b/lib/audioinstruments/acoustickit.py @@ -138,6 +138,7 @@ 127, 70, 74, 120)), } +import array import math import synthio @@ -447,6 +448,15 @@ def layout(table): hat_bank = audiomodal.Bank(modes=hat_modes, sample_rate=SR, channel_count=channel_count) + # One mode table per bank - frequency, decay and gain for every mode - + # rewritten in place on every strike and built once, here, because a + # sequencer cannot wait for a malloc mid-bar. Played live these rows go + # into the bank a `set_mode()` at a time exactly as they always did; + # scheduled, the whole array becomes one `audiopump.STRIKE` and `Keys` + # takes its own copy. See `_support.Keys.strike_bank`. + MAIN_TABLE = array.array("f", (0.0,) * (main_modes * 3)) + HAT_TABLE = array.array("f", (0.0,) * (hat_modes * 3)) + # One excitation, split to both banks. A bank is linear, so what a strike # puts into each mode is the mode's own gain - the burst only has to carry # energy everywhere, not carry a shape. @@ -549,17 +559,31 @@ def level_for(voice): return hat_level return cymbal_level - def load(bank, slots, table, voice, velocity): - """Arm one voice and silence the rest, then the burst plays only it. + def fill(out, slots, rows, flat, voice, velocity): + """Write the whole bank's mode table for one strike of ``voice``. + Arms one voice and silences the rest, so the burst plays only it. Setting another voice's gain to zero does NOT stop it ringing: the gain is how new signal enters a mode, and a mode already in motion carries on decaying from its own state. That is the whole reason one bank can hold a kit - a crash goes on sounding underneath the next four kicks without any of them feeding it. + + Every row is written every time, the struck voice's from the kit's + tables and every other from ``flat`` at gain zero. That sweep is not + an optimisation waiting to happen: the rows a previous strike tuned + have to go back to their resting poles, and a scheduled strike sends + this array whole. + + **A muted mode keeps its pole.** Writing frequency 0 as well - which + is the obvious way to write "off" - takes the recursion with it, and + every other drum in the bank stops dead the instant this one is + struck rather than ringing on underneath. Measured when it was wrong: + a whisper-quiet kick cut a ringing crash from 7584 to 61. It is also + exactly what the engine does to modes a short table does not reach, + which is why this one always covers the bank. """ start, stop = slots[voice] - flat = MAIN_FLAT if slots is MAIN_SLOTS else HAT_FLAT tune = tune_for(voice) stretch = decay_for(voice) gain = level_for(voice) @@ -568,31 +592,40 @@ def load(bank, slots, table, voice, velocity): # away hard and it reads as a heavier stick. bite = 0.25 + 1.5 * hardness snap = 1.0 - for index, (frequency, decay, amplitude, tilt) in enumerate(table): + for index, (frequency, decay, amplitude, tilt) in enumerate(rows): if voice == "snare" and frequency >= 1500.0: snap = 0.4 + 1.2 * snare_snap scaled = (amplitude * (velocity ** (1.0 + tilt * bite)) * gain * NORM[voice]) - bank.set_mode(start + index, frequency * tune * wobble(), - decay * stretch * wobble(), scaled * snap) + at = (start + index) * 3 + # Left to right, and it matters: `wobble()` is one step of an LCG + # and the frequency has always taken the first of the pair. + out[at] = frequency * tune * wobble() + out[at + 1] = decay * stretch * wobble() + out[at + 2] = scaled * snap snap = 1.0 for index in range(0, start): - _silence(bank, flat, index) - for index in range(stop, bank.modes): - _silence(bank, flat, index) - - def _silence(bank, flat, index): - """Mute a mode without stopping it. - - The pole has to go back in unchanged. Setting the frequency to zero - here as well - which is the obvious way to write "off" - takes the - recursion with it, and every other drum in the bank stops dead the - instant this one is struck rather than ringing on underneath. Measured - when it was wrong: a whisper-quiet kick cut a ringing crash from 7584 - to 61. + at = index * 3 + out[at], out[at + 1] = flat[index] + out[at + 2] = 0.0 + for index in range(stop, len(flat)): + at = index * 3 + out[at], out[at + 1] = flat[index] + out[at + 2] = 0.0 + + def load(bank, slots, rows, flat, out, voice, velocity): + """Strike ``voice`` on ``bank`` - now, or at the frame we are armed at. + + `strike_bank` answers False when nothing has armed the keyboard, and + then this is the `set_mode()` sweep it always was: the live path + hands no array across the seam and allocates nothing. """ - frequency, decay = flat[index] - bank.set_mode(index, frequency, decay, 0.0) + fill(out, slots, rows, flat, voice, velocity) + if synth.strike_bank(bank, out): + return + for index in range(len(flat)): + at = index * 3 + bank.set_mode(index, out[at], out[at + 1], out[at + 2]) def strike(pitch, velocity): voice = PITCH_VOICE.get(pitch) @@ -600,11 +633,17 @@ def strike(pitch, velocity): return if voice in HAT_VOICES: # Closing a hi-hat silences what the open one was doing. This is - # the choke, and it is why the hat has a bank to itself. - hat_bank.clear() - load(hat_bank, HAT_SLOTS, HAT_VOICES[voice], voice, velocity) + # the choke, and it is why the hat has a bank to itself. It goes + # on the queue BEFORE the strike that follows it: events at one + # frame apply in schedule order, and a choke after its own strike + # would wipe the hit it was meant to make room for. + if not synth.choke_bank(hat_bank): + hat_bank.clear() + load(hat_bank, HAT_SLOTS, HAT_VOICES[voice], HAT_FLAT, HAT_TABLE, + voice, velocity) else: - load(main_bank, MAIN_SLOTS, VOICES[voice], voice, velocity) + load(main_bank, MAIN_SLOTS, VOICES[voice], MAIN_FLAT, MAIN_TABLE, + voice, velocity) rows = LAYERS.get(voice) if rows is not None: gain = level_for(voice) @@ -676,12 +715,13 @@ def handle_event(event_type, channel, note_id, data0, value0, value1, elif data0 == 15: cymbal_level = value - # NOT SCHEDULABLE, and the reason is `strike()` above: a hit here is + # SCHEDULABLE, and it took an engine change to get here. A hit is # `bank.set_mode()` on a modal bank that is already running, which injects - # the energy the moment it is called. That is a C state change, not a - # press, so no queue can hold it back - and deferring only the two - # `direct.press()` calls would split one drum in half. See - # docs/spikes/live-audio-path-sequenced.md. + # the energy the moment it is called - a C state change rather than a + # press, and for a long time no queue could hold it back. audiodsp#138 + # gave the pump both halves with a frame on them, `STRIKE` and `CHOKE`, + # so `load()` above lays the whole mode table on the queue instead and + # the hat's choke goes with it. The layer notes and the stick were always + # presses and always rode the seam. See docs/sequencing.md. return Instrument(synth, handle_event, PATCHES, MACRO_LABELS, - output=mixer, transport=transport, note_map=NOTE_MAP, - schedulable=False) + output=mixer, transport=transport, note_map=NOTE_MAP) diff --git a/lib/audioinstruments/sequencer.py b/lib/audioinstruments/sequencer.py index c6ef034..9904a69 100644 --- a/lib/audioinstruments/sequencer.py +++ b/lib/audioinstruments/sequencer.py @@ -233,18 +233,47 @@ def health(self): `refused` is the one that is a dropped step, and it has a warning of its own - see `on_refused`. + `unvoiced` is the other dropped note, and it is a **different** + failure with the same feel (audiocomponents#96). `refused` is the + queue turning an event away at the door, for want of capacity: + nothing was applied. `unvoiced` is an event that applied perfectly + and found every channel held, so the note was dropped by the engine. + Adding the two together would be meaningless; showing only one of + them is how a bar loses 488 notes quietly. Both are notes nobody + heard, and the cure is different for each - a deeper queue for the + first, fewer voices or a higher ceiling for the second. + scheduled events laid on the queue, ever refused events the queue turned away: steps not heard + unvoiced notes the engine had no channel for, over the + tracked instruments -- or None where the engine + cannot say (an audiodsp older than audiodsp#137) cancelled events taken back by a tempo change or `stop()` reentered ticks that arrived while this one was writing needed the depth these tracks want (`depth_needed`) capacity the depth this queue has, or None """ return {"scheduled": self.scheduled, "refused": self.refused, + "unvoiced": self.unvoiced(), "cancelled": self.cancelled, "reentered": self.reentered, "needed": self.depth_needed(), "capacity": self.queue_capacity()} + def unvoiced(self): + """Notes the engine dropped for want of a channel, over every track. + + None when any tracked instrument's engine cannot say, because a + partial total would read as a small number rather than as an unknown + one -- and a small number here is the answer an app wants to see. + """ + total = 0 + for instrument, _pattern in self.tracks: + count = instrument.refused + if count is None: + return None + total += count + return total + def queue_capacity(self): """The queue's capacity, or None when it will not say. diff --git a/tests/parity/scheduling_seam_live.py b/tests/parity/scheduling_seam_live.py index ab96d7c..2e64900 100644 --- a/tests/parity/scheduling_seam_live.py +++ b/tests/parity/scheduling_seam_live.py @@ -3,10 +3,12 @@ MICROPYPATH=:/lib /cmods/bin/micropython \ tests/parity/scheduling_seam_live.py [case ...] [--fault WHICH] -Cases: ``frame``, ``velocity``, ``silence``, ``wire`` (all by default). +Cases: ``frame``, ``velocity``, ``silence``, ``wire``, ``kit``, +``kitlevels``, ``kitchoke`` (all by default). Faults: ``early`` (frame), ``flat`` (velocity), ``held`` (silence), -``armed`` (wire). Each one must make this exit non-zero; a probe whose -failing mode is never run is not a gate. +``armed`` (wire), ``unstruck`` (kit), ``even`` (kitlevels), ``ringing`` +(kitchoke). Each one must make this exit non-zero; a probe whose failing +mode is never run is not a gate. Why it exists ------------- @@ -220,8 +222,105 @@ def maybe_armed(queue, frame_of): % (len(plain), plain == other)) +# --- the modal kit, whose strike is not a press ----------------------------- +# +# `acoustickit` is the one instrument here whose hit reaches the audio outside +# a press: a strike is `Bank.set_mode()` on a running node and the energy goes +# in on that line. audiodsp#138 gave the pump `STRIKE` and `CHOKE` with frames +# on them and audiocomponents#94 made the kit emit them, so these three ask +# the same questions of a retune that the four above ask of a press. +# +# Every case strikes a TOM, and that is the measurement, not a taste in drums. +# Only `kick`, `snare` and `sidestick` carry layer notes played straight into +# the mixer; a tom is the bank and nothing else, and the excitation burst is +# not in the mixer at all. So a tom makes a sound if and only if a `STRIKE` +# reached the bank, and a case that passed with the strike missing could not +# happen quietly. + + +def kit(): + return audioinstruments.create("acoustickit", RATE) + + +def kit_frame(fault): + """A kit's strike carries a frame: the bank is retuned by the pump.""" + inst = kit() + at_block = 0 if fault == "unstruck" else 20 + + def lay(queue, frame_of): + with inst.scheduled(queue, frame_of(at_block)): + inst.note_on(45, 127) # Low Tom: bank-only + + pcm, _pulled = render(inst, 40, lay) + inst.deinit() + window = peaks(pcm, BLOCK_FRAMES) + before = max(window[:20]) if len(window) >= 20 else 0 + after = max(window[20:]) if len(window) > 20 else 0 + return say("kit", before == 0 and after > 1000, + "loudest sample before block 20: %d (want 0), after: %d " + "(want a hit)" % (before, after)) + + +def kit_levels(fault): + """Two strikes laid before either sounds keep their own levels. + + This is the case that a frame-stamped `STRIKE` is *for*, and the one a + retune at schedule time cannot pass: both sweeps would have run on this + thread before the first stick arrived, so the downbeat would sound with + the second hit's gains. Measured by declining `strike_bank` and leaving + everything else scheduled - the old behaviour exactly - the loud hit came + out at 666 and the soft one at 808, the bar inside out and both of them + ten times down from 6981 and 2072. + """ + inst = kit() + second = 127 if fault == "even" else 32 + + def lay(queue, frame_of): + with inst.scheduled(queue, frame_of(4)): + inst.note_on(45, 127) + with inst.scheduled(queue, frame_of(24)): + inst.note_on(45, second) + + pcm, _pulled = render(inst, 44, lay) + inst.deinit() + window = peaks(pcm, BLOCK_FRAMES) + loud = max(window[4:20]) if len(window) > 20 else 0 + quiet = max(window[24:40]) if len(window) > 40 else 0 + return say("kitlevels", loud > 1000 and quiet > 0 and loud > quiet * 2, + "tom at 127 peaks %d, tom at 32 peaks %d (want the first at " + "least twice the second)" % (loud, quiet)) + + +def kit_choke(fault): + """A closing hi-hat silences the open one, at the closing hat's frame. + + Live this is `Bank.clear()`. Scheduled it is `audiopump.CHOKE`, laid at + the same frame as the strike that follows it and before it, because + events at one frame apply in schedule order. + """ + inst = kit() + + def lay(queue, frame_of): + with inst.scheduled(queue, frame_of(2)): + inst.note_on(46, 127) # Open Hi-Hat: a long voice + if fault != "ringing": + with inst.scheduled(queue, frame_of(16)): + inst.note_on(42, 20) # Closed Hi-Hat, soft + + pcm, _pulled = render(inst, 40, lay) + inst.deinit() + window = peaks(pcm, BLOCK_FRAMES) + struck = max(window[2:16]) if len(window) > 16 else 0 + tail = max(window[24:]) if len(window) > 24 else 0 + return say("kitchoke", struck > 1000 and tail < struck // 20, + "the open hat peaks %d, the tail 8 blocks after the closing " + "hat peaks %d (want under %d)" % (struck, tail, struck // 20)) + + CASES = (("frame", frame), ("velocity", velocity), - ("silence", silence), ("wire", wire)) + ("silence", silence), ("wire", wire), + ("kit", kit_frame), ("kitlevels", kit_levels), + ("kitchoke", kit_choke)) def main(): diff --git a/tests/support/fake_pump.py b/tests/support/fake_pump.py index 7062b05..7d231f1 100644 --- a/tests/support/fake_pump.py +++ b/tests/support/fake_pump.py @@ -11,8 +11,8 @@ rather than raising when the queue is full; * ``cancel()`` is True only while the event is still pending; * :meth:`Events.apply` is ``audiopump_events_apply``: every event inside - ``[now, now + block)`` is applied in frame order, one press, release or - release-all per event, and late ones are counted; + ``[now, now + block)`` is applied in frame order, one press, release, + release-all, strike or choke per event, and late ones are counted; * ``stats()`` returns the same eight numbers in the same order. **What a fake cannot catch.** Whether the real pump reaches those frames on @@ -34,6 +34,16 @@ PLAY = 4 STOP = 5 LEVEL = 6 +#: ``audiomodal.Bank``, a mode table validated and flattened at schedule time +#: (audiodsp#138). A strike on a modal bank is a retune of a running node, not +#: a press, and these are how it gets a frame. ``acoustickit`` is the one +#: instrument here that emits them -- audiocomponents#94. +STRIKE = 7 +CHOKE = 8 + +#: ``AUDIODSP_MODAL_MIN_DECAY`` (``audiodsp_modal.h``). What the engine puts +#: in a mode the table did not reach, alongside a frequency of 0. +MIN_DECAY = 0.001 #: ``audiopump.STATUS_WORDS`` -- the pump's status block, unused here but #: part of the surface a caller may look for. @@ -136,6 +146,10 @@ def apply(self, now, block_frames=256): event.target.release(event.arg) elif event.op == RELEASE_ALL: event.target.release_all() + elif event.op == STRIKE: + _strike(event.target, event.arg) + elif event.op == CHOKE: + event.target.clear() applied += 1 self.applied_count += applied return applied @@ -163,9 +177,44 @@ def _validate(self, op, target, arg): raise TypeError("a scheduled note is a Note or a MIDI number" " -- an iterable of them is several events") return + if op in (STRIKE, CHOKE): + # Everything that can refuse happens here, as it does in the C: + # the apply half reads floats and stores, and a table that + # reached it in the wrong shape would have to raise on the pump + # thread, which is the one thing the queue exists to prevent. + if not hasattr(target, "set_mode"): + raise TypeError("strike/choke want an audiomodal.Bank") + if op == CHOKE: + return + if getattr(arg, "typecode", None) != "f": + raise TypeError("a strike's table is an array('f') of " + "frequency, decay, gain per mode") + if len(arg) == 0 or len(arg) % 3: + raise ValueError("a strike's table needs three floats a mode") + if len(arg) // 3 > target.modes: + raise ValueError("table has %d modes, bank holds %d" + % (len(arg) // 3, target.modes)) + return raise ValueError("unknown op") +def _strike(bank, table): + """``audiopump_apply_strike``: three floats a mode, then silence the rest. + + The second loop is the part worth having in a stand-in. A mode the table + does not reach gets frequency 0, which takes the recursion with it and + stops that mode dead -- where an instrument's own "off" keeps the pole + and zeroes only the gain. An instrument that sends a short table is + asking for a smaller drum, not a quieter one, and only this will say so. + """ + count = len(table) // 3 + for index in range(count): + bank.set_mode(index, table[index * 3], table[index * 3 + 1], + table[index * 3 + 2]) + for index in range(count, bank.modes): + bank.set_mode(index, 0.0, MIN_DECAY, 0.0) + + class _Clock: """``audiopump.now()`` -- frames pulled, which a test moves by hand.""" @@ -183,8 +232,9 @@ def install(): """Put this module on ``sys.modules`` as ``audiopump`` and reset it. `audioinstruments._support` caches ``(PRESS, RELEASE, RELEASE_ALL)`` the - first time an instrument is scheduled, so the module has to be in place - before that and has to stay the same object afterwards. + first time an instrument is scheduled, and ``(STRIKE, CHOKE)`` the first + time a modal one is, so the module has to be in place before that and has + to stay the same object afterwards. """ sys.modules.setdefault("audiopump", sys.modules[__name__]) now.frame = 0 diff --git a/tests/test_scheduling_seam.py b/tests/test_scheduling_seam.py index 0976022..4e1a158 100644 --- a/tests/test_scheduling_seam.py +++ b/tests/test_scheduling_seam.py @@ -322,16 +322,115 @@ def test_the_three_that_change_what_sounds_are_refused(self): name + " forwards past the scheduling seam") -class NotEveryInstrumentCanBeScheduled(unittest.TestCase): +class _Counting: + """A synthesizer stand-in that can, or cannot, count refused presses. + + `synthio.Synthesizer.refused` arrived in audiodsp#137 and this + repository's `AUDIODSP_PIN` is older, so the engine CI runs against + cannot answer. Testing the reading against whatever engine happens to be + installed would mean this file proves one thing today and the other + thing after the pin moves, and says nothing either way -- so the subject + is a stand-in with the property and one without it. + """ + + sample_rate = RATE + channel_count = 2 + blocks = () + pressed = () + + def __init__(self, refused=None): + if refused is not None: + self.refused = refused + + def press(self, note): + pass + + def release(self, note): + pass + + def release_all(self): + pass + + def deinit(self): + pass + - def test_acoustickit_says_no(self): - inst = audioinstruments.create("acoustickit", RATE) +class ARefusedNoteIsCounted(unittest.TestCase): + """audiocomponents#96: a press the engine had no channel for. + + Stealing cannot be decided at schedule time -- the occupancy at a future + frame is unknowable and every number available to guess with lies -- so + the library refuses, which is what it always did. What it did not do was + say so. Measured on the real pump at the 64-voice ceiling, two bars at + 200 BPM: `tr808` and a four-note `juno106` lose nothing, an eight-note + `juno106` loses **488** notes and `solina` loses 1796, and before this + nothing on either side of the seam could see one of them. + """ + + 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 by hand costs, and this file is that module's test. + """ + del _support._PENDING[:] + keyboards = [_support.Keys(_Counting(count)) for count in counts] + _support._PENDING.extend(keyboards) + inst = _support.Instrument(keyboards[0], lambda *a: None, {}, ()) self.addCleanup(inst.deinit) - self.assertFalse(inst.schedulable) + return inst - def test_it_refuses_with_a_sentence(self): - inst = audioinstruments.create("acoustickit", RATE) + def test_an_engine_that_cannot_count_says_None_rather_than_zero(self): + """0 would read as "your bar is fine" about a bar that lost 488.""" + self.assertIsNone(self.instrument(None).refused) + + def test_the_count_comes_off_the_engine(self): + self.assertEqual(488, self.instrument(488).refused) + + def test_it_is_summed_over_every_keyboard_the_instrument_plays(self): + """`acoustickit` has two synthesizers and is one instrument.""" + self.assertEqual(19, self.instrument(12, 7).refused) + + def test_one_keyboard_that_cannot_say_makes_the_whole_answer_unknown(self): + """A partial total would read as a small number, not an unknown one, + and a small number here is the answer an app is hoping for.""" + self.assertIsNone(self.instrument(12, None).refused) + + def test_a_keyboard_forwards_what_its_node_says(self): + self.assertEqual(3, _support.Keys(_Counting(3)).refused) + self.assertIsNone(_support.Keys(_Counting()).refused) + + +class AnInstrumentCanStillRefuseToBeScheduled(unittest.TestCase): + """`schedulable=False` is part of the `Instrument` contract. Nothing + shipped uses it any more, and the subject here is a stand-in that does. + + `acoustickit` was the one, because a strike there is `Bank.set_mode()` on + a running node rather than a press. audiodsp#138 gave the pump a + frame-stamped `STRIKE` and `CHOKE` and audiocomponents#94 made the kit + emit them, so the kit carries a frame now like everything else. + + These two tests used to name the kit, and that is worth not repeating: + a refusal tested against whichever shipped instrument happens not to + have been fixed yet goes quietly vacuous on the day it is fixed, and a + passing suite is what you get either way. The flag is for a provider + whose note-on reaches the audio outside a press -- which is a thing + outside this package can still be -- so the subject declares it. + """ + + def unschedulable(self): + synth = _support.synthesizer(RATE, 2) + inst = _support.Instrument(synth, lambda *a: None, {}, (), + schedulable=False) self.addCleanup(inst.deinit) + return inst + + def test_it_says_no(self): + self.assertFalse(self.unschedulable().schedulable) + + def test_it_refuses_with_a_sentence(self): + inst = self.unschedulable() queue = fake_pump.Events(capacity=8) with self.assertRaises(RuntimeError) as caught: inst.at(queue, 6000).note_on(38, 100) @@ -342,13 +441,23 @@ def test_it_refuses_with_a_sentence(self): self.assertEqual(queue.pending(), 0, "a refused instrument still wrote to the queue") - def test_the_drum_machines_and_the_melodic_voices_can(self): - for name in ("tr808", "tr909", "karplus", "dx7", "juno106"): + def test_every_instrument_this_package_ships_can_be_scheduled(self): + """All 55 of them, the kit included -- which is audiocomponents#94. + + The whole list rather than a handful: the claim worth holding is + that nothing here plays a bar live and early, and a sample of five + cannot say that. + """ + refused = [] + for name in audioinstruments.ALL: inst = audioinstruments.create(name, RATE) try: - self.assertTrue(inst.schedulable, name + " is not schedulable") + if not inst.schedulable: + refused.append(name) finally: inst.deinit() + self.assertEqual([], refused, + "these ship unschedulable: " + ", ".join(refused)) class PlayingLiveThroughTheKeyboardIsAWire(unittest.TestCase): diff --git a/tests/test_sequencer.py b/tests/test_sequencer.py index 681ab5f..01e51aa 100644 --- a/tests/test_sequencer.py +++ b/tests/test_sequencer.py @@ -24,6 +24,7 @@ import audioinstruments # noqa: E402 from audioinstruments.sequencer import Sequencer # noqa: E402 +from audioinstruments import _support # noqa: E402 RATE = 48000 BLOCK = 256 @@ -271,25 +272,115 @@ def test_start_again_picks_the_groove_up_where_it_was(self): "restarting at step 5 re-played a step that had gone by") +class _Track: + """A real instrument with the refusal count answered by hand. + + `health()` has to carry a number the engine CI runs against cannot + produce -- `synthio.Synthesizer.refused` is newer than `AUDIODSP_PIN` -- + so what is proved here is the plumbing and the separation, and the + numbers themselves are measured on the real pump. + + Everything but the number is a `tr808`, because `health()` calls + `depth_needed()`, which lays the grid: a bare stand-in with no + `scheduled()` does not survive being asked how deep a queue it wants. + """ + + def __init__(self, test, refused): + self._inst = audioinstruments.create("tr808", RATE) + test.addCleanup(self._inst.deinit) + self.refused = refused + + def __getattr__(self, name): + return getattr(self._inst, name) + + +class HealthSeparatesTheTwoWaysANoteIsLost(unittest.TestCase): + """audiocomponents#96. `refused` is the queue turning an event away at + the door for want of capacity: nothing was applied. `unvoiced` is an + event that applied perfectly and found every channel held. + + They are different failures with different cures -- a deeper queue, or + fewer voices -- and a `solina` bar measured on the real pump reads 1520 + and 1796 at the same time, so an app that added them would print a + number that means nothing. + """ + + def sequencer(self): + return Sequencer(fake_pump.Events(capacity=64), sample_rate=RATE, + now=fake_pump.now) + + def test_unvoiced_is_summed_over_the_tracks(self): + seq = self.sequencer() + seq.track(_Track(self, 488), dict(PATTERN)) + seq.track(_Track(self, 12), dict(PATTERN)) + self.assertEqual(500, seq.unvoiced()) + self.assertEqual(500, seq.health()["unvoiced"]) + + def test_an_engine_that_cannot_count_leaves_it_unknown(self): + seq = self.sequencer() + seq.track(_Track(self, None), dict(PATTERN)) + self.assertIsNone(seq.health()["unvoiced"]) + + def test_one_track_that_cannot_say_is_not_a_partial_total(self): + seq = self.sequencer() + seq.track(_Track(self, 488), dict(PATTERN)) + seq.track(_Track(self, None), dict(PATTERN)) + self.assertIsNone(seq.health()["unvoiced"]) + + def test_it_is_a_different_number_from_the_queues_own_refusal(self): + seq = self.sequencer() + seq.track(_Track(self, 488), dict(PATTERN)) + health = seq.health() + self.assertEqual(0, health["refused"], "nothing was turned away") + self.assertEqual(488, health["unvoiced"], "but 488 were not heard") + + def test_a_real_instrument_answers_it_too(self): + """Whatever engine is installed: a number, or None where it cannot + say. What must never happen is the key being absent.""" + rig = Transport(self) + self.assertIn("unvoiced", rig.seq.health()) + count = rig.seq.health()["unvoiced"] + self.assertTrue(count is None or count >= 0) + + class ATrackHasToBeSchedulable(unittest.TestCase): + """The subject is an instrument that declares `schedulable=False`. + + It used to be `acoustickit`, the one shipped instrument that could not + carry a frame; audiocomponents#94 made it able to, and a refusal test + whose subject stops refusing passes for the wrong reason without saying + so. `tests/test_scheduling_seam.py` holds the other half of that -- that + every instrument this package ships can now be tracked. + """ + + def unschedulable(self): + synth = _support.synthesizer(RATE, 2) + inst = _support.Instrument(synth, lambda *a: None, {}, (), + schedulable=False) + self.addCleanup(inst.deinit) + return inst def test_an_unschedulable_instrument_is_refused_with_a_sentence(self): queue = fake_pump.Events(capacity=64) seq = Sequencer(queue, sample_rate=RATE, now=fake_pump.now) - kit = audioinstruments.create("acoustickit", RATE) - self.addCleanup(kit.deinit) with self.assertRaises(ValueError) as caught: - seq.track(kit, dict(PATTERN)) + seq.track(self.unschedulable(), dict(PATTERN)) self.assertIn("cannot be scheduled", str(caught.exception)) self.assertEqual(seq.tracks, [], "the refused instrument was tracked anyway") def test_retrack_refuses_one_too(self): + rig = Transport(self) + with self.assertRaises(ValueError): + rig.seq.retrack(self.unschedulable()) + + def test_the_kit_can_be_tracked_now(self): + """audiocomponents#94, at the seam a drum machine actually uses.""" rig = Transport(self) kit = audioinstruments.create("acoustickit", RATE) self.addCleanup(kit.deinit) - with self.assertRaises(ValueError): - rig.seq.retrack(kit) + rig.seq.retrack(kit) + self.assertIs(kit, rig.seq.tracks[0][0]) class AFullQueueIsCounted(unittest.TestCase): @@ -491,12 +582,20 @@ def test_health_carries_the_counter_that_had_no_reader(self): Kept because a board proved it fires; not shown as a dropped step, because it is not one. The dict is the surface an app puts on a screen, and `refused` is the entry that means a step was not heard. + + `unvoiced` is the *other* entry that means that, and it is a + different failure (audiocomponents#96): `refused` is the queue + turning an event away at the door, `unvoiced` is an event that + applied and found every channel held. The key set is pinned here + because this dict is a surface an app lays out, so adding to it is + a decision and not a side effect. """ _heard, seq, _q = self.bar("tr808", FULL_BAR, 200, at_call=6, capacity=96) row = seq.health() - self.assertEqual(set(row), {"scheduled", "refused", "cancelled", - "reentered", "needed", "capacity"}) + self.assertEqual(set(row), {"scheduled", "refused", "unvoiced", + "cancelled", "reentered", "needed", + "capacity"}) self.assertGreater(row["reentered"], 0, row) self.assertEqual(row["refused"], 0, "the app's own capacity dropped a step")