<?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\SpeechTables;
use seekquarry\yioop\library\UnitTest;
/**
* SpeechFrameHeaderTest checks that SpeechFrameHeader reads the opening
* of a stretch of sound compressed the way built for speech. A browser
* records speech this way, so every recording a wiki page takes arrives
* compressed like this, and the opening is what decides how the rest of
* the stretch is read.
*
* The cases read a real recording rather than bytes made up here,
* because the tables of likelihoods are only exercised by a stream a
* recorder actually wrote.
*
* @author Chris Pollett
*/
class SpeechFrameHeaderTest extends UnitTest
{
/**
* $recording_path stores where setUp wrote the recording that the
* cases read.
* @var string
*/
public $recording_path;
/**
* $speech_packets stores the packets of that recording which were
* compressed the speech way, each already cut into its stretches.
* @var array
*/
public $speech_packets = [];
/**
* setUp writes a recording out of the base64 it is kept in and
* gathers the packets of it that were compressed the speech way. A
* recording made by a browser holds those and no others.
*/
public function setUp()
{
$this->recording_path = C\WORK_DIRECTORY . "/temp/speech" .
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->speech_packets = [];
$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;
}
$this->speech_packets[] = $sound;
}
}
/**
* 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);
}
}
/**
* recordingHoldsSpeechPacketsTestCase checks that the recording the
* other cases read really was compressed the speech way. A recording
* of music would exercise none of these tables, so the other cases
* would pass while saying nothing.
*/
public function recordingHoldsSpeechPacketsTestCase()
{
$this->assertTrue(count($this->speech_packets) > 0,
"the recording holds packets compressed the speech way, " .
"and holds " . count($this->speech_packets));
}
/**
* speechMarksAreReadTestCase checks that the marks at the front
* of a packet are read: one for each stretch saying whether that
* stretch holds speech, then one saying whether a second, poorer
* copy of the sound follows for a listener who lost a packet.
*/
public function speechMarksAreReadTestCase()
{
$sound = $this->speech_packets[0];
$reader = new RangeDecoder($sound->stretches[0]);
$marks = SpeechFrameHeader::readMarks($reader,
count($sound->stretches));
$this->assertEqual(count($sound->stretches),
count($marks->has_speech),
"one speech mark is read for each stretch in the packet");
$this->assertTrue(is_bool($marks->has_spare_copy),
"and the mark for a second copy is read as a yes or a no");
}
/**
* kindOfSoundIsOneOfThreeTestCase checks that a stretch says it
* holds one of the three kinds of sound the standard allows, and
* that its pulses were written one of the two ways. A caller picks
* the tables for the rest of the stretch from these two answers.
*/
public function kindOfSoundIsOneOfThreeTestCase()
{
$kinds = [SpeechTables::QUIET_KIND, SpeechTables::VOICELESS_KIND,
SpeechTables::VOICED_KIND];
$writings = [SpeechTables::QUIETER_PULSES,
SpeechTables::LOUDER_PULSES];
foreach (array_slice($this->speech_packets, 0, 3) as $sound) {
$reader = new RangeDecoder($sound->stretches[0]);
$marks = SpeechFrameHeader::readMarks($reader,
count($sound->stretches));
$head = SpeechFrameHeader::readSound($reader,
$marks->has_speech[0]);
$this->assertTrue(in_array($head->kind, $kinds),
"the stretch names one of the three kinds, and named " .
$head->kind);
$this->assertTrue(in_array($head->pulse_writing, $writings),
"and one of the two ways of writing pulses, and named " .
$head->pulse_writing);
}
}
/**
* everyQuarterHasALoudnessTestCase checks that a loudness step is
* read for each of the four quarters a stretch is cut into, and that
* each sits within the range the standard allows. The pulses of a
* quarter are scaled by its loudness, so a step outside that range
* would scale them by a number the writer never wrote.
*/
public function everyQuarterHasALoudnessTestCase()
{
foreach (array_slice($this->speech_packets, 0, 3) as $sound) {
$reader = new RangeDecoder($sound->stretches[0]);
$marks = SpeechFrameHeader::readMarks($reader,
count($sound->stretches));
$head = SpeechFrameHeader::readSound($reader,
$marks->has_speech[0]);
$this->assertEqual(SpeechTables::QUARTERS_IN_STRETCH,
count($head->loudness),
"a loudness is read for each quarter of the stretch");
foreach ($head->loudness as $step) {
$this->assertTrue($step >=
SpeechTables::LEAST_LOUDNESS_STEP &&
$step <= SpeechTables::MOST_LOUDNESS_STEP,
"each loudness sits in range, and one was $step");
}
}
}
/**
* loudnessMovesGentlyWithinAStretchTestCase checks that the
* loudness of one quarter is near the loudness of the quarter
* before it. Speech does not jump in loudness within twenty
* milliseconds, so a large jump means the changes were read with the
* wrong table or moved the wrong way.
*/
public function loudnessMovesGentlyWithinAStretchTestCase()
{
$most_change = 16;
foreach (array_slice($this->speech_packets, 0, 3) as $sound) {
$reader = new RangeDecoder($sound->stretches[0]);
$marks = SpeechFrameHeader::readMarks($reader,
count($sound->stretches));
$head = SpeechFrameHeader::readSound($reader,
$marks->has_speech[0]);
for ($at = 1; $at < count($head->loudness); $at++) {
$moved = abs($head->loudness[$at] -
$head->loudness[$at - 1]);
$this->assertTrue($moved <= $most_change,
"loudness moves by no more than $most_change steps " .
"from one quarter to the next, and moved $moved");
}
}
}
/**
* tablesCountDownToZeroTestCase checks that every table of
* likelihoods starts below the whole, never rises, and reaches zero
* at its end. The range decoder walks a table until the value it
* holds no longer falls below an entry, and answers with the place
* it stopped at, so a table that rises or never reaches zero sends
* it past the end.
*/
public function tablesCountDownToZeroTestCase()
{
$tables = [SpeechTables::QUIET_KINDS, SpeechTables::SPOKEN_KINDS,
SpeechTables::LOUDNESS_WITHIN_STEP,
SpeechTables::LOUDNESS_CHANGE];
foreach (SpeechTables::LOUDNESS_STEPS as $one) {
$tables[] = $one;
}
foreach ($tables as $which => $table) {
$this->assertTrue($table[0] < 256 && $table[0] > 0,
"table $which starts below the whole, and starts at " .
$table[0]);
$this->assertEqual(0, $table[count($table) - 1],
"and reaches zero at its end");
$falling = true;
for ($at = 1; $at < count($table); $at++) {
if ($table[$at] > $table[$at - 1]) {
$falling = false;
}
}
$this->assertTrue($falling,
"and never rises along the way, for table $which");
}
}
}