diff --git a/settings.ini.example b/settings.ini.example index e888190..88fabc9 100644 --- a/settings.ini.example +++ b/settings.ini.example @@ -3,6 +3,24 @@ [general] size=128 + +; ===== CHROMATIC QUANTIZATION ===== +; Snaps pitch bends to semitones for whole-tone layouts. +; REQUIRED: LinnStrument Quantize=OFF, Quantize Tap=OFF, Quantize Hold=OFF +; +;chromatic_quantize=true +; +; Whole tone bias: compensates for mechanical surface differences (-1 to 1) +; 0 = equal, 0.4375 = recommended for speed bump surface +;whole_tone_bias=0.4375 +; +; Vibrato sensitivity: movement detection threshold (0 to 1) +; 0 = always snap, 0.5 = balanced (recommended), 1 = never snap +; If you increased LinnStrument's Touch Sensor Prescale (Global Settings > hold +; Calibration + tap Pressure Medium) for more sensitivity, the sensor will detect +; smaller movements, so unwanted microtones will pass through. Lower this value to fix. +;quantize_hold_threshold=0.5 + ;velocity_curve=1.0 ;lights=1,9,9,2,2,3,3,5,8,8,11,11 ;split_lights=4,7,5,7,5,5,7,5,7,5,7,5 diff --git a/src/core.py b/src/core.py index 930d754..c159ffa 100644 --- a/src/core.py +++ b/src/core.py @@ -957,6 +957,75 @@ def cb_midi_in(self, data, timestamp, force_channel=None): skip = False if msg == 14: + # Scale pitch bend for mech layout (bend_scale in settings.ini) + # Especially useful for whole-tone slides - smooth bends let you + # reliably hit semitones in between the whole tones + # NOTE: LinnStrument Pitch Quantize must be OFF for smooth slides + # NOTE: Synth must have MPE enabled for per-note slides + bend_val = decompose_pitch_bend((data[1], data[2])) + bend_val *= self.options.bend_scale + + # Software chromatic quantization (12-TET) + # Snaps pitch bends to nearest semitone for whole-tone layouts + # Requires: LinnStrument Quantize=OFF, Quantize Tap=OFF, Quantize Hold=OFF + if self.options.chromatic_quantize: + # Semitone step size in normalized bend units + # column_offset accounts for whole-tone layout (2 semitones per pad) + semitone = 1.0 / (self.options.bend_range * self.options.column_offset) + + # Movement-based vibrato detection + # threshold 0 = always snap, 1 = never snap (all vibrato passes) + note = self.notes[ch] + threshold = self.options.quantize_hold_threshold + + # Calculate movement rate (EMA of bend delta) + # bend_val is normalized -1 to 1, delta is movement per message + delta = abs(bend_val - note.last_bend) + alpha = 0.2 + note.rate_x = note.rate_x * (1 - alpha) + delta * alpha + note.last_bend = bend_val + + # Map threshold (0-1) to rate_threshold + # 0 = high rate_threshold (hard to be "moving", always snap) + # 1 = zero rate_threshold (any movement passes through) + max_rate = 0.02 # 2% of bend range per message is significant movement + rate_threshold = max_rate * (1 - threshold) ** 2 + + # Stationary counter for smooth transitions + is_moving = note.rate_x > rate_threshold + if is_moving: + note.stationary_count = 0 + else: + note.stationary_count = min(note.stationary_count + 1, 20) + + # Quantize only when stationary for a few samples + should_quantize = note.stationary_count >= 8 + + if should_quantize: + semitones = bend_val / semitone + bias = self.options.whole_tone_bias + + if bias == 0: + nearest = round(semitones) + else: + # Biased rounding to compensate for mechanical/surface differences + floor_semi = int(semitones) if semitones >= 0 else int(semitones) - 1 + frac = semitones - floor_semi + + # Even floor = whole tone (pad center), Odd = semitone (between pads) + if floor_semi % 2 == 0: + bias_threshold = 0.5 + bias + else: + bias_threshold = 0.5 - bias + + bias_threshold = max(0.05, min(0.95, bias_threshold)) + nearest = floor_semi + 1 if frac >= bias_threshold else floor_semi + + bend_val = nearest * semitone + # else: moving - bend_val stays raw (vibrato passes through) + + data[1], data[2] = compose_pitch_bend(bend_val) + if self.is_split(): # experimental: ignore pitch bend for a certain split split_chan = self.notes[ch].split @@ -1386,6 +1455,22 @@ def __init__(self): self.options.y_bend = get_option( opts, "y_bend", DEFAULT_OPTIONS.y_bend ) + + # Pitch bend scaling for mech layout (adjust if slides are too slow/fast) + self.options.bend_scale = get_option( + opts, "bend_scale", DEFAULT_OPTIONS.bend_scale + ) + + # Software chromatic quantization (12-TET) + self.options.chromatic_quantize = get_option( + opts, "chromatic_quantize", DEFAULT_OPTIONS.chromatic_quantize + ) + self.options.whole_tone_bias = get_option( + opts, "whole_tone_bias", DEFAULT_OPTIONS.whole_tone_bias + ) + self.options.quantize_hold_threshold = get_option( + opts, "quantize_hold_threshold", DEFAULT_OPTIONS.quantize_hold_threshold + ) # self.options.mpe = get_option( # opts, "mpe", DEFAULT_OPTIONS.mpe diff --git a/src/note.py b/src/note.py index d517415..e676755 100644 --- a/src/note.py +++ b/src/note.py @@ -14,6 +14,11 @@ def __init__(self): # apply additional bend? self.bend = 0.0 self.y_bend = 0.0 + + # Quantize hold state (for movement detection) + self.last_bend = 0.0 # previous bend value + self.rate_x = 0.0 # exponential moving average of bend change rate + self.stationary_count = 0 # how many samples we've been stationary # def logic(self, dt): # if self.pressed: # pressed, fade to pressure value diff --git a/src/settings.py b/src/settings.py index c6eef24..3a79f15 100644 --- a/src/settings.py +++ b/src/settings.py @@ -96,6 +96,9 @@ class Settings: bend_range: int = 24 + # Pitch bend scaling for mech layout (1.0 = no scaling, 2.0 = double) + bend_scale: float = 1.0 + row_offset: int = 5 column_offset: int = 2 base_offset: int = 4 @@ -106,5 +109,40 @@ class Settings: # octave splitting the linn and transposing octaves on the right side octave_split: int = 0 + # Software chromatic quantization (12-TET) + # Snaps pitch bends to nearest semitone, allowing semitones between whole-tone pads. + # Without quantization, every note has slight microtonal errors from human imprecision. + # At this pad scale, you can only realistically aim for semitones or whole tones. + # Hardware quantization only snaps to pad centers (whole tones), missing semitones. + # This software quantization snaps to ALL 12 chromatic semitones. + # REQUIRED: Set LinnStrument Quantize=OFF, Quantize Tap=OFF, Quantize Hold=OFF + chromatic_quantize: bool = False + + # Whole tone bias: adjusts the rounding threshold between semitones and whole tones. + # Range: -1.0 to 1.0 + # 0.0 = equal zones (50/50 split, standard rounding at 0.5 threshold) + # Positive = larger whole-tone zones (need to aim closer to center to hit semitones) + # Negative = larger semitone zones (easier to hit semitones accidentally) + # Recommended: 0.4375 for LinnStrument speed bump surface + whole_tone_bias: float = 0.0 + + # Quantize Hold Threshold: movement sensitivity for vibrato detection (0 to 1) + # + # How it works: + # Detects finger movement (wiggling) vs holding still. + # When moving: microtones pass through (allows vibrato/pitch bends) + # When stationary: snaps to nearest semitone (clean pitch) + # + # Values: + # 0.0 = always snap, no vibrato passthrough (most stable) + # 0.5 = balanced - recommended default + # 1.0 = never snap, all microtones pass through (no quantization) + # + # Tuning guide: + # Too low: vibrato won't trigger, sounds auto-tuned + # Too high: natural hand tremor triggers unwanted microtones + # Start at 0.5, adjust ±0.1 based on your playing style + quantize_hold_threshold: float = 0.5 + DEFAULT_OPTIONS = Settings() diff --git a/src/util.py b/src/util.py index 458ee54..3077359 100644 --- a/src/util.py +++ b/src/util.py @@ -108,7 +108,12 @@ def decompose_pitch_bend(pitch_bend_bytes): return pitch_bend_norm def compose_pitch_bend(pitch_bend_norm): - pitch_bend_value = int((pitch_bend_norm + 1.0) * 8192) + # Clamp input to valid range + pitch_bend_norm = max(-1.0, min(1.0, pitch_bend_norm)) + # Scale to 0-16383 (14-bit MIDI pitch bend range) + # Using 8191.5 and round() to correctly map: -1.0->0, 0.0->8192, 1.0->16383 + pitch_bend_value = int(round((pitch_bend_norm + 1.0) * 8191.5)) + pitch_bend_value = max(0, min(16383, pitch_bend_value)) # Ensure valid range pitch_bend_bytes = [pitch_bend_value & 0x7F, (pitch_bend_value >> 7) & 0x7F] return pitch_bend_bytes @@ -124,3 +129,6 @@ def get_color(col): if col.startswith("#"): return webcolors.hex_to_rgb(col) return webcolors.name_to_rgb(col) + + +