Added multiple features and extended README

This commit is contained in:
Fabio Manganiello 2019-10-03 00:35:51 +02:00
parent 42334586ce
commit 770359a9c0
9 changed files with 321 additions and 104 deletions

View file

@ -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)
```

4
main.py Normal file
View file

@ -0,0 +1,4 @@
from theremin import main
if __name__ == "__main__":
main()

48
setup.py Executable file
View file

@ -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={
},
)

78
theremin/__init__.py Normal file → Executable file
View file

@ -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)

52
theremin/__main__.py Executable file → Normal file
View file

@ -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()

View file

@ -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]
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 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)
if right:
y_right = right.palm_position[1]
self.amplitude = self.y_to_amplitude(y_right)
if frequency:
if self.dsp.discrete:
frequency = midi_to_freq(freq_to_midi(frequency))
self.dsp.set_frequency(self.track, frequency)
self.dsp.set_volume(self.track, self.amplitude)
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:

View file

@ -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)

59
theremin/sound/utils.py Normal file
View file

@ -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

25
theremin/theremin.py Normal file
View file

@ -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()