<?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");
}
}