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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ there, and are recorded in its changelog.
`~/soundfonts/`; samples are read as notes need them. Exclusive classes
choke, 32 voices with oldest-releasing stealing, four macros. Neither is
in `ALL`, since both need a bank on disk.
- A Pan macro (index 4, bipolar: 64 centre, 0 hard left, 127 hard right) on
`soundfont` and `soundfont_drums`. It offsets each region's own pan, so a
stereo pair keeps its spread around the new centre, clamped at the edges;
moving it re-pans the voices already sounding, without a click. Control
Change 10 on either instrument drives it.

### Fixed

Expand Down
73 changes: 65 additions & 8 deletions lib/audioinstruments/soundfont.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@
far quieter and more uneven between presets than their authors heard. The
one constant is the whole convention. Velocity is a square law on top.

Five macros: Volume, Attack and Release (scaling the bank's own envelope,
64 is as written), Tune (bipolar, two semitones each way) and Pan. Pan is
bipolar too: 64 is centre, 0 hard left, 127 hard right. It is an offset
added to each region's own pan, so a stereo pair keeps its spread around
the new centre, clamped at the edges. Moving it re-pans the voices already
sounding, with the engine's ramp, and Control Change 10 on the instrument
drives it, as it does Bank Select; the other GM controllers are the
host's. A program change puts every macro back to the patch's defaults,
Pan at centre.

Thirty-two engine voices, one per region a note resolves to (a stereo pair
is two). A new note with every voice busy steals the oldest releasing voice,
then the oldest. A note in an exclusive class stops the sounding notes of
Expand All @@ -42,24 +52,29 @@
VERSION = '0.0.1'
VENDOR = "PyDevices"

MACRO_LABELS = ("Volume", "Attack", "Release", "Tune")
MACRO_LABELS = ("Volume", "Attack", "Release", "Tune", "Pan")
MACRO_MODES = {
0: "UNIPOLAR",
1: "UNIPOLAR",
2: "UNIPOLAR",
3: "BIPOLAR",
4: "BIPOLAR",
}

#: The macro values every patch carries: the bank's own envelope (Attack and
#: Release at 64 scale it by 1.0), no detune, volume a little under full.
DEFAULT_MACROS = (100, 64, 64, 64)
#: Release at 64 scale it by 1.0), no detune, centre pan, volume a little
#: under full.
DEFAULT_MACROS = (100, 64, 64, 64, 64)

#: Patch 0 as a literal for the scanners; a live instrument's `patches` are
#: the bank's presets, built in `create()`.
PATCHES = {
0: ('Program 0', (100, 64, 64, 64)),
0: ('Program 0', (100, 64, 64, 64, 64)),
}

#: The Pan macro's index, for hosts that drive it by number.
PAN_MACRO = 4

#: The power `initialAttenuation`'s linear gain is raised to. See the module
#: docstring.
ATTENUATION_SCALE = 0.4
Expand All @@ -85,6 +100,26 @@

_CC_BANK_MSB = 0
_CC_BANK_LSB = 32
_CC_PAN = 10


def _bipolar(value0):
"""A bipolar macro's -1..1 from the 0..1 the event carries: the centre
detent law of `audioeffects._component.position_of_midi`, so MIDI 64 is
exactly 0 and 0 and 127 are the ends."""
midi = value0 * 127.0
if midi >= 64.0:
return (midi - 64.0) / 63.0
return (midi - 64.0) / 64.0


def _panned(region_pan, offset):
pan = region_pan + offset
if pan < -1.0:
return -1.0
if pan > 1.0:
return 1.0
return pan


# --- finding and sharing a bank --------------------------------------------
Expand Down Expand Up @@ -202,8 +237,9 @@ class _Player(object):
"""The voices of one instrument on its sampler.

`slots[i]` is engine voice i: `None` when free, else
`[key, serial, releasing, exclusive_class, level, pitch]` where `level`
and `pitch` are the region's own, before the live macros.
`[key, serial, releasing, exclusive_class, level, pitch, pan]` where
`level`, `pitch` and `pan` are the region's own, before the live
macros.
"""

def __init__(self, bank, sampler, default_bank=0):
Expand All @@ -222,6 +258,7 @@ def __init__(self, bank, sampler, default_bank=0):
self.release_scale = 1.0
self.tune = 1.0
self.bend = 1.0
self.pan = 0.0

# -- preset selection ---------------------------------------------------

Expand Down Expand Up @@ -324,14 +361,15 @@ def note_on(self, key, pitch, velocity, detune):
pcm, rate=region.sample_rate,
pitch=ratio * self.tune * self.bend,
loop_start=loop_start, loop_end=loop_end, loop_mode=mode,
level=level * self.volume, pan=region.pan,
level=level * self.volume,
pan=_panned(region.pan, self.pan),
delay=region.delay,
attack=region.attack * self.attack_scale,
hold=region.hold, decay=region.decay,
sustain=region.sustain,
release=region.release * self.release_scale)
self.slots[i] = [key, serial, False, region.exclusive_class,
level, ratio]
level, ratio, region.pan]
indices.append(i)
self.held[key] = indices

Expand Down Expand Up @@ -374,6 +412,13 @@ def repitch(self):
if slot is not None:
voices[i].pitch = slot[5] * factor

def repan(self):
voices = self.sampler.voice
offset = self.pan
for i, slot in enumerate(self.slots):
if slot is not None:
voices[i].pan = _panned(slot[6], offset)

# -- events -------------------------------------------------------------

def handle_event(self, event_type, channel, note_id, data0, value0,
Expand All @@ -394,6 +439,9 @@ def handle_event(self, event_type, channel, note_id, data0, value0,
elif data0 == 3:
self.tune = 2.0 ** ((value0 - 64 / 127.0) * 2.0 / 12.0)
self.repitch()
elif data0 == PAN_MACRO:
self.pan = _bipolar(value0)
self.repan()
elif event_type == EVENT_PITCH_BEND:
self.bend = 2.0 ** (value1 * BEND_RANGE / 12.0)
self.repitch()
Expand Down Expand Up @@ -435,6 +483,15 @@ def preset(self):
def preset_name(self):
return self._player.bank.presets.get(self.preset)

def control_change(self, controller, value, channel=0, sample_position=0):
"""Control Change 10 (Pan) moves the Pan macro, so the macro value
and the sound agree; the rest reach the player as they are."""
if controller == _CC_PAN:
self.set_macro(PAN_MACRO, value, channel=channel,
sample_position=sample_position)
return
super().control_change(controller, value, channel, sample_position)

def program_change(self, index, channel=0, note_id=-1, sample_position=0):
target = self._programs.get(index) if isinstance(index, int) else None
if target is not None:
Expand Down
10 changes: 8 additions & 2 deletions lib/audioinstruments/soundfont_drums.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
and pedal hi-hat (44) stop an open hi-hat (46) that is still ringing when
the bank puts them in one exclusive class, as General MIDI kits do.

The macros are `soundfont`'s: Volume, Attack, Release, Tune and Pan. Pan
(64 centre, 0 left, 127 right) is an offset on each zone's own pan, so a
kit's spread moves as one; it re-pans sounding voices, and Control Change
10 drives it.

`NOTE_MAP` is the whole GM percussion map rather than what any one kit
answers: a bank's kits differ in what they fill, and the map is what a
sequencer writes for.
Expand All @@ -23,18 +28,19 @@
VERSION = '0.0.1'
VENDOR = "PyDevices"

MACRO_LABELS = ("Volume", "Attack", "Release", "Tune")
MACRO_LABELS = ("Volume", "Attack", "Release", "Tune", "Pan")
MACRO_MODES = {
0: "UNIPOLAR",
1: "UNIPOLAR",
2: "UNIPOLAR",
3: "BIPOLAR",
4: "BIPOLAR",
}

#: Patch 0 as a literal for the scanners; a live kit's `patches` are the
#: bank's, built in `create()`.
PATCHES = {
0: ('Standard', (100, 64, 64, 64)),
0: ('Standard', (100, 64, 64, 64, 64)),
}

#: General MIDI percussion, keys 35-81, under GM's names.
Expand Down
147 changes: 142 additions & 5 deletions tests/test_cpython_soundfont.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
The plumbing, without a real bank: how the bank is found, the patch list
and pad map a bank becomes, one voice per region and a stereo layer as two,
the velocity split, release, the exclusive-class choke, Bank Select, voice
stealing, and the macros reaching held voices. The General MIDI gate on a
real bank is `test_cpython_soundfont_gm.py`.
stealing, the macros reaching held voices, and Pan: where the energy lands
per channel, a sounding note moving, a stereo pair keeping its spread (on a
sibling bank whose pair is not already at the edges), and Control Change
10 driving it. The General MIDI gate on a real bank is
`test_cpython_soundfont_gm.py`.
"""

import os
Expand Down Expand Up @@ -107,9 +110,9 @@ def test_patches_and_pads_come_from_the_bank(self):
self.assertEqual(self.kit.note_map, soundfont_drums.NOTE_MAP)
self.assertIsNone(self.inst.note_map)
for module in (soundfont, soundfont_drums):
self.assertEqual(len(module.MACRO_LABELS), 4)
self.assertEqual(set(module.MACRO_MODES), set(range(4)))
self.assertEqual(len(module.PATCHES[0][1]), 4)
self.assertEqual(len(module.MACRO_LABELS), 5)
self.assertEqual(set(module.MACRO_MODES), set(range(5)))
self.assertEqual(len(module.PATCHES[0][1]), 5)

def test_both_are_live_instruments(self):
for inst in (self.inst, self.kit):
Expand Down Expand Up @@ -225,6 +228,140 @@ def test_macros_reach_held_voices(self):
self.inst.set_macro(2, 0)
self.assertAlmostEqual(player.release_scale, 0.25, places=2)

# -- pan ------------------------------------------------------------------

PAN = soundfont.PAN_MACRO

def _channels(self, inst, seconds=0.1):
"""(left RMS, right RMS) of the next `seconds`."""
both = sf2_play.render(inst, seconds)
return sf2_play.rms(both[0::2]), sf2_play.rms(both[1::2])

def test_pan_is_the_fifth_macro_on_both(self):
for inst in (self.inst, self.kit):
self.assertEqual(inst.macro_labels[self.PAN], "Pan")
self.assertEqual(inst.get_macro(self.PAN), 64)
self.assertEqual(soundfont.MACRO_MODES[self.PAN], "BIPOLAR")
self.assertEqual(soundfont_drums.MACRO_MODES[self.PAN], "BIPOLAR")
self.assertEqual(soundfont.DEFAULT_MACROS[self.PAN], 64)

def test_pan_puts_the_energy_where_it_says(self):
# The kit's snare has no pan of its own, so centre is equal.
kit = self.kit
kit.note_on(36)
left, right = self._channels(kit)
self.assertGreater(left, 0.0005)
self.assertAlmostEqual(left, right, delta=left * 0.02)
kit.note_off(36)
sf2_play.render(kit, 1.0)
kit.set_macro(self.PAN, 0)
kit.note_on(36)
left, right = self._channels(kit)
self.assertGreater(left, 0.0005)
self.assertLess(right, left * 0.01)
kit.note_off(36)
sf2_play.render(kit, 1.0)
kit.set_macro(self.PAN, 127)
kit.note_on(36)
left, right = self._channels(kit)
self.assertGreater(right, 0.0005)
self.assertLess(left, right * 0.01)
# The macro is an offset on the region's own pan: the piano's zone
# sits at -0.2, so centre leans left and hard right lands at 0.8.
player = self.inst._player
self.inst.note_on(60, 100)
voice = self.inst.output.voice[player.held[(0, 60)][0]]
self.assertAlmostEqual(voice.pan, -0.2, places=5)
self.inst.set_macro(self.PAN, 127)
self.assertAlmostEqual(voice.pan, 0.8, places=5)
self.inst.set_macro(self.PAN, 0)
self.assertEqual(voice.pan, -1.0) # clamped, not -1.2
self.inst.set_macro(self.PAN, 64)
self.assertAlmostEqual(voice.pan, -0.2, places=5)

def test_a_sounding_note_moves_with_pan(self):
kit = self.kit
kit.note_on(46) # the open hat rings on
left, right = self._channels(kit)
self.assertAlmostEqual(left, right, delta=left * 0.02)
kit.set_macro(self.PAN, 0)
sf2_play.render(kit, 0.05) # past the engine's ramp
left, right = self._channels(kit)
self.assertGreater(left, 0.0005)
self.assertLess(right, left * 0.01)
kit.set_macro(self.PAN, 127)
sf2_play.render(kit, 0.05)
left, right = self._channels(kit)
self.assertGreater(right, 0.0005)
self.assertLess(left, right * 0.01)

def test_a_stereo_pair_keeps_its_spread(self):
# The tiny bank's pair is already at the edges, so the builder makes
# a sibling with the pair at -0.5 and +0.5.
G = sf2_builder.sf2
b = sf2_builder.Bank("Pair")
left = b.sample("L", sf2_builder._frames(2, 120), 10, 110, pitch=60,
link=1, kind=G.SAMPLE_LEFT)
right = b.sample("R", sf2_builder._frames(3, 120), 10, 110, pitch=60,
link=0, kind=G.SAMPLE_RIGHT)
pad = b.instrument("Pad", [
[(G.GEN_PAN, -250), (G.GEN_SAMPLE_MODES, 1),
(G.GEN_SAMPLE_ID, left)],
[(G.GEN_PAN, 250), (G.GEN_SAMPLE_MODES, 1),
(G.GEN_SAMPLE_ID, right)],
])
b.preset("Pad", 0, 0, [[(G.GEN_INSTRUMENT, pad)]])
path = os.path.join(self.tmp, "pair.sf2")
b.write(path)
inst = audioinstruments.create("soundfont", SR, bank=path)
try:
player = inst._player
inst.note_on(60, 100)
voices = [inst.output.voice[i] for i in player.held[(0, 60)]]
pans = sorted(v.pan for v in voices)
self.assertEqual([round(p, 5) for p in pans], [-0.5, 0.5])
inst.set_macro(self.PAN, 80) # (80 - 64) / 63 to the right
pans = sorted(v.pan for v in voices)
self.assertAlmostEqual(pans[1] - pans[0], 1.0, places=5)
self.assertAlmostEqual(pans[0], -0.5 + 16 / 63.0, places=5)
inst.set_macro(self.PAN, 48) # (48 - 64) / 64 to the left
pans = sorted(v.pan for v in voices)
self.assertAlmostEqual(pans[1] - pans[0], 1.0, places=5)
self.assertAlmostEqual(pans[0], -0.75, places=5)
inst.set_macro(self.PAN, 0) # the left voice clamps at -1
pans = sorted(v.pan for v in voices)
self.assertEqual(pans, [-1.0, -0.5])
inst.note_off(60)
inst.set_macro(self.PAN, 127) # new notes start there
inst.note_on(62, 100)
voices = [inst.output.voice[i] for i in player.held[(0, 62)]]
self.assertEqual(sorted(v.pan for v in voices), [0.5, 1.0])
finally:
inst.deinit()

def test_cc10_drives_pan(self):
for inst in (self.inst, self.kit):
key = 60 if inst is self.inst else 36
player = inst._player
inst.note_on(key, 100)
voice = inst.output.voice[player.held[(0, key)][0]]
before = voice.pan
inst.control_change(10, 127)
self.assertEqual(inst.get_macro(self.PAN), 127)
self.assertEqual(voice.pan, min(1.0, before + 1.0))
inst.control_change(10, 0)
self.assertEqual(inst.get_macro(self.PAN), 0)
self.assertEqual(voice.pan, -1.0)
# A program change puts the patch's centre back, as for Volume.
inst.program_change(0)
self.assertEqual(inst.get_macro(self.PAN), 64)
self.assertAlmostEqual(voice.pan, before, places=5)
# Bank Select still reaches the player past the override.
if inst is self.inst:
inst.control_change(0, 1)
self.assertEqual(player.bank_msb, 1)
inst.control_change(0, 0)


if __name__ == "__main__":
unittest.main()
Loading