Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions settings.ini.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions src/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions src/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()

10 changes: 9 additions & 1 deletion src/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -124,3 +129,6 @@ def get_color(col):
if col.startswith("#"):
return webcolors.hex_to_rgb(col)
return webcolors.name_to_rgb(col)