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