/ src / library / av_processing / WebmDemuxer.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;

/**
 * WebmDemuxer reads the pieces of compressed sound back out of a .webm file. A
 * webm file is built out of nested boxes. Each box says what it is, then how
 * long it is, then holds either more boxes or a run of plain data. The sound
 * itself sits in boxes called blocks, gathered into larger boxes called
 * clusters, and what kind of sound it is sits in a separate box near the front
 * of the file. Reading the file means walking the boxes, remembering the one
 * that says what the sound is, and handing back the sound out of the blocks.
 * This is the kind Chrome and Firefox both write when a page asks them to
 * record, so it is the kind most recordings arrive in. A block may hold several
 * pieces of sound at once, packed nose to tail with their lengths written in
 * front in one of three ways. All three are read here, though a recording of
 * sound alone normally uses none of them and puts one piece in each block. The
 * layout of the boxes is Matroska, which webm is a cut down form of; the box
 * lengths and numbers follow the way a WebM file is built out of numbered
 * parts, which is called EBML, the binary markup language Matroska is
 * written in.
 */
class WebmDemuxer
{
    /**
     * SEGMENT_BOX is box holding everything else in the file.
     */
    const SEGMENT_BOX = 0x18538067;
    /**
     * INFO_BOX is box holding facts about the file as a whole, such as how its
     * times are counted.
     */
    const INFO_BOX = 0x1549A966;
    /**
     * TIME_SCALE_BOX is box saying what one tick of the file's clock is worth
     * in billionths of a second.
     */
    const TIME_SCALE_BOX = 0x2AD7B1;
    /**
     * TRACKS_BOX is box holding the description of each sound or picture in the
     * file.
     */
    const TRACKS_BOX = 0x1654AE6B;
    /**
     * TRACK_BOX is the number naming the part of the file that describes
     * one track, whether that track carries sound or pictures.
     */
    const TRACK_BOX = 0xAE;
    /**
     * TRACK_NUMBER_BOX is box giving the number blocks of this sound are
     * labeled with.
     */
    const TRACK_NUMBER_BOX = 0xD7;
    /**
     * TRACK_KIND_BOX is box saying whether this is sound, picture, or something
     * else.
     */
    const TRACK_KIND_BOX = 0x83;
    /**
     * SOUND_KIND is value of the box above that means sound.
     */
    const SOUND_KIND = 2;
    /**
     * CODEC_NAME_BOX is box naming how the sound was compressed.
     */
    const CODEC_NAME_BOX = 0x86;
    /**
     * CODEC_SETUP_BOX is box holding the settings a player needs before it can
     * make sense of the compressed sound.
     */
    const CODEC_SETUP_BOX = 0x63A2;
    /**
     * CODEC_DELAY_BOX is box saying how much sound at the start is only there
     * to let the decoder settle, counted in billionths of a second.
     */
    const CODEC_DELAY_BOX = 0x56AA;
    /**
     * SOUND_BOX is box holding facts about the sound itself.
     */
    const SOUND_BOX = 0xE1;
    /**
     * SAMPLE_RATE_BOX is box giving how many samples a second the sound was
     * taken at.
     */
    const SAMPLE_RATE_BOX = 0xB5;
    /**
     * CHANNEL_COUNT_BOX is box giving how many channels the sound has.
     */
    const CHANNEL_COUNT_BOX = 0x9F;
    /**
     * CLUSTER_BOX is box gathering a run of blocks that share a starting time.
     */
    const CLUSTER_BOX = 0x1F43B675;
    /**
     * CLUSTER_TIME_BOX is box giving the starting time a cluster's blocks count
     * from.
     */
    const CLUSTER_TIME_BOX = 0xE7;
    /**
     * SIMPLE_BLOCK_BOX is box holding sound with nothing else attached to it.
     */
    const SIMPLE_BLOCK_BOX = 0xA3;
    /**
     * BLOCK_GROUP_BOX is box holding sound together with extra facts about it.
     */
    const BLOCK_GROUP_BOX = 0xA0;
    /**
     * BLOCK_BOX is box holding the sound inside the group above.
     */
    const BLOCK_BOX = 0xA1;
    /**
     * OPUS_CODEC_NAME is what the sound is named when it was compressed with
     * Opus.
     */
    const OPUS_CODEC_NAME = "A_OPUS";
    /**
     * DEFAULT_TIME_SCALE is ticks of the file's clock in one second when the
     * file does not say otherwise, in billionths of a second.
     */
    const DEFAULT_TIME_SCALE = 1000000;
    /**
     * TIME_UNITS_PER_SECOND is billionths of a second in one second, used to
     * turn the file's times into a count of samples.
     */
    const TIME_UNITS_PER_SECOND = 1000000000;
    /**
     * SAMPLE_RATE is samples a second the sound is counted in once decoded.
     */
    const SAMPLE_RATE = 48000;
    /**
     * MAX_FILE_SIZE is largest file this will take in. A recording sent in a
     * message is small; anything past this is refused rather than read whole
     * into memory.
     */
    const MAX_FILE_SIZE = 268435456;
    /**
     * NO_LACING is how the lengths of several pieces packed into one block are
     * written, when they are not written at all.
     */
    const NO_LACING = 0;
    /**
     * XIPH_LACING is lengths written as runs of 255 that add up, the oldest of
     * the three ways.
     */
    const XIPH_LACING = 1;
    /**
     * FIXED_LACING is every piece the same length, so only the count is
     * written.
     */
    const FIXED_LACING = 2;
    /**
     * EBML_LACING is the packing where each length is written the way a
     * part's length is written elsewhere in the file, and each
     * length after
     * the first as a difference from the one before.
     */
    const EBML_LACING = 3;
    /**
     * LACING_SHIFT is where in a block's flags the way lengths are written
     * sits.
     */
    const LACING_SHIFT = 1;
    /**
     * LACING_MASK is how much of the flags to look at to read the way above.
     */
    const LACING_MASK = 0x03;
    /**
     * data stores the whole file, held in memory.
     * @var string
     */
    public $data;
    /**
     * track_number stores which number the sound's blocks are labeled with.
     * @var int
     */
    public $track_number;
    /**
     * codec_name stores how the sound was compressed, as the file names it.
     * @var string
     */
    public $codec_name;
    /**
     * codec_setup stores the settings a decoder needs before it can read the
     * sound, as the file gave them.
     * @var string
     */
    public $codec_setup;
    /**
     * codec_delay stores sound at the start that is only there to let the
     * decoder settle, in billionths of a second.
     * @var int
     */
    public $codec_delay;
    /**
     * sample_rate stores how many samples a second the sound was taken at.
     * @var float
     */
    public $sample_rate;
    /**
     * channel_count stores how many channels the sound has.
     * @var int
     */
    public $channel_count;
    /**
     * time_scale stores what one tick of the file's clock is worth in
     * billionths of a second.
     * @var int
     */
    public $time_scale;
    /**
     * block_count stores how many blocks of sound have been read.
     * @var int
     */
    public $block_count;
    /**
     * __construct sets up a read over the contents of a webm file
     *
     * @param string $data the whole file
     */
    public function __construct($data)
    {
        $this->data = $data;
        $this->track_number = -1;
        $this->codec_name = "";
        $this->codec_setup = "";
        $this->codec_delay = 0;
        $this->sample_rate = self::SAMPLE_RATE;
        $this->channel_count = 1;
        $this->time_scale = self::DEFAULT_TIME_SCALE;
        $this->block_count = 0;
    }
    /**
     * 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)
    {
        $size = filesize($name);
        if ($size === false) {
            throw new \Exception("Could not measure $name");
        }
        if ($size > self::MAX_FILE_SIZE) {
            throw new \Exception("$name is larger than this reads");
        }
        $data = file_get_contents($name);
        if ($data === false) {
            throw new \Exception("Could not read $name");
        }
        return new self($data);
    }
    /**
     * packets hands back the pieces of sound in the file one at a time, in the
     * order they were stored. Reading the description of the sound comes first,
     * so what kind of sound it is is known before the first piece is given
     * back.
     *
     * @return \Generator each piece of sound as a MediaPacket
     */
    public function packets()
    {
        $length = strlen($this->data);
        $at = 0;
        $given = 0;
        $cluster_time = 0;
        while ($at < $length) {
            $box = $this->readBoxStart($at);
            if ($box === null) {
                break;
            }
            $name = $box["name"];
            $body_at = $box["body_at"];
            $body_size = $box["body_size"];
            if ($name == self::SEGMENT_BOX || $name == self::CLUSTER_BOX ||
                $name == self::BLOCK_GROUP_BOX) {
                /* These hold more boxes rather than plain data, so the
                   walk steps into them instead of over them. */
                if ($name == self::CLUSTER_BOX) {
                    $cluster_time = 0;
                }
                $at = $body_at;
                continue;
            }
            if ($name == self::INFO_BOX) {
                $this->readInfo($body_at, $body_size);
            } else if ($name == self::TRACKS_BOX) {
                $this->readTracks($body_at, $body_size);
            } else if ($name == self::CLUSTER_TIME_BOX) {
                $cluster_time = $this->readNumber($body_at, $body_size);
            } else if ($name == self::SIMPLE_BLOCK_BOX ||
                $name == self::BLOCK_BOX) {
                foreach ($this->readBlock($body_at, $body_size,
                    $cluster_time) as $piece) {
                    yield new MediaPacket($piece["data"], $this->track_number,
                        $piece["position"], $given, false);
                    $given++;
                }
            }
            $at = $body_at + $body_size;
        }
    }
    /**
     * readInfo reads what the file says about itself, which is where the worth
     * of one tick of its clock is written
     *
     * @param int $at where the box's contents begin
     * @param int $size how long those contents are
     */
    public function readInfo($at, $size)
    {
        $end = $at + $size;
        while ($at < $end) {
            $box = $this->readBoxStart($at);
            if ($box === null) {
                return;
            }
            if ($box["name"] == self::TIME_SCALE_BOX) {
                $this->time_scale = $this->readNumber($box["body_at"],
                    $box["body_size"]);
            }
            $at = $box["body_at"] + $box["body_size"];
        }
    }
    /**
     * readTracks reads the description of each sound or picture in the file and
     * keeps the first sound found
     *
     * @param int $at where the box's contents begin
     * @param int $size how long those contents are
     */
    public function readTracks($at, $size)
    {
        $end = $at + $size;
        while ($at < $end) {
            $box = $this->readBoxStart($at);
            if ($box === null) {
                return;
            }
            if ($box["name"] == self::TRACK_BOX && $this->track_number < 0) {
                $this->readTrack($box["body_at"], $box["body_size"]);
            }
            $at = $box["body_at"] + $box["body_size"];
        }
    }
    /**
     * readTrack reads the description of one sound or picture, keeping it only
     * where it turns out to be sound
     *
     * @param int $at where the box's contents begin
     * @param int $size how long those contents are
     */
    public function readTrack($at, $size)
    {
        $end = $at + $size;
        $number = -1;
        $kind = 0;
        $name = "";
        $setup = "";
        $delay = 0;
        $rate = self::SAMPLE_RATE;
        $channels = 1;
        while ($at < $end) {
            $box = $this->readBoxStart($at);
            if ($box === null) {
                return;
            }
            $body_at = $box["body_at"];
            $body_size = $box["body_size"];
            if ($box["name"] == self::TRACK_NUMBER_BOX) {
                $number = $this->readNumber($body_at, $body_size);
            } else if ($box["name"] == self::TRACK_KIND_BOX) {
                $kind = $this->readNumber($body_at, $body_size);
            } else if ($box["name"] == self::CODEC_NAME_BOX) {
                $name = rtrim(substr($this->data, $body_at, $body_size),
                    "\0");
            } else if ($box["name"] == self::CODEC_SETUP_BOX) {
                $setup = substr($this->data, $body_at, $body_size);
            } else if ($box["name"] == self::CODEC_DELAY_BOX) {
                $delay = $this->readNumber($body_at, $body_size);
            } else if ($box["name"] == self::SOUND_BOX) {
                $sound_end = $body_at + $body_size;
                $sound_at = $body_at;
                while ($sound_at < $sound_end) {
                    $inner = $this->readBoxStart($sound_at);
                    if ($inner === null) {
                        break;
                    }
                    if ($inner["name"] == self::SAMPLE_RATE_BOX) {
                        $rate = $this->readFloat($inner["body_at"],
                            $inner["body_size"]);
                    } else if ($inner["name"] == self::CHANNEL_COUNT_BOX) {
                        $channels = $this->readNumber($inner["body_at"],
                            $inner["body_size"]);
                    }
                    $sound_at = $inner["body_at"] + $inner["body_size"];
                }
            }
            $at = $body_at + $body_size;
        }
        if ($kind == self::SOUND_KIND && $number >= 0) {
            $this->track_number = $number;
            $this->codec_name = $name;
            $this->codec_setup = $setup;
            $this->codec_delay = $delay;
            $this->sample_rate = $rate;
            $this->channel_count = $channels;
        }
    }
    /**
     * readBlock pulls the pieces of sound out of one block, working out where
     * each belongs from the time the block carries
     *
     * @param int $at where the block's contents begin
     * @param int $size how long those contents are
     * @param int $cluster_time the starting time the block counts from
     * @return array each piece of sound with where it belongs
     */
    public function readBlock($at, $size, $cluster_time)
    {
        $end = $at + $size;
        $label = $this->readVariableNumber($at, true);
        if ($label === null) {
            return [];
        }
        $at = $label["next_at"];
        if ($label["value"] != $this->track_number || $at + 3 > $end) {
            return [];
        }
        /* The time a block carries is a difference from its cluster's
           starting time and may run backwards, so it is read as a
           number that can be negative. */
        $offset = (ord($this->data[$at]) << 8) | ord($this->data[$at + 1]);
        if ($offset >= 0x8000) {
            $offset -= 0x10000;
        }
        $flags = ord($this->data[$at + 2]);
        $at += 3;
        $lacing = ($flags >> self::LACING_SHIFT) & self::LACING_MASK;
        $sizes = $this->readLacedSizes($at, $end, $lacing);
        if ($sizes === null) {
            return [];
        }
        $at = $sizes["next_at"];
        $ticks = $cluster_time + $offset;
        $position = (int)round($ticks * $this->time_scale *
            self::SAMPLE_RATE / self::TIME_UNITS_PER_SECOND);
        $pieces = [];
        foreach ($sizes["sizes"] as $piece_size) {
            if ($piece_size < 0 || $at + $piece_size > $end) {
                break;
            }
            $pieces[] = ["data" => substr($this->data, $at, $piece_size),
                "position" => $position];
            $at += $piece_size;
            $this->block_count++;
            /* Only the first piece in a block carries a time; the rest
               follow it, and how far apart they are is a question for
               whatever reads the sound itself. */
            $position = -1;
        }
        return $pieces;
    }
    /**
     * readLacedSizes works out how long each piece packed into a block is, in
     * whichever of the three ways the block wrote them where the lengths could
     * not be read
     *
     * @param int $at where the lengths begin
     * @param int $end where the block's contents end
     * @param int $lacing which of the three ways was used
     * @return mixed the lengths and where the sound begins, or null
     */
    public function readLacedSizes($at, $end, $lacing)
    {
        if ($lacing == self::NO_LACING) {
            return ["sizes" => [$end - $at], "next_at" => $at];
        }
        if ($at >= $end) {
            return null;
        }
        $count = ord($this->data[$at]) + 1;
        $at++;
        if ($lacing == self::FIXED_LACING) {
            $left = $end - $at;
            if ($count < 1 || $left % $count != 0) {
                return null;
            }
            return ["sizes" => array_fill(0, $count, intdiv($left, $count)),
                "next_at" => $at];
        }
        $sizes = [];
        if ($lacing == self::XIPH_LACING) {
            for ($i = 0; $i < $count - 1; $i++) {
                $piece_size = 0;
                while (true) {
                    if ($at >= $end) {
                        return null;
                    }
                    $part = ord($this->data[$at]);
                    $at++;
                    $piece_size += $part;
                    if ($part != 255) {
                        break;
                    }
                }
                $sizes[] = $piece_size;
            }
        } else {
            $first = $this->readVariableNumber($at, true);
            if ($first === null) {
                return null;
            }
            $piece_size = $first["value"];
            $sizes[] = $piece_size;
            $at = $first["next_at"];
            for ($i = 1; $i < $count - 1; $i++) {
                $step = $this->readVariableNumber($at, true);
                if ($step === null) {
                    return null;
                }
                /* Each length after the first is written as a
                   difference from the one before, shifted so it can
                   run either way. */
                $middle = (1 << ($step["width"] * 7 - 1)) - 1;
                $piece_size += $step["value"] - $middle;
                $sizes[] = $piece_size;
                $at = $step["next_at"];
            }
        }
        $used = array_sum($sizes);
        $sizes[] = $end - $at - $used;
        return ["sizes" => $sizes, "next_at" => $at];
    }
    /**
     * readBoxStart reads the start of a box: what it is, and where and how long
     * its contents are long they are, or null where it could not be read
     *
     * @param int $at where the box begins
     * @return mixed the box's name, where its contents begin, and how
     */
    public function readBoxStart($at)
    {
        $name = $this->readVariableNumber($at, false);
        if ($name === null) {
            return null;
        }
        $size = $this->readVariableNumber($name["next_at"], true);
        if ($size === null) {
            return null;
        }
        $body_at = $size["next_at"];
        $body_size = $size["value"];
        $length = strlen($this->data);
        /* A box may say it does not know its own length, which a
           recorder writes when it cannot go back and fill the length
           in. Such a box runs to the end of what there is. */
        if ($size["unstated"] || $body_at + $body_size > $length) {
            $body_size = $length - $body_at;
        }
        return ["name" => $name["value"], "body_at" => $body_at,
            "body_size" => $body_size];
    }
    /**
     * readVariableNumber reads a number whose length is written into its own
     * first byte, which is how box names and box lengths are both stored
     * length, which box lengths do and box names do not and whether it was the
     * all ones value meaning unstated, or null where it could not be read
     *
     * @param int $at where the number begins
     * @param bool $strip whether to drop the bit that marked the
     * @return mixed the number, how many bytes it took, where it ends,
     */
    public function readVariableNumber($at, $strip)
    {
        $length = strlen($this->data);
        if ($at < 0 || $at >= $length) {
            return null;
        }
        $first = ord($this->data[$at]);
        if ($first == 0) {
            return null;
        }
        $width = 1;
        $marker = 0x80;
        while (!($first & $marker)) {
            $marker >>= 1;
            $width++;
        }
        if ($at + $width > $length) {
            return null;
        }
        $value = $strip ? ($first & ($marker - 1)) : $first;
        $all_ones = $strip ? ($marker - 1) : 0;
        for ($i = 1; $i < $width; $i++) {
            $value = ($value << 8) | ord($this->data[$at + $i]);
            $all_ones = ($all_ones << 8) | 0xFF;
        }
        return ["value" => $value, "width" => $width,
            "next_at" => $at + $width,
            "unstated" => $strip && $value == $all_ones];
    }
    /**
     * readNumber reads a plain number stored most significant byte first
     *
     * @param int $at where the number begins
     * @param int $size how many bytes it takes
     * @return int the number read
     */
    public function readNumber($at, $size)
    {
        $value = 0;
        for ($i = 0; $i < $size; $i++) {
            $value = ($value << 8) | ord($this->data[$at + $i]);
        }
        return $value;
    }
    /**
     * readFloat reads a number with a fractional part, which the file writes in
     * either of two widths
     *
     * @param int $at where the number begins
     * @param int $size how many bytes it takes
     * @return float the number read
     */
    public function readFloat($at, $size)
    {
        if ($size == 4) {
            $read = unpack("G", substr($this->data, $at, 4));
            return $read[1];
        }
        if ($size == 8) {
            $read = unpack("E", substr($this->data, $at, 8));
            return $read[1];
        }
        return (float)$this->readNumber($at, $size);
    }
}
X