<?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;
}
}