piccal/p_ac.py
2019-08-19 21:18:57 -04:00

523 lines
16 KiB
Python

import sys,os,argparse,yaml,types,logging,select,time,json
from datetime import datetime
import multiprocessing
from multiprocessing import Process, Pipe
from picadae_api import Picadae
from AudioDevice import AudioDevice
from result import Result
class AttackPulseSeq:
""" Sequence a fixed chord over a list of attack pulse lengths."""
def __init__(self, audio, api, noteDurMs=1000, pauseDurMs=1000, holdDutyPct=50 ):
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
self.holdDutyPct = holdDutyPct # hold voltage duty cycle as a percentage (0-100)
self.pulse_idx = 0 # Index of next pulse
self.state = None # 'note_on','note_off'
self.next_ms = 0 # Time of next event (note-on or note_off)
self.eventTimeL = [] # Onset/offset time of each note [ [onset_ms,offset_ms] ]
self.beginMs = 0
def start( self, ms, outDir, pitchL, pulseUsL ):
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
self.pulse_idx = 0
self.state = 'note_on'
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)
self.eventTimeL = [[0,0]] * len(pulseUsL) # initialize the event time
self.beginMs = ms
self.audio.record_enable(True) # start recording audio
self.tick(ms) # play the first note
def stop(self, ms):
self._send_note_off() # be sure that all notes are actually turn-off
self.audio.record_enable(False) # stop recording audio
self._disable() # disable this sequencer
self._write() # write the results
def is_enabled(self):
return self.state is not None
def tick(self, ms):
self.audio.tick(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)
def _note_on( self, ms ):
self.eventTimeL[ self.pulse_idx ][0] = ms - self.beginMs
self.next_ms = ms + self.noteDurMs
self.state = 'note_off'
for pitch in self.pitchL:
self.api.note_on_us( pitch, int(self.pulseUsL[ self.pulse_idx ]) )
print("note-on:",pitch,self.pulse_idx)
def _note_off( self, ms ):
self.eventTimeL[ self.pulse_idx ][1] = ms - self.beginMs
self.next_ms = ms + self.pauseDurMs
self.state = 'note_on'
self._send_note_off()
def _send_note_off( self ):
for pitch in self.pitchL:
self.api.note_off( pitch )
print("note-off:",pitch,self.pulse_idx)
def _disable(self):
self.state = None
def _write( self ):
d = {
"pulseUsL":self.pulseUsL,
"pitchL":self.pitchL,
"noteDurMs":self.noteDurMs,
"pauseDurMs":self.pauseDurMs,
"holdDutyPct":self.holdDutyPct,
"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
self.seq = AttackPulseSeq( audioDev, api, noteDurMs=1000, pauseDurMs=1000, holdDutyPct=50 )
self.label = None
self.pulseUsL = None
self.chordL = None
self.pitch_idx = -1
def start( self, ms, label, chordL, pulseUsL ):
if len(chordL) > 0:
self.label = label
self.pulseUsL = pulseUsL
self.chordL = chordL
self.pitch_idx = -1
self._start_next_chord( ms )
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():
self._start_next_chord( ms ) # ... else start the next sequence
return None
def _start_next_chord( self, ms ):
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> ... "
dirStr = self.label + "_" + "_".join([ str(pitch) for pitch in pitchL ])
outDir = os.path.join(outDir, dirStr )
# start the sequencer
self.seq.start( ms, outDir, pitchL, self.pulseUsL )
# 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
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)
if res:
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 )
return res
def tick( self, ms ):
if self.calibrate:
self.calibrate.tick(ms)
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)]
self.calibrate.start( ms, "full", chordL, cfg.full_pulseL )
def calibrate_keys_stop( self, ms ):
self.calibrate.stop(ms)
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) )
# 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) )
# 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 = {
'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"}
}
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)
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)
ap.add_argument("-c","--config", default="cfg/p_ac.yml", help="YAML configuration file.")
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()
def parse_yaml_cfg( fn ):
"""Parse the YAML configuration file."""
cfg = None
with open(fn,"r") as f:
cfgD = yaml.load(f, Loader=yaml.FullLoader)
cfg = types.SimpleNamespace(**cfgD['p_ac'])
return cfg
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()