packages feed

monatone-0.4.0.0: test/Test/OGGSpec.hs

{-# LANGUAGE OverloadedStrings #-}

module Test.OGGSpec (tests) where

import Test.Tasty
import Test.Tasty.HUnit
import Control.Monad.Except (runExceptT)
import Data.Bits (shiftL, shiftR, testBit, xor)
import qualified Data.ByteString as BS
import Data.Word (Word32)
import qualified Data.ByteString.Base64 as B64
import qualified Data.HashMap.Strict as HM
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import System.Directory (getTemporaryDirectory, removeFile)
import System.FilePath ((</>))
import System.OsPath (encodeFS)

import Monatone.Metadata
import Monatone.OGG (parseOGG, loadAlbumArtOGG)
import Monatone.Writer (updateMetadata, setTitle, emptyUpdate)

tests :: TestTree
tests = testGroup "OGG Parser"
  [ testCase "parses comment packet spanning multiple pages" testSpanningComment
  , testCase "parses Opus tags" testOpusTags
  , testCase "loads album art with base64 padding from spanning packet" testSpanningAlbumArt
  , testCase "computes Vorbis duration from the last granule" testVorbisDuration
  , testCase "computes Opus duration accounting for pre-skip" testOpusDuration
  , testCase "updates Vorbis tags in place" testVorbisWrite
  , testCase "updates Opus tags in place" testOpusWrite
  ]

-- | A comment packet larger than one page (> 64KB) must be reassembled
-- from its continuation pages, not parsed per-page
testSpanningComment :: IO ()
testSpanningComment = do
  let bigValue = T.replicate 100000 "x"  -- forces the packet across pages
      commentPacket = vorbisCommentPacket
        [ ("TITLE", "Spanning Title")
        , ("BIGTAG", bigValue)
        , ("ARTIST", "Spanning Artist")
        ]
  metadata <- parseSynthetic "monatone-test-span.ogg" [vorbisIdentPacket, commentPacket]
  assertEqual "title" (Just "Spanning Title") (title metadata)
  assertEqual "artist" (Just "Spanning Artist") (artist metadata)
  assertEqual "big tag survives reassembly" (Just [bigValue]) (HM.lookup "BIGTAG" (rawTags metadata))
  assertEqual "sample rate" (Just 44100) (sampleRate (audioProperties metadata))

-- | OpusTags packets were previously never matched at all
testOpusTags :: IO ()
testOpusTags = do
  let commentPacket = opusTagsPacket [("TITLE", "Opus Title"), ("ARTIST", "Opus Artist")]
  metadata <- parseSynthetic "monatone-test-opus.ogg" [opusIdentPacket, commentPacket]
  assertEqual "codec" (Just CodecOpus) (codec (audioProperties metadata))
  assertEqual "title" (Just "Opus Title") (title metadata)
  assertEqual "artist" (Just "Opus Artist") (artist metadata)
  -- Opus always decodes at 48kHz regardless of the header's input rate
  assertEqual "sample rate" (Just 48000) (sampleRate (audioProperties metadata))

-- | Album art forces both a spanning packet and base64 '=' padding
testSpanningAlbumArt :: IO ()
testSpanningAlbumArt = do
  let imageData = BS.replicate 80000 0xAB  -- forces the packet across pages
      pictureBlock = flacPictureBlock "image/png" imageData
      encoded = TE.decodeUtf8 (B64.encode pictureBlock)
      commentPacket = vorbisCommentPacket
        [ ("TITLE", "Art Test")
        , ("METADATA_BLOCK_PICTURE", encoded)
        ]
  path <- writeSynthetic "monatone-test-art.ogg" [vorbisIdentPacket, commentPacket]
  osPath <- encodeFS path
  -- The encoded block ends in '=' padding; the value must not be truncated
  assertBool "test data has base64 padding" ("=" `T.isSuffixOf` encoded)
  result <- runExceptT $ loadAlbumArtOGG osPath
  removeFile path
  case result of
    Left err -> assertFailure $ "loadAlbumArtOGG failed: " ++ show err
    Right Nothing -> assertFailure "Expected album art, got Nothing"
    Right (Just art) -> do
      assertEqual "mime type" "image/png" (albumArtMimeType art)
      assertEqual "image data" imageData (albumArtData art)

-- | Duration comes from the granule of the file's last page
testVorbisDuration :: IO ()
testVorbisDuration = do
  let bytes = mkOggFile [vorbisIdentPacket, vorbisCommentPacket [("TITLE", "T")]]
        <> lastPage 441000  -- 10s at 44100Hz
  metadata <- parseSyntheticBytes "monatone-dur.ogg" bytes
  assertEqual "duration" (Just 10000) (duration (audioProperties metadata))

-- | Opus granules run at 48kHz and include the pre-skip
testOpusDuration :: IO ()
testOpusDuration = do
  let bytes = mkOggFile [opusIdentPacket, opusTagsPacket [("TITLE", "T")]]
        <> lastPage (480000 + 312)  -- 10s at 48kHz + the header's 312 pre-skip
  metadata <- parseSyntheticBytes "monatone-dur.opus.ogg" bytes
  assertEqual "duration" (Just 10000) (duration (audioProperties metadata))

-- | Updating tags rewrites the comment packet, keeps the setup packet and
-- audio pages, and leaves every page with a valid CRC
testVorbisWrite :: IO ()
testVorbisWrite = do
  let commentPacket = vorbisCommentPacket [("ARTIST", "Keep Me"), ("TITLE", "Old")]
      setupPacket = "\x05vorbis" <> BS.replicate 24 0xAB
      audioPacket = "AUDIOPACKETDATA" <> BS.replicate 300 0x77
      bytes = mkOggPageGroups [[vorbisIdentPacket], [commentPacket, setupPacket], [audioPacket]]
  path <- writeSyntheticBytes "monatone-write.ogg" bytes
  osPath <- encodeFS path

  runExceptT (updateMetadata osPath (setTitle "New Title" emptyUpdate))
    >>= either (assertFailure . show) return

  metadata <- runExceptT (parseOGG osPath) >>= either (assertFailure . show) return
  final <- BS.readFile path
  removeFile path
  assertEqual "title updated" (Just "New Title") (title metadata)
  assertEqual "artist kept" (Just "Keep Me") (artist metadata)
  assertBool "audio kept" ("AUDIOPACKETDATA" `BS.isInfixOf` final)
  assertBool "setup packet kept" (BS.replicate 24 0xAB `BS.isInfixOf` final)
  assertBool "all page CRCs valid" (validOggCrcs final)

testOpusWrite :: IO ()
testOpusWrite = do
  let audioPacket = "OPUSAUDIODATA" <> BS.replicate 100 0x55
      bytes = mkOggPageGroups
        [ [opusIdentPacket]
        , [opusTagsPacket [("ARTIST", "Keep Me"), ("TITLE", "Old")]]
        , [audioPacket]
        ]
  path <- writeSyntheticBytes "monatone-write-opus.ogg" bytes
  osPath <- encodeFS path

  runExceptT (updateMetadata osPath (setTitle "Opus New" emptyUpdate))
    >>= either (assertFailure . show) return

  metadata <- runExceptT (parseOGG osPath) >>= either (assertFailure . show) return
  final <- BS.readFile path
  removeFile path
  assertEqual "title updated" (Just "Opus New") (title metadata)
  assertEqual "artist kept" (Just "Keep Me") (artist metadata)
  assertEqual "codec" (Just CodecOpus) (codec (audioProperties metadata))
  assertBool "audio kept" ("OPUSAUDIODATA" `BS.isInfixOf` final)
  assertBool "all page CRCs valid" (validOggCrcs final)

-- Helpers

parseSynthetic :: FilePath -> [BS.ByteString] -> IO Metadata
parseSynthetic name packets = parseSyntheticBytes name (mkOggFile packets)

parseSyntheticBytes :: FilePath -> BS.ByteString -> IO Metadata
parseSyntheticBytes name bytes = do
  path <- writeSyntheticBytes name bytes
  osPath <- encodeFS path
  result <- runExceptT $ parseOGG osPath
  removeFile path
  either (assertFailure . show) return result

writeSynthetic :: FilePath -> [BS.ByteString] -> IO FilePath
writeSynthetic name packets = writeSyntheticBytes name (mkOggFile packets)

writeSyntheticBytes :: FilePath -> BS.ByteString -> IO FilePath
writeSyntheticBytes name bytes = do
  tmpDir <- getTemporaryDirectory
  let path = tmpDir </> name
  BS.writeFile path bytes
  return path

-- | A final page with the given granule position (EOS, one small segment)
lastPage :: Int -> BS.ByteString
lastPage granule = BS.concat
  [ "OggS"
  , BS.singleton 0            -- version
  , BS.singleton 0x04         -- EOS
  , w64le granule
  , BS.replicate 4 0          -- serial
  , BS.replicate 4 0          -- page sequence
  , BS.replicate 4 0          -- CRC (not verified by the parser)
  , BS.singleton 1
  , BS.singleton 10
  , BS.replicate 10 0x33
  ]

-- | Verify the OGG CRC of every page in the stream
validOggCrcs :: BS.ByteString -> Bool
validOggCrcs = go
  where
    go bs
      | BS.null bs = True
      | BS.length bs < 27 || BS.take 4 bs /= "OggS" = False
      | otherwise =
          let numSegments = fromIntegral $ BS.index bs 26
              lacings = map fromIntegral $ BS.unpack $ BS.take numSegments $ BS.drop 27 bs
              pageLen = 27 + numSegments + sum lacings
              page = BS.take pageLen bs
              stored = BS.take 4 $ BS.drop 22 page
              zeroed = BS.take 22 page <> BS.replicate 4 0 <> BS.drop 26 page
          in BS.length page == pageLen
             && stored == w32le' (fromIntegral (oggCrc zeroed))
             && go (BS.drop pageLen bs)

    w32le' :: Int -> BS.ByteString
    w32le' n = BS.pack [fromIntegral (n `shiftR` s) | s <- [0, 8, 16, 24]]

    oggCrc :: BS.ByteString -> Word32
    oggCrc = BS.foldl' step 0
      where
        step crc byte = spin (8 :: Int) (crc `xor` (fromIntegral byte `shiftL` 24))
        spin 0 crc = crc
        spin n crc = spin (n - 1) $
          if testBit crc 31 then (crc `shiftL` 1) `xor` 0x04c11db7 else crc `shiftL` 1

-- | Serialize logical packets into an OGG page stream. Packets are split
-- into 255-byte lacing segments; pages hold at most 255 segments, and a
-- page starting mid-packet gets the continuation flag (0x01).
mkOggFile :: [BS.ByteString] -> BS.ByteString
mkOggFile packets = mkOggPageGroups [packets]

-- | Like mkOggFile, but each group of packets starts on a fresh page -
-- the spec-conformant layout the writer expects:
-- [[ident], [comment, setup], [audio]]
mkOggPageGroups :: [[BS.ByteString]] -> BS.ByteString
mkOggPageGroups groups = BS.concat (concatMap renderGroup groups)
  where
    renderGroup packets =
      renderPages False (chunksOf 255 (concatMap packetSegments packets))

    -- Segments of one packet: all 255 bytes except the final short one.
    -- A packet whose length is a multiple of 255 ends with an empty segment.
    packetSegments packet =
      let (chunk, rest) = BS.splitAt 255 packet
      in if BS.length chunk == 255
           then chunk : packetSegments rest
           else [chunk]

    chunksOf _ [] = []
    chunksOf n xs = let (h, t) = splitAt n xs in h : chunksOf n t

    renderPages _ [] = []
    renderPages continued (pageSegments : rest) =
      let nextContinued = case reverse pageSegments of
            (lastSegment:_) -> BS.length lastSegment == 255
            [] -> False
      in renderPage continued pageSegments : renderPages nextContinued rest

    renderPage continued pageSegments = BS.concat
      [ "OggS"
      , BS.singleton 0                                   -- version
      , BS.singleton (if continued then 0x01 else 0x00)  -- header type flags
      , BS.replicate 8 0                                 -- granule position
      , BS.replicate 4 0                                 -- serial number
      , BS.replicate 4 0                                 -- page sequence
      , BS.replicate 4 0                                 -- CRC (not verified)
      , BS.singleton (fromIntegral (length pageSegments))
      , BS.pack (map (fromIntegral . BS.length) pageSegments)
      , BS.concat pageSegments
      ]

vorbisIdentPacket :: BS.ByteString
vorbisIdentPacket = BS.concat
  [ "\x01vorbis"
  , w32le 0                 -- vorbis version
  , BS.singleton 2          -- channels
  , w32le 44100             -- sample rate
  , w32le 0                 -- bitrate maximum
  , w32le 128000            -- bitrate nominal
  , w32le 0                 -- bitrate minimum
  , BS.singleton 0xB8       -- blocksizes
  , BS.singleton 0x01       -- framing bit
  ]

opusIdentPacket :: BS.ByteString
opusIdentPacket = BS.concat
  [ "OpusHead"
  , BS.singleton 1          -- version
  , BS.singleton 2          -- channels
  , BS.pack [0x38, 0x01]    -- pre-skip
  , w32le 44100             -- input sample rate (not the decode rate)
  , BS.pack [0, 0]          -- output gain
  , BS.singleton 0          -- mapping family
  ]

vorbisCommentPacket :: [(Text, Text)] -> BS.ByteString
vorbisCommentPacket comments =
  "\x03vorbis" <> commentBody comments <> BS.singleton 0x01  -- framing bit

opusTagsPacket :: [(Text, Text)] -> BS.ByteString
opusTagsPacket comments = "OpusTags" <> commentBody comments

commentBody :: [(Text, Text)] -> BS.ByteString
commentBody comments = BS.concat $
  [ w32le 0                                    -- vendor string length
  , w32le (fromIntegral (length comments))
  ] ++ map renderComment comments
  where
    renderComment (key, value) =
      let bytes = TE.encodeUtf8 (key <> "=" <> value)
      in w32le (fromIntegral (BS.length bytes)) <> bytes

flacPictureBlock :: BS.ByteString -> BS.ByteString -> BS.ByteString
flacPictureBlock mimeType imageData = BS.concat
  [ w32be 3                                    -- picture type: front cover
  , w32be (fromIntegral (BS.length mimeType))
  , mimeType
  , w32be 0                                    -- description length
  , w32be 0, w32be 0, w32be 0, w32be 0         -- width/height/depth/colors
  , w32be (fromIntegral (BS.length imageData))
  , imageData
  ]

w32le :: Int -> BS.ByteString
w32le n = BS.pack [fromIntegral (n `div` (256 ^ i)) | i <- [0 :: Int, 1, 2, 3]]

w64le :: Int -> BS.ByteString
w64le n = BS.pack [fromIntegral (n `div` (256 ^ i)) | i <- [0 :: Int .. 7]]

w32be :: Int -> BS.ByteString
w32be n = BS.pack [fromIntegral (n `div` (256 ^ i)) | i <- [3, 2, 1, 0 :: Int]]