discord-haskell-voice-3.0.0: src/Discord/Internal/Voice/OggParser.hs
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-|
Module : Discord.Internal.Voice.OggParser
Description : Strictly for internal use only. See Discord.Voice for the public interface.
Copyright : (c) 2021-2022 Yuto Takano
(c) 2025-PRESENT discord-haskell-voice Contributors
License : MIT
Maintainer : Yuto Takano <moa17stock@gmail.com>
= WARNING
This module is considered __internal__.
The Package Versioning Policy __does not apply__.
The contents of this module may change __in any way whatsoever__ and __without__
__any warning__ between minor versions of this package, unless the identifier is
re-exported from a non-internal module.
= Description
This module provides 'unwrapOggPacketsC', a conduit that unwraps Ogg packets
from a stream of bytes, and extracts the Opus packets from them. This is used
to parse FFmpeg's Ogg output into Opus packets that can be sent to Discord.
-}
module Discord.Internal.Voice.OggParser
( unwrapOggPacketsC
) where
import Codec.Audio.Opus.Encoder
import Conduit
import Control.Monad ( forM_, replicateM, void, unless )
import Data.Binary
import Data.Binary.Put
import Data.Binary.Get
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.Maybe ( isNothing )
import Lens.Micro
-- | The Ogg Page Header, after the initial 4 byte pattern of "OggS".
--
-- As described here: https://xiph.org/ogg/doc/framing.html
-- and here: http://www33146ue.sakura.ne.jp/staff/iz/formats/ogg.html
data OggPageHeader = OggPageHeader
{ oggPageVersion :: Word8
-- ^ The index 4 byte is "stream_structure_version", which should be set to
-- 0x00.
, oggPageFlags :: Word8
-- ^ The index 5 byte is "header_type_flag", which can be a combination of
-- 0x01 continued packet (if unset then fresh packet)
-- 0x02 first page of bitstream (if unset then not first page)
-- 0x04 last page of bitsream (if unset then not last page)
, oggPageGranularPosition :: Word64
-- ^ The index 6 to 13 bytes are the "absolute granule position", which is
-- the "sample" number. "-1" in two's complement indicate that no packets
-- finish on this page.
, oggPageStreamSerial :: Word32
-- ^ The index 14 to 17 bytes are the "stream serial number". Each logical
-- stream has a unique serial number.
, oggPageSeqNumber :: Word32
-- ^ The index 18 to 21 bytes are the "page counter".
, oggPageCRCChecksum :: Word32
-- ^ The index 22 to 25 is the CRC checksum of the entire page, with the
-- checksum field set to 0.
, oggPageSegmentAmt :: Int
-- ^ The index 26 is "page_segments", which is the number of entries
-- appearing in the segment table below.
, oggPageSegmentLengths :: [Int]
-- ^ Index 27 onwards in the header contains the length of each segment that
-- follows this header in the body, up to the number of segments specified
-- in segmentAmt. These are also called the lacing values.
}
deriving stock (Eq, Show)
instance Binary OggPageHeader where
put OggPageHeader{..} = do
putByteString "OggS"
-- Ogg is a little-endian format
putWord8 oggPageVersion
putWord8 oggPageFlags
putWord64le oggPageGranularPosition
putWord32le oggPageStreamSerial
putWord32le oggPageSeqNumber
putWord32le oggPageCRCChecksum
putWord8 $ fromIntegral oggPageSegmentAmt
forM_ oggPageSegmentLengths $ \len ->
putWord8 $ fromIntegral len
get = do
-- Ignore the OggS pattern
void $ getByteString 4
ver <- getWord8
flags <- getWord8
granularPosition <- getWord64le
streamSerial <- getWord32le
seqNumber <- getWord32le
crcChecksum <- getWord32le
segmentAmt <- fromIntegral <$> getWord8
segmentLengths <- replicateM segmentAmt $ fromIntegral <$> getWord8
pure $ OggPageHeader ver flags granularPosition streamSerial seqNumber crcChecksum segmentAmt segmentLengths
-- | An @OggPage@ consists of an 'OggPageHeader' followed by a segment of
-- variable length with maximum 255 bytes. An Opus packet may span multiple
-- page boundaries.
data OggPage = OggPage
{ oggPageHdr :: OggPageHeader
, oggPageSegs :: BS.ByteString
}
deriving stock (Eq, Show)
-- | @unwrapOggPacketsC@ is a conduit that extracts Opus audio frames from
-- a stream of Ogg page packets. It works by parsing the stream into a series
-- of 'OggPage's, then constructing Opus frames by concatinating multiple Ogg
-- pages using a buffer, and finally filtering out any non-audio packets.
--
-- The function is meant to be used as a replacement for Opus encoding when the
-- audio source is already in Ogg format. Thus, the function will also insert
-- five frames of silence to avoid audio interpolation, just like in
-- 'Discord.Internal.Voice.encodeOpusC'.
unwrapOggPacketsC :: ConduitT BS.ByteString BS.ByteString IO ()
unwrapOggPacketsC = oggPageExtractC .| opusPacketExtractC .| filterOpusNonMetaC
-- | Parses a byte stream as a stream of 'OggPage's, using a buffer to consume
-- bytes as needed and yielding the parsed data.
oggPageExtractC :: (Monad m) => ConduitT BS.ByteString OggPage m ()
oggPageExtractC = loop BL.empty
where
-- | Keep awaiting for new chunks of bytestrings, concat that with any
-- previous leftover chunks, and try to parse it as an Ogg, yielding any
-- parsed pages.
-- Since we perform concatenation between large bytestrings repeatedly,
-- we use lazy bytestrings for the intermediate unconsumedBytes variable.
loop :: (Monad m) => BL.ByteString -> ConduitT BS.ByteString OggPage m ()
loop unconsumedBytes = do
-- Get the bytestring chunks that sourceHandle reads, which is
-- defaultChunkSize, roughly 32k bytes.
-- Prepend any previous bytes we didn't consume since it wasn't part of
-- the page.
-- We cannot use 'leftover' to put back the unconsumed bytes at the end
-- of each iteration, since conduit does not concatenate those bytes with
-- the next item, and it'll just loop forever on the same failing input.
-- See: https://stackoverflow.com/a/26872574
mbChunk <- await
-- Return early and prevent the bytestring append operation if neither
-- the unconsumed bytes nor the conduit data exist. Both BL.null and
-- equality check are O(1).
if BL.null unconsumedBytes && isNothing mbChunk then
pure ()
else do
-- Since these are lazy bytestrings, appending is only dependent
-- on the length of the spine in @unconsumedBytes@. Furthermore,
-- conversion of strict to lazy with BL.fromStrict is O(1).
-- This is better than using strict bytestrings, where appending
-- incurs two memory copies.
-- https://hackage.haskell.org/package/bytestring-0.11.3.1/docs/src/Data.ByteString.Internal.html#append
let chunk = unconsumedBytes <> maybe BL.empty BL.fromStrict mbChunk
let page = dropUntilPageStart chunk
case decodeOrFail page of
Left (_unconsumed, _consumedAmt, _err) -> do
-- TOOD: log warning
void $ error "warning"
loop page
Right (unconsumed, _consumedAmt, hdr) -> do
let (page, rest) = BL.splitAt (fromIntegral $ sum $ oggPageSegmentLengths hdr) unconsumed
yield $ OggPage hdr (BL.toStrict page)
loop rest
-- | Conduit to extract the Opus bytes from an Ogg Page. This also handles the
-- addition of empty frames when there is no audio, like in
-- 'Discord.Internal.Voice.encodeOpusC'. The output of this conduit is a strict
-- bytestring since there is no longer any need for laziness, and it's better to
-- make it strict when we can guarantee properties of it (like that each packet
-- will only be made strict once) than later down. We need a strict bytestring
-- at the end anyway to send to Discord.
opusPacketExtractC :: ConduitT OggPage BS.ByteString IO ()
opusPacketExtractC = loop BL.empty
where
loop :: BL.ByteString -> ConduitT OggPage BS.ByteString IO ()
loop opusSegment = do
mbPage <- await
case mbPage of
Nothing -> do
-- Send any incomplete leftovers. The toStrict here is
-- run on the remaining unterminated Opus packet, which was never
-- called toStrict upon previously. This is in sync with the
-- runtime explanation given in splitOggPackets (that toStrict
-- is only called maximally once for each segment).
if BL.null opusSegment then pure () else yield (BL.toStrict opusSegment)
-- Send at least 5 blank frames before stopping.
-- Per Discord docs: "When there's a break in the sent data, the
-- packet transmission shouldn't simply stop. Instead, send five
-- frames of silence (0xF8, 0xFF, 0xFE) before stopping to avoid
-- unintended Opus interpolation with subsequent transmissions."
let encoded = BS.pack [0xF8, 0xFF, 0xFE]
forM_ [0..4] $ \_ -> do
yield encoded
Just (OggPage hdr segsBytes) -> do
let (pkts, untermSeg, _) = splitOggPackets (oggPageSegmentLengths hdr) segsBytes opusSegment
forM_ pkts $ yield . BL.toStrict
loop untermSeg
-- | Conduit to filter out any Opus packets beginning with the first 8 bytes
-- being @OpusHead@ or @OpusTag@.
filterOpusNonMetaC :: (Monad m) => ConduitT BS.ByteString BS.ByteString m ()
filterOpusNonMetaC = do
mbPkt <- await
case mbPkt of
Nothing -> pure ()
Just pkt -> do
unless (BS.take 8 pkt `elem` ["OpusHead", "OpusTags"]) $
yield pkt
filterOpusNonMetaC
-- | @dropUntilPageStart@ drops one byte at a time until the first four bytes
-- say @OggS@.
dropUntilPageStart :: BL.ByteString -> BL.ByteString
dropUntilPageStart bsChunk
| BL.length bsChunk < 4
= BL.empty
| BL.take 4 bsChunk == "OggS"
= bsChunk
| otherwise
= dropUntilPageStart $ BL.tail bsChunk
-- | @splitOggPackets@ splits a series of bytes according to a list of segment
-- lengths, returning any leftover bytes or unterminated segments. Leftover
-- bytes can be present when there are more bytes provided than the segments,
-- while unterminated segments can be present if e.g. the last item in the
-- provided lengths was 255. (This is because the Ogg frame segmentation
-- defines the segments to be 255 long except for the last one, which is <255).
--
-- Callers of this function can also specify leftover bytes from a previous call
-- to the function, to essentially prepend to the parsing bytes.
splitOggPackets
:: [Int]
-- ^ Length of each segment in bytes (0 - 255). This list can be up to 255
-- items long (255 bytes each), and is taken from the segmentAmt header.
-- By definition of the Ogg frame format, each segment should be exactly
-- 255 bytes long, except for the last segment which is <255.
-> BS.ByteString
-- ^ The bytes to split and parse from. Unless the network packet itself was
-- corrupt or lost halfway, this should have at least the total length of
-- all segments.
-> BL.ByteString
-- ^ Any previous segment data that was not terminated in the previous page.
-> ([BL.ByteString], BL.ByteString, BS.ByteString)
-- ^ A tuple containing: (the list of parsed and concatted packets, any
-- unterminated segments, any leftover bytes that were after the segments).
splitOggPackets sl bytes ps = loop sl bytes ps
where
loop
:: [Int]
-- ^ Length of remaining segments in bytes (0-255).
-> BS.ByteString
-- ^ The remaining bytes.
-> BL.ByteString
-- ^ Any previous unterminated segments that need to be prepended with
-- this one to construct a complete Opus packet. This argument is lazy
-- since it can undergo an append operation on each loop.
--
-- To explain:
--
-- 1. Both Lazy and Strict bytestring variants would need to be made
-- strict in its entirety once: O(n). The lazy variant will be made
-- strict at the very end, while the strict one will be made strict
-- before calling splitOggPackets.
-- 2. Both Lazy and Strict bytestring variants have O(1) operation on
-- self-contained segments (i.e. Opus packets that don't span multiple
-- segments). For Lazy, we use constant-time strict splitAt: O(1);
-- then convert the strict bytestring to lazy: O(1); and prepend that
-- to the result list: O(1). For Strict, we use the same splitAt
-- operation: O(1); And just prepend it to the result list: O(1).
-- 3. Both Lazy and Strict bytestring variants have
-- O(255 * (n * (n + 1) / 2)) for n segments that continue onto the
-- next. In the case of Lazy, the discovery of a new nth continuation
-- segment n will perform an append operation walking through the
-- spine of previous (n - 1) * 255 bytes. The discovery of a
-- termination segment at n+1 will walk through n * 255 bytes. This
-- totals to (n * (n + 1) / 2) times walkthrough of 255 bytes.
-- In the case of Strict, the discovery of a new nth continuation
-- segment n will perform an append operation walking through both
-- the previous (n - 1) * 255 bytes and the new 255 bytes, so n * 255.
-- This totals to (n * (n + 1) / 2) times walkthrough of 255 bytes,
-- although we have yet to consider the termination segment.
-- 4. The discovery of a termination segment at n+1 in the Strict variant
-- incurs a further walkthrough of the previous 255 * n bytes, plus
-- however many bytes are in the last segment (<255).
--
-- Because 4 introduces an additional factor of 255 * n + lastBytes, the
-- Lazy variant is preferred.
-> ([BL.ByteString], BL.ByteString, BS.ByteString)
loop [] bytes previousSegment = ([], previousSegment, bytes) -- O(255i)
loop (segLen : segLengths) bytes previousSegment
-- Handle the case when this is the first segment in a series of more
-- than one segments.
| (seg, rest) <- BS.splitAt segLen bytes -- O(1)
, BS.length seg == segLen -- O(1)
, segLen == 255 -- O(1)
, BL.null previousSegment -- O(1)
= loop segLengths rest $ BL.fromStrict seg -- O(1)
| (seg, rest) <- BS.splitAt segLen bytes -- O(1)
, BS.length seg == segLen -- O(1)
, segLen == 255 -- O(1)
= loop segLengths rest (previousSegment <> BL.fromStrict seg) -- O(255 * m), where m is number of previous segments
-- Handle the case when this segment is self-conclusive
| (seg, rest) <- BS.splitAt segLen bytes -- O(1)
, BS.length seg == segLen -- O(1)
, segLen < 255 -- O(1)
, BL.null previousSegment -- O(1)
= _1 %~ (BL.fromStrict seg :) $ loop segLengths rest BL.empty -- O(1)
-- Handle the case when this segment terminates a packet
| (seg, rest) <- BS.splitAt segLen bytes -- O(1)
, BS.length seg == segLen -- O(1)
, segLen < 255 -- O(1)
= _1 %~ (previousSegment <> BL.fromStrict seg :) $ loop segLengths rest BL.empty -- O(255 * m) + recursion, where m is number of previous segments
-- Handle the case when the segment length doesn't match!! This only
-- happens if the network packet was lost somewhere, or if the Ogg packet
-- is simply corrupt.
| otherwise
= error "Ogg packet is corrupt and couldn't be parsed."