From 21ed4bf983893c23bf68b16e595b9a32e7b1a4ab Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:37:17 -0500 Subject: [PATCH] SoundFont instruments: a Pan macro, driven by CC 10 Pan is the fifth macro on soundfont and soundfont_drums, bipolar with the centre detent at 64. It is an offset on each region's own pan, clamped to the edges, so a stereo pair keeps its spread around the new centre. Moving it re-pans the voices already sounding through the engine's ramp, and a program change puts it back to the patch's centre like the other macros. Control Change 10 on either instrument moves the macro, the way Bank Select already reaches the player. Tests on the tiny bank measure the energy per channel at the ends and the centre, a ringing hat moving, a sibling bank's pair at +/-0.5 keeping its spread, and CC 10; with the player ignoring the macro all four go red. --- CHANGELOG.md | 5 + lib/audioinstruments/soundfont.py | 73 ++++++++++-- lib/audioinstruments/soundfont_drums.py | 10 +- tests/test_cpython_soundfont.py | 147 +++++++++++++++++++++++- 4 files changed, 220 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3763ac..8bd9bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/audioinstruments/soundfont.py b/lib/audioinstruments/soundfont.py index cdc03fa..cd99acb 100644 --- a/lib/audioinstruments/soundfont.py +++ b/lib/audioinstruments/soundfont.py @@ -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 @@ -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 @@ -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 -------------------------------------------- @@ -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): @@ -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 --------------------------------------------------- @@ -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 @@ -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, @@ -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() @@ -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: diff --git a/lib/audioinstruments/soundfont_drums.py b/lib/audioinstruments/soundfont_drums.py index 8b31ea6..7a8d17a 100644 --- a/lib/audioinstruments/soundfont_drums.py +++ b/lib/audioinstruments/soundfont_drums.py @@ -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. @@ -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. diff --git a/tests/test_cpython_soundfont.py b/tests/test_cpython_soundfont.py index 7584665..f251e93 100644 --- a/tests/test_cpython_soundfont.py +++ b/tests/test_cpython_soundfont.py @@ -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 @@ -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): @@ -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()