picadae calibration programs
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. import sys,os,argparse,types,logging,select,time,json
  2. from datetime import datetime
  3. import multiprocessing
  4. from multiprocessing import Process, Pipe
  5. from picadae_api import Picadae
  6. from AudioDevice import AudioDevice
  7. from result import Result
  8. from common import parse_yaml_cfg
  9. from plot_seq import form_resample_pulse_time_list
  10. from plot_seq import form_final_pulse_list
  11. class AttackPulseSeq:
  12. """ Sequence a fixed chord over a list of attack pulse lengths."""
  13. def __init__(self, audio, api, noteDurMs=1000, pauseDurMs=1000, holdDutyPctL=[(0,50)] ):
  14. self.audio = audio
  15. self.api = api
  16. self.outDir = None # directory to write audio file and results
  17. self.pitchL = None # chord to play
  18. self.pulseUsL = [] # one onset pulse length in microseconds per sequence element
  19. self.noteDurMs = noteDurMs # duration of each chord in milliseconds
  20. self.pauseDurMs = pauseDurMs # duration between end of previous note and start of next
  21. self.holdDutyPctL= holdDutyPctL # hold voltage duty cycle table [ (minPulseSeqUsec,dutyCyclePct) ]
  22. self.pulse_idx = 0 # Index of next pulse
  23. self.state = None # 'note_on','note_off'
  24. self.prevHoldDutyPct = None
  25. self.next_ms = 0 # Time of next event (note-on or note_off)
  26. self.eventTimeL = [] # Onset/offset time of each note [ [onset_ms,offset_ms] ] (used to locate the note in the audio file)
  27. self.beginMs = 0
  28. self.playOnlyFl = False
  29. def start( self, ms, outDir, pitchL, pulseUsL, playOnlyFl=False ):
  30. self.outDir = outDir # directory to write audio file and results
  31. self.pitchL = pitchL # chord to play
  32. self.pulseUsL = pulseUsL # one onset pulse length in microseconds per sequence element
  33. self.pulse_idx = 0
  34. self.state = 'note_on'
  35. self.prevHoldDutyPct = None
  36. 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)
  37. self.eventTimeL = [[0,0] for _ in range(len(pulseUsL))] # initialize the event time
  38. self.beginMs = ms
  39. self.playOnlyFl = playOnlyFl
  40. #for pitch in pitchL:
  41. # self.api.set_pwm_duty( pitch, self.holdDutyPct )
  42. # print("set PWM:%i"%(self.holdDutyPct))
  43. if not playOnlyFl:
  44. self.audio.record_enable(True) # start recording audio
  45. self.tick(ms) # play the first note
  46. def stop(self, ms):
  47. self._send_note_off() # be sure that all notes are actually turn-off
  48. if not self.playOnlyFl:
  49. self.audio.record_enable(False) # stop recording audio
  50. self._disable() # disable this sequencer
  51. if not self.playOnlyFl:
  52. self._write() # write the results
  53. def is_enabled(self):
  54. return self.state is not None
  55. def tick(self, ms):
  56. self.audio.tick(ms)
  57. # if next event time has arrived
  58. if self.is_enabled() and ms >= self.next_ms:
  59. # if waiting to turn note on
  60. if self.state == 'note_on':
  61. self._note_on(ms)
  62. # if waiting to turn a note off
  63. elif self.state == 'note_off':
  64. self._note_off(ms)
  65. self.pulse_idx += 1
  66. # if all notes have been played
  67. if self.pulse_idx >= len(self.pulseUsL):
  68. self.stop(ms)
  69. else:
  70. assert(0)
  71. def _get_duty_cycle( self, pulseUsec ):
  72. dutyPct = self.holdDutyPctL[0][1]
  73. for refUsec,refDuty in self.holdDutyPctL:
  74. if pulseUsec < refUsec:
  75. break
  76. dutyPct = refDuty
  77. return dutyPct
  78. def _set_duty_cycle( self, pitch, pulseUsec ):
  79. dutyPct = self._get_duty_cycle( pulseUsec )
  80. if dutyPct != self.prevHoldDutyPct:
  81. self.api.set_pwm_duty( pitch, dutyPct )
  82. print("Hold Duty:",dutyPct)
  83. self.prevHoldDutyPct = dutyPct
  84. def _note_on( self, ms ):
  85. self.eventTimeL[ self.pulse_idx ][0] = self.audio.buffer_sample_ms().value
  86. self.next_ms = ms + self.noteDurMs
  87. self.state = 'note_off'
  88. for pitch in self.pitchL:
  89. pulse_usec = int(self.pulseUsL[ self.pulse_idx ])
  90. self._set_duty_cycle( pitch, pulse_usec )
  91. self.api.note_on_us( pitch, pulse_usec )
  92. print("note-on:",pitch,self.pulse_idx)
  93. def _note_off( self, ms ):
  94. self.eventTimeL[ self.pulse_idx ][1] = self.audio.buffer_sample_ms().value
  95. self.next_ms = ms + self.pauseDurMs
  96. self.state = 'note_on'
  97. self._send_note_off()
  98. def _send_note_off( self ):
  99. for pitch in self.pitchL:
  100. self.api.note_off( pitch )
  101. print("note-off:",pitch,self.pulse_idx)
  102. def _disable(self):
  103. self.state = None
  104. def _write( self ):
  105. d = {
  106. "pulseUsL":self.pulseUsL,
  107. "pitchL":self.pitchL,
  108. "noteDurMs":self.noteDurMs,
  109. "pauseDurMs":self.pauseDurMs,
  110. "holdDutyPctL":self.holdDutyPctL,
  111. "eventTimeL":self.eventTimeL,
  112. "beginMs":self.beginMs
  113. }
  114. print("Writing: ", self.outDir )
  115. outDir = os.path.expanduser(self.outDir)
  116. if not os.path.isdir(outDir):
  117. os.mkdir(outDir)
  118. with open(os.path.join( outDir, "seq.json" ),"w") as f:
  119. f.write(json.dumps( d ))
  120. self.audio.write_buffer( os.path.join( outDir, "audio.wav" ) )
  121. class CalibrateKeys:
  122. def __init__(self, cfg, audioDev, api):
  123. self.cfg = cfg
  124. self.seq = AttackPulseSeq( audioDev, api, noteDurMs=cfg.noteDurMs, pauseDurMs=cfg.pauseDurMs, holdDutyPctL=cfg.holdDutyPctL )
  125. self.pulseUsL = None
  126. self.chordL = None
  127. self.pitch_idx = -1
  128. def start( self, ms, chordL, pulseUsL, playOnlyFl=False ):
  129. if len(chordL) > 0:
  130. self.pulseUsL = pulseUsL
  131. self.chordL = chordL
  132. self.pitch_idx = -1
  133. self._start_next_chord( ms, playOnlyFl )
  134. def stop( self, ms ):
  135. self.pitch_idx = -1
  136. self.seq.stop(ms)
  137. def is_enabled( self ):
  138. return self.pitch_idx >= 0
  139. def tick( self, ms ):
  140. if self.is_enabled():
  141. self.seq.tick(ms)
  142. # if the sequencer is done playing
  143. if not self.seq.is_enabled():
  144. self._start_next_chord( ms, self.seq.playOnlyFl ) # ... else start the next sequence
  145. return None
  146. def _start_next_chord( self, ms, playOnlyFl ):
  147. self.pitch_idx += 1
  148. # if the last chord in chordL has been played ...
  149. if self.pitch_idx >= len(self.chordL):
  150. self.stop(ms) # ... then we are done
  151. else:
  152. pitchL = self.chordL[ self.pitch_idx ]
  153. # be sure that the base directory exists
  154. outDir = os.path.expanduser( cfg.outDir )
  155. if not os.path.isdir( outDir ):
  156. os.mkdir( outDir )
  157. # form the output directory as "<label>_<pitch0>_<pitch1> ... "
  158. dirStr = "_".join([ str(pitch) for pitch in pitchL ])
  159. outDir = os.path.join(outDir, dirStr )
  160. if not os.path.isdir(outDir):
  161. os.mkdir(outDir)
  162. # get the next available output directory id
  163. outDir_id = self._calc_next_out_dir_id( outDir )
  164. print(outDir_id,outDir)
  165. # if this is not the first time this note has been sampled then get the resample locations
  166. if outDir_id != 0:
  167. self.pulseUsL,_,_ = form_resample_pulse_time_list( outDir, self.cfg.analysisArgs )
  168. if playOnlyFl:
  169. self.pulseUsL,_ = form_final_pulse_list( outDir, pitchL[0], self.cfg.analysisArgs, take_id=None )
  170. noteN = cfg.analysisArgs['auditionNoteN']
  171. self.pulseUsL = [ self.pulseUsL[ int(round(i*126.0/(noteN-1)))] for i in range(noteN) ]
  172. else:
  173. outDir = os.path.join( outDir, str(outDir_id) )
  174. if not os.path.isdir(outDir):
  175. os.mkdir(outDir)
  176. # start the sequencer
  177. self.seq.start( ms, outDir, pitchL, self.pulseUsL, playOnlyFl )
  178. def _calc_next_out_dir_id( self, outDir ):
  179. id = 0
  180. while os.path.isdir( os.path.join(outDir,"%i" % id)):
  181. id += 1
  182. return id
  183. # This is the main application API it is running in a child process.
  184. class App:
  185. def __init__(self ):
  186. self.cfg = None
  187. self.audioDev = None
  188. self.api = None
  189. self.calibrate = None
  190. def setup( self, cfg ):
  191. self.cfg = cfg
  192. self.audioDev = AudioDevice()
  193. #
  194. # TODO: unify the result error handling
  195. # (the API and the audio device return two diferent 'Result' types
  196. #
  197. res = self.audioDev.setup(**cfg.audio)
  198. if not res:
  199. self.audio_dev_list(0)
  200. else:
  201. self.api = Picadae( key_mapL=cfg.key_mapL)
  202. # wait for the letter 'a' to come back from the serial port
  203. api_res = self.api.wait_for_serial_sync(timeoutMs=cfg.serial_sync_timeout_ms)
  204. # did the serial port sync fail?
  205. if not api_res:
  206. res.set_error("Serial port sync failed.")
  207. else:
  208. print("Serial port sync'ed")
  209. self.calibrate = CalibrateKeys( cfg, self.audioDev, self.api )
  210. return res
  211. def tick( self, ms ):
  212. if self.calibrate:
  213. self.calibrate.tick(ms)
  214. def audio_dev_list( self, ms ):
  215. portL = self.audioDev.get_port_list( True )
  216. for port in portL:
  217. print("chs:%4i label:%s" % (port['chN'],port['label']))
  218. def calibrate_keys_start( self, ms, pitchRangeL ):
  219. chordL = [ [pitch] for pitch in range(pitchRangeL[0], pitchRangeL[1]+1)]
  220. self.calibrate.start( ms, chordL, cfg.full_pulseL )
  221. def play_keys_start( self, ms, pitchRangeL ):
  222. chordL = [ [pitch] for pitch in range(pitchRangeL[0], pitchRangeL[1]+1)]
  223. self.calibrate.start( ms, chordL, cfg.full_pulseL, playOnlyFl=True )
  224. def calibrate_keys_stop( self, ms ):
  225. self.calibrate.stop(ms)
  226. def quit( self, ms ):
  227. if self.api:
  228. self.api.close()
  229. def _send_error( pipe, res ):
  230. if res is None:
  231. return
  232. if res.msg:
  233. pipe.send( [{"type":"error", 'value':res.msg}] )
  234. def _send_error_msg( pipe, msg ):
  235. _send_error( pipe, Result(None,msg))
  236. def _send_quit( pipe ):
  237. pipe.send( [{ 'type':'quit' }] )
  238. # This is the application engine async. process loop
  239. def app_event_loop_func( pipe, cfg ):
  240. multiprocessing.get_logger().info("App Proc Started.")
  241. # create the asynchronous application object
  242. app = App()
  243. res = app.setup(cfg)
  244. # if the app did not initialize successfully
  245. if not res:
  246. _send_error( pipe, res )
  247. _send_quit(pipe)
  248. return
  249. dt0 = datetime.now()
  250. ms = 0
  251. while True:
  252. # have any message arrived from the parent process?
  253. if pipe.poll():
  254. msg = None
  255. try:
  256. msg = pipe.recv()
  257. except EOFError:
  258. return
  259. if not hasattr(app,msg.type):
  260. _send_error_msg( pipe, "Unknown message type:'%s'." % (msg.type) )
  261. else:
  262. # get the command handler function in 'app'
  263. func = getattr(app,msg.type)
  264. ms = int(round( (datetime.now() - dt0).total_seconds() * 1000.0) )
  265. # call the command handler
  266. if msg.value:
  267. res = func( ms, msg.value )
  268. else:
  269. res = func( ms )
  270. # handle any errors returned from the commands
  271. _send_error( pipe, res )
  272. # if a 'quit' msg was recived then break out of the loop
  273. if msg.type == 'quit':
  274. _send_quit(pipe)
  275. break
  276. # give some time to the system
  277. time.sleep(0.1)
  278. # calc the tick() time stamp
  279. ms = int(round( (datetime.now() - dt0).total_seconds() * 1000.0) )
  280. # tick the app
  281. app.tick( ms )
  282. class AppProcess(Process):
  283. def __init__(self,cfg):
  284. self.parent_end, child_end = Pipe()
  285. super(AppProcess, self).__init__(target=app_event_loop_func,name="AppProcess",args=(child_end,cfg))
  286. self.doneFl = False
  287. def send(self, d):
  288. # This function is called by the parent process to send an arbitrary msg to the App process
  289. self.parent_end.send( types.SimpleNamespace(**d) )
  290. return None
  291. def recv(self):
  292. # This function is called by the parent process to receive lists of child messages.
  293. msgL = None
  294. if not self.doneFl and self.parent_end.poll():
  295. msgL = self.parent_end.recv()
  296. for msg in msgL:
  297. if msg['type'] == 'quit':
  298. self.doneFl = True
  299. return msgL
  300. def isdone(self):
  301. return self.doneFl
  302. class Shell:
  303. def __init__( self, cfg ):
  304. self.appProc = None
  305. self.parseD = {
  306. 'q':{ "func":'quit', "minN":0, "maxN":0, "help":"quit"},
  307. '?':{ "func":"_help", "minN":0, "maxN":0, "help":"Print usage text."},
  308. 'a':{ "func":"audio_dev_list", "minN":0, "maxN":0, "help":"List the audio devices."},
  309. 'c':{ "func":"calibrate_keys_start", "minN":1, "maxN":2, "help":"Calibrate a range of keys. "},
  310. 's':{ "func":"calibrate_keys_stop", "minN":0, "maxN":0, "help":"Stop key calibration"},
  311. 'p':{ "func":"play_keys_start", "minN":1, "maxN":2, "help":"Play current calibration"}
  312. }
  313. def _help( self, _=None ):
  314. for k,d in self.parseD.items():
  315. s = "{} = {}".format( k, d['help'] )
  316. print(s)
  317. return None
  318. def _syntaxError( self, msg ):
  319. return Result(None,"Syntax Error: " + msg )
  320. def _exec_cmd( self, tokL ):
  321. if len(tokL) <= 0:
  322. return None
  323. opcode = tokL[0]
  324. if opcode not in self.parseD:
  325. return self._syntaxError("Unknown opcode: '{}'.".format(opcode))
  326. d = self.parseD[ opcode ]
  327. func_name = d['func']
  328. func = None
  329. # find the function associated with this command
  330. if hasattr(self, func_name ):
  331. func = getattr(self, func_name )
  332. try:
  333. # convert the parameter list into integers
  334. argL = [ int(tokL[i]) for i in range(1,len(tokL)) ]
  335. except:
  336. return self._syntaxError("Unable to create integer arguments.")
  337. # validate the count of command args
  338. if d['minN'] != -1 and (d['minN'] > len(argL) or len(argL) > d['maxN']):
  339. return self._syntaxError("Argument count mismatch. {} is out of range:{} to {}".format(len(argL),d['minN'],d['maxN']))
  340. # call the command function
  341. if func:
  342. result = func(*argL)
  343. else:
  344. result = self.appProc.send( { 'type':func_name, 'value':argL } )
  345. return result
  346. def run( self ):
  347. # create the API object
  348. self.appProc = AppProcess(cfg)
  349. self.appProc.start()
  350. print("'q'=quit '?'=help")
  351. time_out_secs = 1
  352. # this is the shell main loop
  353. while True:
  354. # wait for keyboard activity
  355. i, o, e = select.select( [sys.stdin], [], [], time_out_secs )
  356. if i:
  357. # read the command
  358. s = sys.stdin.readline().strip()
  359. # tokenize the command
  360. tokL = s.split(' ')
  361. # execute the command
  362. result = self._exec_cmd( tokL )
  363. # if this is the 'quit' command
  364. if tokL[0] == 'q':
  365. break
  366. # check for msg's from the async application process
  367. if self._handle_app_msgs( self.appProc.recv() ):
  368. break
  369. # wait for the appProc to complete
  370. while not self.appProc.isdone():
  371. self.appProc.recv() # drain the AppProc() as it shutdown
  372. time.sleep(0.1)
  373. def _handle_app_msgs( self, msgL ):
  374. quitAppFl = False
  375. if msgL:
  376. for msg in msgL:
  377. if msg:
  378. if msg['type'] == 'error':
  379. print("Error: {}".format(msg['value']))
  380. elif msg['type'] == 'quit':
  381. quitAppFl = True
  382. else:
  383. print(msg)
  384. return quitAppFl
  385. def parse_args():
  386. """Parse the command line arguments."""
  387. descStr = """Picadae auto-calibrate."""
  388. logL = ['debug','info','warning','error','critical']
  389. ap = argparse.ArgumentParser(description=descStr)
  390. ap.add_argument("-c","--config", default="p_ac.yml", help="YAML configuration file.")
  391. ap.add_argument("-l","--log_level",choices=logL, default="warning", help="Set logging level: debug,info,warning,error,critical. Default:warning")
  392. return ap.parse_args()
  393. if __name__ == "__main__":
  394. logging.basicConfig()
  395. #mplog = multiprocessing.log_to_stderr()
  396. #mplog.setLevel(logging.INFO)
  397. args = parse_args()
  398. cfg = parse_yaml_cfg(args.config)
  399. shell = Shell(cfg)
  400. shell.run()