/ tests / CeltSynthesisTest.php
<?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");
    }
}
X