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
28 changes: 19 additions & 9 deletions docs/sequencing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions lib/audioinstruments/_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
104 changes: 72 additions & 32 deletions lib/audioinstruments/acoustickit.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
127, 70, 74, 120)),
}

import array
import math

import synthio
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -568,43 +592,58 @@ 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)
if voice is None:
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)
Expand Down Expand Up @@ -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)
Loading
Loading