diff --git a/CHANGELOG.md b/CHANGELOG.md index 50e1668..6526a9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ Before any major/minor/patch bump all unit tests will be run to verify they pass - [ ] +## [0.5.0] - 2022-07-24 + +### Added + +- Midi class added to misc. +- Midi added to observer notifications +- Midi example added. +- Midi section added to readme. +- Minor version bump. + ## [0.4.0] - 2022-07-21 ### Added diff --git a/README.md b/README.md index bf5499e..72f3ab0 100644 --- a/README.md +++ b/README.md @@ -519,6 +519,25 @@ vm.option.delay[4].set(30) i, from 0 up to 4. +### Midi + +The following properties are available: + +- `channel`: int, returns the midi channel +- `current`: int, returns the current (or most recently pressed) key + +The following methods are available: + +- `get(key)`: int, returns most recent velocity value for a key + +example: + +```python +print(vm.midi.get(12)) +``` + +get() may return None if no value for requested key in midi cache + ### Multiple parameters - `apply` diff --git a/examples/midi/README.md b/examples/midi/README.md new file mode 100644 index 0000000..d642ad2 --- /dev/null +++ b/examples/midi/README.md @@ -0,0 +1,24 @@ +## About/Requirements + +A simple demonstration showing how to use a midi controller with this API. + +This script was written for and tested with a Korg NanoKontrol2 configured in CC mode. + +In order to run this example script you will need to have setup Voicemeeter with a midi device in Menu->Midi Mapping. + +## Use + +The script launches Voicemeeter Banana version and assumes that is the version being tested (if it was already launched) + +`get_info()` responds to any midi button press or midi slider movement and prints its' CC number and most recent value. + +`on_midi_press()` should enable trigger mode for macrobutton 0 if peak level value for strip 3 exceeds -40 and midi button 48 is pressed. On the NanoKontrol2 midi button 48 corresponds to the leftmost M button. You may need to disable any Keyboard Shortcut assignment first. + +For a clear illustration of what may be done fill in some commands in `Request for Button ON / Trigger IN` and `Request for Button OFF / Trigger OUT`. + +## Resources + +If you want to know how to setup the NanoKontrol2 for CC mode check the following resources. + +- [Korg NanoKontrol2 Manual](https://www.korg.com/us/support/download/manual/0/159/1912/) +- [CC Mode Info](https://i.korg.com/uploads/Support/nanoKONTROL2_PG_E1_634479709631760000.pdf) diff --git a/examples/midi/__main__.py b/examples/midi/__main__.py new file mode 100644 index 0000000..d52b053 --- /dev/null +++ b/examples/midi/__main__.py @@ -0,0 +1,52 @@ +import voicemeeterlib + + +class Observer: + def __init__(self, vm, midi_btn, macrobutton): + self.vm = vm + self.midi_btn = midi_btn + self.macrobutton = macrobutton + + def register(self): + self.vm.subject.add(self) + + def on_update(self, subject): + if subject == "midi": + self.get_info() + self.on_midi_press() + + def get_info(self): + current = self.vm.midi.current + print(f"Value of midi button {current} is {self.vm.midi.get(current)}") + + def on_midi_press(self): + if ( + max(self.vm.strip[3].levels.postfader) > -40 + and self.vm.midi.get(self.midi_btn) != 0 + ): + print( + f"Strip 3 level is greater than -40 and midi button {self.midi_btn} is pressed" + ) + self.vm.button[self.macrobutton].trigger = True + else: + self.vm.button[self.macrobutton].trigger = False + self.vm.button[self.macrobutton].state = False + + +def main(): + with voicemeeterlib.api(kind_id) as vm: + obs = Observer(vm, midi_btn, macrobutton) + obs.register() + + while cmd := input("Press to exit\n"): + if not cmd: + break + + +if __name__ == "__main__": + kind_id = "banana" + # leftmost M on korg nanokontrol2 in CC mode + midi_btn = 48 + macrobutton = 0 + + main() diff --git a/examples/observer/__main__.py b/examples/observer/__main__.py index 67f191f..47a570e 100644 --- a/examples/observer/__main__.py +++ b/examples/observer/__main__.py @@ -19,6 +19,8 @@ class Observer: f"[{self.vm.bus[4]} {self.vm.bus[4].levels.isdirty}]", ) print(" ".join(info)) + if subject == "midi": + print(self.vm.midi.cache) def main(): diff --git a/pyproject.toml b/pyproject.toml index d8468de..82a0876 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "voicemeeter-api" -version = "0.4.0" +version = "0.5.0" description = "A Python wrapper for the Voiceemeter API" authors = ["onyx-and-iris "] license = "MIT" diff --git a/voicemeeterlib/base.py b/voicemeeterlib/base.py index 2d847de..75cbe86 100644 --- a/voicemeeterlib/base.py +++ b/voicemeeterlib/base.py @@ -9,8 +9,9 @@ from .cbindings import CBindings from .error import CAPIError, VMError from .inst import bits from .kinds import KindId +from .misc import Midi from .subject import Subject -from .util import comp, polling, script +from .util import comp, grouper, polling, script class Remote(CBindings): @@ -20,6 +21,7 @@ class Remote(CBindings): def __init__(self, **kwargs): self.cache = {} + self.midi = Midi() self.subject = Subject() self.strip_mode = 0 self.running = None @@ -69,6 +71,8 @@ class Remote(CBindings): self.cache["strip_level"] = self._strip_buf self.cache["bus_level"] = self._bus_buf self.subject.notify("ldirty") + if self.get_midi_message(): + self.subject.notify("midi") time.sleep(self.ratelimit) @@ -231,6 +235,22 @@ class Remote(CBindings): ), ) + def get_midi_message(self): + n = ct.c_long(1024) + buf = ct.create_string_buffer(1024) + res = self.vm_get_midi_message(ct.byref(buf), n) + if res > 0: + vals = tuple(grouper(3, (int.from_bytes(buf[i]) for i in range(res)))) + for msg in vals: + ch, pitch, vel = msg + if not self.midi._channel or self.midi._channel != ch: + self.midi._channel = ch + self.midi._most_recent = pitch + self.midi._set(pitch, vel) + return True + elif res == -1 or res == -2: + raise CAPIError(f"VBVMR_GetMidiMessage returned {res}") + @script def sendtext(self, script: str): """Sets many parameters from a script""" diff --git a/voicemeeterlib/cbindings.py b/voicemeeterlib/cbindings.py index 5a0d4b5..36783e5 100644 --- a/voicemeeterlib/cbindings.py +++ b/voicemeeterlib/cbindings.py @@ -99,6 +99,10 @@ class CBindings(metaclass=ABCMeta): ct.POINTER(WCHAR * 256), ] + vm_get_midi_message = libc.VBVMR_GetMidiMessage + vm_get_midi_message.restype = LONG + vm_get_midi_message.argtypes = [ct.POINTER(CHAR * 1024), LONG] + def call(self, func): res = func() if res != 0: diff --git a/voicemeeterlib/misc.py b/voicemeeterlib/misc.py index aca7db3..a63c0d2 100644 --- a/voicemeeterlib/misc.py +++ b/voicemeeterlib/misc.py @@ -1,3 +1,5 @@ +from typing import Optional + from .error import VMError from .iremote import IRemote from .kinds import kinds_all @@ -227,3 +229,24 @@ class Delay(IRemote): def set(self, val: int): self.setter(f"delay[{self.index}]", val) + + +class Midi: + def __init__(self): + self._channel = None + self.cache = {} + self._most_recent = None + + @property + def channel(self) -> int: + return self._channel + + @property + def current(self) -> int: + return self._most_recent + + def get(self, key: int) -> Optional[int]: + return self.cache.get(key) + + def _set(self, key: int, velocity: int): + self.cache[key] = velocity diff --git a/voicemeeterlib/subject.py b/voicemeeterlib/subject.py index f5f0bf1..2638ce0 100644 --- a/voicemeeterlib/subject.py +++ b/voicemeeterlib/subject.py @@ -12,7 +12,7 @@ class Subject: return self._observers - def notify(self, modifier=None): + def notify(self, modifier): """run callbacks on update""" [o.on_update(modifier) for o in self._observers] diff --git a/voicemeeterlib/util.py b/voicemeeterlib/util.py index 2b92237..f826fc7 100644 --- a/voicemeeterlib/util.py +++ b/voicemeeterlib/util.py @@ -1,4 +1,5 @@ import functools +from itertools import zip_longest from typing import Iterator @@ -61,3 +62,11 @@ def comp(t0: tuple, t1: tuple) -> Iterator[bool]: """ for a, b in zip(t0, t1): yield a == b + + +def grouper(n, iterable, fillvalue=None): + """ + Group elements of an iterable by sets of n length + """ + args = [iter(iterable)] * n + return zip_longest(fillvalue=fillvalue, *args)