Beyond the Keyboard: How I Mapped a MIDI Foot Controller to My macOS Developer Workflow

We’ve all been there: you’re deep in a flow state, your hands are flying across the keyboard, and suddenly you need to toggle your local Docker environment, run a test suite, or mute your mic on a Zoom call. You have to lift your hands, hunt for a complex keyboard shortcut (usually something absurd like Cmd+Shift+Option+Control+T), or worse, reach for the mouse. It’s a minor friction point, but over an eight-hour workday, these micro-interruptions add up, breaking your cognitive momentum.

As developers, we are constantly looking for ways to optimize our physical workspace. We buy split ergonomic keyboards, vertical mice, and macro pads. But today, we are going to look at a wildly underutilized territory in the developer ergonomics space: our feet.

A fascinating project recently surfaced on Hacker News showing how to map a Behringer FCB1010 MIDI pedalboard to macOS macros. It got me thinking about how we can leverage the robust, low-latency MIDI (Musical Instrument Digital Interface) protocol to build a hands-free, hardware-accelerated developer workflow. In this post, we’re going to dive into how MIDI routing works on macOS, how to build a daemon that listens for footpedal events, and how to map those events to powerful automation scripts that will supercharge your daily coding sessions.

Why MIDI? (And Why the Behringer FCB1010?)

If you aren't a musician, you might associate MIDI exclusively with 90s video game soundtracks. In reality, MIDI is a highly efficient, event-driven communication protocol designed in the 1980s that remains the industry standard for musical hardware. When you press a key or step on a pedal, the device sends a tiny packet of data (typically 3 bytes) containing a status byte (e.g., Note On, Note Off, or Control Change), a note/controller number, and a velocity/value (0-127).

For developers, MIDI is an absolute dream for automation because:

  • It is incredibly low latency: Built for real-time musical performance, MIDI events are processed almost instantaneously by the OS kernel.
  • It doesn't interfere with your keyboard: Unlike macro pads that mimic standard keyboard inputs (and can conflict with active applications), MIDI events run on a completely separate input channel.
  • Physical durability: Devices like the Behringer FCB1010 are built like tanks. It features 10 heavy-duty switches and 2 continuous expression pedals designed to survive being kicked repeatedly on stage. It is the ultimate macro pad.

Imagine stepping on Pedal 1 to run your unit tests, Pedal 2 to toggle your Git diff, and using an expression pedal to smoothly scroll through log files or control your system volume. Let's build it.

The Architecture: Connecting Foot to Code

To get this working on macOS, we need a pipeline that translates physical foot presses into system actions. The architecture looks like this:

[ Behringer FCB1010 ] 
       │  (MIDI over USB / MIDI-to-USB Interface)
       ▼
[ macOS CoreMIDI Framework ]
       │
       ▼
[ Custom Daemon / Router (Node.js / Python / HammerSpoon) ]
       │
       ├─► [ Shell Scripts (Docker, Git, Webpacks) ]
       ├─► [ AppleScript (UI Control, Zoom Mute) ]
       └─► [ System Events (Volume, Window Management) ]

While there are commercial GUI applications like Keyboard Maestro or Midi Stroke that can map MIDI to keystrokes, they can be limiting if you want to execute complex, contextual programmatic logic. Instead, we are going to build our own lightweight daemon using Node.js and the node-midi library, giving us infinite flexibility to write JavaScript-driven automation.

Step 1: Setting Up the Hardware and macOS CoreMIDI

The Behringer FCB1010 is a classic piece of gear. It outputs traditional 5-pin DIN MIDI. To connect it to a modern Mac, you will need a cheap USB-to-MIDI interface cable (often called a MIDI utility cable).

Once plugged in, macOS’s built-in CoreMIDI subsystem detects it automatically. You can verify this by opening the Audio MIDI Setup utility on your Mac (found in Applications > Utilities), and selecting Window > Show MIDI Studio. You should see your USB-to-MIDI interface active and highlighted.

Step 2: Building the MIDI Listener Daemon in Node.js

Let's initialize a new Node.js project. We will use the midi package, which provides native C++ bindings to the macOS CoreMIDI framework, ensuring ultra-low latency.

mkdir midi-macro-daemon
cd midi-macro-daemon
npm init -y
npm install midi dotenv execa

We are also installing execa, an excellent library for running terminal commands and shell scripts from Node.

Now, let's write our daemon script (daemon.js). First, we need to probe the system to find our MIDI input port, and then set up a listener to capture the byte arrays sent by our foot pedal.

const midi = require('midi');
const execa = require('execa');

// Set up a new MIDI input channel
const input = new midi.Input();

const portCount = input.getPortCount();
let targetPortIndex = -1;

console.log(`Available MIDI Input Devices: ${portCount}`);
for (let i = 0; i < portCount; i++) {
  const portName = input.getPortName(i);
  console.log(`[Port ${i}]: ${portName}`);
  if (portName.toLowerCase().includes('usb') || portName.toLowerCase().includes('fcb1010')) {
    targetPortIndex = i;
  }
}

if (targetPortIndex === -1) {
  console.error("Could not find a valid MIDI controller. Exiting.");
  process.exit(1);
}

// Open the active port
input.openPort(targetPortIndex);
console.log(`Successfully listening to: ${input.getPortName(targetPortIndex)}`);

// Configure input to not ignore system exclusive, time, or active sensing messages
input.ignoreTypes(false, false, false);

// Listen for incoming messages
input.on('message', async (deltaTime, message) => {
  // message is an array: [statusByte, dataByte1, dataByte2]
  // For CC (Control Change) messages, statusByte is typically 176-191 (depending on MIDI channel)
  const [status, note, velocity] = message;
  
  console.log(`MIDI Event Received: Status=${status}, Note/CC=${note}, Value/Velocity=${velocity}`);
  
  try {
    await handleMidiEvent(status, note, velocity);
  } catch (err) {
    console.error(`Error executing action for Note ${note}:`, err.message);
  }
});

Step 3: Crafting Developer Workflows (The Fun Part)

Now that we can capture MIDI inputs, let's write the handler. This is where we map specific foot pedal switches to custom developer actions.

The FCB1010 pedals can be configured to send simple Program Change (PC) messages or Control Change (CC) messages. Let's assume we have configured our pedals to send CC messages on Channel 1 (which registers as status byte 176), where pedals 1 through 5 map to notes 21 through 25, and the expression pedal maps to CC 27.

async function handleMidiEvent(status, note, value) {
  // We only care about MIDI Channel 1 Control Changes (Status 176)
  if (status !== 176) return;

  switch (note) {
    case 21: // Pedal 1: Toggle Docker Compose Environment
      console.log("Pedal 1 Pressed: Toggling Docker Stack...");
      await toggleDockerStack();
      break;

    case 22: // Pedal 2: Run Active Test Suite
      console.log("Pedal 2 Pressed: Running Unit Tests...");
      await runLocalTests();
      break;

    case 23: // Pedal 3: Git Quick Commit & Push (WIP)
      console.log("Pedal 3 Pressed: Pushing Work-In-Progress to Origin...");
      await gitWipPush();
      break;

    case 24: // Pedal 4: Global Mute/Unmute Zoom
      console.log("Pedal 4 Pressed: Toggling Zoom Mute...");
      await toggleZoomMute();
      break;

    case 27: // Expression Pedal A: Adjust System Volume
      // Value ranges from 0 to 127. Let's map it to Mac volume (0-100)
      const volumeLevel = Math.round((value / 127) * 100);
      await setMacVolume(volumeLevel);
      break;

    default:
      break;
  }
}

Implementing the Automation Helpers

Let's look at how to implement these helper functions. For simple command-line tools, we can execute local shell binaries. For GUI interaction (like controlling Zoom), we can leverage macOS AppleScript.

1. Toggling Your Local Dev Environment

Keep your Docker environments dormant when you aren't using them to save RAM, and bring them up with a stomp:

let dockerRunning = false;
async function toggleDockerStack() {
  const projectPath = '/Users/alex/projects/core-api';
  if (!dockerRunning) {
    // Run detached Docker Compose
    await execa('docker-compose', ['up', '-d'], { cwd: projectPath });
    dockerRunning = true;
    notifyOS('Docker Services', 'Local development environment is UP.');
  } else {
    await execa('docker-compose', ['down'], { cwd: projectPath });
    dockerRunning = false;
    notifyOS('Docker Services', 'Local development environment is DOWN.');
  }
}

2. The "Panic" Zoom Mute Button

When you are working from home and someone unexpectedly walks in or starts vacuuming, finding the mute button on your screen can take too long. A foot pedal is the ultimate safety valve:

async function toggleZoomMute() {
  const appleScript = `
    tell application "System Events"
      if exists process "zoom.us" then
        tell process "zoom.us"
          -- Keystroke Cmd+Shift+A is the default global Zoom mute/unmute shortcut
          keystroke "a" using {command down, shift down}
        end tell
      end if
    end tell
  `;
  await execa('osascript', ['-e', appleScript]);
}

3. Quick WIP Push to Git

If you are pairing or just want to quickly save your progress to your remote branch before stepping away from your desk:

async function gitWipPush() {
  const projectPath = '/Users/alex/projects/core-api';
  try {
    await execa('git', ['add', '.'], { cwd: projectPath });
    await execa('git', ['commit', '-m', 'wip: footpedal autosave'], { cwd: projectPath });
    await execa('git', ['push', 'origin', 'HEAD'], { cwd: projectPath });
    notifyOS('Git Autosave', 'Successfully pushed WIP to remote!');
  } catch (err) {
    notifyOS('Git Autosave Error', err.message);
  }
}

function notifyOS(title, message) {
  // Send native macOS notifications
  const notificationScript = `display notification "${message}" with title "${title}"`;
  execa('osascript', ['-e', notificationScript]);
}

Running the Daemon Continuously

To ensure this script runs continuously in the background on your Mac, you can wrap it in a lightweight launchd service or simply run it inside a persistent process manager like pm2.

npm install -g pm2
pm2 start daemon.js --name "midi-macro-daemon"
pm2 save
pm2 startup

Now, every time you boot up your workstation, your Mac will silently spin up your MIDI listener, waiting for your commands.

Conclusion: The Ultimate Ergonomic Offload

By mapping repetitive tasks to a MIDI controller, you aren’t just saving a few seconds a day—you are preserving mental energy. Moving the "chore" tasks of software development (spinning up containers, running tests, navigating video calls) to your feet keeps your hands where they belong: on your home row, focused on writing clean, elegant code.

Whether you have an old guitar pedalboard gathering dust in your closet, or you decide to pick up a cheap MIDI controller on eBay, integrating MIDI into your development environment is a highly rewarding Saturday afternoon project that pays dividends for years to deferred workspace comfort.

Have you experimented with alternative input devices or hardware macros in your development workflow? What would you map to your foot pedals? Let me know in the comments below!

Post a Comment

Previous Post Next Post