/ tests / AacEncoderTest.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\AacBands;
use seekquarry\yioop\library\av_processing\AacEncoder;
use seekquarry\yioop\library\av_processing\AacFrame;
use seekquarry\yioop\library\av_processing\AacQuantizer;
use seekquarry\yioop\library\av_processing\AacTables;
use seekquarry\yioop\library\av_processing\BitWriter;
use seekquarry\yioop\library\av_processing\Mdct;
use seekquarry\yioop\library\UnitTest;

/**
 * AacEncoderTest checks the pieces that compress sound into a
 * frame of Advanced Audio Coding, which is what AAC stands for.
 *
 * The one thing these cases cannot do is play the result, so they
 * check what can be checked without a player: that rounding a tone and
 * undoing it gives the tone back, that a frame's opening reads back as
 * what was written into it, that the size loop lands near the size it
 * was asked for, and that the code tables are indexed the way the
 * writer assumes. Whether a real decoder accepts the result is checked
 * by hand against ffmpeg and noted in the README, since it needs a
 * program these cases cannot call.
 *
 * @author Chris Pollett
 */
class AacEncoderTest extends UnitTest
{
    /**
     * Samples a second the encoder works at
     */
    const SAMPLE_RATE = 48000;
    /**
     * How many samples a frame advances by
     */
    const HOP = 1024;
    /**
     * A starting point for the made up numbers
     */
    const SEED = 20260803;
    /**
     * setUp sets the made up numbers off from the same place every
 * run
     */
    public function setUp()
    {
        mt_srand(self::SEED);
    }
    /**
     * tearDown has nothing to clear away after these cases
     */
    public function tearDown()
    {
    }
    /**
     * takeBits reads a value of a given width back out of a run of
 * bits
     *
     * @param string $bits the run, as ones and zeroes
     * @param int $at where in it to read, moved past what was read
     * @param int $width how many bits to read
     * @return int the value read
     */
    public function takeBits($bits, &$at, $width)
    {
        $value = bindec(substr($bits, $at, $width));
        $at += $width;
        return $value;
    }
    /**
     * asBits turns a run of bytes into ones and zeroes
     *
     * @param string $bytes the bytes
     * @return string the bits
     */
    public function asBits($bytes)
    {
        $bits = "";
        for ($i = 0; $i < strlen($bytes); $i++) {
            $bits .= sprintf("%08b", ord($bytes[$i]));
        }
        return $bits;
    }
    /**
     * bitsComeBackInOrderTestCase checks that values of any width
 * come back out of a run of bits in the
     * order they went in
     */
    public function bitsComeBackInOrderTestCase()
    {
        $wanted = [[5, 3], [0x2A, 7], [1, 1], [0, 4], [255, 8], [3, 2]];
        $writer = new BitWriter();
        $total = 0;
        foreach ($wanted as $one) {
            $writer->add($one[0], $one[1]);
            $total += $one[1];
        }
        $this->assertEqual($writer->length(), $total,
            "the run is as long as what was put in it");
        $bits = $this->asBits($writer->finish());
        $at = 0;
        $wrong = 0;
        foreach ($wanted as $one) {
            if ($this->takeBits($bits, $at, $one[1]) != $one[0]) {
                $wrong++;
            }
        }
        $this->assertEqual($wrong, 0, "every value came back as it went in");
        $this->assertTrue(strlen($bits) >= $total,
            "the last byte is padded rather than cut short");
    }
    /**
     * smallestStepFollowsTheBandTestCase checks that the smallest
 * step a band may use keeps its loudest tone
     * within what can be written, and should follow the loudness of
     * the band rather than sitting at a fixed place
     */
    public function smallestStepFollowsTheBandTestCase()
    {
        $steps = [];
        foreach ([1000000.0, 1000.0, 1.0, 0.001] as $size) {
            $tones = [$size, -$size / 2];
            $step = AacQuantizer::smallestStepFor($tones, 0, 2);
            $steps[] = $step;
            $rounded = AacQuantizer::roundBand($tones, 0, 2, $step);
            $this->assertTrue(abs($rounded[0]) <=
                AacQuantizer::LARGEST_PLAIN,
                "the loudest tone of a band fits what may be written");
            $this->assertTrue(abs($rounded[0]) >
                AacQuantizer::LARGEST_PLAIN / 4,
                "and it uses most of the room it is given");
        }
        $falling = true;
        for ($i = 1; $i < count($steps); $i++) {
            if ($steps[$i] >= $steps[$i - 1]) {
                $falling = false;
            }
        }
        $this->assertTrue($falling,
            "a quieter band is given a smaller step than a louder one");
    }
    /**
     * frameOpeningReadsBackTestCase checks that the opening of a
 * frame reads back as what was written into
     * it, in the order the standard sets out
     */
    public function frameOpeningReadsBackTestCase()
    {
        $tones = [];
        for ($i = 0; $i < self::HOP; $i++) {
            $tones[] = mt_rand(-1000, 1000) / 10.0;
        }
        $made = AacFrame::write($tones, 2000);
        $bits = $this->asBits($made["bytes"]);
        $at = 0;
        $this->assertEqual($this->takeBits($bits, $at, 3),
            AacFrame::ONE_CHANNEL, "the frame names one run of sound");
        $this->assertEqual($this->takeBits($bits, $at, 4), 0,
            "which run it is");
        $base = $this->takeBits($bits, $at, 8);
        $this->assertTrue($base >= 0 && $base <= AacFrame::BASE_LIMIT,
            "the loudness it counts from is one that may be written");
        $this->assertEqual($this->takeBits($bits, $at, 1), 0,
            "the bit that must be nothing is nothing");
        $this->assertEqual($this->takeBits($bits, $at, 2),
            AacFrame::LONG_SHAPE, "the stretch is one long stretch");
        $this->takeBits($bits, $at, 1);
        $this->assertEqual($this->takeBits($bits, $at, 6),
            AacBands::longBandCount(), "every band may carry sound");
        $this->assertEqual($this->takeBits($bits, $at, 1), 0,
            "nothing is predicted from the stretch before");
    }
    /**
     * silenceMakesATinyFrameTestCase checks that a frame of silence
 * is tiny and is still a whole
     * frame, ending where it says it does
     */
    public function silenceMakesATinyFrameTestCase()
    {
        $made = AacFrame::write(array_fill(0, self::HOP, 0.0), 2000);
        $this->assertTrue(strlen($made["bytes"]) < 12,
            "a silent frame is a handful of bytes");
        $bits = $this->asBits($made["bytes"]);
        $at = $made["bits"] - 3;
        $this->assertEqual($this->takeBits($bits, $at, 3),
            AacFrame::END_OF_FRAME, "it ends where it says it does");
    }
    /**
     * sizeLoopFillsWhatItIsGivenTestCase checks that the size loop
 * comes close to the size it was asked for on
     * sound dense enough to fill it, and should never run past it
     */
    public function sizeLoopFillsWhatItIsGivenTestCase()
    {
        $sound = [];
        for ($i = 0; $i < 2 * self::HOP; $i++) {
            $sound[] = mt_rand(-1000, 1000) / 2500.0;
        }
        $fade = AacBands::fadeFor(AacBands::LONG);
        for ($i = 0; $i < 2 * self::HOP; $i++) {
            $sound[$i] *= $fade[$i];
        }
        $tones = Mdct::forSize(self::HOP)->forward($sound);
        $over = 0;
        $sizes = [];
        foreach ([768, 1365, 2048] as $room) {
            $made = AacFrame::write($tones, $room);
            if (strlen($made["bytes"]) * 8 > $room) {
                $over++;
            }
            $sizes[] = strlen($made["bytes"]) * 8;
        }
        $this->assertEqual($over, 0, "no frame runs past the room allowed");
        $shrank = 0;
        for ($i = 1; $i < count($sizes); $i++) {
            if ($sizes[$i] < $sizes[$i - 1]) {
                $shrank++;
            }
        }
        $this->assertEqual($shrank, 0,
            "more room allowed never gives a smaller frame");
        $this->assertTrue($sizes[1] > $sizes[0],
            "and more room is actually taken up while there is use for it");
    }
    /**
     * everyStepCanBeNamedTestCase checks that every step a band takes
 * from the one before it is one the
     * table of steps can name
     */
    public function everyStepCanBeNamedTestCase()
    {
        $tones = [];
        for ($i = 0; $i < self::HOP; $i++) {
            /* Loud at the bottom and almost silent at the top, which is
               what pulls the steps of neighboring bands apart. */
            $tones[] = ($i < 40 ? 100000.0 : 0.0001) *
                (mt_rand(0, 1) ? 1 : -1);
        }
        $bands = AacBands::longBandCount();
        $floor = [];
        for ($band = 0; $band < $bands; $band++) {
            $floor[$band] = AacQuantizer::smallestStepFor($tones,
                AacBands::LONG_EDGES[$band], AacBands::LONG_EDGES[$band + 1]);
        }
        $carries = array_fill(0, $bands, true);
        $steps = AacFrame::clampSteps($floor, $carries);
        $wrong = 0;
        $lowered = 0;
        for ($band = 0; $band < $bands; $band++) {
            if ($steps[$band] < $floor[$band]) {
                $lowered++;
            }
            if ($band > 0 && abs($steps[$band] - $steps[$band - 1]) >
                AacTables::LOUDNESS_SPAN) {
                $wrong++;
            }
        }
        $this->assertEqual($wrong, 0,
            "every step is within reach of the one before it");
        $this->assertEqual($lowered, 0,
            "no band was made finer than its own floor");
    }
    /**
     * runLengthsDoNotMatterTestCase checks that the encoder takes
 * samples a run at a time and makes the
     * same frames whatever lengths those runs come in. This case runs
     * for about a hundredth of a second, since it compresses the same
     * sound several times over.
     */
    public function runLengthsDoNotMatterTestCase()
    {
        $wave = [];
        for ($i = 0; $i < 2 * self::HOP; $i++) {
            $wave[] = 0.3 * sin(2 * M_PI * 440 * $i / self::SAMPLE_RATE);
        }
        $whole = new AacEncoder();
        $whole->take($wave);
        $whole->finish();
        $piecemeal = new AacEncoder();
        /* Runs of an awkward length, so that no run lines up with the
           length a frame advances by. */
        foreach (array_chunk($wave, 683) as $run) {
            $piecemeal->take($run);
        }
        $piecemeal->finish();
        $this->assertEqual(count($whole->frames), count($piecemeal->frames),
            "the same number of frames either way");
        $this->assertEqual($whole->frames, $piecemeal->frames,
            "and the very same frames");
        $this->assertTrue(count($whole->frames) >= 2,
            "a run of samples makes a frame for each hop it covers");
    }
    /**
     * tablesAreIndexedAsAssumedTestCase checks that the code
 * tables are indexed the way the frame writer
     * assumes, since it looks a pair up by working out where it sits
     */
    public function tablesAreIndexedAsAssumedTestCase()
    {
        $this->assertEqual(count(AacTables::SOUND_CODES),
            AacTables::PAIR_RANGE * AacTables::PAIR_RANGE,
            "the sound table holds every pair it should");
        $this->assertEqual(count(AacTables::SOUND_CODES),
            count(AacTables::SOUND_CODE_BITS),
            "every code has a length");
        $this->assertEqual(0 * AacTables::PAIR_RANGE + 0, 0,
            "the pair of nothing and nothing sits first");
        $this->assertEqual(AacTables::ESCAPE_AT * AacTables::PAIR_RANGE +
            AacTables::ESCAPE_AT, count(AacTables::SOUND_CODES) - 1,
            "the pair of the largest and the largest sits last");
        $wrong = 0;
        foreach (AacTables::SOUND_CODES as $where => $code) {
            if ($code >= (1 << AacTables::SOUND_CODE_BITS[$where])) {
                $wrong++;
            }
        }
        foreach (AacTables::LOUDNESS_CODES as $where => $code) {
            if ($code >= (1 << AacTables::LOUDNESS_CODE_BITS[$where])) {
                $wrong++;
            }
        }
        $this->assertEqual($wrong, 0,
            "every code fits the length it claims");
        $shortest = min(AacTables::LOUDNESS_CODE_BITS);
        $this->assertEqual(array_search($shortest,
            AacTables::LOUDNESS_CODE_BITS), AacTables::LOUDNESS_SPAN,
            "a step of nothing is the shortest to write");
    }
}
X