diff --git a/exe/Main.hs b/exe/Main.hs
new file mode 100644
--- /dev/null
+++ b/exe/Main.hs
@@ -0,0 +1,26 @@
+module Main
+  ( main
+  )
+where
+
+import Control.Monad.IO.Class (liftIO)
+import Data.Foldable (traverse_)
+import Data.Sequence qualified as Seq
+import Data.Text.IO qualified as TIO
+import Libremidi.Api qualified as LMA
+import Libremidi.Simple qualified as LMS
+
+main :: IO ()
+main = flip LMS.runMidiM LMS.stderrLogFun $ do
+  liftIO (putStrLn "* In ports:")
+  inPorts <- LMS.listInPorts
+  traverse_ (liftIO . TIO.putStrLn . fst) inPorts
+  liftIO (putStrLn "* Out ports:")
+  outPorts <- LMS.listOutPorts
+  traverse_ (liftIO . TIO.putStrLn . fst) outPorts
+  liftIO (putStrLn "* Opening first out port")
+  let op = snd (Seq.index outPorts 0)
+  oh <- LMS.openOutPort op
+  liftIO (putStrLn "* Connected")
+  liftIO (LMA.freeOutHandle oh)
+  liftIO (putStrLn "* Freed")
diff --git a/libremidi.cabal b/libremidi.cabal
new file mode 100644
--- /dev/null
+++ b/libremidi.cabal
@@ -0,0 +1,201 @@
+cabal-version: 2.2
+
+-- This file has been generated from package.yaml by hpack version 0.37.0.
+--
+-- see: https://github.com/sol/hpack
+
+name:           libremidi
+version:        0.1.0
+synopsis:       libremidi bindings for haskell
+description:    Please see the README on GitHub at <https://github.com/ejconlon/libremidi-haskell#readme>
+homepage:       https://github.com/ejconlon/libremidi-haskell#readme
+bug-reports:    https://github.com/ejconlon/libremidi-haskell/issues
+author:         Eric Conlon
+maintainer:     ejconlon@gmail.com
+copyright:      (c) 2024 Eric Conlon
+license:        BSD-3-Clause
+build-type:     Simple
+tested-with:
+    GHC == 9.8.4
+
+source-repository head
+  type: git
+  location: https://github.com/ejconlon/libremidi-haskell
+
+library
+  exposed-modules:
+      Libremidi.Api
+      Libremidi.Common
+      Libremidi.Foreign
+      Libremidi.Simple
+  other-modules:
+      Paths_libremidi
+  autogen-modules:
+      Paths_libremidi
+  hs-source-dirs:
+      src
+  default-extensions:
+      BangPatterns
+      ConstraintKinds
+      DataKinds
+      DeriveFunctor
+      DeriveFoldable
+      DeriveGeneric
+      DeriveTraversable
+      DerivingStrategies
+      DerivingVia
+      FlexibleContexts
+      FlexibleInstances
+      FunctionalDependencies
+      GADTs
+      GeneralizedNewtypeDeriving
+      ImportQualifiedPost
+      LambdaCase
+      KindSignatures
+      MultiParamTypeClasses
+      MultiWayIf
+      PatternSynonyms
+      Rank2Types
+      ScopedTypeVariables
+      StandaloneDeriving
+      StandaloneKindSignatures
+      TupleSections
+      TypeApplications
+      TypeOperators
+      TypeFamilies
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints -fno-warn-unused-top-binds
+  include-dirs:
+      vendor
+  cxx-sources:
+      vendor/libremidi/client.cpp
+      vendor/libremidi/libremidi-c.cpp
+      vendor/libremidi/libremidi.cpp
+      vendor/libremidi/midi_in.cpp
+      vendor/libremidi/midi_out.cpp
+      vendor/libremidi/observer.cpp
+      vendor/libremidi/reader.cpp
+      vendor/libremidi/writer.cpp
+  extra-libraries:
+      stdc++
+  build-depends:
+      base >=4.12 && <5
+    , containers >=0.6.7 && <0.7
+    , data-default >=0.7.1.1 && <0.8
+    , mtl >=2.3.1 && <2.4
+    , stm >=2.5.1.0 && <2.6
+    , text >=2.0.2 && <2.2
+    , transformers >=0.6.1 && <0.7
+  default-language: GHC2021
+  if os(darwin)
+    ghc-options: -pgmcxx=clang++
+    cxx-options: -std=c++20 -DLIBREMIDI_COREMIDI=1
+    frameworks:
+        CoreMIDI CoreAudio CoreFoundation
+    ld-options: -Wl,-keep_dwarf_unwind
+  if os(linux)
+    ghc-options: -pgmcxx=clang++
+    cxx-options: -std=c++20 -DLIBREMIDI_PIPEWIRE=1 -DLIBREMIDI_HAS_UDEV=1
+    pkgconfig-depends:
+        libpipewire-0.3
+      , udev
+
+executable libremidi-exe
+  main-is: Main.hs
+  other-modules:
+      Paths_libremidi
+  autogen-modules:
+      Paths_libremidi
+  hs-source-dirs:
+      exe
+  default-extensions:
+      BangPatterns
+      ConstraintKinds
+      DataKinds
+      DeriveFunctor
+      DeriveFoldable
+      DeriveGeneric
+      DeriveTraversable
+      DerivingStrategies
+      DerivingVia
+      FlexibleContexts
+      FlexibleInstances
+      FunctionalDependencies
+      GADTs
+      GeneralizedNewtypeDeriving
+      ImportQualifiedPost
+      LambdaCase
+      KindSignatures
+      MultiParamTypeClasses
+      MultiWayIf
+      PatternSynonyms
+      Rank2Types
+      ScopedTypeVariables
+      StandaloneDeriving
+      StandaloneKindSignatures
+      TupleSections
+      TypeApplications
+      TypeOperators
+      TypeFamilies
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints -fno-warn-unused-top-binds -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      base >=4.12 && <5
+    , containers >=0.6.7 && <0.7
+    , data-default >=0.7.1.1 && <0.8
+    , libremidi
+    , mtl >=2.3.1 && <2.4
+    , stm >=2.5.1.0 && <2.6
+    , text >=2.0.2 && <2.2
+    , transformers >=0.6.1 && <0.7
+  default-language: GHC2021
+
+test-suite libremidi-test
+  type: exitcode-stdio-1.0
+  main-is: Main.hs
+  other-modules:
+      Paths_libremidi
+  autogen-modules:
+      Paths_libremidi
+  hs-source-dirs:
+      test
+  default-extensions:
+      BangPatterns
+      ConstraintKinds
+      DataKinds
+      DeriveFunctor
+      DeriveFoldable
+      DeriveGeneric
+      DeriveTraversable
+      DerivingStrategies
+      DerivingVia
+      FlexibleContexts
+      FlexibleInstances
+      FunctionalDependencies
+      GADTs
+      GeneralizedNewtypeDeriving
+      ImportQualifiedPost
+      LambdaCase
+      KindSignatures
+      MultiParamTypeClasses
+      MultiWayIf
+      PatternSynonyms
+      Rank2Types
+      ScopedTypeVariables
+      StandaloneDeriving
+      StandaloneKindSignatures
+      TupleSections
+      TypeApplications
+      TypeOperators
+      TypeFamilies
+  ghc-options: -Wall -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wpartial-fields -Wredundant-constraints -fno-warn-unused-top-binds -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      base >=4.12 && <5
+    , containers >=0.6.7 && <0.7
+    , data-default >=0.7.1.1 && <0.8
+    , libremidi
+    , mtl >=2.3.1 && <2.4
+    , stm >=2.5.1.0 && <2.6
+    , tasty
+    , tasty-hunit
+    , text >=2.0.2 && <2.2
+    , transformers >=0.6.1 && <0.7
+  default-language: GHC2021
diff --git a/src/Libremidi/Api.hs b/src/Libremidi/Api.hs
new file mode 100644
--- /dev/null
+++ b/src/Libremidi/Api.hs
@@ -0,0 +1,509 @@
+-- | Mid-level interface to Libremidi.
+-- Operations in IO do not fail.
+-- Operations in ErrM can possibly fail.
+module Libremidi.Api where
+
+import Control.Monad.IO.Class (liftIO)
+import Data.Coerce (coerce)
+import Data.Default (Default (..))
+import Data.Foldable (traverse_)
+import Data.Int (Int64)
+import Data.Maybe (fromMaybe)
+import Data.Proxy (Proxy (..))
+import Data.Text (Text)
+import Data.Text.Foreign qualified as TF
+import Foreign.Concurrent qualified as FC
+import Foreign.ForeignPtr (ForeignPtr, finalizeForeignPtr, withForeignPtr)
+import Foreign.ForeignPtr.Unsafe (unsafeForeignPtrToPtr)
+import Foreign.Ptr (FunPtr, Ptr, castPtrToFunPtr, nullPtr)
+import Libremidi.Common
+  ( BitEnum (..)
+  , Cb
+  , ErrM
+  , Field
+  , MallocPtr (..)
+  , UniquePtr
+  , assocM
+  , checkM
+  , consumeCb
+  , consumeUniquePtr
+  , fromCBool
+  , fromCLong
+  , pokeField
+  , takeM
+  , textM
+  , toCBool
+  , toCLong
+  , toCSize
+  , unRunErrM
+  , withCb
+  )
+import Libremidi.Foreign qualified as LMF
+
+data TimestampMode
+  = TimestampModeNone
+  | TimestampModeRelative
+  | TimestampModeAbsolute
+  | TimestampModeSystemMono
+  | TimestampModeAudioFrame
+  | TimestampModeCustom
+  deriving stock (Eq, Ord, Show, Enum, Bounded)
+
+instance BitEnum LMF.TimestampMode TimestampMode where
+  fromBitEnum = \case
+    a | a == LMF.tmNoTimestamp -> Just TimestampModeNone
+    a | a == LMF.tmRelative -> Just TimestampModeRelative
+    a | a == LMF.tmAbsolute -> Just TimestampModeAbsolute
+    a | a == LMF.tmSystemMonotonic -> Just TimestampModeSystemMono
+    a | a == LMF.tmAudioFrame -> Just TimestampModeAudioFrame
+    a | a == LMF.tmCustom -> Just TimestampModeCustom
+    _ -> Nothing
+  toBitEnum = \case
+    TimestampModeNone -> LMF.tmNoTimestamp
+    TimestampModeRelative -> LMF.tmRelative
+    TimestampModeAbsolute -> LMF.tmAbsolute
+    TimestampModeSystemMono -> LMF.tmSystemMonotonic
+    TimestampModeAudioFrame -> LMF.tmAudioFrame
+    TimestampModeCustom -> LMF.tmCustom
+
+data Api
+  = ApiUnspecified
+  | ApiCoremidi
+  | ApiAlsaSeq
+  | ApiAlsaRaw
+  | ApiJackMidi
+  | ApiWindowsMm
+  | ApiWindowsUwp
+  | ApiWebmidi
+  | ApiPipewire
+  | ApiAlsaRawUmp
+  | ApiAlsaSeqUmp
+  | ApiCoremidiUmp
+  | ApiWindowsMidiServices
+  | ApiDummy
+  deriving stock (Eq, Ord, Show, Enum, Bounded)
+
+instance BitEnum LMF.Api Api where
+  fromBitEnum = \case
+    a | a == LMF.apiUnspecified -> Just ApiUnspecified
+    a | a == LMF.apiCoremidi -> Just ApiCoremidi
+    a | a == LMF.apiAlsaSeq -> Just ApiAlsaSeq
+    a | a == LMF.apiAlsaRaw -> Just ApiAlsaRaw
+    a | a == LMF.apiJackMidi -> Just ApiJackMidi
+    a | a == LMF.apiWindowsMm -> Just ApiWindowsMm
+    a | a == LMF.apiWindowsUwp -> Just ApiWindowsUwp
+    a | a == LMF.apiWebmidi -> Just ApiWebmidi
+    a | a == LMF.apiPipewire -> Just ApiPipewire
+    a | a == LMF.apiAlsaRawUmp -> Just ApiAlsaRawUmp
+    a | a == LMF.apiAlsaSeqUmp -> Just ApiAlsaSeqUmp
+    a | a == LMF.apiCoremidiUmp -> Just ApiCoremidiUmp
+    a | a == LMF.apiWindowsMidiServices -> Just ApiWindowsMidiServices
+    a | a == LMF.apiDummy -> Just ApiDummy
+    _ -> Nothing
+  toBitEnum = \case
+    ApiUnspecified -> LMF.apiUnspecified
+    ApiCoremidi -> LMF.apiCoremidi
+    ApiAlsaSeq -> LMF.apiAlsaSeq
+    ApiAlsaRaw -> LMF.apiAlsaRaw
+    ApiJackMidi -> LMF.apiJackMidi
+    ApiWindowsMm -> LMF.apiWindowsMm
+    ApiWindowsUwp -> LMF.apiWindowsUwp
+    ApiWebmidi -> LMF.apiWebmidi
+    ApiPipewire -> LMF.apiPipewire
+    ApiAlsaRawUmp -> LMF.apiAlsaRawUmp
+    ApiAlsaSeqUmp -> LMF.apiAlsaSeqUmp
+    ApiCoremidiUmp -> LMF.apiCoremidiUmp
+    ApiWindowsMidiServices -> LMF.apiWindowsMidiServices
+    ApiDummy -> LMF.apiDummy
+
+data ConfigType
+  = ConfigTypeObserver
+  | ConfigTypeInput
+  | ConfigTypeOutput
+  deriving stock (Eq, Ord, Show, Enum, Bounded)
+
+instance BitEnum LMF.ConfigType ConfigType where
+  fromBitEnum = \case
+    a | a == LMF.ctObserver -> Just ConfigTypeObserver
+    a | a == LMF.ctInput -> Just ConfigTypeInput
+    a | a == LMF.ctOutput -> Just ConfigTypeOutput
+    _ -> Nothing
+  toBitEnum = \case
+    ConfigTypeObserver -> LMF.ctObserver
+    ConfigTypeInput -> LMF.ctInput
+    ConfigTypeOutput -> LMF.ctOutput
+
+data Version
+  = VersionMidi1
+  | VersionMidi2
+  deriving stock (Eq, Ord, Show, Enum, Bounded)
+
+instance BitEnum LMF.Version Version where
+  fromBitEnum = \case
+    a | a == LMF.verMidi1 -> Just VersionMidi1
+    a | a == LMF.verMidi2 -> Just VersionMidi2
+    _ -> Nothing
+  toBitEnum = \case
+    VersionMidi1 -> LMF.verMidi1
+    VersionMidi2 -> LMF.verMidi2
+
+type Timestamp = Int64
+
+type InPort = ForeignPtr LMF.InPort
+
+freeInPort :: InPort -> IO ()
+freeInPort = finalizeForeignPtr
+
+cloneInPort' :: Ptr LMF.InPort -> ErrM InPort
+cloneInPort' = takeM . LMF.libremidi_midi_in_port_clone
+
+cloneInPort :: InPort -> ErrM InPort
+cloneInPort = assocM cloneInPort'
+
+inPortName' :: Ptr LMF.InPort -> ErrM Text
+inPortName' = textM . LMF.libremidi_midi_in_port_name
+
+inPortName :: InPort -> ErrM Text
+inPortName = assocM inPortName'
+
+type OutPort = ForeignPtr LMF.OutPort
+
+freeOutPort :: OutPort -> IO ()
+freeOutPort = finalizeForeignPtr
+
+cloneOutPort' :: Ptr LMF.OutPort -> ErrM OutPort
+cloneOutPort' = takeM . LMF.libremidi_midi_out_port_clone
+
+cloneOutPort :: OutPort -> ErrM OutPort
+cloneOutPort = assocM cloneOutPort'
+
+outPortName' :: Ptr LMF.OutPort -> ErrM Text
+outPortName' = textM . LMF.libremidi_midi_out_port_name
+
+outPortName :: OutPort -> ErrM Text
+outPortName = assocM outPortName'
+
+data LogLvl = LogLvlWarn | LogLvlErr
+  deriving stock (Eq, Ord, Show, Enum, Bounded)
+
+type LogFun = LogLvl -> Text -> IO ()
+
+newLogCb :: LogFun -> LogLvl -> IO (Cb LMF.LogFun)
+newLogCb f lvl = LMF.newLogCb $ \_ s l _ -> do
+  msg <- liftIO (TF.fromPtr (coerce s) (fromIntegral l))
+  f lvl msg
+
+data ObsAct = ObsActAdd | ObsActRem
+  deriving stock (Eq, Ord, Show, Enum, Bounded)
+
+type ObsFun p = ObsAct -> Ptr p -> IO ()
+
+type ObsInFun = ObsFun LMF.InPort
+
+type ObsOutFun = ObsFun LMF.OutPort
+
+newObsCb :: ObsFun p -> ObsAct -> IO (Cb (LMF.ObsFun p))
+newObsCb f = LMF.newObsCb . const . f
+
+type EnumFun p = Ptr p -> IO ()
+
+type EnumInFun = EnumFun LMF.InPort
+
+type EnumOutFun = EnumFun LMF.OutPort
+
+newEnumCb :: EnumFun p -> IO (Cb (LMF.ObsFun p))
+newEnumCb = LMF.newObsCb . const
+
+data ObsConfig = ObsConfig
+  { ocOnErr :: !(Maybe (Cb LMF.LogFun))
+  , ocOnWarn :: !(Maybe (Cb LMF.LogFun))
+  , ocInAdd :: !(Maybe (Cb (LMF.ObsFun LMF.InPort)))
+  , ocInRem :: !(Maybe (Cb (LMF.ObsFun LMF.InPort)))
+  , ocOutAdd :: !(Maybe (Cb (LMF.ObsFun LMF.OutPort)))
+  , ocOutRem :: !(Maybe (Cb (LMF.ObsFun LMF.OutPort)))
+  , ocTrackHardware :: !Bool
+  , ocTrackVirtual :: !Bool
+  , ocTrackAny :: !Bool
+  , ocNotifInCon :: !Bool
+  }
+  deriving stock (Eq)
+
+instance Default ObsConfig where
+  def =
+    ObsConfig
+      { ocOnErr = Nothing
+      , ocOnWarn = Nothing
+      , ocInAdd = Nothing
+      , ocInRem = Nothing
+      , ocOutAdd = Nothing
+      , ocOutRem = Nothing
+      , ocTrackHardware = True
+      , ocTrackVirtual = True
+      , ocTrackAny = True
+      , ocNotifInCon = True
+      }
+
+setObsLogCb :: LogFun -> ObsConfig -> IO ObsConfig
+setObsLogCb f oc = do
+  onErr <- newLogCb f LogLvlErr
+  onWarn <- newLogCb f LogLvlWarn
+  pure $
+    oc
+      { ocOnErr = Just onErr
+      , ocOnWarn = Just onWarn
+      }
+
+setObsInCb :: ObsInFun -> ObsConfig -> IO ObsConfig
+setObsInCb f oc = do
+  onAdd <- newObsCb f ObsActAdd
+  onRem <- newObsCb f ObsActRem
+  pure $
+    oc
+      { ocInAdd = Just onAdd
+      , ocInRem = Just onRem
+      }
+
+setObsOutCb :: ObsOutFun -> ObsConfig -> IO ObsConfig
+setObsOutCb f oc = do
+  onAdd <- newObsCb f ObsActAdd
+  onRem <- newObsCb f ObsActRem
+  pure $
+    oc
+      { ocOutAdd = Just onAdd
+      , ocOutRem = Just onRem
+      }
+
+mkObsConfig :: ObsConfig -> IO (ForeignPtr LMF.ObsConfig)
+mkObsConfig oc = do
+  f <- mallocPtr (Proxy @LMF.ObsConfig)
+  withForeignPtr f $ \p -> do
+    let
+      setFun :: forall x. Field LMF.ObsConfig (FunPtr x) -> Cb x -> IO ()
+      setFun field cb = do
+        q <- consumeCb cb
+        let w = castPtrToFunPtr (unsafeForeignPtrToPtr q)
+        pokeField field p w
+        FC.addForeignPtrFinalizer f (finalizeForeignPtr q)
+    traverse_ (setFun LMF.ocOnErr) (ocOnErr oc)
+    traverse_ (setFun LMF.ocOnWarn) (ocOnWarn oc)
+    traverse_ (setFun LMF.ocInAdd) (ocInAdd oc)
+    traverse_ (setFun LMF.ocInRem) (ocInRem oc)
+    traverse_ (setFun LMF.ocOutAdd) (ocOutAdd oc)
+    traverse_ (setFun LMF.ocOutRem) (ocOutRem oc)
+    pokeField LMF.ocTrackHardware p (toCBool (ocTrackHardware oc))
+    pokeField LMF.ocTrackVirtual p (toCBool (ocTrackVirtual oc))
+    pokeField LMF.ocTrackAny p (toCBool (ocTrackAny oc))
+    pokeField LMF.ocNotifInCon p (toCBool (ocNotifInCon oc))
+  pure f
+
+data MidiPort
+  = MidiPortIn !(UniquePtr LMF.InPort)
+  | MidiPortOut !(UniquePtr LMF.OutPort)
+  deriving stock (Eq)
+
+data MsgFun
+  = MsgFun1 (Cb (LMF.MsgFun LMF.Sym1))
+  | MsgFun2 (Cb (LMF.MsgFun LMF.Sym2))
+  deriving stock (Eq)
+
+msgFunVersion :: MsgFun -> Version
+msgFunVersion = \case
+  MsgFun1 _ -> VersionMidi1
+  MsgFun2 _ -> VersionMidi2
+
+data MidiConfig = MidiConfig
+  { mcVersion :: !(Maybe Version)
+  , mcPort :: !(Maybe MidiPort)
+  , mcOnMsg :: !(Maybe MsgFun)
+  , mcGetTime :: !(Maybe (Cb LMF.TimeFun))
+  , mcOnErr :: !(Maybe (Cb LMF.LogFun))
+  , mcOnWarn :: !(Maybe (Cb LMF.LogFun))
+  , mcVirtualPort :: !Bool
+  , mcIgnoreSysex :: !Bool
+  , mcIgnoreTiming :: !Bool
+  , mcIgnoreSensing :: !Bool
+  , mcTimestamps :: !TimestampMode
+  }
+  deriving stock (Eq)
+
+instance Default MidiConfig where
+  def =
+    MidiConfig
+      { mcVersion = Nothing
+      , mcPort = Nothing
+      , mcOnMsg = Nothing
+      , mcGetTime = Nothing
+      , mcOnErr = Nothing
+      , mcOnWarn = Nothing
+      , mcVirtualPort = False
+      , mcIgnoreSysex = False
+      , mcIgnoreTiming = False
+      , mcIgnoreSensing = True
+      , mcTimestamps = TimestampModeAbsolute
+      }
+
+setMidiLogCb :: LogFun -> MidiConfig -> IO MidiConfig
+setMidiLogCb f mc = do
+  onErr <- newLogCb f LogLvlErr
+  onWarn <- newLogCb f LogLvlWarn
+  pure $
+    mc
+      { mcOnErr = Just onErr
+      , mcOnWarn = Just onWarn
+      }
+
+mkMidiConfig :: MidiConfig -> IO (ForeignPtr LMF.MidiConfig)
+mkMidiConfig mc = do
+  let ver = fromMaybe (maybe VersionMidi1 msgFunVersion (mcOnMsg mc)) (mcVersion mc)
+  f <- mallocPtr (Proxy @LMF.MidiConfig)
+  withForeignPtr f $ \p -> do
+    let
+      setFun :: forall x. Field LMF.MidiConfig (FunPtr x) -> Cb x -> IO ()
+      setFun field cb = do
+        q <- consumeCb cb
+        let w = castPtrToFunPtr (unsafeForeignPtrToPtr q)
+        pokeField field p w
+        FC.addForeignPtrFinalizer f (finalizeForeignPtr q)
+      setPtr :: forall x. Field LMF.MidiConfig (Ptr x) -> UniquePtr x -> IO ()
+      setPtr field u = do
+        q <- consumeUniquePtr u
+        let w = unsafeForeignPtrToPtr q
+        pokeField field p w
+        FC.addForeignPtrFinalizer f (finalizeForeignPtr q)
+    case mcPort mc of
+      Just (MidiPortIn ip) -> setPtr LMF.mcInPort ip
+      Just (MidiPortOut op) -> setPtr LMF.mcOutPort op
+      Nothing -> pure ()
+    case mcOnMsg mc of
+      Just (MsgFun1 cb1) -> setFun LMF.mcOnMsg1 cb1
+      Just (MsgFun2 cb2) -> setFun LMF.mcOnMsg2 cb2
+      Nothing -> pure ()
+    traverse_ (setFun LMF.mcGetTime) (mcGetTime mc)
+    traverse_ (setFun LMF.mcOnErr) (mcOnErr mc)
+    traverse_ (setFun LMF.mcOnWarn) (mcOnWarn mc)
+    pokeField LMF.mcVersion p (toBitEnum ver)
+    pokeField LMF.mcVirtualPort p (toCBool (mcVirtualPort mc))
+    pokeField LMF.mcIgnoreSysex p (toCBool (mcIgnoreSysex mc))
+    pokeField LMF.mcIgnoreTiming p (toCBool (mcIgnoreTiming mc))
+    pokeField LMF.mcIgnoreSensing p (toCBool (mcIgnoreSensing mc))
+    pokeField LMF.mcTimestamps p (toBitEnum (mcTimestamps mc))
+  pure f
+
+data ApiConfig = ApiConfig
+  { acApi :: !Api
+  , acConfigType :: !ConfigType
+  }
+  deriving stock (Eq)
+
+instance Default ApiConfig where
+  def =
+    ApiConfig
+      { acApi = ApiUnspecified
+      , acConfigType = ConfigTypeObserver
+      }
+
+mkApiConfig :: ApiConfig -> IO (ForeignPtr LMF.ApiConfig)
+mkApiConfig ac = do
+  f <- mallocPtr (Proxy @LMF.ApiConfig)
+  withForeignPtr f $ \p -> do
+    pokeField LMF.acApi p (toBitEnum (acApi ac))
+    pokeField LMF.acConfigType p (toBitEnum (acConfigType ac))
+  pure f
+
+type ObsHandle = ForeignPtr LMF.ObsHandle
+
+newObsHandle :: Api -> ObsConfig -> ErrM ObsHandle
+newObsHandle api oc = do
+  fac <- liftIO (mkApiConfig (ApiConfig {acApi = api, acConfigType = ConfigTypeObserver}))
+  foc <- liftIO (mkObsConfig oc)
+  let pac = unsafeForeignPtrToPtr fac
+      poc = unsafeForeignPtrToPtr foc
+  fh <- takeM (LMF.libremidi_midi_observer_new poc pac)
+  liftIO $ do
+    FC.addForeignPtrFinalizer fh (finalizeForeignPtr fac)
+    FC.addForeignPtrFinalizer fh (finalizeForeignPtr foc)
+  pure fh
+
+freeObsHandle :: ObsHandle -> IO ()
+freeObsHandle = finalizeForeignPtr
+
+enumInPorts :: ObsHandle -> EnumInFun -> ErrM ()
+enumInPorts h f = unRunErrM $ do
+  cb <- newEnumCb f
+  withForeignPtr h $ \p ->
+    withCb cb $ \fun ->
+      checkM (LMF.libremidi_midi_observer_enumerate_input_ports p nullPtr fun)
+
+enumOutPorts :: ObsHandle -> EnumOutFun -> ErrM ()
+enumOutPorts h f = unRunErrM $ do
+  cb <- newEnumCb f
+  withForeignPtr h $ \p ->
+    withCb cb $ \fun ->
+      checkM (LMF.libremidi_midi_observer_enumerate_output_ports p nullPtr fun)
+
+type InHandle = ForeignPtr LMF.InHandle
+
+newInHandle :: Api -> MidiConfig -> ErrM InHandle
+newInHandle api mc = do
+  fac <- liftIO (mkApiConfig (ApiConfig {acApi = api, acConfigType = ConfigTypeInput}))
+  fmc <- liftIO (mkMidiConfig mc)
+  let pac = unsafeForeignPtrToPtr fac
+      pmc = unsafeForeignPtrToPtr fmc
+  fh <- takeM (LMF.libremidi_midi_in_new pmc pac)
+  liftIO $ do
+    FC.addForeignPtrFinalizer fh (finalizeForeignPtr fac)
+    FC.addForeignPtrFinalizer fh (finalizeForeignPtr fmc)
+  pure fh
+
+freeInHandle :: InHandle -> IO ()
+freeInHandle = finalizeForeignPtr
+
+inIsConnected :: InHandle -> IO Bool
+inIsConnected fih = withForeignPtr fih (fmap fromCBool . LMF.libremidi_midi_in_is_connected)
+
+inAbsTimestamp :: InHandle -> IO Timestamp
+inAbsTimestamp fih = withForeignPtr fih (fmap fromCLong . LMF.libremidi_midi_in_absolute_timestamp)
+
+type OutHandle = ForeignPtr LMF.OutHandle
+
+newOutHandle :: Api -> MidiConfig -> ErrM OutHandle
+newOutHandle api mc = do
+  fac <- liftIO (mkApiConfig (ApiConfig {acApi = api, acConfigType = ConfigTypeOutput}))
+  fmc <- liftIO (mkMidiConfig mc)
+  let pac = unsafeForeignPtrToPtr fac
+      pmc = unsafeForeignPtrToPtr fmc
+  fh <- takeM (LMF.libremidi_midi_out_new pmc pac)
+  liftIO $ do
+    FC.addForeignPtrFinalizer fh (finalizeForeignPtr fac)
+    FC.addForeignPtrFinalizer fh (finalizeForeignPtr fmc)
+  pure fh
+
+freeOutHandle :: OutHandle -> IO ()
+freeOutHandle = finalizeForeignPtr
+
+outIsConnected :: OutHandle -> IO Bool
+outIsConnected foh = withForeignPtr foh (fmap fromCBool . LMF.libremidi_midi_out_is_connected)
+
+type Msg1 = Ptr LMF.Sym1
+
+outSendMsg1 :: OutHandle -> Msg1 -> Int -> ErrM ()
+outSendMsg1 h msg len = unRunErrM $
+  withForeignPtr h $ \p ->
+    checkM (LMF.libremidi_midi_out_send_message p msg (toCSize (fromIntegral len)))
+
+outSchedMsg1 :: OutHandle -> Timestamp -> Msg1 -> Int -> ErrM ()
+outSchedMsg1 h ts msg len = unRunErrM $
+  withForeignPtr h $ \p ->
+    checkM (LMF.libremidi_midi_out_schedule_message p (toCLong ts) msg (toCSize (fromIntegral len)))
+
+type Msg2 = Ptr LMF.Sym2
+
+outSendMsg2 :: OutHandle -> Msg2 -> Int -> ErrM ()
+outSendMsg2 h msg len = unRunErrM $
+  withForeignPtr h $ \p ->
+    checkM (LMF.libremidi_midi_out_send_ump p msg (toCSize (fromIntegral len)))
+
+outSchedMsg2 :: OutHandle -> Timestamp -> Msg2 -> Int -> ErrM ()
+outSchedMsg2 h ts msg len = unRunErrM $
+  withForeignPtr h $ \p ->
+    checkM (LMF.libremidi_midi_out_schedule_ump p (toCLong ts) msg (toCSize (fromIntegral len)))
diff --git a/src/Libremidi/Common.hs b/src/Libremidi/Common.hs
new file mode 100644
--- /dev/null
+++ b/src/Libremidi/Common.hs
@@ -0,0 +1,236 @@
+module Libremidi.Common where
+
+import Control.Concurrent.STM (STM, atomically, retry, throwSTM)
+import Control.Concurrent.STM.TVar (TVar, newTVarIO, readTVar, writeTVar)
+import Control.Exception (Exception, finally, mask, mask_, throwIO)
+import Control.Monad.Except (ExceptT (..), MonadError (..), runExceptT)
+import Control.Monad.IO.Class (MonadIO (..))
+import Data.Coerce (coerce)
+import Data.Foldable (traverse_)
+import Data.Int (Int32, Int64)
+import Data.Kind (Type)
+import Data.Proxy (Proxy)
+import Data.Text (Text)
+import Data.Text.Foreign qualified as TF
+import Data.Word (Word64)
+import Foreign.C.String (CString)
+import Foreign.C.Types (CBool (..), CInt (..), CLong (..), CSize (..))
+import Foreign.Concurrent qualified as FC
+import Foreign.ForeignPtr (ForeignPtr, finalizeForeignPtr, mallocForeignPtrBytes, withForeignPtr)
+import Foreign.Marshal.Alloc (alloca, free)
+import Foreign.Marshal.Utils (fillBytes)
+import Foreign.Ptr (FunPtr, Ptr, castFunPtrToPtr, castPtrToFunPtr, freeHaskellFunPtr, nullPtr, plusPtr)
+import Foreign.Storable (Storable (..))
+
+mallocForeignPtrBytes0 :: Int -> IO (ForeignPtr a)
+mallocForeignPtrBytes0 len = do
+  fp <- mallocForeignPtrBytes len
+  withForeignPtr fp (\p -> fillBytes p 0 len)
+  pure fp
+
+allocaPtr :: (Ptr (Ptr x) -> IO a) -> IO a
+allocaPtr f = alloca (\p -> poke p nullPtr >> f p)
+
+ptrSize :: Int
+ptrSize = sizeOf nullPtr
+
+-- | A typed "accessor"
+newtype Field a b = Field (Ptr a -> Ptr b)
+
+newField :: Int -> Field a b
+newField i = Field (`plusPtr` i)
+
+pokeField :: (Storable b) => Field a b -> Ptr a -> b -> IO ()
+pokeField (Field f) = poke . f
+
+-- | Maps Haskell enums to C enums
+class (Integral a, Enum b, Bounded b) => BitEnum a b | b -> a where
+  fromBitEnum :: a -> Maybe b
+  toBitEnum :: b -> a
+
+-- | Generalizes ForeignPtr
+class AssocPtr (z :: Type) where
+  type PtrAssoc z :: Type
+  withAssocPtr :: z -> (PtrAssoc z -> IO a) -> IO a
+
+instance AssocPtr (ForeignPtr x) where
+  type PtrAssoc (ForeignPtr x) = Ptr x
+  withAssocPtr = withForeignPtr
+
+data Ref p
+  = RefUnlock !(ForeignPtr p)
+  | RefLock
+  | RefFree
+  deriving stock (Eq, Ord, Show)
+
+newtype UniquePtr p = UniquePtr
+  { unUniquePtr :: TVar (Ref p)
+  }
+  deriving stock (Eq)
+
+instance AssocPtr (UniquePtr x) where
+  type PtrAssoc (UniquePtr x) = Ptr x
+  withAssocPtr = withUniquePtr
+
+data FreeErr = FreeErr
+  deriving stock (Eq, Ord, Show)
+
+instance Exception FreeErr
+
+newUniquePtr :: ForeignPtr p -> IO (UniquePtr p)
+newUniquePtr = fmap UniquePtr . newTVarIO . RefUnlock
+
+aliveUniquePtr :: UniquePtr p -> STM Bool
+aliveUniquePtr (UniquePtr v) = fmap isAlive (readTVar v)
+ where
+  isAlive = \case
+    RefFree -> False
+    _ -> True
+
+freeUniquePtr :: UniquePtr p -> IO ()
+freeUniquePtr (UniquePtr v) = mask_ $ do
+  mfp <- atomically $ do
+    r <- readTVar v
+    case r of
+      RefUnlock fp -> do
+        writeTVar v RefFree
+        pure (Just fp)
+      RefLock -> retry
+      RefFree -> pure Nothing
+  traverse_ finalizeForeignPtr mfp
+
+withUniquePtr' :: UniquePtr p -> (ForeignPtr p -> IO a) -> IO a
+withUniquePtr' (UniquePtr v) f = mask $ \restore -> do
+  fp <- atomically $ do
+    r <- readTVar v
+    case r of
+      RefUnlock fp -> do
+        writeTVar v RefLock
+        pure fp
+      RefLock -> retry
+      RefFree -> throwSTM FreeErr
+  finally
+    (restore (f fp))
+    (atomically (writeTVar v (RefUnlock fp)))
+
+withUniquePtr :: UniquePtr p -> (Ptr p -> IO a) -> IO a
+withUniquePtr u f = withUniquePtr' u (`withForeignPtr` f)
+
+consumeUniquePtr :: UniquePtr p -> IO (ForeignPtr p)
+consumeUniquePtr (UniquePtr v) = atomically $ do
+  r <- readTVar v
+  case r of
+    RefUnlock fp -> do
+      writeTVar v RefFree
+      pure fp
+    RefLock -> retry
+    RefFree -> throwSTM FreeErr
+
+-- | Like a ForeignFunPtr
+-- We need to free the function pointer on finalization to not leak it
+newtype Cb x = Cb {unCb :: UniquePtr x}
+  deriving stock (Eq)
+
+instance AssocPtr (Cb x) where
+  type PtrAssoc (Cb x) = FunPtr x
+  withAssocPtr = withCb
+
+-- | Given an FFI wrapper function, allocate a callback
+newCb :: (x -> IO (FunPtr x)) -> x -> IO (Cb x)
+newCb w x = do
+  y <- w x
+  fp <- FC.newForeignPtr (castFunPtrToPtr y) (freeHaskellFunPtr y)
+  up <- newUniquePtr fp
+  pure (Cb up)
+
+freeCb :: Cb x -> IO ()
+freeCb = freeUniquePtr . unCb
+
+withCb :: Cb x -> (FunPtr x -> IO a) -> IO a
+withCb (Cb up) f = withUniquePtr up (f . castPtrToFunPtr)
+
+consumeCb :: Cb x -> IO (ForeignPtr x)
+consumeCb = consumeUniquePtr . unCb
+
+-- | Foreign types that have a meaningful initial state
+class MallocPtr (p :: Type) where
+  mallocPtr :: Proxy p -> IO (ForeignPtr p)
+
+-- | Error code returned by API calls
+newtype Err = Err CInt
+  deriving stock (Eq, Ord, Show)
+
+instance Exception Err
+
+-- | Computations that short-circuit on error
+newtype ErrM a = ErrM {unErrM :: ExceptT Err IO a}
+  deriving newtype (Functor, Applicative, Monad, MonadIO, MonadError Err)
+
+runErrM :: ErrM a -> IO (Either Err a)
+runErrM = runExceptT . unErrM
+
+unRunErrM :: IO (Either Err a) -> ErrM a
+unRunErrM = ErrM . ExceptT
+
+rethrowErrM :: ErrM a -> IO a
+rethrowErrM m = runErrM m >>= either throwIO pure
+
+assocM :: (AssocPtr x) => (PtrAssoc x -> ErrM y) -> x -> ErrM y
+assocM f fp = unRunErrM (withAssocPtr fp (runErrM . f))
+
+checkM :: IO Err -> IO (Either Err ())
+checkM one = do
+  e@(Err x) <- liftIO one
+  pure (if x == 0 then Right () else Left e)
+
+checkAndThenM :: IO Err -> IO a -> IO (Either Err a)
+checkAndThenM one two = do
+  e@(Err x) <- liftIO one
+  if x == 0
+    then fmap Right two
+    else pure (Left e)
+
+textM :: (Ptr CString -> Ptr CSize -> IO Err) -> ErrM Text
+textM f = unRunErrM $
+  allocaPtr $ \sptr -> do
+    alloca $ \lptr -> do
+      checkAndThenM (f sptr lptr) $ do
+        s <- peek sptr
+        l <- peek lptr
+        TF.fromPtr (coerce s) (fromIntegral l)
+
+takeM :: (Ptr (Ptr x) -> IO Err) -> ErrM (ForeignPtr x)
+takeM f = unRunErrM $
+  allocaPtr $ \pptr -> do
+    checkAndThenM (f pptr) $ do
+      ptr <- peek pptr
+      -- Cannot use finalizerFree because may have other haskell finalizers
+      FC.newForeignPtr ptr (free ptr)
+
+toCBool :: Bool -> CBool
+toCBool = \case
+  False -> CBool 0
+  True -> CBool 1
+
+fromCBool :: CBool -> Bool
+fromCBool = \case
+  0 -> False
+  _ -> True
+
+fromCLong :: CLong -> Int64
+fromCLong (CLong x) = x
+
+toCLong :: Int64 -> CLong
+toCLong = CLong
+
+fromCInt :: CInt -> Int32
+fromCInt (CInt x) = x
+
+toCInt :: Int32 -> CInt
+toCInt = CInt
+
+fromCSize :: CSize -> Word64
+fromCSize (CSize x) = x
+
+toCSize :: Word64 -> CSize
+toCSize = CSize
diff --git a/src/Libremidi/Foreign.hsc b/src/Libremidi/Foreign.hsc
new file mode 100644
--- /dev/null
+++ b/src/Libremidi/Foreign.hsc
@@ -0,0 +1,253 @@
+{-# LANGUAGE ForeignFunctionInterface #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+-- | Low-level bindings to libremidi.
+module Libremidi.Foreign where
+
+import Foreign.C (CInt (..), CSize (..), CBool (..), CUInt (..), CLong (..), CUChar (..))
+import Foreign.C.String (CString)
+import Data.Void (Void)
+import Foreign.Ptr (Ptr, FunPtr)
+import Libremidi.Common (Err (..), Field, Cb, mallocForeignPtrBytes0, newCb, ptrSize, MallocPtr (..), newField)
+
+#include "libremidi/libremidi-c.h"
+
+#{enum TimestampMode
+ , CInt
+ , tmNoTimestamp = NoTimestamp
+ , tmRelative = Relative
+ , tmAbsolute = Absolute
+ , tmSystemMonotonic = SystemMonotonic
+ , tmAudioFrame = AudioFrame
+ , tmCustom = Custom
+}
+
+type Api = CInt
+
+#{enum Api
+ , CInt
+ , apiUnspecified = UNSPECIFIED
+ , apiCoremidi = COREMIDI
+ , apiAlsaSeq = ALSA_SEQ
+ , apiAlsaRaw = ALSA_RAW
+ , apiJackMidi = JACK_MIDI
+ , apiWindowsMm = WINDOWS_MM
+ , apiWindowsUwp = WINDOWS_UWP
+ , apiWebmidi = WEBMIDI
+ , apiPipewire = PIPEWIRE
+ , apiAlsaRawUmp = ALSA_RAW_UMP
+ , apiAlsaSeqUmp = ALSA_SEQ_UMP
+ , apiCoremidiUmp = COREMIDI_UMP
+ , apiWindowsMidiServices = WINDOWS_MIDI_SERVICES
+ , apiDummy = DUMMY
+}
+
+type ConfigType = CInt
+
+#{enum ConfigType
+ , CInt
+ , ctObserver = Observer
+ , ctInput = Input
+ , ctOutput = Output
+}
+
+type Version = CInt
+
+#{enum Version
+ , CInt
+ , verMidi1 = MIDI1
+ , verMidi2 = MIDI2
+}
+
+type Sym1 = CUChar
+type Sym2 = CUInt
+
+type Timestamp = CLong
+
+data InPort
+data OutPort
+
+data InHandle
+data OutHandle
+data ObsHandle
+
+type TimestampMode = CInt
+
+data ApiConfig
+
+instance MallocPtr ApiConfig where
+  mallocPtr _ = mallocForeignPtrBytes0 #{size libremidi_api_configuration}
+
+acApi :: Field ApiConfig Api
+acApi = newField #{offset libremidi_api_configuration, api}
+
+acConfigType :: Field ApiConfig ConfigType
+acConfigType = newField #{offset libremidi_api_configuration, configuration_type}
+
+acData :: Field ApiConfig (Ptr Void)
+acData = newField #{offset libremidi_api_configuration, data}
+
+type LogFun = Ptr Void -> CString -> CSize -> Ptr Void -> IO ()
+
+foreign import ccall "wrapper"
+  logWrap :: LogFun -> IO (FunPtr LogFun)
+
+newLogCb :: LogFun -> IO (Cb LogFun)
+newLogCb = newCb logWrap
+
+type ObsFun p = Ptr Void -> Ptr p -> IO ()
+
+foreign import ccall "wrapper"
+  obsWrap :: ObsFun p -> IO (FunPtr (ObsFun p))
+
+newObsCb :: ObsFun p -> IO (Cb (ObsFun p))
+newObsCb = newCb obsWrap
+
+data ObsConfig
+
+instance MallocPtr ObsConfig where
+  mallocPtr _ = mallocForeignPtrBytes0 #{size libremidi_observer_configuration}
+
+ocOnErr :: Field ObsConfig (FunPtr LogFun)
+ocOnErr = newField (ptrSize + #{offset libremidi_observer_configuration, on_error})
+
+ocOnWarn :: Field ObsConfig (FunPtr LogFun)
+ocOnWarn = newField (ptrSize + #{offset libremidi_observer_configuration, on_warning})
+
+ocInAdd :: Field ObsConfig (FunPtr (ObsFun InPort))
+ocInAdd = newField (ptrSize + #{offset libremidi_observer_configuration, input_added})
+
+ocInRem :: Field ObsConfig (FunPtr (ObsFun InPort))
+ocInRem = newField (ptrSize + #{offset libremidi_observer_configuration, input_removed})
+
+ocOutAdd :: Field ObsConfig (FunPtr (ObsFun OutPort))
+ocOutAdd = newField (ptrSize + #{offset libremidi_observer_configuration, input_added})
+
+ocOutRem :: Field ObsConfig (FunPtr (ObsFun OutPort))
+ocOutRem = newField (ptrSize + #{offset libremidi_observer_configuration, input_removed})
+
+ocTrackHardware :: Field ObsConfig CBool
+ocTrackHardware = newField #{offset libremidi_observer_configuration, track_hardware}
+
+ocTrackVirtual :: Field ObsConfig CBool
+ocTrackVirtual = newField #{offset libremidi_observer_configuration, track_virtual}
+
+ocTrackAny :: Field ObsConfig CBool
+ocTrackAny = newField #{offset libremidi_observer_configuration, track_any}
+
+ocNotifInCon :: Field ObsConfig CBool
+ocNotifInCon = newField #{offset libremidi_observer_configuration, notify_in_constructor}
+
+type MsgFun d = Ptr Void -> Ptr d -> CSize -> IO ()
+
+foreign import ccall "wrapper"
+  msgWrap :: MsgFun d -> IO (FunPtr (MsgFun d))
+
+newMsgCb :: MsgFun d -> IO (Cb (MsgFun d))
+newMsgCb = newCb msgWrap
+
+type TimeFun = Ptr Void -> Timestamp -> IO ()
+
+foreign import ccall "wrapper"
+  timeWrap :: TimeFun -> IO (FunPtr TimeFun)
+
+newTimeCb :: TimeFun -> IO (Cb TimeFun)
+newTimeCb = newCb timeWrap
+
+data MidiConfig
+
+instance MallocPtr MidiConfig where
+  mallocPtr _ = mallocForeignPtrBytes0 #{size libremidi_midi_configuration}
+
+mcVersion :: Field MidiConfig Version
+mcVersion = newField #{offset libremidi_midi_configuration, version}
+
+mcInPort :: Field MidiConfig (Ptr InPort)
+mcInPort = newField #{offset libremidi_midi_configuration, in_port}
+
+mcOutPort :: Field MidiConfig (Ptr OutPort)
+mcOutPort = newField #{offset libremidi_midi_configuration, out_port}
+
+mcOnMsg1 :: Field MidiConfig (FunPtr (MsgFun Sym1))
+mcOnMsg1 = newField #{offset libremidi_midi_configuration, on_midi1_message}
+
+mcOnMsg2 :: Field MidiConfig (FunPtr (MsgFun Sym2))
+mcOnMsg2 = newField #{offset libremidi_midi_configuration, on_midi2_message}
+
+mcGetTime :: Field MidiConfig (FunPtr TimeFun)
+mcGetTime = newField #{offset libremidi_midi_configuration, get_timestamp}
+
+mcOnErr :: Field MidiConfig (FunPtr LogFun)
+mcOnErr = newField #{offset libremidi_midi_configuration, on_error}
+
+mcOnWarn :: Field MidiConfig (FunPtr LogFun)
+mcOnWarn = newField #{offset libremidi_midi_configuration, on_warning}
+
+mcPortName :: Field MidiConfig CString
+mcPortName = newField #{offset libremidi_midi_configuration, port_name}
+
+mcVirtualPort :: Field MidiConfig CBool
+mcVirtualPort = newField #{offset libremidi_midi_configuration, virtual_port}
+
+mcIgnoreSysex :: Field MidiConfig CBool
+mcIgnoreSysex = newField #{offset libremidi_midi_configuration, ignore_sysex}
+
+mcIgnoreTiming :: Field MidiConfig CBool
+mcIgnoreTiming = newField #{offset libremidi_midi_configuration, ignore_timing}
+
+mcIgnoreSensing :: Field MidiConfig CBool
+mcIgnoreSensing = newField #{offset libremidi_midi_configuration, ignore_sensing}
+
+mcTimestamps :: Field MidiConfig TimestampMode
+mcTimestamps = newField #{offset libremidi_midi_configuration, timestamps}
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_in_port_clone :: Ptr InPort -> Ptr (Ptr InPort) -> IO Err
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_in_port_name :: Ptr InPort -> Ptr CString -> Ptr CSize -> IO Err
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_out_port_clone :: Ptr OutPort -> Ptr (Ptr OutPort) -> IO Err
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_out_port_name :: Ptr OutPort -> Ptr CString -> Ptr CSize -> IO Err
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_observer_new :: Ptr ObsConfig -> Ptr ApiConfig -> Ptr (Ptr ObsHandle) -> IO Err
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_observer_enumerate_input_ports :: Ptr ObsHandle -> Ptr Void -> FunPtr (ObsFun InPort) -> IO Err
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_observer_enumerate_output_ports :: Ptr ObsHandle -> Ptr Void -> FunPtr (ObsFun OutPort) -> IO Err
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_in_new :: Ptr MidiConfig -> Ptr ApiConfig -> Ptr (Ptr InHandle) -> IO Err
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_in_is_connected :: Ptr InHandle -> IO CBool
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_in_absolute_timestamp :: Ptr InHandle -> IO Timestamp
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_in_free :: Ptr InHandle -> IO Err
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_out_new :: Ptr MidiConfig -> Ptr ApiConfig -> Ptr (Ptr OutHandle) -> IO Err
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_out_is_connected :: Ptr OutHandle -> IO CBool
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_out_send_message :: Ptr OutHandle -> Ptr Sym1 -> CSize -> IO Err
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_out_send_ump ::  Ptr OutHandle -> Ptr Sym2 -> CSize -> IO Err
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_out_schedule_message :: Ptr OutHandle -> Timestamp -> Ptr Sym1 -> CSize  -> IO Err
+
+foreign import ccall "libremidi/libremidi-c.h"
+  libremidi_midi_out_schedule_ump :: Ptr OutHandle -> Timestamp -> Ptr Sym2 -> CSize -> IO Err
diff --git a/src/Libremidi/Simple.hs b/src/Libremidi/Simple.hs
new file mode 100644
--- /dev/null
+++ b/src/Libremidi/Simple.hs
@@ -0,0 +1,113 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | High-level operations.
+module Libremidi.Simple where
+
+import Control.Monad (when)
+import Control.Monad.IO.Class (liftIO)
+import Control.Monad.Reader (ReaderT (..), ask)
+import Data.Default (def)
+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
+import Data.Maybe (isNothing)
+import Data.Sequence (Seq (..))
+import Data.Text (Text)
+import Data.Text.IO qualified as TIO
+import Foreign.ForeignPtr (ForeignPtr)
+import Foreign.Ptr (Ptr)
+import Libremidi.Api qualified as LMA
+import Libremidi.Common (ErrM, newUniquePtr, rethrowErrM)
+import System.IO qualified as SIO
+
+type MidiM = ReaderT LMA.LogFun IO
+
+runMidiM :: MidiM a -> LMA.LogFun -> IO a
+runMidiM = runReaderT
+
+nullLogFun :: LMA.LogFun
+nullLogFun _ _ = pure ()
+
+stderrLogFun :: LMA.LogFun
+stderrLogFun lvl msg =
+  let prefix = case lvl of
+        LMA.LogLvlWarn -> "[WARN] "
+        LMA.LogLvlErr -> "[ERR ] "
+  in  TIO.hPutStrLn SIO.stderr (prefix <> msg)
+
+newObsHandle :: MidiM LMA.ObsHandle
+newObsHandle = do
+  lf <- ask
+  liftIO $ do
+    oc <- LMA.setObsLogCb lf def
+    rethrowErrM (LMA.newObsHandle LMA.ApiUnspecified oc)
+
+listEnumFun
+  :: (Ptr p -> ErrM Text)
+  -> (Ptr p -> ErrM (ForeignPtr p))
+  -> IORef (Seq (Text, ForeignPtr p))
+  -> LMA.EnumFun p
+listEnumFun name clone r p = do
+  n <- rethrowErrM (name p)
+  fp <- rethrowErrM (clone p)
+  modifyIORef' r (:|> (n, fp))
+
+listPorts
+  :: (Ptr p -> ErrM Text)
+  -> (Ptr p -> ErrM (ForeignPtr p))
+  -> (LMA.ObsHandle -> LMA.EnumFun p -> ErrM ())
+  -> MidiM (Seq (Text, ForeignPtr p))
+listPorts name clone list = do
+  r <- liftIO (newIORef Empty)
+  let f = listEnumFun name clone r
+  foh <- newObsHandle
+  liftIO $ do
+    rethrowErrM (list foh f)
+    readIORef r
+
+listInPorts :: MidiM (Seq (Text, LMA.InPort))
+listInPorts = listPorts LMA.inPortName' LMA.cloneInPort' LMA.enumInPorts
+
+listOutPorts :: MidiM (Seq (Text, LMA.OutPort))
+listOutPorts = listPorts LMA.outPortName' LMA.cloneOutPort' LMA.enumOutPorts
+
+findEnumFun
+  :: (Ptr p -> ErrM Text)
+  -> (Ptr p -> ErrM (ForeignPtr p))
+  -> (Text -> Bool)
+  -> IORef (Maybe (Text, ForeignPtr p))
+  -> LMA.EnumFun p
+findEnumFun name clone sel r p = do
+  missing <- fmap isNothing (readIORef r)
+  when missing $ do
+    n <- rethrowErrM (name p)
+    when (sel n) $ do
+      fp <- rethrowErrM (clone p)
+      writeIORef r (Just (n, fp))
+
+findPort
+  :: (Ptr p -> ErrM Text)
+  -> (Ptr p -> ErrM (ForeignPtr p))
+  -> (LMA.ObsHandle -> LMA.EnumFun p -> ErrM ())
+  -> (Text -> Bool)
+  -> MidiM (Maybe (Text, ForeignPtr p))
+findPort name clone list sel = do
+  r <- liftIO (newIORef Nothing)
+  let f = findEnumFun name clone sel r
+  foh <- newObsHandle
+  liftIO $ do
+    rethrowErrM (list foh f)
+    readIORef r
+
+findInPort :: (Text -> Bool) -> MidiM (Maybe (Text, LMA.InPort))
+findInPort = findPort LMA.inPortName' LMA.cloneInPort' LMA.enumInPorts
+
+findOutPort :: (Text -> Bool) -> MidiM (Maybe (Text, LMA.OutPort))
+findOutPort = findPort LMA.outPortName' LMA.cloneOutPort' LMA.enumOutPorts
+
+openOutPort :: LMA.OutPort -> MidiM LMA.OutHandle
+openOutPort op = do
+  lf <- ask
+  liftIO $ rethrowErrM $ do
+    op' <- LMA.cloneOutPort op >>= liftIO . newUniquePtr
+    let mc = def {LMA.mcPort = Just (LMA.MidiPortOut op')}
+    mc' <- liftIO (LMA.setMidiLogCb lf mc)
+    LMA.newOutHandle LMA.ApiUnspecified mc'
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,21 @@
+module Main
+  ( main
+  )
+where
+
+import Test.Tasty (TestTree, defaultMain, testGroup)
+import Test.Tasty.HUnit (testCase, (@?=))
+
+testDummy :: TestTree
+testDummy = testCase "dummy" $ do
+  let actual = (1 + 1) :: Int
+      expected = 2 :: Int
+  actual @?= expected
+
+main :: IO ()
+main =
+  defaultMain $
+    testGroup
+      "Libremidi"
+      [ testDummy
+      ]
diff --git a/vendor/libremidi/client.cpp b/vendor/libremidi/client.cpp
new file mode 100644
--- /dev/null
+++ b/vendor/libremidi/client.cpp
@@ -0,0 +1,47 @@
+#if !defined(LIBREMIDI_HEADER_ONLY)
+  #include "client.hpp"
+#endif
+#include <libremidi/backends.hpp>
+#include <libremidi/shared_context.hpp>
+
+#ifdef LIBREMIDI_ALSA
+  #include <libremidi/backends/alsa_seq/shared_handler.hpp>
+#endif
+#ifdef LIBREMIDI_JACK
+  #include <libremidi/backends/jack/shared_handler.hpp>
+#endif
+namespace libremidi
+{
+LIBREMIDI_INLINE
+shared_configurations
+create_shared_context(const libremidi::API api, [[maybe_unused]] std::string_view client_name)
+{
+  switch (api)
+  {
+#if __has_include(<boost/lockfree/spsc_queue.hpp>)
+  #if defined(LIBREMIDI_ALSA)
+    case libremidi::API::ALSA_SEQ:
+      return alsa_seq::shared_handler::make(client_name);
+  #endif
+
+  #if defined(LIBREMIDI_JACK)
+    case libremidi::API::JACK_MIDI:
+      return jack::shared_handler::make(client_name);
+  #endif
+#endif
+    case libremidi::API::COREMIDI:
+    case libremidi::API::COREMIDI_UMP:
+      // TODO
+
+    case libremidi::API::WINDOWS_MIDI_SERVICES:
+      // TODO
+
+    default:
+      return {
+          .context = {},
+          .observer = observer_configuration_for(api),
+          .in = midi_in_configuration_for(api),
+          .out = midi_out_configuration_for(api)};
+  }
+}
+}
diff --git a/vendor/libremidi/libremidi-c.cpp b/vendor/libremidi/libremidi-c.cpp
new file mode 100644
--- /dev/null
+++ b/vendor/libremidi/libremidi-c.cpp
@@ -0,0 +1,507 @@
+// clang-format off
+#include <libremidi/libremidi-c.h>
+#include <libremidi/libremidi.hpp>
+#include <libremidi/backends.hpp>
+// clang-format on
+
+#include <cerrno>
+#include <cstdlib>
+#include <cstring>
+#include <limits>
+
+struct libremidi_midi_observer_handle
+{
+  libremidi::observer self;
+};
+
+struct libremidi_midi_in_handle
+{
+  libremidi::midi_in self;
+};
+
+struct libremidi_midi_out_handle
+{
+  libremidi::midi_out self;
+};
+
+namespace libremidi
+{
+
+static void assign_error_callback(const auto& src, auto& dst)
+{
+  if (src.callback)
+  {
+    dst = [src](std::string_view errorText, const source_location& loc) {
+      src.callback(src.context, errorText.data(), errorText.size(), &loc);
+    };
+  }
+}
+}
+
+extern "C" {
+const char* libremidi_get_version(void)
+{
+  return LIBREMIDI_VERSION;
+}
+
+void libremidi_available_midi1_apis(void* ctx, void (*cb)(void* ctx, libremidi_api))
+{
+  if (!cb)
+    return;
+  libremidi::midi1::for_all_backends([=](auto b) { cb(ctx, b.API); });
+}
+
+void libremidi_available_midi2_apis(void* ctx, void (*cb)(void* ctx, libremidi_api))
+{
+  if (!cb)
+    return;
+  libremidi::midi2::for_all_backends([=](auto b) { cb(ctx, b.API); });
+}
+
+const char* libremidi_api_identifier(libremidi_api api)
+{
+  return libremidi::get_api_name(api).data();
+}
+
+const char* libremidi_api_display_name(libremidi_api api)
+{
+  return libremidi::get_api_display_name(api).data();
+}
+
+libremidi_api libremidi_get_compiled_api_by_identifier(const char* name)
+{
+  libremidi_api ret = libremidi_api::UNSPECIFIED;
+  libremidi::midi_any::for_all_backends([&](auto& b) {
+    if (name == b.name)
+      ret = b.API;
+  });
+  return ret;
+}
+
+int libremidi_midi_api_configuration_init(libremidi_api_configuration* conf)
+{
+  memset(conf, 0, sizeof(*conf));
+  return 0;
+}
+
+int libremidi_midi_observer_configuration_init(libremidi_observer_configuration* conf)
+{
+  memset(conf, 0, sizeof(*conf));
+  return 0;
+}
+
+int libremidi_midi_configuration_init(libremidi_midi_configuration* conf)
+{
+  memset(conf, 0, sizeof(*conf));
+  return 0;
+}
+
+int libremidi_midi_in_port_clone(const libremidi_midi_in_port* port, libremidi_midi_in_port** dst)
+{
+  if (!port || !dst)
+    return -EINVAL;
+
+  auto copied = new libremidi::input_port{*reinterpret_cast<const libremidi::input_port*>(port)};
+  *dst = reinterpret_cast<libremidi_midi_in_port*>(copied);
+  return 0;
+}
+
+int libremidi_midi_in_port_free(libremidi_midi_in_port* port)
+{
+  delete reinterpret_cast<libremidi::input_port*>(port);
+  return 0;
+}
+
+int libremidi_midi_in_port_name(const libremidi_midi_in_port* port, const char** name, size_t* len)
+{
+  if (!port || !name || !len)
+    return -EINVAL;
+
+  auto& p = *reinterpret_cast<const libremidi::input_port*>(port);
+  *name = p.port_name.data();
+  *len = p.port_name.size();
+  return 0;
+}
+
+int libremidi_midi_out_port_clone(
+    const libremidi_midi_out_port* port, libremidi_midi_out_port** dst)
+{
+  if (!port || !dst)
+    return -EINVAL;
+
+  auto copied = new libremidi::output_port{*reinterpret_cast<const libremidi::output_port*>(port)};
+  *dst = reinterpret_cast<libremidi_midi_out_port*>(copied);
+  return 0;
+}
+
+int libremidi_midi_out_port_free(libremidi_midi_out_port* port)
+{
+  delete reinterpret_cast<libremidi::output_port*>(port);
+  return 0;
+}
+
+int libremidi_midi_out_port_name(
+    const libremidi_midi_out_port* port, const char** name, size_t* len)
+{
+  if (!port || !name || !len)
+    return -EINVAL;
+
+  auto& p = *reinterpret_cast<const libremidi::output_port*>(port);
+  *name = p.port_name.data();
+  *len = p.port_name.size();
+  return 0;
+}
+
+int libremidi_midi_observer_new(
+    const libremidi_observer_configuration* c, libremidi_api_configuration* api,
+    libremidi_midi_observer_handle** out)
+{
+  if (!out || !c)
+    return -EINVAL;
+
+  libremidi::observer_configuration conf;
+  libremidi::assign_error_callback(c->on_error, conf.on_error);
+  libremidi::assign_error_callback(c->on_warning, conf.on_warning);
+
+  conf.track_hardware = c->track_hardware;
+  conf.track_virtual = c->track_virtual;
+  conf.track_any = c->track_any;
+  conf.notify_in_constructor = c->notify_in_constructor;
+
+  auto api_conf = libremidi::observer_configuration_for(static_cast<libremidi::API>(api->api));
+
+  if (c->input_added.callback)
+  {
+    conf.input_added = [cb = c->input_added](const auto& port) {
+      cb.callback(cb.context, reinterpret_cast<const libremidi_midi_in_port*>(&port));
+    };
+  }
+  if (c->input_removed.callback)
+  {
+    conf.input_removed = [cb = c->input_removed](const auto& port) {
+      cb.callback(cb.context, reinterpret_cast<const libremidi_midi_in_port*>(&port));
+    };
+  }
+  if (c->output_added.callback)
+  {
+    conf.output_added = [cb = c->output_added](const auto& port) {
+      cb.callback(cb.context, reinterpret_cast<const libremidi_midi_out_port*>(&port));
+    };
+  }
+  if (c->output_removed.callback)
+  {
+    conf.output_removed = [cb = c->output_removed](const auto& port) {
+      cb.callback(cb.context, reinterpret_cast<const libremidi_midi_out_port*>(&port));
+    };
+  }
+
+  try
+  {
+    auto ptr = new libremidi_midi_observer_handle{
+        libremidi::observer{std::move(conf), std::move(api_conf)}};
+    *out = ptr;
+    return 0;
+  }
+  catch (...)
+  {
+    return -EINVAL;
+  }
+}
+
+int libremidi_midi_observer_enumerate_input_ports(
+    libremidi_midi_observer_handle* ptr, void* context,
+    void (*cb)(void* ctx, const libremidi_midi_in_port*))
+{
+  if (!ptr || !cb)
+    return -EINVAL;
+
+  for (const auto& port : ptr->self.get_input_ports())
+  {
+    cb(context, reinterpret_cast<const libremidi_midi_in_port*>(&port));
+  }
+
+  return 0;
+}
+
+int libremidi_midi_observer_enumerate_output_ports(
+    libremidi_midi_observer_handle* ptr, void* context,
+    void (*cb)(void* ctx, const libremidi_midi_out_port*))
+{
+  if (!ptr || !cb)
+    return -EINVAL;
+
+  for (const auto& port : ptr->self.get_output_ports())
+  {
+    cb(context, reinterpret_cast<const libremidi_midi_out_port*>(&port));
+  }
+
+  return 0;
+}
+
+int libremidi_midi_observer_free(libremidi_midi_observer_handle* ptr)
+{
+  delete ptr;
+  return 0;
+}
+
+int libremidi_midi_in_new(
+    const libremidi_midi_configuration* c, const libremidi_api_configuration* api,
+    libremidi_midi_in_handle** out)
+{
+  if (!out || !c)
+    return -EINVAL;
+  if (!c->virtual_port && !c->in_port)
+    return -EINVAL;
+
+  *out = nullptr;
+
+  auto api_conf = libremidi::midi_in_configuration_for(static_cast<libremidi::API>(api->api));
+
+  // Create the MIDI object
+  switch (c->version)
+  {
+    case libremidi_midi_configuration::MIDI1:
+    case libremidi_midi_configuration::MIDI1_RAW: {
+      libremidi::input_configuration conf;
+      libremidi::assign_error_callback(c->on_error, conf.on_error);
+      libremidi::assign_error_callback(c->on_warning, conf.on_warning);
+      conf.ignore_sensing = c->ignore_sensing;
+      conf.ignore_sysex = c->ignore_sysex;
+      conf.ignore_timing = c->ignore_timing;
+      conf.timestamps = c->timestamps;
+
+      if (c->get_timestamp.callback)
+      {
+        conf.get_timestamp
+            = [cb = c->get_timestamp](int64_t msg) { return cb.callback(cb.context, msg); };
+      }
+
+      if (c->on_midi1_message.callback)
+        conf.on_message = [cb = c->on_midi1_message](const libremidi::message& msg) {
+          cb.callback(cb.context, msg.timestamp, msg.bytes.data(), msg.size());
+        };
+      else if (c->on_midi1_raw_data.callback)
+      {
+        conf.on_raw_data = [cb = c->on_midi1_raw_data](std::span<const uint8_t> msg, int64_t ts) {
+          cb.callback(cb.context, ts, msg.data(), msg.size());
+        };
+      }
+      else
+      {
+        return -EINVAL;
+      }
+
+      try
+      {
+        auto ptr = new libremidi_midi_in_handle{
+            libremidi::midi_in{std::move(conf), std::move(api_conf)}};
+        *out = ptr;
+      }
+      catch (...)
+      {
+        return -EINVAL;
+      }
+      break;
+    }
+    case libremidi_midi_configuration::MIDI2:
+    case libremidi_midi_configuration::MIDI2_RAW: {
+      libremidi::ump_input_configuration conf;
+      libremidi::assign_error_callback(c->on_error, conf.on_error);
+      libremidi::assign_error_callback(c->on_warning, conf.on_warning);
+      conf.ignore_sensing = c->ignore_sensing;
+      conf.ignore_sysex = c->ignore_sysex;
+      conf.ignore_timing = c->ignore_timing;
+      conf.timestamps = c->timestamps;
+
+      if (c->get_timestamp.callback)
+      {
+        conf.get_timestamp
+            = [cb = c->get_timestamp](int64_t msg) { return cb.callback(cb.context, msg); };
+      }
+
+      if (c->on_midi2_message.callback)
+        conf.on_message = [cb = c->on_midi2_message](const libremidi::ump& msg) {
+          cb.callback(cb.context, msg.timestamp, msg.data, msg.size());
+        };
+      else if (c->on_midi2_raw_data.callback)
+      {
+        conf.on_raw_data = [cb = c->on_midi2_raw_data](std::span<const uint32_t> msg, int64_t ts) {
+          cb.callback(cb.context, ts, msg.data(), msg.size());
+        };
+      }
+      else
+      {
+        return -EINVAL;
+      }
+
+      try
+      {
+        auto ptr = new libremidi_midi_in_handle{
+            libremidi::midi_in{std::move(conf), std::move(api_conf)}};
+        *out = ptr;
+      }
+      catch (...)
+      {
+        return -EINVAL;
+      }
+      break;
+    }
+    default:
+      return -EINVAL;
+  }
+
+  // Open the port
+  auto ptr = *out;
+  if (!ptr)
+    return -EINVAL;
+
+  if (c->virtual_port)
+  {
+    if (auto ret = ptr->self.open_virtual_port(c->port_name); ret != stdx::error{})
+    {
+      *out = nullptr;
+      delete ptr;
+      return -EIO;
+    }
+  }
+  else
+  {
+    auto port = reinterpret_cast<const libremidi::input_port*>(c->in_port);
+    if (auto ret = ptr->self.open_port(*port); ret != stdx::error{})
+    {
+      *out = nullptr;
+      delete ptr;
+      return -EIO;
+    }
+  }
+
+  return 0;
+}
+int libremidi_midi_in_is_connected(const libremidi_midi_in_handle* in)
+{
+  if (!in)
+    return -EINVAL;
+  return in->self.is_port_connected() ? 1 : 0;
+}
+
+libremidi_timestamp libremidi_midi_in_absolute_timestamp(libremidi_midi_in_handle* in)
+{
+  if (!in)
+    return -EINVAL;
+  return in->self.absolute_timestamp();
+}
+
+int libremidi_midi_in_free(libremidi_midi_in_handle* ptr)
+{
+  delete ptr;
+  return 0;
+}
+
+int libremidi_midi_out_new(
+    const libremidi_midi_configuration* c, const libremidi_api_configuration* api,
+    libremidi_midi_out_handle** out)
+{
+  if (!out || !c)
+    return -EINVAL;
+  if (!c->virtual_port && !c->out_port)
+    return -EINVAL;
+
+  *out = nullptr;
+
+  auto api_conf = libremidi::midi_out_configuration_for(static_cast<libremidi::API>(api->api));
+
+  // Create the MIDI object
+  libremidi::output_configuration conf;
+  libremidi::assign_error_callback(c->on_error, conf.on_error);
+  libremidi::assign_error_callback(c->on_warning, conf.on_warning);
+  conf.timestamps = c->timestamps;
+  try
+  {
+    auto ptr
+        = new libremidi_midi_out_handle{libremidi::midi_out{std::move(conf), std::move(api_conf)}};
+    *out = ptr;
+  }
+  catch (...)
+  {
+    return -EINVAL;
+  }
+
+  // Open the port
+  auto ptr = *out;
+  if (!ptr)
+    return -EINVAL;
+
+  if (c->virtual_port)
+  {
+    if (auto ret = ptr->self.open_virtual_port(c->port_name); ret != stdx::error{})
+    {
+      *out = nullptr;
+      delete ptr;
+      return -EIO;
+    }
+  }
+  else
+  {
+    auto port = reinterpret_cast<const libremidi::output_port*>(c->in_port);
+    if (auto ret = ptr->self.open_port(*port); ret != stdx::error{})
+    {
+      *out = nullptr;
+      delete ptr;
+      return -EIO;
+    }
+  }
+  return 0;
+}
+
+int libremidi_midi_out_is_connected(const libremidi_midi_out_handle* out)
+{
+  if (!out)
+    return -EINVAL;
+  return out->self.is_port_connected() ? 1 : 0;
+}
+
+int libremidi_midi_out_send_message(
+    libremidi_midi_out_handle* out, const libremidi_midi1_symbol* msg, size_t sz)
+{
+  if (!out || !msg || sz > std::numeric_limits<int32_t>::max())
+    return -EINVAL;
+
+  auto res = out->self.send_message(msg, sz);
+  return res != stdx::error{} ? -EIO : 0;
+}
+
+int libremidi_midi_out_send_ump(libremidi_midi_out_handle* out, const libremidi_midi2_symbol* msg, size_t sz)
+{
+  if (!out || !msg || sz > std::numeric_limits<int32_t>::max())
+    return -EINVAL;
+
+  auto res = out->self.send_ump(msg, sz);
+  return res != stdx::error{} ? -EIO : 0;
+}
+
+int libremidi_midi_out_schedule_message(
+    libremidi_midi_out_handle* out, int64_t ts, const libremidi_midi1_symbol* msg, size_t sz)
+{
+  if (!out || !msg || sz > std::numeric_limits<int32_t>::max())
+    return -EINVAL;
+
+  auto res = out->self.schedule_message(ts, msg, sz);
+  return res != stdx::error{} ? -EIO : 0;
+}
+
+int libremidi_midi_out_schedule_ump(
+    libremidi_midi_out_handle* out, int64_t ts, const libremidi_midi2_symbol* msg, size_t sz)
+{
+  if (!out || !msg || sz > std::numeric_limits<int32_t>::max())
+    return -EINVAL;
+
+  auto res = out->self.schedule_ump(ts, msg, sz);
+  return res != stdx::error{} ? -EIO : 0;
+}
+
+int libremidi_midi_out_free(libremidi_midi_out_handle* ptr)
+{
+  delete ptr;
+  return 0;
+}
+}
diff --git a/vendor/libremidi/libremidi.cpp b/vendor/libremidi/libremidi.cpp
new file mode 100644
--- /dev/null
+++ b/vendor/libremidi/libremidi.cpp
@@ -0,0 +1,186 @@
+#if !defined(LIBREMIDI_HEADER_ONLY)
+  #include <libremidi/libremidi.hpp>
+#endif
+
+#include <libremidi/backends.hpp>
+#include <libremidi/detail/midi_api.hpp>
+
+#include <cmath>
+
+namespace libremidi
+{
+
+LIBREMIDI_INLINE
+std::string_view get_version() noexcept
+{
+  return LIBREMIDI_VERSION;
+}
+
+LIBREMIDI_INLINE std::string_view get_api_name(libremidi::API api)
+{
+  std::string_view ret;
+  midi_any::for_backend(api, [&](auto& b) { ret = b.name; });
+  return ret;
+}
+
+LIBREMIDI_INLINE std::string_view get_api_display_name(libremidi::API api)
+{
+  std::string_view ret;
+  midi_any::for_backend(api, [&](auto& b) { ret = b.display_name; });
+  return ret;
+}
+
+LIBREMIDI_INLINE libremidi::API get_compiled_api_by_name(std::string_view name)
+{
+  libremidi::API ret = libremidi::API::UNSPECIFIED;
+  midi_any::for_all_backends([&](auto& b) {
+    if (name == b.name)
+      ret = b.API;
+  });
+  return ret;
+}
+
+[[nodiscard]] LIBREMIDI_INLINE std::vector<libremidi::API> available_apis() noexcept
+{
+  std::vector<libremidi::API> apis;
+  midi1::for_all_backends([&](auto b) { apis.push_back(b.API); });
+  return apis;
+}
+
+[[nodiscard]] LIBREMIDI_INLINE std::vector<libremidi::API> available_ump_apis() noexcept
+{
+  std::vector<libremidi::API> apis;
+  midi2::for_all_backends([&](auto b) { apis.push_back(b.API); });
+  return apis;
+}
+LIBREMIDI_INLINE
+libremidi::API midi_api(const std::any& conf)
+{
+  libremidi::API ret = libremidi::API::UNSPECIFIED;
+  midi_any::for_all_backends([&]<typename T>(T) {
+    if (std::any_cast<typename T::midi_in_configuration>(&conf)
+        || std::any_cast<typename T::midi_out_configuration>(&conf)
+        || std::any_cast<typename T::midi_observer_configuration>(&conf))
+    {
+      ret = T::API;
+    }
+  });
+  return ret;
+}
+LIBREMIDI_INLINE
+std::any midi_in_configuration_for(libremidi::API api)
+{
+  std::any ret;
+  midi_any::for_backend(api, [&]<typename T>(T) {
+    using conf_type = typename T::midi_in_configuration;
+    ret = conf_type{};
+  });
+  return ret;
+}
+
+LIBREMIDI_INLINE
+std::any midi_out_configuration_for(libremidi::API api)
+{
+  std::any ret;
+  midi_any::for_backend(api, [&]<typename T>(T) {
+    using conf_type = typename T::midi_out_configuration;
+    ret = conf_type{};
+  });
+  return ret;
+}
+
+LIBREMIDI_INLINE
+std::any observer_configuration_for(libremidi::API api)
+{
+  std::any ret;
+  midi_any::for_backend(api, [&]<typename T>(T) {
+    using conf_type = typename T::midi_observer_configuration;
+    ret = conf_type{};
+  });
+  return ret;
+}
+
+LIBREMIDI_INLINE
+std::any midi_in_configuration_for(const libremidi::observer& obs)
+{
+  return midi_in_configuration_for(obs.get_current_api());
+}
+
+LIBREMIDI_INLINE
+std::any midi_out_configuration_for(const libremidi::observer& obs)
+{
+  // FIXME reuse context when meaningful, e.g. ALSA, JACK...
+  return midi_out_configuration_for(obs.get_current_api());
+}
+
+LIBREMIDI_INLINE
+std::optional<input_port> in_default_port(libremidi::API api) noexcept
+try
+{
+  libremidi::observer obs{{}, observer_configuration_for(api)};
+  if (auto ports = obs.get_input_ports(); !ports.empty())
+    return ports.front();
+  return std::nullopt;
+}
+catch (const std::exception& e)
+{
+  return std::nullopt;
+}
+
+LIBREMIDI_INLINE
+std::optional<output_port> out_default_port(libremidi::API api) noexcept
+try
+{
+  libremidi::observer obs{{}, observer_configuration_for(api)};
+  if (auto ports = obs.get_output_ports(); !ports.empty())
+    return ports.front();
+  return std::nullopt;
+}
+catch (const std::exception& e)
+{
+  return std::nullopt;
+}
+
+namespace midi1
+{
+LIBREMIDI_INLINE
+std::any in_default_configuration()
+{
+  return midi_in_configuration_for(default_api());
+}
+
+LIBREMIDI_INLINE
+std::any out_default_configuration()
+{
+  return midi_out_configuration_for(default_api());
+}
+
+LIBREMIDI_INLINE
+std::any observer_default_configuration()
+{
+  return observer_configuration_for(default_api());
+}
+}
+
+namespace midi2
+{
+LIBREMIDI_INLINE
+std::any in_default_configuration()
+{
+  return midi_in_configuration_for(default_api());
+}
+
+LIBREMIDI_INLINE
+std::any out_default_configuration()
+{
+  return midi_out_configuration_for(default_api());
+}
+
+LIBREMIDI_INLINE
+std::any observer_default_configuration()
+{
+  return observer_configuration_for(default_api());
+}
+}
+
+}
diff --git a/vendor/libremidi/midi_in.cpp b/vendor/libremidi/midi_in.cpp
new file mode 100644
--- /dev/null
+++ b/vendor/libremidi/midi_in.cpp
@@ -0,0 +1,373 @@
+#if !defined(LIBREMIDI_HEADER_ONLY)
+  #include <libremidi/libremidi.hpp>
+#endif
+
+#include <libremidi/backends.hpp>
+#include <libremidi/detail/midi_api.hpp>
+
+#include <cassert>
+
+namespace libremidi
+{
+
+static libremidi::ump_input_configuration
+convert_midi1_to_midi2_input_configuration(const input_configuration& base_conf) noexcept
+{
+  libremidi::ump_input_configuration c2;
+  c2.on_message = [cb = base_conf.on_message,
+                   converter = midi2_to_midi1{}](libremidi::ump&& msg) mutable -> void {
+    converter.convert(
+        msg.data, 1, msg.timestamp, [cb](const unsigned char* midi, std::size_t n, int64_t ts) {
+      cb(libremidi::message{{midi, midi + n}, ts});
+      return stdx::error{};
+    });
+  };
+  c2.get_timestamp = base_conf.get_timestamp;
+  c2.on_error = base_conf.on_error;
+  c2.on_warning = base_conf.on_warning;
+  c2.ignore_sysex = base_conf.ignore_sysex;
+  c2.ignore_timing = base_conf.ignore_timing;
+  c2.ignore_sensing = base_conf.ignore_sensing;
+  c2.timestamps = base_conf.timestamps;
+  return c2;
+}
+
+static libremidi::input_configuration
+convert_midi2_to_midi1_input_configuration(const ump_input_configuration& base_conf) noexcept
+{
+  libremidi::input_configuration c2;
+  c2.on_message = [cb = base_conf.on_message,
+                   converter = midi1_to_midi2{}](libremidi::message&& msg) mutable -> void {
+    converter.convert(
+        msg.bytes.data(), msg.bytes.size(), msg.timestamp,
+        [cb](const uint32_t* ump, std::size_t n, int64_t ts) {
+      if(n >= 4)
+      {
+        libremidi::ump u{ump[0],ump[1],ump[2],ump[3]};
+        u.timestamp = ts;
+        cb(std::move(u));
+      }
+      return stdx::error{};
+    });
+  };
+  c2.get_timestamp = base_conf.get_timestamp;
+  c2.on_error = base_conf.on_error;
+  c2.on_warning = base_conf.on_warning;
+  c2.ignore_sysex = base_conf.ignore_sysex;
+  c2.ignore_timing = base_conf.ignore_timing;
+  c2.ignore_sensing = base_conf.ignore_sensing;
+  c2.timestamps = base_conf.timestamps;
+  return c2;
+}
+
+static LIBREMIDI_INLINE std::unique_ptr<midi_in_api>
+make_midi_in(auto base_conf, std::any api_conf, auto backends)
+{
+  std::unique_ptr<midi_in_api> ptr;
+
+  assert(base_conf.on_message || base_conf.on_raw_data);
+
+  auto from_api = [&]<typename T>(T& /*backend*/) mutable {
+    if (auto conf = std::any_cast<typename T::midi_in_configuration>(&api_conf))
+    {
+      ptr = libremidi::make<typename T::midi_in>(std::move(base_conf), std::move(*conf));
+      return true;
+    }
+    return false;
+  };
+  std::apply([&](auto&&... b) { (from_api(b) || ...); }, backends);
+  return ptr;
+}
+
+/// MIDI 1 helpers
+static LIBREMIDI_INLINE std::unique_ptr<midi_in_api>
+make_midi1_in(const input_configuration& base_conf)
+{
+  for (const auto& api : available_apis())
+  {
+    try
+    {
+      auto impl
+          = make_midi_in(base_conf, midi_in_configuration_for(api), midi1::available_backends);
+      if (impl)
+        return impl;
+    }
+    catch (const std::exception& e)
+    {
+    }
+  }
+
+  // No MIDI 1 backend, try the MIDI 2 ones with a wrap:
+  {
+    auto c2 = convert_midi1_to_midi2_input_configuration(base_conf);
+    for (const auto& api : available_ump_apis())
+    {
+      try
+      {
+        auto impl = make_midi_in(c2, midi_in_configuration_for(api), midi2::available_backends);
+        if (impl)
+          return impl;
+      }
+      catch (const std::exception& e)
+      {
+      }
+    }
+  }
+
+  return std::make_unique<midi_in_dummy>(input_configuration{}, dummy_configuration{});
+}
+
+static LIBREMIDI_INLINE std::unique_ptr<midi_in_api>
+make_midi1_in(const input_configuration& base_conf, const std::any& api_conf, libremidi::API api)
+{
+  if (libremidi::is_midi1(api))
+  {
+    return make_midi_in(base_conf, api_conf, midi1::available_backends);
+  }
+  else if (libremidi::is_midi2(api))
+  {
+    auto c2 = convert_midi1_to_midi2_input_configuration(base_conf);
+    return make_midi_in(c2, api_conf, midi2::available_backends);
+  }
+  return {};
+}
+
+static LIBREMIDI_INLINE std::unique_ptr<midi_in_api>
+make_midi1_in(const input_configuration& base_conf, const std::any& api_conf)
+{
+  if (!api_conf.has_value())
+  {
+    return make_midi1_in(base_conf);
+  }
+  else if (auto api_p = std::any_cast<libremidi::API>(&api_conf))
+  {
+    if (*api_p == libremidi::API::UNSPECIFIED)
+    {
+      return make_midi1_in(base_conf);
+    }
+    else
+    {
+      return make_midi1_in(base_conf, midi_in_configuration_for(*api_p), *api_p);
+    }
+  }
+  else
+  {
+    if (auto api = libremidi::midi_api(api_conf); api == libremidi::API::UNSPECIFIED)
+      return {};
+    else
+      return make_midi1_in(base_conf, api_conf, libremidi::midi_api(api_conf));
+  }
+}
+
+/// MIDI 1 constructors
+LIBREMIDI_INLINE midi_in::midi_in(const input_configuration& base_conf) noexcept
+    : impl_{make_midi1_in(base_conf)}
+{
+}
+
+LIBREMIDI_INLINE
+midi_in::midi_in(input_configuration base_conf, std::any api_conf)
+    : impl_{make_midi1_in(base_conf, api_conf)}
+{
+  if (!impl_)
+  {
+    error_handler e;
+    e.libremidi_handle_error(base_conf, "Could not open midi in for the given api");
+    impl_ = std::make_unique<midi_in_dummy>(input_configuration{}, dummy_configuration{});
+  }
+}
+
+/// MIDI 2 helpers
+static LIBREMIDI_INLINE std::unique_ptr<midi_in_api>
+make_midi2_in(const ump_input_configuration& base_conf)
+{
+  for (const auto& api : available_ump_apis())
+  {
+    try
+    {
+      if (auto ret
+          = make_midi_in(base_conf, midi_in_configuration_for(api), midi2::available_backends))
+        return ret;
+    }
+    catch (const std::exception& e)
+    {
+    }
+  }
+
+  // No MIDI 2 backend, try the MIDI 1 ones with a wrap:
+  {
+    auto c2 = convert_midi2_to_midi1_input_configuration(base_conf);
+    for (const auto& api : available_apis())
+    {
+      try
+      {
+        if (auto ret = make_midi_in(c2, midi_in_configuration_for(api), midi1::available_backends))
+          return ret;
+      }
+      catch (const std::exception& e)
+      {
+      }
+    }
+  }
+
+  return {};
+}
+
+static LIBREMIDI_INLINE std::unique_ptr<midi_in_api> make_midi2_in(
+    const ump_input_configuration& base_conf, const std::any& api_conf, libremidi::API api)
+{
+  if (is_midi2(api))
+  {
+    return make_midi_in(base_conf, api_conf, midi2::available_backends);
+  }
+  else if (is_midi1(api))
+  {
+    auto c2 = convert_midi2_to_midi1_input_configuration(base_conf);
+    return make_midi_in(c2, api_conf, midi1::available_backends);
+  }
+
+  return {};
+}
+
+static LIBREMIDI_INLINE std::unique_ptr<midi_in_api>
+make_midi2_in(const ump_input_configuration& base_conf, const std::any& api_conf)
+{
+  if (!api_conf.has_value())
+  {
+    return make_midi2_in(base_conf);
+  }
+  else if (auto api_p = std::any_cast<libremidi::API>(&api_conf))
+  {
+    if (*api_p == libremidi::API::UNSPECIFIED)
+    {
+      return make_midi2_in(base_conf);
+    }
+    else
+    {
+      return make_midi2_in(base_conf, midi_in_configuration_for(*api_p), *api_p);
+    }
+  }
+  else
+  {
+    if (auto api = libremidi::midi_api(api_conf); api == libremidi::API::UNSPECIFIED)
+      return {};
+    else
+      return make_midi2_in(base_conf, api_conf, libremidi::midi_api(api_conf));
+  }
+}
+
+/// MIDI 2 constructors
+LIBREMIDI_INLINE midi_in::midi_in(ump_input_configuration base_conf) noexcept
+    : impl_{make_midi2_in(base_conf)}
+{
+}
+
+LIBREMIDI_INLINE
+midi_in::midi_in(ump_input_configuration base_conf, std::any api_conf)
+    : impl_{make_midi2_in(base_conf, api_conf)}
+{
+  if (!impl_)
+  {
+    error_handler e;
+    e.libremidi_handle_error(base_conf, "Could not open midi in for the given api");
+    impl_ = std::make_unique<midi_in_dummy>(input_configuration{}, dummy_configuration{});
+  }
+}
+
+LIBREMIDI_INLINE midi_in::~midi_in() = default;
+
+LIBREMIDI_INLINE midi_in::midi_in(midi_in&& other) noexcept
+    : impl_{std::move(other.impl_)}
+{
+  other.impl_
+      = std::make_unique<libremidi::midi_in_dummy>(input_configuration{}, dummy_configuration{});
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_in::set_port_name(std::string_view portName)
+{
+  if(impl_->is_port_open())
+    return impl_->set_port_name(portName);
+
+  return std::errc::not_connected;
+}
+
+LIBREMIDI_INLINE midi_in& midi_in::operator=(midi_in&& other) noexcept
+{
+  this->impl_ = std::move(other.impl_);
+  other.impl_
+      = std::make_unique<libremidi::midi_in_dummy>(input_configuration{}, dummy_configuration{});
+  return *this;
+}
+
+LIBREMIDI_INLINE
+libremidi::API midi_in::get_current_api() const noexcept
+{
+  return impl_->get_current_api();
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_in::open_port(const input_port& port, std::string_view portName)
+{
+  if (auto err = impl_->is_client_open(); err != stdx::error{})
+    return std::errc::not_connected;
+
+  if (impl_->is_port_open())
+    return std::errc::operation_not_supported;
+
+  auto ret = impl_->open_port(port, portName);
+  if (ret == stdx::error{})
+  {
+    impl_->connected_ = true;
+    impl_->port_open_ = true;
+  }
+  return ret;
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_in::open_virtual_port(std::string_view portName)
+{
+  if (auto err = impl_->is_client_open(); err != stdx::error{})
+    return std::errc::not_connected;
+
+  if (impl_->is_port_open())
+    return std::errc::operation_not_supported;
+
+  auto ret = impl_->open_virtual_port(portName);
+  if (ret == stdx::error{})
+    impl_->port_open_ = true;
+  return ret;
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_in::close_port()
+{
+  if (auto err = impl_->is_client_open(); err != stdx::error{})
+    return std::errc::not_connected;
+
+  auto ret = impl_->close_port();
+
+  impl_->connected_ = false;
+  impl_->port_open_ = false;
+
+  return ret;
+}
+
+LIBREMIDI_INLINE
+bool midi_in::is_port_open() const noexcept
+{
+  return impl_->is_port_open();
+}
+
+LIBREMIDI_INLINE
+bool midi_in::is_port_connected() const noexcept
+{
+  return impl_->is_port_connected();
+}
+
+LIBREMIDI_INLINE
+int64_t midi_in::absolute_timestamp() const noexcept
+{
+  return impl_->absolute_timestamp();
+}
+}
diff --git a/vendor/libremidi/midi_out.cpp b/vendor/libremidi/midi_out.cpp
new file mode 100644
--- /dev/null
+++ b/vendor/libremidi/midi_out.cpp
@@ -0,0 +1,326 @@
+#if !defined(LIBREMIDI_HEADER_ONLY)
+  #include <libremidi/libremidi.hpp>
+#endif
+
+#include <libremidi/backends.hpp>
+#include <libremidi/detail/midi_api.hpp>
+
+#include <array>
+#include <cassert>
+
+namespace libremidi
+{
+static LIBREMIDI_INLINE std::unique_ptr<midi_out_api>
+make_midi_out_impl(auto base_conf, std::any api_conf)
+{
+  std::unique_ptr<midi_out_api> ptr;
+  auto from_api = [&]<typename T>(T& /*backend*/) mutable {
+    if (auto conf = std::any_cast<typename T::midi_out_configuration>(&api_conf))
+    {
+      ptr = libremidi::make<typename T::midi_out>(std::move(base_conf), std::move(*conf));
+      return true;
+    }
+    return false;
+  };
+  std::apply([&](auto&&... b) { (from_api(b) || ...); }, midi1::available_backends);
+  std::apply([&](auto&&... b) { (from_api(b) || ...); }, midi2::available_backends);
+  return ptr;
+}
+
+static LIBREMIDI_INLINE std::unique_ptr<midi_out_api>
+make_midi_out(const output_configuration& base_conf)
+{
+  for (const auto& api : available_apis())
+  {
+    try
+    {
+      if (auto ret = make_midi_out_impl(base_conf, midi_out_configuration_for(api)))
+        return ret;
+    }
+    catch (const std::exception& e)
+    {
+    }
+  }
+
+  for (const auto& api : available_ump_apis())
+  {
+    try
+    {
+      if (auto ret = make_midi_out_impl(base_conf, midi_out_configuration_for(api)))
+        return ret;
+    }
+    catch (const std::exception& e)
+    {
+    }
+  }
+
+  return std::make_unique<midi_out_dummy>(output_configuration{}, dummy_configuration{});
+}
+
+static LIBREMIDI_INLINE std::unique_ptr<midi_out_api>
+make_midi_out(const output_configuration& base_conf, const std::any& api_conf)
+{
+  if (!api_conf.has_value())
+  {
+    return make_midi_out(base_conf);
+  }
+  else if (auto api_p = std::any_cast<libremidi::API>(&api_conf))
+  {
+    if (*api_p == libremidi::API::UNSPECIFIED)
+    {
+      return make_midi_out(base_conf);
+    }
+    else
+    {
+      return make_midi_out_impl(base_conf, midi_out_configuration_for(*api_p));
+    }
+  }
+  else
+  {
+    if (auto api = libremidi::midi_api(api_conf); api == libremidi::API::UNSPECIFIED)
+      return make_midi_out(base_conf);
+    else
+      return make_midi_out_impl(base_conf, api_conf);
+  }
+}
+
+LIBREMIDI_INLINE midi_out::midi_out(const output_configuration& base_conf) noexcept
+    : impl_{make_midi_out(base_conf)}
+{
+}
+
+LIBREMIDI_INLINE
+midi_out::midi_out(output_configuration base_conf, std::any api_conf)
+    : impl_{make_midi_out(base_conf, api_conf)}
+{
+  if (!impl_)
+  {
+    error_handler e;
+    e.libremidi_handle_error(base_conf, "Could not open midi out for the given api");
+    impl_ = std::make_unique<midi_out_dummy>(output_configuration{}, dummy_configuration{});
+  }
+}
+
+LIBREMIDI_INLINE midi_out::~midi_out() = default;
+
+LIBREMIDI_INLINE midi_out::midi_out(midi_out&& other) noexcept
+    : impl_{std::move(other.impl_)}
+{
+  other.impl_
+      = std::make_unique<libremidi::midi_out_dummy>(output_configuration{}, dummy_configuration{});
+}
+
+LIBREMIDI_INLINE midi_out& midi_out::operator=(midi_out&& other) noexcept
+{
+  this->impl_ = std::move(other.impl_);
+  other.impl_
+      = std::make_unique<libremidi::midi_out_dummy>(output_configuration{}, dummy_configuration{});
+  return *this;
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::set_port_name(std::string_view portName) const
+{
+  if(impl_->is_port_open())
+    return impl_->set_port_name(portName);
+
+  return std::errc::not_connected;
+}
+
+LIBREMIDI_INLINE
+libremidi::API midi_out::get_current_api() const noexcept
+{
+  return impl_->get_current_api();
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::open_port(const output_port& port, std::string_view portName) const
+{
+  if (auto err = impl_->is_client_open(); err != stdx::error{})
+    return std::errc::not_connected;
+
+  if (impl_->is_port_open())
+    return std::errc::operation_not_supported;
+
+  auto ret = impl_->open_port(port, portName);
+  if (ret == stdx::error{})
+  {
+    impl_->connected_ = true;
+    impl_->port_open_ = true;
+  }
+  return ret;
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::open_virtual_port(std::string_view portName) const
+{
+  if (auto err = impl_->is_client_open(); err != stdx::error{})
+    return std::errc::not_connected;
+
+  if (impl_->is_port_open())
+    return std::errc::operation_not_supported;
+
+  auto ret = impl_->open_virtual_port(portName);
+  if (ret == stdx::error{})
+    impl_->port_open_ = true;
+  return ret;
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::close_port() const
+{
+  if (auto err = impl_->is_client_open(); err != stdx::error{})
+    return std::errc::not_connected;
+
+  auto ret = impl_->close_port();
+  impl_->connected_ = false;
+  impl_->port_open_ = false;
+  return ret;
+}
+
+LIBREMIDI_INLINE
+bool midi_out::is_port_open() const noexcept
+{
+  return impl_->is_port_open();
+}
+
+LIBREMIDI_INLINE
+bool midi_out::is_port_connected() const noexcept
+{
+  return impl_->is_port_connected();
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_message(const libremidi::message& message) const
+{
+  return send_message(message.bytes.data(), message.bytes.size());
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_message(std::span<const unsigned char> message) const
+{
+  return send_message(message.data(), message.size());
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_message(unsigned char b0) const
+{
+  return send_message(&b0, 1);
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_message(unsigned char b0, unsigned char b1) const
+{
+  return send_message(std::to_array({b0, b1}));
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_message(unsigned char b0, unsigned char b1, unsigned char b2) const
+{
+  return send_message(std::to_array({b0, b1, b2}));
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_message(const unsigned char* message, size_t size) const
+{
+#if defined(LIBREMIDI_ASSERTIONS)
+  assert(size > 0);
+#endif
+
+  return impl_->send_message(message, size);
+}
+
+LIBREMIDI_INLINE
+int64_t midi_out::current_time()
+{
+  return impl_->current_time();
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::schedule_message(int64_t ts, const unsigned char* message, size_t size) const
+{
+#if defined(LIBREMIDI_ASSERTIONS)
+  assert(size > 0);
+#endif
+
+  return impl_->schedule_message(ts, message, size);
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_ump(const uint32_t* message, size_t size) const
+{
+#if defined(LIBREMIDI_ASSERTIONS)
+  assert(size > 0);
+  assert(size <= 4);
+#endif
+
+  return impl_->send_ump(message, size);
+}
+LIBREMIDI_INLINE
+stdx::error midi_out::send_ump(const libremidi::ump& message) const
+{
+  return send_ump(message.data, message.size());
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_ump(std::span<const uint32_t> message) const
+{
+  return send_ump(message.data(), message.size());
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_ump(uint32_t b0) const
+{
+  return send_ump(&b0, 1);
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_ump(uint32_t b0, uint32_t b1) const
+{
+  return send_ump(std::to_array({b0, b1}));
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_ump(uint32_t b0, uint32_t b1, uint32_t b2) const
+{
+  return send_ump(std::to_array({b0, b1, b2}));
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_ump(uint32_t b0, uint32_t b1, uint32_t b2, uint32_t b3) const
+{
+  return send_ump(std::to_array({b0, b1, b2, b3}));
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_ump(int32_t b0) const
+{
+  return send_ump(reinterpret_cast<uint32_t*>(&b0), 1);
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_ump(int64_t b01) const
+{
+  auto ptr = reinterpret_cast<uint32_t*>(&b01);
+  uint32_t msg[2]{ptr[1], ptr[0]};
+  return send_ump(msg, 2);
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::send_ump(uint64_t b01) const
+{
+  auto ptr = reinterpret_cast<uint32_t*>(&b01);
+  uint32_t msg[2]{ptr[1], ptr[0]};
+  return send_ump(msg, 2);
+}
+
+LIBREMIDI_INLINE
+stdx::error midi_out::schedule_ump(int64_t ts, const uint32_t* message, size_t size) const
+{
+#if defined(LIBREMIDI_ASSERTIONS)
+  assert(size > 0);
+#endif
+
+  return impl_->schedule_ump(ts, message, size);
+}
+}
diff --git a/vendor/libremidi/observer.cpp b/vendor/libremidi/observer.cpp
new file mode 100644
--- /dev/null
+++ b/vendor/libremidi/observer.cpp
@@ -0,0 +1,133 @@
+#if !defined(LIBREMIDI_HEADER_ONLY)
+  #include <libremidi/libremidi.hpp>
+#endif
+
+#include <libremidi/backends.hpp>
+#include <libremidi/detail/midi_api.hpp>
+
+namespace libremidi
+{
+
+static LIBREMIDI_INLINE std::unique_ptr<observer_api> make_observer(auto base_conf)
+{
+  for (const auto& api : available_apis())
+  {
+    try
+    {
+      if (auto impl_ = make_observer(base_conf, observer_configuration_for(api)))
+        return impl_;
+    }
+    catch (const std::exception& e)
+    {
+    }
+  }
+
+  for (const auto& api : available_ump_apis())
+  {
+    try
+    {
+      if (auto impl_ = make_observer(base_conf, observer_configuration_for(api)))
+        return impl_;
+    }
+    catch (const std::exception& e)
+    {
+    }
+  }
+
+  return std::make_unique<observer_dummy>(observer_configuration{}, dummy_configuration{});
+}
+
+LIBREMIDI_INLINE auto make_observer_impl(auto base_conf, std::any api_conf)
+{
+  std::unique_ptr<observer_api> ptr;
+  auto from_api = [&]<typename T>(T& /*backend*/) mutable {
+    if (auto conf = std::any_cast<typename T::midi_observer_configuration>(&api_conf))
+    {
+      ptr = libremidi::make<typename T::midi_observer>(std::move(base_conf), std::move(*conf));
+      return true;
+    }
+    return false;
+  };
+  std::apply([&](auto&&... b) { (from_api(b) || ...); }, midi1::available_backends);
+  std::apply([&](auto&&... b) { (from_api(b) || ...); }, midi2::available_backends);
+  return ptr;
+}
+
+LIBREMIDI_INLINE std::unique_ptr<observer_api> make_observer(auto base_conf, std::any api_conf)
+{
+  if (!api_conf.has_value())
+  {
+    return make_observer(base_conf);
+  }
+  else if (auto api_p = std::any_cast<libremidi::API>(&api_conf))
+  {
+    if (*api_p == libremidi::API::UNSPECIFIED)
+    {
+      return make_observer(base_conf);
+    }
+    else
+    {
+      return make_observer_impl(base_conf, observer_configuration_for(*api_p));
+    }
+  }
+  else
+  {
+    if (auto api = libremidi::midi_api(api_conf); api == libremidi::API::UNSPECIFIED)
+      return {};
+    else
+      return make_observer_impl(base_conf, api_conf);
+  }
+}
+
+LIBREMIDI_INLINE observer::observer(const observer_configuration& base_conf) noexcept
+    : impl_{make_observer(base_conf)}
+{
+}
+
+LIBREMIDI_INLINE observer::observer(observer_configuration base_conf, std::any api_conf)
+    : impl_{make_observer(base_conf, api_conf)}
+{
+  if (!impl_)
+  {
+    error_handler e;
+    e.libremidi_handle_error(base_conf, "Could not open observer for the given api");
+    impl_ = std::make_unique<observer_dummy>(observer_configuration{}, dummy_configuration{});
+  }
+}
+
+LIBREMIDI_INLINE observer::observer(observer&& other) noexcept
+    : impl_{std::move(other.impl_)}
+{
+  other.impl_ = std::make_unique<libremidi::observer_dummy>(
+      observer_configuration{}, dummy_configuration{});
+}
+
+LIBREMIDI_INLINE observer& observer::operator=(observer&& other) noexcept
+{
+  this->impl_ = std::move(other.impl_);
+  other.impl_ = std::make_unique<libremidi::observer_dummy>(
+      observer_configuration{}, dummy_configuration{});
+  return *this;
+}
+
+LIBREMIDI_INLINE
+observer::~observer() = default;
+
+LIBREMIDI_INLINE
+libremidi::API observer::get_current_api() const noexcept
+{
+  return impl_->get_current_api();
+}
+
+LIBREMIDI_INLINE
+std::vector<libremidi::input_port> observer::get_input_ports() const noexcept
+{
+  return impl_->get_input_ports();
+}
+
+LIBREMIDI_INLINE
+std::vector<libremidi::output_port> observer::get_output_ports() const noexcept
+{
+  return impl_->get_output_ports();
+}
+}
diff --git a/vendor/libremidi/reader.cpp b/vendor/libremidi/reader.cpp
new file mode 100644
--- /dev/null
+++ b/vendor/libremidi/reader.cpp
@@ -0,0 +1,752 @@
+/*
+Copyright (c) 2015, Dimitri Diakopoulos All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+#if !defined(LIBREMIDI_HEADER_ONLY)
+  #include <libremidi/reader.hpp>
+#endif
+#include <libremidi/message.hpp>
+
+#include <algorithm>
+#include <iostream>
+
+// File Parsing Validation Todo:
+// ==============================
+// [] Bad file name
+// [] Bad header
+// [] Unknown header type
+// [] Bad header size
+// [] Bad type
+// [] Bad tmecode
+// [] Header too short
+// [] Track too short
+// [] Event too short
+// ==============================
+
+#if defined(__LIBREMIDI_DEBUG__)
+std::ostream& operator<<(std::ostream& s, const libremidi::message& m)
+{
+  s << "[ MIDI: ";
+  for (auto b : m)
+    s << (unsigned int)b << ' ';
+  s << "]\n";
+  return s;
+}
+#endif
+
+namespace libremidi
+{
+namespace util
+{
+struct no_validator
+{
+  static inline bool validate_track([[maybe_unused]] const midi_track& track) { return true; }
+};
+
+struct validator
+{
+  static inline bool validate_track(const midi_track& track)
+  {
+    if (track.empty())
+    {
+#if defined(__LIBREMIDI_DEBUG__)
+      std::cerr << "empty track" << std::endl;
+#endif
+      return false;
+    }
+
+    // Ensure that there is a unique EOT at the end of the track
+    auto it = std::find_if(track.begin(), track.end(), [](const libremidi::track_event& msg) {
+      static const auto eot = meta_events::end_of_track();
+      return msg.m.bytes == eot.bytes;
+    });
+
+    if (it == track.end())
+    {
+#if defined(__LIBREMIDI_DEBUG__)
+      std::cerr << "track has no END OF TRACK" << std::endl;
+#endif
+      return false;
+    }
+
+    if (&it->m != &track.back().m)
+    {
+#if defined(__LIBREMIDI_DEBUG__)
+      std::cerr << std::distance(it, track.end());
+      std::cerr << "track does not end with END OF TRACK" << std::endl;
+#endif
+      return false;
+    }
+
+    return true;
+  }
+};
+
+// Used when we know that we have enough space
+struct read_unchecked
+{
+  // Read a MIDI-style variable-length integer (big-endian value in groups of 7 bits,
+  // with top bit set to signify that another byte follows).
+  static inline void ensure_size(
+      [[maybe_unused]] const uint8_t* begin, [[maybe_unused]] const uint8_t* end,
+      [[maybe_unused]] int64_t needed)
+  {
+  }
+
+  static inline uint32_t
+  read_variable_length(uint8_t const*& data, [[maybe_unused]] uint8_t const* end)
+  {
+    uint32_t result = 0;
+    while (true)
+    {
+      uint8_t b = *data++;
+      if (b & 0x80)
+      {
+        result += (b & 0x7F);
+        result <<= 7;
+      }
+      else
+      {
+        return result + b; // b is the last byte
+      }
+    }
+  }
+
+  static inline void read_bytes(
+      midi_bytes& buffer, uint8_t const*& data, [[maybe_unused]] const uint8_t* end,
+      const std::size_t num)
+  {
+    buffer.reserve(buffer.size() + num);
+    for (std::size_t i = 0; i < num; ++i)
+      buffer.push_back(*data++);
+  }
+
+  static inline void read_bytes(
+      midi_bytes& buffer, uint8_t const*& data, [[maybe_unused]] const uint8_t* end, const int num)
+  {
+    read_bytes(buffer, data, end, static_cast<std::size_t>(num));
+  }
+
+  static inline uint16_t read_uint16_be(uint8_t const*& data, [[maybe_unused]] const uint8_t* end)
+  {
+    uint16_t result = *data++ << 8u;
+    result += *data++;
+    return result;
+  }
+
+  static inline uint32_t read_uint24_be(uint8_t const*& data, [[maybe_unused]] const uint8_t* end)
+  {
+    uint32_t result = *data++ << 16u;
+    result += static_cast<uint32_t>(*data++ << 8u);
+    result += *data++;
+    return result;
+  }
+
+  static inline uint32_t read_uint32_be(uint8_t const*& data, [[maybe_unused]] const uint8_t* end)
+  {
+    uint32_t result = *data++ << 24u;
+    result += static_cast<uint32_t>(*data++ << 16u);
+    result += static_cast<uint32_t>(*data++ << 8u);
+    result += *data++;
+    return result;
+  }
+};
+
+// Used when we do not know if we have enough bytes and have to check before reading
+struct read_checked
+{
+  // Read a MIDI-style variable-length integer (big-endian value in groups of 7 bits,
+  // with top bit set to signify that another byte follows).
+  static inline void
+  ensure_size(const uint8_t* begin, const uint8_t* end, const std::size_t needed)
+  {
+    if (const auto available = static_cast<std::size_t>(end - begin); available < needed)
+      throw std::runtime_error("MIDI reader: not enough data to process");
+  }
+
+  static inline std::size_t read_variable_length(uint8_t const*& data, uint8_t const* end)
+  {
+    std::size_t result = 0;
+    while (true)
+    {
+      ensure_size(data, end, 1);
+      uint8_t b = *data++;
+      if (b & 0x80)
+      {
+        result += (b & 0x7F);
+        result <<= 7;
+      }
+      else
+      {
+        return result + b; // b is the last byte
+      }
+    }
+  }
+
+  static inline void
+  read_bytes(midi_bytes& buffer, uint8_t const*& data, uint8_t const* end, const std::size_t num)
+  {
+    ensure_size(data, end, num);
+    read_unchecked::read_bytes(buffer, data, end, num);
+  }
+
+  static inline uint16_t read_uint16_be(uint8_t const*& data, uint8_t const* end)
+  {
+    ensure_size(data, end, 2);
+    return read_unchecked::read_uint16_be(data, end);
+  }
+
+  static inline uint32_t read_uint24_be(uint8_t const*& data, uint8_t const* end)
+  {
+    ensure_size(data, end, 3);
+    return read_unchecked::read_uint24_be(data, end);
+  }
+
+  static inline uint32_t read_uint32_be(uint8_t const*& data, uint8_t const* end)
+  {
+    ensure_size(data, end, 4);
+    return read_unchecked::read_uint32_be(data, end);
+  }
+};
+}
+
+#if defined(LIBREMIDI_UNCHECKED)
+using byte_reader = util::read_unchecked;
+#else
+using byte_reader = util::read_checked;
+#endif
+
+#if defined(LIBREMIDI_UNVALIDATED)
+using validator = util::no_validator;
+#else
+using validator = util::validator;
+#endif
+
+LIBREMIDI_INLINE
+track_event parse_event(
+    int tick, int track, const uint8_t*& dataStart, const uint8_t* dataEnd,
+    message_type lastEventTypeByte)
+{
+  byte_reader::ensure_size(dataStart, dataEnd, 1);
+  auto type = static_cast<message_type>(*dataStart++);
+
+  track_event event{tick, track, message{}};
+
+  if ((static_cast<uint8_t>(type) & 0xF0) == 0xF0)
+  {
+    // Meta event
+    if (static_cast<uint8_t>(type) == 0xFF)
+    {
+      byte_reader::ensure_size(dataStart, dataEnd, 1);
+      auto subtype = static_cast<meta_event_type>(*dataStart++);
+
+      event.m.bytes.reserve(3);
+      event.m.bytes.push_back(static_cast<uint8_t>(type));
+      event.m.bytes.push_back(static_cast<uint8_t>(subtype));
+
+      uint32_t length = 0;
+      // Here we read the meta-event length manually, as this way we can also put it into
+      // event.m.bytes
+      while (true)
+      {
+        byte_reader::ensure_size(dataStart, dataEnd, 1);
+        uint8_t b = *dataStart++;
+        event.m.bytes.push_back(b);
+        if (b & 0x80)
+        {
+          const uint8_t byte = (b & 0x7F);
+
+          length += byte;
+          length <<= 7;
+        }
+        else
+        {
+          length += b; // b is the last byte
+          break;
+        }
+      }
+
+      switch (subtype)
+      {
+        case meta_event_type::SEQUENCE_NUMBER: {
+          switch (length)
+          {
+            case 0:
+              return event;
+            case 2:
+              byte_reader::read_bytes(event.m.bytes, dataStart, dataEnd, 2);
+              return event;
+            default:
+              throw std::invalid_argument("Expected length for SEQUENCE_NUMBER event is 0 or 2");
+          }
+        }
+        case meta_event_type::TEXT:
+        case meta_event_type::COPYRIGHT:
+        case meta_event_type::TRACK_NAME:
+        case meta_event_type::INSTRUMENT:
+        case meta_event_type::LYRIC:
+        case meta_event_type::MARKER:
+        case meta_event_type::CUE:
+        case meta_event_type::PATCH_NAME:
+        case meta_event_type::DEVICE_NAME: {
+          byte_reader::read_bytes(event.m.bytes, dataStart, dataEnd, length);
+          return event;
+        }
+
+        case meta_event_type::END_OF_TRACK: {
+          if (length != 0)
+            throw std::invalid_argument("Expected length for END_OF_TRACK event is 0");
+          return event;
+        }
+        case meta_event_type::TEMPO_CHANGE: {
+          if (length != 3)
+            throw std::invalid_argument("Expected length for TEMPO_CHANGE event is 3");
+          // event.m.bytes[3] = read_uint24_be(dataStart); // @dimitri TOFIX
+          byte_reader::read_bytes(event.m.bytes, dataStart, dataEnd, length);
+          return event;
+        }
+        case meta_event_type::SMPTE_OFFSET: {
+          if (length != 5)
+            throw std::invalid_argument("Expected length for SMPTE_OFFSET event is 5");
+          byte_reader::read_bytes(event.m.bytes, dataStart, dataEnd, length);
+          auto& b = event.m.bytes;
+
+          uint8_t format = (b[3] & 0b01100000) >> 5;
+          uint8_t h = (b[3] & 0b00011111);
+
+          if (format > 3)
+            throw std::invalid_argument("SMPTE_OFFSET has unknown format");
+
+          int max = 0;
+          switch (format)
+          {
+            case 0: // 24
+              max = 24;
+              break;
+            case 1: // 25
+              max = 25;
+              break;
+            case 2: // 29
+              max = 29;
+              break;
+            case 3: // 30
+              max = 30;
+              break;
+            default:
+              break;
+          }
+
+          if (h >= 24 || b[4] >= 60 || b[5] >= 60 || b[6] >= max || b[7] >= 100)
+            throw std::invalid_argument("SMPTE_OFFSET is out-of-23:59:59:xx:99 bounds");
+          return event;
+        }
+        case meta_event_type::TIME_SIGNATURE: {
+          if (length != 4)
+            throw std::invalid_argument("Expected length for TIME_SIGNATURE event is 4");
+          byte_reader::read_bytes(event.m.bytes, dataStart, dataEnd, length);
+          return event;
+        }
+        case meta_event_type::KEY_SIGNATURE: {
+          if (length != 2)
+            throw std::invalid_argument("Expected length for KEY_SIGNATURE event is 2");
+          byte_reader::read_bytes(event.m.bytes, dataStart, dataEnd, length);
+          auto k = static_cast<int8_t>(event.m[3]);
+          if (k < -7 || k > 7)
+            throw std::invalid_argument("Invalid KEY_SIGNATURE");
+          if (event.m[4] > 1)
+            throw std::invalid_argument("Invalid KEY_SIGNATURE");
+          return event;
+        }
+        case meta_event_type::PROPRIETARY: {
+          byte_reader::read_bytes(event.m.bytes, dataStart, dataEnd, length);
+          return event;
+        }
+        case meta_event_type::CHANNEL_PREFIX: {
+          if (length != 1)
+            throw std::invalid_argument("Expected length for CHANNEL_PREFIX event is 1");
+          byte_reader::read_bytes(event.m.bytes, dataStart, dataEnd, length);
+          return event;
+        }
+        case meta_event_type::MIDI_PORT: {
+          if (length != 1)
+            throw std::invalid_argument("Expected length for MIDI_PORT event is 1");
+          byte_reader::read_bytes(event.m.bytes, dataStart, dataEnd, length);
+          return event;
+        }
+        case meta_event_type::UNKNOWN:
+        default: {
+          // Unknown events?
+          byte_reader::read_bytes(event.m.bytes, dataStart, dataEnd, length);
+          return event;
+        }
+      }
+    }
+
+    else if (type == message_type::SYSTEM_EXCLUSIVE)
+    {
+      const auto length = byte_reader::read_variable_length(dataStart, dataEnd);
+      event.m.bytes = {static_cast<uint8_t>(type)};
+      byte_reader::read_bytes(event.m.bytes, dataStart, dataEnd, length);
+      return event;
+    }
+
+    else if (type == message_type::EOX)
+    {
+      const auto length = byte_reader::read_variable_length(dataStart, dataEnd);
+      byte_reader::read_bytes(event.m.bytes, dataStart, dataEnd, length);
+      return event;
+    }
+    else
+    {
+      throw std::runtime_error("Unrecognised MIDI event type byte");
+    }
+  }
+
+  // Channel events
+  else
+  {
+    event.m.bytes.clear();
+
+    // Running status...
+    if ((static_cast<uint8_t>(type) & 0x80) == 0)
+    {
+      // Reuse lastEventTypeByte as the event type.
+      // eventTypeByte is actually the first parameter
+      event.m.bytes.push_back(static_cast<uint8_t>(lastEventTypeByte));
+      event.m.bytes.push_back(static_cast<uint8_t>(type));
+      type = lastEventTypeByte;
+    }
+    else
+    {
+      event.m.bytes.push_back(static_cast<uint8_t>(type));
+
+      byte_reader::ensure_size(dataStart, dataEnd, 1);
+      event.m.bytes.push_back(static_cast<uint8_t>(*dataStart++));
+      lastEventTypeByte = type;
+    }
+
+    static constexpr auto validate = [](midi_bytes& b) {
+      if (b[1] < 128 && b[2] < 128)
+        return true;
+      throw std::invalid_argument("MIDI message has arguments > 127");
+    };
+
+    switch (static_cast<message_type>(static_cast<uint8_t>(type) & 0xF0))
+    {
+      case message_type::NOTE_OFF:
+      case message_type::NOTE_ON:
+      case message_type::POLY_PRESSURE:
+      case message_type::CONTROL_CHANGE:
+        byte_reader::ensure_size(dataStart, dataEnd, 1);
+        event.m.bytes.push_back(*dataStart++);
+        validate(event.m.bytes);
+        return event;
+      case message_type::PROGRAM_CHANGE:
+        if (event.m.bytes[1] >= 128)
+          throw std::invalid_argument("MIDI PC has arguments > 127");
+        return event;
+      case message_type::AFTERTOUCH:
+        if (event.m.bytes[1] >= 128)
+          throw std::invalid_argument("MIDI Atertouch has arguments > 127");
+        return event;
+      case message_type::PITCH_BEND:
+        byte_reader::ensure_size(dataStart, dataEnd, 1);
+        event.m.bytes.push_back(*dataStart++);
+        validate(event.m.bytes);
+        return event;
+
+      case message_type::TIME_CODE:
+        throw std::runtime_error("Unsupported MIDI event type TIME_CODE");
+      case message_type::SONG_POS_POINTER:
+        throw std::runtime_error("Unsupported MIDI event type SONG_POS_POINTER");
+      case message_type::SONG_SELECT:
+        throw std::runtime_error("Unsupported MIDI event type SONG_SELECT");
+      case message_type::RESERVED1:
+        throw std::runtime_error("Unsupported MIDI event type RESERVED1");
+      case message_type::RESERVED2:
+        throw std::runtime_error("Unsupported MIDI event type RESERVED2");
+      case message_type::TUNE_REQUEST:
+        throw std::runtime_error("Unsupported MIDI event type TUNE_REQUEST");
+      case message_type::EOX:
+        throw std::runtime_error("Unsupported MIDI event type EOX");
+        // System Realtime Messages :
+      case message_type::TIME_CLOCK:
+        throw std::runtime_error("Unsupported MIDI event type TIME_CLOCK");
+      case message_type::RESERVED3:
+        throw std::runtime_error("Unsupported MIDI event type RESERVED3");
+      case message_type::START:
+        throw std::runtime_error("Unsupported MIDI event type START");
+      case message_type::CONTINUE:
+        throw std::runtime_error("Unsupported MIDI event type CONTINUE");
+      case message_type::STOP:
+        throw std::runtime_error("Unsupported MIDI event type STOP");
+      case message_type::RESERVED4:
+        throw std::runtime_error("Unsupported MIDI event type RESERVED4");
+      case message_type::ACTIVE_SENSING:
+        throw std::runtime_error("Unsupported MIDI event type ACTIVE_SENSING");
+      case message_type::SYSTEM_RESET:
+        throw std::runtime_error("Unsupported MIDI event type SYSTEM_RESET");
+      case message_type::INVALID:
+        throw std::runtime_error("Unsupported MIDI event type INVALID");
+      case message_type::SYSTEM_EXCLUSIVE:
+        throw std::runtime_error("Unsupported MIDI event type SYSTEM_EXCLUSIVE");
+      default:
+        throw std::runtime_error("Unsupported MIDI event type");
+    }
+  }
+}
+
+LIBREMIDI_INLINE
+reader::reader(bool useAbsolute)
+    : ticksPerBeat(480)
+    , startingTempo(120)
+    , useAbsoluteTicks(useAbsolute)
+{
+}
+
+LIBREMIDI_INLINE
+reader::~reader() { }
+
+constexpr int str_to_headerid(const char* str)
+{
+  return str[0] << 24 | str[1] << 16 | str[2] << 8 | str[3];
+}
+
+LIBREMIDI_INLINE
+auto reader::parse(const uint8_t* dataPtr, std::size_t size) noexcept -> parse_result
+try
+{
+  using namespace libremidi::util;
+
+  tracks.clear();
+
+  if (size == 0)
+  {
+#if defined(__LIBREMIDI_DEBUG__)
+    std::cerr << "empty buffer passed to parse." << std::endl;
+#endif
+    return parse_result::invalid;
+  }
+
+  const uint8_t* const dataEnd = dataPtr + size;
+
+  uint32_t headerId = read_checked::read_uint32_be(dataPtr, dataEnd);
+  uint32_t headerLength = read_checked::read_uint32_be(dataPtr, dataEnd);
+
+  if (static_cast<int>(headerId) != str_to_headerid("MThd") || headerLength != 6)
+  {
+#if defined(__LIBREMIDI_DEBUG__)
+    std::cerr << "couldn't parse header" << std::endl;
+#endif
+    return parse_result::invalid;
+  }
+
+  format = read_checked::read_uint16_be(
+      dataPtr, dataEnd); //@tofix format type -> save for later eventually
+  if (format > 2)
+  {
+#if defined(__LIBREMIDI_DEBUG__)
+    std::cerr << "unknown format" << std::endl;
+#endif
+    return parse_result::invalid;
+  }
+  int trackCount = read_checked::read_uint16_be(dataPtr, dataEnd);
+  uint16_t timeDivision = read_checked::read_uint16_be(dataPtr, dataEnd);
+
+  // CBB: deal with the SMPTE style time coding
+  // timeDivision is described here http://www.sonicspot.com/guide/midifiles.html
+  if (timeDivision & 0x8000)
+  {
+#if defined(__LIBREMIDI_DEBUG__)
+    std::cerr << "found SMPTE time frames (unsupported)" << std::endl;
+    int fps = (timeDivision >> 16) & 0x7f;
+    if (fps != -30 && fps != -29 && fps != -25 && fps != -24)
+      return parse_result::invalid;
+    int ticksPerFrame = timeDivision & 0xff;
+#endif
+    // given beats per second, timeDivision should be derivable.
+    return parse_result::invalid;
+  }
+
+  startingTempo = 120.0f;             // midi default
+  ticksPerBeat = float(timeDivision); // ticks per beat (a beat is defined as a quarter note)
+
+  parse_result result = parse_result::validated;
+
+  for (int i = 0; i < trackCount; ++i)
+  {
+    midi_track track;
+
+    headerId = read_checked::read_uint32_be(dataPtr, dataEnd);
+    headerLength = read_checked::read_uint32_be(dataPtr, dataEnd);
+
+    if (headerId != str_to_headerid("MTrk"))
+    {
+#if defined(__LIBREMIDI_DEBUG__)
+      std::cerr << "couldn't find track header" << std::endl;
+#endif
+      return parse_result::incomplete;
+    }
+
+    int64_t available = dataEnd - dataPtr;
+    if (available < headerLength)
+    {
+#if defined(__LIBREMIDI_DEBUG__)
+      std::cerr << "not enough data available" << std::endl;
+#endif
+      return parse_result::incomplete;
+    }
+
+    track.reserve(headerLength / 3);
+
+    const uint8_t* const trackEnd = dataPtr + headerLength;
+
+    auto runningEvent = message_type::INVALID;
+
+    std::size_t tickCount = 0;
+
+    while (dataPtr < trackEnd)
+    {
+      const auto tick = read_checked::read_variable_length(dataPtr, trackEnd);
+      if (useAbsoluteTicks)
+      {
+        tickCount += tick;
+      }
+      else
+      {
+        tickCount = tick;
+      }
+
+      try
+      {
+        track_event ev
+            = parse_event(static_cast<int>(tickCount), i, dataPtr, trackEnd, runningEvent);
+        if (!ev.m.empty())
+        {
+          if (!ev.m.is_meta_event())
+          {
+            runningEvent = static_cast<message_type>(ev.m.bytes[0]);
+          }
+        }
+        else
+        {
+#if defined(__LIBREMIDI_DEBUG__)
+          std::cerr << "could not read event" << std::endl;
+#endif
+          dataPtr = trackEnd;
+          result = parse_result::incomplete;
+          continue;
+        }
+
+        track.push_back(std::move(ev));
+      }
+      catch (const std::exception& e)
+      {
+#if defined(__LIBREMIDI_DEBUG__)
+        std::cerr << "" << e.what() << std::endl;
+#endif
+        dataPtr = trackEnd;
+        result = parse_result::incomplete;
+        continue;
+      }
+    }
+
+    if (result == parse_result::validated)
+    {
+      if (!validator::validate_track(track))
+      {
+        result = parse_result::complete;
+      }
+    }
+    tracks.push_back(std::move(track));
+  }
+
+  if (result == parse_result::validated)
+  {
+    if (dataPtr != dataEnd)
+    {
+#if defined(__LIBREMIDI_DEBUG__)
+      std::cerr << "midifile has junk at end: " << std::intptr_t(dataEnd - dataPtr) << std::endl;
+#endif
+      result = parse_result::complete;
+    }
+  }
+  return result;
+}
+catch (const std::exception& e)
+{
+#if defined(__LIBREMIDI_DEBUG__)
+  std::cerr << "" << e.what() << std::endl;
+#endif
+  return parse_result::invalid;
+}
+
+// In ticks
+LIBREMIDI_INLINE
+double reader::get_end_time() const noexcept
+{
+  if (useAbsoluteTicks)
+  {
+    double totalLength = 0.;
+    for (const auto& t : tracks)
+    {
+      if (!t.empty())
+      {
+        const auto& last_event = t.back();
+        if (last_event.tick > totalLength)
+          totalLength = last_event.tick;
+      }
+    }
+    return totalLength;
+  }
+  else
+  {
+    double totalLength = 0.;
+    for (const auto& t : tracks)
+    {
+      double trackLength = 0.;
+      for (const auto& e : t)
+        trackLength += e.tick;
+
+      if (trackLength > totalLength)
+        totalLength = trackLength;
+    }
+    return totalLength;
+  }
+}
+
+LIBREMIDI_INLINE
+auto reader::parse(const std::vector<uint8_t>& buffer) noexcept -> parse_result
+{
+  return parse(buffer.data(), buffer.size());
+}
+
+#if defined(LIBREMIDI_HAS_SPAN)
+LIBREMIDI_INLINE
+auto reader::parse(std::span<uint8_t> buffer) noexcept -> parse_result
+{
+  return parse(buffer.data(), buffer.size());
+}
+#endif
+}
diff --git a/vendor/libremidi/writer.cpp b/vendor/libremidi/writer.cpp
new file mode 100644
--- /dev/null
+++ b/vendor/libremidi/writer.cpp
@@ -0,0 +1,213 @@
+/*
+Copyright (c) 2015, Dimitri Diakopoulos All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+#if !defined(LIBREMIDI_HEADER_ONLY)
+  #include <libremidi/writer.hpp>
+#endif
+#include <algorithm>
+#include <bit>
+#include <ostream>
+#include <string>
+
+#if !defined(__cpp_lib_bit_cast)
+namespace std
+{
+template <typename R, typename T>
+[[nodiscard]]
+constexpr R bit_cast(const T& v) noexcept
+{
+  union
+  {
+    R res;
+    T init;
+  } u{.init = v};
+  return u.res;
+}
+}
+#endif
+namespace libremidi
+{
+namespace util
+{
+template <typename T>
+static LIBREMIDI_INLINE std::ostream& write_be(std::ostream& out, T value)
+{
+  static_assert(
+      std::endian::native == std::endian::big || std::endian::native == std::endian::little);
+  if constexpr (std::endian::native == std::endian::big)
+  {
+    out << value;
+  }
+  else
+  {
+    static constexpr auto N = sizeof(value);
+    struct storage
+    {
+      uint8_t bytes[N];
+    };
+    auto data = std::bit_cast<storage>(value);
+    std::reverse(data.bytes, data.bytes + N);
+    out.write(reinterpret_cast<const char*>(data.bytes), static_cast<std::streamsize>(N));
+  }
+  return out;
+}
+
+// Write a number to the midifile
+// as a variable length value which segments a file into 7-bit
+// values.  Maximum size of aValue is 0x7fffffff
+static LIBREMIDI_INLINE void write_variable_length(uint32_t aValue, std::vector<uint8_t>& outdata)
+{
+  uint8_t bytes[5] = {0};
+
+  bytes[0] = static_cast<uint8_t>((aValue >> 28) & 0x7F); // most significant 5 bits
+  bytes[1] = static_cast<uint8_t>((aValue >> 21) & 0x7F); // next largest 7 bits
+  bytes[2] = static_cast<uint8_t>((aValue >> 14) & 0x7F);
+  bytes[3] = static_cast<uint8_t>((aValue >> 7) & 0x7F);
+  bytes[4] = static_cast<uint8_t>((aValue) & 0x7F); // least significant 7 bits
+
+  int start = 0;
+  while (start < 5 && bytes[start] == 0)
+    start++;
+
+  for (int i = start; i < 4; i++)
+  {
+    bytes[i] = bytes[i] | 0x80;
+    outdata.push_back(bytes[i]);
+  }
+  outdata.push_back(bytes[4]);
+}
+
+static LIBREMIDI_INLINE void
+add_event_track_count_check(std::vector<midi_track>& tracks, int track)
+{
+  if (track < 0)
+    throw std::out_of_range("Refusing to add an event to track " + std::to_string(track) + ".");
+  if (track > 65535)
+    throw std::out_of_range(
+        "Refusing to add an event to track " + std::to_string(track)
+        + " ; change add_event_track_count_check in libremidi writer.cpp to increase the limit.");
+
+  while (tracks.size() < static_cast<std::size_t>(track + 1)) // NOLINT(*-misplaced-widening-cast)
+    tracks.emplace_back();
+}
+}
+
+LIBREMIDI_INLINE
+void writer::add_event(const int tick, const int track, const message& m)
+{
+  util::add_event_track_count_check(tracks, track);
+
+  tracks[static_cast<std::size_t>(track)].push_back({tick, track, m});
+}
+
+LIBREMIDI_INLINE
+void writer::add_event(int track, const track_event& m)
+{
+  util::add_event_track_count_check(tracks, track);
+
+  tracks[static_cast<std::size_t>(track)].push_back(m);
+}
+
+LIBREMIDI_INLINE
+void writer::add_track()
+{
+  util::add_event_track_count_check(tracks, static_cast<int>(tracks.size() + 1));
+}
+
+LIBREMIDI_INLINE
+void writer::write(std::ostream& out) const
+{
+  // MIDI File Header
+  out.write("MThd", 4);
+  util::write_be<uint32_t>(out, 6);
+  util::write_be<uint16_t>(out, (tracks.size() == 1) ? 0 : 1);
+  util::write_be<uint16_t>(out, static_cast<uint16_t>(tracks.size()));
+  util::write_be<uint16_t>(out, ticksPerQuarterNote);
+
+  std::vector<uint8_t> trackRawData;
+  for (const auto& event_list : tracks)
+  {
+    trackRawData.clear();
+    // Rough estimation of the memory to allocate
+    trackRawData.reserve(event_list.size() * 3);
+
+    for (const auto& event : event_list)
+    {
+      const auto& msg = event.m;
+      if (msg.empty())
+        continue;
+
+      // Suppress end-of-track meta messages (one will be added
+      // automatically after all track data has been written).
+      if (msg.get_meta_event_type() == meta_event_type::END_OF_TRACK)
+        continue;
+
+      util::write_variable_length(static_cast<uint32_t>(event.tick), trackRawData);
+
+      if ((msg.get_message_type() == message_type::SYSTEM_EXCLUSIVE)
+          || (event.m.get_message_type() == message_type::EOX))
+      {
+        // 0xf0 == Complete sysex message (0xf0 is part of the raw MIDI).
+        // 0xf7 == Raw byte message (0xf7 not part of the raw MIDI).
+        // Print the first byte of the message (0xf0 or 0xf7), then
+        // print a VLV length for the rest of the bytes in the message.
+        // In other words, when creating a 0xf0 or 0xf7 MIDI message,
+        // do not insert the VLV byte length yourself, as this code will
+        // do it for you automatically.
+        trackRawData.emplace_back(msg.bytes[0]); // 0xf0 or 0xf7;
+
+        util::write_variable_length(static_cast<uint32_t>(msg.size()) - 1, trackRawData);
+
+        trackRawData.insert(
+            trackRawData.end(), msg.bytes.data() + 1, msg.bytes.data() + msg.bytes.size());
+      }
+      else
+      {
+        // Non-sysex type of message, so just output the bytes of the message:
+        trackRawData.insert(
+            trackRawData.end(), msg.bytes.data(), msg.bytes.data() + msg.bytes.size());
+      }
+    }
+
+    auto size = trackRawData.size();
+    const auto eot = meta_events::end_of_track();
+
+    if ((size < 3) || !((trackRawData[size - 3] == 0xFF) && (trackRawData[size - 2] == 0x2F)))
+    {
+      trackRawData.emplace_back(0x0); // tick
+      trackRawData.emplace_back(eot[0]);
+      trackRawData.emplace_back(eot[1]);
+      trackRawData.emplace_back(eot[2]);
+    }
+
+    // Write the track ID marker "MTrk":
+    out.write("MTrk", 4);
+    util::write_be<uint32_t>(out, static_cast<uint32_t>(trackRawData.size()));
+    out.write(
+        reinterpret_cast<char*>(trackRawData.data()),
+        static_cast<std::streamsize>(trackRawData.size()));
+  }
+}
+}
