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()