/ tests / AudioConverterTest.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\UnitTest;
use seekquarry\yioop\library\av_processing\AacDecoder;
use seekquarry\yioop\library\av_processing\AudioConverter;
use seekquarry\yioop\library\av_processing\PlainSound;
use seekquarry\yioop\library\av_processing\SoundTrack;
use seekquarry\yioop\library\av_processing\VorbisDecoder;

/**
 * Tests the reading and converting of a recording a browser made: that
 * the kind of file is worked out from its first bytes, that what is
 * inside one can be said without writing anything, and that a recording
 * comes out as an MP4 holding AAC, which every browser plays.
 *
 * @author Chris Pollett
 */
class AudioConverterTest extends UnitTest
{
    /**
     * MEMORY_ROOM is how much more memory a conversion may take than
     * the case had already used, in bytes. A conversion that gathered
     * the whole recording would pass this on anything but the shortest
     * file.
     * @var int
     */
    const MEMORY_ROOM = 8388608;
    /**
     * The recordings a case reads, by the kind of file each is.
     * @var array
     */
    public $recordings = [];
    /**
     * Where a case writes what it converts.
     * @var string
     */
    public $written = "";
    /**
     * Writes the recordings out of the base64 they are kept in, so a
     * case has real files to read.
     */
    public function setUp()
    {
        $this->recordings = [];
        foreach (["tiny_tone_webm" => "webm",
            "tiny_recording_ogg" => "ogg"] as $name => $ending) {
            $held = file_get_contents(C\PARENT_DIR .
                "/tests/test_files/$name.txt");
            $where = C\WORK_DIRECTORY . "/temp/$name" . getmypid() .
                ".$ending";
            file_put_contents($where, base64_decode(trim($held)));
            $this->recordings[$name] = $where;
        }
        $this->written = C\WORK_DIRECTORY . "/temp/converted" .
            getmypid() . ".m4a";
    }
    /**
     * Removes what the cases wrote.
     */
    public function tearDown()
    {
        foreach ($this->recordings as $path) {
            if (file_exists($path)) {
                unlink($path);
            }
        }
        if ($this->written !== "" && file_exists($this->written)) {
            unlink($this->written);
        }
    }
    /**
     * The kind of recording is worked out from the first bytes of the
     * file rather than from its name, since a browser does not always
     * name what it uploads.
     */
    public function kindComesFromTheBytesTestCase()
    {
        $this->assertEqual("webm", AudioConverter::soundKind(
            $this->recordings["tiny_tone_webm"]),
            "a WebM recording is read as WebM");
        $this->assertEqual("ogg", AudioConverter::soundKind(
            $this->recordings["tiny_recording_ogg"]),
            "an Ogg recording is read as Ogg");
        $words = C\WORK_DIRECTORY . "/temp/words" . getmypid() . ".txt";
        file_put_contents($words, "these are only words");
        $this->assertEqual("", AudioConverter::soundKind($words),
            "a file that is not a recording is read as neither");
        if (file_exists($words)) {
            unlink($words);
        }
    }
    /**
     * What is inside a recording can be said without writing anything:
     * how it is stored, how many packets of sound it holds, how long it
     * runs and how many channels it was recorded in.
     */
    public function whatIsInsideCanBeSaidTestCase()
    {
        $said = AudioConverter::describe(
            $this->recordings["tiny_tone_webm"], "webm");
        $this->assertEqual("webm", $said["container"],
            "it names how the recording is stored");
        $this->assertEqual(16, $said["packets"],
            "and how many packets of sound it holds");
        $this->assertTrue($said["seconds"] > 0.3
            && $said["seconds"] < 0.34,
            "and how long it runs, near a third of a second");
        $this->assertEqual(1, $said["channels"],
            "and that it was recorded in one channel");
    }
    /**
     * A recording comes out as an MP4 holding AAC. Safari will not play
     * the Opus a browser records, so a site keeping voice messages has
     * to write this kind instead. This case runs for about a tenth of a
     * second, since it decodes and compresses a real recording.
     */
    public function recordingIsWrittenAsAnMp4TestCase()
    {
        $made = AudioConverter::toM4a(
            $this->recordings["tiny_tone_webm"], "webm", $this->written);
        $this->assertTrue(file_exists($this->written),
            "a file is written where it was asked for");
        $written = file_get_contents($this->written);
        $this->assertEqual("ftypM4A ", substr($written, 4, 8),
            "the file says it is an MP4 carrying sound");
        $this->assertTrue(strpos($written, "mp4a") !== false,
            "and names an audio track inside it");
        $this->assertEqual(15360, $made["samples"],
            "every sample of the recording was written");
    }
    /**
     * The sound of a video is found without decoding a picture, and can
     * be written out on its own. A video keeps its sound beside its
     * pictures, so somebody who wants a soundtrack would otherwise have
     * to decode frames to reach it. This case runs for about half a
     * second, since it decodes five seconds of real sound.
     */
    public function soundComesOutOfAVideoTestCase()
    {
        $video = C\WORK_DIRECTORY . "/temp/movie" . getmypid() . ".webm";
        $held = file_get_contents(C\PARENT_DIR .
            "/tests/test_files/video_movie_webm.txt");
        file_put_contents($video, base64_decode(trim($held)));
        $found = AudioConverter::soundOfVideo($video);
        $this->assertEqual("opus", $found["codec"],
            "the sound track of the video says which codec it uses");
        $this->assertTrue(count($found["pieces"]) > 100,
            "and hands back the pieces of sound it holds");
        $written = C\WORK_DIRECTORY . "/temp/track" . getmypid() . ".wav";
        $made = AudioConverter::convert($video, $written);
        $this->assertTrue($made["seconds"] > 4.9 && $made["seconds"] < 5.1,
            "writing it out gives about five seconds of sound");
        $this->assertEqual("wav", PlainSound::kindOf($written),
            "and what was written is a WAV");
        foreach ([$video, $written] as $path) {
            if (file_exists($path)) {
                unlink($path);
            }
        }
    }
    /**
     * Sound compressed as AAC can be decoded back into samples, so an
     * MP4 or an M4A converts to a WAV. The sound that comes back is
     * checked against the same sound taken straight from the recording:
     * they should differ by only what the compressing threw away.
     * This case runs for about a tenth of a second, since it decodes
     * and compresses real sound twice over.
     */
    public function compressedSoundDecodesBackToSamplesTestCase()
    {
        $compressed = C\WORK_DIRECTORY . "/temp/aac" . getmypid() . ".m4a";
        $through = C\WORK_DIRECTORY . "/temp/through" . getmypid() . ".wav";
        $straight = C\WORK_DIRECTORY . "/temp/straight" . getmypid() .
            ".wav";
        AudioConverter::convert($this->recordings["tiny_tone_webm"],
            $compressed);
        AudioConverter::convert($compressed, $through);
        AudioConverter::convert($this->recordings["tiny_tone_webm"],
            $straight);
        $one = self::samplesOf($straight);
        $two = self::samplesOf($through);
        $this->assertEqual(count($one), count($two),
            "the sound comes back with as many samples as it went in");
        $away = 0.0;
        $seen = 0;
        for ($at = 0; $at + 1024 < count($two); $at++) {
            $away += abs($two[$at + 1024] - $one[$at]);
            $seen++;
        }
        $this->assertTrue($away / max(1, $seen) < 0.02,
            "and each sample is within a fiftieth of what went in");
        $loudest = 0.0;
        foreach ($two as $sample) {
            $loudest = max($loudest, abs($sample));
        }
        $this->assertTrue($loudest > 0.1 && $loudest < 0.2,
            "at the loudness the sound was recorded at");
        foreach ([$compressed, $through, $straight] as $path) {
            if (file_exists($path)) {
                unlink($path);
            }
        }
    }
    /**
     * samplesOf reads every sample out of a WAV file, so a case can
     * compare one run of sound with another.
     *
     * @param string $path The file to read.
     * @return array The samples it holds.
     */
    public static function samplesOf($path)
    {
        $samples = [];
        foreach (PlainSound::eachPiece($path, "wav") as $run) {
            foreach ($run as $sample) {
                $samples[] = $sample;
            }
        }
        return $samples;
    }
    /**
     * Sound compressed by another program decodes back to the sound it
     * was made from. The file this reads was written by ffmpeg from a
     * tone of four hundred and forty cycles a second, and what comes
     * back should hold that same tone. This case runs for about a
     * quarter of a second, since it decodes half a second of sound and
     * then looks through it for the loudest tone.
     */
    public function soundFromAnotherEncoderDecodesTestCase()
    {
        $held = file_get_contents(C\PARENT_DIR .
            "/tests/test_files/sound_tone_m4a.txt");
        $path = C\WORK_DIRECTORY . "/temp/tone" . getmypid() . ".m4a";
        file_put_contents($path, base64_decode(trim($held)));
        $found = AudioConverter::soundOfVideo($path);
        $this->assertEqual("aac", $found["codec"],
            "the file says its sound is compressed as AAC");
        $decoder = new AacDecoder();
        $samples = [];
        $skipped = 0;
        foreach ($found["pieces"] as $piece) {
            try {
                $run = $decoder->decodeFrame($piece);
            } catch (\Exception $trouble) {
                $skipped++;
                continue;
            }
            foreach ($run as $sample) {
                $samples[] = $sample;
            }
        }
        $this->assertTrue(count($samples) > 20000,
            "most of its frames decode into samples");
        $this->assertEqual(440, self::loudestTone($samples),
            "and the tone that comes back is the one that went in");
        $this->assertEqual(0, $skipped,
            "with every frame of it read, the short stretches "
            . "included");
        if (file_exists($path)) {
            unlink($path);
        }
    }
    /**
     * A Vorbis file is told from an Opus one by what its first packet
     * names itself, and what it says about itself can be read without
     * decoding any sound. Both live inside an Ogg file, so the kind
     * cannot be settled by the name of the file.
     */
    public function vorbisFileIsToldFromAnOpusOneTestCase()
    {
        $held = file_get_contents(C\PARENT_DIR .
            "/tests/test_files/sound_tone_ogg.txt");
        $path = C\WORK_DIRECTORY . "/temp/vorbis" . getmypid() . ".ogg";
        file_put_contents($path, base64_decode(trim($held)));
        $found = SoundTrack::fromFile($path, "ogg");
        $this->assertEqual("vorbis", $found["codec"],
            "the file says its sound is Vorbis");
        $said = AudioConverter::describe($path, "ogg");
        $this->assertEqual(48000, $said["rate"],
            "and says what rate it was recorded at");
        $this->assertEqual(1, $said["channels"],
            "and how many channels it carries");
        $written = C\WORK_DIRECTORY . "/temp/vorbis" . getmypid() .
            ".wav";
        $made = AudioConverter::convert($path, $written);
        $this->assertTrue($made["seconds"] > 0.9 && $made["seconds"] < 1.1,
            "and its sound converts to about a second of samples");
        $samples = self::samplesOf($written);
        $this->assertEqual(440, self::loudestTone($samples),
            "which hold the tone the file was made from");
        foreach ([$path, $written] as $one) {
            if (file_exists($one)) {
                unlink($one);
            }
        }
    }
    /**
     * A Vorbis file carrying two channels decodes to the sound it was
     * made from. Two channels of music usually hold nearly the same
     * sound, so a stream may write one channel and how far the other
     * sits from it, and those two have to be turned back into a left
     * and a right before either can be heard.
     */
    public function twoChannelVorbisDecodesTestCase()
    {
        $held = file_get_contents(C\PARENT_DIR .
            "/tests/test_files/sound_stereo_ogg.txt");
        $path = C\WORK_DIRECTORY . "/temp/stereo" . getmypid() . ".ogg";
        file_put_contents($path, base64_decode(trim($held)));
        $read = VorbisDecoder::fromFile($path);
        $this->assertEqual(2, $read["decoder"]->header->channels,
            "the file says it carries two channels");
        $written = C\WORK_DIRECTORY . "/temp/stereo" . getmypid() .
            ".wav";
        $made = AudioConverter::convert($path, $written);
        $this->assertTrue($made["seconds"] > 0.9 && $made["seconds"] < 1.1,
            "and converts to about a second of sound");
        $samples = self::samplesOf($written);
        $loudest = 0.0;
        foreach ($samples as $sample) {
            $loudest = max($loudest, abs($sample));
        }
        $this->assertTrue($loudest > 0.05 && $loudest < 0.3,
            "at a loudness near what went in");
        $this->assertEqual(440, self::loudestTone($samples),
            "and the lower of its two tones comes back");
        foreach ([$path, $written] as $one) {
            if (file_exists($one)) {
                unlink($one);
            }
        }
    }
    /**
     * A Vorbis file of silence decodes to silence. Silence is the one
     * case where a Vorbis packet says outright that a channel carries
     * nothing, so decoding one checks the reading of a packet's shape
     * and its channels without depending on the tables of values.
     */
    public function silenceDecodesToSilenceTestCase()
    {
        $held = file_get_contents(C\PARENT_DIR .
            "/tests/test_files/sound_silence_ogg.txt");
        $path = C\WORK_DIRECTORY . "/temp/silence" . getmypid() . ".ogg";
        file_put_contents($path, base64_decode(trim($held)));
        $read = VorbisDecoder::fromFile($path);
        $loudest = 0.0;
        $count = 0;
        foreach ($read["pieces"] as $packet) {
            $run = $read["decoder"]->decodePacket($packet);
            foreach ($run as $sample) {
                $loudest = max($loudest, abs($sample));
            }
            $count += count($run);
        }
        $this->assertTrue($count > 40000,
            "every packet of the file gives samples");
        $this->assertEqual(0.0, $loudest,
            "and every one of those samples is silent");
        if (file_exists($path)) {
            unlink($path);
        }
    }
    /**
     * loudestTone says which tone is loudest in a run of samples, by
     * measuring how strongly the run matches each tone in turn. A case
     * uses it to check that decoded sound holds the tone it was made
     * from.
     *
     * @param array $samples The samples to look through.
     * @return int The tone found, in cycles a second.
     */
    public static function loudestTone($samples)
    {
        $best = 0;
        $loudest = 0.0;
        for ($cycles = 200; $cycles <= 900; $cycles += 5) {
            $real = 0.0;
            $sideways = 0.0;
            for ($at = 5000; $at < 15000 && $at < count($samples); $at++) {
                $angle = 2 * M_PI * $cycles * $at / 48000;
                $real += $samples[$at] * cos($angle);
                $sideways += $samples[$at] * sin($angle);
            }
            $size = sqrt($real * $real + $sideways * $sideways);
            if ($size > $loudest) {
                $loudest = $size;
                $best = $cycles;
            }
        }
        return $best;
    }
    /**
     * Sound already compressed the way an MP4 holds it is carried
     * across as it stands. Decoding it and compressing it again would
     * lose a little each time and take far longer, so the pieces are
     * copied and only the boxes around them are written afresh.
     */
    public function compressedSoundIsCarriedAcrossTestCase()
    {
        $first = C\WORK_DIRECTORY . "/temp/first" . getmypid() . ".m4a";
        AudioConverter::convert($this->recordings["tiny_tone_webm"],
            $first);
        $again = C\WORK_DIRECTORY . "/temp/again" . getmypid() . ".m4a";
        $made = AudioConverter::convert($first, $again);
        $there = AudioConverter::soundOfVideo($first);
        $here = AudioConverter::soundOfVideo($again);
        $this->assertEqual("aac", $here["codec"],
            "what was written is still compressed the same way");
        $this->assertEqual(count($there["pieces"]),
            count($here["pieces"]),
            "with the same number of pieces of sound");
        $this->assertEqual(implode("", $there["pieces"]),
            implode("", $here["pieces"]),
            "and the pieces themselves are unchanged");
        $this->assertTrue($made["seconds"] > 0.3
            && $made["seconds"] < 0.34,
            "and it runs for as long as it did");
        foreach ([$first, $again] as $path) {
            if (file_exists($path)) {
                unlink($path);
            }
        }
    }
    /**
     * Sound written as a WAV or an AIFF can be read back with the same
     * length and loudness, and either can be turned into an MP4. A
     * recording that came out of a browser and a file an editor wrote
     * both go through the same reading, so any of the kinds this
     * handles converts to any other.
     */
    public function eachKindConvertsToTheOthersTestCase()
    {
        $wav = C\WORK_DIRECTORY . "/temp/plain" . getmypid() . ".wav";
        $aiff = C\WORK_DIRECTORY . "/temp/plain" . getmypid() . ".aiff";
        $made = AudioConverter::convert(
            $this->recordings["tiny_tone_webm"], $wav);
        $this->assertEqual(15360, $made["samples"],
            "a recording is written out as a WAV, sample for sample");
        $this->assertEqual("wav", PlainSound::kindOf($wav),
            "and the file says it is a WAV");
        $made = AudioConverter::convert($wav, $aiff);
        $this->assertEqual("aiff", PlainSound::kindOf($aiff),
            "a WAV is written out as an AIFF");
        $said = PlainSound::describe($aiff, "aiff");
        $this->assertEqual(48000, $said["rate"],
            "which keeps the rate the sound was recorded at");
        $made = AudioConverter::convert($aiff, $this->written);
        $written = file_get_contents($this->written);
        $this->assertEqual("ftypM4A ", substr($written, 4, 8),
            "and an AIFF is written out as an MP4");
        foreach ([$wav, $aiff] as $path) {
            if (file_exists($path)) {
                unlink($path);
            }
        }
    }
    /**
     * A recording is converted a couple of seconds at a time, so
     * nothing large is written beside the output and the memory a
     * conversion needs does not grow with the length of the recording.
     * This case runs for about a tenth of a second, since it decodes
     * and compresses a real recording.
     */
    public function nothingIsWrittenBesideTheOutputTestCase()
    {
        $beside = glob(C\WORK_DIRECTORY . "/temp/*.samples");
        $this->assertEqual([], $beside,
            "nothing is left over from an earlier conversion");
        $before = memory_get_peak_usage(true);
        AudioConverter::toM4a($this->recordings["tiny_tone_webm"],
            "webm", $this->written);
        $beside = glob(dirname($this->written) . "/*.samples");
        $this->assertEqual([], $beside,
            "no file of samples is written beside the output");
        $this->assertTrue(memory_get_peak_usage(true) - $before <
            self::MEMORY_ROOM,
            "and the memory it needs stays within a few megabytes");
    }
}
X