packages feed

lame-0.2.0: Codec/Audio/LAME.hs

-- |
-- Module      :  Codec.Audio.LAME
-- Copyright   :  © 2017–2019 Mark Karpov
-- License     :  BSD 3 clause
--
-- Maintainer  :  Mark Karpov <markkarpov92@gmail.com>
-- Stability   :  experimental
-- Portability :  portable
--
-- The module provides an interface to LAME MP3 encoder. All you need to do
-- to encode a WAVE (or RF64) file is to call 'encodeMp3', which see.

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards   #-}

module Codec.Audio.LAME
  ( encodeMp3
  , EncoderSettings (..)
  , defaultEncoderSettings
  , Compression (..)
  , VbrMode (..)
  , MetadataPlacement (..)
  , FilterSetup (..)
  , I.LameException (..) )
where

import Codec.Audio.Wave
import Control.Monad
import Control.Monad.Catch
import Control.Monad.IO.Class
import Data.ByteString (ByteString)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Word
import System.Directory
import System.FilePath
import System.IO
import qualified Codec.Audio.LAME.Internal as I
import qualified Data.Text                 as T

-- | LAME encoder settings.
--
-- Currently some noise-shaping and psycho-acoustic parameters are omitted,
-- but can be added on request.

data EncoderSettings = EncoderSettings
  { encoderCompression :: !Compression
    -- ^ How to determine amount of compression to apply, see 'Compression'.
    -- Default value: @'CompressionRatio' 11@ (corresponds to 128 kbps).
  , encoderQuality :: !(Maybe Word)
    -- ^ Select algorithm. This variable will effect quality by selecting
    -- expensive or cheap algorithms. 0 gives the best quality (very slow). 9 is
    -- very fast, but gives worst quality.
    --
    --     * 2—near-best quality, not too slow.
    --     * 5—good quality, fast.
    --     * 7—OK quality, really fast.
    --
    -- Default value: 'Nothing', meaning that LAME will select the best
    -- value itself.
  , encoderNoGap :: !(Maybe (Word32, Word32))
    -- ^ Enable “no gap” encoding. The first value in the tuple specifies
    -- current index and the second value specifies total number of tracks.
    -- Default value: 'Nothing'—disabled.
  , encoderErrorProtection :: !Bool
    -- ^ Whether to enable error protection. Error protection means that 2
    -- bytes from each frame for CRC checksum. Default value: 'False'.
  , encoderStrictISO :: !Bool
    -- ^ Whether to enforce strict ISO compliance. Default value: 'False'.
  , encoderSampleRate :: !(Maybe Word32)
    -- ^ Sample rate of output file. Default value: 'Nothing', which means
    -- use sample rate of input file.
  , encoderScale :: !Float
    -- ^ Scale input stream by multiplying samples by this value. Default
    -- value: 1 (no scaling).
  , encoderFreeFormat :: !Bool
    -- ^ Use free format. Default value: 'False'.
  , encoderCopyright :: !Bool
    -- ^ Mark as copyright protected. Default value: 'False'.
  , encoderOriginal :: !Bool
    -- ^ Mark as original. Default value: 'True'.
  , encoderWriteVbrTag :: !Bool
    -- ^ Whether to write Xing VBR header frame. Default value: 'True'.
  , encoderMetadataPlacement :: !MetadataPlacement
    -- ^ Which container to use to write metadata. Default value:
    -- 'Id3v2Only'.
  , encoderTagTitle :: !(Maybe Text)
    -- ^ Title tag to write. Default value: 'Nothing'.
  , encoderTagArtist :: !(Maybe Text)
    -- ^ Artist tag to write. Default value: 'Nothing'.
  , encoderTagAlbum :: !(Maybe Text)
    -- ^ Album tag to write. Default value: 'Nothing'.
  , encoderTagYear :: !(Maybe Text)
    -- ^ Year tag to write. Default value: 'Nothing'.
  , encoderTagComment :: !(Maybe Text)
    -- ^ Comment tag to write. Default value: 'Nothing'.
  , encoderTagTrack :: !(Maybe (Word8, Maybe Word8))
    -- ^ Track number (first in tuple) and (optionally) total number of
    -- tracks (second in tuple). Default value: 'Nothing'.
  , encoderTagGenre :: !(Maybe Text)
    -- ^ Genre tag to write. Default value: 'Nothing'.
  , encoderAlbumArt :: !(Maybe ByteString)
    -- ^ Album art (a picture) to write (JPEG\/PNG\/GIF). Default value:
    -- 'Nothing'.
  , encoderLowpassFilter :: !FilterSetup
    -- ^ Settings for low-pass filter. Default value: 'FilterAuto'.
  , encoderHighpassFilter :: !FilterSetup
    -- ^ Settings for high-pass filter. Default value: 'FilterAuto'.
  } deriving (Show, Read, Eq, Ord)

-- | Default value of 'EncoderSettings'.
--
-- @since 0.2.0

defaultEncoderSettings :: EncoderSettings
defaultEncoderSettings = EncoderSettings
  { encoderScale           = 1
  , encoderSampleRate      = Nothing
  , encoderFreeFormat      = False
  , encoderNoGap           = Nothing
  , encoderCopyright       = False
  , encoderOriginal        = True
  , encoderErrorProtection = False
  , encoderStrictISO       = False
  , encoderWriteVbrTag     = True
  , encoderQuality         = Nothing
  , encoderCompression     = CompressionRatio 11
  , encoderMetadataPlacement = Id3v2Only
  , encoderTagTitle        = Nothing
  , encoderTagArtist       = Nothing
  , encoderTagAlbum        = Nothing
  , encoderTagYear         = Nothing
  , encoderTagComment      = Nothing
  , encoderTagTrack        = Nothing
  , encoderTagGenre        = Nothing
  , encoderAlbumArt        = Nothing
  , encoderLowpassFilter   = FilterAuto
  , encoderHighpassFilter  = FilterAuto
  }

-- | The data type represents supported options for compression. You can
-- specify either fixed bitrate, compression ratio, or use the VBR mode.

data Compression
  = CompressionBitrate Word
    -- ^ Specify compression by setting a fixed bitrate, e.g.
    -- @'CompressionBitrate' 320@.
  | CompressionRatio Float
    -- ^ Specify compression ratio.
  | CompressionVBR VbrMode Word
    -- ^ VBR. Here you can specify which mode to use and the second argument
    -- is VBR quality from 0 (highest), to 9 (lowest). Good default is 4.
  deriving (Show, Read, Eq, Ord)

-- | Variable bitrate (VBR) modes with their associated parameters.

data VbrMode
  = VbrRh
    -- ^ VBR RH.
  | VbrAbr (Maybe Word) (Maybe Word) (Maybe Word) Bool
    -- ^ VBR ABR. Parameters of the data constructor in order:
    --
    --     * Minimal bitrate
    --     * Mean bitrate
    --     * Maximal bitrate
    --     * “Hard minimal bitrate”, if 'True', then the specified minimal
    --       bitrate will be enforced in all cases (normally it's violated
    --       for silence).
  | VbrMtrh
    -- ^ VBR MTRH
  deriving (Show, Read, Eq, Ord)

-- | Which container to use to write tags\/metadata.

data MetadataPlacement
  = Id3v1Only          -- ^ Only write ID3v1 metadata
  | Id3v2Only          -- ^ Only write ID3v2 metadata
  | Id3Both            -- ^ Write ID3v1 and ID3v2
  deriving (Show, Read, Eq, Ord)

-- | Settings for a filter.

data FilterSetup
  = FilterAuto         -- ^ Filter settings are chosen automatically
  | FilterDisabled     -- ^ Filter is disabled
  | FilterManual Word (Maybe Word)
    -- ^ The first parameter is the filter's frequency (cut-off frequency)
    -- in Hz and the second parameter is width of transition band in Hz (if
    -- 'Nothing', it's chosen automatically).
  deriving (Show, Read, Eq, Ord)

-- | Encode a WAVE file or RF64 file to MP3.
--
-- If the input file is not a valid WAVE file, 'WaveException' will be
-- thrown. 'I.LameException' is thrown when underlying LAME encoder reports
-- a problem.
--
-- Not all sample formats and bit depth are currently supported. Supported
-- sample formats include:
--
--     * PCM with samples represented as signed integers > 8 bit and <= 16
--       bit per mono-sample.
--     * IEEE floating point (32 bit) samples.
--     * IEEE floating point (64 bit) samples.
--
-- If you feed the encoder with something else, expect
-- 'I.LameUnsupportedSampleFormat' to be thrown.

encodeMp3 :: MonadIO m
  => EncoderSettings   -- ^ Encoder settings
  -> FilePath          -- ^ WAVE file to encode
  -> FilePath          -- ^ Where to save the resulting MP3 file
  -> m ()
encodeMp3 EncoderSettings {..} ipath' opath' = liftIO . I.withLame $ \l -> do
  ipath <- makeAbsolute ipath'
  opath <- makeAbsolute opath'
  wave@Wave {..}  <- readWaveFile ipath
  case waveSampleFormat of
    SampleFormatPcmInt bps ->
      when (bps > 16 || bps <= 8) $
        throwM (I.LameUnsupportedSampleFormat waveSampleFormat)
    _ -> return ()
  I.setNumSamples l waveSamplesTotal
  I.setInputSampleRate l (fromIntegral waveSampleRate)
  I.setNumChannels l (fromIntegral $ waveChannels wave)
  I.setScale l encoderScale
  let targetSampleRate = fromMaybe waveSampleRate encoderSampleRate
  I.setOutputSampleRate l (fromIntegral targetSampleRate)
  I.setFreeFormat l encoderFreeFormat
  forM_ encoderNoGap $ \(currentIndex, total) -> do
    I.setNoGapCurrentIndex l (fromIntegral currentIndex)
    I.setNoGapTotal        l (fromIntegral total)
  I.setCopyright l encoderCopyright
  I.setOriginal l encoderOriginal
  I.setErrorProtection l encoderErrorProtection
  I.setStrictISO l encoderStrictISO
  I.setWriteVbrTag l encoderWriteVbrTag
  forM_ encoderQuality (I.setQuality l . fromIntegral . min 9)
  case encoderCompression of
    CompressionBitrate brate ->
      I.setBitrate l (fromIntegral brate)
    CompressionRatio ratio ->
      I.setCompressionRatio l ratio
    CompressionVBR vbrMode vbrQuality -> do
      (I.setVBRQ l . fromIntegral . min 9) vbrQuality
      case vbrMode of
        VbrRh -> I.setVBR l I.VbrRh
        VbrAbr minRate meanRate maxRate hardMin -> do
          I.setVBR l I.VbrAbr
          forM_ minRate  (I.setVBRMinBitrate  l . fromIntegral)
          forM_ meanRate (I.setVBRMeanBitrate l . fromIntegral)
          forM_ maxRate  (I.setVBRMaxBitrate  l . fromIntegral)
          I.setVBRHardMin l hardMin
        VbrMtrh -> I.setVBR l I.VbrMtrh
  I.id3TagInit l
  case encoderMetadataPlacement of
    Id3v1Only -> I.id3TagV1Only l
    Id3v2Only -> I.id3TagV2Only l
    Id3Both   -> I.id3TagAddV2  l
  forM_ encoderTagTitle   (I.id3TagSetTextInfo l "TIT2")
  forM_ encoderTagArtist  (I.id3TagSetTextInfo l "TPE1")
  forM_ encoderTagAlbum   (I.id3TagSetTextInfo l "TALB")
  forM_ encoderTagYear    (I.id3TagSetTextInfo l "TYER")
  forM_ encoderTagComment (I.id3TagSetComment l)
  forM_ encoderTagTrack   (uncurry renderTrackNumber >=> I.id3TagSetTextInfo l "TRCK")
  forM_ encoderTagGenre   (I.id3TagSetTextInfo l "TCON")
  forM_ encoderAlbumArt   (I.id3TagSetAlbumArt l)
  setupFilter I.setLowpassFreq  I.setLowpassWidth  l encoderLowpassFilter
  setupFilter I.setHighpassFreq I.setHighpassWidth l encoderHighpassFilter
  I.initParams l
  withTempFile' opath $ \otemp -> do
    I.encodingHelper l wave ipath otemp
    renameFile otemp opath

----------------------------------------------------------------------------
-- Helpers

-- | Setup a filter for given 'I.Lame'.

setupFilter
  :: (I.Lame -> Int -> IO ()) -- ^ How to set cut-off frequncy
  -> (I.Lame -> Int -> IO ()) -- ^ How to set width of transition band
  -> I.Lame            -- ^ Settings to edit
  -> FilterSetup       -- ^ Filter setup to apply
  -> IO ()
setupFilter setFreq _ l FilterAuto = setFreq l 0
setupFilter setFreq _ l FilterDisabled = setFreq l (-1)
setupFilter setFreq setWidth l (FilterManual freq mwidth) = do
  setFreq l (fromIntegral freq)
  forM_ mwidth (setWidth l . fromIntegral)

-- | A custom wrapper for creating temporary files in the same directory as
-- given file. 'Handle' is not opened, you only get 'FilePath' in the
-- callback.

withTempFile' :: FilePath -> (FilePath -> IO a) -> IO a
withTempFile' path = bracketOnError acquire cleanup
  where
    acquire = do
      (path',h) <- openBinaryTempFile dir file
      hClose h
      return path'
    cleanup = ignoringIOErrors . removeFile -- in case exception strikes
              -- before we create the actual file
    dir     = takeDirectory path
    file    = takeFileName  path

-- | Perform specified action ignoring IO exceptions it may throw.

ignoringIOErrors :: IO () -> IO ()
ignoringIOErrors ioe = ioe `catch` handler
  where
    handler :: IOError -> IO ()
    handler = const (return ())

-- | Render track number and optionally total number of tracks as a strict
-- 'Text' value.

renderTrackNumber :: Word8 -> Maybe Word8 -> IO Text
renderTrackNumber 0 t          = throwM (I.LameInvalidTrackNumber 0 t)
renderTrackNumber n t@(Just 0) = throwM (I.LameInvalidTrackNumber n t)
renderTrackNumber n t = return . T.pack $ show n ++ maybe "" (("/" ++) . show) t