2019-09-01 14:54:09 +00:00
import sys , os , argparse , types , logging , select , time , json
2019-08-20 01:18:57 +00:00
from datetime import datetime
import multiprocessing
from multiprocessing import Process , Pipe
from picadae_api import Picadae
from AudioDevice import AudioDevice
from result import Result
2019-09-01 14:54:09 +00:00
from common import parse_yaml_cfg
from plot_seq import form_resample_pulse_time_list
2019-11-09 16:13:34 +00:00
from plot_seq import form_final_pulse_list
2019-11-18 16:44:47 +00:00
from rt_note_analysis import RT_Analyzer
from keyboard import Keyboard
2019-08-20 01:18:57 +00:00
class AttackPulseSeq :
""" Sequence a fixed chord over a list of attack pulse lengths. """
2019-11-18 16:44:47 +00:00
def __init__ ( self , cfg , audio , api , noteDurMs = 1000 , pauseDurMs = 1000 ) :
self . cfg = cfg
2019-08-20 01:18:57 +00:00
self . audio = audio
self . api = api
self . outDir = None # directory to write audio file and results
self . pitchL = None # chord to play
self . pulseUsL = [ ] # one onset pulse length in microseconds per sequence element
self . noteDurMs = noteDurMs # duration of each chord in milliseconds
self . pauseDurMs = pauseDurMs # duration between end of previous note and start of next
2019-11-18 16:44:47 +00:00
self . holdDutyPctL = None # hold voltage duty cycle table [ (minPulseSeqUsec,dutyCyclePct) ]
2019-08-20 01:18:57 +00:00
self . pulse_idx = 0 # Index of next pulse
self . state = None # 'note_on','note_off'
2019-11-09 16:13:34 +00:00
self . prevHoldDutyPct = None
2019-08-20 01:18:57 +00:00
self . next_ms = 0 # Time of next event (note-on or note_off)
2019-11-09 16:13:34 +00:00
self . eventTimeL = [ ] # Onset/offset time of each note [ [onset_ms,offset_ms] ] (used to locate the note in the audio file)
2019-08-20 01:18:57 +00:00
self . beginMs = 0
2019-09-01 14:54:09 +00:00
self . playOnlyFl = False
2019-11-18 16:44:47 +00:00
self . rtAnalyzer = RT_Analyzer ( )
2019-08-20 01:18:57 +00:00
2019-11-18 16:44:47 +00:00
def start ( self , ms , outDir , pitchL , pulseUsL , holdDutyPctL , playOnlyFl = False ) :
2019-08-20 01:18:57 +00:00
self . outDir = outDir # directory to write audio file and results
self . pitchL = pitchL # chord to play
self . pulseUsL = pulseUsL # one onset pulse length in microseconds per sequence element
2019-11-18 16:44:47 +00:00
self . holdDutyPctL = holdDutyPctL
2019-08-20 01:18:57 +00:00
self . pulse_idx = 0
self . state = ' note_on '
2019-11-09 16:13:34 +00:00
self . prevHoldDutyPct = None
2019-08-20 01:18:57 +00:00
self . next_ms = ms + 500 # wait for 500ms to play the first note (this will guarantee that there is some empty space in the audio file before the first note)
2019-09-01 14:54:09 +00:00
self . eventTimeL = [ [ 0 , 0 ] for _ in range ( len ( pulseUsL ) ) ] # initialize the event time
2019-08-20 01:18:57 +00:00
self . beginMs = ms
2019-09-01 14:54:09 +00:00
self . playOnlyFl = playOnlyFl
2019-11-09 16:13:34 +00:00
#for pitch in pitchL:
# self.api.set_pwm_duty( pitch, self.holdDutyPct )
# print("set PWM:%i"%(self.holdDutyPct))
2019-09-01 14:54:09 +00:00
2019-11-18 16:44:47 +00:00
# kpl if not playOnlyFl:
self . audio . record_enable ( True ) # start recording audio
2019-08-20 01:18:57 +00:00
self . tick ( ms ) # play the first note
def stop ( self , ms ) :
self . _send_note_off ( ) # be sure that all notes are actually turn-off
2019-09-01 14:54:09 +00:00
2019-11-18 16:44:47 +00:00
# kpl if not self.playOnlyFl:
self . audio . record_enable ( False ) # stop recording audio
2019-09-01 14:54:09 +00:00
2019-08-20 01:18:57 +00:00
self . _disable ( ) # disable this sequencer
2019-09-01 14:54:09 +00:00
if not self . playOnlyFl :
self . _write ( ) # write the results
2019-08-20 01:18:57 +00:00
def is_enabled ( self ) :
return self . state is not None
def tick ( self , ms ) :
# if next event time has arrived
if self . is_enabled ( ) and ms > = self . next_ms :
# if waiting to turn note on
if self . state == ' note_on ' :
self . _note_on ( ms )
# if waiting to turn a note off
elif self . state == ' note_off ' :
self . _note_off ( ms )
self . pulse_idx + = 1
# if all notes have been played
if self . pulse_idx > = len ( self . pulseUsL ) :
self . stop ( ms )
else :
assert ( 0 )
2019-11-09 16:13:34 +00:00
def _get_duty_cycle ( self , pulseUsec ) :
dutyPct = self . holdDutyPctL [ 0 ] [ 1 ]
for refUsec , refDuty in self . holdDutyPctL :
if pulseUsec < refUsec :
break
dutyPct = refDuty
return dutyPct
def _set_duty_cycle ( self , pitch , pulseUsec ) :
dutyPct = self . _get_duty_cycle ( pulseUsec )
if dutyPct != self . prevHoldDutyPct :
self . api . set_pwm_duty ( pitch , dutyPct )
print ( " Hold Duty: " , dutyPct )
self . prevHoldDutyPct = dutyPct
2019-08-20 01:18:57 +00:00
def _note_on ( self , ms ) :
2019-09-01 14:54:09 +00:00
self . eventTimeL [ self . pulse_idx ] [ 0 ] = self . audio . buffer_sample_ms ( ) . value
2019-08-20 01:18:57 +00:00
self . next_ms = ms + self . noteDurMs
self . state = ' note_off '
for pitch in self . pitchL :
2019-11-09 16:13:34 +00:00
pulse_usec = int ( self . pulseUsL [ self . pulse_idx ] )
self . _set_duty_cycle ( pitch , pulse_usec )
self . api . note_on_us ( pitch , pulse_usec )
2019-11-18 16:44:47 +00:00
print ( " note-on: " , pitch , self . pulse_idx , pulse_usec )
2019-08-20 01:18:57 +00:00
def _note_off ( self , ms ) :
2019-09-01 14:54:09 +00:00
self . eventTimeL [ self . pulse_idx ] [ 1 ] = self . audio . buffer_sample_ms ( ) . value
2019-08-20 01:18:57 +00:00
self . next_ms = ms + self . pauseDurMs
self . state = ' note_on '
2019-11-18 16:44:47 +00:00
if self . playOnlyFl :
begTimeMs = self . eventTimeL [ self . pulse_idx ] [ 0 ]
endTimeMs = self . eventTimeL [ self . pulse_idx ] [ 1 ]
self . rtAnalyzer . analyze_note ( self . audio , self . pitchL [ 0 ] , begTimeMs , endTimeMs , self . cfg . analysisArgs [ ' rmsAnalysisArgs ' ] )
2019-08-20 01:18:57 +00:00
self . _send_note_off ( )
def _send_note_off ( self ) :
for pitch in self . pitchL :
self . api . note_off ( pitch )
2019-11-18 16:44:47 +00:00
#print("note-off:",pitch,self.pulse_idx)
2019-08-20 01:18:57 +00:00
def _disable ( self ) :
self . state = None
def _write ( self ) :
d = {
" pulseUsL " : self . pulseUsL ,
" pitchL " : self . pitchL ,
" noteDurMs " : self . noteDurMs ,
" pauseDurMs " : self . pauseDurMs ,
2019-11-09 16:13:34 +00:00
" holdDutyPctL " : self . holdDutyPctL ,
2019-08-20 01:18:57 +00:00
" eventTimeL " : self . eventTimeL ,
" beginMs " : self . beginMs
}
print ( " Writing: " , self . outDir )
outDir = os . path . expanduser ( self . outDir )
if not os . path . isdir ( outDir ) :
os . mkdir ( outDir )
with open ( os . path . join ( outDir , " seq.json " ) , " w " ) as f :
f . write ( json . dumps ( d ) )
self . audio . write_buffer ( os . path . join ( outDir , " audio.wav " ) )
class CalibrateKeys :
def __init__ ( self , cfg , audioDev , api ) :
self . cfg = cfg
2019-11-18 16:44:47 +00:00
self . seq = AttackPulseSeq ( cfg , audioDev , api , noteDurMs = cfg . noteDurMs , pauseDurMs = cfg . pauseDurMs )
2019-08-20 01:18:57 +00:00
self . pulseUsL = None
self . chordL = None
self . pitch_idx = - 1
2019-09-01 14:54:09 +00:00
def start ( self , ms , chordL , pulseUsL , playOnlyFl = False ) :
2019-08-20 01:18:57 +00:00
if len ( chordL ) > 0 :
self . pulseUsL = pulseUsL
self . chordL = chordL
self . pitch_idx = - 1
2019-09-01 14:54:09 +00:00
self . _start_next_chord ( ms , playOnlyFl )
2019-08-20 01:18:57 +00:00
def stop ( self , ms ) :
self . pitch_idx = - 1
self . seq . stop ( ms )
def is_enabled ( self ) :
return self . pitch_idx > = 0
def tick ( self , ms ) :
if self . is_enabled ( ) :
self . seq . tick ( ms )
# if the sequencer is done playing
if not self . seq . is_enabled ( ) :
2019-09-01 14:54:09 +00:00
self . _start_next_chord ( ms , self . seq . playOnlyFl ) # ... else start the next sequence
2019-08-20 01:18:57 +00:00
return None
2019-09-01 14:54:09 +00:00
def _start_next_chord ( self , ms , playOnlyFl ) :
2019-08-20 01:18:57 +00:00
2019-09-01 14:54:09 +00:00
2019-08-20 01:18:57 +00:00
self . pitch_idx + = 1
# if the last chord in chordL has been played ...
if self . pitch_idx > = len ( self . chordL ) :
self . stop ( ms ) # ... then we are done
else :
pitchL = self . chordL [ self . pitch_idx ]
# be sure that the base directory exists
outDir = os . path . expanduser ( cfg . outDir )
if not os . path . isdir ( outDir ) :
os . mkdir ( outDir )
# form the output directory as "<label>_<pitch0>_<pitch1> ... "
2019-09-01 14:54:09 +00:00
dirStr = " _ " . join ( [ str ( pitch ) for pitch in pitchL ] )
2019-08-20 01:18:57 +00:00
outDir = os . path . join ( outDir , dirStr )
2019-09-01 14:54:09 +00:00
if not os . path . isdir ( outDir ) :
os . mkdir ( outDir )
# get the next available output directory id
outDir_id = self . _calc_next_out_dir_id ( outDir )
2019-11-09 16:13:34 +00:00
print ( outDir_id , outDir )
2019-09-01 14:54:09 +00:00
# if this is not the first time this note has been sampled then get the resample locations
if outDir_id != 0 :
self . pulseUsL , _ , _ = form_resample_pulse_time_list ( outDir , self . cfg . analysisArgs )
2019-11-18 16:44:47 +00:00
holdDutyPctL = self . cfg . holdDutyPctL
2019-09-01 14:54:09 +00:00
if playOnlyFl :
2019-11-18 16:44:47 +00:00
self . pulseUsL , _ , holdDutyPctL = form_final_pulse_list ( outDir , pitchL [ 0 ] , self . cfg . analysisArgs , take_id = None )
2019-09-01 14:54:09 +00:00
2019-11-09 16:13:34 +00:00
noteN = cfg . analysisArgs [ ' auditionNoteN ' ]
self . pulseUsL = [ self . pulseUsL [ int ( round ( i * 126.0 / ( noteN - 1 ) ) ) ] for i in range ( noteN ) ]
2019-09-01 14:54:09 +00:00
2019-11-09 16:13:34 +00:00
else :
outDir = os . path . join ( outDir , str ( outDir_id ) )
if not os . path . isdir ( outDir ) :
os . mkdir ( outDir )
2019-09-01 14:54:09 +00:00
2019-08-20 01:18:57 +00:00
# start the sequencer
2019-11-18 16:44:47 +00:00
self . seq . start ( ms , outDir , pitchL , self . pulseUsL , holdDutyPctL , playOnlyFl )
2019-08-20 01:18:57 +00:00
2019-09-01 14:54:09 +00:00
def _calc_next_out_dir_id ( self , outDir ) :
id = 0
while os . path . isdir ( os . path . join ( outDir , " %i " % id ) ) :
id + = 1
return id
2019-08-20 01:18:57 +00:00
# This is the main application API it is running in a child process.
class App :
def __init__ ( self ) :
self . cfg = None
self . audioDev = None
self . api = None
self . calibrate = None
2019-11-18 16:44:47 +00:00
self . keyboard = None
2019-08-20 01:18:57 +00:00
def setup ( self , cfg ) :
self . cfg = cfg
self . audioDev = AudioDevice ( )
#
# TODO: unify the result error handling
# (the API and the audio device return two diferent 'Result' types
#
res = self . audioDev . setup ( * * cfg . audio )
2019-09-01 14:54:09 +00:00
if not res :
self . audio_dev_list ( 0 )
else :
2019-08-20 01:18:57 +00:00
self . api = Picadae ( key_mapL = cfg . key_mapL )
# wait for the letter 'a' to come back from the serial port
api_res = self . api . wait_for_serial_sync ( timeoutMs = cfg . serial_sync_timeout_ms )
# did the serial port sync fail?
if not api_res :
res . set_error ( " Serial port sync failed. " )
else :
print ( " Serial port sync ' ed " )
self . calibrate = CalibrateKeys ( cfg , self . audioDev , self . api )
2019-11-18 16:44:47 +00:00
self . keyboard = Keyboard ( cfg , self . audioDev , self . api )
2019-08-20 01:18:57 +00:00
return res
def tick ( self , ms ) :
2019-11-18 16:44:47 +00:00
self . audioDev . tick ( ms )
2019-08-20 01:18:57 +00:00
if self . calibrate :
self . calibrate . tick ( ms )
2019-11-18 16:44:47 +00:00
if self . keyboard :
self . keyboard . tick ( ms )
2019-08-20 01:18:57 +00:00
def audio_dev_list ( self , ms ) :
portL = self . audioDev . get_port_list ( True )
for port in portL :
print ( " chs: %4i label: %s " % ( port [ ' chN ' ] , port [ ' label ' ] ) )
def calibrate_keys_start ( self , ms , pitchRangeL ) :
chordL = [ [ pitch ] for pitch in range ( pitchRangeL [ 0 ] , pitchRangeL [ 1 ] + 1 ) ]
2019-09-01 14:54:09 +00:00
self . calibrate . start ( ms , chordL , cfg . full_pulseL )
def play_keys_start ( self , ms , pitchRangeL ) :
chordL = [ [ pitch ] for pitch in range ( pitchRangeL [ 0 ] , pitchRangeL [ 1 ] + 1 ) ]
self . calibrate . start ( ms , chordL , cfg . full_pulseL , playOnlyFl = True )
2019-08-20 01:18:57 +00:00
2019-11-18 16:44:47 +00:00
def keyboard_start_pulse_idx ( self , ms , argL ) :
pitchL = [ pitch for pitch in range ( argL [ 0 ] , argL [ 1 ] + 1 ) ]
self . keyboard . start ( ms , pitchL , argL [ 2 ] , None )
def keyboard_repeat_pulse_idx ( self , ms , argL ) :
self . keyboard . repeat ( ms , argL [ 0 ] , None )
def keyboard_start_target_db ( self , ms , argL ) :
pitchL = [ pitch for pitch in range ( argL [ 0 ] , argL [ 1 ] + 1 ) ]
self . keyboard . start ( ms , pitchL , None , argL [ 2 ] )
def keyboard_repeat_target_db ( self , ms , argL ) :
self . keyboard . repeat ( ms , None , argL [ 0 ] )
2019-08-20 01:18:57 +00:00
def calibrate_keys_stop ( self , ms ) :
self . calibrate . stop ( ms )
2019-11-18 16:44:47 +00:00
self . keyboard . stop ( ms )
2019-08-20 01:18:57 +00:00
def quit ( self , ms ) :
if self . api :
self . api . close ( )
def _send_error ( pipe , res ) :
if res is None :
return
if res . msg :
pipe . send ( [ { " type " : " error " , ' value ' : res . msg } ] )
def _send_error_msg ( pipe , msg ) :
_send_error ( pipe , Result ( None , msg ) )
def _send_quit ( pipe ) :
pipe . send ( [ { ' type ' : ' quit ' } ] )
# This is the application engine async. process loop
def app_event_loop_func ( pipe , cfg ) :
multiprocessing . get_logger ( ) . info ( " App Proc Started. " )
# create the asynchronous application object
app = App ( )
res = app . setup ( cfg )
# if the app did not initialize successfully
if not res :
_send_error ( pipe , res )
_send_quit ( pipe )
return
dt0 = datetime . now ( )
ms = 0
while True :
# have any message arrived from the parent process?
if pipe . poll ( ) :
msg = None
try :
msg = pipe . recv ( )
except EOFError :
return
if not hasattr ( app , msg . type ) :
_send_error_msg ( pipe , " Unknown message type: ' %s ' . " % ( msg . type ) )
else :
# get the command handler function in 'app'
func = getattr ( app , msg . type )
ms = int ( round ( ( datetime . now ( ) - dt0 ) . total_seconds ( ) * 1000.0 ) )
2019-09-01 14:54:09 +00:00
2019-08-20 01:18:57 +00:00
# call the command handler
if msg . value :
res = func ( ms , msg . value )
else :
res = func ( ms )
# handle any errors returned from the commands
_send_error ( pipe , res )
# if a 'quit' msg was recived then break out of the loop
if msg . type == ' quit ' :
_send_quit ( pipe )
break
# give some time to the system
time . sleep ( 0.1 )
# calc the tick() time stamp
ms = int ( round ( ( datetime . now ( ) - dt0 ) . total_seconds ( ) * 1000.0 ) )
2019-09-01 14:54:09 +00:00
2019-08-20 01:18:57 +00:00
# tick the app
app . tick ( ms )
class AppProcess ( Process ) :
def __init__ ( self , cfg ) :
self . parent_end , child_end = Pipe ( )
super ( AppProcess , self ) . __init__ ( target = app_event_loop_func , name = " AppProcess " , args = ( child_end , cfg ) )
self . doneFl = False
def send ( self , d ) :
# This function is called by the parent process to send an arbitrary msg to the App process
self . parent_end . send ( types . SimpleNamespace ( * * d ) )
return None
def recv ( self ) :
# This function is called by the parent process to receive lists of child messages.
msgL = None
if not self . doneFl and self . parent_end . poll ( ) :
msgL = self . parent_end . recv ( )
for msg in msgL :
if msg [ ' type ' ] == ' quit ' :
self . doneFl = True
return msgL
def isdone ( self ) :
return self . doneFl
class Shell :
def __init__ ( self , cfg ) :
self . appProc = None
self . parseD = {
2019-11-18 16:44:47 +00:00
' q ' : { " func " : ' quit ' , " minN " : 0 , " maxN " : 0 , " help " : " quit " } ,
' ? ' : { " func " : " _help " , " minN " : 0 , " maxN " : 0 , " help " : " Print usage text. " } ,
' a ' : { " func " : " audio_dev_list " , " minN " : 0 , " maxN " : 0 , " help " : " List the audio devices. " } ,
' c ' : { " func " : " calibrate_keys_start " , " minN " : 1 , " maxN " : 2 , " help " : " Calibrate a range of keys. " } ,
' s ' : { " func " : " calibrate_keys_stop " , " minN " : 0 , " maxN " : 0 , " help " : " Stop key calibration " } ,
' p ' : { " func " : " play_keys_start " , " minN " : 1 , " maxN " : 2 , " help " : " Play current calibration " } ,
' k ' : { " func " : " keyboard_start_pulse_idx " , " minN " : 3 , " maxN " : 3 , " help " : " Play pulse index across keyboard " } ,
' r ' : { " func " : " keyboard_repeat_pulse_idx " , " minN " : 1 , " maxN " : 1 , " help " : " Repeat pulse index across keyboard with new pulse_idx " } ,
' K ' : { " func " : " keyboard_start_target_db " , " minN " : 3 , " maxN " : 3 , " help " : " Play db across keyboard " } ,
' R ' : { " func " : " keyboard_repeat_target_db " , " minN " : 1 , " maxN " : 1 , " help " : " Repeat db across keyboard with new pulse_idx " } ,
2019-08-20 01:18:57 +00:00
}
def _help ( self , _ = None ) :
for k , d in self . parseD . items ( ) :
s = " {} = {} " . format ( k , d [ ' help ' ] )
print ( s )
return None
def _syntaxError ( self , msg ) :
return Result ( None , " Syntax Error: " + msg )
def _exec_cmd ( self , tokL ) :
if len ( tokL ) < = 0 :
return None
opcode = tokL [ 0 ]
if opcode not in self . parseD :
return self . _syntaxError ( " Unknown opcode: ' {} ' . " . format ( opcode ) )
d = self . parseD [ opcode ]
func_name = d [ ' func ' ]
func = None
# find the function associated with this command
if hasattr ( self , func_name ) :
func = getattr ( self , func_name )
try :
# convert the parameter list into integers
argL = [ int ( tokL [ i ] ) for i in range ( 1 , len ( tokL ) ) ]
except :
return self . _syntaxError ( " Unable to create integer arguments. " )
# validate the count of command args
if d [ ' minN ' ] != - 1 and ( d [ ' minN ' ] > len ( argL ) or len ( argL ) > d [ ' maxN ' ] ) :
return self . _syntaxError ( " Argument count mismatch. {} is out of range: {} to {} " . format ( len ( argL ) , d [ ' minN ' ] , d [ ' maxN ' ] ) )
# call the command function
if func :
result = func ( * argL )
else :
result = self . appProc . send ( { ' type ' : func_name , ' value ' : argL } )
return result
def run ( self ) :
# create the API object
self . appProc = AppProcess ( cfg )
self . appProc . start ( )
print ( " ' q ' =quit ' ? ' =help " )
time_out_secs = 1
# this is the shell main loop
while True :
# wait for keyboard activity
i , o , e = select . select ( [ sys . stdin ] , [ ] , [ ] , time_out_secs )
if i :
# read the command
s = sys . stdin . readline ( ) . strip ( )
# tokenize the command
tokL = s . split ( ' ' )
# execute the command
result = self . _exec_cmd ( tokL )
# if this is the 'quit' command
if tokL [ 0 ] == ' q ' :
break
# check for msg's from the async application process
if self . _handle_app_msgs ( self . appProc . recv ( ) ) :
break
# wait for the appProc to complete
while not self . appProc . isdone ( ) :
self . appProc . recv ( ) # drain the AppProc() as it shutdown
time . sleep ( 0.1 )
2019-09-01 14:54:09 +00:00
2019-08-20 01:18:57 +00:00
def _handle_app_msgs ( self , msgL ) :
quitAppFl = False
if msgL :
for msg in msgL :
if msg :
if msg [ ' type ' ] == ' error ' :
print ( " Error: {} " . format ( msg [ ' value ' ] ) )
elif msg [ ' type ' ] == ' quit ' :
quitAppFl = True
else :
print ( msg )
return quitAppFl
def parse_args ( ) :
""" Parse the command line arguments. """
descStr = """ Picadae auto-calibrate. """
logL = [ ' debug ' , ' info ' , ' warning ' , ' error ' , ' critical ' ]
ap = argparse . ArgumentParser ( description = descStr )
2019-11-09 16:13:34 +00:00
ap . add_argument ( " -c " , " --config " , default = " p_ac.yml " , help = " YAML configuration file. " )
2019-08-20 01:18:57 +00:00
ap . add_argument ( " -l " , " --log_level " , choices = logL , default = " warning " , help = " Set logging level: debug,info,warning,error,critical. Default:warning " )
return ap . parse_args ( )
if __name__ == " __main__ " :
logging . basicConfig ( )
#mplog = multiprocessing.log_to_stderr()
#mplog.setLevel(logging.INFO)
args = parse_args ( )
cfg = parse_yaml_cfg ( args . config )
shell = Shell ( cfg )
shell . run ( )