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