diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..765222b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +## 0.5.0 + +_First versioned release._ + +## 0.6.0-pre + +_Currently in development._ \ No newline at end of file diff --git a/CONTRIBUTORS b/CONTRIBUTORS index e5501e3..02701c2 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -1,2 +1,4 @@ Grady O'Connell Andy Klise +zass30 +Richard Smith diff --git a/README.md b/README.md index 904b47e..fc78b27 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ LinnStrument Community Discord: https://discord.gg/h2BcrzmTXe ## Advantages -- Notes that sound good together are closer together. Notes that sound worse are furthest apart. Mistakes will be less likely and less obvious! +- Notes that sound good together are closer together. Notes that sound worse are furthest apart. Mistakes will be less likely and won't sound as dissonant. - Like the LinnStrument's layout, it is also isomorphic (the same chord and scale shapes can be played anywhere) -- The most common chords and scales are far easier to play and remember than other layouts. +- The most common chords and scales are far easier to play and remember than those of other layouts. - Extended range compared to standard +5 tuning, making room for using a split. - Less finger stretching than other layouts when playing chords, which may help ergonomically. - Arpeggios are quite smooth, as you're simply walking stacked shapes. @@ -164,18 +164,13 @@ launchpad=false ### Vibrato -Midimech adds a cool feature to the Launchpad where it can detect wiggling a note to create a vibrato effect. This is enabled by default and mapped to CC0. If your synth supports CC0 vibrato, you should hear the vibrato activate by rocking your finger back and forth from left to right while pressing the note down. +Midimech adds a cool feature to the Launchpad where it can detect wiggling a note to create a vibrato effect. This is disabled by default and mapped to CC0. If your synth supports CC0 vibrato, you should hear the vibrato activate by rocking your finger back and forth from left to right while pressing the note down. -You can disable it in settings.ini using: +You can enable it in settings.ini using: ``` -vibrato=off +vibrato=mod ``` -There is also experimental support for pitch wheel vibrato using: - -``` -vibrato=pitch -``` ## Color Schemes diff --git a/VERSION b/VERSION index 8f0916f..577e58b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.6.0-pre \ No newline at end of file diff --git a/midimech.py b/midimech.py index 2db8b2d..c991867 100755 --- a/midimech.py +++ b/midimech.py @@ -26,6 +26,11 @@ # print("The project dependencies have changed! Run the requirements setup command again!") # sys.exit(1) +def error(err): + print(err) + input() + sys.exit(1) + try: import launchpad_py as launchpad except ImportError: @@ -46,7 +51,6 @@ except ImportError: error("The project dependencies have changed! Run the requirements setup command again!") - def main(): core = None try: diff --git a/requirements.txt b/requirements.txt index f364c84..2df696a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ launchpad-py musicpy pyyaml webcolors +easygui diff --git a/scripts/build_windows_package.ps1 b/scripts/build_windows_package.ps1 index 5f8b3f2..fb7ef9f 100644 --- a/scripts/build_windows_package.ps1 +++ b/scripts/build_windows_package.ps1 @@ -1,3 +1,3 @@ cd .. -python -m PyInstaller --onefile midimech.py +python -m PyInstaller --icon=icon.ico --onefile midimech.py cd scripts diff --git a/src/articulation.py b/src/articulation.py index 16b8681..8c21109 100644 --- a/src/articulation.py +++ b/src/articulation.py @@ -1,6 +1,7 @@ -from enum import Enum + from src.util import * from src.constants import * +from enum import Enum class Articulation: State = Enum('state', 'off pre attack hold release') diff --git a/src/constants.py b/src/constants.py index a8c4b66..9c50940 100644 --- a/src/constants.py +++ b/src/constants.py @@ -2,7 +2,10 @@ TITLE = "midimech" # FOCUS = False -NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] +#U+1D12C flat +#1D130 sharp +NOTES_SHARPS = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] +NOTES_FLATS = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"] WHOLETONE = True FONT_SZ = 32 BRIGHTNESS = 0.4 diff --git a/src/core.py b/src/core.py index 930d754..a3126a4 100644 --- a/src/core.py +++ b/src/core.py @@ -17,6 +17,14 @@ from src.articulation import Articulation # from src.gamepad import Gamepad +GRADIENT = False + +try: + from easygui import msgbox +except ImportError: + print("The project dependencies have changed! Run the requirements setup command again!") + sys.exit(1) + with open(os.devnull, "w") as devnull: # suppress pygame messages (to keep console output clean) stdout = sys.stdout @@ -26,25 +34,30 @@ sys.stdout = stdout import pygame_gui +def error(err): + msgbox(err) + sys.exit(1) + +def dependency_error(): + error("The project dependencies have changed! Run the requirements setup command again!") + try: import launchpad_py as launchpad except ImportError: try: import launchpad except ImportError: - error("The project dependencies have changed! Run the requirements setup command again!") + dependency_error() try: import yaml except ImportError: - error("The project dependencies have changed! Run the requirements setup command again!") - -# import mido + dependency_error() try: import musicpy as mp except ImportError: - error("The project dependencies have changed! Run the requirements setup command again!") + dependency_error() class Core: CORE = None @@ -93,6 +106,15 @@ def next_mode(self, ofs=1): self.set_mode((self.mode_index + ofs) % self.scale_notes.count('x')) self.dirty = self.dirty_lights = True + def toggle_sharps_flats(self, ofs=1): + if (self.use_sharps): + self.use_sharps = False + self.NOTES = NOTES_FLATS + else: + self.use_sharps = True + self.NOTES = NOTES_SHARPS + self.dirty = self.dirty_lights = True + def prev_scale(self, ofs=1): self.next_scale(-ofs) @@ -150,8 +172,8 @@ def send_all_notes_off(self): return # for ch in range(0,15): ch = 0 - self.midi_write(self.midi_out, [0xb0 | ch, 120, 0], 0) - self.midi_write(self.midi_out, [0xb0 | ch, 123, 0], 0) + self.midi_write(self.midi_out, [0xb0 | ch, 120, 0], 0) # all sounds off + self.midi_write(self.midi_out, [0xb0 | ch, 123, 0], 0) # all notes off def ls_color(self, x, y, col): """Set LinnStrument pad color""" @@ -159,8 +181,21 @@ def ls_color(self, x, y, col): self.send_ls_cc(0, 20, x + 1) self.send_ls_cc(0, 21, self.board_h - y - 1) self.send_ls_cc(0, 22, col) + # time.sleep(self.options.rpn_delay) + + def transform_launchpad_coord(self, idx, x, y): + if self.launchpads[idx].rot: + x, y = y, x + x = (8-x-1) + return x, y + + def untransform_launchpad_coord(self, idx, x, y): + if self.launchpads[idx].rot: + x = (8-x-1) + x, y = y, x + return x, y - def set_light(self, x, y, col, index=None, mark=False): # col is [1,11], 0 resets + def set_light(self, x, y, col, index=None, mark=False, transform=True): # col is [1,11], 0 resets """Set light to color `col` at x, y if in range and connected""" if y < 0 or y >= self.board_h: return @@ -186,19 +221,23 @@ def set_light(self, x, y, col, index=None, mark=False): # col is [1,11], 0 rese lp_col = 0 else: lp_col = ivec3(0) - if 0 <= x < 8 and 0 <= y < 8: - if not self.is_macro_button(x, y): + if transform: + xx, yy = self.transform_launchpad_coord(lp.index, x, y) + else: + xx, yy = x, y + if 0 <= xx < 8 and 0 <= yy < 8: + if not self.is_macro_button(xx, yy): if self.options.launchpad_colors: - lp.out.LedCtrlXYByCode(x, y+1, lp_col) + lp.out.LedCtrlXYByCode(xx, yy+1, lp_col) else: - lp.out.LedCtrlXY(x, y+1, lp_col[0], lp_col[1], None if lp_col[2] == 0 else lp_col[2]) + lp.out.LedCtrlXY(xx, yy+1, lp_col[0], lp_col[1], None if lp_col[2] == 0 else lp_col[2]) else: if self.options.launchpad_colors: - lp.out.LedCtrlXYByCode(x, y+1, 3) + lp.out.LedCtrlXYByCode(xx, yy+1, 3) else: - lp.out.LedCtrlXY(x, y+1, 63, 63, 63) + lp.out.LedCtrlXY(xx, yy+1, 63, 63, 63) - def reset_light(self, x, y, reset_red=True): + def reset_light(self, x, y, reset_red=True, transform=True): """Reset the light at x, y""" note = self.get_note_index(x, y, transpose=False) @@ -223,10 +262,10 @@ def reset_light(self, x, y, reset_red=True): except IndexError: light_col = 7 - self.set_light(x, y, light_col, note) + self.set_light(x, y, light_col, note, transform=transform) self.mark_lights[y][x] = False - def reset_launchpad_light(self, x, y, launchpad=None): + def reset_launchpad_light(self, x, y, launchpad=None, transform=True): """Reset the launchpad light at x, y""" note = self.get_note_index(x, 8-y-1, transpose=False) # if self.is_split(): @@ -238,18 +277,23 @@ def reset_launchpad_light(self, x, y, launchpad=None): # else: # light_col = self.options.lights[note] for lp in ([launchpad] if launchpad else self.launchpads): - self.set_launchpad_light(x, y, note) + self.set_launchpad_light(x, y, note, transform=True) - def set_mark_light(self, x, y, state=True, launchpad=None): + # transform: rotate launchpad if in rotated mode + def set_mark_light(self, x, y, state=True, launchpad=None, transform=True): """Set launchpad light to touched color""" self.mark_lights[y][x] = state for lp in ([launchpad] if launchpad else self.launchpads): lp_col = self.options.mark_color if state: - lp.out.LedCtrlXY(x, y, lp_col[0], lp_col[1], lp_col[2]) + if transform: + xx, yy = self.transform_launchpad_coord(lp.index, x, y) + else: + xx, yy = x, y + lp.out.LedCtrlXY(xx, yy, lp_col[0], lp_col[1], lp_col[2]) # `color` below is an scale index (0, 1, 2...) - def set_launchpad_light(self, x, y, color, launchpad=None): + def set_launchpad_light(self, x, y, color, launchpad=None, transform=True): """Set launchpad light to color index""" if self.is_macro_button(x, 8 - y - 1): if self.options.launchpad_colors: @@ -275,10 +319,14 @@ def set_launchpad_light(self, x, y, color, launchpad=None): col = self.options.mark_color / 4 for lp in ([launchpad] if launchpad else self.launchpads): + if transform: + xx, yy = self.transform_launchpad_coord(lp.index, x, y) + else: + xx, yy = x, y if self.options.launchpad_colors: lp.out.LedCtrlXYByCode(x, 8-y, col) else: - lp.out.LedCtrlXY(x, 8-y, col[0], col[1], col[2]) + lp.out.LedCtrlXY(xx, 8-yy, col[0], col[1], col[2]) def setup_lights(self): """Set all lights""" @@ -324,7 +372,7 @@ def get_note_index(self, x, y, transpose=True): y = self.board_h - y - 1 x += self.options.base_offset tr = self.tonic if transpose else 0 - return (row_offset * y + column_offset * x + tr) % len(NOTES) + return (row_offset * y + column_offset * x + tr) % len(self.NOTES) # ofs = (self.board_h - y) // 2 + BASE_OFFSET # step = 2 if WHOLETONE else 1 # tr = self.tonic if transpose else 0 @@ -335,7 +383,7 @@ def get_note_index(self, x, y, transpose=True): def get_note(self, x, y, transpose=True): """Get note name for x, y""" - return NOTES[self.get_note_index(x, y, transpose=transpose)] + return self.NOTES[self.get_note_index(x, y, transpose=transpose)] def get_color(self, x, y): """Get color for x, y""" @@ -478,13 +526,6 @@ def mouse_hover(self, x, y): def get_octave(self, x, y): """Get octave for x, y""" y = self.board_h - y - 1 - # if self.flipped: - # if self.tonic % 2 == 0: - # y -= 1 - # octave = int(x + 4 + self.position.x + y * 2.5) // 6 - # else: - if self.tonic % 2: - y -= 1 octave = int(x + 4 + self.position.x + y * 2.5) // 6 return octave @@ -549,7 +590,7 @@ def next_free_note(self): def is_mpe(self): return self.options.one_channel == 0 - def note_on(self, data, timestamp, width=None, curve=True, mpe=None, octave=0, transpose=0, force_channel=None): + def note_on(self, data, timestamp, width=None, curve=True, mpe=None, octave=0, transpose=0, force_channel=None, bend=0.0): # if mpe is None: # mpe = self.options.mpe d0 = data[0] @@ -709,6 +750,18 @@ def note_on(self, data, timestamp, width=None, curve=True, mpe=None, octave=0, t self.note_set.add(midinote) self.dirty_chord = True + if GRADIENT: + if x > 6: + bend = (x-6) - y/2 + bend *= (1/6) + try: + # print(bend) + pitch_lsb, pitch_msb = compose_pitch_bend(bend, 1/12) + # print(pitch_msb, pitch_lsb) + self.midi_write(self.midi_out, [0xE0 | ch, pitch_lsb, pitch_msb], timestamp) + except Exception as e: + print(e) + if self.is_split(): if split_chan == 0: # self.midi_out.write([[data, ev[1]]] @@ -1054,11 +1107,8 @@ def cb_midi_in(self, data, timestamp, force_channel=None): # self.midi_write(self.split_out, data, timestamp) # else: # self.midi_write(self.midi_out, data, timestamp) - elif note and note.location is not None: - col = note.location.x - row = note.location.y - split_chan = self.channel_from_split(col, row) - if split_chan: + elif note and note.split is not None: + if note.split: self.midi_write(self.split_out, data, timestamp) else: self.midi_write(self.midi_out, data, timestamp) @@ -1133,6 +1183,7 @@ def cb_launchpad_in(self, lp, event, timestamp=0): note = y * 8 + x note += 12 if not self.is_macro_button(x, 8 - y - 1): + # x, y = self.transform_launchpad_coord(lp.index, x, y) self.note_on([160, note, event[2]], timestamp, width=8, transpose=lp.transpose, octave=lp.get_octave(), force_channel=self.options.launchpad_channel) self.articulation.pressure(vel / 127) else: @@ -1141,8 +1192,10 @@ def cb_launchpad_in(self, lp, event, timestamp=0): x = event[0] y = 8 - event[1] if 0 <= x < 8 and 0 <= y < 8: - self.reset_launchpad_light(x, y, launchpad=lp) + # x2, y2 = self.untransform_launchpad_coord(lp.index, x, y) + # self.reset_launchpad_light(x2, y2, launchpad=lp, transform=False) if not self.is_macro_button(x, 8 - y - 1): + x, y = self.transform_launchpad_coord(lp.index, x, y) note = y * 8 + x self.note_off([128, note, event[2]], timestamp, width=8, transpose=lp.transpose, octave=lp.get_octave(), force_channel=self.options.launchpad_channel) else: @@ -1154,8 +1207,10 @@ def cb_launchpad_in(self, lp, event, timestamp=0): x = event[0] y = 8 - event[1] if 0 <= x < 8 and 0 <= y < 8: - self.set_launchpad_light(x, y, -1, launchpad=lp) + # x2, y2 = self.untransform_launchpad_coord(lp.index, x, y) + # self.set_launchpad_light(x2, y2, -1, launchpad=lp, transform=False) if not self.is_macro_button(x, 8 - y - 1): + x, y = self.transform_launchpad_coord(lp.index, x, y) note = y * 8 + x self.note_on([144, note, event[2]], timestamp, width=8, transpose=lp.transpose, octave=lp.get_octave(), force_channel=self.options.launchpad_channel) else: @@ -1300,6 +1355,9 @@ def __init__(self): self.options.colors = list(self.options.colors.split(",")) self.options.colors = list(map(lambda x: glm.ivec3(get_color(x)), self.options.colors)) + self.options.swap_launchpads = get_option(opts, "swap_launchpads", self.options.swap_launchpads) + self.options.rotate_launchpads = get_option(opts, "rotate_launchpads", self.options.rotate_launchpads) + self.options.launchpad_colors = get_option(opts, "launchpad_colors", DEFAULT_OPTIONS.launchpad_colors) if self.options.launchpad_colors: self.options.launchpad_colors = list(self.options.launchpad_colors.split(",")) @@ -1349,6 +1407,10 @@ def __init__(self): opts, "lite", DEFAULT_OPTIONS.lite ) + self.options.rpn_delay = get_option( + opts, "rpn_delay", DEFAULT_OPTIONS.rpn_delay + ) + # bend the velocity curve, examples: 0.5=sqrt, 1.0=default, 2.0=squared self.options.velocity_curve = get_option( opts, "velocity_curve", DEFAULT_OPTIONS.velocity_curve @@ -1544,6 +1606,7 @@ def __init__(self): pygame.display.set_caption(TITLE) self.icon = pygame.image.load('icon.png') pygame.display.set_icon(self.icon) + pygame.display.set_allow_screensaver(True) # if FOCUS: # pygame.mouse.set_visible(0) # pygame.event.set_grab(True) @@ -1636,6 +1699,16 @@ def __init__(self): text='MOD>', manager=self.gui ) + self.btn_sharps_flats = pygame_gui.elements.UIButton( + relative_rect=pygame.Rect((bs.x * 16 + 2, y), (bs.x, bs.y)), + text='#/b', + manager=self.gui + ) + self.btn_quit = pygame_gui.elements.UIButton( + relative_rect=pygame.Rect((bs.x * 17 + 2, y), (bs.x, bs.y)), + text='QUIT', + manager=self.gui + ) # self.next_scale = pygame_gui.elements.UIButton( # relative_rect=pygame.Rect((bs.x * 11 + 2, y), (bs.x, bs.y)), # text='SCL>', @@ -1702,6 +1775,9 @@ def __init__(self): self.linn_out = None self.midi_out = None self.split_out = None + self.use_sharps = True + self.NOTES = NOTES_SHARPS + outnames = rtmidi2.get_out_ports() for i in range(len(outnames)): @@ -1780,8 +1856,11 @@ def __init__(self): self.launchpads += [Launchpad(self, lp, "lpx", num_launchpads, self.options.octave_separation)] num_launchpads += 1 - if self.launchpads: - print('Launchpads:', len(self.launchpads)) + # if self.launchpads: + # launchpad_count = len(self.launchpads) + # print('Launchpads:', launchpad_count) + # if launchpad_count >= 2 and self.options.swap_launchpads: + # self.launchpads = self.launchpads[::-1] self.done = False @@ -1823,6 +1902,8 @@ def __init__(self): self.setup_rpn() # self.test() + # msgbox("Welcome to midimech!\n\nThis project is still in development and some features are experimental. Feel free to play around and report any bugs to flipcoder. Thanks!", "midimech") + def midi_mode_rpn(self, on=True): if on: self.rpn(0, 1 if self.is_mpe() else 0) @@ -1875,7 +1956,7 @@ def rpn(self, num, value): self.midi_write(self.linn_out, [176, 38, value_lsb]) self.midi_write(self.linn_out, [176, 101, 127]) self.midi_write(self.linn_out, [176, 100, 127]) - time.sleep(0.05) + time.sleep(self.options.rpn_delay) def mpe_rpn(self, on=True): """Sets up MPE settings (except MIDI mode)""" @@ -2024,7 +2105,7 @@ def mark(self, midinote, state, use_lights=False, only_row=None): y = 0 for row in rows: x = 0 - for x in range(len(row)): + for x in range(-1 * self.position.x, len(row)-self.position.x): #this band aids bug for 1 move right idx = self.get_note_index(x, y, transpose=False) # print(x, y, midinote%12, idx) if midinote % 12 == idx: @@ -2171,10 +2252,10 @@ def logic(self, dt): self.dirty = self.dirty_lights = True self.clear_marks(use_lights=False) elif ev.ui_element == self.btn_move_left: - self.move_board(-1) + self.move_board(1) self.clear_marks(use_lights=False) elif ev.ui_element == self.btn_move_right: - self.move_board(1) + self.move_board(-1) self.clear_marks(use_lights=False) # elif ev.ui_element == self.btn_mode: # # TODO: toggle mode @@ -2230,6 +2311,10 @@ def logic(self, dt): self.next_mode() elif ev.ui_element == self.btn_prev_mode: self.prev_mode() + elif ev.ui_element == self.btn_sharps_flats: + self.toggle_sharps_flats() + elif ev.ui_element == self.btn_quit: + self.quit() # elif ev.type == pygame_gui.UI_HORIZONTAL_SLIDER_MOVED: # if ev.ui_element == self.slider_velocity: # global self.options.velocity_curve @@ -2317,9 +2402,10 @@ def analyze(self, chord_notes): r = '' for i, note in enumerate(chord_notes): if note: - notes.append(NOTES[i % 12]) + n = mp.degree_to_note(i) + notes.append(n) if notes: - r = mp.alg.detect(mp.chord(','.join(notes))) + r = mp.alg.detect(mp.chord(notes)) # try: # r = r[0:self.chord.index(' sort')] # except ValueError: @@ -2354,7 +2440,7 @@ def render(self): self.screen.surface.fill((0, 0, 0)) b = 2 # border - sz = self.screen_w / self.board_w + sz = self.scale.x y = 0 rad = int(sz // 2 - 8) diff --git a/src/launchpad.py b/src/launchpad.py index 7b5597c..d772428 100644 --- a/src/launchpad.py +++ b/src/launchpad.py @@ -8,11 +8,15 @@ def __init__(self, core, out, mode, index=0, octave_separation=0): self.mode = mode self.index = index self.octave_separation = octave_separation + self.rot = False + if core.options.rotate_launchpads: + if core.options.swap_launchpads: + self.rot = not bool(index) + else: + self.rot = bool(index) print("Launchpad", mode, 'Connected! (#' + str(index) + ")") - # self.pos = glm.ivec2(0, 0) - def button(self, x, y): # if self.mode == 'lpx': if y == -1: @@ -80,7 +84,7 @@ def button(self, x, y): self.core.prev_program() def set_lights(self): - # if self.mode == "lpx": + self.out.LedCtrlXY(0, 0, 0, 0, 63) self.out.LedCtrlXY(1, 0, 0, 0, 63) self.out.LedCtrlXY(2, 0, 63, 0, 63) diff --git a/src/settings.py b/src/settings.py index c6eef24..fcb0111 100644 --- a/src/settings.py +++ b/src/settings.py @@ -20,6 +20,12 @@ class Settings: # Color scheme using webcolors # split_colors: str = "cyan,blue,blue,blue,blue,blue,blue,blue,blue,blue,blue,blue" launchpad_colors: str = '5,1,9,1,13,122,1,37,45,94,1,95' + + # By default we'll rotate the second launchpad so we can fit them together better. + rotate_launchpads: bool = True + + # Use this if the wrong launchpad is rotated (left vs right) + swap_launchpads: bool = False colors: str = "red,darkred,orange,goldenrod,yellow,green,darkolivegreen,blue,darkslateblue,indigo,darkorchid,pink" # colors: str = "cyan,green,green,green,green,green,green,green,green,green,green,green" @@ -34,7 +40,7 @@ class Settings: # lite mode (no extra gfx, less processing) lite: bool = False - # Custom velocity curve exponent, ex: 0.5 = more sensitive + # Bend the velocity curve, examples: 0.5=sqrt, 1.0=default, 2.0=squared velocity_curve: float = 1.0 # Velocity curve exponent for foot controller curve bending @@ -84,7 +90,7 @@ class Settings: height: int = 8 # launchpad viberato method (off, mod, or pitch) - vibrato: str = 'mod' + vibrato: str = 'off' # Set scale based on left hand chord (not yet impl) jazz: bool = False @@ -106,5 +112,8 @@ class Settings: # octave splitting the linn and transposing octaves on the right side octave_split: int = 0 + # Delay between RPN message batches + rpn_delay: float = 0.1 + DEFAULT_OPTIONS = Settings() diff --git a/src/util.py b/src/util.py index 458ee54..869bf85 100644 --- a/src/util.py +++ b/src/util.py @@ -109,8 +109,27 @@ def decompose_pitch_bend(pitch_bend_bytes): def compose_pitch_bend(pitch_bend_norm): pitch_bend_value = int((pitch_bend_norm + 1.0) * 8192) - pitch_bend_bytes = [pitch_bend_value & 0x7F, (pitch_bend_value >> 7) & 0x7F] - return pitch_bend_bytes + return pitch_bend_value & 0x7F, (pitch_bend_value >> 7) & 0x7F + +def decompose_pitch_bend(pitch_bend_bytes): + pitch_bend_value = (pitch_bend_bytes[0] << 7) + pitch_bend_bytes[1] + pitch_bend_norm = (pitch_bend_value - 8192) / 8191.5 # Adjust for exact [0, 16383] range + return pitch_bend_norm + +def compose_pitch_bend(norm, scale): + # scale is the range in semitones that norm=1.0 represents + # Map [-1, 1] to [0, 16383], scaling norm by the desired range + # Assuming a default synth range of ±2 semitones (16383 = 2 semitones) + scale_factor = scale / 2.0 # Adjust relative to default ±2 semitones + scaled_norm = norm * scale_factor + # Clamp to [-1, 1] to stay within synth's max range + scaled_norm = max(-1.0, min(1.0, scaled_norm)) + # Convert to MIDI 14-bit value: [-1, 1] -> [0, 16383] + pitch_bend_value = int(((scaled_norm + 1.0) / 2.0) * 16383 + 0.5) + pitch_bend_value = max(0, min(16383, pitch_bend_value)) + lsb = pitch_bend_value & 0x7F + msb = (pitch_bend_value >> 7) & 0x7F + return lsb, msb def decode_value(value): lsb = value & 0x7F