/ tests / CeltEnergyTest.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\CeltBands;
use seekquarry\yioop\library\av_processing\CeltEnergy;
use seekquarry\yioop\library\av_processing\RangeDecoder;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\UnitTest;

/**
 * Checks how Opus divides sound into bands and how each band's
 * loudness is read back.
 *
 * A loudness is never stored as it is. What is stored is how far it
 * fell from what the decoder already expected, and the expectation is
 * built from the same band in the stretch before and from how wrong
 * the bands before it in this stretch turned out to be. So a fault
 * here does not spoil one band; it spoils that band and then leans on
 * every band after it and every stretch after that. The cases below
 * therefore check the leaning as well as the reading: that a run of
 * differences of nothing leaves everything where it was, that one
 * difference moves the bands after it by the right amount, and that a
 * stretch standing on its own ignores what came before.
 *
 * @author Chris Pollett
 */
class CeltEnergyTest extends UnitTest
{
    /**
     * How much room to give a written piece in these cases
     */
    const ROOM = 2000;
    /**
     * How close worked out loudnesses have to come to each other
     */
    const CLOSE_ENOUGH = 1e-9;
    /**
     * The longest stretch, which nearly every recording uses
     */
    const USUAL_DOUBLINGS = 3;
    /**
     * A starting point for the made up numbers
     */
    const SEED = 20260803;
    /**
     * Brings in the writers these cases check against
     */
    public function setUp()
    {
        if (!class_exists("seekquarry\\yioop\\tests\\RangeEncoder")) {
            require_once C\PARENT_DIR . "/tests/test_files/RangeEncoder.php";
        }
        if (!class_exists("seekquarry\\yioop\\tests\\LaplaceWriter")) {
            require_once C\PARENT_DIR . "/tests/test_files/LaplaceWriter.php";
        }
        mt_srand(self::SEED);
    }
    /**
     * Nothing needs clearing away after these cases
     */
    public function tearDown()
    {
    }
    /**
     * Writes a run of differences, one per band, and reads the
     * loudnesses back
     *
     * @param array $steps the difference to store for each band
     * @param array $before what each band was in the stretch before
     * @param bool $alone whether the stretch stands on its own
     * @return array the loudnesses read back
     */
    public function storeAndRead($steps, $before, $alone)
    {
        $shapes = CeltBands::shapesFor(self::USUAL_DOUBLINGS, $alone);
        $writer = new RangeEncoder(self::ROOM);
        $channels = count($before);
        foreach ($steps as $band => $step) {
            for ($channel = 0; $channel < $channels; $channel++) {
                LaplaceWriter::writeTo($writer, $step, $shapes[$band][0],
                    $shapes[$band][1]);
            }
        }
        $reader = new RangeDecoder($writer->finish());
        return CeltEnergy::readRough($reader, $before, $alone,
            self::USUAL_DOUBLINGS, 0, count($steps));
    }
    /**
     * The bands should get wider as they go up, cover the whole of the
     * sound, and widen by doubling as the stretch gets longer
     */
    public function bandLayoutTestCase()
    {
        $edges = CeltBands::edgesFor(0);
        $this->assertEqual(count($edges), CeltBands::BAND_COUNT + 1,
            "there is one more edge than there are bands");
        $rising = true;
        $widening = true;
        $narrowest = CeltBands::widthOf(0, 0);
        for ($band = 0; $band < CeltBands::BAND_COUNT; $band++) {
            if ($edges[$band + 1] <= $edges[$band]) {
                $rising = false;
            }
            if (CeltBands::widthOf($band, 0) < $narrowest) {
                $widening = false;
            }
        }
        $this->assertTrue($rising, "every band begins after the last ended");
        $this->assertTrue($widening,
            "no band is narrower than the lowest one");
        $this->assertTrue(CeltBands::widthOf(20, 0) >
            CeltBands::widthOf(0, 0) * 8,
            "the top band is far wider than the bottom one");
        $wide = CeltBands::edgesFor(3);
        $this->assertEqual($wide[21], $edges[21] * 8,
            "the longest stretch spreads the bands eight times as wide");
        $this->assertEqual(CeltBands::widthOf(5, 3),
            CeltBands::widthOf(5, 0) * 8, "each band widens to match");
    }
    /**
     * A stretch of length Opus does not have should be refused
     */
    public function impossibleStretchIsRefusedTestCase()
    {
        $refused = 0;
        foreach ([-1, 4, 9] as $doublings) {
            try {
                CeltBands::edgesFor($doublings);
            } catch (\Exception $problem) {
                $refused++;
            }
        }
        $this->assertEqual($refused, 3, "every impossible length refused");
    }
    /**
     * There should be a pair of likelihoods for every band at every
     * length of stretch, and each should sit inside what the reader
     * can work with
     */
    public function likelihoodsAreWholeTestCase()
    {
        $wrong = 0;
        for ($doublings = 0; $doublings <= 3; $doublings++) {
            foreach ([false, true] as $alone) {
                $shapes = CeltBands::shapesFor($doublings, $alone);
                if (count($shapes) != CeltBands::BAND_COUNT) {
                    $wrong++;
                }
                foreach ($shapes as $shape) {
                    if ($shape[0] < 1 || $shape[0] >= 32768 ||
                        $shape[1] < 0 || $shape[1] >= 16384) {
                        $wrong++;
                    }
                }
            }
        }
        $this->assertEqual($wrong, 0,
            "every band at every length has likelihoods the reader can use");
        $following = CeltBands::shapesFor(3, false);
        $standing = CeltBands::shapesFor(3, true);
        $this->assertTrue($standing[0][0] < $following[0][0],
            "a stretch standing alone expects larger differences");
    }
    /**
     * Where every stored difference is nothing, a stretch standing on
     * its own should leave every band at nothing
     */
    public function noDifferencesLeaveNothingTestCase()
    {
        $steps = array_fill(0, CeltBands::BAND_COUNT, 0);
        $before = CeltEnergy::nothingYet(1);
        $read = $this->storeAndRead($steps, $before, true);
        $largest = 0.0;
        foreach ($read[0] as $loudness) {
            $largest = max($largest, abs($loudness));
        }
        $this->assertTrue($largest < self::CLOSE_ENOUGH,
            "nothing stored leaves every band at nothing");
    }
    /**
     * One difference should move its own band by that much, and should
     * lean on the bands after it by a smaller amount that does not
     * fade
     */
    public function oneDifferenceLeansOnTheRestTestCase()
    {
        $steps = array_fill(0, CeltBands::BAND_COUNT, 0);
        $steps[0] = 4;
        $before = CeltEnergy::nothingYet(1);
        $read = $this->storeAndRead($steps, $before, true);
        $this->assertTrue(abs($read[0][0] - 4.0) < self::CLOSE_ENOUGH,
            "the band the difference belongs to moves by all of it");
        $lean = 4.0 * (1.0 - CeltBands::HOLD_BACK_ALONE);
        $this->assertTrue(abs($read[0][1] - $lean) < self::CLOSE_ENOUGH,
            "the next band up leans by what was not held back");
        $this->assertTrue(abs($read[0][20] - $lean) < self::CLOSE_ENOUGH,
            "the lean carries all the way up without fading");
    }
    /**
     * A stretch that follows on should take part of what each band was
     * in the stretch before, and one standing alone should ignore it
     */
    public function followingOnUsesTheStretchBeforeTestCase()
    {
        $steps = array_fill(0, CeltBands::BAND_COUNT, 0);
        $before = CeltEnergy::nothingYet(1);
        for ($band = 0; $band < CeltBands::BAND_COUNT; $band++) {
            $before[0][$band] = 3.0;
        }
        $following = $this->storeAndRead($steps, $before, false);
        $carry = CeltBands::CARRY_ACROSS[self::USUAL_DOUBLINGS];
        $this->assertTrue(abs($following[0][0] - 3.0 * $carry) <
            self::CLOSE_ENOUGH,
            "a stretch that follows on keeps part of what was before");
        $standing = $this->storeAndRead($steps, $before, true);
        $largest = 0.0;
        foreach ($standing[0] as $loudness) {
            $largest = max($largest, abs($loudness));
        }
        $this->assertTrue($largest < self::CLOSE_ENOUGH,
            "a stretch standing alone ignores what was before");
    }
    /**
     * A band that was silent in the stretch before should not be
     * allowed to drag the next stretch down without limit
     */
    public function silentBandsAreFlooredTestCase()
    {
        $steps = array_fill(0, CeltBands::BAND_COUNT, 0);
        $before = CeltEnergy::nothingYet(1);
        for ($band = 0; $band < CeltBands::BAND_COUNT; $band++) {
            $before[0][$band] = -100.0;
        }
        $read = $this->storeAndRead($steps, $before, false);
        $carry = CeltBands::CARRY_ACROSS[self::USUAL_DOUBLINGS];
        $this->assertTrue(abs($read[0][0] - CeltBands::FLOOR * $carry) <
            self::CLOSE_ENOUGH,
            "a silent band is treated as no quieter than the floor");
    }
    /**
     * Both channels of two channel sound should be read, each keeping
     * its own leaning on the bands after it
     */
    public function bothChannelsAreReadTestCase()
    {
        $steps = array_fill(0, CeltBands::BAND_COUNT, 0);
        $steps[0] = 2;
        $before = CeltEnergy::nothingYet(2);
        $read = $this->storeAndRead($steps, $before, true);
        $this->assertEqual(count($read), 2, "two channels come back");
        $this->assertTrue(abs($read[0][0] - $read[1][0]) <
            self::CLOSE_ENOUGH,
            "both channels read the same stored difference");
        $this->assertTrue(abs($read[0][20] - $read[1][20]) <
            self::CLOSE_ENOUGH, "both channels lean the same way");
    }
    /**
     * The finer pass should move each loudness by less than half a
     * step, and more bits should let it land closer
     */
    public function finerPassNarrowsTheStepTestCase()
    {
        $spend = array_fill(0, CeltBands::BAND_COUNT, 3);
        $writer = new RangeEncoder(self::ROOM);
        for ($band = 0; $band < CeltBands::BAND_COUNT; $band++) {
            $writer->encodeRawBits($band % 8, 3);
        }
        $reader = new RangeDecoder($writer->finish());
        $rough = CeltEnergy::nothingYet(1);
        $finer = CeltEnergy::readFiner($reader, $rough, $spend, 0,
            CeltBands::BAND_COUNT);
        $largest = 0.0;
        foreach ($finer[0] as $loudness) {
            $largest = max($largest, abs($loudness));
        }
        $this->assertTrue($largest < 0.5,
            "the finer pass never moves a loudness half a step or more");
        $this->assertTrue($largest > 0.0,
            "the finer pass does move the loudnesses");
        $this->assertTrue(abs($finer[0][0] + 0.5 - 1.0 / 16) <
            self::CLOSE_ENOUGH,
            "the lowest reading lands in the middle of the lowest part");
    }
    /**
     * A band the recording set no room aside for should be left where
     * the rough pass put it
     */
    public function bandsWithNoRoomAreLeftAloneTestCase()
    {
        $spend = array_fill(0, CeltBands::BAND_COUNT, 0);
        $writer = new RangeEncoder(self::ROOM);
        $writer->encodeRawBits(1, 1);
        $reader = new RangeDecoder($writer->finish());
        $rough = CeltEnergy::nothingYet(1);
        $rough[0][4] = 2.5;
        $finer = CeltEnergy::readFiner($reader, $rough, $spend, 0,
            CeltBands::BAND_COUNT);
        $this->assertEqual($finer[0][4], 2.5,
            "a band with no room set aside is left where it was");
    }
    /**
     * Bits left over at the end should go to the bands that asked
     * first, and should run out rather than being spent twice
     */
    public function leftoverBitsGoToTheKeenestTestCase()
    {
        $spent = array_fill(0, CeltBands::BAND_COUNT, 2);
        $wanting = array_fill(0, CeltBands::BAND_COUNT, 1);
        $wanting[7] = 0;
        $wanting[9] = 0;
        $writer = new RangeEncoder(self::ROOM);
        $writer->encodeRawBits(1, 1);
        $writer->encodeRawBits(0, 1);
        $reader = new RangeDecoder($writer->finish());
        $loudness = CeltEnergy::nothingYet(1);
        $finished = CeltEnergy::readLeftovers($reader, $loudness, $spent,
            $wanting, 2, 0, CeltBands::BAND_COUNT);
        $moved = 0;
        foreach ($finished[0] as $band => $loud) {
            if (abs($loud) > self::CLOSE_ENOUGH) {
                $moved++;
            }
        }
        $this->assertEqual($moved, 2, "only two bands were given a bit");
        $this->assertTrue(abs($finished[0][7] - 0.5 / 8) <
            self::CLOSE_ENOUGH, "the first keen band was given its bit");
        $this->assertTrue(abs($finished[0][9] + 0.5 / 8) <
            self::CLOSE_ENOUGH, "the second keen band was given the next");
    }
    /**
     * A loudness in steps should turn into a scale where each whole
     * step is a fourfold change in power
     */
    public function loudnessBecomesAScaleTestCase()
    {
        $quiet = CeltEnergy::scaleFor(0.0, 0);
        $louder = CeltEnergy::scaleFor(1.0, 0);
        $this->assertTrue(abs($louder / $quiet - 2.0) < self::CLOSE_ENOUGH,
            "one whole step doubles the scale");
        $this->assertTrue($quiet > 0.0, "a scale is never nothing");
    }
}
X