<?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\SpeechPitch;
use seekquarry\yioop\library\av_processing\SpeechPitchTables;
use seekquarry\yioop\library\av_processing\SpeechShape;
use seekquarry\yioop\library\av_processing\SpeechTables;
use seekquarry\yioop\library\UnitTest;
/**
* SpeechPitchTest checks that SpeechPitch reads the pitch of a stretch
* of recorded speech: how far back the sound repeats, how that distance
* drifts across the stretch's four quarters, and the filter that says
* how strongly it repeats.
*
* The check that carries weight is steadiness. A speaker's pitch moves
* slowly, so the distance read for one stretch should sit near the one
* read for the stretch before it. A misread bit puts the reading out by
* a large jump, so a run of stretches that stays steady says the bits
* were taken in the order the writer wrote them.
*
* @author Chris Pollett
*/
class SpeechPitchTest extends UnitTest
{
/**
* MOST_MIDDLING_STEP is the largest middling jump in distance
* between neighboring voiced stretches that counts as steady, in
* samples at sixteen thousand a second. At a distance near a hundred
* and ten, ten samples is a pitch moving by about twelve cycles a
* second over a fiftieth of a second, which is more than a voice
* does.
* @var int
*/
const MOST_MIDDLING_STEP = 10;
/**
* $recording_path stores where setUp wrote the recorded speech the
* cases read.
* @var string
*/
public $recording_path;
/**
* $pitches stores the pitch read from each voiced stretch of that
* recording, in the order the stretches were written.
* @var array
*/
public $pitches = [];
/**
* $runs stores the distances of each unbroken run of voiced
* stretches, so a case can look at how the pitch moves within a run
* without crossing a pause.
* @var array
*/
public $runs = [];
/**
* setUp writes ten seconds of recorded speech out of the base64 it
* is kept in, then reads every stretch of it, keeping the pitch of
* the voiced ones and grouping them into unbroken runs.
*/
public function setUp()
{
$this->recording_path = C\WORK_DIRECTORY . "/temp/pitch" .
getmypid() . ".opus";
$held = file_get_contents(C\PARENT_DIR .
"/tests/test_files/speech_churchill_opus.txt");
file_put_contents($this->recording_path,
base64_decode(trim($held)));
@chmod($this->recording_path, 0777);
$this->pitches = [];
$this->runs = [];
$run = [];
$carried = -1;
$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]);
SpeechShape::read($range, $head->kind);
if ($head->kind !== SpeechTables::VOICED_KIND) {
if (count($run) > 1) {
$this->runs[] = $run;
}
$run = [];
$carried = -1;
continue;
}
$pitch = SpeechPitch::read($range, $carried);
$carried = $pitch->lag;
$this->pitches[] = $pitch;
$run[] = $pitch->lag;
}
if (count($run) > 1) {
$this->runs[] = $run;
}
}
/**
* 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);
}
}
/**
* everyQuarterOfARealStretchKeepsItsDistanceTestCase checks the
* four distances of one stretch of the test speech against what the
* reference decoder works out for it. The four share one shape,
* whose table holds a row for each quarter and a column for each
* shape, and reading that table the other way about gave each
* quarter the wrong step.
*/
public function everyQuarterOfARealStretchKeepsItsDistanceTestCase()
{
$wanted = [110, 110, 110, 110];
$found = [];
foreach ($this->pitches as $pitch) {
if ($pitch->lag == 110) {
$found = $pitch->quarter_lags;
break;
}
}
$this->assertEqual(4, count($found),
"a stretch of the recording repeats over 110 samples");
$this->assertEqual($wanted, $found,
"its four quarters keep that distance, as the reference " .
"works them out");
}
/**
* recordingHoldsVoicedSpeechTestCase checks that the recording
* gives up stretches of speech made with the voice. A recording of
* whispering or of silence would exercise none of this reading, so
* the other cases would pass while saying nothing.
*/
public function recordingHoldsVoicedSpeechTestCase()
{
$this->assertTrue(count($this->pitches) > 100,
"the recording holds many voiced stretches, and holds " .
count($this->pitches));
}
/**
* everyDistanceSitsInRangeTestCase checks that the distance the
* sound repeats over sits between the shortest and the longest a
* stretch of wide sound may name. A distance outside that reaches
* into sound the decoder does not hold.
*/
public function everyDistanceSitsInRangeTestCase()
{
foreach ($this->pitches as $which => $pitch) {
$this->assertTrue($pitch->lag >=
SpeechPitchTables::LEAST_LAG_WIDE &&
$pitch->lag <= SpeechPitchTables::MOST_LAG_WIDE,
"stretch $which repeats over a distance in range, and " .
"over " . $pitch->lag);
}
}
/**
* eachQuarterHasItsOwnDistanceTestCase checks that a distance is
* read for each of the four quarters and that each sits near the
* stretch's own. The four share one shape, so a quarter far from
* the stretch's distance means the shape was read wrongly.
*/
public function eachQuarterHasItsOwnDistanceTestCase()
{
$most_drift = 32;
foreach (array_slice($this->pitches, 0, 20) as $which => $pitch) {
$this->assertEqual(SpeechPitchTables::QUARTERS_IN_STRETCH,
count($pitch->quarter_lags),
"stretch $which gives a distance for each quarter");
foreach ($pitch->quarter_lags as $one) {
$this->assertTrue(abs($one - $pitch->lag) <= $most_drift,
"each quarter sits within $most_drift of the " .
"stretch's own distance, and one was $one against " .
$pitch->lag);
}
}
}
/**
* everyQuarterNamesAFilterTestCase checks that each quarter names a
* filter of five taps out of the book the stretch chose. The taps
* say how much of the sound found a distance back to add in again.
*/
public function everyQuarterNamesAFilterTestCase()
{
foreach (array_slice($this->pitches, 0, 20) as $which => $pitch) {
$this->assertEqual(SpeechPitchTables::QUARTERS_IN_STRETCH,
count($pitch->quarter_filters),
"stretch $which names a filter for each quarter");
foreach ($pitch->quarter_filters as $filter) {
$this->assertEqual(SpeechPitch::TAPS_IN_FILTER,
count($filter),
"and each filter holds five taps");
}
}
}
/**
* pitchMovesSteadilyWithinARunTestCase checks that the distance
* read for one stretch sits near the distance read for the stretch
* before it, within an unbroken run of voiced speech. A speaker's
* pitch moves slowly, so a large middling jump means bits were taken
* in the wrong order rather than that the speaker leapt an octave.
*/
public function pitchMovesSteadilyWithinARunTestCase()
{
$steps = [];
foreach ($this->runs as $run) {
for ($at = 1; $at < count($run); $at++) {
$steps[] = abs($run[$at] - $run[$at - 1]);
}
}
$this->assertTrue(count($steps) > 50,
"the recording holds many neighboring voiced stretches, " .
"and holds " . count($steps));
sort($steps);
$middling = $steps[(int)(count($steps) / 2)];
$this->assertTrue($middling <= self::MOST_MIDDLING_STEP,
"half the jumps are " . self::MOST_MIDDLING_STEP .
" samples or less, and the middling jump is $middling");
}
}