/ src / library / av_processing / OggDemuxer.php
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\library\av_processing;

/**
 * OggDemuxer reads the pieces of compressed sound back out of an .ogg or .opus
 * file. An Ogg file is a chain of small blocks called pages. Each page carries
 * a checksum, the number of the sound it belongs to, and a table saying where
 * one piece of sound ends and the next begins inside it. A piece too big for
 * one page is split across several, so reading a file means gathering the parts
 * of a piece until the table says the piece has ended. This class does that
 * gathering and hands back whole pieces. A page whose checksum does not match
 * is skipped and the read picks up at the next page, so a damaged file gives up
 * what is still readable instead of nothing. The container is described by RFC
 * 3533, the specification of the Ogg encapsulation format.
 */
class OggDemuxer
{
    /**
     * CAPTURE_PATTERN is the four letters every page begins with, used to find
     * where a page starts.
     */
    const CAPTURE_PATTERN = "OggS";
    /**
     * HEADER_SIZE is size of the fixed part of a page's opening block, before
     * the table of piece lengths.
     */
    const HEADER_SIZE = 27;
    /**
     * MAX_PAGE_SIZE is largest a page can be: the fixed opening block, the
     * longest possible table of piece lengths, and the most sound those lengths
     * can describe.
     */
    const MAX_PAGE_SIZE = 65307;
    /**
     * READ_SIZE is how much of the file to pull into memory at a time.
     */
    const READ_SIZE = 65536;
    /**
     * CHECKSUM_OFFSET is where in a page the checksum sits.
     */
    const CHECKSUM_OFFSET = 22;
    /**
     * SEGMENT_COUNT_OFFSET is where in a page the count of table entries sits.
     */
    const SEGMENT_COUNT_OFFSET = 26;
    /**
     * POSITION_OFFSET is where in a page the position within the recording
     * sits.
     */
    const POSITION_OFFSET = 6;
    /**
     * STREAM_OFFSET is where in a page the sound number sits.
     */
    const STREAM_OFFSET = 14;
    /**
     * CONTINUING_LENGTH is a table entry of this value means the piece carries
     * on into the next entry rather than ending.
     */
    const CONTINUING_LENGTH = 255;
    /**
     * CONTINUED_FLAG is set on a page whose first piece began on an earlier
     * page.
     */
    const CONTINUED_FLAG = 0x01;
    /**
     * FIRST_PAGE_FLAG is set on the first page of a sound.
     */
    const FIRST_PAGE_FLAG = 0x02;
    /**
     * LAST_PAGE_FLAG is set on the last page of a sound.
     */
    const LAST_PAGE_FLAG = 0x04;
    /**
     * CHECKSUM_RULE is the rule Ogg checksums are built on. This is not the
     * rule PHP's own crc32 uses, so that function cannot stand in for this one.
     */
    const CHECKSUM_RULE = 0x04C11DB7;
    /**
     * CHECKSUM_WIDTH is number of bits in the value a checksum is built up in.
     */
    const CHECKSUM_WIDTH = 32;
    /**
     * CHECKSUM_LIMIT is largest value a checksum can hold, used to keep it from
     * growing past the width it is defined at.
     */
    const CHECKSUM_LIMIT = 0xFFFFFFFF;
    /**
     * checksum_table stores table of part-way checksums, one for each possible
     * byte value, worked out once and shared by every read
     *
     * @var array
     */
    public static $checksum_table = [];
    /**
     * file stores the open recording this reader takes its pages
     * from, opened once and read a piece at a time.
     * @var mixed
     */
    public $file;
    /**
     * held stores what has been pulled from the file but not yet read past.
     * @var string
     */
    public $held;
    /**
     * at_end stores whether the end of the file has been reached.
     * @var bool
     */
    public $at_end;
    /**
     * sounds stores for each sound in the file, the parts of a piece gathered
     * so far, how many whole pieces it has given back, and whether it has
     * begun.
     * @var array
     */
    public $sounds;
    /**
     * page_count stores how many pages have been read whole.
     * @var int
     */
    public $page_count;
    /**
     * damaged_count stores how many pages were skipped for a checksum that did
     * not match.
     * @var int
     */
    public $damaged_count;
    /**
     * __construct sets up a read over an already open file
     *
     * @param mixed $file an open file to read the sound out of
     */
    public function __construct($file)
    {
        $this->file = $file;
        $this->held = "";
        $this->at_end = false;
        $this->sounds = [];
        $this->page_count = 0;
        $this->damaged_count = 0;
        self::buildChecksumTable();
    }
    /**
     * fromName opens a file by name and sets up a read over it
     *
     * @param string $name path of the file to read
     * @return object a reader over that file
     */
    public static function fromName($name)
    {
        $file = fopen($name, "rb");
        if ($file === false) {
            throw new \Exception("Could not open $name");
        }
        return new self($file);
    }
    /**
     * packets hands back the pieces of sound in the file one at a time, in the
     * order they were stored
     *
     * @return \Generator each whole piece of sound as a MediaPacket
     */
    public function packets()
    {
        while (($page = $this->nextPage()) !== null) {
            $stream = $page["stream"];
            $continued = ($page["flags"] & self::CONTINUED_FLAG) != 0;
            if (!isset($this->sounds[$stream])) {
                /* Where the first page seen of a sound says it carries
                   on from an earlier one, the read began part way into
                   the file. The piece that page finishes is missing its
                   opening, so it is let go rather than handed on as
                   sound. */
                $this->sounds[$stream] = ["parts" => [], "given" => 0,
                    "drop_next" => $continued];
            }
            /* A page that does not carry on the piece before it means
               whatever was gathered can never be completed, so it is
               let go rather than joined to the wrong piece. */
            if (!$continued && $this->sounds[$stream]["parts"] != []) {
                $this->sounds[$stream]["parts"] = [];
            }
            $parts = $this->sounds[$stream]["parts"];
            $lengths = $page["lengths"];
            $body = $page["body"];
            $taken = 0;
            $count = count($lengths);
            for ($i = 0; $i < $count; $i++) {
                $length = $lengths[$i];
                $parts[] = substr($body, $taken, $length);
                $taken += $length;
                if ($length == self::CONTINUING_LENGTH) {
                    continue;
                }
                $data = implode("", $parts);
                $parts = [];
                $is_final_part = ($i == $count - 1);
                $ends_sound = ($page["flags"] & self::LAST_PAGE_FLAG) != 0;
                if ($this->sounds[$stream]["drop_next"]) {
                    $this->sounds[$stream]["drop_next"] = false;
                    continue;
                }
                yield new MediaPacket($data, $stream,
                    $is_final_part ? $page["position"] : -1,
                    $this->sounds[$stream]["given"],
                    $ends_sound && $is_final_part);
                $this->sounds[$stream]["given"]++;
            }
            $this->sounds[$stream]["parts"] = $parts;
        }
    }
    /**
     * nextPage reads the next whole page out of the file, stepping over
     * anything that does not check out the file
     *
     * @return mixed the page's parts as an array, or null at the end of
     */
    public function nextPage()
    {
        while (true) {
            $this->pullMore(self::MAX_PAGE_SIZE);
            $at = strpos($this->held, self::CAPTURE_PATTERN);
            if ($at === false) {
                if ($this->at_end) {
                    return null;
                }
                /* The four letters may be split across two reads, so
                   the last three bytes are kept to be looked at again
                   with what comes next. */
                $keep = strlen(self::CAPTURE_PATTERN) - 1;
                $this->held = substr($this->held, -$keep);
                continue;
            }
            if ($at > 0) {
                $this->held = substr($this->held, $at);
            }
            if (strlen($this->held) < self::HEADER_SIZE) {
                if ($this->at_end) {
                    return null;
                }
                continue;
            }
            $entry_count = ord($this->held[self::SEGMENT_COUNT_OFFSET]);
            $header_size = self::HEADER_SIZE + $entry_count;
            if (strlen($this->held) < $header_size) {
                if ($this->at_end) {
                    return null;
                }
                continue;
            }
            $lengths = [];
            $body_size = 0;
            for ($i = 0; $i < $entry_count; $i++) {
                $length = ord($this->held[self::HEADER_SIZE + $i]);
                $lengths[] = $length;
                $body_size += $length;
            }
            $whole_size = $header_size + $body_size;
            if (strlen($this->held) < $whole_size) {
                if ($this->at_end) {
                    return null;
                }
                continue;
            }
            $whole = substr($this->held, 0, $whole_size);
            /* The checksum is worked out over the page with the
               checksum's own place blanked, since it was worked out
               that way when the page was written. */
            $blanked = substr($whole, 0, self::CHECKSUM_OFFSET) .
                "\0\0\0\0" . substr($whole, self::SEGMENT_COUNT_OFFSET);
            $stated = self::readSmallNumber($whole, self::CHECKSUM_OFFSET);
            if (self::checksum($blanked) != $stated) {
                $this->damaged_count++;
                $this->held = substr($this->held, 1);
                continue;
            }
            $this->held = substr($this->held, $whole_size);
            $this->page_count++;
            return ["stream" => self::readSmallNumber($whole,
                self::STREAM_OFFSET),
                "position" => self::readLargeNumber($whole,
                self::POSITION_OFFSET),
                "flags" => ord($whole[5]),
                "lengths" => $lengths,
                "body" => substr($whole, $header_size)];
        }
    }
    /**
     * pullMore pulls more of the file into memory until at least the wanted
     * amount is held or the file runs out
     *
     * @param int $wanted how many bytes should be held
     */
    public function pullMore($wanted)
    {
        while (!$this->at_end && strlen($this->held) < $wanted) {
            $more = fread($this->file, self::READ_SIZE);
            if ($more === false || $more === "") {
                $this->at_end = true;
                break;
            }
            $this->held .= $more;
        }
    }
    /**
     * readSmallNumber reads a four byte number stored least significant byte
     * first
     *
     * @param string $data where to read from
     * @param int $at where in it the number begins
     * @return int the number read
     */
    public static function readSmallNumber($data, $at)
    {
        return ord($data[$at]) | (ord($data[$at + 1]) << 8) |
            (ord($data[$at + 2]) << 16) | (ord($data[$at + 3]) << 24);
    }
    /**
     * readLargeNumber reads an eight byte number stored least significant byte
     * first. An all ones value is the file's way of saying it has no position
     * to give, and is handed back as -1.
     *
     * @param string $data where to read from
     * @param int $at where in it the number begins
     * @return int the number read, or -1 where the file gave none
     */
    public static function readLargeNumber($data, $at)
    {
        $low = self::readSmallNumber($data, $at);
        $high = self::readSmallNumber($data, $at + 4);
        if ($low == self::CHECKSUM_LIMIT && $high == self::CHECKSUM_LIMIT) {
            return -1;
        }
        return $low | ($high << self::CHECKSUM_WIDTH);
    }
    /**
     * buildChecksumTable works out the table of part-way checksums, one for
     * each possible byte value, so a checksum can be taken a byte at a time
     * instead of a bit at a time
     */
    public static function buildChecksumTable()
    {
        if (self::$checksum_table != []) {
            return;
        }
        $table = [];
        $top_bit = 1 << (self::CHECKSUM_WIDTH - 1);
        for ($i = 0; $i < 256; $i++) {
            $running = $i << (self::CHECKSUM_WIDTH - 8);
            for ($j = 0; $j < 8; $j++) {
                if ($running & $top_bit) {
                    $running = (($running << 1) & self::CHECKSUM_LIMIT) ^
                        self::CHECKSUM_RULE;
                } else {
                    $running = ($running << 1) & self::CHECKSUM_LIMIT;
                }
            }
            $table[$i] = $running & self::CHECKSUM_LIMIT;
        }
        self::$checksum_table = $table;
    }
    /**
     * checksum works out the check value a stretch of a page should have, so a
     * page can be told apart from a damaged one
     *
     * @param string $data the stretch of page to check
     * @return int the value that stretch works out to
     */
    public static function checksum($data)
    {
        self::buildChecksumTable();
        $table = self::$checksum_table;
        $running = 0;
        $count = strlen($data);
        for ($i = 0; $i < $count; $i++) {
            $running = (($running << 8) & self::CHECKSUM_LIMIT) ^
                $table[(($running >> (self::CHECKSUM_WIDTH - 8)) & 0xFF) ^
                ord($data[$i])];
        }
        return $running & self::CHECKSUM_LIMIT;
    }
}
X