<?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\CeltEnergy;
use seekquarry\yioop\library\av_processing\CeltFrameHeader;
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;
/**
* Checks what a stretch of sound says about itself, against real
* recordings made by other software.
*
* Everything before this was checked against a writer written here,
* which shows two pieces of work agree with each other but not that
* either agrees with the world. These cases are different. They read
* recordings made by software that never saw this code, and they check
* things whose right answer is known from the sound that was recorded
* rather than from any calculation done here.
*
* Three recordings are used, each chosen because it forces a
* particular answer. A recording of silence must say it is silent. A
* recording of one steady tone must switch on the filter that locks
* onto a pitch, and the pitch it reports must be the tone's own. A
* recording of noise has no pitch to lock onto, so the same filter
* must stay off, and the shapes must be spread differently than for
* the tone.
*
* A reader that had lost its place could not produce those answers. It
* would give a pitch unrelated to the tone, or claim silence where
* there is sound, and it would do so differently for each recording
* rather than consistently.
*
* @author Chris Pollett
*/
class CeltFrameHeaderTest extends UnitTest
{
/**
* Where the recording of silence sits
*/
const SILENCE = "/test_files/tiny_silence_webm.txt";
/**
* Where the recording of one steady tone sits
*/
const TONE = "/test_files/tiny_tone_webm.txt";
/**
* Where the recording of noise sits
*/
const NOISE = "/test_files/tiny_noise_webm.txt";
/**
* The pitch of the tone that was recorded, in cycles a second
*/
const TONE_PITCH = 440;
/**
* Samples a second Opus gives back
*/
const SAMPLE_RATE = 48000;
/**
* How many times the shortest stretch has been doubled to reach
* the one these recordings use
*/
const DOUBLINGS = 3;
/**
* How many bands the recordings carry
*/
const BANDS = 21;
/**
* The spreading used when the recording says nothing about it
*/
const USUAL_SPREAD = 2;
/**
* Nothing needs setting up for these cases
*/
public function setUp()
{
}
/**
* Nothing needs clearing away after these cases
*/
public function tearDown()
{
}
/**
* Reads what every stretch of one recording says about itself
*
* @param string $where which recording to read
* @return array what each stretch said, and how long it was
*/
public function readHeaders($where)
{
$stored = file_get_contents(C\PARENT_DIR . "/tests" . $where);
$reader = new WebmDemuxer(base64_decode($stored));
$before = CeltEnergy::nothingYet(1);
$found = [];
foreach ($reader->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;
}
$entropy = new RangeDecoder($stretch);
$header = CeltFrameHeader::readFrom($entropy,
self::DOUBLINGS, $before, 0, self::BANDS);
$found[] = ["header" => $header,
"size" => strlen($stretch),
"used" => $entropy->bitsUsed()];
$before = $header->loudness;
}
}
return $found;
}
/**
* A recording of silence should say so in every stretch, and those
* stretches should be tiny because there is nothing else to say
*/
public function silenceSaysItIsSilentTestCase()
{
$found = $this->readHeaders(self::SILENCE);
$this->assertTrue(count($found) > 10, "there were stretches to read");
$silent = 0;
$largest = 0;
foreach ($found as $one) {
if ($one["header"]->silent) {
$silent++;
}
$largest = max($largest, $one["size"]);
}
$this->assertEqual($silent, count($found),
"every stretch of silence says it is silent");
$this->assertTrue($largest <= 4,
"a stretch that says only that it is silent is tiny");
}
/**
* A recording of one steady tone should switch on the filter that
* locks onto a pitch, and the pitch it reports should be the
* tone's own
*/
public function toneGivesItsOwnPitchTestCase()
{
$found = $this->readHeaders(self::TONE);
$this->assertTrue(count($found) > 10, "there were stretches to read");
$switched_on = 0;
$right_pitch = 0;
$wanted = self::SAMPLE_RATE / self::TONE_PITCH;
foreach ($found as $one) {
$header = $one["header"];
if ($header->silent) {
continue;
}
if ($header->filter_on) {
$switched_on++;
/* The pitch is stored in whole samples, so it can
only land either side of the true period rather than
on it, and the stretches at each end of the
recording, where the tone begins and stops, are
slightly further out again. A reader out of step
would give values scattered across the whole range
the pitch can take, which runs to a thousand
samples, so a few samples either way is a tight
check even so. */
if (abs($header->filter_pitch - $wanted) < 3.0) {
$right_pitch++;
}
}
}
$this->assertEqual($switched_on, count($found),
"the filter is switched on for every stretch of the tone");
$this->assertEqual($right_pitch, $switched_on,
"every stretch reports the pitch the tone was recorded at");
}
/**
* Noise has no pitch to lock onto, so the same filter should stay
* off throughout
*/
public function noiseLeavesTheFilterOffTestCase()
{
$found = $this->readHeaders(self::NOISE);
$this->assertTrue(count($found) > 10, "there were stretches to read");
$switched_on = 0;
foreach ($found as $one) {
if ($one["header"]->filter_on) {
$switched_on++;
}
}
$this->assertEqual($switched_on, 0,
"the filter stays off for every stretch of noise");
}
/**
* A tone and noise should have their shapes spread differently,
* since spreading is what stops a shape sounding tonal when it
* should not
*/
public function toneAndNoiseSpreadDifferentlyTestCase()
{
$tone = [0, 0, 0, 0];
foreach ($this->readHeaders(self::TONE) as $one) {
$tone[$one["header"]->spread]++;
}
$noise = [0, 0, 0, 0];
foreach ($this->readHeaders(self::NOISE) as $one) {
$noise[$one["header"]->spread]++;
}
$this->assertTrue($tone[self::USUAL_SPREAD] >
$tone[self::USUAL_SPREAD + 1],
"the tone is mostly spread the usual amount");
$this->assertTrue($noise[self::USUAL_SPREAD + 1] >
$noise[self::USUAL_SPREAD],
"noise is mostly spread further than usual");
}
/**
* Reading a stretch should never account for more of it than there
* is, which is what would happen were the reader out of step
*/
public function readingStaysInsideTheStretchTestCase()
{
$overrun = 0;
$read = 0;
foreach ([self::SILENCE, self::TONE, self::NOISE] as $where) {
foreach ($this->readHeaders($where) as $one) {
if ($one["used"] > $one["size"] * 8) {
$overrun++;
}
$read++;
}
}
$this->assertTrue($read > 30, "stretches were read");
$this->assertEqual($overrun, 0,
"no stretch was read past its end, $read read");
}
/**
* The loudnesses read from a real recording should sit in a range
* a real sound could take, rather than anywhere at all
*/
public function loudnessesAreBelievableTestCase()
{
$lowest = 1000.0;
$highest = -1000.0;
foreach ($this->readHeaders(self::TONE) as $one) {
foreach ($one["header"]->loudness[0] as $loudness) {
$lowest = min($lowest, $loudness);
$highest = max($highest, $loudness);
}
}
$this->assertTrue($lowest > -30.0 && $highest < 30.0,
"every loudness sits within what a real sound can reach");
$this->assertTrue($highest > $lowest,
"the bands are not all at the same loudness");
}
/**
* The length changes should name a real change for every band,
* rather than something outside what the stretch allows
*/
public function lengthChangesAreRealTestCase()
{
$wrong = 0;
$checked = 0;
foreach ($this->readHeaders(self::TONE) as $one) {
foreach ($one["header"]->length_changes as $change) {
if ($change < -3 || $change > 3) {
$wrong++;
}
$checked++;
}
}
$this->assertTrue($checked > 100, "changes were read");
$this->assertEqual($wrong, 0,
"every change is one the stretch could really have made");
}
/**
* Reading the same recording twice should give the same answer
* both times
*/
public function readingTwiceAgreesTestCase()
{
$once = $this->readHeaders(self::TONE);
$twice = $this->readHeaders(self::TONE);
$this->assertEqual(count($once), count($twice),
"both readings found the same number of stretches");
$differ = 0;
foreach ($once as $at => $one) {
if ($one["header"]->filter_pitch !=
$twice[$at]["header"]->filter_pitch ||
$one["header"]->loudness != $twice[$at]["header"]->loudness) {
$differ++;
}
}
$this->assertEqual($differ, 0, "both readings agree throughout");
}
}