From af1a519569159a94bd4797408589db3ddb510a2c Mon Sep 17 00:00:00 2001 From: The Zilchotron6000 Date: Sat, 17 Jan 2026 20:07:21 +0000 Subject: [PATCH 1/5] Add pitch bend scaling for MPE slides + fix compose_pitch_bend overflow - Add bend_scale setting (default 1.0) for adjusting pitch bend intensity - Fix compose_pitch_bend() overflow bug: max bend (16384) wrapped to min - Add helpful comments: LinnStrument Pitch Quantize must be OFF for smooth slides --- src/core.py | 14 ++++++++++++++ src/settings.py | 3 +++ src/util.py | 7 ++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/core.py b/src/core.py index 930d754..01ceec6 100644 --- a/src/core.py +++ b/src/core.py @@ -957,6 +957,15 @@ 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 + 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 +1395,11 @@ 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 + ) # self.options.mpe = get_option( # opts, "mpe", DEFAULT_OPTIONS.mpe diff --git a/src/settings.py b/src/settings.py index c6eef24..9c82b6a 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 diff --git a/src/util.py b/src/util.py index 458ee54..0e7bcc2 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 From f0452d54c34f72f1af2572e2ec512a7f5fb2ed79 Mon Sep 17 00:00:00 2001 From: The Zilchotron6000 Date: Mon, 19 Jan 2026 20:02:41 +0000 Subject: [PATCH 2/5] Add software chromatic quantization (12-TET) for whole-tone layouts Allows semitones to be hit between whole-tone pads via software quantization. New settings: - chromatic_quantize: Enable 12-TET semitone snapping (default: false) - whole_tone_bias: Adjust zone balance, range -1.0 to 1.0 (default: 0.0) - 0.15 recommended for LinnStrument speed bump surface Requirements: - LinnStrument Quantize=OFF, Quantize Tap=OFF, Quantize Hold=OFF - This setting overrides hardware quantization with proper semitone support --- settings.ini.example | 7 +++++ src/core.py | 72 ++++++++++++++++++++++++++++++++++++++++++++ src/note.py | 5 +++ src/settings.py | 24 +++++++++++++++ src/util.py | 3 ++ 5 files changed, 111 insertions(+) diff --git a/settings.ini.example b/settings.ini.example index e888190..64e5252 100644 --- a/settings.ini.example +++ b/settings.ini.example @@ -3,6 +3,13 @@ [general] size=128 + +; Software chromatic quantization (12-TET) - see settings.py for full documentation +; REQUIRED: LinnStrument Quantize=OFF, Quantize Tap=OFF, Quantize Hold=OFF +;chromatic_quantize=true +;whole_tone_bias=0.4375 +;quantize_hold=medium + ;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 01ceec6..9abb50c 100644 --- a/src/core.py +++ b/src/core.py @@ -964,6 +964,70 @@ def cb_midi_in(self, data, timestamp, force_channel=None): # 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) + + # Quan Hold: movement detection (allows vibrato when moving) + # Based on LinnStrument firmware behavior + note = self.notes[ch] + hold_mode = self.options.quantize_hold + + # Mode settings: (rate_threshold, stationary_samples) + # Lower threshold = more sensitive to movement + # Higher samples = slower snap-back + hold_configs = { + "off": (0.0, 0), # Always quantize + "fast": (0.004, 8), # Quick snap + "medium": (0.003, 24), # Balanced + "slow": (0.002, 48) # Gradual + } + rate_threshold, stationary_samples = hold_configs.get(hold_mode, hold_configs["medium"]) + + # Calculate movement rate (EMA of bend delta) + delta = abs(bend_val - note.last_bend) + alpha = 0.2 # EMA smoothing factor + note.rate_x = note.rate_x * (1 - alpha) + delta * alpha + note.last_bend = bend_val + + # Determine if stationary + is_stationary = note.rate_x < rate_threshold + + if is_stationary: + note.stationary_count = min(note.stationary_count + 1, stationary_samples + 10) + else: + note.stationary_count = max(0, note.stationary_count - 2) + + # Only quantize when stationary (or always if hold_mode is "off") + should_quantize = hold_mode == "off" or note.stationary_count >= stationary_samples + + 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: + threshold = 0.5 + bias + else: + threshold = 0.5 - bias + + threshold = max(0.05, min(0.95, threshold)) + nearest = floor_semi + 1 if frac >= threshold else floor_semi + + bend_val = nearest * semitone + data[1], data[2] = compose_pitch_bend(bend_val) if self.is_split(): @@ -1401,6 +1465,14 @@ def __init__(self): 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.mpe = get_option( # opts, "mpe", DEFAULT_OPTIONS.mpe # ) or get_option( 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 9c82b6a..6e5819f 100644 --- a/src/settings.py +++ b/src/settings.py @@ -109,5 +109,29 @@ 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: movement detection for vibrato support + # When moving (vibrato): allow microtones to pass through + # When stationary: snap to nearest semitone + # Modes: "off" = always snap, "fast" = quick snap, "medium" = balanced, "slow" = gradual + # Based on LinnStrument firmware Quan Hold behavior + quantize_hold: str = "medium" + DEFAULT_OPTIONS = Settings() diff --git a/src/util.py b/src/util.py index 0e7bcc2..3077359 100644 --- a/src/util.py +++ b/src/util.py @@ -129,3 +129,6 @@ def get_color(col): if col.startswith("#"): return webcolors.hex_to_rgb(col) return webcolors.name_to_rgb(col) + + + From a56468ed26ce4e1ea7ee0528e1b65b66a6f320ff Mon Sep 17 00:00:00 2001 From: The Zilchotron6000 Date: Mon, 19 Jan 2026 22:15:47 +0000 Subject: [PATCH 3/5] Add movement-based vibrato detection with numeric threshold - Replace string-based quantize_hold (off/fast/medium/slow) with numeric quantize_hold_threshold (0-1) - 0 = always snap (no vibrato), 1 = never snap (all microtones), 0.5 = balanced default - Movement detection: wiggling passes through microtones, stationary snaps to semitones - Preserves whole_tone_bias for mechanical surface compensation - Layout-independent: works regardless of underlying interval (semitones, whole tones, etc.) --- settings.ini.example | 2 +- src/core.py | 51 ++++++++++++++++++++++---------------------- src/settings.py | 13 +++++------ 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/settings.ini.example b/settings.ini.example index 64e5252..e77b253 100644 --- a/settings.ini.example +++ b/settings.ini.example @@ -8,7 +8,7 @@ size=128 ; REQUIRED: LinnStrument Quantize=OFF, Quantize Tap=OFF, Quantize Hold=OFF ;chromatic_quantize=true ;whole_tone_bias=0.4375 -;quantize_hold=medium +;quantize_hold_threshold=0.3 ;velocity_curve=1.0 ;lights=1,9,9,2,2,3,3,5,8,8,11,11 diff --git a/src/core.py b/src/core.py index 9abb50c..c159ffa 100644 --- a/src/core.py +++ b/src/core.py @@ -973,38 +973,33 @@ def cb_midi_in(self, data, timestamp, force_channel=None): # column_offset accounts for whole-tone layout (2 semitones per pad) semitone = 1.0 / (self.options.bend_range * self.options.column_offset) - # Quan Hold: movement detection (allows vibrato when moving) - # Based on LinnStrument firmware behavior + # Movement-based vibrato detection + # threshold 0 = always snap, 1 = never snap (all vibrato passes) note = self.notes[ch] - hold_mode = self.options.quantize_hold - - # Mode settings: (rate_threshold, stationary_samples) - # Lower threshold = more sensitive to movement - # Higher samples = slower snap-back - hold_configs = { - "off": (0.0, 0), # Always quantize - "fast": (0.004, 8), # Quick snap - "medium": (0.003, 24), # Balanced - "slow": (0.002, 48) # Gradual - } - rate_threshold, stationary_samples = hold_configs.get(hold_mode, hold_configs["medium"]) + 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 # EMA smoothing factor + alpha = 0.2 note.rate_x = note.rate_x * (1 - alpha) + delta * alpha note.last_bend = bend_val - # Determine if stationary - is_stationary = note.rate_x < rate_threshold + # 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 - if is_stationary: - note.stationary_count = min(note.stationary_count + 1, stationary_samples + 10) + # Stationary counter for smooth transitions + is_moving = note.rate_x > rate_threshold + if is_moving: + note.stationary_count = 0 else: - note.stationary_count = max(0, note.stationary_count - 2) + note.stationary_count = min(note.stationary_count + 1, 20) - # Only quantize when stationary (or always if hold_mode is "off") - should_quantize = hold_mode == "off" or note.stationary_count >= stationary_samples + # Quantize only when stationary for a few samples + should_quantize = note.stationary_count >= 8 if should_quantize: semitones = bend_val / semitone @@ -1019,14 +1014,15 @@ def cb_midi_in(self, data, timestamp, force_channel=None): # Even floor = whole tone (pad center), Odd = semitone (between pads) if floor_semi % 2 == 0: - threshold = 0.5 + bias + bias_threshold = 0.5 + bias else: - threshold = 0.5 - bias + bias_threshold = 0.5 - bias - threshold = max(0.05, min(0.95, threshold)) - nearest = floor_semi + 1 if frac >= threshold else floor_semi + 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) @@ -1472,6 +1468,9 @@ def __init__(self): 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/settings.py b/src/settings.py index 6e5819f..d5540d0 100644 --- a/src/settings.py +++ b/src/settings.py @@ -126,12 +126,13 @@ class Settings: # Recommended: 0.4375 for LinnStrument speed bump surface whole_tone_bias: float = 0.0 - # Quantize Hold: movement detection for vibrato support - # When moving (vibrato): allow microtones to pass through - # When stationary: snap to nearest semitone - # Modes: "off" = always snap, "fast" = quick snap, "medium" = balanced, "slow" = gradual - # Based on LinnStrument firmware Quan Hold behavior - quantize_hold: str = "medium" + # Quantize Hold Threshold: movement sensitivity for vibrato (0 to 1) + # Detects if you're wiggling (vibrato) vs holding still + # 0 = always snap (even when moving) + # 1 = never snap (all movement passes through as microtones) + # 0.5 = balanced (moderate movement triggers vibrato) + # Lower = need more aggressive movement to trigger vibrato + quantize_hold_threshold: float = 0.0 DEFAULT_OPTIONS = Settings() From 279b7a682784ad4d1d5e93409db56cf840274116 Mon Sep 17 00:00:00 2001 From: The Zilchotron6000 Date: Mon, 19 Jan 2026 22:17:01 +0000 Subject: [PATCH 4/5] Clean up documentation and set sensible default (0.5) - Comprehensive docs in settings.py (single source of truth) - Updated settings.ini.example with grouped chromatic quantization settings - Default quantize_hold_threshold=0.5 (balanced vibrato sensitivity) --- settings.ini.example | 12 ++++++++++-- src/settings.py | 24 +++++++++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/settings.ini.example b/settings.ini.example index e77b253..8647d0e 100644 --- a/settings.ini.example +++ b/settings.ini.example @@ -4,11 +4,19 @@ [general] size=128 -; Software chromatic quantization (12-TET) - see settings.py for full documentation +; ===== 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 -;quantize_hold_threshold=0.3 +; +; Vibrato sensitivity: movement detection threshold (0 to 1) +; 0 = always snap, 0.5 = balanced (recommended), 1 = never snap +;quantize_hold_threshold=0.5 ;velocity_curve=1.0 ;lights=1,9,9,2,2,3,3,5,8,8,11,11 diff --git a/src/settings.py b/src/settings.py index d5540d0..3a79f15 100644 --- a/src/settings.py +++ b/src/settings.py @@ -126,13 +126,23 @@ class Settings: # Recommended: 0.4375 for LinnStrument speed bump surface whole_tone_bias: float = 0.0 - # Quantize Hold Threshold: movement sensitivity for vibrato (0 to 1) - # Detects if you're wiggling (vibrato) vs holding still - # 0 = always snap (even when moving) - # 1 = never snap (all movement passes through as microtones) - # 0.5 = balanced (moderate movement triggers vibrato) - # Lower = need more aggressive movement to trigger vibrato - quantize_hold_threshold: 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() From 75dfa4a835fd26542981eb5e759b2351b4695bb5 Mon Sep 17 00:00:00 2001 From: The Zilchotron6000 Date: Tue, 27 Jan 2026 21:20:24 +0000 Subject: [PATCH 5/5] Add note about Touch Sensor Prescale affecting quantize threshold --- settings.ini.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/settings.ini.example b/settings.ini.example index 8647d0e..88fabc9 100644 --- a/settings.ini.example +++ b/settings.ini.example @@ -16,6 +16,9 @@ size=128 ; ; 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