Skip to content

Commit f0452d5

Browse files
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
1 parent af1a519 commit f0452d5

5 files changed

Lines changed: 111 additions & 0 deletions

File tree

settings.ini.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33

44
[general]
55
size=128
6+
7+
; Software chromatic quantization (12-TET) - see settings.py for full documentation
8+
; REQUIRED: LinnStrument Quantize=OFF, Quantize Tap=OFF, Quantize Hold=OFF
9+
;chromatic_quantize=true
10+
;whole_tone_bias=0.4375
11+
;quantize_hold=medium
12+
613
;velocity_curve=1.0
714
;lights=1,9,9,2,2,3,3,5,8,8,11,11
815
;split_lights=4,7,5,7,5,5,7,5,7,5,7,5

src/core.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(

src/note.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ def __init__(self):
1414
# apply additional bend?
1515
self.bend = 0.0
1616
self.y_bend = 0.0
17+
18+
# Quantize hold state (for movement detection)
19+
self.last_bend = 0.0 # previous bend value
20+
self.rate_x = 0.0 # exponential moving average of bend change rate
21+
self.stationary_count = 0 # how many samples we've been stationary
1722

1823
# def logic(self, dt):
1924
# if self.pressed: # pressed, fade to pressure value

src/settings.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,29 @@ class Settings:
109109
# octave splitting the linn and transposing octaves on the right side
110110
octave_split: int = 0
111111

112+
# Software chromatic quantization (12-TET)
113+
# Snaps pitch bends to nearest semitone, allowing semitones between whole-tone pads.
114+
# Without quantization, every note has slight microtonal errors from human imprecision.
115+
# At this pad scale, you can only realistically aim for semitones or whole tones.
116+
# Hardware quantization only snaps to pad centers (whole tones), missing semitones.
117+
# This software quantization snaps to ALL 12 chromatic semitones.
118+
# REQUIRED: Set LinnStrument Quantize=OFF, Quantize Tap=OFF, Quantize Hold=OFF
119+
chromatic_quantize: bool = False
120+
121+
# Whole tone bias: adjusts the rounding threshold between semitones and whole tones.
122+
# Range: -1.0 to 1.0
123+
# 0.0 = equal zones (50/50 split, standard rounding at 0.5 threshold)
124+
# Positive = larger whole-tone zones (need to aim closer to center to hit semitones)
125+
# Negative = larger semitone zones (easier to hit semitones accidentally)
126+
# Recommended: 0.4375 for LinnStrument speed bump surface
127+
whole_tone_bias: float = 0.0
128+
129+
# Quantize Hold: movement detection for vibrato support
130+
# When moving (vibrato): allow microtones to pass through
131+
# When stationary: snap to nearest semitone
132+
# Modes: "off" = always snap, "fast" = quick snap, "medium" = balanced, "slow" = gradual
133+
# Based on LinnStrument firmware Quan Hold behavior
134+
quantize_hold: str = "medium"
135+
112136
DEFAULT_OPTIONS = Settings()
113137

src/util.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,6 @@ def get_color(col):
129129
if col.startswith("#"):
130130
return webcolors.hex_to_rgb(col)
131131
return webcolors.name_to_rgb(col)
132+
133+
134+

0 commit comments

Comments
 (0)