/ tests / CeltStretchTest.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\CeltStretch;
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;

/**
 * Reads whole stretches of real recordings and checks where the
 * reading lands.
 *
 * This is the strongest check in the whole of this work, and it is
 * worth saying why. Whatever wrote these recordings filled each
 * stretch to the bit: there is no slack at the end, because slack
 * would be room that could have carried sound. So a reading that
 * agrees with the writer at every step will finish a stretch with
 * almost nothing left over.
 *
 * A reading that has gone wrong anywhere will not. It will not be a
 * little out; it will be hundreds or thousands of bits out, because
 * every step decides how much room the next has, and one wrong step
 * throws off every step after it. There is no way for a reading with a
 * fault in it to land near the end by accident.
 *
 * The cases below read every stretch of three recordings made by other
 * software and check that each lands within a bit of its end. That
 * covers the whole chain at once: the compressed number reader, the
 * loudnesses, the sharing out of room, the splitting of wide bands,
 * the smearing, the filling of empty bands, and the leftover bits.
 *
 * @author Chris Pollett
 */
class CeltStretchTest extends UnitTest
{
    /**
     * Recordings to read whole stretches of
     */
    const RECORDINGS = ["/test_files/tiny_tone_webm.txt",
        "/test_files/tiny_noise_webm.txt",
        "/test_files/tiny_silence_webm.txt"];
    /**
     * The longest stretch, which nearly every recording uses
     */
    const DOUBLINGS = 3;
    /**
     * How many bands the recordings carry
     */
    const BANDS = 21;
    /**
     * Which band covers the tone the first recording holds. Band two
     * runs from 400 to 600 cycles a second and the tone is at 440.
     */
    const TONE_BAND = 2;
    /**
     * How much room may be left at the end of a stretch. A whole bit
     * is generous: the reading normally lands within half of one.
     */
    const ROOM_ALLOWED = 8;
    /**
     * How far over the end a stretch may run. The finest amount that
     * exists is an eighth of a bit, and rounding at the very end of a
     * recording can cost that much.
     */
    const OVERRUN_ALLOWED = 1;
    /**
     * How many stretches of each recording to read. Reading a stretch
     * whole is the most work anything here does, so the cases read
     * enough to be convincing and leave measuring the speed of it to
     * the experiment beside them.
     */
    const STRETCHES_READ = 3;
    /**
     * How many stretches the case that reads twice uses, since it
     * cannot share the reading the other cases share
     */
    const STRETCHES_READ_TWICE = 2;
    /**
     * What reading each recording gave, kept so the work is done once
     * @var array
     */
    public static $read_already = [];
    /**
     * Nothing needs setting up for these cases
     */
    public function setUp()
    {
    }
    /**
     * Nothing needs clearing away after these cases
     */
    public function tearDown()
    {
    }
    /**
     * Reads every stretch of one recording
     *
     * @param string $where which recording to read
     * @return array what each stretch gave
     */
    public function readEverything($where)
    {
        if (isset(self::$read_already[$where])) {
            return self::$read_already[$where];
        }
        $stored = file_get_contents(C\PARENT_DIR . "/tests" . $where);
        $file = new WebmDemuxer(base64_decode($stored));
        $before = CeltEnergy::nothingYet(1);
        $seed = 0;
        $found = [];
        foreach ($file->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;
                }
                $reader = new RangeDecoder($stretch);
                $read = CeltStretch::readFrom($reader, self::DOUBLINGS,
                    $before, $seed, 0, self::BANDS);
                $before = $read["loudness"];
                $seed = $read["seed"];
                $read["size"] = strlen($stretch);
                $found[] = $read;
                if (count($found) >= self::STRETCHES_READ) {
                    break 2;
                }
            }
        }
        self::$read_already[$where] = $found;
        return $found;
    }
    /**
     * Reads every stretch of every recording
     *
     * @return array what each stretch gave
     */
    public function readAll()
    {
        $found = [];
        foreach (self::RECORDINGS as $where) {
            foreach ($this->readEverything($where) as $one) {
                $found[] = $one;
            }
        }
        return $found;
    }
    /**
     * Every stretch should be read right to its end, with less than a
     * bit left over. This case runs for about fifteen thousandths of a
     * second, since it decodes every stretch of a real recording.
     */
    public function everyStretchIsReadToItsEndTestCase()
    {
        $short = 0;
        $over = 0;
        $checked = 0;
        $total = 0;
        foreach ($this->readAll() as $one) {
            if ($one["header"]->silent) {
                continue;
            }
            if ($one["left"] > self::ROOM_ALLOWED) {
                $short++;
            }
            if ($one["left"] < -self::OVERRUN_ALLOWED) {
                $over++;
            }
            $total += $one["left"];
            $checked++;
        }
        $this->assertTrue($checked > 4, "many stretches were read whole");
        $this->assertEqual($short, 0,
            "every stretch was read to within a bit of its end");
        $this->assertEqual($over, 0, "no stretch was read past its end");
        $this->assertTrue($total / $checked < self::ROOM_ALLOWED / 2,
            "on average less than half a bit is left over");
    }
    /**
     * Every band kept should end up holding something, since a band
     * given no room is filled rather than left silent
     */
    public function keptBandsHoldSomethingTestCase()
    {
        $empty = 0;
        $checked = 0;
        foreach ($this->readAll() as $one) {
            if ($one["header"]->silent) {
                continue;
            }
            for ($band = 0; $band < $one["shared"]["kept"]; $band++) {
                if ($one["marks"][$band] == 0) {
                    $empty++;
                }
                $checked++;
            }
        }
        $this->assertTrue($checked > 50, "many bands were checked");
        $this->assertEqual($empty, 0,
            "every band kept ends up holding something");
    }
    /**
     * The slots a stretch gives back should all be real numbers rather
     * than anything that has gone astray
     */
    public function everySlotIsARealNumberTestCase()
    {
        $astray = 0;
        $checked = 0;
        $loudest = 0.0;
        foreach ($this->readAll() as $one) {
            foreach ($one["slots"] as $slot) {
                if (!is_finite($slot)) {
                    $astray++;
                }
                $loudest = max($loudest, abs($slot));
                $checked++;
            }
        }
        $this->assertTrue($checked > 2000, "many slots were checked");
        $this->assertEqual($astray, 0, "every slot is a real number");
        $this->assertTrue($loudest > 0.0 && $loudest < 100.0,
            "no slot has run away");
    }
    /**
     * A stretch of silence should be read as silent, and should still
     * give back a full set of slots.
     *
     * A silent stretch is not skipped. Its room is marked as used up
     * and the reading carries on, so every band is given nothing and
     * is filled with faint noise instead. What makes it silent is the
     * loudness, which sits at its floor, rather than the slots being
     * empty. Skipping such a stretch would leave the reading in a
     * different place than the recording expects.
     */
    public function silenceIsStillReadThroughTestCase()
    {
        $silent = 0;
        $checked = 0;
        foreach ($this->readEverything(self::RECORDINGS[2]) as $one) {
            if ($one["header"]->silent) {
                $silent++;
                $this->assertTrue(count($one["slots"]) > 100,
                    "a silent stretch still gives back a full set of slots");
                $filled = 0;
                foreach ($one["slots"] as $slot) {
                    if ($slot != 0.0) {
                        $filled++;
                    }
                }
                $this->assertTrue($filled > 100,
                    "and those slots hold the faint noise it is filled with");
            }
            $checked++;
            if ($checked > 2) {
                break;
            }
        }
        $this->assertTrue($silent > 0, "the silence was read as silent");
    }
    /**
     * The loudnesses read from a recording of one steady tone should
     * put that tone in the band it really belongs to.
     *
     * This is the check that does not depend on anything downstream.
     * The recording holds a 440 cycle tone, the bands are known
     * stretches of the sound spectrum, and band two covers 400 to 600
     * cycles a second. So the loudest band must be band two, and the
     * loudness must fall away on both sides of it. A reading that had
     * gone wrong would put the peak somewhere else or spread it
     * everywhere.
     */
    public function toneLandsInTheRightBandTestCase()
    {
        $found = $this->readEverything(self::RECORDINGS[0]);
        $this->assertTrue(count($found) > 1, "stretches were read");
        $checked = 0;
        $wrong_band = 0;
        $not_peaked = 0;
        foreach ($found as $one) {
            if ($one["header"]->silent) {
                continue;
            }
            $loudest = -1000.0;
            $which = -1;
            $levels = [];
            foreach ($one["loudness"][0] as $band => $value) {
                /* What a band really comes to is what was read plus
                   what that band was expected to be. */
                $levels[$band] = $value + CeltBands::EXPECTED[$band];
                if ($levels[$band] > $loudest) {
                    $loudest = $levels[$band];
                    $which = $band;
                }
            }
            if ($which != self::TONE_BAND) {
                $wrong_band++;
            }
            if ($levels[self::TONE_BAND] <= $levels[self::TONE_BAND - 1] ||
                $levels[self::TONE_BAND] <= $levels[self::TONE_BAND + 1] ||
                $levels[self::TONE_BAND] - $levels[11] < 5.0) {
                $not_peaked++;
            }
            $checked++;
        }
        $this->assertTrue($checked > 1, "stretches of the tone were read");
        $this->assertEqual($wrong_band, 0,
            "the tone is loudest in the band it belongs to");
        $this->assertEqual($not_peaked, 0,
            "the loudness falls away on both sides of that band");
    }
    /**
     * Reading the same recording twice should give the same answer
     * both times, since nothing in the reading may depend on anything
     * outside the recording
     */
    public function readingTwiceAgreesTestCase()
    {
        $stored = file_get_contents(C\PARENT_DIR .
            "/tests" . self::RECORDINGS[0]);
        $once = [];
        $twice = [];
        foreach ([0, 1] as $turn) {
            $file = new WebmDemuxer(base64_decode($stored));
            $before = CeltEnergy::nothingYet(1);
            $seed = 0;
            foreach ($file->packets() as $piece) {
                $sound = OpusPacket::fromString($piece->data);
                if ($sound->method != OpusPacket::MUSIC_METHOD) {
                    continue;
                }
                foreach ($sound->stretches as $stretch) {
                    $reader = new RangeDecoder($stretch);
                    $read = CeltStretch::readFrom($reader, self::DOUBLINGS,
                        $before, $seed, 0, self::BANDS);
                    $before = $read["loudness"];
                    $seed = $read["seed"];
                    if ($turn == 0) {
                        $once[] = $read["slots"];
                    } else {
                        $twice[] = $read["slots"];
                    }
                    if (count($once) >= self::STRETCHES_READ_TWICE &&
                        $turn == 0) {
                        break 2;
                    }
                    if (count($twice) >= self::STRETCHES_READ_TWICE &&
                        $turn == 1) {
                        break 2;
                    }
                }
            }
        }
        $this->assertTrue(count($once) > 1, "stretches were read");
        $this->assertEqual($once, $twice, "both readings agree throughout");
    }
}
X