diff --git a/README.md b/README.md new file mode 100644 index 0000000..18d992d --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ + +# RPi Theremin + +This is a quick-n-dirty Theremin daemon for use on the RaspberryPi. + +It is designed to be used with a couple of these distance sensors: + + https://www.adafruit.com/product/4007 + +Use 'raspi-config' to configure your default sound output (HDMI, 3.5", etc). + + +This is intended to be run from daemontools or runit. Quick setup: + + # apt install ruby fluidsynth runit + # gem install -Ng + # ln -s /path/to/here/theremin /etc/service + # ln -s /path/to/here/fluidsynth /etc/service + + diff --git a/fluidsynth/run b/fluidsynth/run new file mode 100755 index 0000000..dce152e --- /dev/null +++ b/fluidsynth/run @@ -0,0 +1,12 @@ +#!/bin/sh +# +# Run fluidsynth as a server, with some more dedicated CPU "oomph". +# + +exec 2>&1 +exec fluidsynth -v -n -i -s \ + -a alsa -c2 -z1024 \ + -o synth.cpu-cores=4 \ + -o audio.realtime-prio=95 \ + -o audio.alsa.device=hw:0 + diff --git a/gem.deps.rb b/gem.deps.rb new file mode 100644 index 0000000..f9e1575 --- /dev/null +++ b/gem.deps.rb @@ -0,0 +1,4 @@ +source 'https://rubygems.org/' + +gem 'configurability', '~> 4.2' + diff --git a/theremin/Theremin.sf2 b/theremin/Theremin.sf2 new file mode 100644 index 0000000..4770055 Binary files /dev/null and b/theremin/Theremin.sf2 differ diff --git a/theremin/config.yml b/theremin/config.yml new file mode 100644 index 0000000..3131140 --- /dev/null +++ b/theremin/config.yml @@ -0,0 +1,18 @@ +# -*- ruby -*- +# vim: set et sta sw=2 : + +--- +logging: + __default__: info (color) + theremin: debug (color) + +theremin: + pitch_trigger: 16 + font: /usr/share/sounds/sf2/FluidR3_GM.sf2 + glide: 1 + slew: 0 + sample_interval: 0.1 + reverb: false + chorus: false + instrument_index: 29 + diff --git a/theremin/lib/fluidsynth.rb b/theremin/lib/fluidsynth.rb new file mode 100644 index 0000000..d6fd84c --- /dev/null +++ b/theremin/lib/fluidsynth.rb @@ -0,0 +1,187 @@ +# -*- ruby -*- +# vim: set noet sta sw=4 ts=4 : + +require 'socket' +require 'configurability' +require 'loggability' + + +# Communicate with a running FluidSynth daemon via it's TCP socket API. +# +class FluidSynth + extend Configurability, + Loggability + + # The default host for the fluidsynth server. + DEFAULT_HOST = 'localhost' + + # The default port for the fluidsynth server. + DEFAULT_PORT = 9800 + + + # Loggability API + log_as :fluidsynth + + # Configuration API + # + configurability( :fluidsynth ) do + + ## + # How many simultaneous notes can play. Keep low for better CPU. + setting :polyphony, default: 6 + + ## + # Reverb amount. + setting :reverb_size, default: 0.9 + + ## + # Reverb gain level. + setting :reverb_gain, default: 0.7 + + ## + # Chorus depth. + setting :chorus_depth, default: 64.0 + + ## + # Chorus gain level. + setting :chorus_gain, default: 1.5 + end + + + def initialize( host=DEFAULT_HOST, port=DEFAULT_PORT ) + @host = host + @port = port + @lastnote = nil + @velocity = 80 + end + + # The TCP socket to fluidsynth. + attr_reader :socket + + # The previous note played, to enable glides. + attr_reader :lastnote + + # The current velocity to play new notes at. + attr_accessor :velocity + + + ### Open the TCP socket to the synth and perform initial setups. + ### + def connect + @socket = TCPSocket.new( @host, @port ) + + self.setup_effects + self.polyphony( self.class.polyphony ) + end + + + ### Setup polyphony amount. + ### + def polyphony( val ) + self.send "synth.polyphony #{val}" + end + + + ### Stop all notes and reset effects. + ### + def panic + self.send "reset" + end + + + ### Alter the default volume. + ### + def gain( level ) + self.send "gain #{level}" + end + + + ### Turn glide (portamento) on or off. Val is an integer that + ### represents 128ms. 4 == 512ms. + ### + def glide( val ) + if val.zero? + self.send "cc 0 68 0" + self.send "cc 0 65 0" + self.send "cc 0 5 0" + else + self.send "setlegatomode 0 1" + self.send "cc 0 68 127" # legato + self.send "cc 0 65 127" # portamento + self.send "cc 0 5 #{val}" + end + end + + + ### Play a midi +note+ at +velocity+, then silence the previous note. + ### (This allows portamento to function if enabled.) + ### + def playnote( note, velocity=self.velocity ) + return if @lastnote == note + self.send "noteon 0 #{note} #{velocity}" + self.stopnote + @lastnote = note + end + + + ### Silences the previous note played. + ### + def stopnote + return unless @lastnote + self.send "noteoff 0 #{@lastnote}" + end + + + ### Selects an instrument +index+ within the currently loaded sf2 for channel 0. + ### + def instrument( index ) + self.send "select 0 1 0 #{index}" + end + + + ### Loads a resource (sf2, etc) from +path+. + ### + def load( path ) + self.send "load #{path}" + end + + + ### Enable/disable reverb. + ### + def reverb( toggle=true ) + toggle = toggle ? 1 : 0 + self.send "set synth.reverb.active #{toggle}" + end + + + ### Enable/disable chorus. + ### + def chorus( toggle=true ) + toggle = toggle ? 1 : 0 + self.send "set synth.chorus.active #{toggle}" + end + + + ######### + protected + ######### + + ### Initial effect setup. + ### + def setup_effects + self.send "set synth.reverb.room-size 0.9" + self.send "set synth.reverb.level 0.7" + self.send "set synth.chorus.level 1.5" + self.send "set synth.chorus.depth 64.0" + end + + + ### Issue a command to the synth, after possible logging. + ### + def send( cmd ) + self.log.debug( cmd ) + self.socket.puts( cmd ) + end +end + + diff --git a/theremin/lib/gpio.rb b/theremin/lib/gpio.rb new file mode 100755 index 0000000..95cca86 --- /dev/null +++ b/theremin/lib/gpio.rb @@ -0,0 +1,121 @@ +# -*- ruby -*- +# vim: set noet sta sw=4 ts=4 : + +require 'socket' +require 'configurability' +require 'loggability' +require 'pathname' + + +# A representation of a GPIO pin. +# Manages basic reads/writes (HIGH/LOW). +# +class GPIO + extend Configurability, + Loggability + + # Default read timeout. + TIMEOUT = 0.5 + + # The sysfs path. + BASE = Pathname( "/sys/class/gpio" ) + + + # Loggability API + log_as :gpio + + + ### Instance a new GPIO at +pin+. It will be automatically exported. + ### + def initialize( pin ) + @pin = pin + @paths = { + export: BASE + "export", + unexport: BASE + "unexport", + edge: BASE + "gpio#{pin}" + "edge", + direction: BASE + "gpio#{pin}" + "direction", + value: BASE + "gpio#{pin}" + "value" + } + + self.export rescue Errno::EBUSY nil + end + + # The GPIO PIN number. + attr_reader :pin + + + ### Mark this pin as either input or output. + ### + def mode( dir ) + case dir.to_sym + when :in, :out + self.set( :direction ) { dir.to_s } + else + raise "Mode must be :in or :out." + end + end + + + ### Hint at voltage measurement capture. + ### + def edge( mode ) + case mode.to_sym + when :none, :rising, :falling, :both + self.set( :edge ) { mode.to_s } + else + raise "Edge must be one of: :none, :rising, :falling, or :both" + end + end + + + ### Tell the operating system to make this gpio available to sysfs. + ### + def export + self.set( :export ) { self.pin } + end + + + ### Remove this gpio from sysfs. + ### + def unexport + self.set( :unexport ) { self.pin } + end + + + ### Write a single +val+ to the GPIO. + ### + def write( val ) + self.set( :value ) { val } + end + + + ### Read a single value from the GPIO, with an optional timeout. + ### + def read( timeout=TIMEOUT ) + io = @paths[ :value ].open + + ready = IO.select( [io], nil, nil, timeout ) + return unless ready # timeout + + rv = io.read( 1 ).to_i + self.log.debug "Read %p from GPIO %d" % [ rv, self.pin ] + return rv + ensure + io.close + end + + + ######### + protected + ######### + + ### Generic write helper. + ### + def set( path, &block ) + @paths[ path ].open( 'w' ) do |f| + self.log.debug "Writing %p to %p of GPIO %d" % [ block.yield, path, self.pin ] + f.puts( block.yield ) + end + end +end + diff --git a/theremin/lib/theremin.rb b/theremin/lib/theremin.rb new file mode 100755 index 0000000..70fb766 --- /dev/null +++ b/theremin/lib/theremin.rb @@ -0,0 +1,293 @@ +# -*- ruby -*- +# vim: set noet sta sw=4 ts=4 : + +require 'forwardable' +require 'configurability' +require 'loggability' +require 'gpio' +require 'fluidsynth' + + +# A class that coordinates GPIO events and translates +# them into commands for Fluidsynth. +# +class Theremin + extend Configurability, + Loggability, + Forwardable + + # The speed of sound, in centmeters/sec. + SONICSPEED = 34300 + + # Loggability API + log_as :theremin + + # Configuration API + # + configurability( :theremin ) do + + ## + # The host that fluidsynth lives at. + setting :host, default: 'localhost' + + ## + # The port that fluidsynth lives at. + setting :port, default: 9800 + + ## + # The GPIO pin the pitch trigger is on. + setting :pitch_trigger do |val| + GPIO.new( val ) if val + end + + ## + # The GPIO pin the pitch echo measurement is on. + setting :pitch_echo do |val| + GPIO.new( val ) if val + end + + ## + # The GPIO pin the velocity trigger is on. + setting :velocity_trigger do |val| + GPIO.new( val ) if val + end + + ## + # The GPIO pin the velocity echo measurement is on. + setting :velocity_echo do |val| + GPIO.new( val ) if val + end + + ## + # The soundfont to use for the synth. Will look in the + # current working directory by default, but absolute paths + # can be used for system sounds. + setting :font, default: 'Theremin.sf2' do |val| + val = Pathname( val ) + val = Pathname.pwd + val unless val.absolute? + val + end + + + ## + # If a note is "stuck", turn if off after this many seconds. + setting :cancel_after, default: 5 + + ## + # A reasonable distance in feet for the theremin to map notes. + setting :distance_max, default: 4 + + ## + # Initial synth volume. + setting :gain, default: 1.0 + + ## + # Enable reverb? + setting :reverb, default: true + + ## + # Enable chorus? + setting :chorus, default: true + + ## + # What instrument to use from the loaded SF2. + setting :instrument_index, default: 1 + + ## + # Portamento. Value in milliseconds to glide between notes to + # emulate analog theremin. 0 disables completely. Values + # are in 128ms increments. + setting :glide, default: 768 do |val| + val = 128 if val < 128 && ! val.zero? + val = ( val.to_f / 128 ).round + val + end + + ## + # How frequently should the range finders measure distance? + # Value in seconds. + setting :sample_interval, default: 0.3 + + ## + # Only change the note if the range finder value moves +/- this much + # between samples. This helps hold pitch, and avoids "warbles". + setting :slew, default: 5 + end + + + ### Instance a new Theremin daemon. + ### + def initialize + # TODO: @pins = %i[ pitch_trigger pitch_echo velocity_trigger velocity_echo ] + @pins = %i[ pitch_trigger ] + + self.setup_gpio + + @timeout = 0 + @synth = FluidSynth.new( self.class.host, self.class.port ) + @running = false + @threads = [] + + rescue => err + self.log.warn( err.message ) + self.stop rescue nil + end + + # Delegate various methods directly to the underlying synth instance. + def_delegators :@synth, :gain, :glide, :load, :reverb, :chorus, :instrument + + # The names of the GPIO pins. + attr_reader :pins + + # The Fluidsynth instance. + attr_reader :synth + + # Is this Theremin currently running? + attr_reader :running + + + ### Start polling the GPIO for measurements. + ### + def start + @running = true + self.synth.connect + + self.gain( self.class.gain ) + self.glide( self.class.glide ) + self.load( self.class.font.to_s ) + self.reverb( self.class.reverb ) + self.chorus( self.class.chorus ) + self.instrument( self.class.instrument_index ) + + @threads << self.note_thread + self.log.warn "Waiting for interaction, or ctrl-c to exit." + @threads.map( &:join ) + end + + + ### Cleanly shutdown GPIO and synth. + ### + def stop + @running = false + self.pins.each {|pin| self.class.send( pin ).unexport rescue nil } + self.synth.socket.close + end + + + ######### + protected + ######### + + ### Measure pitch distance, and manage notes sent to the synth. + ### + def note_thread + self.log.info "Starting note/pitch measurements (every %s/sec) within %d feet." % [ + self.class.sample_interval, + self.class.distance_max + ] + + return self.running_thread do + distance = self.distance + note = self.distance_to_note( distance ) + + # Only play if within maximum distance. + # + if distance <= self.class.distance_max + self.log.debug "Pitch distance %0.2f (note %s)" % [ distance, note ] + + # Hold the note if it is unchanged within slew amount. + # + if synth.lastnote + if note <= synth.lastnote - self.class.slew || note >= synth.lastnote + self.class.slew + synth.playnote( note ) + end + else + synth.playnote( note ) + end + + # Cancel note if timeout is hit. + # + elsif @timeout >= self.class.cancel_after + @timeout = 0 + synth.stopnote + end + + sleep self.class.sample_interval + @timeout += self.class.sample_interval + end + end + + + ### Create a new thread loop that returns when @running is false. + ### + def running_thread( &block ) + return Thread.new do + while @running + yield + end + end + end + + + ### Validate GPIO config, and mark edge behavior for the echo measurement pins. + ### + def setup_gpio + pincheck = self.pins.map{|p| self.class.send(p) } + + if pincheck.any?( nil ) + self.stop rescue nil + raise "GPIO pins are required configuration options: %p" % [ self.pins ] + end + + self.class.pitch_trigger.edge( :both ) + # self.class.pitch_echo.edge( :both ) + # TODO: self.class.velocity_echo.edge( :both ) + end + + + ### For a given +distance+ in feet, convert into a valid MIDI note + ### within the range. + ### + def distance_to_note( distance ) + midi_start = 107 # 127, but the theramin soundfont maxes out at 107. + midi_end = 0 + distance_min = 0 + + slope = 1.0 * ( midi_end - midi_start ) / ( self.class.distance_max - distance_min ) + return midi_start + ( slope * ( distance - distance_min ) ).round + end + + + ### TODO: make generic for a trigger/echo pin pair + ### Returns detected distance in feet. + ### + def distance + self.class.pitch_trigger.mode( :out ) + self.class.pitch_trigger.write( 1 ) + sleep 0.00001 + self.class.pitch_trigger.write( 0 ) + self.class.pitch_trigger.mode( :in ) + + duration = Time.now + while self.class.pitch_trigger.read == 0 + starttime = Time.now + break if starttime - duration > GPIO::TIMEOUT + end + while self.class.pitch_trigger.read == 1 + stoptime = Time.now + break if stoptime - duration > GPIO::TIMEOUT + end + + elapsed = stoptime - starttime + distance = ( elapsed * SONICSPEED ) / 2 + distance = 0 if distance < 0 + + distance = distance / 30.48 + + return distance + + rescue + return 0 + end +end + diff --git a/theremin/run b/theremin/run new file mode 100755 index 0000000..6598790 --- /dev/null +++ b/theremin/run @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby +# vim: set noet sta sw=4 ts=4 : + +$LOAD_PATH << './lib' +require 'pathname' +require 'configurability' +require 'theremin' + +Configurability::Config.load( 'config.yml' ).install + +theremin = Theremin.new + +Signal.trap( "INT" ) do + puts "\n\nShutting down..." + theremin.stop if theremin.running +end + +theremin.start +