/ tests / WebmDemuxerTest.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\tests;

use seekquarry\yioop\library\av_processing\OpusHeader;
use seekquarry\yioop\library\av_processing\OpusPacket;
use seekquarry\yioop\library\av_processing\WebmDemuxer;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\UnitTest;

/**
 * Checks that a .webm file can be taken apart again: that the boxes
 * nest and walk correctly, that the description of the sound is found,
 * that blocks give back the sound they hold, and that all three ways
 * of packing several pieces into one block are unpacked.
 *
 * This is the kind Chrome and Firefox both write when a page asks them
 * to record, so it matters more than the others that it reads right. A
 * quarter second recording made by other software is read as well as
 * the boxes built here, since boxes built and read by the same code
 * could agree with each other while disagreeing with what a browser
 * actually writes.
 *
 * @author Chris Pollett
 */
class WebmDemuxerTest extends UnitTest
{
    /**
     * Where the quarter second recording made by other software sits,
     * written as text so no binary file enters the tree
     */
    const REAL_RECORDING = "/test_files/tiny_recording_webm.txt";
    /**
     * The number blocks in the boxes built here are labelled with
     */
    const TRACK = 1;
    /**
     * Nothing needs setting up for these cases
     */
    public function setUp()
    {
    }
    /**
     * Nothing needs clearing away after these cases
     */
    public function tearDown()
    {
    }
    /**
     * Writes how long a box is, in the form where the length of the
     * length is written into its own first byte
     *
     * @param int $size how long the box is
     * @return string the length, ready to put in front of the box
     */
    public function buildSize($size)
    {
        if ($size < 0x7F) {
            return chr(0x80 | $size);
        }
        if ($size < 0x3FFF) {
            return chr(0x40 | ($size >> 8)) . chr($size & 0xFF);
        }
        return chr(0x10) . chr(($size >> 16) & 0xFF) .
            chr(($size >> 8) & 0xFF) . chr($size & 0xFF);
    }
    /**
     * Builds one box around what it should hold
     *
     * @param int $name which box this is
     * @param string $body what the box holds
     * @return string the box, ready to put in a file
     */
    public function buildBox($name, $body)
    {
        $written = "";
        $shift = 0;
        /* A box name is written as the bytes it was given as, with the
           number of them decided by where its highest set bit falls. */
        while ($name >> $shift > 0xFF) {
            $shift += 8;
        }
        for ($at = $shift; $at >= 0; $at -= 8) {
            $written .= chr(($name >> $at) & 0xFF);
        }
        return $written . $this->buildSize(strlen($body)) . $body;
    }
    /**
     * Builds a plain number in the form the boxes store them
     *
     * @param int $value the number to write
     * @return string the number, most significant byte first
     */
    public function buildNumber($value)
    {
        if ($value < 0x100) {
            return chr($value);
        }
        if ($value < 0x10000) {
            return chr($value >> 8) . chr($value & 0xFF);
        }
        return chr(($value >> 24) & 0xFF) . chr(($value >> 16) & 0xFF) .
            chr(($value >> 8) & 0xFF) . chr($value & 0xFF);
    }
    /**
     * Builds the box describing one Opus sound
     *
     * @param string $setup the settings a decoder needs first
     * @return string the box describing the sound
     */
    public function buildTracks($setup)
    {
        $sound = $this->buildBox(WebmDemuxer::CHANNEL_COUNT_BOX,
            $this->buildNumber(1));
        $track = $this->buildBox(WebmDemuxer::TRACK_NUMBER_BOX,
            $this->buildNumber(self::TRACK)) .
            $this->buildBox(WebmDemuxer::TRACK_KIND_BOX,
            $this->buildNumber(WebmDemuxer::SOUND_KIND)) .
            $this->buildBox(WebmDemuxer::CODEC_NAME_BOX,
            WebmDemuxer::OPUS_CODEC_NAME) .
            $this->buildBox(WebmDemuxer::CODEC_SETUP_BOX, $setup) .
            $this->buildBox(WebmDemuxer::SOUND_BOX, $sound);
        return $this->buildBox(WebmDemuxer::TRACKS_BOX,
            $this->buildBox(WebmDemuxer::TRACK_BOX, $track));
    }
    /**
     * Builds one block of sound
     *
     * @param string $body the sound and any lengths in front of it
     * @param int $offset how far the block sits from its cluster start
     * @param int $lacing which of the ways of packing was used
     * @return string the block, ready to put in a cluster
     */
    public function buildBlock($body, $offset = 0, $lacing = 0)
    {
        $head = chr(0x80 | self::TRACK) . chr(($offset >> 8) & 0xFF) .
            chr($offset & 0xFF) . chr($lacing << WebmDemuxer::LACING_SHIFT);
        return $this->buildBox(WebmDemuxer::SIMPLE_BLOCK_BOX, $head . $body);
    }
    /**
     * Builds a whole small file around the blocks it should carry
     *
     * @param string $blocks the blocks, already built
     * @param string $setup the settings a decoder needs first
     * @param int $cluster_time the time the cluster's blocks count from
     * @return string the whole file
     */
    public function buildFile($blocks, $setup = "SETUP", $cluster_time = 0)
    {
        $info = $this->buildBox(WebmDemuxer::INFO_BOX,
            $this->buildBox(WebmDemuxer::TIME_SCALE_BOX,
            $this->buildNumber(WebmDemuxer::DEFAULT_TIME_SCALE)));
        $cluster = $this->buildBox(WebmDemuxer::CLUSTER_BOX,
            $this->buildBox(WebmDemuxer::CLUSTER_TIME_BOX,
            $this->buildNumber($cluster_time)) . $blocks);
        return $this->buildBox(WebmDemuxer::SEGMENT_BOX,
            $info . $this->buildTracks($setup) . $cluster);
    }
    /**
     * Reads every piece of sound out of a file held in memory
     *
     * @param string $data the whole file
     * @return array the pieces read and the reader that read them
     */
    public function readAll($data)
    {
        $reader = new WebmDemuxer($data);
        $pieces = [];
        foreach ($reader->packets() as $piece) {
            $pieces[] = $piece;
        }
        return [$pieces, $reader];
    }
    /**
     * The description of the sound should be found and read before any
     * sound is handed back
     */
    public function soundDescriptionIsFoundTestCase()
    {
        $data = $this->buildFile($this->buildBlock("SOUND"));
        list($pieces, $reader) = $this->readAll($data);
        $this->assertEqual($reader->codec_name,
            WebmDemuxer::OPUS_CODEC_NAME, "the sound is named as Opus");
        $this->assertEqual($reader->codec_setup, "SETUP",
            "the decoder settings came back");
        $this->assertEqual($reader->track_number, self::TRACK,
            "the sound's number came back");
        $this->assertEqual($reader->channel_count, 1,
            "the channel count came back");
    }
    /**
     * A block holding one piece should give back that piece whole
     */
    public function oneBlockOnePieceTestCase()
    {
        $data = $this->buildFile($this->buildBlock("SOUND"));
        list($pieces, $reader) = $this->readAll($data);
        $this->assertEqual(count($pieces), 1, "one piece comes back");
        $this->assertEqual($pieces[0]->data, "SOUND", "the piece is intact");
        $this->assertEqual($pieces[0]->position, 0,
            "the piece belongs at the start");
    }
    /**
     * Where a block sits away from its cluster's start, that distance
     * should turn into a count of samples
     */
    public function blockTimeBecomesSamplesTestCase()
    {
        $blocks = $this->buildBlock("ONE", 0) . $this->buildBlock("TWO", 20);
        $data = $this->buildFile($blocks);
        list($pieces, $reader) = $this->readAll($data);
        $this->assertEqual(count($pieces), 2, "both pieces come back");
        $this->assertEqual($pieces[0]->position, 0, "the first is at zero");
        /* Twenty ticks of a clock running a thousand ticks a second is
           twenty milliseconds, which at 48000 samples a second is 960
           samples. */
        $this->assertEqual($pieces[1]->position, 960,
            "twenty milliseconds later is 960 samples in");
    }
    /**
     * A cluster starting away from zero should push its blocks along
     * by that much
     */
    public function clusterTimeMovesBlocksTestCase()
    {
        $data = $this->buildFile($this->buildBlock("SOUND", 0), "SETUP", 100);
        list($pieces, $reader) = $this->readAll($data);
        $this->assertEqual($pieces[0]->position, 4800,
            "a cluster starting a tenth of a second in moves its block");
    }
    /**
     * Where the lengths of several pieces are written as runs that add
     * up, each piece should come back separately
     */
    public function xiphPackingTestCase()
    {
        $lengths = chr(2) . chr(3) . chr(4);
        $body = $lengths . "AAA" . "BBBB" . "CCCCC";
        $data = $this->buildFile($this->buildBlock($body, 0,
            WebmDemuxer::XIPH_LACING));
        list($pieces, $reader) = $this->readAll($data);
        $this->assertEqual(count($pieces), 3, "three pieces come back");
        $this->assertEqual($pieces[0]->data, "AAA", "first as stated");
        $this->assertEqual($pieces[1]->data, "BBBB", "second as stated");
        $this->assertEqual($pieces[2]->data, "CCCCC",
            "the last is what was left");
    }
    /**
     * Where the pieces are all the same length, only the count is
     * written and the rest divides evenly
     */
    public function fixedPackingTestCase()
    {
        $body = chr(2) . "AAABBBCCC";
        $data = $this->buildFile($this->buildBlock($body, 0,
            WebmDemuxer::FIXED_LACING));
        list($pieces, $reader) = $this->readAll($data);
        $this->assertEqual(count($pieces), 3, "three pieces come back");
        $this->assertEqual($pieces[1]->data, "BBB", "the middle is right");
    }
    /**
     * Where the lengths are written the way box lengths are, each
     * after the first as a difference, the pieces should still come
     * back at their right lengths
     */
    public function ebmlPackingTestCase()
    {
        /* The first length is written as it is; the second is written
           as how much longer it is than the first, shifted so it can
           run either way. */
        $body = chr(2) . chr(0x80 | 3) . chr(0x80 | (63 + 1)) .
            "AAA" . "BBBB" . "CCCCC";
        $data = $this->buildFile($this->buildBlock($body, 0,
            WebmDemuxer::EBML_LACING));
        list($pieces, $reader) = $this->readAll($data);
        $this->assertEqual(count($pieces), 3, "three pieces come back");
        $this->assertEqual($pieces[0]->data, "AAA", "first as stated");
        $this->assertEqual($pieces[1]->data, "BBBB",
            "the second is longer by the difference written");
        $this->assertEqual($pieces[2]->data, "CCCCC",
            "the last is what was left");
    }
    /**
     * Only the first piece in a block carries a time; the rest follow
     * it and say they have none
     */
    public function onlyFirstPackedPieceHasATimeTestCase()
    {
        $body = chr(1) . "AAABBB";
        $data = $this->buildFile($this->buildBlock($body, 0,
            WebmDemuxer::FIXED_LACING));
        list($pieces, $reader) = $this->readAll($data);
        $this->assertEqual($pieces[0]->position, 0, "the first has a time");
        $this->assertEqual($pieces[1]->position, -1,
            "the one after it has none");
    }
}
X