/ tests / OggDemuxerTest.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\OggDemuxer;
use seekquarry\yioop\library\av_processing\OpusHeader;
use seekquarry\yioop\library\av_processing\OpusPacket;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\UnitTest;

/**
 * Checks that an .opus file can be taken apart again: that whole pieces
 * of sound come back out of it, that a piece split across two pages is
 * put back together, and that a damaged page is stepped over rather
 * than handed on as sound.
 *
 * Two kinds of test data are used. Most cases build pages here, so a
 * case can set up exactly the shape it means to check. One case reads
 * a quarter second recording made by other software, which is what
 * shows the check value is worked out the same way everything else
 * works it out; pages built and checked by the same code could agree
 * with each other and still disagree with the rest of the world.
 *
 * @author Chris Pollett
 */
class OggDemuxerTest extends UnitTest
{
    /**
     * The four letters every page begins with
     */
    const CAPTURE = "OggS";
    /**
     * Set on a page whose first piece began on an earlier page
     */
    const CONTINUED = 0x01;
    /**
     * Set on the last page of a sound
     */
    const LAST_PAGE = 0x04;
    /**
     * A length of this value means the piece carries on
     */
    const CONTINUING = 255;
    /**
     * 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_ogg.txt";
    /**
     * Where scratch files this test writes are put
     * @var string
     */
    public $folder;
    /**
     * Names of the scratch files written, so they can be removed after
     * @var array
     */
    public $written;
    /**
     * Sets up somewhere to write the small files these cases read back
     */
    public function setUp()
    {
        $this->folder = sys_get_temp_dir();
        $this->written = [];
    }
    /**
     * Removes the small files these cases wrote
     */
    public function tearDown()
    {
        foreach ($this->written as $name) {
            if (file_exists($name)) {
                unlink($name);
            }
        }
        $this->written = [];
    }
    /**
     * Builds one page of an .opus file around the pieces of sound it
     * should carry
     *
     * @param array $lengths how long each run of the page's table says
     *      its part of the sound is
     * @param string $body the sound the page carries
     * @param int $flags what the page says about itself
     * @param int $position where the page's last whole piece belongs
     * @param int $stream which sound in the file the page belongs to
     * @param int $number how many pages of this sound came before
     * @return string the page, ready to write
     */
    public function buildPage($lengths, $body, $flags = 0, $position = 0,
        $stream = 1, $number = 0)
    {
        $header = self::CAPTURE . chr(0) . chr($flags) .
            pack("P", $position) . pack("V", $stream) . pack("V", $number) .
            pack("V", 0) . chr(count($lengths));
        foreach ($lengths as $length) {
            $header .= chr($length);
        }
        $whole = $header . $body;
        $check = OggDemuxer::checksum($whole);
        /* The check value is worked out over the page with its own
           place left blank, then written into that place. */
        return substr($whole, 0, OggDemuxer::CHECKSUM_OFFSET) .
            pack("V", $check) .
            substr($whole, OggDemuxer::SEGMENT_COUNT_OFFSET);
    }
    /**
     * Writes bytes to a scratch file and hands back its name
     *
     * @param string $data what to write
     * @param string $label a word to build the name from
     * @return string the name written to
     */
    public function writeScratch($data, $label)
    {
        $name = $this->folder . "/ogg_test_$label.tmp";
        file_put_contents($name, $data);
        $this->written[] = $name;
        return $name;
    }
    /**
     * Reads every piece of sound out of a file
     *
     * @param string $name the file to read
     * @return array the pieces read
     */
    public function readAll($name)
    {
        $reader = OggDemuxer::fromName($name);
        $pieces = [];
        foreach ($reader->packets() as $piece) {
            $pieces[] = $piece;
        }
        return [$pieces, $reader];
    }
    /**
     * A page holding two whole pieces should give back both, each with
     * the sound it was built around
     */
    public function twoWholePiecesTestCase()
    {
        $page = $this->buildPage([5, 3], "HELLOBYE", 0, 960);
        $name = $this->writeScratch($page, "two");
        list($pieces, $reader) = $this->readAll($name);
        $this->assertEqual(count($pieces), 2, "two pieces come back");
        $this->assertEqual($pieces[0]->data, "HELLO", "first piece intact");
        $this->assertEqual($pieces[1]->data, "BYE", "second piece intact");
        $this->assertEqual($reader->damaged_count, 0, "no page damaged");
        $this->assertEqual($pieces[1]->position, 960,
            "last piece carries the page position");
        $this->assertEqual($pieces[0]->position, -1,
            "an earlier piece carries no position");
    }
    /**
     * A piece too long for one page should come back whole, joined
     * from the parts the two pages carried
     */
    public function pieceAcrossTwoPagesTestCase()
    {
        $first_part = str_repeat("A", self::CONTINUING);
        $second_part = "TAIL";
        $first = $this->buildPage([self::CONTINUING], $first_part, 0, -1, 1,
            0);
        $second = $this->buildPage([strlen($second_part)], $second_part,
            self::CONTINUED, 960, 1, 1);
        $name = $this->writeScratch($first . $second, "across");
        list($pieces, $reader) = $this->readAll($name);
        $this->assertEqual(count($pieces), 1, "the two parts make one piece");
        $this->assertEqual($pieces[0]->data, $first_part . $second_part,
            "the piece is joined in the right order");
        $this->assertEqual($reader->page_count, 2, "both pages were read");
    }
    /**
     * Changing a byte inside a page should make that page fail its
     * check, so its sound is dropped while the pages around it still
     * come back
     */
    public function damagedPageIsSteppedOverTestCase()
    {
        $first = $this->buildPage([4], "ONE_", 0, 480, 1, 0);
        $second = $this->buildPage([4], "TWO_", 0, 960, 1, 1);
        $third = $this->buildPage([5], "THREE", self::LAST_PAGE, 1440, 1, 2);
        $whole = $first . $second . $third;
        $spoil_at = strlen($first) + strlen($second) - 2;
        $whole[$spoil_at] = "X";
        $name = $this->writeScratch($whole, "damaged");
        list($pieces, $reader) = $this->readAll($name);
        $this->assertEqual($reader->damaged_count, 1, "one page was damaged");
        $this->assertEqual($reader->page_count, 2, "two pages still read");
        $this->assertEqual(count($pieces), 2, "two pieces still come back");
        $this->assertEqual($pieces[0]->data, "ONE_", "first piece intact");
        $this->assertEqual($pieces[1]->data, "THREE", "third piece intact");
    }
    /**
     * A piece of no length is a real thing in these files and should
     * come back as an empty piece rather than being skipped
     */
    public function emptyPieceTestCase()
    {
        $page = $this->buildPage([0, 4], "TAIL", 0, 960);
        $name = $this->writeScratch($page, "empty");
        list($pieces, $reader) = $this->readAll($name);
        $this->assertEqual(count($pieces), 2, "the empty piece counts");
        $this->assertEqual($pieces[0]->data, "", "the empty piece is empty");
        $this->assertEqual($pieces[1]->data, "TAIL", "the next piece intact");
    }
    /**
     * Two sounds stored in the same file should be kept apart, each
     * gathering only its own pages
     */
    public function twoSoundsInOneFileTestCase()
    {
        $one_first = $this->buildPage([self::CONTINUING],
            str_repeat("A", self::CONTINUING), 0, -1, 7, 0);
        $two_only = $this->buildPage([4], "OTHR", 0, 960, 9, 0);
        $one_rest = $this->buildPage([3], "END", self::CONTINUED, 960, 7, 1);
        $name = $this->writeScratch($one_first . $two_only . $one_rest,
            "twosounds");
        list($pieces, $reader) = $this->readAll($name);
        $this->assertEqual(count($pieces), 2, "one piece from each sound");
        $by_stream = [];
        foreach ($pieces as $piece) {
            $by_stream[$piece->stream] = $piece->data;
        }
        $this->assertEqual($by_stream[9], "OTHR",
            "the other sound is untouched");
        $this->assertEqual($by_stream[7],
            str_repeat("A", self::CONTINUING) . "END",
            "the split piece joined across the other sound's page");
    }
}
X