<?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\CeltSynthesis;
use seekquarry\yioop\library\av_processing\Mdct;
use seekquarry\yioop\library\UnitTest;
/**
* Checks the fade that joins one stretch of sound to the next, and the
* filter that lets back down the tilt a recording was given before it
* was compressed.
*
* The fade is the interesting part. An ordinary one rises across a
* whole stretch and falls across the next, so half the sound is being
* faded at any moment and a player must wait for both stretches
* covering a sample before it can give that sample out. Opus cannot
* afford that wait, so its fade is flat across nearly all of a stretch
* and tapers only over two and a half milliseconds at each end. What
* has to be shown is that the shorter taper still cancels exactly: two
* stretches turned into tones and back, faded and added, must give
* back the sound that went in.
*
* What these cases do not check is whether the samples that come out
* are the ones another decoder would produce from the same recording.
* They are not, yet. See the note in the README.
*
* @author Chris Pollett
*/
class CeltSynthesisTest extends UnitTest
{
/**
* How close the fade has to come to cancelling exactly
*/
const CLOSE_ENOUGH = 1e-9;
/**
* How long the taper at each end of a stretch is
*/
const TAPER = 120;
/**
* The stretch lengths Opus works in, as numbers of tones
*/
const LENGTHS = [120, 240, 480, 960];
/**
* 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()
{
}
/**
* The fade must be flat across most of a stretch and taper only at
* its ends, which is what keeps the delay short
*/
public function fadeIsMostlyFlatTestCase()
{
$tones = 480;
$fade = CeltSynthesis::fadeFor($tones, self::TAPER);
$this->assertEqual(count($fade), 2 * $tones,
"the fade covers twice as many samples as there are tones");
$begins = intdiv($tones - self::TAPER, 2);
$this->assertEqual($fade[0], 0.0,
"the fade is nothing where the stretches do not yet overlap");
$this->assertEqual($fade[2 * $tones - 1], 0.0,
"and nothing again once they no longer overlap");
$this->assertEqual($fade[$begins + self::TAPER], 1.0,
"it has reached full by the end of its first taper");
$this->assertEqual($fade[$tones - 1], 1.0,
"and is still full at the middle of the stretch");
$this->assertTrue($fade[$begins] > 0.0 &&
$fade[$begins] < 0.05, "the taper starts from near nothing");
$flat = 0;
foreach ($fade as $value) {
if ($value > 0.999999) {
$flat++;
}
}
/* Only the two tapers and the runs of nothing beyond them are
not flat, which is what keeps a player from having to wait
for two whole stretches before it can give out a sample. */
$this->assertTrue($flat > 2 * ($tones - $begins - self::TAPER) - 1,
"the fade is flat across everything but its tapers");
}
/**
* The fade must satisfy the condition that makes neighboring
* stretches cancel each other's errors
*/
public function fadeCancelsTestCase()
{
$worst = 0.0;
foreach (self::LENGTHS as $tones) {
$fade = CeltSynthesis::fadeFor($tones, self::TAPER);
$half = count($fade) >> 1;
for ($i = 0; $i < $half; $i++) {
$total = $fade[$i] * $fade[$i] +
$fade[$half + $i] * $fade[$half + $i];
$worst = max($worst, abs($total - 1.0));
}
}
$this->assertTrue($worst < self::CLOSE_ENOUGH,
"the fade satisfies the condition at every stretch length");
}
/**
* Sound cut into stretches, turned into tones and back, faded and
* added, must give back exactly what went in
*/
public function joinedStretchesGiveBackTheSoundTestCase()
{
foreach ([120, 480] as $tones) {
$length = $tones * 6;
$sound = [];
for ($i = 0; $i < $length; $i++) {
$sound[] = mt_rand(-1000, 1000) / 1000.0;
}
$fade = CeltSynthesis::fadeFor($tones, self::TAPER);
$plan = Mdct::forSize($tones);
$rebuilt = array_fill(0, $length, 0.0);
for ($start = 0; $start + 2 * $tones <= $length;
$start += $tones) {
$stretch = [];
for ($i = 0; $i < 2 * $tones; $i++) {
$stretch[$i] = $sound[$start + $i] * $fade[$i];
}
$back = $plan->inverse($plan->forward($stretch));
for ($i = 0; $i < 2 * $tones; $i++) {
$rebuilt[$start + $i] += $back[$i] * $fade[$i];
}
}
$worst = 0.0;
for ($i = $tones; $i < $length - $tones; $i++) {
$worst = max($worst, abs($rebuilt[$i] - $sound[$i]));
}
$this->assertTrue($worst < self::CLOSE_ENOUGH,
"stretches of $tones tones give back the sound exactly");
}
}
/**
* The filter that lets the tilt back down should lift the low end
* relative to the high end, and should carry on across a join
* rather than starting afresh
*/
public function tiltIsLetBackDownTestCase()
{
$steady = array_fill(0, 200, 1.0);
$carried = 0.0;
$out = CeltSynthesis::letDownTilt($steady, $carried);
$this->assertTrue($out[199] > $out[0] * 5.0,
"a steady input builds up, which is the low end being lifted");
$alternating = [];
for ($i = 0; $i < 200; $i++) {
$alternating[] = ($i % 2 == 0) ? 1.0 : -1.0;
}
$held = 0.0;
$fast = CeltSynthesis::letDownTilt($alternating, $held);
$this->assertTrue(abs($fast[199]) < abs($out[199]),
"a fast changing input is not built up the same way");
$this->assertTrue(abs($carried) > 0.0,
"the filter carries something across to the next stretch");
$first = CeltSynthesis::letDownTilt([1.0, 0.0, 0.0], $held);
$held_on = $held;
$second = CeltSynthesis::letDownTilt([0.0, 0.0], $held_on);
$this->assertTrue(abs($second[0]) > 0.0,
"what the filter carried shows up in the next stretch");
}
/**
* nothingHangingOverAtTheStartTestCase checks that a stretch with
* nothing before it starts from silence.
*/
public function nothingHangingOverAtTheStartTestCase()
{
$tail = array_fill(0, intdiv(CeltSynthesis::TAPER, 2), 0.0);
$this->assertEqual(count($tail), 60,
"the buffer holds the raw half taper the fold reads");
$loudest = 0.0;
foreach ($tail as $value) {
$loudest = max($loudest, abs($value));
}
$this->assertEqual($loudest, 0.0, "it holds nothing at all");
}
}