From 770359a9c066bef9f700328af7930438e6d51fae Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Thu, 3 Oct 2019 00:35:51 +0200 Subject: [PATCH] Added multiple features and extended README --- README.md | 87 +++++++++++++++++++++++- main.py | 4 ++ setup.py | 48 +++++++++++++ theremin/__init__.py | 78 ++++++++++++++++----- theremin/__main__.py | 52 +------------- theremin/leap.py | 68 +++++++++--------- theremin/{sound.py => sound/__init__.py} | 4 +- theremin/sound/utils.py | 59 ++++++++++++++++ theremin/theremin.py | 25 +++++++ 9 files changed, 321 insertions(+), 104 deletions(-) create mode 100644 main.py create mode 100755 setup.py mode change 100644 => 100755 theremin/__init__.py mode change 100755 => 100644 theremin/__main__.py rename theremin/{sound.py => sound/__init__.py} (97%) create mode 100644 theremin/sound/utils.py create mode 100644 theremin/theremin.py diff --git a/README.md b/README.md index 0f2e518..d200009 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,87 @@ # theremin -Theremin synth emulator in Python + +This is a [theremin](https://en.wikipedia.org/wiki/Theremin) synth emulator written in Python that +leverages a [Leap Motion](https://www.leapmotion.com/) device as a controller for pitch, timber and volume. + +Make music waving your hands: now you can. + +## Installation + +You'll need a [Leap Motion](https://www.leapmotion.com/) device and the +[Leap Motion SDK](https://developer-archive.leapmotion.com/documentation/python/index.html) +installed. You'll need in particular the `Leap.py` script to be somewhere in your Python libpath +as well as the `leapd` executable from the SDK. + +You'll ealso need the [PYO](https://github.com/belangeo/pyo) DSP module installed: + +```bash +pip install pyo +``` + +Finally, clone and install this repo: + +```bash +git clone https://github.com/BlackLight/theremin +cd theremin +[sudo] python setup.py install +``` + +## Usage + +Plug your Leap Motion, start the `leapd` daemon and make sure that your device is detected: + +```bash +[sudo] leapd & +``` + +Start the theremin: + +```bash +theremin +``` + +Move your hands and enjoy the fun! + +You can set the pitch of the sound by moving your right hand up and down, while the height of +the left hand will set the volume. Use the `--left-handed` option to invert the order. + +## Options + +``` +usage: theremin [-h] [--list-audio-outputs] [--list-leap-motions] + [--audio-output AUDIO_OUTPUT] [--audio-backend AUDIO_BACKEND] + [--channels CHANNELS] [--discrete] [--left-handed] + [--generator GENERATOR] [--min-frequency MIN_FREQUENCY] + [--max-frequency MAX_FREQUENCY] [--min-note MIN_NOTE] + [--max-note MAX_NOTE] + +optional arguments: + -h, --help show this help message and exit + --list-audio-outputs, -l + List the available audio output devices + --list-leap-motions, -L + List the available Leap Motion devices + --audio-output AUDIO_OUTPUT, -o AUDIO_OUTPUT + Select an output audio device by index (see -l) + --audio-backend AUDIO_BACKEND, -b AUDIO_BACKEND + Select the audio backend (default: portaudio). + Supported: {"portaudio", "jack", "coreaudio"} + --channels CHANNELS, -c CHANNELS + Number of audio channels (default: 2) + --discrete, -d If set then discrete notes will be generated instead + of samples over a continuous frequency space (default: + false) + --left-handed If set then the pitch control will be on the left hand + and the volume control on theright hand. Otherwise, + the controls are inverted (default: false) + --generator GENERATOR, -g GENERATOR + Wave generator to be used. See http://ajaxsoundstudio. + com/pyodoc/api/classes/generators.html. Default: + SineLoop + --min-frequency MIN_FREQUENCY + Minimum audio frequency (default: 55 Hz) + --max-frequency MAX_FREQUENCY + Maximum audio frequency (default: 10 kHz) + --min-note MIN_NOTE Minimum MIDI note, as a string (e.g. A4) + --max-note MAX_NOTE Maximum MIDI note, as a string (e.g. A4) +``` diff --git a/main.py b/main.py new file mode 100644 index 0000000..bfc186b --- /dev/null +++ b/main.py @@ -0,0 +1,4 @@ +from theremin import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..4b58193 --- /dev/null +++ b/setup.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python + +import os + +from setuptools import setup, find_packages + + +def path(fname=''): + return os.path.abspath(os.path.join(os.path.dirname(__file__), fname)) + + +def readfile(fname): + with open(path(fname)) as f: + return f.read() + + +setup( + name="theremin", + version="0.1", + author="Fabio Manganiello", + author_email="info@fabiomanganiello.com", + description="A theremin synth emulator controllable through a Leap Motion", + license="MIT", + python_requires='>= 3.5', + keywords="music synth theremin pyo leap_motion", + url="https://github.com/BlackLight/theremin", + packages=find_packages(), + include_package_data=True, + entry_points={ + 'console_scripts': [ + 'theremin=theremin:main', + ], + }, + long_description=readfile('README.md'), + long_description_content_type='text/markdown', + classifiers=[ + "Topic :: Multimedia :: Sound/Audio :: Sound Synthesis", + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + ], + install_requires=[ + 'pyo', + # Also requires Leap.py from the Leap Motion SDK installation + # see https://developer-archive.leapmotion.com/documentation/python/index.html + ], + extras_require={ + }, +) diff --git a/theremin/__init__.py b/theremin/__init__.py old mode 100644 new mode 100755 index c851afc..c10bb3b --- a/theremin/__init__.py +++ b/theremin/__init__.py @@ -1,25 +1,67 @@ +import argparse import sys -import pyo - -from .leap import LeapMotion -from .sound import SoundProcessor +from .leap import list_leap_motions +from .sound import list_output_devices +from .sound.utils import midi_str_to_midi, midi_to_freq +from .theremin import theremin -def theremin(wave='SineLoop', audio_output=None, audio_backend='portaudio', channels=2, - min_frequency=55, max_frequency=10000): - dsp = SoundProcessor(output=audio_output, backend=audio_backend, channels=channels) - dsp.start() +def parse_args(args): + parser = argparse.ArgumentParser() + parser.add_argument('--list-audio-outputs', '-l', dest='list_audio_outputs', required=False, + action='store_true', help='List the available audio output devices') + parser.add_argument('--list-leap-motions', '-L', dest='list_leap_motions', required=False, + action='store_true', help='List the available Leap Motion devices') + parser.add_argument('--audio-output', '-o', dest='audio_output', required=False, + type=int, help='Select an output audio device by index (see -l)') + parser.add_argument('--audio-backend', '-b', dest='audio_backend', required=False, default='portaudio', + help='Select the audio backend (default: portaudio). Supported: ' + + '{"portaudio", "jack", "coreaudio"}') + parser.add_argument('--channels', '-c', dest='channels', required=False, type=int, default=2, + help='Number of audio channels (default: 2)') + parser.add_argument('--discrete', '-d', dest='discrete', required=False, action='store_true', + help='If set then discrete notes will be generated instead of samples over a continuous ' + + 'frequency space (default: false)') + parser.add_argument('--left-handed', dest='left_handed', required=False, action='store_true', + help='If set then the pitch control will be on the left hand and the volume control on the' + + 'right hand. Otherwise, the controls are inverted (default: false)') + parser.add_argument('--generator', '-g', dest='generator', required=False, + default='SineLoop', help='Wave generator to be used. See ' + + 'http://ajaxsoundstudio.com/pyodoc/api/classes/generators.html. ' + + 'Default: SineLoop') + parser.add_argument('--min-frequency', dest='min_frequency', required=False, type=int, default=55, + help='Minimum audio frequency (default: 55 Hz)') + parser.add_argument('--max-frequency', dest='max_frequency', required=False, type=int, default=10000, + help='Maximum audio frequency (default: 10 kHz)') + parser.add_argument('--min-note', dest='min_note', required=False, type=str, default=None, + help='Minimum MIDI note, as a string (e.g. A4)') + parser.add_argument('--max-note', dest='max_note', required=False, type=str, default=None, + help='Maximum MIDI note, as a string (e.g. A4)') - assert hasattr(pyo, wave) - wave = getattr(pyo, wave) - audio = wave() - channel = dsp.add_track(audio) - print('Audio processor started') + opts, args = parser.parse_known_args(args) + return opts, args - sensor = LeapMotion(dsp, track=channel, min_frequency=min_frequency, max_frequency=max_frequency) - print('Press ENTER to quit') - sys.stdin.readline() - sensor.stop() - dsp.shutdown() +def main(args=None): + if not args: + args = sys.argv[1:] + + opts, args = parse_args(args) + + if opts.list_audio_outputs: + list_output_devices() + return + + if opts.list_leap_motions: + list_leap_motions() + return + + if opts.min_note: + opts.min_frequency = midi_to_freq(midi_str_to_midi(opts.min_note)) + if opts.max_note: + opts.max_frequency = midi_to_freq(midi_str_to_midi(opts.max_note)) + + theremin(wave=opts.generator, audio_backend=opts.audio_backend, discrete=opts.discrete, + min_frequency=opts.min_frequency, max_frequency=opts.max_frequency, left_handed=opts.left_handed, + audio_output=opts.audio_output, channels=opts.channels) diff --git a/theremin/__main__.py b/theremin/__main__.py old mode 100755 new mode 100644 index f153559..868d99e --- a/theremin/__main__.py +++ b/theremin/__main__.py @@ -1,52 +1,4 @@ -import argparse -import sys - -from . import theremin -from .leap import list_leap_motions -from .sound import list_output_devices - - -def parse_args(args): - parser = argparse.ArgumentParser() - parser.add_argument('--list-audio-outputs', '-l', dest='list_audio_outputs', required=False, - action='store_true', help='List the available audio output devices') - parser.add_argument('--list-leap-motions', '-L', dest='list_leap_motions', required=False, - action='store_true', help='List the available Leap Motion devices') - parser.add_argument('--audio-output', '-o', dest='audio_output', required=False, - type=int, help='Select an output audio device by index (see -l)') - parser.add_argument('--audio-backend', '-a', dest='audio_backend', required=False, default='portaudio', - help='Select the audio backend (default: portaudio). Supported: ' + - '{"portaudio", "jack", "coreaudio"}') - parser.add_argument('--channels', '-c', dest='channels', required=False, type=int, default=2, - help='Number of audio channels (default: 2)') - parser.add_argument('--generator', '-g', dest='generator', required=False, - default='SineLoop', help='Wave generator to be used. See ' + - 'http://ajaxsoundstudio.com/pyodoc/api/classes/generators.html. ' + - 'Default: SineLoop') - parser.add_argument('--min-frequency', '-m', dest='min_frequency', required=False, type=int, default=55, - help='Minimum audio frequency (default: 55 Hz)') - parser.add_argument('--max-frequency', '-M', dest='max_frequency', required=False, type=int, default=10000, - help='Maximum audio frequency (default: 10 kHz)') - - opts, args = parser.parse_known_args(args) - return opts, args - - -def main(args): - opts, args = parse_args(args) - - if opts.list_audio_outputs: - list_output_devices() - return - - if opts.list_leap_motions: - list_leap_motions() - return - - theremin(wave=opts.generator, audio_backend=opts.audio_backend, - min_frequency=opts.min_frequency, max_frequency=opts.max_frequency, - audio_output=opts.audio_output, channels=opts.channels) - +from . import main if __name__ == "__main__": - main(sys.argv[1:]) + main() diff --git a/theremin/leap.py b/theremin/leap.py index cd17642..a0bde44 100644 --- a/theremin/leap.py +++ b/theremin/leap.py @@ -1,7 +1,7 @@ -import math - import Leap +from .sound.utils import freq_to_midi_str, freq_to_midi, midi_to_freq + def list_leap_motions(): controller = Leap.Controller() @@ -16,13 +16,14 @@ def list_leap_motions(): class LeapMotion(Leap.Listener): - def __init__(self, dsp, track=0, amplitude=.5, min_frequency=55, max_frequency=10000): + def __init__(self, dsp, track=0, amplitude=.5, min_frequency=55, max_frequency=10000, left_handed=False): super().__init__() self.dsp = dsp self.track = track self.amplitude = amplitude self.min_frequency = min_frequency self.max_frequency = max_frequency + self.left_handed = left_handed self.controller = Leap.Controller() self.controller.add_listener(self) @@ -44,15 +45,11 @@ class LeapMotion(Leap.Listener): if len(frame.hands) > 0: if len(frame.hands) == 1: - left = frame.hands[0] - right = None + left = frame.hands[0] if frame.hands[0].is_left else None + right = frame.hands[0] if frame.hands[0].is_right else None else: - if frame.hands[0].palm_position[0] < frame.hands[1].palm_position[0]: - left = frame.hands[0] - right = frame.hands[1] - else: - left = frame.hands[1] - right = frame.hands[0] + left = frame.hands[0] if frame.hands[0].is_left else frame.hands[1] + right = frame.hands[1] if frame.hands[0].is_right else frame.hands[1] return left, right @@ -64,33 +61,37 @@ class LeapMotion(Leap.Listener): self.dsp.stop(self.track) return - y_left = left.palm_position[1] - frequency = self.y_to_freq(y_left) + y_left = left.palm_position[1] if left else None + y_right = right.palm_position[1] if right else None + frequency = None + amplitude = None - if right: - y_right = right.palm_position[1] - self.amplitude = self.y_to_amplitude(y_right) + if self.left_handed: + if y_left: + frequency = self.y_to_freq(y_left) + if y_right: + amplitude = self.y_to_amplitude(y_right) + else: + if y_left: + amplitude = self.y_to_amplitude(y_left) + if y_right: + frequency = self.y_to_freq(y_right) - self.dsp.set_frequency(self.track, frequency) - self.dsp.set_volume(self.track, self.amplitude) + if frequency: + if self.dsp.discrete: + frequency = midi_to_freq(freq_to_midi(frequency)) + self.dsp.set_frequency(self.track, frequency) - if not self.dsp.is_playing(self.track): - self.dsp.play(self.track) + if not self.dsp.is_playing(self.track): + self.dsp.play(self.track) - print('Hand height: {:.8f}, Frequency: {:1f}, MIDI: {}, Amplitude: {:4f}'.format( - y_left, frequency, self.freq_to_midi_note(frequency), self.amplitude)) + if amplitude: + self.dsp.set_volume(self.track, amplitude) + self.amplitude = amplitude - @staticmethod - def midi_note_to_str(midi): - notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] - base_c = 24 - i = (midi - base_c) % len(notes) - octave = int((midi - base_c) / 12) - return '{note}{octave}'.format(note=notes[i], octave=octave) - - @classmethod - def freq_to_midi_note(cls, freq): - return cls.midi_note_to_str(int(math.log2(freq / 440.0) * 12) + 69) + print('Left hand height: {:.8f}, Right hand height: {:.8f}, Frequency: {:1f}, MIDI: {}, Amplitude: {:4f}'. + format(y_left or 0, y_right or 0, frequency or 0, freq_to_midi_str(frequency) if frequency else 0, + self.amplitude)) def y_to_freq(self, y): y_min_height = 70 @@ -100,7 +101,6 @@ class LeapMotion(Leap.Listener): y -= y_min_height y_max_height -= y_min_height - # max_freq -= min_freq frequency = min_freq + ((max_freq*y) / y_max_height) if frequency < min_freq: diff --git a/theremin/sound.py b/theremin/sound/__init__.py similarity index 97% rename from theremin/sound.py rename to theremin/sound/__init__.py index 165a9dd..0e9b9c3 100644 --- a/theremin/sound.py +++ b/theremin/sound/__init__.py @@ -26,8 +26,9 @@ class Track: class SoundProcessor: - def __init__(self, backend='portaudio', output=None, channels=2): + def __init__(self, backend='portaudio', output=None, channels=2, discrete=False): self.backend = backend + self.discrete = discrete self.server = Server(audio=self.backend, jackname='theremin', winhost='theremin', nchnls=channels) self.audio_output = output or pa_get_default_output() self.server.setOutputDevice(self.audio_output) @@ -73,3 +74,4 @@ class SoundProcessor: def set_frequency(self, i, frequency): assert 0 <= i < len(self.tracks) self.tracks[i].set_frequency(frequency) + diff --git a/theremin/sound/utils.py b/theremin/sound/utils.py new file mode 100644 index 0000000..fdfa1ad --- /dev/null +++ b/theremin/sound/utils.py @@ -0,0 +1,59 @@ +import math + +notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] +base_c = 24 + + +def midi_to_midi_str(midi): + """ + :param int midi: MIDI int value (e.g. 69) + :return: MIDI string (e.g. A4) + :rtype: str + """ + global notes, base_c + + assert 24 <= midi <= 127 + i = (midi - base_c) % len(notes) + octave = int((midi - base_c) / 12) + 1 + return '{note}{octave}'.format(note=notes[i], octave=octave) + + +def freq_to_midi(freq): + """ + :param float freq: Frequency (e.g. 440.0) + :return: MIDI int value (e.g. 69) + :rtype: int + """ + return int(math.log2(freq / 440.0) * 12) + 69 + + +def freq_to_midi_str(freq): + """ + :param float freq: Frequency (e.g. 440.0) + :return: MIDI string (e.g. A4) + :rtype: str + """ + return midi_to_midi_str(freq_to_midi(freq)) + + +def midi_to_freq(midi): + """ + :param int midi: MIDI note (e.g. 69) + :return: Frequency in Hz (e.g. 440.0) + :rtype: float + """ + return 440.0 * math.pow(2.0, (midi - 69) / 12) + + +def midi_str_to_midi(midi_str): + """ + :param str midi_str: MIDI string (e.g. A4) + :return: MIDI int value (e.g. 69) + :rtype: int + """ + global notes, base_c + + note = notes.index(midi_str[0].upper()) + octave = int(midi_str[1]) + assert 1 <= octave <= 9 + return 12 + note + 12*octave diff --git a/theremin/theremin.py b/theremin/theremin.py new file mode 100644 index 0000000..3b33c61 --- /dev/null +++ b/theremin/theremin.py @@ -0,0 +1,25 @@ +import sys +import pyo + +from .leap import LeapMotion +from .sound import SoundProcessor + + +def theremin(wave='SineLoop', audio_output=None, audio_backend='portaudio', channels=2, min_frequency=55, + max_frequency=10000, discrete=False, left_handed=False): + dsp = SoundProcessor(output=audio_output, backend=audio_backend, channels=channels, discrete=discrete) + dsp.start() + + assert hasattr(pyo, wave) + wave = getattr(pyo, wave) + audio = wave() + channel = dsp.add_track(audio) + print('Audio processor started') + + sensor = LeapMotion(dsp, track=channel, min_frequency=min_frequency, max_frequency=max_frequency, + left_handed=left_handed) + print('Press ENTER to quit') + sys.stdin.readline() + + sensor.stop() + dsp.shutdown()