<?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
*
* The audio half of AVTool: reading a recording a browser made, saying
* what is inside it, and writing it out as a file every browser plays.
*/
namespace seekquarry\yioop\library\av_processing;
/**
* AudioConverter reads a recording made by a browser and writes it out
* in a kind every browser plays. A browser records Opus inside an Ogg
* or a WebM file, which Safari will not play, so a site that keeps
* voice messages has to turn each recording into an MP4 holding AAC.
* Yioop calls this when a message is saved, and AVTool calls it from
* the command line.
*
* @author Chris Pollett
*/
class AudioConverter
{
/**
* FULL_SCALE is the value a sample takes at the loudest a recording
* can hold. The decoder hands back whole numbers up to this, and
* the encoder wants a fraction of one, so each sample is divided by
* it on the way through.
* @var int
*/
/**
* OPUS_PLAY_RATE is how many samples a second a recording made with
* the Opus audio codec plays at, whatever rate its sound was
* recorded at. The standard fixes this.
*/
const OPUS_PLAY_RATE = 48000;
const FULL_SCALE = 32768;
/**
* SAMPLES_A_PIECE is how many decoded samples are gathered before
* they go to the encoder. Two seconds of sound at a time keeps the
* memory a conversion needs flat, whatever the length of the
* recording.
* @var int
*/
const SAMPLES_A_PIECE = 96000;
/**
* RECORDED_BITS_PER_SECOND is how many bits a second the written
* file aims at. Speech at one channel sounds clean at this rate and
* the file stays small enough to send.
* @var int
*/
const RECORDED_BITS_PER_SECOND = 32000;
/**
* convert reads one sound file and writes another, choosing what to
* read and what to write from the bytes of the input and the name
* of the output. Sound passes through a couple of seconds at a
* time, so a file of any length is converted without a large file
* beside it.
*
* @param string $path The file to read.
* @param string $out Where to write the converted sound.
* @return array How many samples were written under the key
* samples, and how long they run under the key seconds.
*/
public static function convert($path, $out)
{
$writing = self::kindWanted($out);
if ($writing === "") {
throw new \RuntimeException("a sound file is written as "
. ".m4a, .mp4, .wav or .aiff");
}
$reading = self::readingKind($path);
/* Sound already compressed the way an MP4 wants it is copied
across rather than decoded and compressed again, which is
quicker and loses nothing. */
if ($writing === "m4a" && $reading === "video") {
$found = self::soundOfVideo($path);
if ($found["codec"] === "aac") {
return self::copySound($found, $out);
}
}
if ($writing === "m4a") {
return self::toM4a($path, $reading, $out);
}
return self::toPlain($path, $out, $writing);
}
/**
* copySound writes the sound track of a video into a file of its
* own without decoding it. Where the sound is already compressed
* the way an MP4 holds it, the pieces are carried across as they
* stand, so nothing is lost and no decoder is needed.
*
* @param array $found The sound track, as soundOfVideo gave it.
* @param string $out Where to write the file.
* @return array How many samples were written under the key
* samples, and how long they run under the key seconds.
*/
public static function copySound($found, $out)
{
if (empty($found["pieces"])) {
/* A file with no sound in it plays in no browser, and one
written anyway would replace a recording that at least
played where it was made. */
throw new \RuntimeException("no sound was found in that "
. "recording");
}
$rate = self::rateWithin($found["setup"]);
Mp4Writer::writeCompressed($out, $found["pieces"], $rate,
$found["channels"]);
$samples = count($found["pieces"]) * Mp4Writer::FRAME_SAMPLES;
return ["samples" => $samples,
"seconds" => round($samples / max(1, $rate), 3)];
}
/**
* rateWithin reads how many samples a second a compressed sound
* track runs at, out of the settings its file carries. The settings
* name the rate by its place in a list the standard fixes rather
* than writing the rate itself.
*
* @param string $setup The settings, as the file stored them.
* @return int How many samples a second, or forty-eight thousand
* where the settings do not say.
*/
public static function rateWithin($setup)
{
if (strlen($setup) < 2) {
return OpusDecoder::SAMPLE_RATE;
}
$first = ord($setup[0]);
$second = ord($setup[1]);
$named = (($first & 0x07) << 1) | (($second >> 7) & 0x01);
$listed = Mp4Writer::LISTED_RATES;
return $listed[$named] ?? OpusDecoder::SAMPLE_RATE;
}
/**
* soundOfVideo hands back the sound track of a video file: the
* pieces of compressed sound and the name of the codec that
* compressed them. Somebody who wants the sound of a video would
* otherwise have to decode its pictures to reach it.
*
* @param string $path The video to read.
* @return array What the sound track holds, keyed by codec, pieces,
* channels and setup.
*/
public static function soundOfVideo($path)
{
$kind = SoundTrack::kindOf($path);
if ($kind === "") {
throw new \RuntimeException(basename($path) . " is not a "
. "video file this reads");
}
return SoundTrack::fromFile($path, $kind);
}
/**
* readingKind says which kind of sound a file holds, whether it is
* a recording a browser made or a file that keeps its samples as
* they are. A caller needs the answer before it can read a single
* sample.
*
* @param string $path The file to look at.
* @return string The word ogg, webm, wav or aiff.
*/
public static function readingKind($path)
{
$found = self::soundKind($path);
if ($found !== "") {
return $found;
}
$found = PlainSound::kindOf($path);
if ($found !== "") {
return $found;
}
/* A video carries its sound beside its pictures, and the sound
is read out of it the same way a recording is read. */
$video = SoundTrack::kindOf($path);
if ($video !== "") {
return "video";
}
throw new \RuntimeException(basename($path) . " is not a sound "
. "file this reads");
}
/**
* kindWanted says which kind of file a name asks for, from the
* letters after its last dot. A caller uses it to choose what to
* write before it reads anything.
*
* @param string $out The name of the file to write.
* @return string The word m4a, wav or aiff, or an empty string
* where the name asks for something else.
*/
public static function kindWanted($out)
{
$ending = strtolower(substr($out, strrpos($out, ".") ?: 0));
if ($ending === ".m4a" || $ending === ".mp4") {
return "m4a";
}
if ($ending === ".wav") {
return "wav";
}
if ($ending === ".aiff" || $ending === ".aif") {
return "aiff";
}
return "";
}
/**
* eachPiece hands back the samples of any sound file this reads, a
* couple of seconds at a time, as fractions of one. A caller can
* write them wherever it likes without knowing which kind of file
* they came out of.
*
* @param string $path The file to read.
* @param string $kind The word ogg, webm, wav or aiff.
* @return Generator Runs of samples.
*/
public static function eachPiece($path, $kind)
{
if ($kind === "wav" || $kind === "aiff") {
foreach (PlainSound::eachPiece($path, $kind) as $run) {
yield $run;
}
return;
}
/* An Ogg file may carry Vorbis instead of Opus, and the two are
read by different decoders, so which one is settled by what
the first packet names itself. */
if ($kind === "ogg" && SoundTrack::fromOgg($path)["codec"]
=== "vorbis") {
$read = VorbisDecoder::fromFile($path);
foreach ($read["decoder"]->eachPacket($read["pieces"])
as $run) {
yield $run;
}
return;
}
if ($kind === "video") {
$found = self::soundOfVideo($path);
if ($found["codec"] === "aac") {
$decoder = new AacDecoder();
foreach ($found["pieces"] as $piece) {
$run = $decoder->decodeFrame($piece);
yield $run;
}
return;
}
if ($found["codec"] !== "opus") {
throw new \RuntimeException("the sound of this video is "
. $found["codec"] . ", which is not decoded yet");
}
$read = ["pieces" => $found["pieces"], "header" => null];
$channels = $found["channels"];
} else {
$read = self::packetsOf($path, $kind);
$channels = ($read["header"] !== null) ?
$read["header"]->channel_count : 1;
}
$stretches = [];
foreach ($read["pieces"] as $piece) {
try {
$sound = OpusPacket::fromString($piece);
} catch (\Exception $trouble) {
continue;
}
if ($sound->method != OpusPacket::MUSIC_METHOD) {
/* A browser records a voice the way built for speech,
which a different decoder reads. The whole file goes
to that decoder rather than this loop, since it keeps
what one stretch leaves the next. */
foreach (self::speechOf($path, $read["header"],
$read["pieces"]) as $run) {
yield $run;
}
return;
}
foreach ($sound->stretches as $stretch) {
if (strlen($stretch) >= 2) {
$stretches[] = $stretch;
}
}
}
$waiting = [];
foreach (OpusDecoder::eachStretch($stretches, $channels)
as $settled) {
foreach ($settled as $sample) {
$waiting[] = self::holdInRange($sample /
self::FULL_SCALE);
}
if (count($waiting) >= self::SAMPLES_A_PIECE) {
yield $waiting;
$waiting = [];
}
}
if ($waiting !== []) {
yield $waiting;
}
}
/**
* toPlain writes a sound file that keeps its samples as they are,
* reading whatever kind of file it was given a couple of seconds at
* a time. A WAV or an AIFF plays anywhere and can be edited, where
* the compressed kinds cannot.
*
* @param string $path The file to read.
* @param string $out Where to write the file.
* @param string $writing The word wav or aiff.
* @return array How many samples were written under the key
* samples, and how long they run under the key seconds.
*/
public static function toPlain($path, $out, $writing)
{
$reading = self::readingKind($path);
$rate = OpusDecoder::SAMPLE_RATE;
if ($reading === "wav" || $reading === "aiff") {
$said = PlainSound::readHeader($path, $reading);
$rate = max(1, $said["rate"]);
}
$handle = PlainSound::startWriting($out, $writing, $rate);
$written = 0;
foreach (self::eachPiece($path, $reading) as $run) {
$written += PlainSound::addPiece($handle, $run, $writing);
}
if ($written < 1) {
throw new \RuntimeException("no sound was found in that "
. "recording");
}
PlainSound::finishWriting($handle, $out, $writing, $rate,
$written);
return ["samples" => $written,
"seconds" => round($written / $rate, 3)];
}
/**
* soundKind says which kind of recording a file holds, reading the
* first bytes rather than trusting the name. A caller uses it to
* decide whether a file is a recording at all before opening it.
*
* @param string $path The file to look at.
* @return string The word ogg or webm, or an empty string where the
* file is neither.
*/
public static function soundKind($path)
{
$handle = @fopen($path, "rb");
if ($handle === false) {
return "";
}
$head = fread($handle, 4);
fclose($handle);
if ($head === "OggS") {
return "ogg";
}
if ($head === "\x1A\x45\xDF\xA3") {
return "webm";
}
return "";
}
/**
* packetsOf reads every packet of compressed sound out of a
* recording, along with what the recording says about itself. A
* caller needs both before it can decode anything.
*
* @param string $path The recording to read.
* @param string $kind The word ogg or webm, as soundKind gave it.
* @return array The packets under the key pieces, and the
* recording's own description under the key header.
*/
public static function packetsOf($path, $kind)
{
$pieces = [];
$header = null;
if ($kind == "ogg") {
$reader = OggDemuxer::fromName($path);
$seen = 0;
foreach ($reader->packets() as $piece) {
$seen++;
if ($seen == 1) {
$header = OpusHeader::fromString($piece->data);
continue;
}
if ($seen == 2) {
continue;
}
$pieces[] = $piece->data;
}
} else {
$reader = WebmDemuxer::fromName($path);
foreach ($reader->packets() as $piece) {
$pieces[] = $piece->data;
}
if ($reader->codec_setup !== "") {
$header = OpusHeader::fromString($reader->codec_setup);
}
}
return ["pieces" => $pieces, "header" => $header];
}
/**
* describe says what a recording holds: how it is stored, how many
* packets of sound it carries, how long it runs and how many
* channels it was recorded in. A caller prints this when somebody
* asks what is inside a file.
*
* @param string $path The recording to look at.
* @param string $kind The word ogg or webm, as soundKind gave it.
* @return array What the recording says about itself, keyed by
* container, packets, seconds and channels.
*/
public static function describe($path, $kind)
{
if ($kind === "ogg") {
$found = SoundTrack::fromOgg($path);
if ($found["codec"] === "vorbis") {
$said = VorbisHeader::fromString(
OggDemuxer::fromName($path)->packets()
->current()->data);
return ["container" => "ogg", "codec" => "vorbis",
"packets" => count($found["pieces"]),
"rate" => $said->rate,
"channels" => $said->channels];
}
}
$read = self::packetsOf($path, $kind);
$seconds = 0.0;
foreach ($read["pieces"] as $piece) {
try {
$sound = OpusPacket::fromString($piece);
} catch (\Exception $trouble) {
continue;
}
$seconds += $sound->duration() / 1000.0;
}
return ["container" => $kind,
"packets" => count($read["pieces"]),
"seconds" => round($seconds, 3),
"channels" => ($read["header"] !== null) ?
$read["header"]->channel_count : 1];
}
/**
* toM4a turns a recording into an MP4 file holding AAC, which every
* browser plays. The sound is read a couple of seconds at a time:
* each piece is decoded, handed to the encoder, and dropped, so a
* recording of any length is converted without a large file beside
* it and without holding the whole sound in memory.
*
* The samples keep the loudness the recording was made at. Anything
* past what a sample can hold is held at the edge, which a
* recording made by a browser never reaches.
*
* @param string $path The recording to read.
* @param string $kind The word ogg or webm, as soundKind gave it.
* @param string $out Where to write the MP4 file.
* @return array How many samples were written under the key
* samples, and how long they run under the key seconds.
*/
public static function toM4a($path, $kind, $out)
{
$encoder = new AacEncoder(self::RECORDED_BITS_PER_SECOND);
$written = 0;
foreach (self::eachPiece($path, $kind) as $run) {
$encoder->take($run);
$written += count($run);
}
$encoder->finish();
if ($written < 1 || $encoder->frames === []) {
/* A file with no sound in it plays in no browser, and one
written anyway would replace a recording that at least
played where it was made. */
throw new \RuntimeException("no sound was found in that "
. "recording");
}
Mp4Writer::writeCompressed($out, $encoder->frames,
OpusDecoder::SAMPLE_RATE, 1);
return ["samples" => $written,
"seconds" => round($written / OpusDecoder::SAMPLE_RATE, 3)];
}
/**
* speechOf gives the samples of a recording made the way built for
* speech, at the rate the file says it holds. A browser records a
* voice message this way, so this is the path a message takes when
* a wiki page saves it.
*
* @param string $path the file holding the recording
* @param object $header what the file says about itself, or null
* where it says nothing; its lead in says how many samples to
* drop from the front
* @param array $pieces the recording's packets where the caller has
* already taken them out of the file, or null to open the file
* here; a recording held in a WebM file comes this way, since
* the speech decoder opens only Ogg files itself
* @return \Generator runs of samples, each between minus one and
* one
*/
public static function speechOf($path, $header = null,
$pieces = null)
{
/* The packets are handed over where the caller has them, since
a recording made by a browser may sit in a WebM file, which
the speech decoder does not open for itself. */
$dropping = ($header !== null) ?
intdiv($header->lead_in, intdiv(SpeechDecoder::HEADER_RATE,
SpeechDecoder::SAMPLE_RATE)) : 0;
$samples = ($pieces === null) ?
SpeechDecoder::samplesOfRecording($path) :
SpeechDecoder::samplesOfPackets($pieces, $dropping);
/* Opus always plays at forty-eight thousand samples a second
whatever rate the sound was recorded at, so that is the rate
the samples are raised to. */
$wanted = self::OPUS_PLAY_RATE;
$samples = SpeechDecoder::raisedToRate($samples,
SpeechDecoder::SAMPLE_RATE, $wanted);
$waiting = [];
foreach ($samples as $one) {
$waiting[] = self::holdInRange($one / self::FULL_SCALE);
if (count($waiting) >= self::SAMPLES_A_PIECE) {
yield $waiting;
$waiting = [];
}
}
if ($waiting !== []) {
yield $waiting;
}
}
/**
* holdInRange keeps a sample within the range the encoder works in,
* which runs from one below zero to one above it. A recording made
* loud can decode a little past that edge, and a value past the
* edge would wrap round to the opposite sign when written.
*
* @param float $sample One decoded sample, already divided by
* FULL_SCALE.
* @return float The sample, held at the edge where it went past.
*/
public static function holdInRange($sample)
{
if ($sample > 1.0) {
return 1.0;
}
if ($sample < -1.0) {
return -1.0;
}
return $sample;
}
}