diff --git a/.gitignore b/.gitignore index 14feef5..e710631 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ midimech.spec build/ dist/ .idea/ +result diff --git a/README.md b/README.md index 904b47e..3912398 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,38 @@ After downloading, make sure to follow the instructions under `Setup`. *Note: These builds are not always up to date.* +### NixOS / Nix + +The included Nix flake handles everything automatically — Python environment, all dependencies, and a virtual MIDI cable (equivalent to loopMIDI on Windows). + +**Try it (no install):** +``` +nix run github:flipcoder/midimech +``` + +**NixOS module (recommended):** + +Add to your `flake.nix` inputs: +```nix +midimech.url = "github:flipcoder/midimech"; +``` + +Then in your NixOS configuration: +```nix +{ midimech, ... }: +{ + imports = [ midimech.nixosModules.default ]; + services.midimech.enable = true; +} +``` + +This installs midimech system-wide with: +- Desktop entry with icon (shows in GNOME, KDE, etc.) +- Virtual MIDI cable — equivalent to loopMIDI on Windows, started automatically +- Removes the "Midi Through" phantom MIDI port (`snd_seq_dummy`) + +Point your DAW/synth (e.g. SurgeXT) at the **midimech** MIDI input to receive notes. + ### Mac, Linux, and Running from Git - Download the project by typing the following commands in terminal: diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..1b8772e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1770841267, + "narHash": "sha256-9xejG0KoqsoKEGp2kVbXRlEYtFFcDTHjidiuX8hGO44=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ec7c70d12ce2fc37cb92aff673dcdca89d187bae", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7324ad8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,209 @@ +{ + description = "Midimech - Isomorphic musical layout engine for LinnStrument and Launchpad X"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + python = pkgs.python312; + + # --- Custom Python packages not in nixpkgs --- + + # rtmidi2: Cython wrapper around RtMidi C++ library. + # Uses pre-built manylinux wheel + autoPatchelfHook since no sdist is published. + rtmidi2 = python.pkgs.buildPythonPackage rec { + pname = "rtmidi2"; + version = "1.4.1"; + format = "wheel"; + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/cc/08/e426f1a8dae34acb8a13a0eca47970a78955a0c00395efe89a94ef04ad49/rtmidi2-1.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"; + hash = "sha256:2270b773302806209eb3aec341156ef841e2f5ea7a83f5b242311d0da1df3a76"; + }; + nativeBuildInputs = [ pkgs.autoPatchelfHook ]; + buildInputs = [ + pkgs.alsa-lib + pkgs.libjack2 + pkgs.stdenv.cc.cc.lib # libstdc++ + ]; + pythonImportsCheck = [ "rtmidi2" ]; + }; + + # launchpad-py: Pure Python Novation Launchpad control suite + launchpad-py = python.pkgs.buildPythonPackage rec { + pname = "launchpad-py"; + version = "0.9.1"; + pyproject = true; + src = python.pkgs.fetchPypi { + pname = "launchpad_py"; + inherit version; + hash = "sha256:9c70885a9079d9960a066515f4b83727e7c475543da8ef68786f00a8ef10727c"; + }; + build-system = [ python.pkgs.setuptools ]; + dependencies = [ python.pkgs.pygame-ce ]; + pythonImportsCheck = [ "launchpad_py" ]; + doCheck = false; + }; + + # mido-fix: Fork of mido (MIDI Objects), required by musicpy + mido-fix = python.pkgs.buildPythonPackage rec { + pname = "mido-fix"; + version = "1.2.12"; + pyproject = true; + src = python.pkgs.fetchPypi { + pname = "mido_fix"; + inherit version; + hash = "sha256:8ce7ad87f847de36c7dd3048876581113c4d83367d1f392e5a7a9f9562b3374e"; + }; + build-system = [ python.pkgs.setuptools ]; + pythonImportsCheck = [ "mido_fix" ]; + doCheck = false; + }; + + # musicpy: Music programming language / theory library + # musicpy declares mido-fix + dataclasses as deps; midimech only uses + # musicpy for chord analysis (not MIDI I/O), so we skip the runtime + # deps check and provide pygame-ce which musicpy actually imports. + musicpy = python.pkgs.buildPythonPackage rec { + pname = "musicpy"; + version = "7.11"; + pyproject = true; + src = python.pkgs.fetchPypi { + inherit pname version; + hash = "sha256:7957971dc1be5b310a83253acd8dd8d3ce803ba47f67d88603904667badde993"; + }; + build-system = [ python.pkgs.setuptools ]; + dependencies = [ python.pkgs.pygame-ce mido-fix ]; + pythonImportsCheck = [ "musicpy" ]; + dontCheckRuntimeDeps = true; + doCheck = false; + }; + + # --- Python environment with all deps --- + + pythonEnv = python.withPackages (ps: [ + ps.pygame-ce + ps.pygame-gui + ps.pyglm + rtmidi2 + launchpad-py + musicpy + ps.pyyaml + ps.webcolors + ]); + + runtimeLibs = pkgs.lib.makeLibraryPath [ + pkgs.SDL2 + pkgs.SDL2_image + pkgs.SDL2_mixer + pkgs.SDL2_ttf + pkgs.alsa-lib + pkgs.libjack2 + ]; + + in { + packages.${system} = { + midimech = pkgs.stdenv.mkDerivation { + pname = "midimech"; + version = "0.1.0"; + src = self; + + nativeBuildInputs = [ pkgs.makeWrapper pkgs.pkg-config ]; + buildInputs = [ pythonEnv pkgs.alsa-lib ]; + + buildPhase = '' + # Compile the native MIDI virtual cable (zero-overhead loopMIDI equivalent) + cc -O2 -Wall $(pkg-config --cflags --libs alsa) \ + midimech-vport.c -o midimech-vport + ''; + + installPhase = '' + mkdir -p $out/share/midimech $out/bin + cp -r . $out/share/midimech/ + + # Install the native MIDI virtual cable + install -m755 midimech-vport $out/bin/midimech-vport + + # Main entry point: starts virtual MIDI cable + midimech + cat > $out/bin/midimech </dev/null; wait "\$VPORT_PID" 2>/dev/null; } + trap cleanup EXIT INT TERM + $out/bin/midimech-vport & + VPORT_PID=\$! + sleep 0.3 + cd $out/share/midimech + exec ${pythonEnv}/bin/python3 $out/share/midimech/midimech.py "\$@" + LAUNCHER + chmod +x $out/bin/midimech + patchShebangs $out/bin/midimech + + # Wrap to include runtime libraries + set window class for desktop icon matching + wrapProgram $out/bin/midimech \ + --prefix LD_LIBRARY_PATH : "${runtimeLibs}" \ + --set ALSA_CONFIG_PATH "${pkgs.alsa-lib}/share/alsa/alsa.conf" \ + --set SDL_VIDEO_WAYLAND_WMCLASS midimech \ + --set SDL_VIDEO_X11_WMCLASS midimech \ + --set SDL_APP_ID midimech + + # Desktop entry for application launchers + mkdir -p $out/share/applications $out/share/icons/hicolor/256x256/apps + cp $out/share/midimech/icon.png $out/share/icons/hicolor/256x256/apps/midimech.png + cat > $out/share/applications/midimech.desktop < +#include +#include + +static volatile int running = 1; + +static void on_signal(int sig) { + (void)sig; + running = 0; +} + +int main(void) { + snd_seq_t *seq; + int err; + + err = snd_seq_open(&seq, "default", SND_SEQ_OPEN_DUPLEX, 0); + if (err < 0) { + fprintf(stderr, "Cannot open ALSA sequencer: %s\n", snd_strerror(err)); + return 1; + } + + snd_seq_set_client_name(seq, "midimech"); + + /* Create a single port with both read and write capabilities. + * - WRITE + SUBS_WRITE: midimech.py can send MIDI here + * - READ + SUBS_READ: SurgeXT (or any synth) can subscribe to receive MIDI + */ + int port = snd_seq_create_simple_port(seq, "midimech", + SND_SEQ_PORT_CAP_WRITE | SND_SEQ_PORT_CAP_SUBS_WRITE | + SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_SUBS_READ, + SND_SEQ_PORT_TYPE_MIDI_GENERIC | SND_SEQ_PORT_TYPE_APPLICATION); + + if (port < 0) { + fprintf(stderr, "Cannot create port: %s\n", snd_strerror(port)); + snd_seq_close(seq); + return 1; + } + + fprintf(stderr, "[midimech-vport] Virtual MIDI cable 'midimech' ready (port %d)\n", port); + + signal(SIGINT, on_signal); + signal(SIGTERM, on_signal); + + /* Event loop: read incoming MIDI events, forward to all subscribers */ + snd_seq_event_t *ev; + while (running) { + err = snd_seq_event_input(seq, &ev); + if (err < 0) { + if (err == -EAGAIN) continue; + break; + } + + snd_seq_ev_set_source(ev, port); + snd_seq_ev_set_subs(ev); + snd_seq_ev_set_direct(ev); + snd_seq_event_output_direct(seq, ev); + } + + snd_seq_close(seq); + return 0; +} diff --git a/src/core.py b/src/core.py index 930d754..fdd1539 100644 --- a/src/core.py +++ b/src/core.py @@ -1757,28 +1757,30 @@ def __init__(self): self.launchpads = [] num_launchpads = 0 if self.options.launchpad: - launchpads = [] - lp = launchpad.LaunchpadProMk3() - if lp.Check(0): - if lp.Open(0): - self.launchpads += [Launchpad(self, lp, "promk3", num_launchpads)] - num_launchpads += 1 - lp = launchpad.LaunchpadPro() - if lp.Check(0): - if lp.Open(0): - self.launchpads += [Launchpad(self, lp, "pro", num_launchpads)] - num_launchpads += 1 - lp = launchpad.LaunchpadLPX() - if lp.Check(1): + try: + lp = launchpad.LaunchpadProMk3() + if lp.Check(0): + if lp.Open(0): + self.launchpads += [Launchpad(self, lp, "promk3", num_launchpads)] + num_launchpads += 1 + lp = launchpad.LaunchpadPro() + if lp.Check(0): + if lp.Open(0): + self.launchpads += [Launchpad(self, lp, "pro", num_launchpads)] + num_launchpads += 1 lp = launchpad.LaunchpadLPX() - if lp.Open(1): - self.launchpads += [Launchpad(self, lp, "lpx", num_launchpads)] - num_launchpads += 1 - if launchpad.LaunchpadLPX().Check(3): + if lp.Check(1): lp = launchpad.LaunchpadLPX() - if lp.Open(3): # second - self.launchpads += [Launchpad(self, lp, "lpx", num_launchpads, self.options.octave_separation)] + if lp.Open(1): + self.launchpads += [Launchpad(self, lp, "lpx", num_launchpads)] num_launchpads += 1 + if launchpad.LaunchpadLPX().Check(3): + lp = launchpad.LaunchpadLPX() + if lp.Open(3): # second + self.launchpads += [Launchpad(self, lp, "lpx", num_launchpads, self.options.octave_separation)] + num_launchpads += 1 + except Exception as e: + print(f"Launchpad detection skipped ({e})") if self.launchpads: print('Launchpads:', len(self.launchpads))