packages feed

RtMidi 0.2.0.0 → 0.3.0.0

raw patch · 9 files changed

+126/−121 lines, 9 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

- Sound.RtMidi: closeDevice :: MonadIO m => IsDevice d => d -> m ()
+ Sound.RtMidi: instance GHC.Enum.Bounded Sound.RtMidi.Api
+ Sound.RtMidi: setForeignCallback :: MonadIO m => InputDevice -> FunPtr (CDouble -> Ptr CUChar -> CInt -> Ptr () -> IO ()) -> Ptr () -> m ()

Files

README.md view
@@ -1,5 +1,7 @@ # RtMidi +[![CircleCI](https://circleci.com/gh/riottracker/RtMidi/tree/master.svg?style=svg)](https://circleci.com/gh/riottracker/RtMidi/tree/master)+ Haskell wrapper for [RtMidi](http://www.music.mcgill.ca/~gary/rtmidi/), the lightweight, cross-platform MIDI I/O library.  ## Development@@ -24,7 +26,7 @@     make docker-repl      # Inside the docker image:-    cabal update && cabal build -fjack+    cabal update && cabal build  (Note that you can't use any `RtMidi` functions in the containerized env unless you are running a Linux host, and even then you'd probably have to start the process with something like `docker run --device /dev/snd`.)
RtMidi.cabal view
@@ -1,5 +1,5 @@ name:                RtMidi-version:             0.2.0.0+version:             0.3.0.0 synopsis:            Haskell wrapper for RtMidi, the lightweight, cross-platform MIDI I/O library. description:         Please see the README on GitHub at <https://github.com/riottracker/RtMidi#readme> category:            Sound@@ -19,27 +19,15 @@ license:             MIT  Flag alsa {-  Description:  Enable ALSA sequencer api+  Description:  Enable ALSA sequencer api on Linux   Default:      True }  Flag jack {-  Description:  Enable JACK api+  Description:  Enable JACK api on Linux or OSX   Default:      False } -Flag core {-  Description:  Enable CoreMIDI api-  Default:      True-}---- TODO(ejconlon) Add windows support--- Flag mm {---   Description:  Enable Windows Multimedia Library api---   Default:      False--- }-- library   exposed-modules:     Sound.RtMidi   other-modules:       Sound.RtMidi.Foreign@@ -50,14 +38,26 @@   extra-libraries:     stdc++   c-sources:           rtmidi/RtMidi.cpp                        rtmidi/rtmidi_c.cpp-  if flag(alsa) && os(linux)-    cc-options:       -D__LINUX_ALSA__-    extra-libraries:  asound pthread-  if flag(jack) && (os(linux) || os(darwin))-    cc-options:       -D__UNIX_JACK__-    extra-libraries:  jack-  if flag(core) && os(darwin)-    cc-options:       -D__MACOSX_CORE__++  -- TODO(ejconlon) Add windows support++  if os(linux)+    if flag(alsa) && flag(jack)+      cc-options:       -D__LINUX_ALSA__ -D__UNIX_JACK__+      extra-libraries:  asound pthread jack+    if flag(alsa) && !flag(jack)+      cc-options:       -D__LINUX_ALSA__+      extra-libraries:  asound pthread+    if !flag(alsa) && flag(jack)+      cc-options:       -D__UNIX_JACK__+      extra-libraries:  jack++  if os(darwin)+    if flag(jack)+      cc-options:       -D__MACOSX_CORE__ -D__UNIX_JACK__+      extra-libraries:  jack+    else+      cc-options:       -D__MACOSX_CORE__     frameworks:       CoreMIDI CoreAudio CoreFoundation     -- NOTE(ejconlon) This is to make the c ffi wrapper actually catch     -- the c++ exceptions instead of simply aborting.@@ -65,7 +65,6 @@     -- avoid https://gitlab.haskell.org/ghc/ghc/issues/11829     ld-options:        -Wl,-keep_dwarf_unwind     ghc-options:       -pgmc=clang++-  -- TODO(ejconlon) Add windows support  executable rtmidi-callback   main-is:            callback.hs
Sound/RtMidi.hs view
@@ -18,6 +18,7 @@   , defaultInput   , createInput   , setCallback+  , setForeignCallback   , cancelCallback   , ignoreTypes   , getMessage@@ -25,18 +26,18 @@   , defaultOutput   , createOutput   , sendMessage-  , closeDevice   , currentApi   ) where  import Control.Exception (Exception, throwIO)-import Control.Monad (unless)+import Control.Monad (unless, void) import Control.Monad.IO.Class (MonadIO (..)) import Control.Monad.IO.Unlift (MonadUnliftIO (..)) import Data.Coerce (coerce) import Data.Word (Word8) import Foreign (FunPtr, Ptr, Storable (..), alloca, allocaArray, nullPtr, peekArray, with, withArrayLen) import Foreign.C (CDouble (..), CInt (..), CSize, CString, CUChar (..), peekCString, withCString)+import Foreign.ForeignPtr (ForeignPtr, newForeignPtr, withForeignPtr) import Sound.RtMidi.Foreign  -- The default message size (in bytes) expected from 'getMessage'@@ -51,7 +52,7 @@   | OutputDeviceType   deriving (Eq, Show) -newtype Device = Device { unDevice :: Ptr Wrapper }+newtype Device = Device { unDevice :: ForeignPtr Wrapper }   deriving (Eq, Show)  -- | Generalizes 'InputDevice' and 'OutputDevice' for use in common functions@@ -59,9 +60,6 @@   toDevice :: d -> Device   getDeviceType :: d -> DeviceType -toDevicePtr :: IsDevice d => d -> Ptr Wrapper-toDevicePtr = unDevice . toDevice- -- | A handle to a device to be used for input newtype InputDevice = InputDevice { unInputDevice :: Device }   deriving (Eq, Show)@@ -70,6 +68,9 @@   toDevice = unInputDevice   getDeviceType _ = InputDeviceType +newInputDevice :: Ptr Wrapper -> IO InputDevice+newInputDevice = fmap (InputDevice . Device) . newForeignPtr rtmidi_in_free+ -- | A handle to a device to be used for input newtype OutputDevice = OutputDevice { unOutputDevice :: Device }   deriving (Eq, Show)@@ -78,6 +79,9 @@   toDevice = unOutputDevice   getDeviceType _ = OutputDeviceType +newOutputDevice :: Ptr Wrapper -> IO OutputDevice+newOutputDevice = fmap (OutputDevice . Device) . newForeignPtr rtmidi_out_free+ -- | Enum of RtMidi-supported APIs data Api   = UnspecifiedApi@@ -89,6 +93,10 @@   | DummyApi   deriving (Eq, Show) +instance Bounded Api where+  minBound = UnspecifiedApi+  maxBound = DummyApi+ instance Enum Api where   fromEnum UnspecifiedApi = 0   fromEnum CoreMidiApi = 1@@ -103,10 +111,6 @@   toEnum 4 = MultimediaApi   toEnum 5 = DummyApi --- | Check if a device is ok-ready :: (MonadIO m, IsDevice d) => d -> m Bool-ready = liftIO . fmap ok . peek  . toDevicePtr- -- | An internal RtMidi error newtype Error = Error String deriving (Eq, Show) instance Exception Error@@ -119,15 +123,26 @@     e <- peekCString (msg w)     throwIO (Error e) +-- Operate on the underlying device ptr+withDevicePtrUnguarded :: IsDevice d => d -> (Ptr Wrapper -> IO a) -> IO a+withDevicePtrUnguarded = withForeignPtr . unDevice . toDevice++-- Operate on the underlying device ptr and guard for errors+withDevicePtr :: IsDevice d => d -> (Ptr Wrapper -> IO a) -> IO a+withDevicePtr d f = withDevicePtrUnguarded d (\dptr -> f dptr <* guardError dptr)++-- | Check if a device is ok+ready :: (MonadIO m, IsDevice d) => d -> m Bool+ready d = liftIO (withDevicePtr d (fmap ok . peek))+ -- | A static function to determine MIDI 'Api's built in. compiledApis :: MonadIO m => m [Api] compiledApis = liftIO $ do-  n <- fmap fromIntegral (rtmidi_get_compiled_api nullPtr)-  as <- allocaArray n $ flip with $ \ptr -> do-    rtmidi_get_compiled_api ptr-    x <- peek ptr-    peekArray n x-  pure (map (toEnum . fromEnum) as)+  n <- fmap fromIntegral (rtmidi_get_compiled_api nullPtr 0)+  as <- allocaArray n $ \ptr -> do+    rtmidi_get_compiled_api ptr (fromIntegral n)+    peekArray n ptr+  pure (map (toEnum . fromIntegral) as)  -- | Open a MIDI connection openPort :: (MonadIO m, IsDevice d)@@ -135,42 +150,32 @@          -> Int          -- ^ port number          -> String       -- ^ name for the application port that is used          -> m ()-openPort d n name = liftIO $ do-  let dptr = toDevicePtr d+openPort d n name = liftIO $ withDevicePtr d $ \dptr ->   withCString name (rtmidi_open_port dptr (toEnum n))-  guardError dptr  -- | This function creates a virtual MIDI output port to which other software applications can connect. -- -- This type of functionality is currently only supported by the Macintosh OS X, Linux ALSA and JACK APIs -- (the function does nothing with the other APIs). openVirtualPort :: (MonadIO m, IsDevice d) => d -> String -> m ()-openVirtualPort d name = liftIO $ do-  let dptr = toDevicePtr d+openVirtualPort d name = liftIO $ withDevicePtr d $ \dptr -> do   withCString name (rtmidi_open_virtual_port dptr)-  guardError dptr  -- | Close an open MIDI connection (if one exists). closePort :: (MonadIO m, IsDevice d) => d -> m ()-closePort d = liftIO $ do-  let dptr = toDevicePtr d-  rtmidi_close_port dptr-  guardError dptr+closePort d = liftIO (withDevicePtr d rtmidi_close_port)  -- | Return the number of MIDI ports available to the 'Device'. portCount :: (MonadIO m, IsDevice d) => d -> m Int-portCount d = liftIO$ do-  let dptr = toDevicePtr d+portCount d = liftIO $ withDevicePtr d $ \dptr -> do   x <- rtmidi_get_port_count dptr-  guardError dptr   pure (fromIntegral x)  -- | Return a string identifier for the specified MIDI port number. -- -- 'Nothing' is returned if an invalid port specifier is provided. portName :: (MonadIO m, IsDevice d) => d -> Int -> m (Maybe String)-portName d n = liftIO $ do-  let dptr = toDevicePtr d+portName d n = liftIO $ withDevicePtrUnguarded d $ \dptr -> do   x <- rtmidi_get_port_name dptr (toEnum n)   guardError dptr   s <- peekCString x@@ -220,7 +225,7 @@ defaultInput = liftIO $ do   dptr <- rtmidi_in_create_default   guardError dptr-  pure (InputDevice (Device dptr))+  newInputDevice dptr  -- | Create a new 'Device' to use for input. createInput :: MonadIO m@@ -229,9 +234,9 @@             -> Int        -- ^ size of the MIDI input queue             -> m InputDevice createInput api clientName queueSizeLimit = liftIO $ do-  dptr <- withCString clientName (\str -> rtmidi_in_create (toEnum $ fromEnum api) str (toEnum queueSizeLimit))+  dptr <- withCString clientName (\str -> rtmidi_in_create (fromIntegral (fromEnum api)) str (fromIntegral queueSizeLimit))   guardError dptr-  pure (InputDevice (Device dptr))+  newInputDevice dptr  foreign import ccall "wrapper"   mkCallbackPointer :: (CDouble -> Ptr CUChar -> CInt -> Ptr () -> IO ()) -> IO (FunPtr (CDouble -> Ptr CUChar -> CInt -> Ptr () -> IO ()))@@ -248,20 +253,27 @@             => InputDevice             -> (Double -> [Word8] -> m ())  -- ^ Function that takes a timestamp and a MIDI message as arguments             -> m ()-setCallback d c = withRunInIO $ \run -> do-  let dptr = toDevicePtr d+setCallback d c = withRunInIO $ \run -> withDevicePtr d $ \dptr -> do   f <- mkCallbackPointer (adaptCallbackCTypes (\ts bytes -> run (c ts bytes)))   rtmidi_in_set_callback dptr f nullPtr-  guardError dptr +-- | Set a /foreign/ callback function to be invoked for incoming MIDI messages.+--+-- This variant allows you to set the callback to a C function pointer so we're not forced+-- to enter a Haskell wrapper every invocation.+setForeignCallback :: MonadIO m+                   => InputDevice+                   -> FunPtr (CDouble -> Ptr CUChar -> CInt -> Ptr () -> IO ())+                   -> Ptr () -- ^ Pointer to context that will be passed into the callback+                   -> m ()+setForeignCallback d f ctx = liftIO $ withDevicePtr d $ \dptr ->+  rtmidi_in_set_callback dptr f ctx+ -- | Cancel use of the current callback function (if one exists). -- -- Subsequent incoming MIDI messages will be written to the queue and can be retrieved with the `getMessage` function. cancelCallback :: MonadIO m => InputDevice -> m ()-cancelCallback d = liftIO $ do-  let dptr = toDevicePtr d-  rtmidi_in_cancel_callback dptr-  guardError dptr+cancelCallback d = liftIO (withDevicePtr d rtmidi_in_cancel_callback)  -- | Specify whether certain MIDI message types should be queued or ignored during input. --@@ -274,12 +286,11 @@             -> Bool       -- ^ Time messages             -> Bool       -- ^ Sense messages             -> m ()-ignoreTypes d x y z = liftIO (rtmidi_in_ignore_types (toDevicePtr d) x y z)+ignoreTypes d x y z = liftIO (withDevicePtrUnguarded d (\dptr -> rtmidi_in_ignore_types dptr x y z))  -- | Variant of 'getMessage' that allows you to set message buffer size (typically for large sysex messages). getMessageSized :: MonadIO m => InputDevice -> Int -> m (Double, [Word8])-getMessageSized d n = liftIO $ alloca $ \s -> allocaArray n $ flip with $ \m -> do-  let dptr = toDevicePtr d+getMessageSized d n = liftIO $ alloca $ \s -> allocaArray n $ flip with $ \m -> withDevicePtrUnguarded d $ \dptr -> do   poke s (fromIntegral n)   CDouble timestamp <- rtmidi_in_get_message dptr m s   guardError dptr@@ -306,7 +317,7 @@ defaultOutput = liftIO $ do   dptr <- rtmidi_out_create_default   guardError dptr-  pure (OutputDevice (Device dptr))+  newOutputDevice dptr  -- | Create a new 'Device' to use for output. createOutput :: MonadIO m@@ -314,32 +325,20 @@              -> String     -- ^ client name              -> m OutputDevice createOutput api clientName = liftIO $ do-  dptr <- withCString clientName (rtmidi_out_create (toEnum (fromEnum api)))+  dptr <- withCString clientName (rtmidi_out_create (fromIntegral (fromEnum api)))   guardError dptr-  pure (OutputDevice (Device dptr))+  newOutputDevice dptr  -- | Immediately send a single message out an open MIDI output port. sendMessage :: MonadIO m => OutputDevice -> [Word8] -> m ()-sendMessage d m = liftIO $ withArrayLen m $ \n ptr -> do-  let dptr = toDevicePtr d-  rtmidi_out_send_message dptr (coerce ptr) (fromIntegral n)-  guardError dptr---- | If a MIDI connection is still open, it will be closed-closeDevice :: MonadIO m => IsDevice d => d -> m ()-closeDevice d = liftIO $-  let dptr = toDevicePtr d-  in case getDeviceType d of-    InputDeviceType -> rtmidi_in_free dptr-    OutputDeviceType -> rtmidi_out_free dptr+sendMessage d m = liftIO $ withArrayLen m $ \n ptr -> withDevicePtr d $ \dptr ->+  void (rtmidi_out_send_message dptr (coerce ptr) (fromIntegral n))  -- | Returns the specifier for the MIDI 'Api' in use currentApi :: MonadIO m => IsDevice d => d -> m Api-currentApi d = liftIO $ do-  let dptr = toDevicePtr d+currentApi d = liftIO $ withDevicePtr d $ \dptr -> do   res <-     case getDeviceType d of       InputDeviceType -> rtmidi_in_get_current_api dptr       OutputDeviceType -> rtmidi_out_get_current_api dptr-  guardError dptr-  pure (toEnum (fromEnum res))+  pure (toEnum (fromIntegral res))
Sound/RtMidi/Foreign.hsc view
@@ -28,7 +28,7 @@ #include "rtmidi_c.h"  import Foreign (FunPtr, Ptr, Storable (..))-import Foreign.C (CDouble (..), CInt (..), CString, CSize, CUChar)+import Foreign.C (CDouble (..), CInt (..), CString, CSize, CUChar, CUInt (..))  data Wrapper = Wrapper   { ptr :: !(Ptr ())@@ -52,32 +52,35 @@     d <- #{peek struct RtMidiWrapper, msg} ptr     pure (Wrapper a b c d) +-- A parameter we'll be de/serializing from the 'Api' enum.+type ApiEnum = CInt+ foreign import ccall "rtmidi_c.h rtmidi_close_port"   rtmidi_close_port :: Ptr Wrapper -> IO ()  foreign import ccall "rtmidi_c.h rtmidi_get_compiled_api"-  rtmidi_get_compiled_api :: Ptr (Ptr CInt) -> IO CInt+  rtmidi_get_compiled_api :: Ptr ApiEnum -> CUInt -> IO CInt  foreign import ccall "rtmidi_c.h rtmidi_get_port_count"-  rtmidi_get_port_count :: Ptr Wrapper -> IO CInt+  rtmidi_get_port_count :: Ptr Wrapper -> IO CUInt  foreign import ccall "rtmidi_c.h rtmidi_get_port_name"-  rtmidi_get_port_name :: Ptr Wrapper -> CInt -> IO CString+  rtmidi_get_port_name :: Ptr Wrapper -> CUInt -> IO CString  foreign import ccall "rtmidi_c.h rtmidi_in_cancel_callback"   rtmidi_in_cancel_callback :: Ptr Wrapper -> IO ()  foreign import ccall "rtmidi_c.h rtmidi_in_create"-  rtmidi_in_create :: CInt -> CString -> CInt -> IO (Ptr Wrapper)+  rtmidi_in_create :: ApiEnum -> CString -> CUInt -> IO (Ptr Wrapper)  foreign import ccall "rtmidi_c.h rtmidi_in_create_default"   rtmidi_in_create_default :: IO (Ptr Wrapper) -foreign import ccall "rtmidi_c.h rtmidi_in_free"-  rtmidi_in_free :: Ptr Wrapper -> IO ()+foreign import ccall "rtmidi_c.h &rtmidi_in_free"+  rtmidi_in_free :: FunPtr (Ptr Wrapper -> IO ())  foreign import ccall "rtmidi_c.h rtmidi_in_get_current_api"-  rtmidi_in_get_current_api :: Ptr Wrapper -> IO CInt+  rtmidi_in_get_current_api :: Ptr Wrapper -> IO ApiEnum  foreign import ccall "rtmidi_c.h rtmidi_in_get_message"   rtmidi_in_get_message :: Ptr Wrapper -> Ptr (Ptr CUChar) -> Ptr CSize -> IO CDouble@@ -89,22 +92,22 @@   rtmidi_in_set_callback :: Ptr Wrapper -> FunPtr (CDouble -> Ptr CUChar -> CInt -> Ptr () -> IO ()) -> Ptr () -> IO ()  foreign import ccall "rtmidi_c.h rtmidi_open_port"-  rtmidi_open_port :: Ptr Wrapper -> CInt -> CString -> IO ()+  rtmidi_open_port :: Ptr Wrapper -> CUInt -> CString -> IO ()  foreign import ccall "rtmidi_c.h rtmidi_open_virtual_port"   rtmidi_open_virtual_port :: Ptr Wrapper -> CString -> IO ()  foreign import ccall "rtmidi_c.h rtmidi_out_create"-  rtmidi_out_create :: CInt -> CString -> IO (Ptr Wrapper)+  rtmidi_out_create :: ApiEnum-> CString -> IO (Ptr Wrapper)  foreign import ccall "rtmidi_c.h rtmidi_out_create_default"   rtmidi_out_create_default :: IO (Ptr Wrapper) -foreign import ccall "rtmidi_c.h rtmidi_out_free"-  rtmidi_out_free :: Ptr Wrapper -> IO ()+foreign import ccall "rtmidi_c.h &rtmidi_out_free"+  rtmidi_out_free :: FunPtr (Ptr Wrapper -> IO ())  foreign import ccall "rtmidi_c.h rtmidi_out_get_current_api"-  rtmidi_out_get_current_api :: Ptr Wrapper -> IO CInt+  rtmidi_out_get_current_api :: Ptr Wrapper -> IO ApiEnum  foreign import ccall "rtmidi_c.h rtmidi_out_send_message"   rtmidi_out_send_message :: Ptr Wrapper -> Ptr CUChar -> CInt -> IO CInt
examples/callback.hs view
@@ -1,5 +1,5 @@ import Data.Word (Word8)-import Sound.RtMidi (closeDevice, closePort, defaultInput, openPort, setCallback)+import Sound.RtMidi (closePort, defaultInput, openPort, setCallback) import Numeric (showHex)  callback :: Double -> [Word8] -> IO ()@@ -12,5 +12,4 @@   setCallback i callback   _ <- getLine   closePort i-  closeDevice i   return ()
examples/playback.hs view
@@ -1,4 +1,4 @@-import Sound.RtMidi (closeDevice, closePort, defaultOutput, openPort, sendMessage, portCount, portName)+import Sound.RtMidi (closePort, defaultOutput, openPort, sendMessage, portCount, portName) import Control.Concurrent (threadDelay)  main :: IO ()@@ -18,4 +18,3 @@   mapM_ (\x -> sendMessage device [0x90, x, 0x7f] >> threadDelay 120000) $ take 240 song   putStrLn "exiting..."   closePort device-  closeDevice device
examples/poll.hs view
@@ -1,4 +1,4 @@-import Sound.RtMidi (InputDevice, closeDevice, closePort, defaultInput, getMessage, openPort)+import Sound.RtMidi (InputDevice, closePort, defaultInput, getMessage, openPort) import Control.Concurrent (forkIO, killThread) import Control.Monad (forever) @@ -17,5 +17,4 @@   _ <- getLine   killThread id   closePort i-  closeDevice i   return ()
examples/query.hs view
@@ -1,4 +1,4 @@-import Sound.RtMidi (closeDevice, compiledApis, createOutput, currentApi, defaultOutput, listPorts)+import Sound.RtMidi (compiledApis, createOutput, currentApi, defaultOutput, listPorts)  main :: IO () main = do@@ -9,4 +9,3 @@   putStrLn $ "RtMidi output using " ++ (show api)   putStrLn $ "built-in: " ++ (show builtin)   putStrLn $ "available ports: " ++ (show portPairs)-  closeDevice device
test/Main.hs view
@@ -1,13 +1,13 @@ module Main (main) where  import Control.Concurrent (threadDelay)-import Control.Monad (replicateM_)+import Control.Monad (replicateM_, unless, when) import Data.IORef (IORef, newIORef, readIORef, modifyIORef) import Data.List (isInfixOf) import Data.Word (Word8)-import Sound.RtMidi (closeDevice, closePort, defaultInput, defaultOutput, findPort, sendMessage, setCallback, openPort, openVirtualPort)+import Sound.RtMidi (Api (..), closePort, compiledApis, createInput, createOutput, currentApi, findPort, sendMessage, setCallback, openPort, openVirtualPort) import Test.Tasty (TestTree, defaultMain, testGroup)-import Test.Tasty.HUnit (testCase, (@?=))+import Test.Tasty.HUnit (assertFailure, testCase, (@?=))  incIORef :: IORef Int -> IO () incIORef = flip modifyIORef succ@@ -15,21 +15,25 @@ readerCallback :: IORef Int -> Double -> [Word8] -> IO () readerCallback countRef _ msg = incIORef countRef -testVirtualReadWrite :: TestTree-testVirtualReadWrite = testCase "virtual read write" $ do+testVirtualReadWrite :: Api -> TestTree+testVirtualReadWrite api = testCase ("virtual read write with " <> show api) $ do   let expectedCount = 3       -- a simple note-on message       message = [0x90, 0x51, 0x7f]       portName = "rtmidi-test-port"-      -- 0.01 second delay in us-      delayUs = 10000+      -- 100 ms delay in us+      delayUs = 100000   countRef <- newIORef 0   -- Create reader with callback-  inDev <- defaultInput+  inDev <- createInput api "rtmidi-test-input" 100+  inApi <- currentApi inDev+  inApi @?= api   setCallback inDev (readerCallback countRef)   openVirtualPort inDev portName   -- Create writer and connect to reader virtual port-  outDev <- defaultOutput+  outDev <- createOutput api "rtmidi-test-output"+  outApi <- currentApi outDev+  outApi @?= api   maybePortNum <- findPort outDev (isInfixOf portName)   let portNum = maybe (error "Could not find port") id maybePortNum   openPort outDev portNum portName@@ -39,13 +43,15 @@   threadDelay delayUs   -- Close writer   closePort outDev-  closeDevice outDev   -- Close reader   closePort inDev-  closeDevice inDev   -- Verify number of messages received   actualCount <- readIORef countRef   actualCount @?= expectedCount  main :: IO ()-main = defaultMain (testGroup "RtMidi" [testVirtualReadWrite])+main = do+  apis <- compiledApis+  when (null apis) (assertFailure "No compiled APIs found")+  let tests = fmap testVirtualReadWrite apis+  defaultMain (testGroup "RtMidi" tests)