/ src / library / av_processing / AudioConverter.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
 *
 * 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;
    }
}
X