Why I Built MidiLoop Instead of Reaching for a DAW

I built MidiLoop because I wanted a MIDI looper that felt more at home in a hardware setup than inside a DAW.

A lot of MIDI tools are either very polished and very opinionated, or incredibly powerful but not especially friendly when you are standing in front of a synth rack trying to move fast. What I wanted was simpler: record a real MIDI phrase, loop it back with the original timing, keep playback routed to the right instrument, and be able to switch devices quickly during a performance.

That idea turned into MidiLoop, a terminal-first MIDI looper and performance router built in Go.

Starting with the actual workflow

The core use case for MidiLoop is DAWless performance.

That meant the app had to do a few things well from the start:

  • record incoming MIDI without “helpfully” rewriting it
  • preserve message timing and ordering
  • keep loop playback tied to the right device
  • let me move between instruments fast
  • support keyboard and MIDI bindings for transport and actions
  • still be usable when the machine is headless or tucked away somewhere

Once I was honest about the workflow, a lot of design decisions got easier.

I did not need a giant arrange view. I did not need a plugin format. I did not need a browser app pretending to be desktop software. I needed a focused piece of software that could sit between controllers and hardware and stay out of the way.

Why Go

I picked Go mostly because I wanted the boring parts to stay boring.

MidiLoop has a lot of concurrency in it, even though the feature set looks straightforward at first glance. There is live MIDI input, loop recording, loop playback, device management, config persistence, a terminal UI, and an optional web control server. Go is just very good at that kind of work. Goroutines and channels are a natural fit for event-driven tools, and the standard library covers a lot without drama.

The project also benefits from Go’s deployment story. I can run it locally, package it for desktop, and cross-compile for Raspberry Pi without turning the build into a science project.

Building around a simple architecture

I kept the architecture pretty direct.

There is an application core that owns runtime state and orchestration. Around that are a few focused subsystems:

  • a Bubble Tea terminal UI
  • a MIDI layer with backend abstractions
  • a device manager
  • a binding engine for keyboard and MIDI actions
  • a loop engine for recording and playback
  • some music helpers for scale and chord logic
  • a small web control server

The app layer is where most of the real coordination happens. It receives MIDI events, decides whether they should trigger an action or be treated as performance data, routes messages to the correct output, and keeps snapshots of state for the UI and web control page.

That split helped a lot. The TUI is just a view over application state. The web control page is also just a view over application state. The looping logic does not need to know anything about menus or HTTP.

Recording raw MIDI was the right call

One of the most important decisions in the whole project was to record raw MIDI bytes with timing deltas instead of storing some higher-level note representation.

That sounds obvious in retrospect, but it shaped almost everything.

If the goal is to build a useful looper for real hardware, then “close enough” timing is not actually close enough. The recorder stores each message with its original bytes and the elapsed time since the previous event. Playback then re-sends those events in order using the captured deltas.

That preserves feel much better than translating everything into sanitized note objects and rebuilding it later. It also means control changes, expressive gestures, and device-specific behavior survive the round trip much more faithfully.

The tradeoff is that you inherit all the messiness of MIDI. Running status, controller behavior, device quirks, and stateful messages do not go away just because you want a clean architecture. You have to deal with them.

That was worth it.

Device routing mattered more than I expected

Another thing that became clear early was that looping alone was not enough. In a multi-instrument setup, routing is half the problem.

MidiLoop keeps per-device loop state and playback routing, and it also supports optional output port overrides. That matters because real hardware names are not always clean or stable across systems. On top of that, the app has a selected-device model, so transport actions like record, play, and stop target the currently selected instrument.

There is also an optional shared input mode for single-controller setups. In that mode, one input device can feed the currently selected instrument while each instrument still plays back through its own output route.

That feature ended up being much more useful than it sounds on paper. It makes the app feel a lot more playable with a small rig.

The terminal UI was a feature, not a shortcut

I built the main interface as a terminal UI using Bubble Tea and Lip Gloss.

Part of that was practical. A terminal app is fast to launch, easy to SSH into, and works well on machines that are doing one job. But it was also a philosophical choice. I wanted MidiLoop to feel lightweight and direct. Menus for Project, Keybinds, Devices, and Settings were enough. Anything more complicated would have made the app feel heavier than the problem it solves.

The TUI also pushed me toward clearer state management. If every screen is just a different lens on the same runtime state, you are less tempted to scatter logic everywhere.

At the same time, I knew the terminal could not be the only interface.

Adding web control for headless setups

A nice surprise during development was how useful a tiny web control page turned out to be.

MidiLoop can run in headless mode, which means no Bubble Tea interface, but MIDI bindings and the web UI still work. That makes it a much better fit for Raspberry Pi setups, rack-mounted machines, or anything that lives off to the side during performance.

The web page is intentionally small. It shows the selected device, transport state, whether a loop exists, some recent status, and a few core controls like previous device, next device, record, play, and stop. It stays on one page and polls a lightweight JSON status endpoint.

That was a deliberate choice. I did not want a “real frontend” here. I wanted something you could open on a phone and trust immediately.

Projects, sessions, and export came from real use

Once looping worked, the next problem was persistence.

It is not enough to capture loops if you cannot save the current working state and come back to it later. MidiLoop ended up with two layers of persistence:

Projects are the full saved working set, including current loops, sessions, selected device state, and patch snapshots.

Sessions are more like quick recall slots. You can save the current set of loops and later move forward and backward through previously saved sessions.

I also added MIDI export so the current loops can be written to standard .mid files for DAW import. That helped keep the app from feeling like a closed box. If something starts in MidiLoop and later belongs in a larger arrangement, it can move there.

That kind of escape hatch always makes software feel more honest.

Testing without hardware was non-negotiable

One of the best choices in the codebase is the backend abstraction for MIDI.

There is a real RT MIDI backend for actual hardware, but there is also a simulated backend. That meant I could develop and test the app without always having devices attached. It also made automated tests much more realistic than they would have been if the code talked directly to hardware everywhere.

A lot of the tests focus on the behavior that matters most:

  • recorder byte and timing integrity
  • loop service lifecycle
  • device behavior
  • binding behavior
  • app-level action flow

For a project like this, testing is not about chasing coverage for its own sake. It is about protecting the parts that are easy to break and annoying to debug live.

Packaging for Raspberry Pi changed the project

Supporting Raspberry Pi pushed MidiLoop into a more complete shape.

Once I started thinking about Pi deployment, I had to make headless mode cleaner, config paths more explicit, startup more predictable, and packaging more repeatable. The project now has scripts for cross-compiling, building Debian packages, and even creating Pi images.

That packaging work is not glamorous, but it matters. A music tool only feels real once it can leave the development machine.

What I learned

The biggest lesson from building MidiLoop is that “small” tools still need strong boundaries.

It would have been easy for this project to grow into a vague MIDI workstation. Instead, the useful version was the one that stayed focused: capture real MIDI, route it reliably, control it quickly, and support the environments where hardware musicians actually use it.

I also came away with more respect for the weirdness of MIDI. It is simple in theory, but real devices expose a lot of edge cases. Preserving musical intent often means resisting the urge to over-normalize everything.

And finally, I was reminded that interfaces do not need to be flashy to feel good. A solid terminal UI, a tiny web page, clear config files, and reliable behavior can go a long way.

MidiLoop is still a focused tool, not an everything app. That is exactly what I wanted.