2023-05-09 03:32:09 +00:00
|
|
|
# -*- ruby -*-
|
|
|
|
|
# vim: set noet sta sw=4 ts=4 :
|
|
|
|
|
|
2023-05-12 05:46:35 +00:00
|
|
|
require 'pathname'
|
2023-05-09 03:32:09 +00:00
|
|
|
require 'forwardable'
|
|
|
|
|
require 'configurability'
|
|
|
|
|
require 'loggability'
|
|
|
|
|
require 'gpio'
|
|
|
|
|
require 'fluidsynth'
|
2023-05-12 06:44:09 +00:00
|
|
|
require 'open3'
|
2023-05-24 03:08:39 +00:00
|
|
|
require 'socket'
|
2023-05-09 03:32:09 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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.
|
2023-05-12 05:46:35 +00:00
|
|
|
SONICSPEED = 34300
|
|
|
|
|
|
|
|
|
|
# The soundfont that is loaded to index 1.
|
|
|
|
|
DEFAULT_FONT = Pathname.pwd + 'Theremin.sf2'
|
|
|
|
|
|
2023-05-09 03:32:09 +00:00
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
##
|
2023-05-12 06:44:09 +00:00
|
|
|
# The GPIO pin the gain trigger is on.
|
|
|
|
|
setting :gain_trigger do |val|
|
2023-05-09 03:32:09 +00:00
|
|
|
GPIO.new( val ) if val
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
##
|
2023-05-12 06:44:09 +00:00
|
|
|
# The GPIO pin the gain echo measurement is on.
|
|
|
|
|
setting :gain_echo do |val|
|
2023-05-09 03:32:09 +00:00
|
|
|
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.
|
2023-05-12 05:46:35 +00:00
|
|
|
# The "Theremin.sf2" soundfont is always loaded into index 1!
|
2023-05-09 03:32:09 +00:00
|
|
|
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.
|
2023-05-12 05:46:35 +00:00
|
|
|
setting :distance_max, default: 3
|
2023-05-09 03:32:09 +00:00
|
|
|
|
|
|
|
|
##
|
|
|
|
|
# Initial synth volume.
|
2023-05-12 06:44:09 +00:00
|
|
|
setting :gain, default: 0.0
|
2023-05-09 03:32:09 +00:00
|
|
|
|
|
|
|
|
##
|
|
|
|
|
# Enable reverb?
|
|
|
|
|
setting :reverb, default: true
|
|
|
|
|
|
|
|
|
|
##
|
|
|
|
|
# Enable chorus?
|
|
|
|
|
setting :chorus, default: true
|
|
|
|
|
|
|
|
|
|
##
|
2023-05-12 05:46:35 +00:00
|
|
|
# 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.
|
2023-05-09 03:32:09 +00:00
|
|
|
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
|
2023-05-12 17:36:55 +00:00
|
|
|
|
|
|
|
|
##
|
|
|
|
|
# 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 ]
|
2023-05-24 03:08:39 +00:00
|
|
|
|
|
|
|
|
## Watch for camera motion?
|
|
|
|
|
setting :use_motion, default: false
|
|
|
|
|
|
|
|
|
|
## A list of phrases to randomly say when motion is detected.
|
|
|
|
|
setting :motion_messages, default: []
|
2023-05-09 03:32:09 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### Instance a new Theremin daemon.
|
|
|
|
|
###
|
|
|
|
|
def initialize
|
2023-05-12 06:44:09 +00:00
|
|
|
@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
|
2023-05-12 05:46:35 +00:00
|
|
|
self.setup_gpio
|
|
|
|
|
|
2023-05-09 03:32:09 +00:00
|
|
|
rescue => err
|
|
|
|
|
self.log.warn( err.message )
|
|
|
|
|
self.stop rescue nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Delegate various methods directly to the underlying synth instance.
|
2023-05-12 06:45:38 +00:00
|
|
|
def_delegators :@synth, :gain, :glide, :load, :reverb, :chorus, :instrument
|
2023-05-09 03:32:09 +00:00
|
|
|
|
|
|
|
|
# The names of the GPIO pins.
|
|
|
|
|
attr_reader :pins
|
|
|
|
|
|
|
|
|
|
# The Fluidsynth instance.
|
2023-05-12 06:45:38 +00:00
|
|
|
attr_reader :synth
|
2023-05-09 03:32:09 +00:00
|
|
|
|
2023-05-12 06:44:09 +00:00
|
|
|
# An IO to stream to espeak.
|
2023-05-12 06:45:38 +00:00
|
|
|
attr_reader :speech_io
|
2023-05-12 06:44:09 +00:00
|
|
|
|
2023-05-24 03:08:39 +00:00
|
|
|
# A TCPServer for motion IPC
|
|
|
|
|
attr_reader :motion_socket
|
|
|
|
|
|
2023-05-09 03:32:09 +00:00
|
|
|
# Is this Theremin currently running?
|
2023-05-12 06:45:38 +00:00
|
|
|
attr_reader :running
|
2023-05-09 03:32:09 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
### Start polling the GPIO for measurements.
|
|
|
|
|
###
|
|
|
|
|
def start
|
|
|
|
|
@running = true
|
|
|
|
|
self.synth.connect
|
|
|
|
|
|
|
|
|
|
self.gain( self.class.gain )
|
|
|
|
|
self.glide( self.class.glide )
|
2023-05-12 05:46:35 +00:00
|
|
|
self.load( DEFAULT_FONT )
|
|
|
|
|
self.load( self.class.font.to_s ) unless self.class.font == DEFAULT_FONT
|
2023-05-09 03:32:09 +00:00
|
|
|
self.reverb( self.class.reverb )
|
|
|
|
|
self.chorus( self.class.chorus )
|
2023-05-12 05:46:35 +00:00
|
|
|
self.instrument( self.class.font_index, self.class.instrument_index )
|
2023-05-09 03:32:09 +00:00
|
|
|
|
2023-05-24 03:08:39 +00:00
|
|
|
self.speak( self.class.startup_message ) if self.class.startup_message
|
2023-05-09 03:32:09 +00:00
|
|
|
@threads << self.note_thread
|
2023-05-12 06:44:09 +00:00
|
|
|
@threads << self.gain_thread
|
2023-05-24 03:08:39 +00:00
|
|
|
@threads << self.motion_thread if self.class.use_motion
|
2023-05-12 05:46:35 +00:00
|
|
|
self.led( :green )
|
2023-05-09 03:32:09 +00:00
|
|
|
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
|
2023-05-24 03:08:39 +00:00
|
|
|
self.motion_socket.close
|
2023-05-12 06:44:09 +00:00
|
|
|
self.speech_io.close if self.speech_io && !self.speech_io.closed?
|
2023-05-12 05:46:35 +00:00
|
|
|
self.led( :red )
|
2023-05-09 03:32:09 +00:00
|
|
|
end
|
|
|
|
|
|
2023-05-24 03:08:39 +00:00
|
|
|
|
|
|
|
|
#########
|
|
|
|
|
protected
|
|
|
|
|
#########
|
2023-05-09 03:32:09 +00:00
|
|
|
|
|
|
|
|
### 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
|
2023-05-12 05:46:35 +00:00
|
|
|
distance = self.distance( :pitch )
|
2023-05-09 03:32:09 +00:00
|
|
|
note = self.distance_to_note( distance )
|
|
|
|
|
|
|
|
|
|
# Only play if within maximum distance.
|
|
|
|
|
#
|
2023-05-12 05:46:35 +00:00
|
|
|
if distance > 0 && distance <= self.class.distance_max
|
2023-05-09 03:32:09 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2023-05-12 06:44:09 +00:00
|
|
|
### Measure gain distance, convert for any playing notes.
|
2023-05-12 05:46:35 +00:00
|
|
|
###
|
2023-05-12 06:44:09 +00:00
|
|
|
def gain_thread
|
|
|
|
|
self.log.info "Starting gain measurements (every %s/sec) within %d feet." % [
|
2023-05-12 05:46:35 +00:00
|
|
|
self.class.sample_interval,
|
|
|
|
|
self.class.distance_max
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return self.running_thread do
|
2023-05-12 06:44:09 +00:00
|
|
|
distance = self.distance( :gain )
|
|
|
|
|
gain = self.distance_to_gain( distance )
|
2023-05-12 05:46:35 +00:00
|
|
|
|
|
|
|
|
# Only set if within maximum distance.
|
|
|
|
|
#
|
|
|
|
|
if distance > 0 && distance <= self.class.distance_max
|
2023-05-12 06:44:09 +00:00
|
|
|
self.log.debug "Gain distance %0.2f (gain %s)" % [ distance, gain ]
|
|
|
|
|
synth.gain( gain )
|
2023-05-12 05:46:35 +00:00
|
|
|
else
|
2023-05-12 06:44:09 +00:00
|
|
|
synth.gain( 0 )
|
2023-05-12 05:46:35 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
sleep self.class.sample_interval
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
2023-05-24 03:08:39 +00:00
|
|
|
### Watch the motion fifo, say a random phrase when motion seen.
|
|
|
|
|
###
|
|
|
|
|
def motion_thread
|
|
|
|
|
phrases = self.class.motion_messages
|
|
|
|
|
return if phrases.empty?
|
|
|
|
|
|
|
|
|
|
@motion_socket = TCPServer.new( 62877 )
|
|
|
|
|
return self.running_thread do
|
|
|
|
|
client = @motion_socket.accept
|
|
|
|
|
self.log.info "Camera motion detected."
|
|
|
|
|
client.close
|
|
|
|
|
self.speak( phrases.sample )
|
|
|
|
|
rescue IOError
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
2023-05-12 06:44:09 +00:00
|
|
|
### Open a pipe to espeak, and wait on input.
|
|
|
|
|
##
|
|
|
|
|
def setup_speech
|
|
|
|
|
self.log.info "Starting background espeak process."
|
2023-05-12 17:36:55 +00:00
|
|
|
cmd = [ 'espeak' ]
|
|
|
|
|
cmd << self.class.espeak_flags
|
|
|
|
|
cmd.flatten!
|
2023-05-12 06:44:09 +00:00
|
|
|
@speech_io, stdout, stderr, wait_thr = Open3.popen3( *cmd )
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
2023-05-09 03:32:09 +00:00
|
|
|
### 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
|
|
|
|
|
|
2023-05-12 05:46:35 +00:00
|
|
|
self.class.pitch_trigger.mode( :out )
|
2023-05-12 06:44:09 +00:00
|
|
|
self.class.gain_trigger.mode( :out )
|
2023-05-12 05:46:35 +00:00
|
|
|
self.class.pitch_echo.mode( :in )
|
|
|
|
|
self.class.pitch_echo.edge( :both )
|
2023-05-12 06:44:09 +00:00
|
|
|
self.class.gain_echo.mode( :in )
|
|
|
|
|
self.class.gain_echo.edge( :both )
|
2023-05-09 03:32:09 +00:00
|
|
|
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 )
|
2023-05-12 06:44:09 +00:00
|
|
|
return ( midi_start + ( slope * ( distance - distance_min ) ) ).round
|
2023-05-09 03:32:09 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
|
2023-05-12 06:44:09 +00:00
|
|
|
### For a given +distance+ in feet, convert into a gain range (0-5).
|
2023-05-09 03:32:09 +00:00
|
|
|
###
|
2023-05-12 06:44:09 +00:00
|
|
|
def distance_to_gain( distance )
|
|
|
|
|
gain_start = 5.0
|
|
|
|
|
gain_end = 0.0
|
|
|
|
|
distance_min = 0
|
2023-05-12 05:46:35 +00:00
|
|
|
|
2023-05-12 06:44:09 +00:00
|
|
|
slope = 1.0 * ( gain_end - gain_start ) / ( self.class.distance_max - distance_min )
|
|
|
|
|
return ( gain_start + ( slope * ( distance - distance_min ) ) ).round( 2 )
|
2023-05-12 05:46:35 +00:00
|
|
|
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 )
|
2023-05-09 03:32:09 +00:00
|
|
|
sleep 0.00001
|
2023-05-12 05:46:35 +00:00
|
|
|
device_trigger.write( 0 )
|
2023-05-09 03:32:09 +00:00
|
|
|
|
|
|
|
|
duration = Time.now
|
2023-05-12 05:46:35 +00:00
|
|
|
while device_echo.read == 0
|
2023-05-09 03:32:09 +00:00
|
|
|
starttime = Time.now
|
|
|
|
|
break if starttime - duration > GPIO::TIMEOUT
|
|
|
|
|
end
|
2023-05-12 05:46:35 +00:00
|
|
|
while device_echo.read == 1
|
2023-05-09 03:32:09 +00:00
|
|
|
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
|
2023-05-12 05:46:35 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
### Set the LED. Maybe it'll be visible.
|
|
|
|
|
###
|
|
|
|
|
def led( color )
|
|
|
|
|
case color
|
|
|
|
|
when :red
|
2023-05-24 03:08:39 +00:00
|
|
|
File.open( '/sys/class/leds/PWR/brightness', 'w' ){|l| l.puts(1) }
|
|
|
|
|
File.open( '/sys/class/leds/ACT/brightness', 'w' ){|l| l.puts(0) }
|
2023-05-12 05:46:35 +00:00
|
|
|
when :green
|
2023-05-24 03:08:39 +00:00
|
|
|
File.open( '/sys/class/leds/PWR/brightness', 'w' ){|l| l.puts(0) }
|
|
|
|
|
File.open( '/sys/class/leds/ACT/brightness', 'w' ){|l| l.puts(1) }
|
2023-05-12 05:46:35 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2023-05-12 06:44:09 +00:00
|
|
|
|
|
|
|
|
### Send text to the speech engine.
|
|
|
|
|
###
|
|
|
|
|
def speak( phrase )
|
|
|
|
|
self.speech_io.puts( phrase )
|
|
|
|
|
end
|
|
|
|
|
|
2023-05-12 05:46:35 +00:00
|
|
|
end # class Theremin
|
2023-05-09 03:32:09 +00:00
|
|
|
|