@@ -964,6 +964,70 @@ def cb_midi_in(self, data, timestamp, force_channel=None):
964964 # NOTE: Synth must have MPE enabled for per-note slides
965965 bend_val = decompose_pitch_bend ((data [1 ], data [2 ]))
966966 bend_val *= self .options .bend_scale
967+
968+ # Software chromatic quantization (12-TET)
969+ # Snaps pitch bends to nearest semitone for whole-tone layouts
970+ # Requires: LinnStrument Quantize=OFF, Quantize Tap=OFF, Quantize Hold=OFF
971+ if self .options .chromatic_quantize :
972+ # Semitone step size in normalized bend units
973+ # column_offset accounts for whole-tone layout (2 semitones per pad)
974+ semitone = 1.0 / (self .options .bend_range * self .options .column_offset )
975+
976+ # Quan Hold: movement detection (allows vibrato when moving)
977+ # Based on LinnStrument firmware behavior
978+ note = self .notes [ch ]
979+ hold_mode = self .options .quantize_hold
980+
981+ # Mode settings: (rate_threshold, stationary_samples)
982+ # Lower threshold = more sensitive to movement
983+ # Higher samples = slower snap-back
984+ hold_configs = {
985+ "off" : (0.0 , 0 ), # Always quantize
986+ "fast" : (0.004 , 8 ), # Quick snap
987+ "medium" : (0.003 , 24 ), # Balanced
988+ "slow" : (0.002 , 48 ) # Gradual
989+ }
990+ rate_threshold , stationary_samples = hold_configs .get (hold_mode , hold_configs ["medium" ])
991+
992+ # Calculate movement rate (EMA of bend delta)
993+ delta = abs (bend_val - note .last_bend )
994+ alpha = 0.2 # EMA smoothing factor
995+ note .rate_x = note .rate_x * (1 - alpha ) + delta * alpha
996+ note .last_bend = bend_val
997+
998+ # Determine if stationary
999+ is_stationary = note .rate_x < rate_threshold
1000+
1001+ if is_stationary :
1002+ note .stationary_count = min (note .stationary_count + 1 , stationary_samples + 10 )
1003+ else :
1004+ note .stationary_count = max (0 , note .stationary_count - 2 )
1005+
1006+ # Only quantize when stationary (or always if hold_mode is "off")
1007+ should_quantize = hold_mode == "off" or note .stationary_count >= stationary_samples
1008+
1009+ if should_quantize :
1010+ semitones = bend_val / semitone
1011+ bias = self .options .whole_tone_bias
1012+
1013+ if bias == 0 :
1014+ nearest = round (semitones )
1015+ else :
1016+ # Biased rounding to compensate for mechanical/surface differences
1017+ floor_semi = int (semitones ) if semitones >= 0 else int (semitones ) - 1
1018+ frac = semitones - floor_semi
1019+
1020+ # Even floor = whole tone (pad center), Odd = semitone (between pads)
1021+ if floor_semi % 2 == 0 :
1022+ threshold = 0.5 + bias
1023+ else :
1024+ threshold = 0.5 - bias
1025+
1026+ threshold = max (0.05 , min (0.95 , threshold ))
1027+ nearest = floor_semi + 1 if frac >= threshold else floor_semi
1028+
1029+ bend_val = nearest * semitone
1030+
9671031 data [1 ], data [2 ] = compose_pitch_bend (bend_val )
9681032
9691033 if self .is_split ():
@@ -1401,6 +1465,14 @@ def __init__(self):
14011465 opts , "bend_scale" , DEFAULT_OPTIONS .bend_scale
14021466 )
14031467
1468+ # Software chromatic quantization (12-TET)
1469+ self .options .chromatic_quantize = get_option (
1470+ opts , "chromatic_quantize" , DEFAULT_OPTIONS .chromatic_quantize
1471+ )
1472+ self .options .whole_tone_bias = get_option (
1473+ opts , "whole_tone_bias" , DEFAULT_OPTIONS .whole_tone_bias
1474+ )
1475+
14041476 # self.options.mpe = get_option(
14051477 # opts, "mpe", DEFAULT_OPTIONS.mpe
14061478 # ) or get_option(
0 commit comments