Added calibrate.py, plot_calibrate.py and made associated changes.

Added plot_calibrate.ipynb jupyter notebook.
This commit is contained in:
kpl 2019-11-24 20:07:47 -05:00
parent 26b997811a
commit fc1a0d8a61
8 changed files with 1190 additions and 27 deletions

222
MidiDevice.py Normal file
View File

@ -0,0 +1,222 @@
import rtmidi
import rtmidi.midiutil
from result import Result
class MidiDevice(object):
def __init__(self, **kwargs ):
self.mip = None
self.mop = None
self.inMonitorFl = False
self.outMonitorFl = False
self.throughFl = False
self.inPortLabel = None
self.outPortLabel = None
self.setup(**kwargs)
def setup( self, **kwargs ):
res = Result()
if kwargs is None:
return res
if 'inPortLabel' in kwargs:
res += self.select_port( True, kwargs['inPortLabel'] )
if 'outPortLabel' in kwargs:
res += self.select_port( False, kwargs['outPortLabel'] )
if 'inMonitorFl' in kwargs:
self.enable_monitor( True, kwargs['inMonitorFl'] )
if 'outMonitorFl' in kwargs:
self.enable_monitor( True, kwargs['outMonitorFl'] )
if 'throughFl' in kwargs:
self.enable_through( kwargs['throughFl'] )
return res
def _clean_port_label( self, portLabel ):
return ' '.join(portLabel.split(' ')[:-1])
def _get_port_list( self, inDirFl ):
dev = rtmidi.MidiIn() if inDirFl else rtmidi.MidiOut()
# get port list and drop the numeric id at the end of the port label
return [ self._clean_port_label(p) for p in dev.get_ports() ]
def get_port_list( self, inDirFl ):
return { 'type':'midi',
'dir': 'in' if inDirFl else 'out',
'op': 'list',
'listL': self._get_port_list( inDirFl )
}
def get_in_port_list( self ):
return self.get_port_list( True )
def get_out_port_list( self ):
return self.get_port_list( False )
def select_port( self, inDirFl, portLabel ):
res = Result()
if portLabel:
dirLabel = "input" if inDirFl else "output"
portL = self._get_port_list( inDirFl )
if portLabel not in portL:
res.set_error("The port '%s' is not an available %s port." % (portLabel,dirLabel))
else:
port_idx = portL.index(portLabel) # TODO error check
if inDirFl:
self.mip,self.inPortLabel = rtmidi.midiutil.open_midiinput(port=port_idx)
self.inPortLabel = self._clean_port_label(self.inPortLabel)
else:
self.mop,self.outPortLabel = rtmidi.midiutil.open_midioutput(port=port_idx)
self.outPortLabel = self._clean_port_label(self.outPortLabel)
return res
def select_in_port( self, portLabel ):
return self.select_port( True, portLabel )
def select_out_port( self, portLabel ):
return self.select_port( False, portLabel )
def enable_through( self, throughFl ):
self.throughFl = throughFl
def enable_monitor( self, inDirFl, monitorFl ):
if inDirFl:
self.inMonitorFl = monitorFl
else:
self.outMonitorFl = monitorFl
def enable_in_monitor( self, monitorFl):
self.enable_monitor( True, monitorFl )
def enable_out_monitor( self, monitorFl):
self.enable_monitor( False, monitorFl )
def port_name( self, inDirFl ):
return inPortLabel if inDirFl else outPortLabel
def in_port_name( self ):
return self.port_name(True)
def out_port_name( self ):
return self.port_name(False)
def _midi_data_to_text_msg( self, inFl, midi_data ):
text = ""
if len(midi_data) > 0:
text += "{:02x}".format(midi_data[0])
if len(midi_data) > 1:
text += " {:3d}".format(midi_data[1])
if len(midi_data) > 2:
text += " {:3d}".format(midi_data[2])
text = ("in: " if inFl else "out: ") + text
return { 'type':'midi', 'dir':inFl, 'op':'monitor', 'value':text }
def get_input( self ):
o_msgL = []
if self.mip is not None:
midi_msg = self.mip.get_message()
if midi_msg and midi_msg[0]:
if self.monitorInFl:
o_msgL.append( self._midi_data_to_text_msg(True,midi_msg[0]) )
if self.throughFl and self.mop is not None:
self.mop.send_message(midi_msg[0])
o_msgL.append( { 'type':'midi', 'op':'data', 'dir':'in', 'value':midi_msg[0] } )
return o_msgL
def send_output( self, m ):
o_msgL = []
if self.mop is not None:
self.mop.send_message(m)
if self.outMonitorFl:
o_msgL += [self._midi_data_to_text_msg( False, m )]
return o_msgL
def send_note_on( self, pitch, vel, ch=0 ):
return self.send_output( [ 0x90+ch, pitch, vel ] )
def send_note_off( self, pitch, ch=0 ):
return self.send_note_on( 0, ch )
def send_pgm_change( self, pgm, ch=0 ):
return self.send_output( [ 0xc0+ch, pgm ] )
def send_pbend( self, val, ch=0 ):
assert( val < 8192 )
ival = int(val)
lsb = ival & 0x7f
msb = (ival >> 7) & 0x7f
return self.send_output( [ 0xe0+ch, lsb, msb ] )
def send_controller( self, num, value, ch=0 ):
return self.send_output( [0xb0+ch, num, value ] )
def send_all_notes_off(self, ch=0 ):
return self.send_controller( 123, 1, ch=ch )
def get_state( self ):
return {
"inMonitorFl":self.inMonitorFl,
"outMonitorFl":self.outMonitorFl,
"throughFl":self.throughFl,
"inPortLabel":self.inPortLabel,
"outPortLabel":self.outPortLabel,
"inPortL":self.get_in_port_list(),
"outPortL":self.get_out_port_list()
}
def on_command( self, m, ms ):
errL = []
if m.type == 'midi':
if m.op == 'sel':
errL.append(self.select_port( m.dir=='in', m.value ))
elif m.op == 'through':
self.enable_through( m.value )
elif m.op == 'monitor':
self.enable_monitor( m.dir=='in', m.value )
return errL
if __name__ == "__main__":
md = MidiDevice()
print(md.get_port_list( True ))
print(md.get_port_list( False ))

427
calibrate.py Normal file
View File

@ -0,0 +1,427 @@
import os,types,wave,json,array
import numpy as np
from rms_analysis import rms_analyze_one_note
class Calibrate:
def __init__( self, cfg, audio, midi, api ):
self.cfg = types.SimpleNamespace(**cfg)
self.audio = audio
self.midi = midi
self.api = api
self.state = "stopped" # stopped | started | note_on | note_off | analyzing
self.playOnlyFl = False
self.startMs = None
self.nextStateChangeMs = None
self.curHoldDutyCyclePctD = None # { pitch:dutyPct}
self.noteAnnotationL = [] # (noteOnMs,noteOffMs,pitch,pulseUs)
self.measD = None # { midi_pitch: [ {pulseUs, db, durMs, targetDb } ] }
self.curNoteStartMs = None
self.curPitchIdx = None
self.curTargetDbIdx = None
self.successN = None
self.failN = None
self.curTargetDb = None
self.curPulseUs = None
self.curMatchN = None
self.curAttemptN = None
self.lastAudiblePulseUs = None
self.maxTooShortPulseUs = None
self.pulseDbL = None
self.deltaUpMult = None
self.deltaDnMult = None
self.skipMeasFl = None
def start(self,ms):
self.stop(ms)
self.state = 'started'
self.playOnlyFl = False
self.nextStateChangeMs = ms + 500
self.startMs = ms
self.curPitchIdx = 0
self.curPulseUs = self.cfg.initPulseUs
self.lastAudiblePulseUs = None
self.maxTooShortPulseUs = None
self.pulseDbL = []
self.deltaUpMult = 1
self.deltaDnMult = 1
self.curTargetDbIdx = -1
self._start_new_db_target()
self.curDutyPctD = {}
self.skipMeasFl = False
self.measD = {}
self.successN = 0
self.failN = 0
self.audio.record_enable(True)
def stop(self,ms):
if self.midi is not None:
self.midi.send_all_notes_off()
if not self.playOnlyFl:
self.audio.record_enable(False)
self._save_results()
def play(self,ms):
if self.measD is None or len(self.measD) == 0:
print("Nothing to play.")
else:
self.state = 'started'
self.playOnlyFl = True
self.nextStateChangeMs = ms + 500
self.curPitchIdx = -1
self.curTargetDbIdx = 0
self._do_play_update()
def tick(self,ms):
if self.nextStateChangeMs is not None and ms > self.nextStateChangeMs:
if self.state == 'stopped':
pass
elif self.state == 'started':
self._do_note_on(ms)
self.nextStateChangeMs += self.cfg.noteOnDurMs
self.state = 'note_on'
elif self.state == 'note_on':
self._do_note_off(ms)
self.nextStateChangeMs += self.cfg.noteOffDurMs
self.state = 'note_off'
elif self.state == 'note_off':
if self.playOnlyFl:
if not self._do_play_update():
self.state = 'stopped'
else:
if self._do_analysis(ms):
if not self._start_new_db_target():
self.stop(ms)
self.state = 'stopped'
print("DONE!")
# if the state was not changed to 'stopped'
if self.state == 'note_off':
self.state = 'started'
def _do_play_update( self ):
self.curPitchIdx +=1
if self.curPitchIdx >= len(self.cfg.pitchL):
self.curPitchIdx = 0
self.curTargetDbIdx += 1
if self.curTargetDbIdx >= len(self.cfg.targetDbL):
return False
pitch = self.cfg.pitchL[ self.curPitchIdx ]
targetDb = self.cfg.targetDbL[ self.curTargetDbIdx ]
self.curPulseUs = -1
for d in self.measD[ pitch ]:
if d['targetDb'] == targetDb and d['matchFl']==True:
self.curPulseUs = d['pulse_us']
break
if self.curPulseUs == -1:
print("Pitch:%i TargetDb:%f not found." % (pitch,targetDb))
return False
print("Target db: %4.1f" % (targetDb))
return True
def _get_duty_cycle( self, pitch, pulseUsec ):
dutyPct = 50
if pitch in self.cfg.holdDutyPctD:
dutyPct = self.cfg.holdDutyPctD[pitch][0][1]
for refUsec,refDuty in self.cfg.holdDutyPctD[pitch]:
if pulseUsec < refUsec:
break
dutyPct = refDuty
return dutyPct
def _set_duty_cycle( self, pitch, pulseUsec ):
dutyPct = self._get_duty_cycle( pitch, pulseUsec )
if pitch not in self.curDutyPctD or self.curDutyPctD[pitch] != dutyPct:
self.curDutyPctD[pitch] = dutyPct
self.api.set_pwm_duty( pitch, dutyPct )
print("Hold Duty Set:",dutyPct)
self.skipMeasFl = True
return dutyPct
def _do_note_on(self,ms):
self.curNoteStartMs = ms
pitch = self.cfg.pitchL[ self.curPitchIdx]
if self.midi is not None:
self.midi.send_note_on( pitch, 60 )
else:
self._set_duty_cycle( pitch, self.curPulseUs )
self.api.note_on_us( pitch, self.curPulseUs )
print("note-on: ",pitch," ",self.curPulseUs," us")
def _do_note_off(self,ms):
self.noteAnnotationL.append( { 'beg_ms':self.curNoteStartMs-self.startMs, 'end_ms':ms-self.startMs, 'midi_pitch':self.cfg.pitchL[ self.curPitchIdx], 'pulse_us':self.curPulseUs } )
if self.midi is not None:
self.midi.send_note_off( self.cfg.pitchL[ self.curPitchIdx] )
else:
for pitch in self.cfg.pitchL:
self.api.note_off( pitch )
#print("note-off: ",self.cfg.pitchL[ self.curPitchIdx])
def _calc_next_pulse_us( self, targetDb ):
# sort pulseDb ascending on db
self.pulseDbL = sorted( self.pulseDbL, key=lambda x: x[1] )
pulseL,dbL = zip(*self.pulseDbL)
max_i = np.argmax(dbL)
min_i = np.argmin(dbL)
if targetDb > dbL[max_i]:
pu = pulseL[max_i] + self.deltaUpMult * 500
self.deltaUpMult += 1
elif targetDb < dbL[min_i]:
pu = pulseL[min_i] - self.deltaDnMult * 500
self.deltaDnMult += 1
if self.maxTooShortPulseUs is not None and pu < self.maxTooShortPulseUs:
# BUG: this is a problem is self.pulseL[min_i] is <= than self.maxTooShortPulseUs
# the abs() covers the problem to prevent decreasing from maxTooShortPulseus
pu = self.maxTooShortPulseUs + (abs(pulseL[min_i] - self.maxTooShortPulseUs))/2
self.deltaDnMult = 1
else:
self.deltaUpMult = 1
self.deltaDnMult = 1
pu = np.interp([targetDb],dbL,pulseL)
return max(min(pu,self.cfg.maxPulseUs),self.cfg.minPulseUs)
def _do_analysis(self,ms):
analysisDoneFl = False
midi_pitch = self.cfg.pitchL[self.curPitchIdx]
pulse_us = self.curPulseUs
measD = self._meas_note(midi_pitch,pulse_us)
# if the the 'skip' flag is set then don't analyze this note
if self.skipMeasFl:
self.skipMeasFl = False
print("SKIP")
else:
db = measD[self.cfg.dbSrcLabel]['db']
durMs = measD['hm']['durMs']
# if this note is shorter than the minimum allowable duration
if durMs < self.cfg.minMeasDurMs:
print("SHORT!")
if self.maxTooShortPulseUs is None or self.curPulseUs > self.maxTooShortPulseUs:
self.maxTooShortPulseUs = self.curPulseUs
if self.lastAudiblePulseUs is not None and self.curPulseUs < self.lastAudiblePulseUs:
self.curPulseUs = self.lastAudiblePulseUs
else:
self.curPulseUs = self.cfg.initPulseUs
else:
# this is a valid measurement store it to the pulse-db table
self.pulseDbL.append( (self.curPulseUs,db) )
# track the most recent audible note - to return to if a successive note is too short
self.lastAudiblePulseUs = self.curPulseUs
# calc the upper and lower bounds db range
lwr_db = self.curTargetDb * ((100.0 - self.cfg.tolDbPct)/100.0)
upr_db = self.curTargetDb * ((100.0 + self.cfg.tolDbPct)/100.0)
# was this note is inside the db range then set the 'match' flag
if lwr_db <= db and db <= upr_db:
self.curMatchN += 1
measD['matchFl'] = True
print("MATCH!")
#
self.curPulseUs = int(self._calc_next_pulse_us(self.curTargetDb))
# if at least minMatchN matches have been made on this pitch/targetDb
if self.curMatchN >= self.cfg.minMatchN:
analysisDoneFl = True
self.successN += 1
print("Anysis Done: Success")
# if at least maxAttemptN match attempts have been made without success
self.curAttemptN += 1
if self.curAttemptN >= self.cfg.maxAttemptN:
analysisDoneFl = True
self.failN += 1
print("Analysis Done: Fail")
if midi_pitch not in self.measD:
self.measD[ midi_pitch ] = []
self.measD[ midi_pitch ].append( measD )
return analysisDoneFl
def _meas_note(self,midi_pitch,pulse_us):
# get the annotation information for the last note
annD = self.noteAnnotationL[-1]
buf_result = self.audio.linear_buffer()
if buf_result:
sigV = buf_result.value
# get the annotated begin and end of the note as sample indexes into sigV
bi = int(round(annD['beg_ms'] * self.audio.srate / 1000))
ei = int(round(annD['end_ms'] * self.audio.srate / 1000))
# calculate half the length of the note-off duration in samples
noteOffSmp_o_2 = int(round(self.cfg.noteOffDurMs/2 * self.audio.srate / 1000))
# widen the note analysis space noteOffSmp_o_2 samples pre/post the annotated begin/end of the note
bi = max(0,bi - noteOffSmp_o_2)
ei = min(noteOffSmp_o_2,sigV.shape[0]-1)
ar = types.SimpleNamespace(**self.cfg.analysisD)
# shift the annotatd begin/end of the note to be relative to index bi
begMs = noteOffSmp_o_2 * 1000 / self.audio.srate
endMs = begMs + (annD['end_ms'] - annD['beg_ms'])
# analyze the note
resD = rms_analyze_rt_one_note( sigV[bi:ei], self.audio.srate, begMs, endMs, midi_pitch, rmsWndMs=ar.rmsWndMs, rmsHopMs=ar.rmsHopMs, dbRefWndMs=ar.dbRefWndMs, harmCandN=ar.harmCandN, harmN=ar.harmN, durDecayPct=ar.durDecayPct )
resD["pulse_us"] = pulse_us
resD["midi_pitch"] = midi_pitch
resD["beg_ms"] = annD['beg_ms']
resD['end_ms'] = annD['end_ms']
resD['skipMeasFl'] = self.skipMeasFl
resD['matchFl'] = False
resD['targetDb'] = self.curTargetDb
resD['annIdx'] = len(self.noteAnnotationL)-1
print( "%4.1f hm:%4.1f (%4.1f) %4i td:%4.1f (%4.1f) %4i" % (self.curTargetDb,resD['hm']['db'], resD['hm']['db']-self.curTargetDb, resD['hm']['durMs'], resD['td']['db'], resD['td']['db']-self.curTargetDb, resD['td']['durMs']))
return resD
def _start_new_db_target(self):
self.curTargetDbIdx += 1
# if all db targets have been queried then advance to the next pitch
if self.curTargetDbIdx >= len(self.cfg.targetDbL):
self.curTargetDbIdx = 0
self.curPitchIdx += 1
# if all pitches have been queried then we are done
if self.curPitchIdx >= len(self.cfg.pitchL):
return False
self.curTargetDb = self.cfg.targetDbL[ self.curTargetDbIdx ]
self.curMatchN = 0
self.curAttemptN = 0
self.lastAudiblePulseUs = None
self.maxTooShortPulseUs = None
self.pulseDbL = []
self.deltaUpMult = 1
self.deltaDnMult = 1
return True
def _write_16_bit_wav_file( self, fn ):
srate = int(self.audio.srate)
buf_result = self.audio.linear_buffer()
sigV = buf_result.value
smpN = sigV.shape[0]
chN = 1
sigV = np.squeeze(sigV.reshape( smpN * chN, )) * 0x7fff
sigL = [ int(round(sigV[i])) for i in range(smpN) ]
sigA = array.array('h',sigL)
with wave.open( fn, "wb") as f:
bits = 16
bits_per_byte = 8
f.setparams((chN, bits//bits_per_byte, srate, 0, 'NONE', 'not compressed'))
f.writeframes(sigA)
def _save_results( self ):
if self.measD is None or len(self.measD) == 0:
return
outDir = os.path.expanduser( self.cfg.outDir )
if not os.path.isdir(outDir):
os.mkdir(outDir)
outDir = os.path.join( outDir, self.cfg.outLabel )
if not os.path.isdir(outDir):
os.mkdir(outDir)
i = 0
while( os.path.isdir( os.path.join(outDir,"%i" % i )) ):
i += 1
outDir = os.path.join( outDir, "%i" % i )
os.mkdir(outDir)
self._write_16_bit_wav_file( os.path.join(outDir,"audio.wav"))
d = {'cfg':self.cfg.__dict__, 'measD': self.measD, 'annoteL':self.noteAnnotationL }
with open( os.path.join(outDir,"meas.json"), "w") as f:
json.dump(d,f)

73
p_ac.py
View File

@ -6,12 +6,14 @@ from multiprocessing import Process, Pipe
from picadae_api import Picadae
from AudioDevice import AudioDevice
from MidiDevice import MidiDevice
from result import Result
from common import parse_yaml_cfg
from plot_seq import form_resample_pulse_time_list
from plot_seq import form_final_pulse_list
from rt_note_analysis import RT_Analyzer
from keyboard import Keyboard
from calibrate import Calibrate
class AttackPulseSeq:
""" Sequence a fixed chord over a list of attack pulse lengths."""
@ -274,13 +276,15 @@ class App:
self.cfg = None
self.audioDev = None
self.api = None
self.calibrate = None
self.cal_keys = None
self.keyboard = None
self.calibrate = None
def setup( self, cfg ):
self.cfg = cfg
self.audioDev = AudioDevice()
self.midiDev = MidiDevice()
#
# TODO: unify the result error handling
@ -292,20 +296,31 @@ class App:
if not res:
self.audio_dev_list(0)
else:
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)
if hasattr(cfg,'midi'):
res = self.midiDev.setup(**cfg.midi)
# did the serial port sync fail?
if not api_res:
res.set_error("Serial port sync failed.")
if not res:
self.midi_dev_list(0)
else:
print("Serial port sync'ed")
self.midiDev = None
self.calibrate = CalibrateKeys( cfg, self.audioDev, self.api )
self.api = Picadae( key_mapL=cfg.key_mapL)
self.keyboard = Keyboard( cfg, self.audioDev, self.api )
# 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.cal_keys = CalibrateKeys( cfg, self.audioDev, self.api )
self.keyboard = Keyboard( cfg, self.audioDev, self.api )
self.calibrate = Calibrate( cfg.calibrateArgs, self.audioDev, self.midiDev, self.api )
return res
@ -313,24 +328,40 @@ class App:
self.audioDev.tick(ms)
if self.calibrate:
self.calibrate.tick(ms)
if self.cal_keys:
self.cal_keys.tick(ms)
if self.keyboard:
self.keyboard.tick(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 midi_dev_list( self, ms ):
d = self.midiDev.get_port_list( True )
for port in d['listL']:
print("IN:",port)
d = self.midiDev.get_port_list( False )
for port in d['listL']:
print("OUT:",port)
def calibrate_keys_start( self, ms, pitchRangeL ):
chordL = [ [pitch] for pitch in range(pitchRangeL[0], pitchRangeL[1]+1)]
self.calibrate.start( ms, chordL, cfg.full_pulseL )
self.cal_keys.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 )
self.cal_keys.start( ms, chordL, cfg.full_pulseL, playOnlyFl=True )
def keyboard_start_pulse_idx( self, ms, argL ):
pitchL = [ pitch for pitch in range(argL[0], argL[1]+1)]
@ -346,9 +377,16 @@ class App:
def keyboard_repeat_target_db( self, ms, argL ):
self.keyboard.repeat( ms, None, argL[0] )
def calibrate_start( self, ms, argL ):
self.calibrate.start(ms)
def calibrate_play( self, ms, argL ):
self.calibrate.play(ms)
def calibrate_keys_stop( self, ms ):
self.calibrate.stop(ms)
self.cal_keys.stop(ms)
self.keyboard.stop(ms)
self.calibrate.stop(ms)
def quit( self, ms ):
if self.api:
@ -424,7 +462,7 @@ def app_event_loop_func( pipe, cfg ):
# give some time to the system
time.sleep(0.1)
time.sleep(0.05)
# calc the tick() time stamp
ms = int(round( (datetime.now() - dt0).total_seconds() * 1000.0) )
@ -470,7 +508,10 @@ class Shell:
'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."},
'm':{ "func":"midi_dev_list", "minN":0, "maxN":0, "help":"List the MIDI devices."},
'c':{ "func":"calibrate_keys_start", "minN":1, "maxN":2, "help":"Calibrate a range of keys. "},
'd':{ "func":"calibrate_start", "minN":1, "maxN":1, "help":"Calibrate based on fixed db levels. "},
'D':{ "func":"calibrate_play", "minN":1, "maxN":1, "help":"Play back last calibration."},
'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"},

127
p_ac.yml
View File

@ -6,8 +6,17 @@
audio: {
inPortLabel: "5 USB Audio CODEC:", #"HDA Intel PCH: CS4208", # "5 USB Audio CODEC:", #"5 USB Sound Device",
outPortLabel: ,
},
},
midi_off: {
inMonitorFl: False,
outMonitorFl: False,
throughFl: False,
inPortLabel: "Fastlane:Fastlane MIDI A",
outPortLabel: "Fastlane:Fastlane MIDI A"
#inPortLabel: "picadae:picadae MIDI 1",
#outPortLabel: "picadae:picadae MIDI 1"
},
# Picadae API args
serial_dev: "/dev/ttyACM0",
@ -62,6 +71,121 @@
rmsAnalysisCacheFn: "/home/kevin/temp/rms_analysis_cache.pickle"
},
calibrateArgs: {
outDir: "~/temp/calib0",
outLabel: "test",
analysisD: {
rmsWndMs: 300, # length of the RMS measurment window
rmsHopMs: 30, # RMS measurement inter window distance
dbRefWndMs: 500, # length of initial portion of signal to use to calculate the dB reference level
harmCandN: 5, # count of harmonic candidates to locate during harmonic based RMS analysis
harmN: 3, # count of harmonics to use to calculate harmonic based RMS analysis
durDecayPct: 40 # percent drop in RMS to indicate the end of a note
},
noteOnDurMs: 1000,
noteOffDurMs: 1000,
pitchL: [ 50, 51, 52 ], # list of pitches
targetDbL: [ 16, 20, 23 ], # list of target db
minMeasDurMs: 800, # minimum candidate note duration
tolDbPct: 5.0, # tolerance as a percent of targetDb above/below used to form match db window
maxPulseUs: 45000, # max. allowable pulse us
minPulseUs: 8000, # min. allowable pulse us
initPulseUs: 15000, # pulseUs for first note
minMatchN: 3, # at least 3 candidate notes must be within tolDbPct to move on to a new targetDb
maxAttemptN: 30, # give up if more than 20 candidate notes fail for a given targetDb
dbSrcLabel: 'td', # source of the db measurement 'td' (time-domain) or 'hm' (harmonic)
holdDutyPctD: {
23: [[0, 70]],
24: [[0, 75]],
25: [[0, 70]],
26: [[0, 65]],
27: [[0, 70]],
28: [[0, 70]],
29: [[0, 65]],
30: [[0, 65]],
31: [[0, 65]],
32: [[0, 60]],
33: [[0, 65]],
34: [[0, 65]],
35: [[0, 65]],
36: [[0, 65]],
37: [[0, 65]],
38: [[0, 60]],
39: [[0, 60]],
40: [[0, 55]],
41: [[0, 60]],
42: [[0, 60]],
43: [[0, 65]],
44: [[0, 60]],
45: [[0, 60]],
46: [[0, 60]],
47: [[0, 60]],
48: [[0, 70]],
49: [[0, 60]],
50: [[0, 50]],
51: [[0, 50]],
52: [[0, 55]],
53: [[0, 50]],
54: [[0, 50]],
55: [[0, 50], [22000, 55]],
56: [[0, 50]],
57: [[0, 50]],
58: [[0, 50]],
59: [[0, 60]],
60: [[0, 50]],
61: [[0, 50]],
62: [[0, 55]],
63: [[0, 50]],
64: [[0, 50]],
65: [[0, 50], [17000, 65]],
66: [[0, 53]],
67: [[0, 55]],
68: [[0, 53]],
69: [[0, 55]],
70: [[0, 50]],
71: [[0, 50]],
72: [[0, 60]],
73: [[0, 50]],
74: [[0, 60]],
75: [[0, 55]],
76: [[0, 70]],
77: [[0, 50], [15000, 60], [19000, 70]],
78: [[0, 60]],
79: [[0, 50], [15000, 60], [19000, 70]],
80: [[0, 45]],
81: [[0, 50], [15000, 70]],
82: [[0, 50], [12500, 60], [14000, 65], [17000, 70]],
83: [[0, 50], [15000, 65]],
84: [[0, 50], [12500, 60], [14000, 65], [17000, 70]],
85: [[0, 50], [12500, 60], [14000, 65], [17000, 70]],
86: [[0, 50], [12500, 60], [14000, 65], [17000, 70]],
87: [[0, 50], [14000, 60]],
88: [[0, 50], [12500, 60], [14000, 65], [17000, 70]],
89: [[0, 50], [12500, 60], [14000, 65], [17000, 70]],
91: [[0, 40], [12500, 50], [14000, 60], [17000, 65]],
92: [[0, 40], [14000, 50]],
93: [[0, 40], [12500, 50], [14000, 60], [17000, 65]],
94: [[0, 40], [14000, 50]],
95: [[0, 40], [12500, 50], [14000, 60], [17000, 65]],
96: [[0, 40], [12500, 50], [14000, 60], [17000, 65]],
97: [[0, 40], [14000, 50]],
98: [[0, 50]],
99: [[0, 50]],
100: [[0, 50]],
101: [[0, 50]],
106: [[0, 50]]
},
},
key_mapL: [
{ index: 0, board: 1, ch: 1, type: 'wB', midi: 21, class: 'A' },
@ -163,6 +287,5 @@
]
}
}

72
plot_calibrate.ipynb Normal file

File diff suppressed because one or more lines are too long

150
plot_calibrate.py Normal file
View File

@ -0,0 +1,150 @@
import sys,os,json,types
import numpy as np
import matplotlib.pyplot as plt
import matplotlib._color_data as mcd
from matplotlib.pyplot import figure
from rms_analysis import calibrate_recording_analysis
def plot_by_pitch( inDir, pitch=None ):
anlD = calibrate_recording_analysis( inDir )
jsonFn = os.path.join(inDir, "meas.json" )
audioFn = os.path.join(inDir, "audio.wav" )
with open(jsonFn,"r") as f:
r = json.load(f)
measD = r['measD']
cfg = types.SimpleNamespace(**r['cfg'])
axN = len(measD) if pitch is None else 1
fig,axL = plt.subplots(axN,1)
fig.set_size_inches(18.5, 10.5*axN)
# for each pitch
for axi,(midi_pitch,measL)in enumerate(measD.items()):
midi_pitch = int(midi_pitch)
if pitch is not None and pitch != midi_pitch:
continue
if pitch is not None:
axi = 0
axL = [ axL ]
targetDbS = set()
hmPulseDbL = []
tdPulseDbL = []
anPulseDbL = []
# for each measurement on this pitch
for mi,d in enumerate(measL):
m = types.SimpleNamespace(**d)
# form a list of pulse/db measurements associated with this pitch
hmPulseDbL.append( (m.pulse_us,m.hm['db'],m.matchFl,m.hm['durMs'],m.skipMeasFl) )
tdPulseDbL.append( (m.pulse_us,m.td['db'],m.matchFl,m.td['durMs'],m.skipMeasFl) )
ar = next(ad for ad in anlD[midi_pitch] if ad['meas_idx']==mi )
anPulseDbL.append( (m.pulse_us,ar['db'],m.matchFl,m.hm['durMs'],m.skipMeasFl))
# get the unique set of targets
targetDbS.add(m.targetDb)
# sort measurements on pulse length
hmPulseDbL = sorted(hmPulseDbL,key=lambda x: x[0])
tdPulseDbL = sorted(tdPulseDbL,key=lambda x: x[0])
anPulseDbL = sorted(anPulseDbL,key=lambda x: x[0])
# plot the re-analysis
pulseL,dbL,matchFlL,_,_ = zip(*anPulseDbL)
axL[axi].plot( pulseL, dbL, label="post", marker='.' )
# plot harmonic measurements
pulseL,dbL,matchFlL,durMsL,skipFlL = zip(*hmPulseDbL)
axL[axi].plot( pulseL, dbL, label="harm", marker='.' )
# plot time-domain based measuremented
pulseL,dbL,matchFlL,_,_ = zip(*tdPulseDbL)
axL[axi].plot( pulseL, dbL, label="td", marker='.' )
# plot target boundaries
for targetDb in targetDbS:
lwr = targetDb * ((100.0 - cfg.tolDbPct)/100.0)
upr = targetDb * ((100.0 + cfg.tolDbPct)/100.0 )
axL[axi].axhline(targetDb)
axL[axi].axhline(lwr,color='lightgray')
axL[axi].axhline(upr,color='gray')
# plot match and 'too-short' markers
for i,matchFl in enumerate(matchFlL):
if durMsL[i] < cfg.minMeasDurMs:
axL[axi].plot( pulseL[i], dbL[i], marker='x', color='black', linestyle='None')
if skipFlL[i]:
axL[axi].plot( pulseL[i], dbL[i], marker='+', color='blue', linestyle='None')
if matchFl:
axL[axi].plot( pulseL[i], dbL[i], marker='.', color='red', linestyle='None')
axL[axi].set_title("pitch:%i " % (midi_pitch))
plt.legend()
plt.show()
def plot_all_notes( inDir ):
jsonFn = os.path.join(inDir, "meas.json" )
audioFn = os.path.join(inDir, "audio.wav" )
with open(jsonFn,"r") as f:
r = json.load(f)
measD = r['measD']
axN = 0
for midi_pitch,measL in measD.items():
axN += len(measL)
print(axN)
fig,axL = plt.subplots(axN,1)
fig.set_size_inches(18.5, 10.5*axN)
i = 0
for midi_pitch,measL in measD.items():
for d in measL:
axL[i].plot(d['td']['rmsDbV'])
axL[i].plot(d['hm']['rmsDbV'])
axL[i].axvline(d['td']['pk_idx'],color='red')
axL[i].axvline(d['hm']['pk_idx'],color='green')
i += 1
plt.show()
if __name__ == "__main__":
pitch = None
inDir = sys.argv[1]
if len(sys.argv) > 2:
pitch = int(sys.argv[2])
#plot_all_notes( inDir )
plot_by_pitch(inDir,pitch)
#calibrate_recording_analysis( inDir )

View File

@ -1,4 +1,4 @@
import os,sys,pickle
import os,sys,pickle,json
import numpy as np
import matplotlib.pyplot as plt
@ -279,7 +279,35 @@ def plot_quiet_note_db( cacheFn, yamlCfgFn, minDurMs=700 ):
plt.show()
def dump_hold_duty_pct( inDir ):
pitchL = []
folderL = os.listdir(inDir)
for folder in folderL:
midi_pitch = int(folder)
fn = os.path.join( inDir,folder,"0","seq.json")
if not os.path.isfile(fn):
print("No sequence file:%s" % (fn))
else:
with open(fn,"r") as f:
d = json.load(f)
if 'holdDutyPct' in d:
holdDutyPctL = [ [0,d['holdDutyPct']] ]
else:
holdDutyPctL = d['holdDutyPctL']
pitchL.append( {'pitch':midi_pitch, 'holdDutyPctL':holdDutyPctL} )
#print(midi_pitch, holdDutyPctL)
pitchL = sorted(pitchL, key=lambda x: x['pitch'])
for d in pitchL:
print("{",d['pitch'],":",d['holdDutyPctL'],"},")
if __name__ == "__main__":
@ -297,4 +325,6 @@ if __name__ == "__main__":
#get_all_note_durations("/home/kevin/temp/p_ac_3c",durFn)
#plot_all_note_durations(durFn, np.arange(45,55),2,"p_ac.yml",800,20000)
plot_quiet_note_db(durFn,"p_ac.yml")
#plot_quiet_note_db(durFn,"p_ac.yml")
dump_hold_duty_pct( "/home/kevin/temp/p_ac_3c" )

View File

@ -1,4 +1,4 @@
import os,types,json,pickle
import os,types,json,pickle,types
from scipy.io import wavfile
from scipy.signal import stft
import numpy as np
@ -27,8 +27,9 @@ def calc_harm_bins( srate, binHz, midiPitch, harmN ):
return fund_l_binL, fund_m_binL, fund_u_binL
def rms_to_db( xV, rms_srate, refWndMs ):
dbWndN = int(round(refWndMs * rms_srate / 1000.0))
dbRef = ref = np.mean(xV[0:dbWndN])
#dbWndN = int(round(refWndMs * rms_srate / 1000.0))
#dbRef = ref = np.mean(xV[0:dbWndN])
dbRef = refWndMs ######################################################### HACK HACK HACK HACK HACK
rmsDbV = 20.0 * np.log10( xV / dbRef )
return rmsDbV
@ -213,9 +214,6 @@ def note_stats( r, decay_pct=50.0, extraDurSearchMs=500 ):
bi = pkSmpIdx
ei = pkSmpIdx + int(round(durMs * srate / 1000.0))
#bi = begSmpIdx
#ei = endSmpIdx
qualityCoeff = np.sum(r.rmsDbV[bi:ei]) + np.sum(r.tdRmsDbV[bi:ei])
if qualityCoeff > qmax:
qmax = qualityCoeff
@ -259,6 +257,106 @@ def key_info_dictionary( keyMapL=None, yamlCfgFn=None):
return kmD
def rms_analyze_one_rt_note( sigV, srate, begMs, endMs, midi_pitch, rmsWndMs=300, rmsHopMs=30, dbRefWndMs=500, harmCandN=5, harmN=3, durDecayPct=40 ):
sigV = np.squeeze(sigV)
# HACK HACK HACK HACK
dbRefWndMs = 0.002 # HACK HACK HACK HACK
# HACK HACK HACK HACK
td_rmsDbV, td_srate = audio_rms( srate, sigV, rmsWndMs, rmsHopMs, dbRefWndMs )
begSmpIdx = int(round(begMs * td_srate/1000))
endSmpIdx = int(round(endMs * td_srate/1000))
td_pk_idx = begSmpIdx + np.argmax(td_rmsDbV[begSmpIdx:endSmpIdx])
td_durMs = measure_duration_ms( td_rmsDbV, td_srate, td_pk_idx, len(sigV)-1, durDecayPct )
# HACK HACK HACK HACK
dbRefWndMs = 0.01 # HACK HACK HACK HACK
# HACK HACK HACK HACK
hm_rmsDbV, hm_srate, binHz = audio_harm_rms( srate, sigV, rmsWndMs, rmsHopMs, dbRefWndMs, midi_pitch, harmCandN, harmN )
begSmpIdx = int(round(begMs * hm_srate/1000))
endSmpIdx = int(round(endMs * hm_srate/1000))
hm_pk_idx = begSmpIdx + np.argmax(hm_rmsDbV[begSmpIdx:endSmpIdx])
hm_durMs = measure_duration_ms( hm_rmsDbV, hm_srate, hm_pk_idx, len(sigV)-1, durDecayPct )
tdD = { "rmsDbV":td_rmsDbV.tolist(), "srate":td_srate, "pk_idx":int(td_pk_idx), "db":float(td_rmsDbV[td_pk_idx]), "durMs":td_durMs }
hmD = { "rmsDbV":hm_rmsDbV.tolist(), "srate":hm_srate, "pk_idx":int(hm_pk_idx), "db":float(hm_rmsDbV[hm_pk_idx]), "durMs":hm_durMs }
return { "td":tdD, "hm":hmD }
def calibrate_rms( sigV, srate, beg_ms, end_ms ):
bi = int(round(beg_ms * srate / 1000))
ei = int(round(end_ms * srate / 1000))
rms = np.sqrt( np.mean( sigV[bi:ei] * sigV[bi:ei] ))
return 20.0*np.log10( rms / 0.002 )
def calibrate_recording_analysis( inDir ):
jsonFn = os.path.join(inDir, "meas.json" )
audioFn = os.path.join(inDir, "audio.wav" )
with open(jsonFn,"r") as f:
r = json.load(f)
measD = r['measD']
cfg = types.SimpleNamespace(**r['cfg'])
annL = r['annoteL']
anlD = {}
n = 0
for midi_pitch,measL in measD.items():
n += len(measL)
anlD[int(midi_pitch)] = []
srate, signalM = wavfile.read(audioFn)
sigV = signalM / float(0x7fff)
anlr = types.SimpleNamespace(**cfg.analysisD)
# HACK HACK HACK HACK
dbRefWndMs = 0.002 # HACK HACK HACK HACK
# HACK HACK HACK HACK
tdRmsDbV, td_srate = audio_rms( srate, sigV, anlr.rmsWndMs, anlr.rmsHopMs, dbRefWndMs )
# for each measured pitch
for midi_pitch,measL in measD.items():
# for each measured note at this pitch
for mi,d in enumerate(measL):
mr = types.SimpleNamespace(**d)
# locate the associated annotation reocrd
for annD in annL:
ar = types.SimpleNamespace(**annD)
if ar.midi_pitch == mr.midi_pitch and ar.beg_ms==mr.beg_ms and ar.end_ms==mr.end_ms:
assert( ar.pulse_us == mr.pulse_us )
bi = int(round(ar.beg_ms * td_srate / 1000))
ei = int(round(ar.end_ms * td_srate / 1000))
db = np.mean(tdRmsDbV[bi:ei])
db = calibrate_rms( sigV, srate, ar.beg_ms, ar.end_ms )
anlD[int(midi_pitch)].append({ 'pulse_us':ar.pulse_us, 'db':db, 'meas_idx':mi })
break
return anlD
def rms_analysis_main( inDir, midi_pitch, rmsWndMs=300, rmsHopMs=30, dbRefWndMs=500, harmCandN=5, harmN=3, durDecayPct=40 ):