Friday, September 4, 2026

Midi Program Changer

Midi Program Changer github

Made this in python to change programs with the plus and minus keys on a qwerty keyboard. The easiest way to use is run the linux x86 build on github that I packaged together with pyinstall so you dont need to manually deal with dependencies. Otherwise if your own win/mac heres the python.


#!/usr/bin/env python3

import tkinter as tk
from tkinter import ttk, messagebox

import mido
from pynput import keyboard


# ============================================================
# GENERAL MIDI INSTRUMENTS
# ============================================================

GM_INSTRUMENTS = [
    "Acoustic Grand Piano",
    "Bright Acoustic Piano",
    "Electric Grand Piano",
    "Honky-tonk Piano",
    "Electric Piano 1",
    "Electric Piano 2",
    "Harpsichord",
    "Clavinet",
    "Celesta",
    "Glockenspiel",
    "Music Box",
    "Vibraphone",
    "Marimba",
    "Xylophone",
    "Tubular Bells",
    "Dulcimer",
    "Drawbar Organ",
    "Percussive Organ",
    "Rock Organ",
    "Church Organ",
    "Reed Organ",
    "Accordion",
    "Harmonica",
    "Tango Accordion",
    "Acoustic Guitar (nylon)",
    "Acoustic Guitar (steel)",
    "Electric Guitar (jazz)",
    "Electric Guitar (clean)",
    "Electric Guitar (muted)",
    "Overdriven Guitar",
    "Distortion Guitar",
    "Guitar Harmonics",
    "Acoustic Bass",
    "Electric Bass (finger)",
    "Electric Bass (pick)",
    "Fretless Bass",
    "Slap Bass 1",
    "Slap Bass 2",
    "Synth Bass 1",
    "Synth Bass 2",
    "Violin",
    "Viola",
    "Cello",
    "Contrabass",
    "Tremolo Strings",
    "Pizzicato Strings",
    "Orchestral Harp",
    "Timpani",
    "String Ensemble 1",
    "String Ensemble 2",
    "SynthStrings 1",
    "SynthStrings 2",
    "Choir Aahs",
    "Voice Oohs",
    "Synth Voice",
    "Orchestra Hit",
    "Trumpet",
    "Trombone",
    "Tuba",
    "Muted Trumpet",
    "French Horn",
    "Brass Section",
    "SynthBrass 1",
    "SynthBrass 2",
    "Soprano Sax",
    "Alto Sax",
    "Tenor Sax",
    "Baritone Sax",
    "Oboe",
    "English Horn",
    "Bassoon",
    "Clarinet",
    "Piccolo",
    "Flute",
    "Recorder",
    "Pan Flute",
    "Blown Bottle",
    "Shakuhachi",
    "Whistle",
    "Ocarina",
    "Lead 1 (square)",
    "Lead 2 (sawtooth)",
    "Lead 3 (calliope)",
    "Lead 4 (chiff)",
    "Lead 5 (charang)",
    "Lead 6 (voice)",
    "Lead 7 (fifths)",
    "Lead 8 (bass + lead)",
    "Pad 1 (new age)",
    "Pad 2 (warm)",
    "Pad 3 (polysynth)",
    "Pad 4 (choir)",
    "Pad 5 (bowed)",
    "Pad 6 (metallic)",
    "Pad 7 (halo)",
    "Pad 8 (sweep)",
    "FX 1 (rain)",
    "FX 2 (soundtrack)",
    "FX 3 (crystal)",
    "FX 4 (atmosphere)",
    "FX 5 (brightness)",
    "FX 6 (goblins)",
    "FX 7 (echoes)",
    "FX 8 (sci-fi)",
    "Sitar",
    "Banjo",
    "Shamisen",
    "Koto",
    "Kalimba",
    "Bagpipe",
    "Fiddle",
    "Shanai",
    "Tinkle Bell",
    "Agogo",
    "Steel Drums",
    "Woodblock",
    "Taiko Drum",
    "Melodic Tom",
    "Synth Drum",
    "Reverse Cymbal",
    "Guitar Fret Noise",
    "Breath Noise",
    "Seashore",
    "Bird Tweet",
    "Telephone Ring",
    "Helicopter",
    "Applause",
    "Gunshot",
]


# ============================================================
# CHANNEL 10 DRUM KITS
# ============================================================

DRUM_KITS = {
    1: "Standard Drum Kit",
    9: "Room Drum Kit",
    17: "Power Drum Kit",
    25: "Electric Drum Kit",
    26: "Rap TR808 Drums",
    33: "Jazz Drum Kit",
    41: "Brush Kit",
}

DRUM_PROGRAMS = list(DRUM_KITS.keys())


# ============================================================
# MIDI CC
# ============================================================

REVERB_CC = 91


# ============================================================
# APPLICATION
# ============================================================

class MidiProgramChanger:

    def __init__(self, root):

        self.root = root
        self.root.title("MIDI Program Changer")

        self.port = None
        self.keyboard_listener = None

        self.program = 1
        self.channel = 1

        self.reverb = 0

        self.matrix_var = tk.IntVar(value=self.program)
        self.reverb_var = tk.IntVar(value=self.reverb)

        self.matrix_buttons = []

        self.create_gui()
        self.refresh_ports()
        self.start_keyboard_listener()

        self.root.protocol(
            "WM_DELETE_WINDOW",
            self.close
        )


    # ========================================================
    # GUI
    # ========================================================

    def create_gui(self):

        main = ttk.Frame(
            self.root,
            padding=10
        )

        main.pack(
            fill="both",
            expand=True
        )


        # ----------------------------------------------------
        # MIDI OUTPUT
        # ----------------------------------------------------

        port_frame = ttk.LabelFrame(
            main,
            text="MIDI Output",
            padding=8
        )

        port_frame.pack(
            fill="x",
            pady=(0, 8)
        )

        self.port_var = tk.StringVar()

        self.port_combo = ttk.Combobox(
            port_frame,
            textvariable=self.port_var,
            state="readonly",
            width=55
        )

        self.port_combo.pack(
            side="left",
            fill="x",
            expand=True
        )

        ttk.Button(
            port_frame,
            text="Refresh",
            command=self.refresh_ports
        ).pack(
            side="left",
            padx=(5, 0)
        )

        ttk.Button(
            port_frame,
            text="Connect",
            command=self.connect_port
        ).pack(
            side="left",
            padx=(5, 0)
        )


        # ----------------------------------------------------
        # CHANNEL
        # ----------------------------------------------------

        channel_frame = ttk.Frame(main)

        channel_frame.pack(
            fill="x",
            pady=(0, 8)
        )

        ttk.Label(
            channel_frame,
            text="MIDI Channel:"
        ).pack(side="left")

        self.channel_var = tk.IntVar(value=1)

        self.channel_spin = tk.Spinbox(
            channel_frame,
            from_=1,
            to=16,
            textvariable=self.channel_var,
            width=5,
            command=self.channel_changed
        )

        self.channel_spin.pack(
            side="left",
            padx=(5, 0)
        )

        self.channel_spin.bind(
            "",
            lambda event: self.channel_changed()
        )

        self.channel_spin.bind(
            "",
            lambda event: self.channel_changed()
        )


        # ----------------------------------------------------
        # PROGRAM CONTROLS
        # ----------------------------------------------------

        control_frame = ttk.Frame(main)

        control_frame.pack(
            fill="x",
            pady=(0, 8)
        )

        ttk.Button(
            control_frame,
            text="Previous",
            command=self.previous_program
        ).pack(side="left")

        self.program_var = tk.IntVar(value=1)

        self.program_spin = tk.Spinbox(
            control_frame,
            from_=1,
            to=128,
            textvariable=self.program_var,
            width=6,
            command=self.program_spin_changed
        )

        self.program_spin.pack(
            side="left",
            padx=8
        )

        self.program_spin.bind(
            "",
            lambda event: self.program_spin_changed()
        )

        self.program_spin.bind(
            "",
            lambda event: self.program_spin_changed()
        )

        ttk.Button(
            control_frame,
            text="Next",
            command=self.next_program
        ).pack(side="left")


        # ----------------------------------------------------
        # INSTRUMENT NAME
        # ----------------------------------------------------

        self.instrument_label = ttk.Label(
            main,
            text="1  Acoustic Grand Piano",
            anchor="center",
            font=("TkDefaultFont", 12, "bold")
        )

        self.instrument_label.pack(
            fill="x",
            pady=(0, 8)
        )


        # ----------------------------------------------------
        # REVERB
        # ----------------------------------------------------

        reverb_frame = ttk.LabelFrame(
            main,
            text="Reverb (MIDI CC 91)",
            padding=8
        )

        reverb_frame.pack(
            fill="x",
            pady=(0, 8)
        )

        self.reverb_knob = tk.Scale(
            reverb_frame,
            from_=0,
            to=127,
            orient="horizontal",
            variable=self.reverb_var,
            command=self.reverb_changed,
            showvalue=True,
            resolution=1,
            length=300
        )

        self.reverb_knob.pack(
            fill="x"
        )


        # ----------------------------------------------------
        # SCROLLABLE PROGRAM MATRIX
        # ----------------------------------------------------

        matrix_frame = ttk.LabelFrame(
            main,
            text="Program",
            padding=5
        )

        matrix_frame.pack(
            fill="both",
            expand=True
        )


        # Canvas
        self.canvas = tk.Canvas(
            matrix_frame,
            highlightthickness=0
        )

        self.canvas.pack(
            side="left",
            fill="both",
            expand=True
        )


        # Vertical scrollbar
        scrollbar = ttk.Scrollbar(
            matrix_frame,
            orient="vertical",
            command=self.canvas.yview
        )

        scrollbar.pack(
            side="right",
            fill="y"
        )

        self.canvas.configure(
            yscrollcommand=scrollbar.set
        )


        # Frame inside canvas
        self.matrix_inner = ttk.Frame(
            self.canvas
        )

        self.canvas_window = self.canvas.create_window(
            (0, 0),
            window=self.matrix_inner,
            anchor="nw"
        )


        # Update scroll region when frame changes
        self.matrix_inner.bind(
            "",
            self.update_scroll_region
        )


        # Make inner frame follow canvas width
        self.canvas.bind(
            "",
            self.resize_matrix
        )


        # Mouse wheel
        self.canvas.bind_all(
            "",
            self.mousewheel
        )


        # Linux mouse wheel
        self.canvas.bind_all(
            "",
            self.mousewheel_linux_up
        )

        self.canvas.bind_all(
            "",
            self.mousewheel_linux_down
        )


        # ----------------------------------------------------
        # CREATE MATRIX
        # ----------------------------------------------------

        for program in range(1, 129):

            row = (program - 1) // 8
            col = (program - 1) % 8

            button = ttk.Radiobutton(
                self.matrix_inner,
                text=str(program),
                value=program,
                variable=self.matrix_var,
                command=lambda p=program:
                    self.matrix_program_selected(p)
            )

            button.grid(
                row=row,
                column=col,
                padx=2,
                pady=2,
                sticky="ew"
            )

            self.matrix_buttons.append(button)


        for col in range(8):

            self.matrix_inner.columnconfigure(
                col,
                weight=1
            )


        self.update_program_controls()
        self.update_matrix()
        self.update_label()


    # ========================================================
    # SCROLLING
    # ========================================================

    def update_scroll_region(self, event=None):

        self.canvas.configure(
            scrollregion=self.canvas.bbox("all")
        )


    def resize_matrix(self, event):

        self.canvas.itemconfigure(
            self.canvas_window,
            width=event.width
        )


    def mousewheel(self, event):

        self.canvas.yview_scroll(
            int(-1 * (event.delta / 120)),
            "units"
        )


    def mousewheel_linux_up(self, event):

        self.canvas.yview_scroll(
            -3,
            "units"
        )


    def mousewheel_linux_down(self, event):

        self.canvas.yview_scroll(
            3,
            "units"
        )


    # ========================================================
    # MIDI PORTS
    # ========================================================

    def refresh_ports(self):

        ports = mido.get_output_names()

        self.port_combo["values"] = ports

        if ports:
            self.port_combo.current(0)
        else:
            self.port_var.set("")


    def connect_port(self):

        name = self.port_var.get()

        if not name:

            messagebox.showwarning(
                "MIDI",
                "No MIDI output port selected."
            )

            return

        try:

            if self.port:
                self.port.close()

            self.port = mido.open_output(name)

            self.send_program()
            self.send_reverb()

        except Exception as e:

            self.port = None

            messagebox.showerror(
                "MIDI Error",
                str(e)
            )


    # ========================================================
    # MIDI PROGRAM CHANGE
    # ========================================================

    def send_program(self):

        if self.port is None:
            return

        midi_channel = self.channel - 1
        midi_program = self.program - 1

        try:

            self.port.send(
                mido.Message(
                    "program_change",
                    channel=midi_channel,
                    program=midi_program
                )
            )

        except Exception as e:

            print("MIDI error:", e)


    # ========================================================
    # REVERB CC 91
    # ========================================================

    def send_reverb(self):

        if self.port is None:
            return

        midi_channel = self.channel - 1

        try:

            self.port.send(
                mido.Message(
                    "control_change",
                    channel=midi_channel,
                    control=REVERB_CC,
                    value=self.reverb
                )
            )

        except Exception as e:

            print("MIDI reverb error:", e)


    def reverb_changed(self, value):

        try:
            self.reverb = int(float(value))
        except ValueError:
            return

        self.send_reverb()


    # ========================================================
    # CHANNEL
    # ========================================================

    def channel_changed(self):

        try:

            channel = int(
                self.channel_var.get()
            )

            if channel < 1 or channel > 16:
                raise ValueError

        except ValueError:

            self.channel_var.set(
                self.channel
            )

            return


        self.channel = channel


        # Channel 10 only supports the selected drum kits.
        if self.channel == 10:

            if self.program not in DRUM_KITS:

                self.program = DRUM_PROGRAMS[0]


        self.program_var.set(
            self.program
        )

        self.matrix_var.set(
            self.program
        )

        self.update_program_controls()
        self.update_matrix()
        self.update_label()

        self.send_program()
        self.send_reverb()


    # ========================================================
    # PROGRAM
    # ========================================================

    def set_program(self, program):

        if self.channel == 10:

            if program not in DRUM_KITS:
                return

        if program < 1 or program > 128:
            return

        self.program = program

        self.program_var.set(
            program
        )

        self.matrix_var.set(
            program
        )

        self.update_label()
        self.update_matrix()

        self.send_program()


    def program_spin_changed(self):

        try:

            program = int(
                self.program_var.get()
            )

        except ValueError:

            self.program_var.set(
                self.program
            )

            return


        if self.channel == 10:

            if program not in DRUM_KITS:

                program = min(
                    DRUM_PROGRAMS,
                    key=lambda p:
                        abs(p - program)
                )

        else:

            if program < 1:
                program = 1

            if program > 128:
                program = 128


        self.set_program(program)


    # ========================================================
    # PREVIOUS
    # ========================================================

    def previous_program(self):

        if self.channel == 10:

            current_index = DRUM_PROGRAMS.index(
                self.program
            )

            new_index = (
                current_index - 1
            ) % len(DRUM_PROGRAMS)

            self.set_program(
                DRUM_PROGRAMS[new_index]
            )

        else:

            if self.program <= 1:
                new_program = 128
            else:
                new_program = self.program - 1

            self.set_program(
                new_program
            )


    # ========================================================
    # NEXT
    # ========================================================

    def next_program(self):

        if self.channel == 10:

            current_index = DRUM_PROGRAMS.index(
                self.program
            )

            new_index = (
                current_index + 1
            ) % len(DRUM_PROGRAMS)

            self.set_program(
                DRUM_PROGRAMS[new_index]
            )

        else:

            if self.program >= 128:
                new_program = 1
            else:
                new_program = self.program + 1

            self.set_program(
                new_program
            )


    # ========================================================
    # MATRIX
    # ========================================================

    def matrix_program_selected(self, program):

        self.set_program(program)


    def update_matrix(self):

        if not self.matrix_buttons:
            return

        for program, button in enumerate(
            self.matrix_buttons,
            start=1
        ):

            if self.channel == 10:

                if program in DRUM_KITS:

                    button.configure(
                        text=f"{program}\n"
                             f"{DRUM_KITS[program]}",
                        state="normal"
                    )

                else:

                    button.configure(
                        text=str(program),
                        state="disabled"
                    )

            else:

                button.configure(
                    text=str(program),
                    state="normal"
                )


    # ========================================================
    # PROGRAM SPINBOX
    # ========================================================

    def update_program_controls(self):

        if self.channel == 10:

            self.program_spin.configure(
                values=tuple(
                    DRUM_PROGRAMS
                )
            )

        else:

            self.program_spin.configure(
                from_=1,
                to=128,
                values=()
            )


    # ========================================================
    # LABEL
    # ========================================================

    def update_label(self):

        if self.channel == 10:

            name = DRUM_KITS.get(
                self.program,
                "Unknown Drum Kit"
            )

        else:

            name = GM_INSTRUMENTS[
                self.program - 1
            ]

        self.instrument_label.configure(
            text=f"{self.program}  {name}"
        )


    # ========================================================
    # GLOBAL KEYBOARD
    # ========================================================

    def start_keyboard_listener(self):

        self.keyboard_listener = keyboard.Listener(
            on_press=self.key_pressed
        )

        self.keyboard_listener.start()


    def key_pressed(self, key):

        try:

            if key.char == "-":

                self.root.after(
                    0,
                    self.previous_program
                )

            elif key.char == "=":

                self.root.after(
                    0,
                    self.next_program
                )

        except AttributeError:

            pass


    # ========================================================
    # CLOSE
    # ========================================================

    def close(self):

        if self.keyboard_listener:

            self.keyboard_listener.stop()

        if self.port:

            self.port.close()

        self.root.destroy()


# ============================================================
# MAIN
# ============================================================

if __name__ == "__main__":

    root = tk.Tk()

    app = MidiProgramChanger(root)

    root.mainloop()

No comments:

Post a Comment