diff --git a/Main.hs b/Main.hs
new file mode 100644
--- /dev/null
+++ b/Main.hs
@@ -0,0 +1,241 @@
+module Main (main) where
+
+import Control.Applicative ((<$>), (<|>))
+import Control.Monad ((>=>), forM_)
+import Data.Char (toLower)
+import Data.List (sort, nub, partition)
+import Data.Maybe (mapMaybe, fromMaybe, listToMaybe)
+import Data.Version (showVersion)
+import qualified System.Console.GetOpt as Opt
+import qualified System.Environment as Env
+
+import System.FilePath ((</>))
+import Text.PrettyPrint.Boxes
+  (text, vcat, left, render, hsep, top, (/+/))
+
+import Sound.Jammit.Base
+import Sound.Jammit.Export
+import qualified Paths_jammittools as Paths
+
+main :: IO ()
+main = do
+  (fs, _, _) <- Opt.getOpt Opt.Permute argOpts <$> Env.getArgs
+  let args = foldr ($) defaultArgs fs
+  case function args of
+    PrintUsage -> do
+      prog <- Env.getProgName
+      putStrLn $ "jammittools v" ++ showVersion Paths.version
+      let header = "Usage: " ++ prog ++ " [options]"
+      putStr $ Opt.usageInfo header argOpts
+    ShowDatabase -> do
+      matches <- searchResults args
+      putStr $ showLibrary matches
+    ExportAudio fout -> do
+      matches <- getAudioParts <$> searchResults args
+      let f = mapM (`getOneResult` matches) . mapMaybe charToAudioPart
+      case (f $ selectParts args, f $ rejectParts args) of
+        (Left  err   , _           ) -> error err
+        (_           , Left  err   ) -> error err
+        (Right yaifcs, Right naifcs) -> runAudio yaifcs naifcs fout
+    CheckPresence -> do
+      matches <- getAudioParts <$> searchResults args
+      let f = mapM (`getOneResult` matches) . mapMaybe charToAudioPart
+      case (f $ selectParts args, f $ rejectParts args) of
+        (Left  err   , _           ) -> error err
+        (_           , Left  err   ) -> error err
+        (Right _     , Right _     ) -> return ()
+    ExportSheet fout -> do
+      matches <- getSheetParts <$> searchResults args
+      let f = mapM (`getOneResult` matches) . mapMaybe charToSheetPart
+      case f $ selectParts args of
+        Left  err   -> error err
+        Right parts -> let
+          systemHeight = sum $ map snd parts
+          in runSheet parts (getPageLines systemHeight args) fout
+    ExportAll dout -> do
+      matches <- searchResults args
+      let sheets = getSheetParts matches
+          audios = getAudioParts matches
+          backingOrder = [Drums, Guitar, Keyboard, Bass, Vocal]
+          isGuitar p = elem (partToInstrument p) [Guitar, Bass]
+          (gtrs, nongtrs) = partition isGuitar [minBound .. maxBound]
+          chosenBacking = listToMaybe $ flip mapMaybe backingOrder $ \i ->
+            case getOneResult (Without i) audios of
+              Left  _  -> Nothing
+              Right fp -> Just (i, fp)
+      forM_ gtrs $ \p ->
+        case (getOneResult (Notation p) sheets, getOneResult (Tab p) sheets) of
+          (Right note, Right tab) -> let
+            parts = [note, tab]
+            systemHeight = sum $ map snd parts
+            fout = dout </> drop 4 (map toLower (show p) ++ ".pdf")
+            in runSheet [note, tab] (getPageLines systemHeight args) fout
+          _ -> return ()
+      forM_ nongtrs $ \p ->
+        case getOneResult (Notation p) sheets of
+          Left  _    -> return ()
+          Right note -> let
+            fout = dout </> drop 4 (map toLower (show p) ++ ".pdf")
+            in runSheet [note] (getPageLines (snd note) args) fout
+      forM_ [minBound .. maxBound] $ \p ->
+        case getOneResult (Only p) audios of
+          Left  _  -> return ()
+          Right fp -> let
+            fout = dout </> drop 4 (map toLower (show p) ++ ".wav")
+            in runAudio [fp] [] fout
+      case chosenBacking of
+        Nothing            -> return ()
+        Just (inst, fback) -> let
+          others = [ fp | (Only p, fp) <- audios, partToInstrument p /= inst ]
+          fout = dout </> "backing.wav"
+          in runAudio [fback] others fout
+
+getPageLines :: Integer -> Args -> Int
+getPageLines systemHeight args = let
+  pageHeight   = 724 / 8.5 * 11 :: Double
+  defaultLines = round $ pageHeight / fromIntegral systemHeight
+  in max 1 $ fromMaybe defaultLines $ pageLines args
+
+-- | If there is exactly one pair with the given first element, returns its
+-- second element. Otherwise (for 0 or >1 elements) returns an error.
+getOneResult :: (Eq a, Show a) => a -> [(a, b)] -> Either String b
+getOneResult x xys = case [ b | (a, b) <- xys, a == x ] of
+  [y] -> Right y
+  ys  -> Left $ "Got " ++ show (length ys) ++ " results for " ++ show x
+
+-- | Displays a table of the library, possibly filtered by search terms.
+showLibrary :: Library -> String
+showLibrary lib = let
+  titleArtists = sort $ nub [ (title info, artist info) | (_, info, _) <- lib ]
+  partsFor ttl art = map partToChar $ sort $ concat
+    [ mapMaybe (trackTitle >=> titleToPart) trks
+    | (_, info, trks) <- lib
+    , (ttl, art) == (title info, artist info) ]
+  makeColumn h col = text h /+/ vcat left (map text col)
+  titleColumn  = makeColumn "Title"  $ map fst                titleArtists
+  artistColumn = makeColumn "Artist" $ map snd                titleArtists
+  partsColumn  = makeColumn "Parts"  $ map (uncurry partsFor) titleArtists
+  in render $ hsep 1 top [titleColumn, artistColumn, partsColumn]
+
+-- | Loads the Jammit library, and applies the search terms from the arguments
+-- to filter it.
+searchResults :: Args -> IO Library
+searchResults args = do
+  jmt <- case jammitDir args of
+    Just j  -> return j
+    Nothing -> Env.lookupEnv "JAMMIT" >>= \mv -> case mv of
+      Just j -> return j
+      Nothing ->
+        fromMaybe (error "Couldn't find Jammit directory.") <$> findJammitDir
+  db <- loadLibrary jmt
+  return $ filterLibrary args db
+
+argOpts :: [Opt.OptDescr (Args -> Args)]
+argOpts =
+  [ Opt.Option ['t'] ["title"]
+    (Opt.ReqArg
+      (\s a -> a { filterLibrary = fuzzySearchBy title s . filterLibrary a })
+      "str")
+    "search by song title"
+  , Opt.Option ['r'] ["artist"]
+    (Opt.ReqArg
+      (\s a -> a { filterLibrary = fuzzySearchBy artist s . filterLibrary a })
+      "str")
+    "search by song artist"
+  , Opt.Option ['T'] ["title-exact"]
+    (Opt.ReqArg
+      (\s a -> a { filterLibrary = exactSearchBy title s . filterLibrary a })
+      "str")
+    "search by song title (exact)"
+  , Opt.Option ['R'] ["artist-exact"]
+    (Opt.ReqArg
+      (\s a -> a { filterLibrary = exactSearchBy artist s . filterLibrary a })
+      "str")
+    "search by song artist (exact)"
+  , Opt.Option ['y'] ["yes-parts"]
+    (Opt.ReqArg (\s a -> a { selectParts = s }) "parts")
+    "parts to appear in sheet music or audio"
+  , Opt.Option ['n'] ["no-parts"]
+    (Opt.ReqArg (\s a -> a { rejectParts = s }) "parts")
+    "parts to subtract (add inverted) from audio"
+  , Opt.Option ['l'] ["lines"]
+    (Opt.ReqArg (\s a -> a { pageLines = Just $ read s }) "int")
+    "number of systems per page"
+  , Opt.Option ['j'] ["jammit"]
+    (Opt.ReqArg (\s a -> a { jammitDir = Just s }) "directory")
+    "location of Jammit library"
+  , Opt.Option ['?'] ["help"]
+    (Opt.NoArg $ \a -> a { function = PrintUsage })
+    "function: print usage info"
+  , Opt.Option ['d'] ["database"]
+    (Opt.NoArg $ \a -> a { function = ShowDatabase })
+    "function: display all songs in db"
+  , Opt.Option ['s'] ["sheet"]
+    (Opt.ReqArg (\s a -> a { function = ExportSheet s }) "file")
+    "function: export sheet music"
+  , Opt.Option ['a'] ["audio"]
+    (Opt.ReqArg (\s a -> a { function = ExportAudio s }) "file")
+    "function: export audio"
+  , Opt.Option ['x'] ["export"]
+    (Opt.ReqArg (\s a -> a { function = ExportAll s }) "dir")
+    "function: export all to dir"
+  , Opt.Option ['c'] ["check"]
+    (Opt.NoArg $ \a -> a { function = CheckPresence })
+    "function: check presence of audio parts"
+  ]
+
+data Args = Args
+  { filterLibrary :: Library -> Library
+  , selectParts   :: String
+  , rejectParts   :: String
+  , pageLines     :: Maybe Int
+  , jammitDir     :: Maybe FilePath
+  , function      :: Function
+  }
+
+data Function
+  = PrintUsage
+  | ShowDatabase
+  | ExportSheet FilePath
+  | ExportAudio FilePath
+  | ExportAll   FilePath
+  | CheckPresence
+  deriving (Eq, Ord, Show, Read)
+
+defaultArgs :: Args
+defaultArgs = Args
+  { filterLibrary = id
+  , selectParts   = ""
+  , rejectParts   = ""
+  , pageLines     = Nothing
+  , jammitDir     = Nothing
+  , function      = PrintUsage
+  }
+
+partToChar :: Part -> Char
+partToChar p = case p of
+  PartGuitar1 -> 'g'
+  PartGuitar2 -> 'r'
+  PartBass    -> 'b'
+  PartDrums   -> 'd'
+  PartKeys1   -> 'k'
+  PartKeys2   -> 'y'
+  PartPiano   -> 'p'
+  PartSynth   -> 's'
+  PartVocal   -> 'v'
+  PartBVocals -> 'x'
+
+charPartMap :: [(Char, Part)]
+charPartMap = [ (partToChar p, p) | p <- [minBound .. maxBound] ]
+
+charToSheetPart :: Char -> Maybe SheetPart
+charToSheetPart c = let
+  notation = Notation <$> lookup c           charPartMap
+  tab      = Tab      <$> lookup (toLower c) charPartMap
+  in notation <|> tab
+
+charToAudioPart :: Char -> Maybe AudioPart
+charToAudioPart c = let
+  only    = Only                       <$> lookup c           charPartMap
+  without = Without . partToInstrument <$> lookup (toLower c) charPartMap
+  in only <|> without
diff --git a/jammittools.cabal b/jammittools.cabal
--- a/jammittools.cabal
+++ b/jammittools.cabal
@@ -1,24 +1,17 @@
 Name:               jammittools
-Version:            0.2.0.1
+Version:            0.3
 Synopsis:           Export sheet music and audio from Windows/Mac app Jammit
-Description:        <http://www.jammit.com/ Jammit> is a service and associated
-                    app for Windows\/Mac\/iOS that sells isolated instrument
-                    audio tracks from popular music, along with full
-                    transcriptions. This is a tool that allows you to export the
-                    sheet music (in PDF format) and audio (in WAV format) to
-                    tracks that you have purchased.
-                    .
-                    Any sheet music or audio you export is solely for your own
-                    use, e.g. for use on Linux and Android devices that do not
-                    have the official app available. Please do not use this tool
-                    to share content with others who have not purchased it!
-                    .
-                    You must install ImageMagick and Sox for sheet music and
-                    audio export respectively, because they are used to do the
-                    actual conversion.
-                    Please see the
-                    <https://github.com/mtolly/jammittools/blob/master/README.md README>
-                    for usage information.
+Description:
+
+  <http://www.jammit.com/ Jammit> is a service and associated app for Windows\/Mac\/iOS that sells isolated instrument audio tracks from popular music, along with full transcriptions.
+  This is a library and executable that allow you to export the sheet music (in PDF format) and audio (in WAV format) to tracks that you have purchased.
+  .
+  Any sheet music or audio you export is solely for your own use, e.g. for use on Linux and Android devices that do not have the official app available.
+  Please do not use this tool to share content with others who have not purchased it!
+  .
+  You must install ImageMagick and Sox for sheet music and audio export respectively, because they are used to do the actual conversion.
+  Please see the <https://github.com/mtolly/jammittools/blob/master/README.md README> for usage information.
+
 License:            GPL
 License-File:       LICENSE
 Author:             Michael Tolly
@@ -30,26 +23,40 @@
 Bug-Reports:        https://github.com/mtolly/jammittools/issues
 Extra-Source-Files: README.md
 
-Executable jammittools
-  Main-Is:          Main.hs
-  Other-Modules:    AIFC2WAV
-                    ImageMagick
-                    Jammit
-                    Sox
-                    TempFile
-  Hs-Source-Dirs:   src
-  Build-Depends:    base            >= 4.6.0.1 && < 5
-                    , property-list >= 0.1.0.4 && < 0.2
-                    , directory     >= 1.2.0.1 && < 1.3
-                    , filepath      >= 1.3.0.1 && < 1.4
-                    , containers    >= 0.5.0.0 && < 0.6
-                    , process       >= 1.1.0.2 && < 1.3
-                    , temporary     >= 1.1.2.5 && < 1.3
-                    , boxes         >= 0.1.3   && < 1.2
-                    , transformers  >= 0.3.0.0 && < 0.5
-  Ghc-Options:      -Wall
-  C-Sources:        cbits/aifc2wav-5.1.c
+library
+  exposed-modules:
+    Sound.Jammit.Base
+    Sound.Jammit.Export
+  other-modules:
+    Sound.Jammit.Internal.AIFC2WAV
+    Sound.Jammit.Internal.ImageMagick
+    Sound.Jammit.Internal.Sox
+    Sound.Jammit.Internal.TempFile
+  hs-source-dirs:   src
+  build-depends:
+    base            >= 4.6.0.1 && < 5
+    , property-list >= 0.1.0.4 && < 0.2
+    , directory     >= 1.2.0.1 && < 1.3
+    , filepath      >= 1.3.0.1 && < 1.4
+    , containers    >= 0.5.0.0 && < 0.6
+    , process       >= 1.1.0.2 && < 1.3
+    , temporary     >= 1.1.2.5 && < 1.3
+    , transformers  >= 0.3.0.0 && < 0.5
+  ghc-options:      -Wall
+  c-sources:        cbits/aifc2wav-5.1.c
 
-Source-Repository head
-  Type:             git
+executable jammittools
+  main-is:          Main.hs
+  other-modules:
+    Paths_jammittools
+  build-depends:
+    base            >= 4.6.0.1 && < 5
+    , directory     >= 1.2.0.1 && < 1.3
+    , filepath      >= 1.3.0.1 && < 1.4
+    , boxes         >= 0.1.3   && < 1.2
+    , jammittools   == 0.3
+  ghc-options:      -Wall
+
+source-repository head
+  type:             git
   location:         https://github.com/mtolly/jammittools
diff --git a/src/AIFC2WAV.hs b/src/AIFC2WAV.hs
deleted file mode 100644
--- a/src/AIFC2WAV.hs
+++ /dev/null
@@ -1,27 +0,0 @@
-{-# LANGUAGE ForeignFunctionInterface #-}
-module AIFC2WAV
-( aifcToWav
-) where
-
-import Foreign.C (withCString, CInt(..), CChar(..))
-import Foreign.Marshal.Array (withArrayLen)
-import Foreign.Ptr (Ptr)
-
-import TempFile
-
-foreign import ccall unsafe "aifc2wav_main" aifc2wav_main
-  :: CInt -> Ptr (Ptr CChar) -> IO CInt
-
--- | Given a (new-style) IMA4-compressed AIFC file, converts it to a WAV file.
-aifcToWav :: FilePath -> TempIO FilePath
-aifcToWav aifc = do
-  wav <- newTempFile "aifcToWav.wav"
-  code <- liftIO $
-    withCString "aifc2wav" $ \progC ->
-    withCString aifc       $ \aifcC ->
-    withCString wav        $ \wavC  ->
-    withArrayLen [progC, aifcC, wavC] $ \n v ->
-      aifc2wav_main (fromIntegral n) v
-  if code == 0
-    then return wav
-    else error $ "aifcToWav: returned " ++ show code
diff --git a/src/ImageMagick.hs b/src/ImageMagick.hs
deleted file mode 100644
--- a/src/ImageMagick.hs
+++ /dev/null
@@ -1,89 +0,0 @@
-module ImageMagick
-( connectVertical
-, splitVertical
-, joinPages
-) where
-
-import Control.Applicative ((<$>))
-import Control.Monad (void)
-import Data.Char (isDigit)
-import Data.List (isPrefixOf, sortBy)
-import Data.Maybe (listToMaybe)
-import Data.Ord (comparing)
-import System.Environment (lookupEnv)
-import System.Exit (ExitCode(..))
-import qualified System.Info as Info
-
-import System.Directory (getDirectoryContents)
-import System.FilePath ((</>))
-import System.IO.Temp (createTempDirectory)
-import System.Process (readProcess, readProcessWithExitCode)
-
-import TempFile
-
--- | Find an ImageMagick binary, because the names are way too generic, and
--- "convert" is both an ImageMagick program and a Windows built-in utility.
-imageMagick :: String -> IO (Maybe String)
-imageMagick cmd = do
-  (code, _, _) <- readProcessWithExitCode cmd ["-version"] ""
-  case code of
-    ExitSuccess -> return $ Just cmd
-    _ -> case Info.os of
-      "mingw32" -> firstJustM $
-        -- env variables for different configs of (ghc arch)/(imagemagick arch)
-        -- ProgramFiles: 32/32 or 64/64
-        -- ProgramFiles(x86): 64/32
-        -- ProgramW6432: 32/64
-        flip map ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] $ \env ->
-          lookupEnv env >>= \var -> case var of
-            Nothing -> return Nothing
-            Just pf
-              ->  fmap (\im -> pf </> im </> cmd)
-              .   listToMaybe
-              .   filter ("ImageMagick" `isPrefixOf`)
-              <$> getDirectoryContents pf
-      _ -> return Nothing
-
-imageMagick' :: String -> IO String
-imageMagick' cmd = imageMagick cmd >>= \ms -> case ms of
-  Nothing -> error $ "imageMagick': couldn't find program " ++ cmd
-  Just s  -> return s
-
--- | Only runs actions until the first that gives 'Just'.
-firstJustM :: (Monad m) => [m (Maybe a)] -> m (Maybe a)
-firstJustM [] = return Nothing
-firstJustM (mx : xs) = mx >>= \x -> case x of
-  Nothing -> firstJustM xs
-  Just y  -> return $ Just y
-
--- | Stick images together vertically into one long image.
-connectVertical :: [FilePath] -> TempIO FilePath
-connectVertical [fin] = return fin
-connectVertical fins = do
-  cmd  <- liftIO $ imageMagick' "montage"
-  fout <- newTempFile "connectVertical.png"
-  void $ liftIO $ readProcess cmd
-    (["-geometry", "100%", "-tile", "1x"] ++ fins ++ [fout]) ""
-  return fout
-
--- | Splits an image vertically into chunks of a given height.
-splitVertical :: Integer -> FilePath -> TempIO [FilePath]
-splitVertical i fin = do
-  cmd      <- liftIO $ imageMagick' "convert"
-  tempdir  <- ask
-  splitdir <- liftIO $ createTempDirectory tempdir "splitVertical"
-  void $ liftIO $
-    readProcess cmd ["-crop", "x" ++ show i, fin, splitdir </> "x.png"] ""
-  map (splitdir </>) . sortBy (comparing getNumber) . filter isFile
-    <$> liftIO (getDirectoryContents splitdir)
-  where getNumber :: String -> Integer
-        getNumber = read . takeWhile isDigit . dropWhile (not . isDigit)
-        isFile = (`notElem` [".", ".."])
-
--- | Joins several images into a PDF, where each image is a page.
-joinPages :: [FilePath] -> TempIO FilePath
-joinPages fins = do
-  cmd  <- liftIO $ imageMagick' "convert"
-  fout <- newTempFile "joinPages.pdf"
-  void $ liftIO $ readProcess cmd (fins ++ [fout]) ""
-  return fout
diff --git a/src/Jammit.hs b/src/Jammit.hs
deleted file mode 100644
--- a/src/Jammit.hs
+++ /dev/null
@@ -1,253 +0,0 @@
-module Jammit
-( Instrument(..)
-, Part(..)
-, AudioPart(..)
-, SheetPart(..)
-, titleToPart
-, titleToAudioPart
-, partToInstrument
-, audioPartToInstrument
-, Info(..)
-, Track(..)
-, loadInfo
-, loadTracks
-, findJammitDir
-, songSubdirs
-) where
-
-import Control.Applicative ((<$>), (<*>), liftA2)
-import Control.Arrow ((***))
-import Control.Exception (evaluate)
-import Control.Monad (filterM, guard)
-import Data.Char (toLower, toUpper)
-import Data.Maybe (catMaybes)
-import System.Environment (lookupEnv)
-import Text.Read (readMaybe)
-
-import qualified Data.Map as Map
-import qualified Data.PropertyList as PL
-import qualified System.Directory as Dir
-import System.FilePath ((</>))
-import qualified System.Info as Info
-
--- | The Enum instance corresponds to the number used in the "instrument"
--- property, and the names (used by Show/Read) are capitalized versions of those
--- used in the "skillLevel" property.
-data Instrument = Guitar | Bass | Drums | Keyboard | Vocal
-  deriving (Eq, Ord, Show, Read, Enum, Bounded)
-
-data Part
-  = PartGuitar1
-  | PartGuitar2
-  | PartBass
-  | PartDrums
-  | PartKeys1
-  | PartKeys2
-  | PartPiano
-  | PartSynth
-  | PartVocal
-  | PartBVocals
-  deriving (Eq, Ord, Show, Read, Enum, Bounded)
-
-data AudioPart
-  = Only Part
-  | Without Instrument
-  deriving (Eq, Ord, Show, Read)
-
-data SheetPart
-  = Notation Part
-  | Tab Part
-  deriving (Eq, Ord, Show, Read)
-
-titleToPart :: String -> Maybe Part
-titleToPart s = case s of
-  "Guitar"   -> Just PartGuitar1
-  "Guitar 1" -> Just PartGuitar1
-  "Guitar 2" -> Just PartGuitar2
-  "Bass"     -> Just PartBass
-  "Drums"    -> Just PartDrums
-  "Keys"     -> Just PartKeys1
-  "Keys 1"   -> Just PartKeys1
-  "Keys 2"   -> Just PartKeys2
-  "Piano"    -> Just PartPiano
-  "Synth"    -> Just PartSynth
-  "Vocal"    -> Just PartVocal
-  "B Vocals" -> Just PartBVocals
-  _          -> Nothing
-
-titleToAudioPart :: String -> Instrument -> Maybe AudioPart
-titleToAudioPart "Band" i = Just $ Without i
-titleToAudioPart s      _ = Only <$> titleToPart s
-
-partToInstrument :: Part -> Instrument
-partToInstrument p = case p of
-  PartGuitar1 -> Guitar
-  PartGuitar2 -> Guitar
-  PartBass    -> Bass
-  PartDrums   -> Drums
-  PartKeys1   -> Keyboard
-  PartKeys2   -> Keyboard
-  PartPiano   -> Keyboard
-  PartSynth   -> Keyboard
-  PartVocal   -> Vocal
-  PartBVocals -> Vocal
-
-audioPartToInstrument :: AudioPart -> Instrument
-audioPartToInstrument (Only    p) = partToInstrument p
-audioPartToInstrument (Without i) = i
-
-data Info = Info
-  { album        :: String
-  , artist       :: String
-  , bpm          :: String
-  , copyright    :: String
-  , countInBeats :: Integer
-  , courtesyOf   :: String
-  , demo         :: Bool
-  , explicit     :: Bool
-  , genre        :: String
-  , instrument   :: Instrument
-  , publishedBy  :: String
-  , skillLevel   :: Either Integer [(Instrument, Integer)]
-  , sku          :: String
-  , slow         :: Double
-  , title        :: String
-  , version      :: Integer
-  , writtenBy    :: String
-  } deriving (Eq, Ord, Show, Read)
-
-instance PL.PropertyListItem Info where
-
-  fromPropertyList pl = PL.fromPlDict pl >>= \dict -> Info
-    <$> (Map.lookup "album"        dict >>= PL.fromPlString)
-    <*> (Map.lookup "artist"       dict >>= PL.fromPlString)
-    <*> (Map.lookup "bpm"          dict >>= PL.fromPlString)
-    <*> (Map.lookup "copyright"    dict >>= PL.fromPlString)
-    <*> (Map.lookup "countInBeats" dict >>= PL.fromPlInt   )
-    <*> (Map.lookup "courtesyOf"   dict >>= PL.fromPlString)
-    <*> (Map.lookup "demo"         dict >>=    fromPlEnum  )
-    <*> (Map.lookup "explicit"     dict >>=    fromPlEnum  )
-    <*> (Map.lookup "genre"        dict >>= PL.fromPlString)
-    <*> (Map.lookup "instrument"   dict >>=    fromPlEnum  )
-    <*> (Map.lookup "publishedBy"  dict >>= PL.fromPlString)
-    <*> (Map.lookup "skillLevel"   dict >>=    fromPlSkills)
-    <*> (Map.lookup "sku"          dict >>= PL.fromPlString)
-    <*> (Map.lookup "slow"         dict >>= PL.fromPlReal  )
-    <*> (Map.lookup "title"        dict >>= PL.fromPlString)
-    <*> (Map.lookup "version"      dict >>= PL.fromPlInt   )
-    <*> (Map.lookup "writtenBy"    dict >>= PL.fromPlString)
-
-  toPropertyList info = PL.plDict $ Map.fromList
-    [ ("album"       , PL.plString $ album        info)
-    , ("artist"      , PL.plString $ artist       info)
-    , ("bpm"         , PL.plString $ bpm          info)
-    , ("copyright"   , PL.plString $ copyright    info)
-    , ("countInBeats", PL.plInt    $ countInBeats info)
-    , ("courtesyOf"  , PL.plString $ courtesyOf   info)
-    , ("demo"        ,    plEnum   $ demo         info)
-    , ("explicit"    ,    plEnum   $ explicit     info)
-    , ("genre"       , PL.plString $ genre        info)
-    , ("instrument"  ,    plEnum   $ instrument   info)
-    , ("publishedBy" , PL.plString $ publishedBy  info)
-    , ("skillLevel"  ,    plSkills $ skillLevel   info)
-    , ("sku"         , PL.plString $ sku          info)
-    , ("slow"        , PL.plReal   $ slow         info)
-    , ("title"       , PL.plString $ title        info)
-    , ("version"     , PL.plInt    $ version      info)
-    , ("writtenBy"   , PL.plString $ writtenBy    info)
-    ]
-
-fromPlEnum :: (Enum a) => PL.PropertyList -> Maybe a
-fromPlEnum pl = toEnum . fromIntegral <$> PL.fromPlInt pl
-
-plEnum :: (Enum a) => a -> PL.PropertyList
-plEnum = PL.plInt . fromIntegral . fromEnum
-
-fromPlSkills
-  :: PL.PropertyList -> Maybe (Either Integer [(Instrument, Integer)])
-fromPlSkills pl = case (PL.fromPlInt pl, PL.fromPlDict pl) of
-  (Nothing, Nothing) -> Nothing
-  (Just i , _      ) -> Just $ Left i
-  (_      , Just d ) -> let
-    getSkill (x, y) = liftA2 (,) (readMaybe $ capitalize x) (PL.fromPlInt y)
-    capitalize ""     = ""
-    capitalize (c:cs) = toUpper c : map toLower cs
-    in fmap Right $ mapM getSkill $ Map.toList d
-
-plSkills :: Either Integer [(Instrument, Integer)] -> PL.PropertyList
-plSkills (Left  i ) = PL.plInt i
-plSkills (Right sl) = PL.plDict $
-  Map.fromList $ map (map toLower . show *** PL.plInt) sl
-
-loadInfo :: FilePath -> IO (Maybe Info)
-loadInfo dir =
-  PL.fromPropertyList <$> readXmlPropertyListFromFile' (dir </> "info.plist")
-
-data Track = Track
-  { trackClass          :: String
-  , identifier          :: String
-  , scoreSystemHeight   :: Maybe Integer
-  , scoreSystemInterval :: Maybe Integer
-  , trackTitle          :: Maybe String
-  } deriving (Eq, Ord, Show, Read)
-
-instance PL.PropertyListItem Track where
-
-  toPropertyList t = PL.plDict $ Map.fromList $ catMaybes
-    [ Just ("class"              , PL.plString $ trackClass          t)
-    , Just ("identifier"         , PL.plString $ identifier          t)
-    , (\i -> ("scoreSystemHeight"  , PL.plInt    i)) <$> scoreSystemHeight   t
-    , (\i -> ("scoreSystemInterval", PL.plInt    i)) <$> scoreSystemInterval t
-    , (\s -> ("title"              , PL.plString s)) <$> trackTitle          t
-    ]
-
-  fromPropertyList pl = PL.fromPlDict pl >>= \d -> Track
-    <$>      (Map.lookup "class"               d >>= PL.fromPlString)
-    <*>      (Map.lookup "identifier"          d >>= PL.fromPlString)
-    <*> Just (Map.lookup "scoreSystemHeight"   d >>= PL.fromPlInt   )
-    <*> Just (Map.lookup "scoreSystemInterval" d >>= PL.fromPlInt   )
-    <*> Just (Map.lookup "title"               d >>= PL.fromPlString)
-
-loadTracks :: FilePath -> IO (Maybe [Track])
-loadTracks dir = PL.listFromPropertyList <$>
-  readXmlPropertyListFromFile' (dir </> "tracks.plist")
-
--- | Reads strictly so as not to exhaust our allowed open files.
-readXmlPropertyListFromFile' :: FilePath -> IO PL.PropertyList
-readXmlPropertyListFromFile' f = do
-  str <- readFile f
-  _ <- evaluate $ length str
-  either fail return $ PL.readXmlPropertyList str
-
--- | Tries to find the top-level Jammit library directory on Windows or
--- Mac OS X.
-findJammitDir :: IO (Maybe FilePath)
-findJammitDir = case Info.os of
-  "mingw32" -> do
-    var <- lookupEnv "LocalAppData"
-    case var of
-      Just local -> jammitIn local
-      Nothing    -> return Nothing
-  "darwin" -> do
-    home <- Dir.getHomeDirectory
-    jammitIn $ home </> "Library" </> "Application Support"
-  _ -> return Nothing
-  where jammitIn dir = do
-          let jmt = dir </> "Jammit"
-          b <- Dir.doesDirectoryExist jmt
-          return $ guard b >> Just jmt
-
--- | Gets the contents of a directory without the @.@ and @..@ special paths,
--- and adds the directory to the front of all the names to make absolute paths.
-lsAbsolute :: FilePath -> IO [FilePath]
-lsAbsolute d =
-  map (d </>) . filter (`notElem` [".", ".."]) <$> Dir.getDirectoryContents d
-
--- | Searches a directory and all subdirectories for folders containing a Jammit
--- info file.
-songSubdirs :: FilePath -> IO [FilePath]
-songSubdirs dir = do
-  isSong <- Dir.doesFileExist $ dir </> "info.plist"
-  let here = [dir | isSong]
-  subdirs <- lsAbsolute dir >>= filterM Dir.doesDirectoryExist
-  (here ++) . concat <$> mapM songSubdirs subdirs
diff --git a/src/Main.hs b/src/Main.hs
deleted file mode 100644
--- a/src/Main.hs
+++ /dev/null
@@ -1,320 +0,0 @@
-module Main (main) where
-
-import Control.Applicative ((<$>), liftA2, (<|>))
-import Control.Monad (forM, (>=>), forM_)
-import Data.Char (toLower)
-import Data.List (isInfixOf, transpose, sort, nub, partition, isPrefixOf)
-import Data.Maybe (mapMaybe, catMaybes, fromMaybe, listToMaybe)
-import Data.Version (showVersion)
-import qualified System.Console.GetOpt as Opt
-import qualified System.Environment as Env
-
-import System.Directory (getDirectoryContents)
-import System.FilePath ((</>), splitFileName, takeFileName)
-import Text.PrettyPrint.Boxes
-  (text, vcat, left, render, hsep, top, (/+/))
-
-import AIFC2WAV
-import ImageMagick
-import Jammit
-import qualified Paths_jammittools as Paths
-import Sox
-import TempFile
-
-data Args = Args
-  { filterLibrary :: Library -> Library
-  , selectParts   :: String
-  , rejectParts   :: String
-  , pageLines     :: Maybe Int
-  , jammitDir     :: Maybe FilePath
-  , function      :: Function
-  }
-
-data Function
-  = PrintUsage
-  | ShowDatabase
-  | ExportSheet FilePath
-  | ExportAudio FilePath
-  | ExportAll   FilePath
-  | CheckPresence
-  deriving (Eq, Ord, Show, Read)
-
-defaultArgs :: Args
-defaultArgs = Args
-  { filterLibrary = id
-  , selectParts   = ""
-  , rejectParts   = ""
-  , pageLines     = Nothing
-  , jammitDir     = Nothing
-  , function      = PrintUsage
-  }
-
-partToChar :: Part -> Char
-partToChar p = case p of
-  PartGuitar1 -> 'g'
-  PartGuitar2 -> 'r'
-  PartBass    -> 'b'
-  PartDrums   -> 'd'
-  PartKeys1   -> 'k'
-  PartKeys2   -> 'y'
-  PartPiano   -> 'p'
-  PartSynth   -> 's'
-  PartVocal   -> 'v'
-  PartBVocals -> 'x'
-
-charPartMap :: [(Char, Part)]
-charPartMap = [ (partToChar p, p) | p <- [minBound .. maxBound] ]
-
-charToSheetPart :: Char -> Maybe SheetPart
-charToSheetPart c = let
-  notation = Notation <$> lookup c           charPartMap
-  tab      = Tab      <$> lookup (toLower c) charPartMap
-  in notation <|> tab
-
-charToAudioPart :: Char -> Maybe AudioPart
-charToAudioPart c = let
-  only    = Only                       <$> lookup c           charPartMap
-  without = Without . partToInstrument <$> lookup (toLower c) charPartMap
-  in only <|> without
-
-type Library = [(FilePath, Info, [Track])]
-
-fuzzySearchBy :: (Info -> String) -> String -> Library -> Library
-fuzzySearchBy f str = let str' = map toLower str in
-  filter $ \(_, info, _) -> str' `isInfixOf` map toLower (f info)
-
-exactSearchBy :: (Info -> String) -> String -> Library -> Library
-exactSearchBy f str = filter $ \(_, info, _) -> f info == str
-
-argOpts :: [Opt.OptDescr (Args -> Args)]
-argOpts =
-  [ Opt.Option ['t'] ["title"]
-    (Opt.ReqArg
-      (\s a -> a { filterLibrary = fuzzySearchBy title s . filterLibrary a })
-      "str")
-    "search by song title"
-  , Opt.Option ['r'] ["artist"]
-    (Opt.ReqArg
-      (\s a -> a { filterLibrary = fuzzySearchBy artist s . filterLibrary a })
-      "str")
-    "search by song artist"
-  , Opt.Option ['T'] ["title-exact"]
-    (Opt.ReqArg
-      (\s a -> a { filterLibrary = exactSearchBy title s . filterLibrary a })
-      "str")
-    "search by song title (exact)"
-  , Opt.Option ['R'] ["artist-exact"]
-    (Opt.ReqArg
-      (\s a -> a { filterLibrary = exactSearchBy artist s . filterLibrary a })
-      "str")
-    "search by song artist (exact)"
-  , Opt.Option ['y'] ["yes-parts"]
-    (Opt.ReqArg (\s a -> a { selectParts = s }) "parts")
-    "parts to appear in sheet music or audio"
-  , Opt.Option ['n'] ["no-parts"]
-    (Opt.ReqArg (\s a -> a { rejectParts = s }) "parts")
-    "parts to subtract (add inverted) from audio"
-  , Opt.Option ['l'] ["lines"]
-    (Opt.ReqArg (\s a -> a { pageLines = Just $ read s }) "int")
-    "number of systems per page"
-  , Opt.Option ['j'] ["jammit"]
-    (Opt.ReqArg (\s a -> a { jammitDir = Just s }) "directory")
-    "location of Jammit library"
-  , Opt.Option ['?'] ["help"]
-    (Opt.NoArg $ \a -> a { function = PrintUsage })
-    "function: print usage info"
-  , Opt.Option ['d'] ["database"]
-    (Opt.NoArg $ \a -> a { function = ShowDatabase })
-    "function: display all songs in db"
-  , Opt.Option ['s'] ["sheet"]
-    (Opt.ReqArg (\s a -> a { function = ExportSheet s }) "file")
-    "function: export sheet music"
-  , Opt.Option ['a'] ["audio"]
-    (Opt.ReqArg (\s a -> a { function = ExportAudio s }) "file")
-    "function: export audio"
-  , Opt.Option ['x'] ["export"]
-    (Opt.ReqArg (\s a -> a { function = ExportAll s }) "dir")
-    "function: export all to dir"
-  , Opt.Option ['c'] ["check"]
-    (Opt.NoArg $ \a -> a { function = CheckPresence })
-    "function: check presence of audio parts"
-  ]
-
-loadLibrary :: FilePath -> IO Library
-loadLibrary jmt = do
-  dirs <- songSubdirs jmt
-  fmap catMaybes $ forM dirs $ \d -> do
-    maybeInfo <- loadInfo   d
-    maybeTrks <- loadTracks d
-    return $ liftA2 (\i t -> (d, i, t)) maybeInfo maybeTrks
-
--- | Displays a table of the library, possibly filtered by search terms.
-showLibrary :: Library -> String
-showLibrary lib = let
-  titleArtists = sort $ nub [ (title info, artist info) | (_, info, _) <- lib ]
-  partsFor ttl art = map partToChar $ sort $ concat
-    [ mapMaybe (trackTitle >=> titleToPart) trks
-    | (_, info, trks) <- lib
-    , (ttl, art) == (title info, artist info) ]
-  makeColumn h col = text h /+/ vcat left (map text col)
-  titleColumn  = makeColumn "Title"  $ map fst                titleArtists
-  artistColumn = makeColumn "Artist" $ map snd                titleArtists
-  partsColumn  = makeColumn "Parts"  $ map (uncurry partsFor) titleArtists
-  in render $ hsep 1 top [titleColumn, artistColumn, partsColumn]
-
--- | Loads the Jammit library, and applies the search terms from the arguments
--- to filter it.
-searchResults :: Args -> IO Library
-searchResults args = do
-  jmt <- case jammitDir args of
-    Just j  -> return j
-    Nothing -> Env.lookupEnv "JAMMIT" >>= \mv -> case mv of
-      Just j -> return j
-      Nothing ->
-        fromMaybe (error "Couldn't find Jammit directory.") <$> findJammitDir
-  db <- loadLibrary jmt
-  return $ filterLibrary args db
-
--- | A mapping from audio part to absolute filename of an audio file.
-getAudioParts :: Library -> [(AudioPart, FilePath)]
-getAudioParts lib = do
-  (dir, info, trks) <- lib
-  trk <- trks
-  case trackTitle trk >>= \t -> titleToAudioPart t (instrument info) of
-    Nothing -> []
-    Just ap -> [(ap, dir </> (identifier trk ++ "_jcfx"))]
-
--- | A mapping from sheet part to
--- @(prefix of image files, line height in pixels)@.
-getSheetParts :: Library -> [(SheetPart, (FilePath, Integer))]
-getSheetParts lib = do
-  (dir, _info, trks) <- lib
-  trk <- trks
-  case (trackTitle trk >>= \t -> titleToPart t, scoreSystemInterval trk) of
-    (Just p, Just ht) -> let
-      sheet = (Notation p, (dir </> (identifier trk ++ "_jcfn"), ht))
-      tab   = (Tab      p, (dir </> (identifier trk ++ "_jcft"), ht))
-      in if elem (partToInstrument p) [Guitar, Bass]
-        then [sheet, tab]
-        else [sheet]
-    _ -> []
-
--- | If there is exactly one pair with the given first element, returns its
--- second element. Otherwise (for 0 or >1 elements) returns an error.
-getOneResult :: (Eq a, Show a) => a -> [(a, b)] -> Either String b
-getOneResult x xys = case [ b | (a, b) <- xys, a == x ] of
-  [y] -> Right y
-  ys  -> Left $ "Got " ++ show (length ys) ++ " results for " ++ show x
-
-main :: IO ()
-main = do
-  (fs, _, _) <- Opt.getOpt Opt.Permute argOpts <$> Env.getArgs
-  let args = foldr ($) defaultArgs fs
-  case function args of
-    PrintUsage -> do
-      prog <- Env.getProgName
-      putStrLn $ "jammittools v" ++ showVersion Paths.version
-      let header = "Usage: " ++ prog ++ " [options]"
-      putStr $ Opt.usageInfo header argOpts
-    ShowDatabase -> do
-      matches <- searchResults args
-      putStr $ showLibrary matches
-    ExportAudio fout -> do
-      matches <- getAudioParts <$> searchResults args
-      let f = mapM (`getOneResult` matches) . mapMaybe charToAudioPart
-      case (f $ selectParts args, f $ rejectParts args) of
-        (Left  err   , _           ) -> error err
-        (_           , Left  err   ) -> error err
-        (Right yaifcs, Right naifcs) -> runAudio yaifcs naifcs fout
-    CheckPresence -> do
-      matches <- getAudioParts <$> searchResults args
-      let f = mapM (`getOneResult` matches) . mapMaybe charToAudioPart
-      case (f $ selectParts args, f $ rejectParts args) of
-        (Left  err   , _           ) -> error err
-        (_           , Left  err   ) -> error err
-        (Right _     , Right _     ) -> return ()
-    ExportSheet fout -> do
-      matches <- getSheetParts <$> searchResults args
-      let f = mapM (`getOneResult` matches) . mapMaybe charToSheetPart
-      case f $ selectParts args of
-        Left  err   -> error err
-        Right parts -> let
-          systemHeight = sum $ map snd parts
-          in runSheet parts (getPageLines systemHeight args) fout
-    ExportAll dout -> do
-      matches <- searchResults args
-      let sheets = getSheetParts matches
-          audios = getAudioParts matches
-          backingOrder = [Drums, Guitar, Keyboard, Bass, Vocal]
-          isGuitar p = elem (partToInstrument p) [Guitar, Bass]
-          (gtrs, nongtrs) = partition isGuitar [minBound .. maxBound]
-          chosenBacking = listToMaybe $ flip mapMaybe backingOrder $ \i ->
-            case getOneResult (Without i) audios of
-              Left  _  -> Nothing
-              Right fp -> Just (i, fp)
-      forM_ gtrs $ \p ->
-        case (getOneResult (Notation p) sheets, getOneResult (Tab p) sheets) of
-          (Right note, Right tab) -> let
-            parts = [note, tab]
-            systemHeight = sum $ map snd parts
-            fout = dout </> drop 4 (map toLower (show p) ++ ".pdf")
-            in runSheet [note, tab] (getPageLines systemHeight args) fout
-          _ -> return ()
-      forM_ nongtrs $ \p ->
-        case getOneResult (Notation p) sheets of
-          Left  _    -> return ()
-          Right note -> let
-            fout = dout </> drop 4 (map toLower (show p) ++ ".pdf")
-            in runSheet [note] (getPageLines (snd note) args) fout
-      forM_ [minBound .. maxBound] $ \p ->
-        case getOneResult (Only p) audios of
-          Left  _  -> return ()
-          Right fp -> let
-            fout = dout </> drop 4 (map toLower (show p) ++ ".wav")
-            in runAudio [fp] [] fout
-      case chosenBacking of
-        Nothing            -> return ()
-        Just (inst, fback) -> let
-          others = [ fp | (Only p, fp) <- audios, partToInstrument p /= inst ]
-          fout = dout </> "backing.wav"
-          in runAudio [fback] others fout
-
-getPageLines :: Integer -> Args -> Int
-getPageLines systemHeight args = let
-  pageHeight   = 724 / 8.5 * 11 :: Double
-  defaultLines = round $ pageHeight / fromIntegral systemHeight
-  in max 1 $ fromMaybe defaultLines $ pageLines args
-
--- | Takes a list of positive AIFCs, a list of negative AIFCs, and a WAV file
--- to produce.
-runAudio :: [FilePath] -> [FilePath] -> FilePath -> IO ()
-runAudio pos neg fout = runTempIO fout $ do
-  -- I've only found one audio file where the instruments are not aligned:
-  -- the drums and drums backing track for Take the Time are 38 samples ahead
-  -- of the other instruments. So as a hack, we pad the front of them by 38
-  -- samples to line things up.
-  let tttDrums     = "793EAAE0-6761-44D7-9A9A-1FB451A2A438_jcfx"
-      tttDrumsBack = "37EE5AA5-4049-4CED-844A-D34F6B165F67_jcfx"
-      aifcToWav' a = if takeFileName a `elem` [tttDrums, tttDrumsBack]
-        then Pad (Samples 38) . File <$> aifcToWav a
-        else File <$> aifcToWav a
-  posWavs <- map (\w -> ( 1, w)) <$> mapM aifcToWav' pos
-  negWavs <- map (\w -> (-1, w)) <$> mapM aifcToWav' neg
-  renderAudio $ optimize $ Mix (posWavs ++ negWavs)
-
-runSheet :: [(FilePath, Integer)] -> Int -> FilePath -> IO ()
-runSheet trks lns fout = runTempIO fout $ do
-  trkLns <- forM trks $ \(fp, ht) -> do
-    let (dir, file) = splitFileName fp
-    ls <- liftIO $ getDirectoryContents dir
-    cnct <- connectVertical $
-      map (dir </>) $ sort $ filter (file `isPrefixOf`) ls
-    splitVertical ht cnct
-  pages <- forM (map concat $ chunksOf lns $ transpose trkLns) $ \pg ->
-    connectVertical pg
-  joinPages pages
-
-chunksOf :: Int -> [a] -> [[a]]
-chunksOf _ [] = []
-chunksOf n xs = case splitAt n xs of
-  (ys, zs) -> ys : chunksOf n zs
diff --git a/src/Sound/Jammit/Base.hs b/src/Sound/Jammit/Base.hs
new file mode 100644
--- /dev/null
+++ b/src/Sound/Jammit/Base.hs
@@ -0,0 +1,256 @@
+{- |
+Basic types and functions for dealing with Jammit song packages.
+-}
+module Sound.Jammit.Base
+( Instrument(..)
+, Part(..)
+, AudioPart(..)
+, SheetPart(..)
+, titleToPart
+, titleToAudioPart
+, partToInstrument
+, audioPartToInstrument
+, Info(..)
+, Track(..)
+, loadInfo
+, loadTracks
+, findJammitDir
+, songSubdirs
+) where
+
+import Control.Applicative ((<$>), (<*>), liftA2)
+import Control.Arrow ((***))
+import Control.Exception (evaluate)
+import Control.Monad (filterM, guard)
+import Data.Char (toLower, toUpper)
+import Data.Maybe (catMaybes)
+import System.Environment (lookupEnv)
+import Text.Read (readMaybe)
+
+import qualified Data.Map as Map
+import qualified Data.PropertyList as PL
+import qualified System.Directory as Dir
+import System.FilePath ((</>))
+import qualified System.Info as Info
+
+-- | The Enum instance corresponds to the number used in the "instrument"
+-- property, and the names (used by Show/Read) are capitalized versions of those
+-- used in the "skillLevel" property.
+data Instrument = Guitar | Bass | Drums | Keyboard | Vocal
+  deriving (Eq, Ord, Show, Read, Enum, Bounded)
+
+data Part
+  = PartGuitar1 -- ^ Used for both Guitar and Guitar 1
+  | PartGuitar2
+  | PartBass
+  | PartDrums
+  | PartKeys1 -- ^ Used for both Keys and Keys 1
+  | PartKeys2
+  | PartPiano -- ^ Rarely used. Seen in \"The Answer Lies Within\" and \"Wait for Sleep\"
+  | PartSynth -- ^ Rarely used. Seen in \"Wait for Sleep\"
+  | PartVocal
+  | PartBVocals
+  deriving (Eq, Ord, Show, Read, Enum, Bounded)
+
+data AudioPart
+  = Only Part -- ^ An audio file for a single notated part.
+  | Without Instrument -- ^ The backing track for an instrument package.
+  deriving (Eq, Ord, Show, Read)
+
+data SheetPart
+  = Notation Part -- ^ For any instrument, the notation sheet music.
+  | Tab Part -- ^ For guitar and bass, the tablature sheet music.
+  deriving (Eq, Ord, Show, Read)
+
+titleToPart :: String -> Maybe Part
+titleToPart s = case s of
+  "Guitar"   -> Just PartGuitar1
+  "Guitar 1" -> Just PartGuitar1
+  "Guitar 2" -> Just PartGuitar2
+  "Bass"     -> Just PartBass
+  "Drums"    -> Just PartDrums
+  "Keys"     -> Just PartKeys1
+  "Keys 1"   -> Just PartKeys1
+  "Keys 2"   -> Just PartKeys2
+  "Piano"    -> Just PartPiano
+  "Synth"    -> Just PartSynth
+  "Vocal"    -> Just PartVocal
+  "B Vocals" -> Just PartBVocals
+  _          -> Nothing
+
+titleToAudioPart :: String -> Instrument -> Maybe AudioPart
+titleToAudioPart "Band" i = Just $ Without i
+titleToAudioPart s      _ = Only <$> titleToPart s
+
+partToInstrument :: Part -> Instrument
+partToInstrument p = case p of
+  PartGuitar1 -> Guitar
+  PartGuitar2 -> Guitar
+  PartBass    -> Bass
+  PartDrums   -> Drums
+  PartKeys1   -> Keyboard
+  PartKeys2   -> Keyboard
+  PartPiano   -> Keyboard
+  PartSynth   -> Keyboard
+  PartVocal   -> Vocal
+  PartBVocals -> Vocal
+
+audioPartToInstrument :: AudioPart -> Instrument
+audioPartToInstrument (Only    p) = partToInstrument p
+audioPartToInstrument (Without i) = i
+
+data Info = Info
+  { album        :: String
+  , artist       :: String
+  , bpm          :: String
+  , copyright    :: String
+  , countInBeats :: Integer
+  , courtesyOf   :: String
+  , demo         :: Bool
+  , explicit     :: Bool
+  , genre        :: String
+  , instrument   :: Instrument
+  , publishedBy  :: String
+  , skillLevel   :: Either Integer [(Instrument, Integer)]
+  , sku          :: String
+  , slow         :: Double
+  , title        :: String
+  , version      :: Integer
+  , writtenBy    :: String
+  } deriving (Eq, Ord, Show, Read)
+
+instance PL.PropertyListItem Info where
+
+  fromPropertyList pl = PL.fromPlDict pl >>= \dict -> Info
+    <$> (Map.lookup "album"        dict >>= PL.fromPlString)
+    <*> (Map.lookup "artist"       dict >>= PL.fromPlString)
+    <*> (Map.lookup "bpm"          dict >>= PL.fromPlString)
+    <*> (Map.lookup "copyright"    dict >>= PL.fromPlString)
+    <*> (Map.lookup "countInBeats" dict >>= PL.fromPlInt   )
+    <*> (Map.lookup "courtesyOf"   dict >>= PL.fromPlString)
+    <*> (Map.lookup "demo"         dict >>=    fromPlEnum  )
+    <*> (Map.lookup "explicit"     dict >>=    fromPlEnum  )
+    <*> (Map.lookup "genre"        dict >>= PL.fromPlString)
+    <*> (Map.lookup "instrument"   dict >>=    fromPlEnum  )
+    <*> (Map.lookup "publishedBy"  dict >>= PL.fromPlString)
+    <*> (Map.lookup "skillLevel"   dict >>=    fromPlSkills)
+    <*> (Map.lookup "sku"          dict >>= PL.fromPlString)
+    <*> (Map.lookup "slow"         dict >>= PL.fromPlReal  )
+    <*> (Map.lookup "title"        dict >>= PL.fromPlString)
+    <*> (Map.lookup "version"      dict >>= PL.fromPlInt   )
+    <*> (Map.lookup "writtenBy"    dict >>= PL.fromPlString)
+
+  toPropertyList info = PL.plDict $ Map.fromList
+    [ ("album"       , PL.plString $ album        info)
+    , ("artist"      , PL.plString $ artist       info)
+    , ("bpm"         , PL.plString $ bpm          info)
+    , ("copyright"   , PL.plString $ copyright    info)
+    , ("countInBeats", PL.plInt    $ countInBeats info)
+    , ("courtesyOf"  , PL.plString $ courtesyOf   info)
+    , ("demo"        ,    plEnum   $ demo         info)
+    , ("explicit"    ,    plEnum   $ explicit     info)
+    , ("genre"       , PL.plString $ genre        info)
+    , ("instrument"  ,    plEnum   $ instrument   info)
+    , ("publishedBy" , PL.plString $ publishedBy  info)
+    , ("skillLevel"  ,    plSkills $ skillLevel   info)
+    , ("sku"         , PL.plString $ sku          info)
+    , ("slow"        , PL.plReal   $ slow         info)
+    , ("title"       , PL.plString $ title        info)
+    , ("version"     , PL.plInt    $ version      info)
+    , ("writtenBy"   , PL.plString $ writtenBy    info)
+    ]
+
+fromPlEnum :: (Enum a) => PL.PropertyList -> Maybe a
+fromPlEnum pl = toEnum . fromIntegral <$> PL.fromPlInt pl
+
+plEnum :: (Enum a) => a -> PL.PropertyList
+plEnum = PL.plInt . fromIntegral . fromEnum
+
+fromPlSkills
+  :: PL.PropertyList -> Maybe (Either Integer [(Instrument, Integer)])
+fromPlSkills pl = case (PL.fromPlInt pl, PL.fromPlDict pl) of
+  (Nothing, Nothing) -> Nothing
+  (Just i , _      ) -> Just $ Left i
+  (_      , Just d ) -> let
+    getSkill (x, y) = liftA2 (,) (readMaybe $ capitalize x) (PL.fromPlInt y)
+    capitalize ""     = ""
+    capitalize (c:cs) = toUpper c : map toLower cs
+    in fmap Right $ mapM getSkill $ Map.toList d
+
+plSkills :: Either Integer [(Instrument, Integer)] -> PL.PropertyList
+plSkills (Left  i ) = PL.plInt i
+plSkills (Right sl) = PL.plDict $
+  Map.fromList $ map (map toLower . show *** PL.plInt) sl
+
+loadInfo :: FilePath -> IO (Maybe Info)
+loadInfo dir =
+  PL.fromPropertyList <$> readXmlPropertyListFromFile' (dir </> "info.plist")
+
+data Track = Track
+  { trackClass          :: String
+  , identifier          :: String
+  , scoreSystemHeight   :: Maybe Integer
+  , scoreSystemInterval :: Maybe Integer
+  , trackTitle          :: Maybe String
+  } deriving (Eq, Ord, Show, Read)
+
+instance PL.PropertyListItem Track where
+
+  toPropertyList t = PL.plDict $ Map.fromList $ catMaybes
+    [ Just ("class"              , PL.plString $ trackClass          t)
+    , Just ("identifier"         , PL.plString $ identifier          t)
+    , (\i -> ("scoreSystemHeight"  , PL.plInt    i)) <$> scoreSystemHeight   t
+    , (\i -> ("scoreSystemInterval", PL.plInt    i)) <$> scoreSystemInterval t
+    , (\s -> ("title"              , PL.plString s)) <$> trackTitle          t
+    ]
+
+  fromPropertyList pl = PL.fromPlDict pl >>= \d -> Track
+    <$>      (Map.lookup "class"               d >>= PL.fromPlString)
+    <*>      (Map.lookup "identifier"          d >>= PL.fromPlString)
+    <*> Just (Map.lookup "scoreSystemHeight"   d >>= PL.fromPlInt   )
+    <*> Just (Map.lookup "scoreSystemInterval" d >>= PL.fromPlInt   )
+    <*> Just (Map.lookup "title"               d >>= PL.fromPlString)
+
+loadTracks :: FilePath -> IO (Maybe [Track])
+loadTracks dir = PL.listFromPropertyList <$>
+  readXmlPropertyListFromFile' (dir </> "tracks.plist")
+
+-- | Reads strictly so as not to exhaust our allowed open files.
+readXmlPropertyListFromFile' :: FilePath -> IO PL.PropertyList
+readXmlPropertyListFromFile' f = do
+  str <- readFile f
+  _ <- evaluate $ length str
+  either fail return $ PL.readXmlPropertyList str
+
+-- | Tries to find the top-level Jammit library directory on Windows or
+-- Mac OS X.
+findJammitDir :: IO (Maybe FilePath)
+findJammitDir = case Info.os of
+  "mingw32" -> do
+    var <- lookupEnv "LocalAppData"
+    case var of
+      Just local -> jammitIn local
+      Nothing    -> return Nothing
+  "darwin" -> do
+    home <- Dir.getHomeDirectory
+    jammitIn $ home </> "Library" </> "Application Support"
+  _ -> return Nothing
+  where jammitIn dir = do
+          let jmt = dir </> "Jammit"
+          b <- Dir.doesDirectoryExist jmt
+          return $ guard b >> Just jmt
+
+-- | Gets the contents of a directory without the @.@ and @..@ special paths,
+-- and adds the directory to the front of all the names to make absolute paths.
+lsAbsolute :: FilePath -> IO [FilePath]
+lsAbsolute d =
+  map (d </>) . filter (`notElem` [".", ".."]) <$> Dir.getDirectoryContents d
+
+-- | Searches a directory and all subdirectories for folders containing a Jammit
+-- info file.
+songSubdirs :: FilePath -> IO [FilePath]
+songSubdirs dir = do
+  isSong <- Dir.doesFileExist $ dir </> "info.plist"
+  let here = [dir | isSong]
+  subdirs <- lsAbsolute dir >>= filterM Dir.doesDirectoryExist
+  (here ++) . concat <$> mapM songSubdirs subdirs
diff --git a/src/Sound/Jammit/Export.hs b/src/Sound/Jammit/Export.hs
new file mode 100644
--- /dev/null
+++ b/src/Sound/Jammit/Export.hs
@@ -0,0 +1,115 @@
+{- |
+Functions for exporting Jammit audio (as WAV) and sheet music (as PDF).
+-}
+module Sound.Jammit.Export
+( Library
+, fuzzySearchBy
+, exactSearchBy
+, loadLibrary
+, getAudioParts
+, getSheetParts
+, runAudio
+, runSheet
+) where
+
+import Control.Applicative ((<$>), liftA2)
+import Control.Monad (forM)
+import Data.Char (toLower)
+import Data.List (isInfixOf, transpose, sort, isPrefixOf)
+import Data.Maybe (catMaybes)
+
+import System.Directory (getDirectoryContents)
+import System.FilePath ((</>), splitFileName, takeFileName)
+
+import Sound.Jammit.Internal.AIFC2WAV
+import Sound.Jammit.Internal.ImageMagick
+import Sound.Jammit.Base
+import Sound.Jammit.Internal.Sox
+import Sound.Jammit.Internal.TempFile
+
+type Library = [(FilePath, Info, [Track])]
+
+-- | Filter the library based on some string selector. The selector is
+-- applied case-insensitively, and the song's field only has to contain the
+-- search term rather than match it exactly.
+fuzzySearchBy :: (Info -> String) -> String -> Library -> Library
+fuzzySearchBy f str = let str' = map toLower str in
+  filter $ \(_, info, _) -> str' `isInfixOf` map toLower (f info)
+
+-- | Filter the library based on some string selector. The selector must match
+-- exactly.
+exactSearchBy :: (Info -> String) -> String -> Library -> Library
+exactSearchBy f str = filter $ \(_, info, _) -> f info == str
+
+-- | Given the top-level Jammit library directory, finds all song packages.
+loadLibrary :: FilePath -> IO Library
+loadLibrary jmt = do
+  dirs <- songSubdirs jmt
+  fmap catMaybes $ forM dirs $ \d -> do
+    maybeInfo <- loadInfo   d
+    maybeTrks <- loadTracks d
+    return $ liftA2 (\i t -> (d, i, t)) maybeInfo maybeTrks
+
+-- | A mapping from audio part to absolute filename of an audio file.
+getAudioParts :: Library -> [(AudioPart, FilePath)]
+getAudioParts lib = do
+  (dir, info, trks) <- lib
+  trk <- trks
+  case trackTitle trk >>= \t -> titleToAudioPart t (instrument info) of
+    Nothing -> []
+    Just ap -> [(ap, dir </> (identifier trk ++ "_jcfx"))]
+
+-- | A mapping from sheet part to
+-- @(prefix of image files, line height in pixels)@.
+getSheetParts :: Library -> [(SheetPart, (FilePath, Integer))]
+getSheetParts lib = do
+  (dir, _info, trks) <- lib
+  trk <- trks
+  case (trackTitle trk >>= \t -> titleToPart t, scoreSystemInterval trk) of
+    (Just p, Just ht) -> let
+      sheet = (Notation p, (dir </> (identifier trk ++ "_jcfn"), ht))
+      tab   = (Tab      p, (dir </> (identifier trk ++ "_jcft"), ht))
+      in if elem (partToInstrument p) [Guitar, Bass]
+        then [sheet, tab]
+        else [sheet]
+    _ -> []
+
+runAudio
+  :: [FilePath] -- ^ AIFCs to mix in normally
+  -> [FilePath] -- ^ AIFCs to mix in inverted
+  -> FilePath   -- ^ the resulting WAV file
+  -> IO ()
+runAudio pos neg fout = runTempIO fout $ do
+  -- I've only found one audio file where the instruments are not aligned:
+  -- the drums and drums backing track for Take the Time are 38 samples ahead
+  -- of the other instruments. So as a hack, we pad the front of them by 38
+  -- samples to line things up.
+  let tttDrums     = "793EAAE0-6761-44D7-9A9A-1FB451A2A438_jcfx"
+      tttDrumsBack = "37EE5AA5-4049-4CED-844A-D34F6B165F67_jcfx"
+      aifcToWav' a = if takeFileName a `elem` [tttDrums, tttDrumsBack]
+        then Pad (Samples 38) . File <$> aifcToWav a
+        else File <$> aifcToWav a
+  posWavs <- map (\w -> ( 1, w)) <$> mapM aifcToWav' pos
+  negWavs <- map (\w -> (-1, w)) <$> mapM aifcToWav' neg
+  renderAudio $ optimize $ Mix (posWavs ++ negWavs)
+
+runSheet
+  :: [(FilePath, Integer)] -- ^ pairs of @(png file prefix, line height in px)@
+  -> Int                   -- ^ how many sheet music systems per page
+  -> FilePath              -- ^ the resulting PDF
+  -> IO ()
+runSheet trks lns fout = runTempIO fout $ do
+  trkLns <- forM trks $ \(fp, ht) -> do
+    let (dir, file) = splitFileName fp
+    ls <- liftIO $ getDirectoryContents dir
+    cnct <- connectVertical $
+      map (dir </>) $ sort $ filter (file `isPrefixOf`) ls
+    splitVertical ht cnct
+  pages <- forM (map concat $ chunksOf lns $ transpose trkLns) $ \pg ->
+    connectVertical pg
+  joinPages pages
+
+chunksOf :: Int -> [a] -> [[a]]
+chunksOf _ [] = []
+chunksOf n xs = case splitAt n xs of
+  (ys, zs) -> ys : chunksOf n zs
diff --git a/src/Sound/Jammit/Internal/AIFC2WAV.hs b/src/Sound/Jammit/Internal/AIFC2WAV.hs
new file mode 100644
--- /dev/null
+++ b/src/Sound/Jammit/Internal/AIFC2WAV.hs
@@ -0,0 +1,27 @@
+{-# LANGUAGE ForeignFunctionInterface #-}
+module Sound.Jammit.Internal.AIFC2WAV
+( aifcToWav
+) where
+
+import Foreign.C (withCString, CInt(..), CChar(..))
+import Foreign.Marshal.Array (withArrayLen)
+import Foreign.Ptr (Ptr)
+
+import Sound.Jammit.Internal.TempFile
+
+foreign import ccall unsafe "aifc2wav_main" aifc2wav_main
+  :: CInt -> Ptr (Ptr CChar) -> IO CInt
+
+-- | Given a (new-style) IMA4-compressed AIFC file, converts it to a WAV file.
+aifcToWav :: FilePath -> TempIO FilePath
+aifcToWav aifc = do
+  wav <- newTempFile "aifcToWav.wav"
+  code <- liftIO $
+    withCString "aifc2wav" $ \progC ->
+    withCString aifc       $ \aifcC ->
+    withCString wav        $ \wavC  ->
+    withArrayLen [progC, aifcC, wavC] $ \n v ->
+      aifc2wav_main (fromIntegral n) v
+  if code == 0
+    then return wav
+    else error $ "aifcToWav: returned " ++ show code
diff --git a/src/Sound/Jammit/Internal/ImageMagick.hs b/src/Sound/Jammit/Internal/ImageMagick.hs
new file mode 100644
--- /dev/null
+++ b/src/Sound/Jammit/Internal/ImageMagick.hs
@@ -0,0 +1,89 @@
+module Sound.Jammit.Internal.ImageMagick
+( connectVertical
+, splitVertical
+, joinPages
+) where
+
+import Control.Applicative ((<$>))
+import Control.Monad (void)
+import Data.Char (isDigit)
+import Data.List (isPrefixOf, sortBy)
+import Data.Maybe (listToMaybe)
+import Data.Ord (comparing)
+import System.Environment (lookupEnv)
+import System.Exit (ExitCode(..))
+import qualified System.Info as Info
+
+import System.Directory (getDirectoryContents)
+import System.FilePath ((</>))
+import System.IO.Temp (createTempDirectory)
+import System.Process (readProcess, readProcessWithExitCode)
+
+import Sound.Jammit.Internal.TempFile
+
+-- | Find an ImageMagick binary, because the names are way too generic, and
+-- "convert" is both an ImageMagick program and a Windows built-in utility.
+imageMagick :: String -> IO (Maybe String)
+imageMagick cmd = do
+  (code, _, _) <- readProcessWithExitCode cmd ["-version"] ""
+  case code of
+    ExitSuccess -> return $ Just cmd
+    _ -> case Info.os of
+      "mingw32" -> firstJustM $
+        -- env variables for different configs of (ghc arch)/(imagemagick arch)
+        -- ProgramFiles: 32/32 or 64/64
+        -- ProgramFiles(x86): 64/32
+        -- ProgramW6432: 32/64
+        flip map ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] $ \env ->
+          lookupEnv env >>= \var -> case var of
+            Nothing -> return Nothing
+            Just pf
+              ->  fmap (\im -> pf </> im </> cmd)
+              .   listToMaybe
+              .   filter ("ImageMagick" `isPrefixOf`)
+              <$> getDirectoryContents pf
+      _ -> return Nothing
+
+imageMagick' :: String -> IO String
+imageMagick' cmd = imageMagick cmd >>= \ms -> case ms of
+  Nothing -> error $ "imageMagick': couldn't find program " ++ cmd
+  Just s  -> return s
+
+-- | Only runs actions until the first that gives 'Just'.
+firstJustM :: (Monad m) => [m (Maybe a)] -> m (Maybe a)
+firstJustM [] = return Nothing
+firstJustM (mx : xs) = mx >>= \x -> case x of
+  Nothing -> firstJustM xs
+  Just y  -> return $ Just y
+
+-- | Stick images together vertically into one long image.
+connectVertical :: [FilePath] -> TempIO FilePath
+connectVertical [fin] = return fin
+connectVertical fins = do
+  cmd  <- liftIO $ imageMagick' "montage"
+  fout <- newTempFile "connectVertical.png"
+  void $ liftIO $ readProcess cmd
+    (["-geometry", "100%", "-tile", "1x"] ++ fins ++ [fout]) ""
+  return fout
+
+-- | Splits an image vertically into chunks of a given height.
+splitVertical :: Integer -> FilePath -> TempIO [FilePath]
+splitVertical i fin = do
+  cmd      <- liftIO $ imageMagick' "convert"
+  tempdir  <- ask
+  splitdir <- liftIO $ createTempDirectory tempdir "splitVertical"
+  void $ liftIO $
+    readProcess cmd ["-crop", "x" ++ show i, fin, splitdir </> "x.png"] ""
+  map (splitdir </>) . sortBy (comparing getNumber) . filter isFile
+    <$> liftIO (getDirectoryContents splitdir)
+  where getNumber :: String -> Integer
+        getNumber = read . takeWhile isDigit . dropWhile (not . isDigit)
+        isFile = (`notElem` [".", ".."])
+
+-- | Joins several images into a PDF, where each image is a page.
+joinPages :: [FilePath] -> TempIO FilePath
+joinPages fins = do
+  cmd  <- liftIO $ imageMagick' "convert"
+  fout <- newTempFile "joinPages.pdf"
+  void $ liftIO $ readProcess cmd (fins ++ [fout]) ""
+  return fout
diff --git a/src/Sound/Jammit/Internal/Sox.hs b/src/Sound/Jammit/Internal/Sox.hs
new file mode 100644
--- /dev/null
+++ b/src/Sound/Jammit/Internal/Sox.hs
@@ -0,0 +1,96 @@
+module Sound.Jammit.Internal.Sox
+( Audio(..)
+, Time(..)
+, renderAudio
+, optimize
+) where
+
+import Control.Arrow (first)
+import Control.Monad (void, forM, guard)
+
+import System.Process (readProcess)
+
+import Sound.Jammit.Internal.TempFile
+
+data Audio
+  = Empty                 -- ^ An empty stereo file
+  | File FilePath         -- ^ An existing (stereo) file
+  | Pad Time Audio        -- ^ Pad audio start with silence
+  | Mix [(Double, Audio)] -- ^ Add audio sample-wise, also multiplying volumes
+  | Concat [Audio]        -- ^ Sequentially connect audio
+  deriving (Eq, Ord, Show, Read)
+
+data Time
+  = Seconds Double
+  | Samples Integer
+  deriving (Eq, Ord, Show, Read)
+
+showTime :: Time -> String
+showTime (Seconds d) = show d
+showTime (Samples i) = show i ++ "s"
+
+renderAudio :: Audio -> TempIO FilePath
+renderAudio aud = case aud of
+  Empty -> do
+    fout <- newTempFile "render.wav"
+    void $ liftIO $
+      readProcess "sox" (["-n", fout] ++ words "trim 0 0 channels 2") ""
+    return fout
+  File f -> return f
+  Pad t x -> do
+    fin <- renderAudio x
+    fout <- newTempFile "render.wav"
+    void $ liftIO $ readProcess "sox" [fin, fout, "pad", showTime t] ""
+    return fout
+  Mix xs -> case xs of
+    [] -> renderAudio Empty
+    [(d, x)] -> do
+      fin <- renderAudio x
+      fout <- newTempFile "render.wav"
+      void $ liftIO $
+        readProcess "sox" ["-v", show d, fin, fout] ""
+      return fout
+    _ -> do
+      dfins <- forM xs $ \(d, x) -> do
+        fin <- renderAudio x
+        return (d, fin)
+      let argsin = concatMap
+            (\(d, fin) -> ["-v", show d, fin]) dfins
+      fout <- newTempFile "render.wav"
+      void $ liftIO $
+        readProcess "sox" (["--combine", "mix"] ++ argsin ++ [fout]) ""
+      return fout
+  Concat xs -> case xs of
+    [] -> renderAudio Empty
+    _ -> do
+      fins <- mapM renderAudio xs
+      fout <- newTempFile "render.wav"
+      void $ liftIO $ readProcess "sox" (fins ++ [fout]) ""
+      return fout
+
+optimize :: Audio -> Audio
+optimize aud = case aud of
+  Pad (Samples 0) x -> x
+  Pad (Seconds 0) x -> x
+  Mix xs -> let
+    xs' = do
+      (d, x) <- xs
+      guard $ d /= 0
+      case optimize x of
+        Mix ys -> map (first (* d)) ys
+        x'     -> [(d, x')]
+    in case xs' of
+      []       -> Empty
+      [(1, x)] -> x
+      _        -> Mix xs'
+  Concat xs -> let
+    xs' = do
+      x <- xs
+      case optimize x of
+        Concat ys -> ys
+        x'        -> [x']
+    in case xs' of
+      []  -> Empty
+      [x] -> x
+      _   -> Concat xs'
+  _ -> aud
diff --git a/src/Sound/Jammit/Internal/TempFile.hs b/src/Sound/Jammit/Internal/TempFile.hs
new file mode 100644
--- /dev/null
+++ b/src/Sound/Jammit/Internal/TempFile.hs
@@ -0,0 +1,41 @@
+-- | A wrapper around IO that allows you to treat temporary files like
+-- garbage-collected values.
+module Sound.Jammit.Internal.TempFile
+( TempIO
+, runTempIO
+, newTempFile
+, ask
+, liftIO
+) where
+
+import Data.List (stripPrefix)
+import System.IO (hClose)
+import System.IO.Error (catchIOError)
+
+import Control.Monad.IO.Class (liftIO)
+import Control.Monad.Trans.Reader (ask, ReaderT(..))
+import System.Directory (copyFile, renameFile)
+import System.FilePath (splitPath)
+import System.IO.Temp (openTempFile, withSystemTempDirectory)
+
+-- | A wrapper around IO with a designated directory for temporary files.
+type TempIO = ReaderT FilePath IO
+
+-- | Creates a new temporary directory to run a computation in. When finished,
+-- the final file will be moved/copied to the given path, and the temporary
+-- directory will be deleted.
+runTempIO :: FilePath -> TempIO FilePath -> IO ()
+runTempIO fout act = withSystemTempDirectory "tempfile" $ \tmp -> do
+  res <- runReaderT act tmp
+  case stripPrefix (splitPath tmp) (splitPath res) of
+    Just f | ".." `notElem` f -> -- try rename if we know the file is in tmp
+      catchIOError (renameFile res fout) $ \_ -> copyFile res fout
+    _ -> copyFile res fout
+
+-- | Creates a new file in the temporary directory, given a template.
+newTempFile :: String -> TempIO FilePath
+newTempFile pat = do
+  tmp <- ask
+  (f, h) <- liftIO $ openTempFile tmp pat
+  liftIO $ hClose h
+  return f
diff --git a/src/Sox.hs b/src/Sox.hs
deleted file mode 100644
--- a/src/Sox.hs
+++ /dev/null
@@ -1,96 +0,0 @@
-module Sox
-( Audio(..)
-, Time(..)
-, renderAudio
-, optimize
-) where
-
-import Control.Arrow (first)
-import Control.Monad (void, forM, guard)
-
-import System.Process (readProcess)
-
-import TempFile
-
-data Audio
-  = Empty                 -- ^ An empty stereo file
-  | File FilePath         -- ^ An existing (stereo) file
-  | Pad Time Audio        -- ^ Pad audio start with silence
-  | Mix [(Double, Audio)] -- ^ Add audio sample-wise, also multiplying volumes
-  | Concat [Audio]        -- ^ Sequentially connect audio
-  deriving (Eq, Ord, Show, Read)
-
-data Time
-  = Seconds Double
-  | Samples Integer
-  deriving (Eq, Ord, Show, Read)
-
-showTime :: Time -> String
-showTime (Seconds d) = show d
-showTime (Samples i) = show i ++ "s"
-
-renderAudio :: Audio -> TempIO FilePath
-renderAudio aud = case aud of
-  Empty -> do
-    fout <- newTempFile "render.wav"
-    void $ liftIO $
-      readProcess "sox" (["-n", fout] ++ words "trim 0 0 channels 2") ""
-    return fout
-  File f -> return f
-  Pad t x -> do
-    fin <- renderAudio x
-    fout <- newTempFile "render.wav"
-    void $ liftIO $ readProcess "sox" [fin, fout, "pad", showTime t] ""
-    return fout
-  Mix xs -> case xs of
-    [] -> renderAudio Empty
-    [(d, x)] -> do
-      fin <- renderAudio x
-      fout <- newTempFile "render.wav"
-      void $ liftIO $
-        readProcess "sox" ["-v", show d, fin, fout] ""
-      return fout
-    _ -> do
-      dfins <- forM xs $ \(d, x) -> do
-        fin <- renderAudio x
-        return (d, fin)
-      let argsin = concatMap
-            (\(d, fin) -> ["-v", show d, fin]) dfins
-      fout <- newTempFile "render.wav"
-      void $ liftIO $
-        readProcess "sox" (["--combine", "mix"] ++ argsin ++ [fout]) ""
-      return fout
-  Concat xs -> case xs of
-    [] -> renderAudio Empty
-    _ -> do
-      fins <- mapM renderAudio xs
-      fout <- newTempFile "render.wav"
-      void $ liftIO $ readProcess "sox" (fins ++ [fout]) ""
-      return fout
-
-optimize :: Audio -> Audio
-optimize aud = case aud of
-  Pad (Samples 0) x -> x
-  Pad (Seconds 0) x -> x
-  Mix xs -> let
-    xs' = do
-      (d, x) <- xs
-      guard $ d /= 0
-      case optimize x of
-        Mix ys -> map (first (* d)) ys
-        x'     -> [(d, x')]
-    in case xs' of
-      []       -> Empty
-      [(1, x)] -> x
-      _        -> Mix xs'
-  Concat xs -> let
-    xs' = do
-      x <- xs
-      case optimize x of
-        Concat ys -> ys
-        x'        -> [x']
-    in case xs' of
-      []  -> Empty
-      [x] -> x
-      _   -> Concat xs'
-  _ -> aud
diff --git a/src/TempFile.hs b/src/TempFile.hs
deleted file mode 100644
--- a/src/TempFile.hs
+++ /dev/null
@@ -1,41 +0,0 @@
--- | A wrapper around IO that allows you to treat temporary files like
--- garbage-collected values.
-module TempFile
-( TempIO
-, runTempIO
-, newTempFile
-, ask
-, liftIO
-) where
-
-import Data.List (stripPrefix)
-import System.IO (hClose)
-import System.IO.Error (catchIOError)
-
-import Control.Monad.IO.Class (liftIO)
-import Control.Monad.Trans.Reader (ask, ReaderT(..))
-import System.Directory (copyFile, renameFile)
-import System.FilePath (splitPath)
-import System.IO.Temp (openTempFile, withSystemTempDirectory)
-
--- | A wrapper around IO with a designated directory for temporary files.
-type TempIO = ReaderT FilePath IO
-
--- | Creates a new temporary directory to run a computation in. When finished,
--- the final file will be moved/copied to the given path, and the temporary
--- directory will be deleted.
-runTempIO :: FilePath -> TempIO FilePath -> IO ()
-runTempIO fout act = withSystemTempDirectory "tempfile" $ \tmp -> do
-  res <- runReaderT act tmp
-  case stripPrefix (splitPath tmp) (splitPath res) of
-    Just f | ".." `notElem` f -> -- try rename if we know the file is in tmp
-      catchIOError (renameFile res fout) $ \_ -> copyFile res fout
-    _ -> copyFile res fout
-
--- | Creates a new file in the temporary directory, given a template.
-newTempFile :: String -> TempIO FilePath
-newTempFile pat = do
-  tmp <- ask
-  (f, h) <- liftIO $ openTempFile tmp pat
-  liftIO $ hClose h
-  return f
