<?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");
}
}