diff --git a/.gitignore b/.gitignore index 8b41fd0..a7fc76a 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,6 @@ venv.bak/ # Pycharm .idea/ + +# Notebook +notebook.ipynb diff --git a/theremin/__init__.py b/theremin/__init__.py new file mode 100644 index 0000000..c851afc --- /dev/null +++ b/theremin/__init__.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): + dsp = SoundProcessor(output=audio_output, backend=audio_backend, channels=channels) + 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) + print('Press ENTER to quit') + sys.stdin.readline() + + sensor.stop() + dsp.shutdown() diff --git a/theremin/__main__.py b/theremin/__main__.py new file mode 100755 index 0000000..f153559 --- /dev/null +++ b/theremin/__main__.py @@ -0,0 +1,52 @@ +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) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/theremin/leap.py b/theremin/leap.py new file mode 100644 index 0000000..cd17642 --- /dev/null +++ b/theremin/leap.py @@ -0,0 +1,133 @@ +import math + +import Leap + + +def list_leap_motions(): + controller = Leap.Controller() + has_devices = False + + for device in controller.devices: + has_devices = True + print('{}'.format(str(device))) + + if not has_devices: + print('No Leap Motion devices found') + + +class LeapMotion(Leap.Listener): + def __init__(self, dsp, track=0, amplitude=.5, min_frequency=55, max_frequency=10000): + super().__init__() + self.dsp = dsp + self.track = track + self.amplitude = amplitude + self.min_frequency = min_frequency + self.max_frequency = max_frequency + self.controller = Leap.Controller() + self.controller.add_listener(self) + + def on_init(self, controller): + print('Leap initialized') + + def on_connect(self, controller): + print('Leap connected') + + def on_disconnect(self, controller): + print('Leap disconnected') + + def on_exit(self, controller): + print('Leap exited') + + @staticmethod + def _get_hands(frame): + (left, right) = (None, None) + + if len(frame.hands) > 0: + if len(frame.hands) == 1: + left = frame.hands[0] + right = 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] + + return left, right + + def on_frame(self, controller): + frame = controller.frame() + left, right = self._get_hands(frame) + if not left and not right: + if self.dsp.is_playing(self.track): + self.dsp.stop(self.track) + return + + y_left = left.palm_position[1] + frequency = self.y_to_freq(y_left) + + if right: + y_right = right.palm_position[1] + self.amplitude = self.y_to_amplitude(y_right) + + 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)) + + @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) + + def y_to_freq(self, y): + y_min_height = 70 + y_max_height = 500 + min_freq = self.min_frequency + max_freq = self.max_frequency + + 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: + return min_freq + if frequency > max_freq: + return max_freq + + return frequency + + @staticmethod + def y_to_amplitude(y): + y_min_height = 70 + y_max_height = 500 + min_amplitude = 0 + max_amplitude = 1.5 + + y -= y_min_height + y_max_height -= y_min_height + max_amplitude -= min_amplitude + + amplitude = min_amplitude + ((max_amplitude*y) / y_max_height) + if amplitude < min_amplitude: + return min_amplitude + if amplitude > max_amplitude: + return max_amplitude + + return amplitude + + def stop(self): + self.controller.remove_listener(self) diff --git a/theremin/sound.py b/theremin/sound.py new file mode 100644 index 0000000..165a9dd --- /dev/null +++ b/theremin/sound.py @@ -0,0 +1,75 @@ +from pyo import * + + +def list_output_devices(): + pa_list_devices() + + +class Track: + def __init__(self, audio): + self.audio = audio + self.playing = False + + def play(self): + self.audio.out() + self.playing = True + + def stop(self): + self.audio.stop() + self.playing = False + + def set_volume(self, volume): + self.audio.setMul(volume) + + def set_frequency(self, frequency): + self.audio.setFreq(frequency) + + +class SoundProcessor: + def __init__(self, backend='portaudio', output=None, channels=2): + self.backend = backend + 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) + self.tracks = [] + + def start(self): + self.server.boot() + self.server.start() + + def shutdown(self): + self.stop() + self.server.stop() + self.server.shutdown() + + def add_track(self, audio): + self.tracks.append(Track(audio)) + return len(self.tracks) - 1 + + def remove_track(self, i): + assert 0 <= i < len(self.tracks) + return self.tracks.pop(i) + + def play(self, i): + assert 0 <= i < len(self.tracks) + self.tracks[i].play() + + def stop(self, i=None): + if i is not None: + assert 0 <= i < len(self.tracks) + self.tracks[i].stop() + else: + for track in self.tracks: + track.stop() + + def is_playing(self, i): + assert 0 <= i < len(self.tracks) + return self.tracks[i].playing + + def set_volume(self, i, volume): + assert 0 <= i < len(self.tracks) + self.tracks[i].set_volume(volume) + + def set_frequency(self, i, frequency): + assert 0 <= i < len(self.tracks) + self.tracks[i].set_frequency(frequency)