packages feed

dahdit-audio-0.8.0: src/Dahdit/Audio/Aiff.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE NoStarIsType #-}
{-# OPTIONS_GHC -fplugin GHC.TypeLits.KnownNat.Solver #-}

module Dahdit.Audio.Aiff
  ( PascalString (..)
  , Chunk (..)
  , KnownChunk (..)
  , Variant (..)
  , AiffCommonBody (..)
  , AiffCommonChunk
  , AiffDataBody (..)
  , AiffDataChunk
  , AiffChunk (..)
  , Aiff (..)
  , lookupAiffChunk
  , lookupAiffCommonChunk
  , lookupAiffDataChunk
  , aiffToPcmContainer
  , aiffGatherMarkers
  )
where

import Control.Monad (unless)
import Dahdit
  ( Binary (..)
  , ByteArray
  , ByteCount (..)
  , Get
  , Put
  , ShortByteString
  , StaticByteSized (..)
  , StaticBytes (..)
  , ViaGeneric (..)
  , ViaStaticGeneric (..)
  , Word16BE
  , Word32BE (..)
  , byteSizeFoldable
  , getByteString
  , getExact
  , getLookAhead
  , getRemainingByteArray
  , getRemainingSeq
  , getSeq
  , getSkip
  , getWord16BE
  , getWord8
  , putByteArray
  , putByteString
  , putSeq
  , putWord16BE
  , putWord8
  )
import Dahdit.Audio.Common
  ( ChunkHeaderSize
  , ConvertErr
  , CountSize
  , KnownLabel (..)
  , Label
  , LabelSize
  , PadCount
  , SimpleMarker (..)
  , UnparsedBody
  , chunkHeaderSize
  , countSize
  , dedupeSimpleMarkers
  , getChunkSizeBE
  , getExpectLabel
  , guardChunk
  , labelSize
  , padCount
  , putChunkSizeBE
  )
import Dahdit.Audio.Dsp (PcmContainer (PcmContainer), PcmMeta (PcmMeta))
import Data.ByteString.Char8 qualified as BSC
import Data.ByteString.Short qualified as BSS
import Data.Default (Default (..))
import Data.Foldable (toList)
import Data.Maybe (fromMaybe)
import Data.Primitive.ByteArray (byteArrayFromListN, indexByteArray, sizeofByteArray)
import Data.Proxy (Proxy (..))
import Data.Sequence (Seq (..))
import Data.Sequence qualified as Seq
import Data.Word (Word8)
import GHC.Generics (Generic)
import GHC.TypeLits (type (*), type (+))

-- AIFF-C file parsing according to
-- http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/AIFF/AIFF.html
-- We only bother to support "sowt" compression (little endian samples)

-- AIFF shares a similar 4-byte label + size + payload structure with RIFF
-- We could use a lot of the same structures to read the file... If they were
-- big-endian.

labelForm, labelAifc, labelAiff, labelComm, labelSsnd, labelFver, labelAnno, labelMark, labelSowt :: Label
labelForm = "FORM"
labelAifc = "AIFC"
labelAiff = "AIFF"
labelComm = "COMM"
labelSsnd = "SSND"
labelFver = "FVER"
labelAnno = "ANNO"
labelMark = "MARK"
labelSowt = "sowt"

data Chunk a = Chunk
  { chunkLabel :: !Label
  , chunkBody :: !a
  }
  deriving stock (Eq, Show)

chunkUnpaddedByteSize :: (Binary a) => Chunk a -> ByteCount
chunkUnpaddedByteSize (Chunk _ body) = byteSize body

instance (StaticByteSized a) => StaticByteSized (Chunk a) where
  type StaticSize (Chunk a) = PadCount (ChunkHeaderSize + StaticSize a)

instance (Binary a) => Binary (Chunk a) where
  byteSize c = padCount (chunkHeaderSize + chunkUnpaddedByteSize c)
  get = do
    lab <- get
    usz <- getChunkSizeBE
    body <- getExact usz get
    unless (even usz) (getSkip 1)
    pure $! Chunk lab body
  put c@(Chunk lab body) = do
    put lab
    let !usz = chunkUnpaddedByteSize c
    putChunkSizeBE usz
    put body
    unless (even usz) (putWord8 0)

newtype KnownChunk a = KnownChunk
  { knownChunkBody :: a
  }
  deriving stock (Show)
  deriving newtype (Eq, Default)

knownChunkUnpaddedByteSize :: (Binary a) => KnownChunk a -> ByteCount
knownChunkUnpaddedByteSize (KnownChunk body) = byteSize body

instance (StaticByteSized a) => StaticByteSized (KnownChunk a) where
  type StaticSize (KnownChunk a) = PadCount (ChunkHeaderSize + StaticSize a)

instance (Binary a, KnownLabel a) => Binary (KnownChunk a) where
  byteSize kc = padCount (chunkHeaderSize + knownChunkUnpaddedByteSize kc)
  get = do
    getExpectLabel (knownLabel (Proxy :: Proxy a))
    usz <- getChunkSizeBE
    body <- getExact usz get
    unless (even usz) (getSkip 1)
    pure $! KnownChunk body
  put kc@(KnownChunk body) = do
    put (knownLabel (Proxy :: Proxy a))
    let !usz = knownChunkUnpaddedByteSize kc
    putChunkSizeBE usz
    put body
    unless (even usz) (putWord8 0)

-- | A "Pascal-style string" with a leading byte count and optional
-- trailing padding byte to make total length even.
newtype PascalString = PascalString {unPascalString :: ShortByteString}
  deriving stock (Show)
  deriving newtype (Eq)

instance Default PascalString where
  def = PascalString BSS.empty

instance Binary PascalString where
  byteSize (PascalString sbs) = padCount (ByteCount (BSS.length sbs + 1))
  get = do
    usz <- fmap fromIntegral getWord8
    sbs <- getByteString usz
    unless (odd usz) (getSkip 1)
    pure $! PascalString sbs
  put (PascalString sbs) = do
    let !usz = fromIntegral (BSS.length sbs)
    putWord8 usz
    putByteString sbs
    unless (odd usz) (putWord8 0)

notCompressed :: PascalString
notCompressed = PascalString (BSS.toShort (BSC.pack "not compressed"))

-- | "80 bit IEEE Standard 754 floating point number"
newtype ExtendedFloat = ExtendedFloat {unExtendedFloat :: StaticBytes 10}
  deriving stock (Show)
  deriving newtype (Eq, Default, StaticByteSized, Binary)

data AiffCommonBody = AiffCommonBody
  { aceNumChannels :: !Word16BE
  , aceNumSampleFrames :: !Word32BE
  , aceSampleSize :: !Word16BE
  , aceSampleRate :: !ExtendedFloat
  , aceCompressionType :: !Label
  , aceCompressionName :: !PascalString
  }
  deriving stock (Eq, Show, Generic)
  deriving (Binary) via (ViaGeneric AiffCommonBody)

instance KnownLabel AiffCommonBody where
  knownLabel _ = labelComm

type AiffCommonChunk = KnownChunk AiffCommonBody

data AiffCommonBodyOld = AiffCommonBodyOld
  { aceoNumChannels :: !Word16BE
  , aceoNumSampleFrames :: !Word32BE
  , aceoSampleSize :: !Word16BE
  , aceoSampleRate :: !ExtendedFloat
  }
  deriving stock (Eq, Show, Generic)
  deriving (Binary) via (ViaStaticGeneric AiffCommonBodyOld)

instance KnownLabel AiffCommonBodyOld where
  knownLabel _ = labelComm

type AiffCommonChunkOld = KnownChunk AiffCommonBodyOld

oldToNewCommonChunk :: KnownChunk AiffCommonBodyOld -> KnownChunk AiffCommonBody
oldToNewCommonChunk (KnownChunk (AiffCommonBodyOld x y z w)) = KnownChunk (AiffCommonBody x y z w labelSowt notCompressed)

newToOldCommonChunk :: KnownChunk AiffCommonBody -> KnownChunk AiffCommonBodyOld
newToOldCommonChunk (KnownChunk (AiffCommonBody x y z w _ _)) = KnownChunk (AiffCommonBodyOld x y z w)

getCommonChunk :: Variant -> Get AiffCommonChunk
getCommonChunk = \case
  VariantAiff -> fmap oldToNewCommonChunk get
  VariantAifc -> get

putCommonChunk :: Variant -> AiffCommonChunk -> Put
putCommonChunk = \case
  VariantAiff -> put . newToOldCommonChunk
  VariantAifc -> put

data AiffDataBody = AiffDataBody
  { adbOffset :: !Word32BE
  , adbBlockSize :: !Word32BE
  , adbSoundData :: !ByteArray
  }
  deriving stock (Eq, Show, Generic)

instance Binary AiffDataBody where
  byteSize (AiffDataBody _ _ arr) = 8 + fromIntegral (sizeofByteArray arr)
  get = do
    adbOffset <- get
    unless (adbOffset == 0) (fail "need zero offset")
    adbBlockSize <- get
    adbSoundData <- getRemainingByteArray
    pure $! AiffDataBody {..}
  put (AiffDataBody {..}) = do
    put adbOffset
    put adbBlockSize
    putByteArray adbSoundData

instance KnownLabel AiffDataBody where
  knownLabel _ = labelSsnd

type AiffDataChunk = KnownChunk AiffDataBody

type AiffVersionChunk = Chunk UnparsedBody

type AiffAnnoChunk = Chunk UnparsedBody

data AiffMark = AiffMark
  { amId :: !Word16BE
  , amPosition :: !Word32BE
  , amName :: !PascalString
  }
  deriving stock (Eq, Show, Generic)
  deriving (Binary) via (ViaGeneric AiffMark)

newtype AiffMarkBody = AiffMarkBody
  { ambMarkers :: Seq AiffMark
  }
  deriving stock (Show)
  deriving newtype (Eq)

instance Binary AiffMarkBody where
  byteSize (AiffMarkBody marks) = 2 + byteSizeFoldable marks
  get = do
    ec <- fmap fromIntegral getWord16BE
    marks <- getSeq ec get
    pure $! AiffMarkBody marks
  put (AiffMarkBody marks) = do
    putWord16BE (fromIntegral (Seq.length marks))
    putSeq put marks

instance KnownLabel AiffMarkBody where
  knownLabel _ = labelMark

type AiffMarkChunk = KnownChunk AiffMarkBody

type AiffUnparsedChunk = Chunk UnparsedBody

data AiffChunk
  = AiffChunkCommon !AiffCommonChunk
  | AiffChunkData !AiffDataChunk
  | AiffChunkVersion !AiffVersionChunk
  | AiffChunkAnno !AiffAnnoChunk
  | AiffChunkMark !AiffMarkChunk
  | AiffChunkUnparsed !AiffUnparsedChunk
  deriving stock (Eq, Show, Generic)

getChunk :: Variant -> Get AiffChunk
getChunk variant = do
  label <- getLookAhead get
  if
    | label == labelComm -> fmap AiffChunkCommon (getCommonChunk variant)
    | label == labelSsnd -> fmap AiffChunkData get
    | label == labelFver -> fmap AiffChunkVersion get
    | label == labelAnno -> fmap AiffChunkAnno get
    | label == labelMark -> fmap AiffChunkMark get
    | otherwise -> fmap AiffChunkUnparsed get

putChunk :: Variant -> AiffChunk -> Put
putChunk variant = \case
  AiffChunkCommon x -> putCommonChunk variant x
  AiffChunkData x -> put x
  AiffChunkVersion x -> put x
  AiffChunkAnno x -> put x
  AiffChunkMark x -> put x
  AiffChunkUnparsed x -> put x

instance Binary AiffChunk where
  byteSize = \case
    AiffChunkCommon x -> byteSize x
    AiffChunkData x -> byteSize x
    AiffChunkVersion x -> byteSize x
    AiffChunkAnno x -> byteSize x
    AiffChunkMark x -> byteSize x
    AiffChunkUnparsed x -> byteSize x
  get = getChunk VariantAifc
  put = putChunk VariantAifc

data Variant = VariantAiff | VariantAifc
  deriving stock (Eq, Show)

data AiffHeader = AiffHeader
  { ahVariant :: !Variant
  , ahRemainingSize :: ByteCount
  }
  deriving stock (Eq, Show, Generic)

type AiffHeaderSize = 2 * LabelSize + CountSize

aiffHeaderSize :: ByteCount
aiffHeaderSize = 2 * labelSize + countSize

instance StaticByteSized AiffHeader where
  type StaticSize AiffHeader = AiffHeaderSize
  staticByteSize _ = aiffHeaderSize

instance Binary AiffHeader where
  get = do
    getExpectLabel labelForm
    sz <- getChunkSizeBE
    label <- get @Label
    variant <-
      if
        | label == labelAiff -> pure VariantAiff
        | label == labelAifc -> pure VariantAifc
        | otherwise -> fail ("Expected label AIFC or AIFF in header but got: " ++ show (unStaticBytes label))
    pure $! AiffHeader variant (sz - labelSize)
  put (AiffHeader variant remSz) = do
    put labelForm
    putChunkSizeBE (remSz + labelSize)
    put $ case variant of
      VariantAiff -> labelAiff
      VariantAifc -> labelAifc

data Aiff = Aiff
  { aiffVariant :: !Variant
  , aiffChunks :: !(Seq AiffChunk)
  }
  deriving stock (Eq, Show)

instance Binary Aiff where
  byteSize (Aiff _ chunks) = aiffHeaderSize + byteSizeFoldable chunks
  get = do
    AiffHeader variant remSz <- get
    chunks <- getExact remSz (getRemainingSeq (getChunk variant))
    let !chunks' = swapDataEndian variant chunks
    pure $! Aiff variant chunks'
  put (Aiff variant chunks) = do
    let !chunks' = swapDataEndian variant chunks
    let !remSz = byteSizeFoldable chunks'
    put (AiffHeader variant remSz)
    putSeq (putChunk variant) chunks'

swapDataEndian :: Variant -> Seq AiffChunk -> Seq AiffChunk
swapDataEndian variant chunks =
  case variant of
    VariantAifc -> chunks
    VariantAiff -> fromMaybe chunks $ do
      let aiff = Aiff variant chunks
      KnownChunk comBody <- lookupAiffCommonChunk aiff
      KnownChunk (AiffDataBody x y arr) <- lookupAiffDataChunk aiff
      let arr' = swapArrayEndian (fromIntegral (aceSampleSize comBody)) arr
      let datChunk' = KnownChunk (AiffDataBody x y arr')
      Just $! flip fmap chunks $ \c ->
        case c of
          AiffChunkData _ -> AiffChunkData datChunk'
          _ -> c

swappedByteArray2 :: ByteArray -> [Word8]
swappedByteArray2 arr = go 0 Empty
 where
  sz = sizeofByteArray arr
  go !i !acc =
    if i == sz
      then toList acc
      else go (i + 2) (acc :|> indexByteArray arr (i + 1) :|> indexByteArray arr i)

swappedByteArray3 :: ByteArray -> [Word8]
swappedByteArray3 arr = go 0 Empty
 where
  sz = sizeofByteArray arr
  go !i !acc =
    if i == sz
      then toList acc
      else go (i + 3) (acc :|> indexByteArray arr (i + 2) :|> indexByteArray arr (i + 1) :|> indexByteArray arr i)

swapArrayEndian :: Int -> ByteArray -> ByteArray
swapArrayEndian bitDepth arr =
  if
    | bitDepth == 8 -> arr
    | bitDepth == 16 -> byteArrayFromListN (sizeofByteArray arr) (swappedByteArray2 arr)
    | bitDepth == 24 -> byteArrayFromListN (sizeofByteArray arr) (swappedByteArray3 arr)
    | otherwise -> error ("Unsupported endian swap depth: " ++ show bitDepth)

lookupAiffChunk :: (AiffChunk -> Bool) -> Aiff -> Maybe AiffChunk
lookupAiffChunk p (Aiff _ chunks) = fmap (Seq.index chunks) (Seq.findIndexL p chunks)

lookupAiffCommonChunk :: Aiff -> Maybe AiffCommonChunk
lookupAiffCommonChunk w =
  case lookupAiffChunk (\case AiffChunkCommon _ -> True; _ -> False) w of
    Just (AiffChunkCommon x) -> Just x
    _ -> Nothing

lookupAiffDataChunk :: Aiff -> Maybe AiffDataChunk
lookupAiffDataChunk w =
  case lookupAiffChunk (\case AiffChunkData _ -> True; _ -> False) w of
    Just (AiffChunkData x) -> Just x
    _ -> Nothing

lookupAiffMarkChunk :: Aiff -> Maybe AiffMarkChunk
lookupAiffMarkChunk w =
  case lookupAiffChunk (\case AiffChunkMark _ -> True; _ -> False) w of
    Just (AiffChunkMark x) -> Just x
    _ -> Nothing

-- NOTE: Taking sr as a param here so we don't have to interpret extended fp
aiffToPcmContainer :: Int -> Aiff -> Either ConvertErr PcmContainer
aiffToPcmContainer sr aiff = do
  KnownChunk commBody <- guardChunk "common" (lookupAiffCommonChunk aiff)
  KnownChunk dataBody <- guardChunk "data" (lookupAiffDataChunk aiff)
  let !nc = fromIntegral (aceNumChannels commBody)
      !ns = fromIntegral (aceNumSampleFrames commBody)
      !bps = fromIntegral (aceSampleSize commBody)
      !meta = PcmMeta nc ns bps sr
      !arr = adbSoundData dataBody
  pure $! PcmContainer meta arr

aiffGatherMarkers :: Aiff -> Seq SimpleMarker
aiffGatherMarkers aiff =
  case lookupAiffMarkChunk aiff of
    Nothing -> Empty
    Just (KnownChunk (AiffMarkBody marks)) ->
      dedupeSimpleMarkers (fmap (\am -> SimpleMarker (unPascalString (amName am)) (unWord32BE (amPosition am))) marks)