diff --git a/lib/audioinstruments/soundfont.py b/lib/audioinstruments/soundfont.py index 90a0b24..5923cb1 100644 --- a/lib/audioinstruments/soundfont.py +++ b/lib/audioinstruments/soundfont.py @@ -80,6 +80,14 @@ the host's. A program change puts every macro back to the patch's defaults, Pan at centre. +The pitch wheel bends every note of the instrument by up to `bend_range` +semitones either way, two until a host sets it (General MIDI's Pitch Bend +Sensitivity, which a host reads from RPN 0). `tuning` shifts the whole +instrument by a number of semitones, fractions included, for a host's +RPN 1 and 2 (Channel Fine and Coarse Tuning); it is 0 until set. Both apply +to the notes already sounding, and `reset()` puts them back, with the +wheel centred. + 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 @@ -947,6 +955,9 @@ def __init__(self, bank, voices, default_bank=0): self.release_scale = 1.0 self.tune = 1.0 self.bend = 1.0 + self.bend_amount = 0.0 # the wheel, -1 to 1 + self.bend_range = BEND_RANGE + self.tuning = 1.0 # the host's channel tuning, as a ratio self.pan = 0.0 self.volume_cc = {} # channel -> Control Change 7 self.expression = {} # channel -> Control Change 11 @@ -1063,7 +1074,7 @@ def note_on(self, key, pitch, velocity, detune): i = self._allocate() voices[i].play( pcm, rate=region.sample_rate, - pitch=ratio * self.tune * self.bend, + pitch=ratio * self.tune * self.bend * self.tuning, loop_start=loop_start, loop_end=loop_end, loop_mode=mode, level=level * controllers * self.volume * pool.gain, pan=_panned(region.pan, self.pan), @@ -1169,7 +1180,7 @@ def revibrato(self, channel): def repitch(self): voices = self.pool.voice - factor = self.tune * self.bend + factor = self.tune * self.bend * self.tuning for i, slot in enumerate(self.slots): if slot is not None and slot[7] is self: voices[i].pitch = slot[5] * factor @@ -1209,7 +1220,8 @@ def handle_event(self, event_type, channel, note_id, data0, value0, self.pan = _bipolar(value0) self.repan() elif event_type == EVENT_PITCH_BEND: - self.bend = 2.0 ** (value1 * BEND_RANGE / 12.0) + self.bend_amount = value1 + self.bend = 2.0 ** (value1 * self.bend_range / 12.0) self.repitch() elif event_type == EVENT_CHANNEL_PRESSURE: self.pressure[channel] = value0 * PRESSURE_TO_VIBRATO @@ -1243,6 +1255,7 @@ class SoundFontInstrument(Instrument): def __init__(self, player, patches, programs, macro_labels, note_map=None, transport=None, bank_select=True): self._player = player + self._tuning = 0.0 self._programs = programs self._bank_select = bank_select super().__init__(None, player.handle_event, patches, macro_labels, @@ -1263,6 +1276,32 @@ def preset(self): def preset_name(self): return self._player.bank.presets.get(self.preset) + @property + def bend_range(self): + """Semitones the pitch wheel bends at full deflection, either way: + `BEND_RANGE` (2) until set. Setting it moves the notes sounding + with the wheel where it is.""" + return self._player.bend_range + + @bend_range.setter + def bend_range(self, semitones): + player = self._player + player.bend_range = float(semitones) + player.bend = 2.0 ** (player.bend_amount * player.bend_range / 12.0) + player.repitch() + + @property + def tuning(self): + """The whole instrument's shift in semitones, fractions included: + 0 until set.""" + return self._tuning + + @tuning.setter + def tuning(self, semitones): + self._tuning = float(semitones) + self._player.tuning = 2.0 ** (self._tuning / 12.0) + self._player.repitch() + 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. @@ -1291,6 +1330,11 @@ def reset(self): for state in (self._player.volume_cc, self._player.expression, self._player.wheel, self._player.pressure): state.clear() + self._player.bend_amount = 0.0 + self._player.bend = 1.0 + self._player.bend_range = BEND_RANGE + self._tuning = 0.0 + self._player.tuning = 1.0 super().reset() @property diff --git a/tests/test_cpython_soundfont.py b/tests/test_cpython_soundfont.py index 2676152..6c6734d 100644 --- a/tests/test_cpython_soundfont.py +++ b/tests/test_cpython_soundfont.py @@ -231,6 +231,40 @@ def test_macros_reach_held_voices(self): self.inst.set_macro(2, 0) self.assertAlmostEqual(player.release_scale, 0.25, places=2) + def test_bend_range_and_tuning_move_held_voices(self): + player = self.inst._player + self.assertEqual(self.inst.bend_range, soundfont.BEND_RANGE) + self.assertEqual(self.inst.tuning, 0.0) + self.inst.note_on(60, 100) + slot = player.held[(0, 60)][0] + voice = self.inst.output.voice[slot] + ratio = player.slots[slot][5] + # Half the wheel down, then the range widened under it: the note + # follows without another bend message. + self.inst.pitch_bend(4096) + self.assertAlmostEqual(voice.pitch, ratio * 2 ** (-1 / 12.0), places=4) + self.inst.bend_range = 12 + self.assertAlmostEqual(voice.pitch, ratio * 2 ** (-6 / 12.0), places=4) + self.inst.pitch_bend(0) + self.assertAlmostEqual(voice.pitch, ratio * 2 ** (-12 / 12.0), places=4) + self.inst.pitch_bend(4096) + self.inst.tuning = 2.5 + self.assertAlmostEqual(voice.pitch, ratio * 2 ** (-3.5 / 12.0), places=4) + # A new note starts where the held one is. + self.inst.note_on(64, 100) + slot2 = player.held[(0, 64)][0] + self.assertAlmostEqual(self.inst.output.voice[slot2].pitch, + player.slots[slot2][5] * 2 ** (-3.5 / 12.0), + places=4) + # reset() puts the wheel, its range and the tuning back. + self.inst.reset() + self.assertEqual(self.inst.bend_range, soundfont.BEND_RANGE) + self.assertEqual(self.inst.tuning, 0.0) + self.inst.note_on(60, 100) + slot = player.held[(0, 60)][0] + self.assertAlmostEqual(self.inst.output.voice[slot].pitch, + player.slots[slot][5], places=5) + # -- pan ------------------------------------------------------------------ PAN = soundfont.PAN_MACRO