Initial commit.
Stuff working well with a single 3-pin range finder. FossilOrigin-Name: 483d8caaef350827c026d7c234b026e9e720d26a7e27e54b0436f60b099a0ab9
This commit is contained in:
parent
af047120c4
commit
07b2d81523
9 changed files with 674 additions and 0 deletions
293
theremin/lib/theremin.rb
Executable file
293
theremin/lib/theremin.rb
Executable file
|
|
@ -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
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue