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