/ src / library / av_processing / SoundTrack.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
 *
 * SoundTrack finds the sound inside a video file and hands back its
 * pieces, so that sound can be converted without the pictures.
 */
namespace seekquarry\yioop\library\av_processing;

/**
 * SoundTrack finds the sound track of a video file and hands back the
 * pieces of compressed sound it holds, along with the name of the codec
 * that compressed them. A video carries its pictures and its sound
 * side by side, and somebody who wants only the sound would otherwise
 * have to decode the pictures to reach it.
 *
 * AudioConverter uses this to turn the sound of a video into a file
 * that plays on its own. Which codec the sound uses decides whether it
 * can be decoded here: Opus can, and the others are named in what this
 * class hands back so a caller can say what is missing.
 *
 * @author Chris Pollett
 */
class SoundTrack
{
    /**
     * SAMPLE_TABLE_PATH names the boxes an MP4 keeps its sample tables
     * in, walked in this order from a track to reach where its pieces
     * of sound sit.
     * @var array
     */
    const SAMPLE_TABLE_PATH = ['mdia', 'minf', 'stbl'];
    /**
     * WEBM_AUDIO_TRACK is the number a Matroska or WebM file uses to
     * say that a track carries sound rather than pictures.
     * @var int
     */
    const WEBM_AUDIO_TRACK = 2;
    /**
     * kindOf says which kind of file holds the video, reading its first
     * bytes rather than trusting its name. A caller needs the answer
     * before it can look for a sound track.
     *
     * @param string $path The file to look at.
     * @return string The word mp4, webm, avi or ogg, or an empty
     *     string where the file is none of those.
     */
    /**
     * PART_HEADING is the four letters naming the heading of one part
     * of a recording written in parts as it was made.
     */
    const PART_HEADING = "moof";
    /**
     * PART_TRACK is the four letters naming, inside a part's heading,
     * the piece of it that belongs to one track.
     */
    const PART_TRACK = "traf";
    /**
     * PIECE_RUN is the four letters naming a run of pieces inside a
     * part's track, which gives the length of each piece.
     */
    const PIECE_RUN = "trun";
    /**
     * BOX_HEADER_LENGTH is how many bytes stand at the front of a box
     * before its contents: four saying how long it is and four naming
     * what it is.
     */
    const BOX_HEADER_LENGTH = 8;
    /**
     * RUN_GIVES_START is the bit a run sets when it says where in the
     * part its first piece begins.
     */
    const RUN_GIVES_START = 0x000001;
    /**
     * RUN_GIVES_FIRST_KIND is the bit a run sets when it says
     * separately what kind its first piece is.
     */
    const RUN_GIVES_FIRST_KIND = 0x000004;
    /**
     * RUN_GIVES_HOW_LONG is the bit a run sets when each piece says how
     * long it plays for.
     */
    const RUN_GIVES_HOW_LONG = 0x000100;
    /**
     * RUN_GIVES_LENGTH is the bit a run sets when each piece says how
     * many bytes it takes, which is what a reader needs to cut the
     * block into pieces.
     */
    const RUN_GIVES_LENGTH = 0x000200;
    /**
     * RUN_GIVES_KIND is the bit a run sets when each piece says what
     * kind it is.
     */
    const RUN_GIVES_KIND = 0x000400;
    /**
     * RUN_GIVES_OFFSET is the bit a run sets when each piece says how
     * far its playing time sits from its writing time.
     */
    const RUN_GIVES_OFFSET = 0x000800;
    /**
     * kindOf says which kind of file this is, reading the first bytes
     * rather than trusting the name. A caller needs the answer before
     * it can look for a sound track at all.
     *
     * @param string $path The file to look at.
     * @return string The word mp4, webm or ogg, or an empty string
     *     where the file is none of those.
     */
    public static function kindOf($path)
    {
        $handle = @fopen($path, "rb");
        if ($handle === false) {
            return "";
        }
        $head = fread($handle, 12);
        fclose($handle);
        if (strlen($head) < 12) {
            return "";
        }
        if (substr($head, 4, 4) === "ftyp") {
            return "mp4";
        }
        if (substr($head, 0, 4) === "\x1A\x45\xDF\xA3") {
            return "webm";
        }
        if (substr($head, 0, 4) === "RIFF"
            && substr($head, 8, 4) === "AVI ") {
            return "avi";
        }
        if (substr($head, 0, 4) === "OggS") {
            return "ogg";
        }
        return "";
    }
    /**
     * fromFile finds the sound track of a video file and hands back
     * what it holds. Nothing is decoded, so this is quick whatever the
     * length of the video.
     *
     * @param string $path The video to read.
     * @param string $kind The word mp4, webm or ogg, as kindOf gave it.
     * @return array The codec's name under the key codec, the pieces of
     *     compressed sound under the key pieces, how many channels
     *     under the key channels, and the settings the codec needs
     *     under the key setup.
     */
    public static function fromFile($path, $kind)
    {
        if ($kind === "mp4") {
            $mp4_reader = new Mp4SoundReader($path);
            return $mp4_reader->read();
        }
        if ($kind === "webm") {
            return self::fromWebm($path);
        }
        if ($kind === "ogg") {
            return self::fromOgg($path);
        }
        throw new \RuntimeException("no sound can be read out of a "
            . "$kind file yet");
    }
    /**
     * fromWebm walks a Matroska or WebM file to its sound track and
     * gathers the frames of compressed sound it holds.
     *
     * @param string $path The video to read.
     * @return array What the sound track holds, keyed by codec, pieces,
     *     channels and setup.
     */
    public static function fromWebm($path)
    {
        $reader = new WebmDemuxer(file_get_contents($path));
        $pieces = [];
        foreach ($reader->packets() as $piece) {
            $pieces[] = $piece->data;
        }
        return ["codec" => self::plainName($reader->codec_name),
            "pieces" => $pieces,
            "channels" => max(1, $reader->channel_count),
            "setup" => $reader->codec_setup];
    }
    /**
     * fromOgg walks an Ogg file to its sound stream and gathers the
     * packets it holds. An Ogg file may carry a video stream beside the
     * sound, and the reader keeps the streams apart by the number each
     * page names.
     *
     * @param string $path The video to read.
     * @return array What the sound track holds, keyed by codec, pieces,
     *     channels and setup.
     */
    public static function fromOgg($path)
    {
        $reader = OggDemuxer::fromName($path);
        $packets = [];
        foreach ($reader->packets() as $piece) {
            $packets[] = $piece->data;
        }
        if ($packets === []) {
            throw new \RuntimeException("this Ogg file carries no "
                . "sound");
        }
        /* An Ogg file may carry Opus or Vorbis, and the first packet
           names which by the word that follows its first byte. */
        if (substr($packets[0], 1, 6) === VorbisHeader::MARK) {
            $said = VorbisHeader::fromString($packets[0]);
            return ["codec" => "vorbis",
                "pieces" => array_slice($packets, 3),
                "channels" => $said->channels,
                "setup" => $packets[2] ?? ""];
        }
        $header = OpusHeader::fromString($packets[0]);
        return ["codec" => "opus",
            "pieces" => array_slice($packets, 2),
            "channels" => $header->channel_count, "setup" => ""];
    }
    /**
     * plainName turns the name a file gives its codec into the short
     * word this folder uses. A Matroska file writes a name such as
     * A_OPUS, and an MP4 writes four letters, where a reader here wants
     * one word.
     *
     * @param string $named The name as the file wrote it.
     * @return string The word opus, vorbis, aac, mp3, or the name as it
     *     stood where none of those fit.
     */
    public static function plainName($named)
    {
        $lowered = strtolower($named);
        if (strpos($lowered, "opus") !== false) {
            return "opus";
        }
        if (strpos($lowered, "vorbis") !== false) {
            return "vorbis";
        }
        if (strpos($lowered, "aac") !== false
            || strpos($lowered, "mp4a") !== false) {
            return "aac";
        }
        if (strpos($lowered, "mp3") !== false
            || strpos($lowered, "mpeg/l3") !== false) {
            return "mp3";
        }
        return $lowered;
    }
}

/**
 * Mp4SoundReader walks the boxes of an MP4 file to its sound track and
 * reads the pieces of compressed sound out of it. An MP4 keeps its
 * sound in one place and a set of tables saying where each piece of it
 * begins, so the pieces are gathered from those tables rather than by
 * decoding.
 *
 * @author Chris Pollett
 */
class Mp4SoundReader
{
    use ByteSource;
    /**
     * $boxes stores the boxes found at the top of the file, each with
     * the name it gives itself and where it begins and ends.
     * @var array
     */
    private array $boxes = [];
    /**
     * __construct opens the video and reads the boxes at the top of it.
     * Nothing is read out of the sound track until read is called.
     *
     * @param string $path The video to read.
     */
    public function __construct($path)
    {
        $this->openSource($path);
        $this->boxes = $this->boxesWithin(0, $this->sourceSize());
    }
    /**
     * read finds the sound track and hands back what it holds.
     *
     * @return array What the sound track holds, keyed by codec, pieces,
     *     channels and setup.
     */
    public function read()
    {
        $movie = $this->named($this->boxes, "moov");
        if ($movie === null) {
            throw new \RuntimeException("this MP4 file holds no movie");
        }
        $tracks = $this->boxesWithin($movie["start"], $movie["end"]);
        foreach ($tracks as $box) {
            if ($box["type"] !== "trak") {
                continue;
            }
            if ($this->carriesSound($box)) {
                return $this->readTrack($box);
            }
        }
        throw new \RuntimeException("this MP4 file has no sound track");
    }
    /**
     * carriesSound says whether a track holds sound, by reading the box
     * in which a track names what it carries.
     *
     * @param array $box The track, with where it begins and ends.
     * @return bool True where the track carries sound.
     */
    public function carriesSound($box)
    {
        $media = $this->named($this->boxesWithin($box["start"],
            $box["end"]), "mdia");
        if ($media === null) {
            return false;
        }
        $handler = $this->named($this->boxesWithin($media["start"],
            $media["end"]), "hdlr");
        if ($handler === null) {
            return false;
        }
        $head = $this->readBytesAt($handler["start"], 12);
        return strlen($head) >= 12 && substr($head, 8, 4) === "soun";
    }
    /**
     * readTrack gathers the pieces of compressed sound of one track,
     * along with the name of its codec and the settings that codec
     * needs before a piece can be decoded.
     *
     * @param array $box The track, with where it begins and ends.
     * @return array What the track holds, keyed by codec, pieces,
     *     channels and setup.
     */
    public function readTrack($box)
    {
        $table = $this->descend($box, SoundTrack::SAMPLE_TABLE_PATH);
        if ($table === null) {
            throw new \RuntimeException("this sound track has no tables");
        }
        $kids = $this->boxesWithin($table["start"], $table["end"]);
        $described = $this->named($kids, "stsd");
        $codec = "";
        $channels = 1;
        $setup = "";
        if ($described !== null) {
            $said = $this->readSoundDescription($described);
            $codec = $said["codec"];
            $channels = $said["channels"];
            $setup = $said["setup"];
        }
        $sizes = $this->readSizes($this->named($kids, "stsz"));
        $places = $this->readPlaces($kids, count($sizes));
        $pieces = [];
        foreach ($sizes as $at => $length) {
            if (!isset($places[$at]) || $length <= 0) {
                continue;
            }
            $pieces[] = $this->readBytesAt($places[$at], $length);
        }
        if ($pieces === []) {
            /* A browser writes its recording in pieces as it records,
               so the tables above are empty and each piece says its own
               length in a run beside it. */
            $pieces = $this->piecesInRuns();
        }
        return ["codec" => SoundTrack::plainName($codec),
            "pieces" => $pieces, "channels" => $channels,
            "setup" => $setup];
    }
    /**
     * piecesInRuns gathers the pieces of a recording written in parts
     * as it was made, which is how every browser writes one. Such a
     * file carries no table of where its pieces are. Instead each part
     * has a heading that lists the length of every piece in it, and the
     * pieces themselves follow in the block after that heading.
     *
     * @return array the compressed pieces, in playing order
     */
    public function piecesInRuns()
    {
        $pieces = [];
        foreach ($this->boxes as $box) {
            if ($box["type"] !== SoundTrack::PART_HEADING) {
                continue;
            }
            /* Where a run says its first piece begins is counted from
               the start of the heading itself, header and all, rather
               than from where the heading's contents begin. */
            $from = $box["start"] - SoundTrack::BOX_HEADER_LENGTH;
            foreach ($this->runsWithin($box) as $run) {
                $at = $from + $run["start"];
                foreach ($run["lengths"] as $length) {
                    if ($length <= 0) {
                        break;
                    }
                    $piece = $this->readBytesAt($at, $length);
                    if (strlen($piece) < $length) {
                        break;
                    }
                    $pieces[] = $piece;
                    $at += $length;
                }
            }
        }
        return $pieces;
    }
    /**
     * runsWithin reads, out of a part's heading, each run of pieces it
     * describes: where in the block after it the run starts, and the
     * length of every piece in the run.
     *
     * @param array $box the heading, with where it begins and ends
     * @return array one entry a run, each with start and lengths
     */
    public function runsWithin($box)
    {
        $runs = [];
        $inner = $this->boxesWithin($box["start"], $box["end"]);
        foreach ($inner as $one) {
            if ($one["type"] !== SoundTrack::PART_TRACK) {
                continue;
            }
            $deeper = $this->boxesWithin($one["start"], $one["end"]);
            foreach ($deeper as $other) {
                if ($other["type"] !== SoundTrack::PIECE_RUN) {
                    continue;
                }
                $runs[] = $this->oneRun($other);
            }
        }
        return $runs;
    }
    /**
     * oneRun reads a single run of pieces: how many there are, where in
     * the block the first begins, and how long each is.
     *
     * @param array $box the run, with where it begins and ends
     * @return array the run, with start and lengths
     */
    public function oneRun($box)
    {
        $at = $box["start"];
        $head = $this->readBytesAt($at, 8);
        $flags = (ord($head[1]) << 16) | (ord($head[2]) << 8) |
            ord($head[3]);
        $count = unpack("N", substr($head, 4, 4))[1];
        $at += 8;
        $start = 0;
        if ($flags & SoundTrack::RUN_GIVES_START) {
            $start = unpack("N", $this->readBytesAt($at, 4))[1];
            $at += 4;
        }
        if ($flags & SoundTrack::RUN_GIVES_FIRST_KIND) {
            $at += 4;
        }
        $lengths = [];
        for ($which = 0; $which < $count; $which++) {
            if ($flags & SoundTrack::RUN_GIVES_HOW_LONG) {
                $at += 4;
            }
            if ($flags & SoundTrack::RUN_GIVES_LENGTH) {
                $lengths[] = unpack("N",
                    $this->readBytesAt($at, 4))[1];
                $at += 4;
            }
            if ($flags & SoundTrack::RUN_GIVES_KIND) {
                $at += 4;
            }
            if ($flags & SoundTrack::RUN_GIVES_OFFSET) {
                $at += 4;
            }
        }
        return ["start" => $start, "lengths" => $lengths];
    }
    /**
     * readSoundDescription reads the box in which a track says how its
     * sound was compressed: the four letters naming the codec, how many
     * channels it carries, and any settings a decoder needs first.
     *
     * @param array $box The description box, with where it begins.
     * @return array What the box says, keyed by codec, channels and
     *     setup.
     */
    public function readSoundDescription($box)
    {
        $said = $this->readBytesAt($box["start"],
            $box["end"] - $box["start"]);
        if (strlen($said) < 24) {
            return ["codec" => "", "channels" => 1, "setup" => ""];
        }
        $codec = substr($said, 12, 4);
        $channels = 1;
        if (strlen($said) >= 34) {
            $channels = max(1, unpack("n", substr($said, 32, 2))[1]);
        }
        $setup = "";
        $at = strpos($said, "esds");
        if ($at !== false) {
            $setup = self::settingsWithin(substr($said, $at + 4));
        }
        return ["codec" => $codec, "channels" => $channels,
            "setup" => $setup];
    }
    /**
     * settingsWithin picks the settings a codec needs out of the part
     * of an MP4 that describes the stream. That part is a run of nested
     * pieces, each named by one byte and sized by a few more, and the
     * settings sit in the piece named five.
     *
     * @param string $said The bytes of that part of the file.
     * @return string The settings, or an empty string where the part
     *     carries none.
     */
    public static function settingsWithin($said)
    {
        $at = 4;
        $length = strlen($said);
        while ($at < $length) {
            $name = ord($said[$at]);
            $at++;
            $size = 0;
            $more = true;
            while ($more && $at < $length) {
                $byte = ord($said[$at]);
                $at++;
                $size = ($size << 7) | ($byte & 0x7F);
                $more = ($byte & 0x80) !== 0;
            }
            if ($name === 0x05) {
                return substr($said, $at, $size);
            }
            if ($name === 0x03) {
                $at += 3;
                continue;
            }
            if ($name === 0x04) {
                $at += 13;
                continue;
            }
            $at += $size;
        }
        return "";
    }
    /**
     * readSizes reads how long each piece of sound is from the table
     * that holds those lengths. Where every piece is the same length
     * the table says so once rather than repeating itself.
     *
     * @param array $box The table, or nothing where the file has none.
     * @return array How long each piece is, in order.
     */
    public function readSizes($box)
    {
        if ($box === null) {
            return [];
        }
        $said = $this->readBytesAt($box["start"],
            $box["end"] - $box["start"]);
        if (strlen($said) < 12) {
            return [];
        }
        $one_size = unpack("N", substr($said, 4, 4))[1];
        $count = unpack("N", substr($said, 8, 4))[1];
        if ($one_size > 0) {
            return array_fill(0, $count, $one_size);
        }
        $sizes = [];
        for ($at = 0; $at < $count; $at++) {
            $spot = 12 + $at * 4;
            if ($spot + 4 > strlen($said)) {
                break;
            }
            $sizes[] = unpack("N", substr($said, $spot, 4))[1];
        }
        return $sizes;
    }
    /**
     * readPlaces works out where each piece of sound sits in the file.
     * An MP4 groups its pieces into chunks, says where each chunk
     * begins, and says how many pieces each chunk holds, so the place
     * of a piece is its chunk's start plus the lengths before it.
     *
     * @param array $kids The boxes of the sample table.
     * @param int $wanted How many pieces the track holds.
     * @return array Where each piece begins, in order.
     */
    public function readPlaces($kids, $wanted)
    {
        $chunks = $this->readChunkStarts($kids);
        $per_chunk = $this->readPerChunk($this->named($kids, "stsc"));
        $sizes = $this->readSizes($this->named($kids, "stsz"));
        $places = [];
        $piece = 0;
        foreach ($chunks as $index => $start) {
            $holds = self::piecesInChunk($per_chunk, $index + 1);
            $at = $start;
            for ($seen = 0; $seen < $holds && $piece < $wanted; $seen++) {
                $places[$piece] = $at;
                $at += $sizes[$piece] ?? 0;
                $piece++;
            }
        }
        return $places;
    }
    /**
     * readChunkStarts reads where each chunk of sound begins, from
     * whichever of the two tables the file carries: one written with
     * four byte numbers and one with eight, for a file past four
     * gigabytes.
     *
     * @param array $kids The boxes of the sample table.
     * @return array Where each chunk begins, in order.
     */
    public function readChunkStarts($kids)
    {
        $box = $this->named($kids, "stco");
        $wide = false;
        if ($box === null) {
            $box = $this->named($kids, "co64");
            $wide = true;
        }
        if ($box === null) {
            return [];
        }
        $said = $this->readBytesAt($box["start"],
            $box["end"] - $box["start"]);
        if (strlen($said) < 8) {
            return [];
        }
        $count = unpack("N", substr($said, 4, 4))[1];
        $starts = [];
        $step = $wide ? 8 : 4;
        for ($at = 0; $at < $count; $at++) {
            $spot = 8 + $at * $step;
            if ($spot + $step > strlen($said)) {
                break;
            }
            if ($wide) {
                $halves = unpack("N2", substr($said, $spot, 8));
                $starts[] = $halves[1] * 4294967296 + $halves[2];
            } else {
                $starts[] = unpack("N", substr($said, $spot, 4))[1];
            }
        }
        return $starts;
    }
    /**
     * readPerChunk reads the table saying how many pieces of sound each
     * chunk holds. The table says it once for a run of chunks rather
     * than for each, so each row names the first chunk of its run.
     *
     * @param array $box The table, or nothing where the file has none.
     * @return array One row per run, each with the first chunk of the
     *     run and how many pieces its chunks hold.
     */
    public function readPerChunk($box)
    {
        if ($box === null) {
            return [];
        }
        $said = $this->readBytesAt($box["start"],
            $box["end"] - $box["start"]);
        if (strlen($said) < 8) {
            return [];
        }
        $count = unpack("N", substr($said, 4, 4))[1];
        $rows = [];
        for ($at = 0; $at < $count; $at++) {
            $spot = 8 + $at * 12;
            if ($spot + 12 > strlen($said)) {
                break;
            }
            $rows[] = ["first" => unpack("N",
                substr($said, $spot, 4))[1],
                "holds" => unpack("N", substr($said, $spot + 4, 4))[1]];
        }
        return $rows;
    }
    /**
     * piecesInChunk says how many pieces of sound one chunk holds,
     * from the rows that name the first chunk of each run.
     *
     * @param array $rows The rows, as readPerChunk gave them.
     * @param int $chunk Which chunk, counting from one.
     * @return int How many pieces that chunk holds.
     */
    public static function piecesInChunk($rows, $chunk)
    {
        $holds = 0;
        foreach ($rows as $row) {
            if ($row["first"] <= $chunk) {
                $holds = $row["holds"];
            }
        }
        return $holds;
    }
    /**
     * descend walks from a box down through the boxes named, and hands
     * back the last of them. A sound track keeps its tables several
     * boxes deep, and a reader needs to reach them without knowing what
     * else the file carries.
     *
     * @param array $box The box to start from.
     * @param array $names The names to walk through, in order.
     * @return array The box reached, or nothing where the path is not
     *     there.
     */
    public function descend($box, $names)
    {
        $at = $box;
        foreach ($names as $name) {
            $found = $this->named($this->boxesWithin($at["start"],
                $at["end"]), $name);
            if ($found === null) {
                return null;
            }
            $at = $found;
        }
        return $at;
    }
    /**
     * named picks the box with a given name out of a run of boxes.
     *
     * @param array $boxes The boxes to look through.
     * @param string $name The name to look for.
     * @return array The box, or nothing where none carries that name.
     */
    public function named($boxes, $name)
    {
        foreach ($boxes as $box) {
            if ($box["type"] === $name) {
                return $box;
            }
        }
        return null;
    }
    /**
     * boxesWithin reads the boxes that sit between two places in the
     * file, each with the name it gives itself and where it begins and
     * ends. An MP4 is built of such boxes, some holding others.
     *
     * @param int $start Where to start reading.
     * @param int $end Where to stop.
     * @return array The boxes found, in order.
     */
    public function boxesWithin($start, $end)
    {
        $boxes = [];
        $at = $start;
        while ($at + 8 <= $end) {
            $head = $this->readBytesAt($at, 8);
            if (strlen($head) < 8) {
                break;
            }
            $length = unpack("N", substr($head, 0, 4))[1];
            $type = substr($head, 4, 4);
            $body = $at + 8;
            if ($length === 1) {
                $wide = $this->readBytesAt($at + 8, 8);
                $halves = unpack("N2", $wide);
                $length = $halves[1] * 4294967296 + $halves[2];
                $body = $at + 16;
            }
            if ($length === 0) {
                $length = $end - $at;
            }
            if ($length < 8 || $at + $length > $end) {
                break;
            }
            $boxes[] = ["type" => $type, "start" => $body,
                "end" => $at + $length];
            $at += $length;
        }
        return $boxes;
    }
}
X