<?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\Mdct;
use seekquarry\yioop\library\UnitTest;
/**
* Checks the transform that turns overlapping stretches of sound into
* tones and back.
*
* Two things have to hold. Each direction on its own has to match what
* working the answer out one term at a time gives, since that is the
* definition. And the two together, applied to overlapping stretches
* with a fade, have to give back exactly the sound that went in. The
* second is the one that matters in use: it is what lets a recording
* be cut into stretches without leaving a click at every join, and it
* holds only if the folding, the fade and the scaling are all right at
* once. A fault in any of them shows up there even where the two
* directions separately look correct.
*
* @author Chris Pollett
*/
class MdctTest extends UnitTest
{
/**
* How close the transform has to come to the definition
*/
const CLOSE_ENOUGH = 1e-9;
/**
* Sizes to check against the definition. These are small because
* working the definition out costs the square of the size.
*/
const SMALL_SIZES = [8, 16, 60];
/**
* Sizes to check the round trip at, which is cheap enough to do at
* the sizes Opus really uses
*/
const OPUS_SIZES = [60, 120, 240, 480];
/**
* A starting point for the made up numbers
*/
const SEED = 20260803;
/**
* Sets the made up numbers off from the same place every run
*/
public function setUp()
{
mt_srand(self::SEED);
}
/**
* Nothing needs clearing away after these cases
*/
public function tearDown()
{
}
/**
* Makes up a stretch of sound to work on
*
* @param int $length how long a stretch to make
* @return array the stretch
*/
public function madeUpSound($length)
{
$sound = [];
for ($i = 0; $i < $length; $i++) {
$sound[] = mt_rand(-1000, 1000) / 1000.0;
}
return $sound;
}
/**
* Works the tones out one term at a time, which is the definition
*
* @param array $sound the stretch of sound
* @param int $size how many tones to give back
* @return array the tones
*/
public function forwardOneAtATime($sound, $size)
{
$tones = [];
for ($tone = 0; $tone < $size; $tone++) {
$total = 0.0;
for ($sample = 0; $sample < 2 * $size; $sample++) {
$total += $sound[$sample] * cos(M_PI / $size *
($sample + 0.5 + $size / 2) * ($tone + 0.5));
}
$tones[] = $total;
}
return $tones;
}
/**
* Works the sound out one term at a time from the tones
*
* @param array $tones the tones
* @param int $size how many tones there are
* @return array the stretch of sound
*/
public function inverseOneAtATime($tones, $size)
{
$sound = [];
for ($sample = 0; $sample < 2 * $size; $sample++) {
$total = 0.0;
for ($tone = 0; $tone < $size; $tone++) {
$total += $tones[$tone] * cos(M_PI / $size *
($sample + 0.5 + $size / 2) * ($tone + 0.5));
}
$sound[] = 2.0 * $total / $size;
}
return $sound;
}
/**
* Going from sound to tones should match the definition
*/
public function forwardMatchesDefinitionTestCase()
{
foreach (self::SMALL_SIZES as $size) {
$sound = $this->madeUpSound(2 * $size);
$got = Mdct::forSize($size)->forward($sound);
$want = $this->forwardOneAtATime($sound, $size);
$stray = 0.0;
for ($i = 0; $i < $size; $i++) {
$stray = max($stray, abs($got[$i] - $want[$i]));
}
$this->assertTrue($stray < self::CLOSE_ENOUGH,
"size $size matches the definition going forward");
}
}
/**
* Going from tones back to sound should match the definition
*/
public function inverseMatchesDefinitionTestCase()
{
foreach (self::SMALL_SIZES as $size) {
$tones = $this->madeUpSound($size);
$got = Mdct::forSize($size)->inverse($tones);
$want = $this->inverseOneAtATime($tones, $size);
$stray = 0.0;
for ($i = 0; $i < 2 * $size; $i++) {
$stray = max($stray, abs($got[$i] - $want[$i]));
}
$this->assertTrue($stray < self::CLOSE_ENOUGH,
"size $size matches the definition going back");
}
}
/**
* The same should hold for the fade that falls away faster at its
* ends, since it satisfies the same condition
*/
public function shapedFadeAlsoComesBackTestCase()
{
$size = 120;
$this->assertTrue($this->roundTripStray($size,
Mdct::shapedFade(2 * $size)) < self::CLOSE_ENOUGH,
"the shaped fade gives the sound back too");
}
/**
* How far a run of sound strays from itself after being cut into
* overlapping stretches and put back together
*
* @param int $size how many tones each stretch gives
* @param array $fade the fade to apply
* @return float the largest difference in the part that two
* stretches cover between them
*/
public function roundTripStray($size, $fade)
{
$length = 4 * $size;
$sound = $this->madeUpSound($length);
$rebuilt = array_fill(0, $length, 0.0);
$plan = Mdct::forSize($size);
for ($start = 0; $start + 2 * $size <= $length; $start += $size) {
$stretch = [];
for ($i = 0; $i < 2 * $size; $i++) {
$stretch[$i] = $sound[$start + $i] * $fade[$i];
}
$back = $plan->inverse($plan->forward($stretch));
for ($i = 0; $i < 2 * $size; $i++) {
$rebuilt[$start + $i] += $back[$i] * $fade[$i];
}
}
/* Only the middle is covered by two stretches. The ends are
covered by one, so nothing there has anything to cancel
against and it is not asked to come back. */
$stray = 0.0;
for ($i = $size; $i < 3 * $size; $i++) {
$stray = max($stray, abs($rebuilt[$i] - $sound[$i]));
}
return $stray;
}
/**
* Silence should give no tones at all, and a run of tones that are
* all zero should give silence back
*/
public function silenceStaysSilentTestCase()
{
$size = 120;
$plan = Mdct::forSize($size);
$tones = $plan->forward(array_fill(0, 2 * $size, 0.0));
$loudest = 0.0;
foreach ($tones as $tone) {
$loudest = max($loudest, abs($tone));
}
$this->assertTrue($loudest < self::CLOSE_ENOUGH,
"silence is made of no tones");
$sound = $plan->inverse(array_fill(0, $size, 0.0));
$loudest = 0.0;
foreach ($sound as $sample) {
$loudest = max($loudest, abs($sample));
}
$this->assertTrue($loudest < self::CLOSE_ENOUGH,
"no tones give back silence");
}
/**
* A size that cannot work should be refused rather than giving a
* wrong answer quietly
*/
public function impossibleSizeIsRefusedTestCase()
{
$refused = 0;
foreach ([0, 1, 7, -4] as $size) {
try {
new Mdct($size);
} catch (\Exception $problem) {
$refused++;
}
}
$this->assertEqual($refused, 4, "every impossible size refused");
}
}