# -*- ruby -*- # vim: set noet sta sw=4 ts=4 : require 'pathname' require 'forwardable' require 'configurability' require 'loggability' require 'gpio' require 'fluidsynth' require 'open3' # 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 # The soundfont that is loaded to index 1. DEFAULT_FONT = Pathname.pwd + 'Theremin.sf2' # 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 gain trigger is on. setting :gain_trigger do |val| GPIO.new( val ) if val end ## # The GPIO pin the gain echo measurement is on. setting :gain_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. # The "Theremin.sf2" soundfont is always loaded into index 1! 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: 3 ## # Initial synth volume. setting :gain, default: 0.0 ## # Enable reverb? setting :reverb, default: true ## # Enable chorus? setting :chorus, default: true ## # What font to initially use. 1 is the theremin. 2 is user loaded. setting :font_index, default: 1 ## # What instrument to initially 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 ## # Optional message to speak at startup. setting :startup_message, default: nil ## ESpeak TTL options. setting :espeak_flags, default: %w[ -k20 -v f5 -s 160 -a 150 ] end ### Instance a new Theremin daemon. ### def initialize @pins = %i[ pitch_trigger pitch_echo gain_trigger gain_echo ] @timeout = 0 @synth = FluidSynth.new( self.class.host, self.class.port ) @speech_io = nil @running = false @threads = [] self.setup_speech self.setup_gpio 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 # An IO to stream to espeak. attr_reader :speech_io # 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( DEFAULT_FONT ) self.load( self.class.font.to_s ) unless self.class.font == DEFAULT_FONT self.reverb( self.class.reverb ) self.chorus( self.class.chorus ) self.instrument( self.class.font_index, self.class.instrument_index ) @threads << self.note_thread @threads << self.gain_thread self.led( :green ) self.speak( self.class.startup_message ) if self.class.startup_message 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 self.speech_io.close if self.speech_io && !self.speech_io.closed? self.led( :red ) 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( :pitch ) note = self.distance_to_note( distance ) # Only play if within maximum distance. # if distance > 0 && 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 ### Measure gain distance, convert for any playing notes. ### def gain_thread self.log.info "Starting gain measurements (every %s/sec) within %d feet." % [ self.class.sample_interval, self.class.distance_max ] return self.running_thread do distance = self.distance( :gain ) gain = self.distance_to_gain( distance ) # Only set if within maximum distance. # if distance > 0 && distance <= self.class.distance_max self.log.debug "Gain distance %0.2f (gain %s)" % [ distance, gain ] synth.gain( gain ) else synth.gain( 0 ) end sleep self.class.sample_interval end end ### Open a pipe to espeak, and wait on input. ## def setup_speech self.log.info "Starting background espeak process." cmd = [ 'espeak' ] cmd << self.class.espeak_flags cmd.flatten! @speech_io, stdout, stderr, wait_thr = Open3.popen3( *cmd ) 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.mode( :out ) self.class.gain_trigger.mode( :out ) self.class.pitch_echo.mode( :in ) self.class.pitch_echo.edge( :both ) self.class.gain_echo.mode( :in ) self.class.gain_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 ### For a given +distance+ in feet, convert into a gain range (0-5). ### def distance_to_gain( distance ) gain_start = 5.0 gain_end = 0.0 distance_min = 0 slope = 1.0 * ( gain_end - gain_start ) / ( self.class.distance_max - distance_min ) return ( gain_start + ( slope * ( distance - distance_min ) ) ).round( 2 ) end ### Returns detected distance in feet for a given +device+. ### def distance( device ) device_trigger = self.class.send( "#{device}_trigger".to_sym ) device_echo = self.class.send( "#{device}_echo".to_sym ) device_trigger.write( 1 ) sleep 0.00001 device_trigger.write( 0 ) duration = Time.now while device_echo.read == 0 starttime = Time.now break if starttime - duration > GPIO::TIMEOUT end while device_echo.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 ### Set the LED. Maybe it'll be visible. ### def led( color ) case color when :red File.open( '/sys/class/leds/led0/brightness', 'w' ){|l| l.puts(0) } File.open( '/sys/class/leds/led1/brightness', 'w' ){|l| l.puts(1) } when :green File.open( '/sys/class/leds/led0/brightness', 'w' ){|l| l.puts(1) } File.open( '/sys/class/leds/led1/brightness', 'w' ){|l| l.puts(0) } end end ### Send text to the speech engine. ### def speak( phrase ) self.speech_io.puts( phrase ) end end # class Theremin