/ tests / SpeechShapeTest.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\configs as C;
use seekquarry\yioop\library\av_processing\OggDemuxer;
use seekquarry\yioop\library\av_processing\OpusPacket;
use seekquarry\yioop\library\av_processing\RangeDecoder;
use seekquarry\yioop\library\av_processing\SpeechFrameHeader;
use seekquarry\yioop\library\av_processing\SpeechShape;
use seekquarry\yioop\library\av_processing\SpeechShapeTables;
use seekquarry\yioop\library\UnitTest;

/**
 * SpeechShapeTest checks that SpeechShape reads the shape of a stretch
 * of recorded speech: the mouth and throat that made the sound, written
 * as sixteen frequencies rising in order.
 *
 * The frequencies rising in order is the check that matters. They are
 * built from a guess out of a fixed book plus a mending for each one,
 * and a mistake anywhere in that arithmetic leaves two of them equal or
 * crossed, which no mouth can make. So a run of real stretches reading
 * back in order says the tables were read the way the writer wrote them.
 *
 * @author Chris Pollett
 */
class SpeechShapeTest extends UnitTest
{
    /**
     * $recording_path stores where setUp wrote the recording that the
     * cases read.
     * @var string
     */
    public $recording_path;
    /**
     * $shapes stores the shape read from each speech stretch of that
     * recording, in the order the stretches were written.
     * @var array
     */
    public $shapes = [];
    /**
     * setUp writes a recording out of the base64 it is kept in and reads
     * the shape of each of its speech stretches, so a case can look at
     * all of them without reading the file again.
     */
    public function setUp()
    {
        $this->recording_path = C\WORK_DIRECTORY . "/temp/shape" .
            getmypid() . ".ogg";
        $held = file_get_contents(C\PARENT_DIR .
            "/tests/test_files/tiny_recording_ogg.txt");
        file_put_contents($this->recording_path,
            base64_decode(trim($held)));
        @chmod($this->recording_path, 0777);
        $this->shapes = [];
        $reader = OggDemuxer::fromName($this->recording_path);
        foreach ($reader->packets() as $packet) {
            try {
                $sound = OpusPacket::fromString($packet->data);
            } catch (\Exception $trouble) {
                continue;
            }
            if ($sound->method == OpusPacket::MUSIC_METHOD) {
                continue;
            }
            $range = new RangeDecoder($sound->stretches[0]);
            $marks = SpeechFrameHeader::readMarks($range,
                count($sound->stretches));
            $head = SpeechFrameHeader::readSound($range,
                $marks->has_speech[0]);
            $this->shapes[] = SpeechShape::read($range, $head->kind);
        }
    }
    /**
     * tearDown removes the recording that setUp wrote, so a run leaves
     * nothing behind in the work directory.
     */
    public function tearDown()
    {
        if (file_exists($this->recording_path)) {
            unlink($this->recording_path);
        }
    }
    /**
     * everyStretchGivesSixteenFrequenciesTestCase checks that a shape
     * holds one frequency for each of the sixteen the standard writes
     * for sound of this width. A shape of another length would leave the
     * filter built from it with the wrong number of terms.
     */
    public function everyStretchGivesSixteenFrequenciesTestCase()
    {
        $this->assertTrue(count($this->shapes) > 0,
            "the recording gives up at least one shape, and gave " .
            count($this->shapes));
        foreach ($this->shapes as $which => $shape) {
            $this->assertEqual(
                SpeechShapeTables::FREQUENCIES_IN_SHAPE,
                count($shape->frequencies),
                "shape $which holds sixteen frequencies");
        }
    }
    /**
     * frequenciesRiseInOrderTestCase checks that each frequency of a
     * shape sits above the one before it. This is the check that says
     * the book, the mendings and the leaning were read correctly: a
     * mistake in any of them leaves two frequencies equal or crossed.
     */
    public function frequenciesRiseInOrderTestCase()
    {
        foreach ($this->shapes as $which => $shape) {
            $rising = true;
            for ($at = 1; $at < count($shape->frequencies); $at++) {
                if ($shape->frequencies[$at] <=
                    $shape->frequencies[$at - 1]) {
                    $rising = false;
                }
            }
            $this->assertTrue($rising,
                "the frequencies of shape $which rise in order");
        }
    }
    /**
     * frequenciesSitInsideTheSoundTestCase checks that no frequency
     * falls below nothing or rises past half the sampling rate. A
     * frequency outside that range names a pitch the sound cannot hold.
     */
    public function frequenciesSitInsideTheSoundTestCase()
    {
        foreach ($this->shapes as $which => $shape) {
            foreach ($shape->frequencies as $one) {
                $this->assertTrue($one > 0 &&
                    $one < SpeechShape::WHOLE_TURN,
                    "each frequency of shape $which sits inside the " .
                    "sound, and one was $one");
            }
        }
    }
    /**
     * guessComesFromTheBookTestCase checks that the guess a stretch
     * names is one the book holds. A number past the end of the book
     * would read the shape out of whatever numbers followed it.
     */
    public function guessComesFromTheBookTestCase()
    {
        foreach ($this->shapes as $which => $shape) {
            $this->assertTrue($shape->guess >= 0 && $shape->guess <
                SpeechShapeTables::GUESSES_IN_BOOK,
                "shape $which names a guess the book holds, and named " .
                $shape->guess);
        }
    }
    /**
     * longSpeechReadsThroughoutTestCase reads ten seconds of a recorded
     * speech and checks that every stretch of it gives a shape whose
     * frequencies rise in order. Thirteen stretches of a short recording
     * can pass by luck; five hundred of a real speech, holding pauses,
     * voiced sounds and voiceless ones, cannot.
     */
    public function longSpeechReadsThroughoutTestCase()
    {
        $where = C\WORK_DIRECTORY . "/temp/churchill" . getmypid() .
            ".opus";
        $held = file_get_contents(C\PARENT_DIR .
            "/tests/test_files/speech_churchill_opus.txt");
        file_put_contents($where, base64_decode(trim($held)));
        @chmod($where, 0777);
        $read = 0;
        $rising = 0;
        $reader = OggDemuxer::fromName($where);
        foreach ($reader->packets() as $packet) {
            try {
                $sound = OpusPacket::fromString($packet->data);
            } catch (\Exception $trouble) {
                continue;
            }
            if ($sound->method == OpusPacket::MUSIC_METHOD) {
                continue;
            }
            $range = new RangeDecoder($sound->stretches[0]);
            $marks = SpeechFrameHeader::readMarks($range,
                count($sound->stretches));
            $head = SpeechFrameHeader::readSound($range,
                $marks->has_speech[0]);
            $shape = SpeechShape::read($range, $head->kind);
            $read++;
            $ordered = true;
            for ($at = 1; $at < count($shape->frequencies); $at++) {
                if ($shape->frequencies[$at] <=
                    $shape->frequencies[$at - 1]) {
                    $ordered = false;
                }
            }
            if ($ordered) {
                $rising++;
            }
        }
        if (file_exists($where)) {
            unlink($where);
        }
        $this->assertTrue($read > 400,
            "ten seconds of speech gives many stretches, and gave $read");
        $this->assertEqual($read, $rising,
            "every one of them gives a shape rising in order");
    }
    /**
     * realStretchGivesTheFrequenciesTheReferenceGivesTestCase checks
     * the sixteen frequencies of one stretch of the test speech against
     * what the reference decoder works out for it. The frequencies
     * rising in order is a weak check; these are the numbers
     * themselves.
     */
    public function realStretchGivesTheFrequenciesTheReferenceGivesTestCase()
    {
        $wanted = [1324, 1675, 3953, 5462, 6723, 8741, 10291, 12230,
            13144, 14993, 17664, 20645, 22656, 24687, 27345, 30336];
        $where = C\WORK_DIRECTORY . "/temp/oneshape" . getmypid() .
            ".opus";
        $held = file_get_contents(C\PARENT_DIR .
            "/tests/test_files/speech_churchill_opus.txt");
        file_put_contents($where, base64_decode(trim($held)));
        @chmod($where, 0777);
        $at = 0;
        $found = [];
        $reader = OggDemuxer::fromName($where);
        foreach ($reader->packets() as $packet) {
            $at++;
            if ($at != 43) {
                continue;
            }
            $sound = OpusPacket::fromString($packet->data);
            $range = new RangeDecoder($sound->stretches[0]);
            $marks = SpeechFrameHeader::readMarks($range,
                count($sound->stretches));
            $head = SpeechFrameHeader::readSound($range,
                $marks->has_speech[0]);
            $found = SpeechShape::read($range, $head->kind)->frequencies;
            break;
        }
        if (file_exists($where)) {
            unlink($where);
        }
        $this->assertEqual($wanted, $found,
            "the sixteen frequencies are the ones the reference gives");
    }
    /**
     * frequenciesTooCloseArePushedApartTestCase checks the step that
     * keeps a shape usable: frequencies handed in crossed or touching
     * come back rising and at least the least gap apart.
     */
    public function frequenciesTooCloseArePushedApartTestCase()
    {
        $crossed = array_fill(0,
            SpeechShapeTables::FREQUENCIES_IN_SHAPE, 1000);
        $pushed = SpeechShape::pushedApart($crossed);
        $rising = true;
        for ($at = 1; $at < count($pushed); $at++) {
            if ($pushed[$at] <= $pushed[$at - 1]) {
                $rising = false;
            }
        }
        $this->assertTrue($rising,
            "sixteen frequencies all at the same place come back rising");
        $this->assertTrue($pushed[count($pushed) - 1] <
            SpeechShape::WHOLE_TURN,
            "and the highest still sits inside the sound");
    }
}
X