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