; MEDIA KEYS AS MIDI CONTROLLER ; -> Play, stop, skip, and adjust volume in Ableton using your keyboard's media keys. ; ! Install LoopBe1 virtual MIDI port + LiveHack control surface #Requires AutoHotkey v2.0 #SingleInstance Force SendMode("Input") SetWorkingDir(A_ScriptDir) SetTitleMatchMode(2) global MidiPortIdentifier := "LoopBe Internal MIDI" global ControlSurfaceDownloadUrl := "https://livehack.tools/ahk/static/downloads/LiveHack_Ableton_Live_Control_Surface.zip" global MidiNoteDurationMs := 5 global MidiSetupWarningShown := false global MidiPortOpenWarningShown := false global CachedMidiPortIndex := -1 global CachedMidiPortResolvedName := "" ; MIDI device selection: ; Put the Windows MIDI OUTPUT device name you want to use in MidiPortIdentifier. ; Matching is intentionally conservative and tries, in order: ; 1) exact match ; 2) exact match after stripping whitespace ; 3) case-insensitive substring search ; 4) ordered word search (words must appear in sequence) ; MIDI setup required: ; 1. Install LoopBe1 (https://www.nerds.de/en/loopbe1.html) — free virtual MIDI port. ; The port name will be "LoopBe Internal MIDI". If you have a MIDI interface you ; can also wire the output into the input. Change "MidiPortIdentifier" to the ; MIDI port name of your audio interface. ; 2. Download the LiveHack control surface package: ; https://livehack.tools/ahk/static/downloads/LiveHack_Ableton_Live_Control_Surface.zip ; 3. Install the control surface folder in Ableton's Remote Scripts location. ; 4. In Ableton, enable the LoopBe Internal MIDI port for the control surface. ; Without this setup, the script will not control Ableton. #HotIf WinExist("ahk_class Ableton Live Window Class") ; Mute selected track Volume_Mute::LiveHack_SendMidiNote(1, 3, 127) ; Coarse track volume Volume_Down::LiveHack_SendMidiNote(1, 4, 127) Volume_Up::LiveHack_SendMidiNote(1, 5, 127) ; Transport Media_Play_Pause::LiveHack_SendMidiNote(1, 6, 127) Media_Stop::LiveHack_SendMidiNote(1, 7, 127) Media_Prev::LiveHack_SendMidiNote(1, 8, 127) Media_Next::LiveHack_SendMidiNote(1, 9, 127) ; Fine track volume +Volume_Down::LiveHack_SendMidiNote(1, 10, 127) +Volume_Up::LiveHack_SendMidiNote(1, 11, 127) ; Play/Pause (Ctrl variant) ^Media_Play_Pause::LiveHack_SendMidiNote(1, 12, 127) ; Track pan ^Volume_Down::LiveHack_SendMidiNote(1, 13, 127) ^Volume_Up::LiveHack_SendMidiNote(1, 14, 127) ^+Volume_Down::LiveHack_SendMidiNote(1, 15, 127) ^+Volume_Up::LiveHack_SendMidiNote(1, 16, 127) ; Track solo / arm ^Volume_Mute::LiveHack_SendMidiNote(1, 17, 127) ^+Volume_Mute::LiveHack_SendMidiNote(1, 18, 127) #HotIf ; Debug: Ctrl+Alt+Shift+L — shows MIDI status and key mappings ^!+l::LiveHack_ShowMidiDebugInfo() LiveHack_SendMidiNote(channel, note, noteVelocity) { global MidiPortIdentifier, MidiNoteDurationMs midiDevice := LiveHack_GetPreferredMidiOutPort(MidiPortIdentifier) if (midiDevice < 0) { LiveHack_ShowMidiSetupWarning() return } hMidiOut := LiveHack_MidiOutOpen(midiDevice) if (!hMidiOut) return LiveHack_MidiOutShortMsg(hMidiOut, "N1", channel, note, noteVelocity) Sleep(MidiNoteDurationMs) LiveHack_MidiOutClose(hMidiOut) } LiveHack_ShowMidiSetupWarning() { global MidiSetupWarningShown, MidiPortIdentifier, ControlSurfaceDownloadUrl if (MidiSetupWarningShown) return MidiSetupWarningShown := true msg := 'Could not find a MIDI output named "' MidiPortIdentifier '".' . "`n`nInstall LoopBe1 from https://www.nerds.de/en/loopbe1.html" . '`nThe virtual MIDI port will appear as "LoopBe Internal MIDI".' . "`n`nThen select the same port for the LiveHack control surface in Ableton." . "`n`nMatching tries: exact, whitespace-stripped, case-insensitive, then ordered words." . "`n`nControl-surface download:`n" ControlSurfaceDownloadUrl . LiveHack_MidiPortListText() MsgBox(msg, "LiveHack MIDI setup required", "Icon!") } LiveHack_ShowMidiDebugInfo() { global MidiPortIdentifier, CachedMidiPortIndex, CachedMidiPortResolvedName numPorts := LiveHack_MidiOutGetNumDevs() resolved := LiveHack_FindMidiOutPort(MidiPortIdentifier) mappings := LiveHack_GetMappingTable() cBg := "1E1E2E" cGrn := "A6E3A1" cDim := "585B70" cOK := resolved >= 0 ? "A6E3A1" : "F38BA8" W := 620 dg := Gui("+AlwaysOnTop -MaximizeBox +ToolWindow", "LiveHack · MIDI Debug") dg.BackColor := cBg dg.MarginX := 24 dg.MarginY := 20 ; Dark titlebar on Win 10 1903+ / Win 11 DllCall("dwmapi\DwmSetWindowAttribute", "Ptr", dg.Hwnd, "UInt", 20, "Int*", 1, "UInt", 4) dg.SetFont("s14 w700 c" cGrn, "Segoe UI") dg.Add("Text", "w" W, "LiveHack · MIDI Debug") dg.SetFont("s8 c" cDim, "Segoe UI") dg.Add("Text", "w" W " y+2", FormatTime(, "ddd d MMM yyyy HH:mm:ss") " · Esc to close") ; ── Port ────────────────────────────────────────────────────────── dg.SetFont("s8 w600 c" cDim, "Segoe UI") dg.Add("Text", "w" W " y+16", "MIDI OUTPUT") dg.SetFont("s10 w600 c" cOK, "Consolas") dg.Add("Text", "w" W " y+4", resolved >= 0 ? "[" resolved "] " LiveHack_MidiOutNameGet(resolved) " ✔" : "NOT FOUND ✘ — check MidiPortIdentifier") dg.SetFont("s8 c" cDim, "Segoe UI") dg.Add("Text", "w" W " y+2", 'Configured: "' MidiPortIdentifier '"' . " Cache: " (CachedMidiPortIndex >= 0 ? "hit [" CachedMidiPortIndex "]" : "miss")) ; Compact device list as plain wrapping text devLine := "" Loop numPorts { i := A_Index - 1 devLine .= (i ? " " : "") "[" i "] " LiveHack_MidiOutNameGet(i) (i = resolved ? " ✓" : "") } dg.SetFont("s8 c" cDim, "Consolas") dg.Add("Text", "w" W " y+4", devLine) ; ── Ableton setup ────────────────────────────────────────────── dg.SetFont("s8 w600 c" cDim, "Segoe UI") dg.Add("Text", "w" W " y+16", "ABLETON MIDI SETTINGS") dg.SetFont("s8 c" cDim, "Consolas") setupText := " Control Surfaces : LiveHack | Input: LoopBe Internal MIDI | Output: None`n" . " Input Ports : LoopBe Internal MIDI — Track ○ Sync ○ Remote ○`n" . " Output Ports : LoopBe Internal MIDI — Track ○ Sync ○ Remote ○`n" . " Signal flow : AHK → LoopBe Internal MIDI → Ableton → LiveHack CS" dg.Add("Text", "w" W " y+4", setupText) ; ── Mappings ────────────────────────────────────────────────────── dg.SetFont("s8 w600 c" cDim, "Segoe UI") dg.Add("Text", "w" W " y+16", "MIDI MAPPINGS (" mappings.Length ")") lv := dg.Add("ListView", "w" W " r" Min(mappings.Length + 1, 22) " y+4 -Multi +ReadOnly", ["Hotkey", "Ch", "Note", "CS Action"]) DllCall("uxtheme\SetWindowTheme", "Ptr", lv.Hwnd, "Str", "DarkMode_Explorer", "Ptr", 0) lv.SetFont("s9", "Consolas") for m in mappings lv.Add("", m["key"], m["ch"], m["note"], m["desc"]) lv.ModifyCol(1, 160), lv.ModifyCol(2, 36), lv.ModifyCol(3, 46), lv.ModifyCol(4, W - 260) ; ── OK ──────────────────────────────────────────────────────────── dg.SetFont("s9 w700 c1E1E2E", "Segoe UI") btn := dg.Add("Button", "w" W " h32 y+16", "OK") btn.Opt("Background" cGrn) DllCall("uxtheme\SetWindowTheme", "Ptr", btn.Hwnd, "Str", " ", "Ptr", 0) btn.OnEvent("Click", (*) => dg.Destroy()) dg.OnEvent("Escape", (*) => dg.Destroy()) dg.OnEvent("Close", (*) => dg.Destroy()) dg.Show("AutoSize Center") } LiveHack_GetMappingTable() { t := [ Map("key", "Volume Mute", "ch", 1, "note", 3, "desc", "Mute selected track"), Map("key", "Volume Down", "ch", 1, "note", 4, "desc", "Volume −1 dB"), Map("key", "Volume Up", "ch", 1, "note", 5, "desc", "Volume +1 dB"), Map("key", "Media Play/Pause", "ch", 1, "note", 6, "desc", "Play / pause transport"), Map("key", "Media Stop", "ch", 1, "note", 7, "desc", "Stop transport"), Map("key", "Media Prev", "ch", 1, "note", 8, "desc", "Scroll arranger ←"), Map("key", "Media Next", "ch", 1, "note", 9, "desc", "Scroll arranger →"), Map("key", "Shift+Vol Down", "ch", 1, "note", 10, "desc", "Volume − fine"), Map("key", "Shift+Vol Up", "ch", 1, "note", 11, "desc", "Volume + fine"), Map("key", "Ctrl+Media Play", "ch", 1, "note", 12, "desc", "Continue playing"), Map("key", "Ctrl+Vol Down", "ch", 1, "note", 13, "desc", "Pan ← coarse"), Map("key", "Ctrl+Vol Up", "ch", 1, "note", 14, "desc", "Pan → coarse"), Map("key", "Ctrl+Shift+Vol Dn", "ch", 1, "note", 15, "desc", "Pan ← fine"), Map("key", "Ctrl+Shift+Vol Up", "ch", 1, "note", 16, "desc", "Pan → fine"), Map("key", "Ctrl+Mute", "ch", 1, "note", 17, "desc", "Solo track"), Map("key", "Ctrl+Shift+Mute", "ch", 1, "note", 18, "desc", "Arm track"), Map("key", "—", "ch", 1, "note", 19, "desc", "Hide all views"), Map("key", "—", "ch", 1, "note", 20, "desc", "Prev track"), Map("key", "—", "ch", 1, "note", 21, "desc", "Next track"), ] ; Ch2: master_track.devices[0].parameters[1..16] Loop 16 t.Push(Map("key", "—", "ch", 2, "note", A_Index, "desc", "Master device param " A_Index)) return t } LiveHack_MidiOutOpen(uDeviceID := 0) { global MidiPortOpenWarningShown hMidiOut := 0 result := DllCall("winmm.dll\midiOutOpen", "Ptr*", &hMidiOut, "UInt", uDeviceID, "Ptr", 0, "Ptr", 0, "UInt", 0) if (result != 0) { if (!MidiPortOpenWarningShown) { MidiPortOpenWarningShown := true MsgBox("Could not open the selected MIDI output. Close any app that is locking the port, then try again.", "LiveHack MIDI output unavailable", "Icon!") } return 0 } return hMidiOut } LiveHack_MidiOutShortMsg(hMidiOut, eventType, channel, param1, param2) { ; MIDI status byte: event nibble | zero-based channel ; NoteOn = 0x90, NoteOff = 0x80; channel param is 1-based if (eventType = "NoteOn" || eventType = "N1") midiStatus := 0x8F + channel else if (eventType = "NoteOff" || eventType = "N0") midiStatus := 0x7F + channel else return -1 return DllCall("winmm.dll\midiOutShortMsg", "Ptr", hMidiOut, "UInt", midiStatus | (param1 << 8) | (param2 << 16)) } LiveHack_MidiOutClose(hMidiOut) { if (hMidiOut) DllCall("winmm.dll\midiOutClose", "Ptr", hMidiOut) } LiveHack_GetPreferredMidiOutPort(preferredName) { global CachedMidiPortIndex, CachedMidiPortResolvedName ; Cache is valid if the device at the cached index still has the same name if (CachedMidiPortIndex >= 0) { if (LiveHack_MidiOutNameGet(CachedMidiPortIndex) = CachedMidiPortResolvedName) return CachedMidiPortIndex CachedMidiPortIndex := -1 CachedMidiPortResolvedName := "" } idx := LiveHack_FindMidiOutPort(preferredName) if (idx >= 0) { CachedMidiPortIndex := idx CachedMidiPortResolvedName := LiveHack_MidiOutNameGet(idx) } return idx } LiveHack_FindMidiOutPort(preferredName) { preferredName := Trim(preferredName) if (preferredName = "") return -1 midiPorts := [] numPorts := LiveHack_MidiOutGetNumDevs() Loop numPorts { portIndex := A_Index - 1 portName := LiveHack_MidiOutNameGet(portIndex) if (portName != "") midiPorts.Push(Map("index", portIndex, "name", portName)) } ; 1) Exact match. for midiPort in midiPorts { if (midiPort["name"] = preferredName) return midiPort["index"] } ; 2) Exact match after stripping whitespace. preferredNoWhitespace := LiveHack_StripWhitespace(preferredName) for midiPort in midiPorts { if (LiveHack_StripWhitespace(midiPort["name"]) = preferredNoWhitespace) return midiPort["index"] } ; 3) Case-insensitive search. preferredLower := StrLower(preferredName) for midiPort in midiPorts { if (InStr(StrLower(midiPort["name"]), preferredLower)) return midiPort["index"] } ; 4) Ordered word search. preferredWords := LiveHack_SplitWords(preferredName) if (preferredWords.Length = 0) return -1 for midiPort in midiPorts { if (LiveHack_PortContainsWordsInOrder(midiPort["name"], preferredWords)) return midiPort["index"] } return -1 } LiveHack_StripWhitespace(text) { return RegExReplace(text, "\s+") } LiveHack_SplitWords(text) { text := Trim(RegExReplace(text, "\s+", " ")) if (text = "") return [] return StrSplit(text, " ") } LiveHack_PortContainsWordsInOrder(portName, preferredWords) { if (preferredWords.Length = 0) return false portNameLower := StrLower(portName) searchStartPos := 1 for preferredWord in preferredWords { preferredWordLower := StrLower(preferredWord) foundPos := InStr(portNameLower, preferredWordLower, true, searchStartPos) if (!foundPos) return false searchStartPos := foundPos + StrLen(preferredWordLower) } return true } LiveHack_MidiPortListText() { numPorts := LiveHack_MidiOutGetNumDevs() if (numPorts < 1) return "`n`nNo MIDI output ports are currently available." text := "`n`nAvailable MIDI outputs:" Loop numPorts { portIndex := A_Index - 1 portName := LiveHack_MidiOutNameGet(portIndex) text .= "`n- " . portIndex . ": " . portName } return text } LiveHack_MidiOutGetNumDevs() { return DllCall("winmm.dll\midiOutGetNumDevs") } LiveHack_MidiOutNameGet(uDeviceID := 0) { MidiOutCaps := Buffer(50, 0) result := DllCall("winmm.dll\midiOutGetDevCapsA", "UInt", uDeviceID, "Ptr", MidiOutCaps, "UInt", 50) if (result != 0) return "" return StrGet(MidiOutCaps.Ptr + 8, 32, "CP0") } ; April 2026 — Released under MIT license by Yannis Lever ; This code is provided as-is with no warranty. Use at your own risk. ; Check out www.livehack.tools for more LiveHacks!