/ tests / CeltAllocationTest.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\CeltAllocation;
use seekquarry\yioop\library\av_processing\CeltBands;
use seekquarry\yioop\library\av_processing\CeltEnergy;
use seekquarry\yioop\library\av_processing\CeltFrameHeader;
use seekquarry\yioop\library\av_processing\CeltPulseCache;
use seekquarry\yioop\library\av_processing\OpusPacket;
use seekquarry\yioop\library\av_processing\RangeDecoder;
use seekquarry\yioop\library\av_processing\WebmDemuxer;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\UnitTest;

/**
 * Checks how a stretch's room is shared out between its bands.
 *
 * Almost nothing about the sharing is stored in a recording. The
 * writer and the reader run the same arithmetic from the same starting
 * point and are expected to arrive at the same answer, so there is
 * nothing to compare a wrong answer against. That makes it the hardest
 * part to check and the worst part to get wrong: a fault does not spoil
 * one value, it hands every band the wrong amount of room.
 *
 * What can be checked is that the answer holds together. Everything
 * handed out has to add back up to what there was to hand out, and the
 * only room unaccounted for should be the bit held back to say where
 * the dropping of bands stops, plus one bit for each band the
 * recording was actually asked about. No band may be given more than
 * it can use. The finer loudness pass may not be given more bits than
 * it can spend. Bands dropped entirely must be left with nothing.
 *
 * Those hold across every stretch of four real recordings made by
 * other software. A sharing that had gone wrong would fail them almost
 * at once, since the amounts would stop adding up.
 *
 * @author Chris Pollett
 */
class CeltAllocationTest extends UnitTest
{
    /**
     * Recordings to share out the room of
     */
    const RECORDINGS = ["/test_files/tiny_tone_webm.txt",
        "/test_files/tiny_noise_webm.txt",
        "/test_files/tiny_recording_webm.txt"];
    /**
     * The longest stretch, which nearly every recording uses
     */
    const DOUBLINGS = 3;
    /**
     * How many bands the recordings carry
     */
    const BANDS = 21;
    /**
     * How finely room is counted, as parts of a bit
     */
    const BIT_PARTS = 3;
    /**
     * How many stretches of each recording to share out. Sharing is
     * real work, so the cases do enough to be convincing rather than
     * every stretch there is.
     */
    const STRETCHES_SHARED = 6;
    /**
     * A whole bit, in those parts
     */
    const WHOLE_BIT = 8;
    /**
     * Most bits the finer loudness pass may spend on one band
     */
    const MOST_FINE_BITS = 8;
    /**
     * Nothing needs setting up for these cases
     */
    public function setUp()
    {
    }
    /**
     * Nothing needs clearing away after these cases
     */
    public function tearDown()
    {
    }
    /**
     * Shares out the room of every stretch of one recording
     *
     * @param string $where which recording to read
     * @return array what the sharing gave for each stretch
     */
    public function shareEverything($where)
    {
        $stored = file_get_contents(C\PARENT_DIR . "/tests" . $where);
        $reader = new WebmDemuxer(base64_decode($stored));
        $before = CeltEnergy::nothingYet(1);
        $shared = [];
        foreach ($reader->packets() as $piece) {
            $sound = OpusPacket::fromString($piece->data);
            if ($sound->method != OpusPacket::MUSIC_METHOD) {
                continue;
            }
            foreach ($sound->stretches as $stretch) {
                if (strlen($stretch) < 2) {
                    continue;
                }
                $entropy = new RangeDecoder($stretch);
                $header = CeltFrameHeader::readFrom($entropy, self::DOUBLINGS,
                    $before, 0, self::BANDS);
                $before = $header->loudness;
                if ($header->silent) {
                    continue;
                }
                $shared[] = $this->shareOne($entropy, $header,
                    strlen($stretch));
                if (count($shared) >= self::STRETCHES_SHARED) {
                    break 2;
                }
            }
        }
        return $shared;
    }
    /**
     * Shares out the room of one stretch
     *
     * @param object $entropy the reader partway through the stretch
     * @param object $header what the stretch said about itself
     * @param int $size how long the stretch is, in bytes
     * @return array what the sharing gave, and what it had to give
     */
    public function shareOne($entropy, $header, $size)
    {
        $whole = $size * 8 << self::BIT_PARTS;
        $asked = CeltAllocation::readBoosts($entropy, self::DOUBLINGS, 1, 0,
            self::BANDS, $whole);
        $tilt = CeltAllocation::readTilt($entropy, $asked["room"]);
        $left = $whole - $entropy->bitsUsedFinely() - 1;
        /* A stretch where the sound changed suddenly holds back one bit
           to say whether a band that fell silent should be filled back
           in. */
        if ($header->sudden && $left >= (self::DOUBLINGS + 2) *
            self::WHOLE_BIT) {
            $left -= self::WHOLE_BIT;
        }
        $given = CeltAllocation::shareOut($entropy, $asked["boosts"], $tilt,
            $left, self::DOUBLINGS, 1, 0, self::BANDS);
        $given["had"] = $left;
        $given["size"] = $size;
        $given["used"] = $entropy->bitsUsedFinely();
        $given["tilt"] = $tilt;
        $given["boosts"] = $asked["boosts"];
        return $given;
    }
    /**
     * What sharing every recording gave, kept so that the work is done
     * once for the whole file rather than once for each case
     * @var array
     */
    public static $shared_already = [];
    /**
     * Shares out every stretch of every recording
     *
     * @return array what the sharing gave for each stretch
     */
    public function shareAll()
    {
        if (self::$shared_already != []) {
            return self::$shared_already;
        }
        $shared = [];
        foreach (self::RECORDINGS as $where) {
            foreach ($this->shareEverything($where) as $one) {
                $shared[] = $one;
            }
        }
        self::$shared_already = $shared;
        return $shared;
    }
    /**
     * Everything handed out should add back up to what there was to
     * hand out, apart from the bit held back to say where dropping
     * stops and one bit for each band the recording was asked about
     */
    public function everythingAddsUpTestCase()
    {
        $wrong = 0;
        $ragged = 0;
        $checked = 0;
        foreach ($this->shareAll() as $one) {
            $handed = array_sum($one["shape"]) +
                array_sum($one["fine"]) * self::WHOLE_BIT + $one["spare"];
            $gap = $one["had"] - $handed;
            if ($gap % self::WHOLE_BIT != 0) {
                $ragged++;
            }
            $dropped = self::BANDS - $one["kept"];
            if ($gap < self::WHOLE_BIT ||
                $gap > self::WHOLE_BIT * (1 + $dropped)) {
                $wrong++;
            }
            $checked++;
        }
        $this->assertTrue($checked > 8, "many stretches were shared out");
        $this->assertEqual($ragged, 0,
            "what is left over is always a whole number of bits");
        $this->assertEqual($wrong, 0,
            "what is left over is the bit held back plus the bands asked " .
            "about, $checked stretches");
    }
    /**
     * No band should be given more room than it can use, since room
     * beyond that is wasted while another band goes short
     */
    public function noBandGetsMoreThanItCanUseTestCase()
    {
        $over = 0;
        $checked = 0;
        foreach ($this->shareAll() as $one) {
            for ($band = 0; $band < $one["kept"]; $band++) {
                $ceiling = CeltPulseCache::ceilingRoom($band,
                    self::DOUBLINGS, 1);
                if ($one["shape"][$band] > $ceiling) {
                    $over++;
                }
                $checked++;
            }
        }
        $this->assertTrue($checked > 150, "many bands were checked");
        $this->assertEqual($over, 0,
            "no band was given more room than it can use");
    }
    /**
     * The finer loudness pass should never be given more bits than it
     * can spend, nor a negative number of them
     */
    public function finerPassStaysInItsLimitsTestCase()
    {
        $wrong = 0;
        $any = 0;
        foreach ($this->shareAll() as $one) {
            foreach ($one["fine"] as $bits) {
                if ($bits < 0 || $bits > self::MOST_FINE_BITS) {
                    $wrong++;
                }
                if ($bits > 0) {
                    $any++;
                }
            }
        }
        $this->assertEqual($wrong, 0,
            "every band's finer pass sits within its limits");
        $this->assertTrue($any > 50,
            "the finer pass is given something to do");
    }
    /**
     * A band dropped entirely should be left with no room for a shape
     */
    public function droppedBandsGetNoShapeTestCase()
    {
        $wrong = 0;
        $dropped = 0;
        foreach ($this->shareAll() as $one) {
            for ($band = $one["kept"]; $band < self::BANDS; $band++) {
                if ($one["shape"][$band] != 0) {
                    $wrong++;
                }
                $dropped++;
            }
        }
        $this->assertTrue($dropped > 0, "some bands were dropped");
        $this->assertEqual($wrong, 0, "a dropped band gets no shape room");
    }
    /**
     * At least one band should always be kept, since a stretch that
     * kept none would be silence and would have said so
     */
    public function somethingIsAlwaysKeptTestCase()
    {
        $empty = 0;
        $checked = 0;
        foreach ($this->shareAll() as $one) {
            if ($one["kept"] < 1) {
                $empty++;
            }
            $checked++;
        }
        $this->assertTrue($checked > 8, "many stretches were checked");
        $this->assertEqual($empty, 0, "every stretch keeps some bands");
    }
    /**
     * Sharing should never account for more of a stretch than there is
     */
    public function readingStaysInsideTheStretchTestCase()
    {
        $over = 0;
        $checked = 0;
        foreach ($this->shareAll() as $one) {
            if ($one["used"] > $one["size"] * 8 * self::WHOLE_BIT) {
                $over++;
            }
            $checked++;
        }
        $this->assertEqual($over, 0,
            "no stretch was read past its end, $checked read");
    }
    /**
     * The lean and the extra room asked for should both name something
     * the stretch could really have said
     */
    public function whatWasReadIsBelievableTestCase()
    {
        $wrong = 0;
        $any_asked = 0;
        foreach ($this->shareAll() as $one) {
            if ($one["tilt"] < 0 || $one["tilt"] > 10) {
                $wrong++;
            }
            foreach ($one["boosts"] as $band => $boost) {
                if ($boost < 0 || $boost > CeltPulseCache::ceilingRoom($band,
                    self::DOUBLINGS, 1) * 2) {
                    $wrong++;
                }
                if ($boost > 0) {
                    $any_asked++;
                }
            }
        }
        $this->assertEqual($wrong, 0,
            "the lean and the extra asked for are both believable");
        $this->assertTrue($any_asked > 0,
            "some band somewhere asked for extra room");
    }
    /**
     * A recording given more room overall should end up with more room
     * for its shapes
     */
    public function moreRoomOverallMeansMoreForShapesTestCase()
    {
        $spare = $this->shareEverything(self::RECORDINGS[1]);
        $plenty = 0;
        $stretches = 0;
        foreach ($spare as $one) {
            $plenty += array_sum($one["shape"]);
            $stretches++;
        }
        $tight = $this->shareEverything(self::RECORDINGS[0]);
        $little = 0;
        $fewer = 0;
        foreach ($tight as $one) {
            $little += array_sum($one["shape"]);
            $fewer++;
        }
        $this->assertTrue($stretches > 0 && $fewer > 0,
            "both recordings had stretches");
        $this->assertTrue($plenty / $stretches > $little / $fewer,
            "the recording with more room gives its shapes more");
    }
}
X