/ src / library / av_processing / Mp4Writer.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\library\av_processing;

/**
 * Mp4Writer writes samples out as an .m4a file. An mp4 file, which is what .m4a
 * is, is built out of nested boxes. Each box says what it is, then how long it
 * is, then holds either more boxes or a run of data. Nearly all of them are
 * bookkeeping: how long the sound runs, how it is laid out, where each run of
 * samples begins. The sound itself sits in one box at the end. The samples are
 * written as they are, uncompressed. That makes a larger file than compressing
 * them would, about a megabyte for every ten seconds of one channel, and it
 * needs no encoder at all. An encoder that compresses them is the next piece of
 * work; when it lands, only the box saying how the sound is stored changes,
 * since everything else here is the same either way. The layout is set out in
 * ISO base media file format, the standard mp4 is built on.
 */
class Mp4Writer
{
    /**
     * FILE_TICKS is how many units of the file's clock pass in a second for the
     * file as a whole.
     */
    const FILE_TICKS = 1000;
    /**
     * SAMPLE_BYTES is how many bytes one sample of sound takes in the
     * file's own tables, which the writer fills in.
     */
    const SAMPLE_BYTES = 2;
    /**
     * SAMPLE_BITS is how many bits one sample of sound takes. The
     * file's sound description asks for the count in bits, while its
     * sample tables ask for it in bytes, so both are kept.
     */
    const SAMPLE_BITS = 16;
    /**
     * SAMPLE_LIMIT is the largest a sample may be once written as a whole
     * number.
     */
    /**
     * FILE_BRANDS names the shapes of file this one may be read as, four
     * letters to a name and no more. A name of any other length leaves
     * the opening box a length that is not a whole number of names, and
     * a strict player refuses the whole file rather than reading past
     * it.
     */
    const FILE_BRANDS = "M4A mp42isomM4B ";
    const SAMPLE_LIMIT = 32767;
    /**
     * SOUND_KIND is how the sound is stored, which for samples written as they
     * are with the lower byte first is this name.
     */
    const SOUND_KIND = "sowt";
    /**
     * BLOCK_BYTES is how much of a scratch file to read back at a time.
     */
    const BLOCK_BYTES = 65536;
    /**
     * write writes samples out as an .m4a file
     *
     * @param string $name what to call the file
     * @param array $wave the samples, each between minus one and one
     * @param int $rate how many samples a second
     * @param int $channels how many channels
     */
    public static function write($name, $wave, $rate = 48000, $channels = 1)
    {
        $count = intdiv(count($wave), $channels);
        $sound = "";
        foreach ($wave as $sample) {
            $whole = (int)round($sample * self::SAMPLE_LIMIT);
            $whole = max(-self::SAMPLE_LIMIT - 1,
                min(self::SAMPLE_LIMIT, $whole));
            $sound .= pack("v", $whole & 0xFFFF);
        }
        $length = intdiv($count * self::FILE_TICKS, $rate);
        $header = self::box("ftyp",
            "M4A " . pack("N", 512) . self::FILE_BRANDS);
        $tree = self::describe($count, $length, $rate, $channels,
            strlen($header), strlen($sound));
        file_put_contents($name,
            $header . $tree . self::box("mdat", $sound));
    }
    /**
     * COMPRESSED_KIND is how the sound is stored when it has been compressed.
     */
    const COMPRESSED_KIND = "mp4a";
    /**
     * COMPRESSION_FAMILY is which family of compression this is, as the
     * standard numbers them: sound compressed the way this encoder does.
     */
    const COMPRESSION_FAMILY = 0x40;
    /**
     * STREAM_IS_SOUND is what kind of stream this is, sound rather than
     * picture.
     */
    const STREAM_IS_SOUND = 0x15;
    /**
     * FRAME_SAMPLES is how many samples of sound each compressed frame stands
     * for.
     */
    const FRAME_SAMPLES = 1024;
    /**
     * RATE_48000 is which of the rates the standard lists 48000 samples a
     * second is.
     */
    const RATE_48000 = 3;
    /**
     * LISTED_RATES is every recording rate the standard lists, in the
     * order it numbers them. A file names its rate by that number
     * rather than writing the rate itself.
     * @var array
     */
    const LISTED_RATES = [96000, 88200, 64000, 48000, 44100, 32000,
        24000, 22050, 16000, 12000, 11025, 8000, 7350];
    /**
     * PLAIN_FORM is which family of compression the settings name, where two
     * means the plain form of it.
     */
    const PLAIN_FORM = 2;
    /**
     * writeCompressed writes compressed frames out as an .m4a file
     *
     * @param string $name what to call the file
     * @param array $frames each compressed frame, in order
     * @param int $rate how many samples a second
     * @param int $channels how many channels
     */
    public static function writeCompressed($name, $frames, $rate = 48000,
        $channels = 1)
    {
        $count = count($frames);
        $each = $count * self::FRAME_SAMPLES;
        $length = intdiv($each * self::FILE_TICKS, max(1, $rate));
        $sound = implode("", $frames);
        $sizes = [];
        foreach ($frames as $frame) {
            $sizes[] = strlen($frame);
        }
        $header = self::box("ftyp",
            "M4A " . pack("N", 512) . self::FILE_BRANDS);
        /* Where the sound begins is everything before it plus the
           eight bytes naming the box it sits in, and that eight is
           counted once, in the measuring below. */
        $tree = self::describeCompressed($count, $each, $length, $rate,
            $channels, $sizes, strlen($header), strlen($sound));
        file_put_contents($name,
            $header . $tree . self::box("mdat", $sound));
    }
    /**
     * describeCompressed builds every box describing compressed sound
     *
     * @param int $count how many frames there are
     * @param int $each how many samples of sound they stand for
     * @param int $length how long the sound runs on the file's clock
     * @param int $rate how many samples a second
     * @param int $channels how many channels
     * @param array $sizes how long each frame is
     * @param int $before how many bytes come before this
     * @param int $sound_size how many bytes the sound takes
     * @return string the boxes
     */
    public static function describeCompressed($count, $each, $length, $rate,
        $channels, $sizes, $before, $sound_size)
    {
        $movie = self::box("mvhd", pack("NNNNNN", 0, 0, 0,
            self::FILE_TICKS, $length, 0x00010000) .
            pack("n", 0x0100) . pack("n", 0) . pack("NN", 0, 0) .
            self::identityShape() . pack("NNNNNN", 0, 0, 0, 0, 0, 0) .
            pack("N", 2));
        $track = self::box("tkhd", pack("N", 0x0000000F) .
            pack("NNNNN", 0, 0, 1, 0, $length) . pack("NN", 0, 0) .
            pack("nn", 0, 0) . pack("n", 0x0100) . pack("n", 0) .
            self::identityShape() . pack("NN", 0, 0));
        $entry = str_repeat("\0", 6) . pack("n", 1) .
            pack("nn", 0, 0) . pack("N", 0) .
            pack("nn", $channels, self::SAMPLE_BITS) .
            pack("nn", 0, 0) . pack("N", $rate << 16) .
            self::settingsBox($rate, $channels);
        $kinds = self::box("stsd", pack("NN", 0, 1) .
            self::box(self::COMPRESSED_KIND, $entry));
        $times = self::box("stts", pack("NN", 0, 1) .
            pack("NN", $count, self::FRAME_SAMPLES));
        $runs = self::box("stsc", pack("NN", 0, 1) .
            pack("NNN", 1, $count, 1));
        $sizes_box = self::box("stsz", pack("NN", 0, 0) .
            pack("N", $count) . pack("N*", ...$sizes));
        $places = self::box("stco", pack("NN", 0, 1) .
            pack("N", $before + self::compressedSize($count, $each, $rate,
            $channels, $sizes)));
        $table = self::box("stbl", $kinds . $times . $runs . $sizes_box .
            $places);
        $inside = self::box("minf",
            self::box("smhd", pack("N", 0) . pack("nn", 0, 0)) .
            self::box("dinf", self::box("dref", pack("NN", 0, 1) .
            self::box("url ", pack("N", 1)))) . $table);
        $media = self::box("mdia",
            self::box("mdhd", pack("NNNNN", 0, 0, 0, $rate, $each) .
                pack("nn", 0x55C4, 0)) .
            self::box("hdlr", pack("NN", 0, 0) . "soun" .
                pack("NNN", 0, 0, 0) . "SoundHandler\0") . $inside);
        return self::box("moov", $movie . self::box("trak",
            $track . $media));
    }
    /**
     * compressedSize how long the boxes describing compressed sound come to
     *
     * @param int $count how many frames there are
     * @param int $each how many samples they stand for
     * @param int $rate how many samples a second
     * @param int $channels how many channels
     * @param array $sizes how long each frame is
     * @return int how many bytes those boxes take
     */
    public static function compressedSize($count, $each, $rate, $channels,
        $sizes)
    {
        static $sizes_seen = [];
        $key = $count . ":" . $rate . ":" . $channels;
        if (isset($sizes_seen[$key])) {
            return $sizes_seen[$key];
        }
        $sizes_seen[$key] = 0;
        $length = intdiv($each * self::FILE_TICKS, max(1, $rate));
        $built = self::describeCompressed($count, $each, $length, $rate,
            $channels, $sizes, 0, 0);
        $sizes_seen[$key] = strlen($built) + 8;
        return $sizes_seen[$key];
    }
    /**
     * listedRate says which of the rates the standard lists a recording
     * rate is. A player reads that number rather than the rate itself,
     * so a file recorded at a rate the standard does not list cannot
     * name it and is written as the nearest listed one.
     *
     * @param int $rate How many samples a second the sound runs at.
     * @return int Which listed rate that is, counting from zero.
     */
    public static function listedRate($rate)
    {
        $at = array_search($rate, self::LISTED_RATES, true);
        if ($at !== false) {
            return $at;
        }
        $nearest = 0;
        $away = PHP_INT_MAX;
        foreach (self::LISTED_RATES as $index => $listed) {
            if (abs($listed - $rate) < $away) {
                $away = abs($listed - $rate);
                $nearest = $index;
            }
        }
        return $nearest;
    }
    /**
     * settingsBox the box holding the settings a player needs before it can
     * make sense of the compressed sound: which family of compression, at what
     * rate, and how many channels
     *
     * @param int $rate how many samples a second
     * @param int $channels how many channels
     * @return string the box
     */
    public static function settingsBox($rate, $channels)
    {
        $writer = new BitWriter();
        $writer->add(self::PLAIN_FORM, 5);
        $writer->add(self::listedRate($rate), 4);
        $writer->add($channels, 4);
        $writer->add(0, 3);
        $settings = $writer->finish();
        $inner = self::tagged(5, $settings);
        $decoder = self::tagged(4, chr(self::COMPRESSION_FAMILY) .
            chr(self::STREAM_IS_SOUND) . str_repeat("\0", 3) .
            pack("NN", 0, 0) . $inner);
        $stream = self::tagged(3, pack("n", 0) . chr(0) . $decoder .
            self::tagged(6, chr(2)));
        return self::box("esds", pack("N", 0) . $stream);
    }
    /**
     * tagged wraps a run of data in a numbered description of the kind the
     * settings box is built from
     *
     * @param int $tag which kind of description
     * @param string $body what it holds
     * @return string the description
     */
    public static function tagged($tag, $body)
    {
        return chr($tag) . chr(strlen($body)) . $body;
    }
    /**
     * Wraps a run of data in a box of a given name
     *
     * @param string $kind the four letters naming the box
     * @param string $body what the box holds
     * @return string the box
     */
    public static function box($kind, $body)
    {
        return pack("N", strlen($body) + 8) . $kind . $body;
    }
    /**
     * describe builds every box describing the sound, which is everything in
     * the file but the sound itself
     *
     * @param int $count how many samples there are
     * @param int $length how long the sound runs on the file's clock
     * @param int $rate how many samples a second
     * @param int $channels how many channels
     * @param int $before how many bytes come before this
     * @param int $sound_size how many bytes the sound takes
     * @return string the boxes
     */
    public static function describe($count, $length, $rate, $channels,
        $before, $sound_size)
    {
        $movie = self::box("mvhd", pack("NNNNNN", 0, 0, 0,
            self::FILE_TICKS, $length, 0x00010000) .
            pack("n", 0x0100) . pack("n", 0) . pack("NN", 0, 0) .
            self::identityShape() . pack("NNNNNN", 0, 0, 0, 0, 0, 0) .
            pack("N", 2));
        $track = self::box("tkhd", pack("N", 0x0000000F) .
            pack("NNNNN", 0, 0, 1, 0, $length) . pack("NN", 0, 0) .
            pack("nn", 0, 0) . pack("n", 0x0100) . pack("n", 0) .
            self::identityShape() . pack("NN", 0, 0));
        $sound_head = self::box("smhd", pack("N", 0) . pack("nn", 0, 0));
        $where = self::box("dinf", self::box("dref",
            pack("NN", 0, 1) . self::box("url ", pack("N", 1))));
        $table = self::soundTable($count, $rate, $channels, $before,
            $sound_size);
        $inside = self::box("minf", $sound_head . $where . $table);
        $media = self::box("mdia",
            self::box("mdhd", pack("NNNNN", 0, 0, 0, $rate, $count) .
                pack("nn", 0x55C4, 0)) .
            self::box("hdlr", pack("NN", 0, 0) . "soun" .
                pack("NNN", 0, 0, 0) . "SoundHandler\0") . $inside);
        return self::box("moov", $movie . self::box("trak",
            $track . $media));
    }
    /**
     * soundTable the tables saying how the sound is stored and where each run
     * of it begins
     *
     * @param int $count how many samples there are
     * @param int $rate how many samples a second
     * @param int $channels how many channels
     * @param int $before how many bytes come before the sound
     * @param int $sound_size how many bytes the sound takes
     * @return string the boxes holding those tables
     */
    public static function soundTable($count, $rate, $channels, $before,
        $sound_size)
    {
        /* Six bytes held back, then which description this uses,
           then eight more held back, then the sound's own facts. */
        $entry = str_repeat("\0", 6) . pack("n", 1) .
            pack("nn", 0, 0) . pack("N", 0) .
            pack("nn", $channels, self::SAMPLE_BITS) .
            pack("nn", 0, 0) . pack("N", $rate << 16);
        $kinds = self::box("stsd", pack("NN", 0, 1) .
            self::box(self::SOUND_KIND, $entry));
        /* Every sample lasts the same one tick of the sound's own
           clock, so one entry covers all of them. */
        $times = self::box("stts", pack("NN", 0, 1) . pack("NN", $count, 1));
        $runs = self::box("stsc", pack("NN", 0, 1) .
            pack("NNN", 1, $count, 1));
        $sizes = self::box("stsz", pack("NN", 0,
            $channels * self::SAMPLE_BYTES) . pack("N", $count));
        /* Where the sound begins is only known once everything before
           it has been built, which is why it is passed in. */
        $places = self::box("stco", pack("NN", 0, 1) .
            pack("N", $before + self::describeSize($count, $rate,
            $channels, $sound_size)));
        return self::box("stbl", $kinds . $times . $runs . $sizes .
            $places);
    }
    /**
     * describeSize how long the describing boxes come to, worked out by
     * building them once with a stand in for the place the sound begins
     *
     * @param int $count how many samples there are
     * @param int $rate how many samples a second
     * @param int $channels how many channels
     * @param int $sound_size how many bytes the sound takes
     * @return int how many bytes those boxes take
     */
    public static function describeSize($count, $rate, $channels,
        $sound_size)
    {
        static $sizes = [];
        $key = $count . ":" . $rate . ":" . $channels;
        if (isset($sizes[$key])) {
            return $sizes[$key];
        }
        /* The place the sound begins sits inside the very boxes whose
           length decides it, so the boxes are built once with nothing
           in that place to measure them, and again with it filled in.
           Their length does not change, since the place is a fixed
           four bytes either way. */
        $sizes[$key] = 0;
        $length = intdiv($count * self::FILE_TICKS, max(1, $rate));
        $built = self::describe($count, $length, $rate, $channels, 0,
            $sound_size);
        $sizes[$key] = strlen($built) + 8;
        return $sizes[$key];
    }
    /**
     * identityShape the nine numbers saying a track is not turned or stretched,
     * which every mp4 carries whether it means anything or not
     *
     * @return string those nine numbers
     */
    public static function identityShape()
    {
        return pack("NNNNNNNNN", 0x00010000, 0, 0, 0, 0x00010000, 0, 0, 0,
            0x40000000);
    }
}
X