/ tests / SpeechPitchTest.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\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");
    }
}
X