<?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\CeltBands;
use seekquarry\yioop\library\av_processing\CeltEnergy;
use seekquarry\yioop\library\av_processing\CeltStretch;
use seekquarry\yioop\library\av_processing\OpusPacket;
use seekquarry\yioop\library\av_processing\RangeDecoder;
use seekquarry\yioop\library\av_processing\WebmDemuxer;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\UnitTest;
/**
* Reads whole stretches of real recordings and checks where the
* reading lands.
*
* This is the strongest check in the whole of this work, and it is
* worth saying why. Whatever wrote these recordings filled each
* stretch to the bit: there is no slack at the end, because slack
* would be room that could have carried sound. So a reading that
* agrees with the writer at every step will finish a stretch with
* almost nothing left over.
*
* A reading that has gone wrong anywhere will not. It will not be a
* little out; it will be hundreds or thousands of bits out, because
* every step decides how much room the next has, and one wrong step
* throws off every step after it. There is no way for a reading with a
* fault in it to land near the end by accident.
*
* The cases below read every stretch of three recordings made by other
* software and check that each lands within a bit of its end. That
* covers the whole chain at once: the compressed number reader, the
* loudnesses, the sharing out of room, the splitting of wide bands,
* the smearing, the filling of empty bands, and the leftover bits.
*
* @author Chris Pollett
*/
class CeltStretchTest extends UnitTest
{
/**
* Recordings to read whole stretches of
*/
const RECORDINGS = ["/test_files/tiny_tone_webm.txt",
"/test_files/tiny_noise_webm.txt",
"/test_files/tiny_silence_webm.txt"];
/**
* The longest stretch, which nearly every recording uses
*/
const DOUBLINGS = 3;
/**
* How many bands the recordings carry
*/
const BANDS = 21;
/**
* Which band covers the tone the first recording holds. Band two
* runs from 400 to 600 cycles a second and the tone is at 440.
*/
const TONE_BAND = 2;
/**
* How much room may be left at the end of a stretch. A whole bit
* is generous: the reading normally lands within half of one.
*/
const ROOM_ALLOWED = 8;
/**
* How far over the end a stretch may run. The finest amount that
* exists is an eighth of a bit, and rounding at the very end of a
* recording can cost that much.
*/
const OVERRUN_ALLOWED = 1;
/**
* How many stretches of each recording to read. Reading a stretch
* whole is the most work anything here does, so the cases read
* enough to be convincing and leave measuring the speed of it to
* the experiment beside them.
*/
const STRETCHES_READ = 3;
/**
* How many stretches the case that reads twice uses, since it
* cannot share the reading the other cases share
*/
const STRETCHES_READ_TWICE = 2;
/**
* What reading each recording gave, kept so the work is done once
* @var array
*/
public static $read_already = [];
/**
* Nothing needs setting up for these cases
*/
public function setUp()
{
}
/**
* Nothing needs clearing away after these cases
*/
public function tearDown()
{
}
/**
* Reads every stretch of one recording
*
* @param string $where which recording to read
* @return array what each stretch gave
*/
public function readEverything($where)
{
if (isset(self::$read_already[$where])) {
return self::$read_already[$where];
}
$stored = file_get_contents(C\PARENT_DIR . "/tests" . $where);
$file = new WebmDemuxer(base64_decode($stored));
$before = CeltEnergy::nothingYet(1);
$seed = 0;
$found = [];
foreach ($file->packets() as $piece) {
$sound = OpusPacket::fromString($piece->data);
if ($sound->method != OpusPacket::MUSIC_METHOD) {
continue;
}
foreach ($sound->stretches as $stretch) {
if (strlen($stretch) < 2) {
continue;
}
$reader = new RangeDecoder($stretch);
$read = CeltStretch::readFrom($reader, self::DOUBLINGS,
$before, $seed, 0, self::BANDS);
$before = $read["loudness"];
$seed = $read["seed"];
$read["size"] = strlen($stretch);
$found[] = $read;
if (count($found) >= self::STRETCHES_READ) {
break 2;
}
}
}
self::$read_already[$where] = $found;
return $found;
}
/**
* Reads every stretch of every recording
*
* @return array what each stretch gave
*/
public function readAll()
{
$found = [];
foreach (self::RECORDINGS as $where) {
foreach ($this->readEverything($where) as $one) {
$found[] = $one;
}
}
return $found;
}
/**
* Every stretch should be read right to its end, with less than a
* bit left over. This case runs for about fifteen thousandths of a
* second, since it decodes every stretch of a real recording.
*/
public function everyStretchIsReadToItsEndTestCase()
{
$short = 0;
$over = 0;
$checked = 0;
$total = 0;
foreach ($this->readAll() as $one) {
if ($one["header"]->silent) {
continue;
}
if ($one["left"] > self::ROOM_ALLOWED) {
$short++;
}
if ($one["left"] < -self::OVERRUN_ALLOWED) {
$over++;
}
$total += $one["left"];
$checked++;
}
$this->assertTrue($checked > 4, "many stretches were read whole");
$this->assertEqual($short, 0,
"every stretch was read to within a bit of its end");
$this->assertEqual($over, 0, "no stretch was read past its end");
$this->assertTrue($total / $checked < self::ROOM_ALLOWED / 2,
"on average less than half a bit is left over");
}
/**
* Every band kept should end up holding something, since a band
* given no room is filled rather than left silent
*/
public function keptBandsHoldSomethingTestCase()
{
$empty = 0;
$checked = 0;
foreach ($this->readAll() as $one) {
if ($one["header"]->silent) {
continue;
}
for ($band = 0; $band < $one["shared"]["kept"]; $band++) {
if ($one["marks"][$band] == 0) {
$empty++;
}
$checked++;
}
}
$this->assertTrue($checked > 50, "many bands were checked");
$this->assertEqual($empty, 0,
"every band kept ends up holding something");
}
/**
* The slots a stretch gives back should all be real numbers rather
* than anything that has gone astray
*/
public function everySlotIsARealNumberTestCase()
{
$astray = 0;
$checked = 0;
$loudest = 0.0;
foreach ($this->readAll() as $one) {
foreach ($one["slots"] as $slot) {
if (!is_finite($slot)) {
$astray++;
}
$loudest = max($loudest, abs($slot));
$checked++;
}
}
$this->assertTrue($checked > 2000, "many slots were checked");
$this->assertEqual($astray, 0, "every slot is a real number");
$this->assertTrue($loudest > 0.0 && $loudest < 100.0,
"no slot has run away");
}
/**
* A stretch of silence should be read as silent, and should still
* give back a full set of slots.
*
* A silent stretch is not skipped. Its room is marked as used up
* and the reading carries on, so every band is given nothing and
* is filled with faint noise instead. What makes it silent is the
* loudness, which sits at its floor, rather than the slots being
* empty. Skipping such a stretch would leave the reading in a
* different place than the recording expects.
*/
public function silenceIsStillReadThroughTestCase()
{
$silent = 0;
$checked = 0;
foreach ($this->readEverything(self::RECORDINGS[2]) as $one) {
if ($one["header"]->silent) {
$silent++;
$this->assertTrue(count($one["slots"]) > 100,
"a silent stretch still gives back a full set of slots");
$filled = 0;
foreach ($one["slots"] as $slot) {
if ($slot != 0.0) {
$filled++;
}
}
$this->assertTrue($filled > 100,
"and those slots hold the faint noise it is filled with");
}
$checked++;
if ($checked > 2) {
break;
}
}
$this->assertTrue($silent > 0, "the silence was read as silent");
}
/**
* The loudnesses read from a recording of one steady tone should
* put that tone in the band it really belongs to.
*
* This is the check that does not depend on anything downstream.
* The recording holds a 440 cycle tone, the bands are known
* stretches of the sound spectrum, and band two covers 400 to 600
* cycles a second. So the loudest band must be band two, and the
* loudness must fall away on both sides of it. A reading that had
* gone wrong would put the peak somewhere else or spread it
* everywhere.
*/
public function toneLandsInTheRightBandTestCase()
{
$found = $this->readEverything(self::RECORDINGS[0]);
$this->assertTrue(count($found) > 1, "stretches were read");
$checked = 0;
$wrong_band = 0;
$not_peaked = 0;
foreach ($found as $one) {
if ($one["header"]->silent) {
continue;
}
$loudest = -1000.0;
$which = -1;
$levels = [];
foreach ($one["loudness"][0] as $band => $value) {
/* What a band really comes to is what was read plus
what that band was expected to be. */
$levels[$band] = $value + CeltBands::EXPECTED[$band];
if ($levels[$band] > $loudest) {
$loudest = $levels[$band];
$which = $band;
}
}
if ($which != self::TONE_BAND) {
$wrong_band++;
}
if ($levels[self::TONE_BAND] <= $levels[self::TONE_BAND - 1] ||
$levels[self::TONE_BAND] <= $levels[self::TONE_BAND + 1] ||
$levels[self::TONE_BAND] - $levels[11] < 5.0) {
$not_peaked++;
}
$checked++;
}
$this->assertTrue($checked > 1, "stretches of the tone were read");
$this->assertEqual($wrong_band, 0,
"the tone is loudest in the band it belongs to");
$this->assertEqual($not_peaked, 0,
"the loudness falls away on both sides of that band");
}
/**
* Reading the same recording twice should give the same answer
* both times, since nothing in the reading may depend on anything
* outside the recording
*/
public function readingTwiceAgreesTestCase()
{
$stored = file_get_contents(C\PARENT_DIR .
"/tests" . self::RECORDINGS[0]);
$once = [];
$twice = [];
foreach ([0, 1] as $turn) {
$file = new WebmDemuxer(base64_decode($stored));
$before = CeltEnergy::nothingYet(1);
$seed = 0;
foreach ($file->packets() as $piece) {
$sound = OpusPacket::fromString($piece->data);
if ($sound->method != OpusPacket::MUSIC_METHOD) {
continue;
}
foreach ($sound->stretches as $stretch) {
$reader = new RangeDecoder($stretch);
$read = CeltStretch::readFrom($reader, self::DOUBLINGS,
$before, $seed, 0, self::BANDS);
$before = $read["loudness"];
$seed = $read["seed"];
if ($turn == 0) {
$once[] = $read["slots"];
} else {
$twice[] = $read["slots"];
}
if (count($once) >= self::STRETCHES_READ_TWICE &&
$turn == 0) {
break 2;
}
if (count($twice) >= self::STRETCHES_READ_TWICE &&
$turn == 1) {
break 2;
}
}
}
}
$this->assertTrue(count($once) > 1, "stretches were read");
$this->assertEqual($once, $twice, "both readings agree throughout");
}
}