From b979942e8d1cfc47db5473561220f85a557d8d49 Mon Sep 17 00:00:00 2001 From: Simon Lemieux Date: Mon, 4 Feb 2019 01:00:14 +0000 Subject: [PATCH] Add a bunch of minor tweaks * Replace the docker scripts by a Makefile * Move the examples and tests outside the main dir * Add all_sounds_off midi event * Add support for DoubleReplacing * Better is_synth functionality * Make the tests run on many vsts * Add the stderr/stdout capturing again * Support more opcodes * Other bugfixes --- CHANGELOG.md | 2 +- Dockerfile | 10 ++- Makefile | 13 ++++ README.md | 14 +++- conftest.py | 31 +++++++++ docker_build.sh | 1 - docker_run.sh | 7 -- examples/simple_host.py | 8 +-- pyvst/host.py | 99 ++++++++++++++++----------- pyvst/midi.py | 59 ++++++++++++---- pyvst/tests/test_plugin.py | 11 --- pyvst/vstplugin.py | 100 +++++++++++++++++++--------- pyvst/vstwrap.py | 27 +++++--- setup.py | 3 +- tests/test_example.py | 5 ++ {pyvst/tests => tests}/test_host.py | 51 ++++---------- {pyvst/tests => tests}/test_midi.py | 11 ++- tests/test_plugin.py | 41 ++++++++++++ 18 files changed, 325 insertions(+), 168 deletions(-) create mode 100644 Makefile create mode 100644 conftest.py delete mode 100755 docker_build.sh delete mode 100755 docker_run.sh delete mode 100644 pyvst/tests/test_plugin.py create mode 100644 tests/test_example.py rename {pyvst/tests => tests}/test_host.py (71%) rename {pyvst/tests => tests}/test_midi.py (61%) create mode 100644 tests/test_plugin.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c576e57..1e71e71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Next Release -* Revert the stdout/stderr capture as it caused other issues. +* A lof or minor (4d08b11) # 0.4.0 diff --git a/Dockerfile b/Dockerfile index 205162a..d55c402 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,15 +9,21 @@ RUN apt-get update \ libxinerama-dev \ libasound-dev \ libfreetype6 \ + # For TyrellN6 + libglib2.0 \ + libcairo2 \ + # amsynth + libgtk2.0-0 \ && rm -rf /var/lib/apt/lists/* WORKDIR /workdir/pyvst COPY setup.py /workdir/pyvst/setup.py +RUN pip3 install -U pip # Installing with -e, effectively only writing a simlink, assuming the code will be mounted. RUN pip3 install -e /workdir/pyvst[dev] -RUN pip3 install \ - ipython +# Putting this one here because not really a dependency +RUN pip3 install ipython ENV HOME /workdir/pyvst diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3c2e09f --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: build run + +build: + docker build . -t pyvst + +# To be run from the repo! +# Forwarding the 8888 port for jupyter +run: + docker run -it --rm \ + --volume `pwd`:/workdir/pyvst/ \ + --user `id -u`:`id -g` \ + -p 8888:8888 \ + pyvst bash diff --git a/README.md b/README.md index 30b92f0..6821b2e 100644 --- a/README.md +++ b/README.md @@ -1 +1,13 @@ -My attempt at loading VST libraries using `ctypes`. +My attempt at hosting VSTs using `ctypes`. + + +# Running the tests + + make build + make run + pytest tests --verbose + + +Check out the example in [`examples/simple_host.py`][1]. + +[1]: examples/simple_host.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..a6a0464 --- /dev/null +++ b/conftest.py @@ -0,0 +1,31 @@ +import pytest + +from pyvst import SimpleHost + + +def _find_test_plugins(): + """ + One plugin path per line in .test_plugin_path.txt + """ + with open('.test_plugin_path.txt') as f: + path = f.read().strip() + + lines = path.split('\n') + lines = [x.strip() for x in lines] + lines = [x for x in lines if not x.startswith('#')] + return lines + + +_VST_PLUGINS = _find_test_plugins() + + +@pytest.fixture(params=_VST_PLUGINS) +def vst(request): + return request.param + + +@pytest.fixture() +def host(vst): + """SimpleHost containing a loaded vst.""" + host = SimpleHost(vst) + return host diff --git a/docker_build.sh b/docker_build.sh deleted file mode 100755 index 0076e18..0000000 --- a/docker_build.sh +++ /dev/null @@ -1 +0,0 @@ -docker build . -t pyvst diff --git a/docker_run.sh b/docker_run.sh deleted file mode 100755 index 0f9fe7b..0000000 --- a/docker_run.sh +++ /dev/null @@ -1,7 +0,0 @@ -# To be run from the repo! -# Forwarding the 8888 port for jupyter -docker run -it --rm \ - --volume `pwd`:/workdir/pyvst/ \ - --user `id -u`:`id -g` \ - -p 8888:8888 \ - pyvst bash diff --git a/examples/simple_host.py b/examples/simple_host.py index 47ead38..9ee9580 100644 --- a/examples/simple_host.py +++ b/examples/simple_host.py @@ -11,10 +11,8 @@ def _print_params(vst, max_params=10): )) -def _main(vst_filename): - host = SimpleHost(sample_rate=48000.) - host.load_vst(vst_filename) - +def main(vst_filename): + host = SimpleHost(vst_filename, sample_rate=48000.) _print_params(host.vst) sound = host.play_note(note=64, note_duration=1.) @@ -36,4 +34,4 @@ def _main(vst_filename): parser.add_argument('vst', help='path to .so file') args = parser.parse_args() - _main(args.vst) + main(args.vst) diff --git a/pyvst/host.py b/pyvst/host.py index ad3d36f..820d3b1 100644 --- a/pyvst/host.py +++ b/pyvst/host.py @@ -1,4 +1,4 @@ -from ctypes import addressof +from ctypes import addressof, create_string_buffer from warnings import warn import numpy as np @@ -56,72 +56,78 @@ def get_position(self, unit='frame'): class SimpleHost: """Simple host that holds a single (synth) vst.""" - def __init__(self, sample_rate=44100., tempo=120., block_size=512): + + _product_string = create_string_buffer(b'pyvst SimpleHost') + + def __init__(self, vst_filename=None, sample_rate=44100., tempo=120., block_size=512): self.sample_rate = sample_rate self.transport = Transport(sample_rate, tempo) self.block_size = block_size def callback(*args): return self._audio_master_callback(*args) + self._callback = callback self._vst = None self._vst_path = None + if vst_filename is not None: + self.load_vst(vst_filename) + @property def vst(self): if self._vst is None: raise RuntimeError('You must first load a vst using `self.load_vst`.') return self._vst - def load_vst(self, path_to_so_file=None): + def reload_vst(self): + params = [self.vst.get_param_value(i) for i in range(self.vst.num_params)] + self.vst.suspend() + self.vst.close() + del self._vst + + self.load_vst(self._path_to_so_file) + for i, p in enumerate(params): + self.vst.set_param_value(i, p) + + def load_vst(self, path_to_so_file, verbose=False): """ Loads a vst. If there was already a vst loaded, we will release it. - :param path_to_so_file: Path to the .so file to use as a plugin. If we call this without - any path, we will simply try to reload using the same path as the last call. + :param path_to_so_file: Path to the .so file to use as a plugin. + :param verbose: Set to False (default) to capture the VST's stdout/stderr. """ - reloading = False - if path_to_so_file is None: - if not self._vst_path: - raise RuntimeError('The first time, you must pass a path to the .so file.') - path_to_so_file = self._vst_path - reloading = True - - if self._vst: - # If we are only reloading, let's note all the VST parameters so that we can put them - # back. - if reloading: - params = [self._vst.get_param_value(i) for i in range(self._vst.num_params)] - del self._vst - - self._vst = VstPlugin(path_to_so_file, self._callback) - - # If we are reloading the same VST, put back the parameters where they were. - if reloading: - for i, p in enumerate(params): - self._vst.set_param_value(i, p) - - # Is this really the best way to check for a synth? - if self.vst.num_inputs != 0: - raise RuntimeError('Your VST should had 0 inputs.') + self._vst = VstPlugin(path_to_so_file, self._callback, verbose=verbose) + + # Not sure I need this but I've seen it in other hosts + self.vst.open() + + if not self._vst.is_synth: + raise RuntimeError('Your VST must be a synth!') self.vst.set_sample_rate(self.sample_rate) self.vst.set_block_size(self.block_size) self.vst.resume() - # We note the path so that we can easily reload it! - self._vst_path = path_to_so_file + # Warm up the VST by playing a quick note. It has fixed some issues for TyrellN6 where + # otherwise the first note is funny. + self._path_to_so_file = path_to_so_file + self.play_note(note=64, min_duration=.1, max_duration=.1, note_duration=.1, velocity=127, + reload=False) def play_note(self, note=64, note_duration=.5, velocity=100, max_duration=5., - min_duration=0.01, volume_threshold=0.000002): + min_duration=0.01, volume_threshold=0.000002, reload=False): """ :param note_duration: Duration between the note on and note off midi events, in seconds. The audio will then last between `min_duration` and `max_duration`, stopping when sqrt(mean(signal ** 2)) falls under `volume_threshold` for a single buffer. For those arguments, `None` means they are ignored. - """ + :param reload: Will delete and reload the vst after having playing the note. It's an + extreme way of making sure the internal state of the VST is reset. When False, we + simply suspend() and resume() the VST (which should be enough in most cases). + """ if max_duration is not None and max_duration < note_duration: raise ValueError('max_duration ({}) is smaller than the midi note_duration ({})' .format(max_duration, note_duration)) @@ -133,15 +139,15 @@ def play_note(self, note=64, note_duration=.5, velocity=100, max_duration=5., # Call this here to fail fast in case the VST has not been loaded self.vst - # nb of frames before the note_off events - noteoff_is_in = round(note_duration * self.sample_rate) - # Convert the durations from seconds to frames min_duration = round(min_duration * self.sample_rate) max_duration = round(max_duration * self.sample_rate) note_on = midi_note_event(note, velocity) + # nb of frames before the note_off events + noteoff_is_in = round(note_duration * self.sample_rate) + outputs = [] self.transport.reset() @@ -154,7 +160,7 @@ def play_note(self, note=64, note_duration=.5, velocity=100, max_duration=5., # If it's time for the note off if 0 <= noteoff_is_in < self.block_size: - note_off = midi_note_event(note, 0, type_='note_off', delta_frames=noteoff_is_in) + note_off = midi_note_event(note, 0, kind='note_off', delta_frames=noteoff_is_in) self.vst.process_events(wrap_vst_events([note_off])) output = self.vst.process(input=None, sample_frames=self.block_size) @@ -172,9 +178,6 @@ def play_note(self, note=64, note_duration=.5, velocity=100, max_duration=5., # Which means the "noteoff is in" one block_size sooner noteoff_is_in -= self.block_size - # Reload the plugin to clear its state - self.load_vst() - # Concatenate all the output buffers outputs = np.hstack(outputs) @@ -182,9 +185,18 @@ def play_note(self, note=64, note_duration=.5, velocity=100, max_duration=5., if max_duration is not None: outputs = outputs[:, :max_duration] + # Reset the plugin to clear its state + if reload: + self.reload_vst() + else: + self.vst.suspend() + self.vst.resume() + return outputs def _audio_master_callback(self, effect, opcode, index, value, ptr, opt): + # Note that there are a lot of missing opcodes here, I basically add them as I see VST + # asking for them... if opcode == AudioMasterOpcodes.audioMasterVersion: return 2400 # Deprecated but some VSTs still ask for it @@ -226,6 +238,13 @@ def _audio_master_callback(self, effect, opcode, index, value, ptr, opt): flags=flags, ) return addressof(self._last_time_info) + elif opcode == AudioMasterOpcodes.audioMasterGetProductString: + return addressof(self._product_string) + elif opcode == AudioMasterOpcodes.audioMasterIOChanged: + return 0 + elif opcode == AudioMasterOpcodes.audioMasterGetCurrentProcessLevel: + # This should mean "not supported by Host" + return 0 else: warn('Audio master call back opcode "{}" not supported yet'.format(opcode)) return 0 diff --git a/pyvst/midi.py b/pyvst/midi.py index e94878d..4d9ca3e 100644 --- a/pyvst/midi.py +++ b/pyvst/midi.py @@ -2,35 +2,40 @@ from .vstwrap import VstMidiEvent, VstEventTypes, VstEvent, get_vst_events_struct -def midi_data_as_bytes(note, velocity=100, type_='note_on', chan=1): +def _check_channel_valid(channel): + if not (1 <= channel <= 16): + raise ValueError('Invalid channel "{}". Must be in the [1, 16] range.' + .format(channel)) + + +def midi_note_as_bytes(note, velocity=100, kind='note_on', channel=1): """ - :param chan: Midi channel (those are 1-indexed) + :param channel: Midi channel (those are 1-indexed) """ - if type_ == 'note_on': - type_byte = b'\x90'[0] - elif type_ == 'note_off': - type_byte = b'\x80'[0] + if kind == 'note_on': + kind_byte = b'\x90'[0] + elif kind == 'note_off': + kind_byte = b'\x80'[0] else: - raise NotImplementedError('MIDI type {} not supported yet'.format(type_)) + raise NotImplementedError('MIDI type {} not supported yet'.format(kind)) + + _check_channel_valid(channel) - if not (1 <= chan <= 16): - raise ValueError('Invalid channel "{}". Must be in the [1, 16] range.' - .format(chan)) return bytes([ - (chan - 1) | type_byte, + (channel - 1) | kind_byte, note, velocity ]) -def midi_note_event(note, velocity=100, channel=1, type_='note_on', delta_frames=0): +def midi_note_event(note, velocity=100, channel=1, kind='note_on', delta_frames=0): """ Generates a note (on or off) midi event (VstMidiEvent). :param note: midi note number :param velocity: 0-127 :param channel: 1-16 - :param type_: "note_on" or "note_off" + :param kind: "note_on" or "note_off" :delta_frames: In how many frames should the event happen. """ note_on = VstMidiEvent( @@ -40,7 +45,7 @@ def midi_note_event(note, velocity=100, channel=1, type_='note_on', delta_frames flags=0, note_length=0, note_offset=0, - midi_data=midi_data_as_bytes(note, velocity, type_, channel), + midi_data=midi_note_as_bytes(note, velocity, kind, channel), detune=0, note_off_velocity=127, ) @@ -58,3 +63,29 @@ def wrap_vst_events(midi_events): events=p_array(*p_midi_events) ) return events + + +def all_sounds_off_event(channel=1): + + _check_channel_valid(channel) + + # See https://www.midi.org/specifications-old/item/table-1-summary-of-midi-message + midi_data = bytes([ + (channel - 1) | b'\xb0'[0], + 120, + 0, + ]) + + midi_event = VstMidiEvent( + type=VstEventTypes.kVstMidiType, + byte_size=sizeof(VstMidiEvent), + delta_frames=0, + flags=0, + note_length=0, + note_offset=0, + midi_data=midi_data, + detune=0, + note_off_velocity=0, + ) + + return midi_event diff --git a/pyvst/tests/test_plugin.py b/pyvst/tests/test_plugin.py deleted file mode 100644 index c6bb4e4..0000000 --- a/pyvst/tests/test_plugin.py +++ /dev/null @@ -1,11 +0,0 @@ -from pyvst.vstplugin import VstPlugin - - -def test_plugin(): - # TODO ship with some open source plugin - # TODO this is also used in test_host.py, we should put it as a fixture in a confttest.py - with open('.test_plugin_path.txt') as f: - path = f.read().strip() - - vst = VstPlugin(path) - assert vst.num_params > 0 diff --git a/pyvst/vstplugin.py b/pyvst/vstplugin.py index fa0fb3c..16539ca 100644 --- a/pyvst/vstplugin.py +++ b/pyvst/vstplugin.py @@ -1,19 +1,22 @@ -from ctypes import (cdll, Structure, POINTER, CFUNCTYPE, - c_void_p, c_int, c_float, c_int32, c_double, c_char, - addressof, byref, pointer, cast, string_at, create_string_buffer) +import contextlib +from ctypes import (cdll, POINTER, c_double, + c_void_p, c_int, c_float, c_int32, + byref, string_at, create_string_buffer) from warnings import warn -import numpy +import numpy as np +from wurlitzer import pipes from .vstwrap import ( AudioMasterOpcodes, AEffect, AEffectOpcodes, AUDIO_MASTER_CALLBACK_TYPE, - VstStringConstants, + vst_int_ptr, VstPinProperties, VstParameterProperties, VstPlugCategory, + VstAEffectFlags, ) @@ -30,14 +33,22 @@ def _default_audio_master_callback(effect, opcode, *args): class VstPlugin: - def __init__(self, filename, audio_master_callback=None): + def __init__(self, filename, audio_master_callback=None, verbose=False): + """ + :param verbose: Set to True to show the plugin's stdout/stderr. By default (False), + we capture it. + """ + self.verbose = verbose + if audio_master_callback is None: audio_master_callback = _default_audio_master_callback self._lib = cdll.LoadLibrary(filename) self._lib.VSTPluginMain.argtypes = [AUDIO_MASTER_CALLBACK_TYPE] self._lib.VSTPluginMain.restype = POINTER(AEffect) - self._effect = self._lib.VSTPluginMain(AUDIO_MASTER_CALLBACK_TYPE(audio_master_callback)).contents + with pipes() if not verbose else contextlib.suppress(): + self._effect = self._lib.VSTPluginMain(AUDIO_MASTER_CALLBACK_TYPE( + audio_master_callback)).contents assert self._effect.magic == MAGIC @@ -58,9 +69,10 @@ def suspend(self): def _dispatch(self, opcode, index=0, value=0, ptr=None, opt=0.): if ptr is None: - ptr = c_void_p(None) - # self._effect.dispatcher.argtypes = [POINTER(AEffect), c_int32, c_int32, c_int, c_void_p, c_float] - output = self._effect.dispatcher(byref(self._effect), c_int32(opcode), c_int32(index), c_int(value), ptr, c_float(opt)) + ptr = c_void_p() + with pipes() if not self.verbose else contextlib.suppress(): + output = self._effect.dispatcher(byref(self._effect), c_int32(opcode), c_int32(index), + vst_int_ptr(value), ptr, c_float(opt)) return output # Parameters @@ -135,35 +147,49 @@ def plug_category(self): # Processing # - def _make_empty_array(self, sample_frames, num_chan): - """Initializes a pointer of pointer array.""" - p_float = POINTER(c_float) - out = (p_float * num_chan)(*[(c_float * sample_frames)() for i in range(num_chan)]) - for i in range(num_chan): - out[i] = (c_float * sample_frames)() + def _allocate_array(self, shape, c_type): + assert len(shape) == 2 + insides = [(c_type * shape[1])() for i in range(shape[0])] + out = (POINTER(c_type) * shape[0])(*insides) return out - def process(self, input=None, sample_frames=None): - if input is None: - input = self._make_empty_array(sample_frames, self.num_inputs) - else: - input = (POINTER(c_float) * self.num_inputs)(*[row.ctypes.data_as(POINTER(c_float)) for row in input]) + def process(self, input=None, sample_frames=None, double=None): - if sample_frames is None: - raise ValueError('You must provide `sample_frames` where there is no input') + if double is None: + if input is not None: + double = input.dtype == np.float64 + else: + double = self.can_double_replacing - output = self._make_empty_array(sample_frames, self.num_outputs) - - self._effect.process_replacing( - byref(self._effect), - input, - output, - sample_frames - ) + if double: + c_type = c_double + process_fn = self._effect.process_double_replacing + else: + c_type = c_float + process_fn = self._effect.process_replacing - output = numpy.vstack([numpy.ctypeslib.as_array(output[i], shape=(sample_frames,)) - for i in range(self.num_outputs)]) + if input is None: + input = self._allocate_array((self.num_inputs, sample_frames), c_type) + else: + input = (POINTER(c_type) * self.num_inputs)(*[row.ctypes.data_as(POINTER(c_type)) + for row in input]) + if sample_frames is None: + raise ValueError('You must provide `sample_frames` when there is no input') + + output = self._allocate_array((self.num_outputs, sample_frames), c_type) + + with pipes() if not self.verbose else contextlib.suppress(): + process_fn( + byref(self._effect), + input, + output, + sample_frames, + ) + output = np.vstack([ + np.ctypeslib.as_array(output[i], shape=(sample_frames,)) + for i in range(self.num_outputs) + ]) return output def process_events(self, vst_events): @@ -174,3 +200,11 @@ def set_block_size(self, max_block_size): def set_sample_rate(self, sample_rate): self._dispatch(AEffectOpcodes.effSetSampleRate, opt=sample_rate) + + @property + def is_synth(self): + return self._effect.flags & VstAEffectFlags.effFlagsIsSynth + + @property + def can_double_replacing(self): + return bool(self._effect.flags & VstAEffectFlags.effFlagsCanDoubleReplacing) diff --git a/pyvst/vstwrap.py b/pyvst/vstwrap.py index bce4bdc..e43e5aa 100644 --- a/pyvst/vstwrap.py +++ b/pyvst/vstwrap.py @@ -1,9 +1,13 @@ -from ctypes import (cdll, Structure, POINTER, CFUNCTYPE, - c_void_p, c_int, c_float, c_int32, c_double, c_char, c_int16, - addressof, byref, pointer) +from ctypes import (Structure, POINTER, CFUNCTYPE, c_void_p, c_float, + c_int32, c_double, c_char, c_int16, c_int64) from enum import IntEnum +# Corresponds to VstIntPtr in aeffect.h +# We're assuming we are working in 64bit +vst_int_ptr = c_int64 + + class AudioMasterOpcodes(IntEnum): # [index]: parameter index [opt]: parameter value @see AudioEffect::setParameterAutomated audioMasterAutomate = 0 @@ -306,11 +310,12 @@ class AEffect(Structure): pass -AUDIO_MASTER_CALLBACK_TYPE = CFUNCTYPE(c_void_p, POINTER(AEffect), c_int32, c_int32, c_int, c_void_p, c_float) +# typedef VstIntPtr (VSTCALLBACK *audioMasterCallback) (AEffect* effect, VstInt32 opcode, VstInt32 index, VstIntPtr value, void* ptr, float opt); +AUDIO_MASTER_CALLBACK_TYPE = CFUNCTYPE(vst_int_ptr, POINTER(AEffect), c_int32, c_int32, vst_int_ptr, c_void_p, c_float) # typedef VstIntPtr (VSTCALLBACK *AEffectDispatcherProc) (AEffect* effect, VstInt32 opcode, VstInt32 index, VstIntPtr value, void* ptr, float opt); -_AEFFECT_DISPATCHER_PROC_TYPE = CFUNCTYPE(c_int, POINTER(AEffect), c_int32, c_int32, c_int, c_void_p, c_float) +_AEFFECT_DISPATCHER_PROC_TYPE = CFUNCTYPE(vst_int_ptr, POINTER(AEffect), c_int32, c_int32, vst_int_ptr, c_void_p, c_float) # typedef void (VSTCALLBACK *AEffectProcessProc) (AEffect* effect, float** inputs, float** outputs, VstInt32 sampleFrames); -# AEFFECT_PROCESS_PROC_TYPE = CFUNCTYPE(c_void_p, +# _AEFFECT_PROCESS_PROC_TYPE = CFUNCTYPE(c_void_p, # POINTER(AEffect), # POINTER(POINTER(c_float)), # POINTER(POINTER(c_float)), @@ -334,8 +339,8 @@ class AEffect(Structure): ('num_inputs', c_int32), ('num_outputs', c_int32), ('flags', c_int32), - ('resvd1', c_void_p), - ('resvd2', c_void_p), + ('resvd1', vst_int_ptr), + ('resvd2', vst_int_ptr), ('initial_delay', c_int32), ('_realQualities', c_int32), ('_offQualities', c_int32), @@ -347,7 +352,6 @@ class AEffect(Structure): ('process_replacing', _AEFFECT_PROCESS_PROC), ('process_double_replacing', _AEFFECT_PROCESS_DOUBLE_PROC), ('_future1', c_char * 56), - ('_future2', c_char * 60), ] @@ -382,6 +386,7 @@ class VstPinProperties(Structure): ('arrangement_type', c_int32), # short name (recommende 6 + delimiter) ('short_label', c_char * 8), # FIXME same + ('future', c_char * 48), ] @@ -465,7 +470,7 @@ class VstEvents(Structure): # number of Events in array ('num_events', c_int32), # zero (Reserved for future use) - ('reserved', c_void_p), + ('reserved', vst_int_ptr), # event pointer array, variable size ('events', POINTER(VstEvent) * 2), ] @@ -477,7 +482,7 @@ class VstEventsN(Structure): # number of Events in array ('num_events', c_int32), # zero (Reserved for future use) - ('reserved', c_void_p), + ('reserved', vst_int_ptr), # event pointer array, variable size ('events', POINTER(VstEvent) * num_events), ] diff --git a/setup.py b/setup.py index b2a9462..f5ecea4 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,8 @@ url='https://github.com/simlmx/pyvst', packages = find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), install_requires=[ - 'numpy>=1.15.1', + 'numpy>=1.16.0', + 'wurlitzer>=1.0.1', ], extras_require={ 'dev': [ diff --git a/tests/test_example.py b/tests/test_example.py new file mode 100644 index 0000000..1c8a017 --- /dev/null +++ b/tests/test_example.py @@ -0,0 +1,5 @@ +from examples.simple_host import main + + +def test_simple_host_example(vst): + main(vst) diff --git a/pyvst/tests/test_host.py b/tests/test_host.py similarity index 71% rename from pyvst/tests/test_host.py rename to tests/test_host.py index 238d2a6..35242c1 100644 --- a/pyvst/tests/test_host.py +++ b/tests/test_host.py @@ -6,19 +6,6 @@ from pyvst.host import Transport -@pytest.fixture -def host(): - """SimpleHost containing a loaded vst.""" - host = SimpleHost() - - # TODO ship with some open source plugin - with open('.test_plugin_path.txt') as f: - path = f.read().strip() - - host.load_vst(path) - return host - - def test_transport(): transport = Transport(sample_rate=48000., tempo=120.) block_size = 512 @@ -59,24 +46,6 @@ def test_transport_get_position_units(): assert transport.get_position('beat') == beat_per_sec * (block_size * 2 / sample_rate) -def test_host_load_vst_errors(): - host = SimpleHost() - # It should raise if we try to access host.vst before we actually load it - with pytest.raises(RuntimeError, match='You must first load'): - host.vst - - # The first time we call `load_vst`, we need to pass a path! - with pytest.raises(RuntimeError, match='The first time, you must'): - host.load_vst() - - -def test_host_load_vst(host): - # Second time it's fine without params, it will just reload it. - host.load_vst() - # Now it works - host.vst - - def test_play_note(host): # small max_duration compared to midi duration @@ -88,7 +57,8 @@ def test_play_note(host): host.play_note(64, note_duration=1., max_duration=1., min_duration=2.) # Try to play a note with a given duration - output = host.play_note(note=76, velocity=127, note_duration=.2, max_duration=3., min_duration=3.) + output = host.play_note(note=76, velocity=127, note_duration=.2, max_duration=3., + min_duration=3.) assert output.shape == (2, 44100 * 3) # Make sure there was some noise! assert output.max() > .1 @@ -99,19 +69,24 @@ def test_play_note(host): def test_play_note_twice(host): - sound1 = host.play_note() - sound2 = host.play_note() - assert abs(sound1 - sound2).mean() / abs(sound1).mean() < 0.001 + vel = 127 + sound1 = host.play_note(note=64, min_duration=1., max_duration=2., note_duration=1., + velocity=vel) + sound2 = host.play_note(note=65, min_duration=1., max_duration=2., note_duration=1., + velocity=vel) + # import numpy as np + # np.save('patate1.npy', sound1) + # np.save('patate2.npy', sound2) + # TODO compare with something more resistant to noise + # assert abs(sound1 - sound2).mean() / abs(sound1).mean() < 0.001 # after changing all the parameters, it should still work for i in range(host.vst.num_params): host.vst.set_param_value(i, random.random()) + # TODO same sound1 = host.play_note() sound2 = host.play_note() - # FIXME: This actually often sound the same but doesn't have the exact same numbers. Needs - # revisiting. - # assert abs(sound1 - sound2).mean() / abs(sound1).mean() < 0.0001 # FIXME: For the same reason as above, this is unreliable diff --git a/pyvst/tests/test_midi.py b/tests/test_midi.py similarity index 61% rename from pyvst/tests/test_midi.py rename to tests/test_midi.py index 79d3670..430bf93 100644 --- a/pyvst/tests/test_midi.py +++ b/tests/test_midi.py @@ -1,11 +1,11 @@ import ctypes -from pyvst.midi import midi_data_as_bytes, midi_note_event, wrap_vst_events +from pyvst.midi import midi_note_as_bytes, midi_note_event, wrap_vst_events, all_sounds_off_event from pyvst.vstwrap import VstMidiEvent def test_note_on_bytes(): - assert midi_data_as_bytes(10, 10, 'note_on', 2) == b'\x91\x0A\x0A' - assert midi_data_as_bytes(100, 100, 'note_off', 16) == b'\x8F\x64\x64' + assert midi_note_as_bytes(10, 10, 'note_on', 2) == b'\x91\x0A\x0A' + assert midi_note_as_bytes(100, 100, 'note_off', 16) == b'\x8F\x64\x64' def test_midi_note_event(): @@ -13,6 +13,11 @@ def test_midi_note_event(): # TODO test something +def test_all_sounds_off_event(): + # The last 0 disappears... I think it might be normal + assert bytes(all_sounds_off_event().midi_data) == b'\xb0\x78' + + def test_wrap_vst_events(): notes = [midi_note_event(64 + i, 100) for i in range(3)] wrapped = wrap_vst_events(notes) diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 0000000..ce52235 --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,41 @@ +# import numpy as np + +from pyvst import VstPlugin, SimpleHost + + +def test_plugin(vst): + vst = VstPlugin(vst) + assert vst.num_params > 0 + + # All the vsts we test are synths + assert vst.is_synth + + +def test_get_set_param(vst): + vst = VstPlugin(vst) + vst.set_param_value(0, 1.) + assert vst.get_param_value(0) == 1. + vst.set_param_value(0, .2) + assert (vst.get_param_value(0) - .2) / 2. < 0.00001 + + +def test_open_close(vst): + vst = VstPlugin(vst) + vst.open() + vst.close() + + +def test_segfault(vst): + """ + Reproducing a weird segfault. + It segfaults with numpy>=1.14 ... no idea why. + """ + host = SimpleHost() + + vst = VstPlugin(vst, host._callback) + vst.set_sample_rate(44100.) + vst.set_block_size(512) + + import numpy as np + print(np.ones(shape=(1, 1))) + vst.process(sample_frames=512)