/ tests / CeltShapeTest.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\CeltShape;
use seekquarry\yioop\library\av_processing\CeltBandReader;
use seekquarry\yioop\library\av_processing\Pvq;
use seekquarry\yioop\library\av_processing\RangeDecoder;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\UnitTest;

/**
 * Checks the steps that turn a band's stored pattern of pulses into
 * the tones the band holds.
 *
 * The main one is the smearing. A pattern of pulses leaves most slots
 * empty, and played as it stands the silence between the pulses is
 * heard as a ringing, so the pattern is smeared across the band by
 * turning neighboring slots slightly into each other. Two things have
 * to hold for that to be right. It must leave the band's loudness
 * exactly where it was, since it is meant to change the shape and
 * nothing else. And it must be undoable, since a turning that lost
 * information would not be a turning at all. Both are checked here,
 * and either failing would mean the smearing is doing something other
 * than what it claims.
 *
 * The two approximations are checked against the true functions they
 * approximate. They exist because their answers feed back into how the
 * room is shared out, so they have to come out identical on every
 * machine, which an ordinary cosine cannot promise.
 *
 * @author Chris Pollett
 */
class CeltShapeTest extends UnitTest
{
    /**
     * How close the approximations have to come to the true functions
     */
    const CLOSE_ENOUGH = 1e-3;
    /**
     * How close a turning has to come to giving back what it was given
     */
    const VERY_CLOSE = 1e-8;
    /**
     * A whole, against which the cosine counts
     */
    const WHOLE = 32768;
    /**
     * A quarter turn, in the units the cosine takes
     */
    const QUARTER_TURN = 16384;
    /**
     * The smearing setting the recordings mostly use
     */
    const USUAL_SMEAR = 2;
    /**
     * A starting point for the made up numbers
     */
    const SEED = 20260803;
    /**
     * Brings in the writer used to make a band to read back
     */
    public function setUp()
    {
        if (!class_exists("seekquarry\\yioop\\tests\\RangeEncoder")) {
            require_once C\PARENT_DIR . "/tests/test_files/RangeEncoder.php";
        }
        mt_srand(self::SEED);
    }
    /**
     * Nothing needs clearing away after these cases
     */
    public function tearDown()
    {
    }
    /**
     * Makes up a band's slots to work on
     *
     * @param int $length how many slots
     * @return array the slots
     */
    public function madeUpBand($length)
    {
        $band = [];
        for ($i = 0; $i < $length; $i++) {
            $band[] = mt_rand(-1000, 1000) / 1000.0;
        }
        return $band;
    }
    /**
     * How loud a band is, taken as the sum of its slots squared
     *
     * @param array $band the band's slots
     * @return float how loud it is
     */
    public function loudnessOf($band)
    {
        $total = 0.0;
        foreach ($band as $slot) {
            $total += $slot * $slot;
        }
        return $total;
    }
    /**
     * Smearing changes the shape of a band and must leave its loudness
     * exactly where it was
     */
    public function smearingLeavesLoudnessAloneTestCase()
    {
        $worst = 0.0;
        $changed = 0.0;
        foreach ([16, 32, 64] as $length) {
            foreach ([1, 2, 3] as $setting) {
                $band = $this->madeUpBand($length);
                $before = $this->loudnessOf($band);
                $smeared = $band;
                CeltShape::smear($smeared, $length, -1, 1, 3, $setting);
                $worst = max($worst,
                    abs($this->loudnessOf($smeared) - $before) / $before);
                for ($i = 0; $i < $length; $i++) {
                    $changed = max($changed, abs($smeared[$i] - $band[$i]));
                }
            }
        }
        $this->assertTrue($worst < self::VERY_CLOSE,
            "smearing leaves the band's loudness where it was");
        $this->assertTrue($changed > 0.01,
            "smearing does change the band's shape");
    }
    /**
     * Smearing must be undoable, since a turning that lost anything
     * would not be a turning
     */
    public function smearingCanBeUndoneTestCase()
    {
        $worst = 0.0;
        foreach ([16, 48] as $length) {
            foreach ([1, 2, 3] as $setting) {
                foreach ([1, 2] as $groups) {
                    $band = $this->madeUpBand($length);
                    $there = $band;
                    CeltShape::smear($there, $length, -1, $groups, 3,
                        $setting);
                    CeltShape::smear($there, $length, 1, $groups, 3,
                        $setting);
                    for ($i = 0; $i < $length; $i++) {
                        $worst = max($worst, abs($there[$i] - $band[$i]));
                    }
                }
            }
        }
        $this->assertTrue($worst < self::VERY_CLOSE,
            "smearing and undoing gives back what was started with");
    }
    /**
     * A band with a pulse in half its slots has no gaps worth filling,
     * and one setting says to leave the band alone, so neither is
     * smeared
     */
    public function someBandsAreLeftAloneTestCase()
    {
        $length = 16;
        $band = $this->madeUpBand($length);
        $plenty = $band;
        CeltShape::smear($plenty, $length, -1, 1, $length, self::USUAL_SMEAR);
        $this->assertEqual($plenty, $band,
            "a band with pulses in half its slots is left alone");
        $told = $band;
        CeltShape::smear($told, $length, -1, 1, 3, CeltShape::NO_SMEAR);
        $this->assertEqual($told, $band,
            "the setting that says to leave a band alone does so");
    }
    /**
     * A band with few pulses has more gaps and should be smeared
     * harder than one with many.
     *
     * This is checked where the band is looked at in enough groups
     * that only neighboring slots are turned into each other. A band
     * long enough for the second, wider turning behaves differently,
     * because that turning takes the same two numbers the other way
     * round and so grows as the first shrinks. Both are wanted; only
     * the first is a simple trend.
     */
    public function fewerPulsesAreSmearedHarderTestCase()
    {
        $length = 16;
        $groups = 4;
        $band = $this->madeUpBand($length);
        $before = 0.0;
        $falling = true;
        foreach ([1, 2, 3, 5, 7] as $pulses) {
            $smeared = $band;
            CeltShape::smear($smeared, $length, -1, $groups, $pulses,
                self::USUAL_SMEAR);
            $moved = 0.0;
            for ($i = 0; $i < $length; $i++) {
                $moved += abs($smeared[$i] - $band[$i]);
            }
            if ($before > 0.0 && $moved >= $before) {
                $falling = false;
            }
            $before = $moved;
        }
        $this->assertTrue($falling,
            "each extra pulse leaves the band nearer where it started");
        $this->assertTrue($before > 0.0,
            "a band with pulses to spare is still smeared a little");
    }
    /**
     * Pairing up the slots of a band is its own opposite, so doing it
     * twice gives back what was started with, and it leaves the band's
     * loudness alone
     */
    public function pairingUpIsItsOwnOppositeTestCase()
    {
        $length = 32;
        $band = $this->madeUpBand($length);
        $before = $this->loudnessOf($band);
        $paired = $band;
        CeltShape::pairUp($paired, 0, $length, 1);
        $this->assertTrue(abs($this->loudnessOf($paired) - $before) /
            $before < self::CLOSE_ENOUGH,
            "pairing up leaves the band's loudness where it was");
        CeltShape::pairUp($paired, 0, $length, 1);
        $worst = 0.0;
        for ($i = 0; $i < $length; $i++) {
            $worst = max($worst, abs($paired[$i] - $band[$i]));
        }
        $this->assertTrue($worst < self::CLOSE_ENOUGH,
            "pairing up twice gives back what was started with");
    }
    /**
     * The cosine that comes out the same on every machine should
     * follow the true cosine across the range it is used over
     */
    public function steadyCosineFollowsTheTrueOneTestCase()
    {
        $worst = 0.0;
        for ($turn = 0; $turn < self::QUARTER_TURN; $turn += 59) {
            $mine = CeltShape::steadyCos($turn) / self::WHOLE;
            $true_value = cos(0.5 * M_PI * $turn / self::QUARTER_TURN);
            $worst = max($worst, abs($mine - $true_value));
        }
        $this->assertTrue($worst < self::CLOSE_ENOUGH,
            "the steady cosine follows the true one throughout");
        $this->assertEqual(CeltShape::steadyCos(0), self::WHOLE,
            "at no turn at all it gives the whole");
        $this->assertTrue(CeltShape::steadyCos(self::QUARTER_TURN - 1) < 10,
            "at nearly a quarter turn it gives nearly nothing");
    }
    /**
     * The logarithm of how far a split leaned should follow the true
     * logarithm of the same ratio
     */
    public function leanLogarithmFollowsTheTrueOneTestCase()
    {
        $worst = 0.0;
        $tried = 0;
        for ($turn = 1000; $turn < 15000; $turn += 311) {
            $up = CeltShape::steadyCos(self::QUARTER_TURN - $turn);
            $across = CeltShape::steadyCos($turn);
            $mine = CeltShape::leanLog($up, $across) / 2048.0;
            $worst = max($worst, abs($mine - log($up / $across, 2)));
            $tried++;
        }
        $this->assertTrue($tried > 40, "many leans were tried");
        $this->assertTrue($worst < 0.05,
            "the lean logarithm follows the true one throughout");
        $this->assertEqual(CeltShape::leanLog(1000, 1000), 0,
            "a split that leaned neither way gives nothing");
    }
    /**
     * Every lean a split band can report should be read back as the
     * value that was written.
     *
     * A lean says how a band's loudness divided between its two halves,
     * and a stretch that did not change suddenly stores it with the
     * middle more likely than the ends, since an even split is the
     * common case. Reading it back wrongly would put a band's energy in
     * the wrong half while consuming exactly the right number of bits,
     * so nothing downstream would notice. Every value at every size up
     * to a hundred and twenty eight steps is checked here rather than a
     * sample; widening the list of sizes sweeps further at the cost of
     * a slower case.
     */
    public function everyLeanIsReadBackTestCase()
    {
        $wrong = 0;
        $tried = 0;
        foreach ([2, 4, 8, 16, 32, 64, 128] as $steps) {
            for ($turn = 0; $turn <= $steps; $turn++) {
                $writer = new RangeEncoder(400);
                $this->writeLean($writer, $turn, $steps);
                $reader = new LeanReader(new RangeDecoder($writer->finish()));
                if ($reader->readTriangular($steps) != $turn) {
                    $wrong++;
                }
                $tried++;
            }
        }
        $this->assertTrue($tried > 250, "every lean at every size tried");
        $this->assertEqual($wrong, 0,
            "every lean reads back as it was written, $tried tried");
    }
    /**
     * Writes a lean the way the specification stores it, so that what
     * the reader gives back can be checked against it
     *
     * @param object $writer the writer partway through a piece
     * @param int $turn which step the lean fell on
     * @param int $steps how many steps the lean is stored in
     */
    public function writeLean($writer, $turn, $steps)
    {
        $half = $steps >> 1;
        $whole = ($half + 1) * ($half + 1);
        if ($turn <= $half) {
            $width = $turn + 1;
            $below = ($turn * ($turn + 1)) >> 1;
        } else {
            $width = $steps + 1 - $turn;
            $below = $whole -
                ((($steps + 1 - $turn) * ($steps + 2 - $turn)) >> 1);
        }
        $writer->encode($below, $below + $width, $whole);
    }
    /**
     * A band read out of a stretch should come back at the loudness
     * asked for
     */
    public function bandComesBackAtTheLoudnessAskedForTestCase()
    {
        $length = 16;
        $pulses = 5;
        $wanted = 2.5;
        $writer = new RangeEncoder(200);
        $writer->encodeNumber(11, Pvq::patternCount($length, $pulses));
        $reader = new RangeDecoder($writer->finish());
        $read = CeltShape::readBand($reader, $length, $pulses,
            self::USUAL_SMEAR, 1, $wanted);
        $loudness = sqrt($this->loudnessOf($read["shape"]));
        $this->assertTrue(abs($loudness - $wanted) < self::CLOSE_ENOUGH,
            "the band comes back at the loudness asked for");
        $this->assertEqual(count($read["shape"]), $length,
            "the band has as many slots as it should");
    }
    /**
     * A group of a band left with no pulses should be reported as
     * empty, since a later step fills those back in rather than
     * letting them ring
     */
    public function emptyGroupsAreFoundTestCase()
    {
        $pattern = [2, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0];
        $this->assertEqual(CeltShape::filledGroups($pattern, 16, 2), 3,
            "both halves hold something");
        $pattern = [2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
        $this->assertEqual(CeltShape::filledGroups($pattern, 16, 2), 1,
            "only the first half holds anything");
        $pattern = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0];
        $this->assertEqual(CeltShape::filledGroups($pattern, 16, 2), 2,
            "only the second half holds anything");
        $this->assertEqual(CeltShape::filledGroups($pattern, 16, 1), 1,
            "a band looked at in one group is never called empty");
    }
    /**
     * Putting a band's shape back together with its loudness should
     * scale the band and leave the ones around it alone
     */
    public function loudnessIsAppliedToTheShapeTestCase()
    {
        $doublings = 3;
        $edges = CeltBands::edgesFor($doublings);
        $length = $edges[CeltBands::BAND_COUNT];
        $shape = array_fill(0, $length, 0.5);
        $loudness = array_fill(0, CeltBands::BAND_COUNT, 0.0);
        $loudness[3] = 2.0;
        $tones = CeltShape::toTones($shape, $loudness, $doublings, 0,
            CeltBands::BAND_COUNT, $length);
        $quiet = $tones[$edges[3]];
        $loudness[3] = 4.0;
        $louder = CeltShape::toTones($shape, $loudness, $doublings, 0,
            CeltBands::BAND_COUNT, $length);
        $loud = $louder[$edges[3]];
        /* Each whole step doubles the scale, so two steps make a band
           four times as loud. The comparison is between the same band
           at two loudnesses, since different bands start from
           different expectations. */
        $this->assertTrue($loud > $quiet * 3.99 && $loud < $quiet * 4.01,
            "two whole steps of loudness makes a band four times as loud");
        $this->assertTrue($quiet > 0.0, "the quieter band is still there");
    }
}
/**
 * Lets a lean be read on its own, without a whole stretch around it.
 *
 * The lean reader lives inside the class that walks a band's splits and
 * needs a good deal of state around it. This stands that state down to
 * the one thing a lean actually needs, which is somewhere to read from.
 */
class LeanReader extends CeltBandReader
{
    /**
     * Sets up a reader over one written lean
     *
     * @param object $reader a reader over the piece holding the lean
     */
    public function __construct($reader)
    {
        $this->reader = $reader;
    }
}
X