Skip to content

Commit

Permalink
Add a bunch of minor tweaks
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Simon Lemieux committed Feb 4, 2019
1 parent d2a5e73 commit b979942
Show file tree
Hide file tree
Showing 18 changed files with 325 additions and 168 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Next Release

* Revert the stdout/stderr capture as it caused other issues.
* A lof or minor (4d08b11)

# 0.4.0

Expand Down
10 changes: 8 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion docker_build.sh

This file was deleted.

7 changes: 0 additions & 7 deletions docker_run.sh

This file was deleted.

8 changes: 3 additions & 5 deletions examples/simple_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand All @@ -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)
99 changes: 59 additions & 40 deletions pyvst/host.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from ctypes import addressof
from ctypes import addressof, create_string_buffer
from warnings import warn

import numpy as np
Expand Down Expand Up @@ -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))
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -172,19 +178,25 @@ 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)

# Cut the extra of the last buffer if need be, to respect the `max_duration`.
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
Expand Down Expand Up @@ -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
Loading

0 comments on commit b979942

Please sign in to comment.