/ tests / MdctTest.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\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");
    }
}
X