<?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;
/**
* WebmDemuxer reads the pieces of compressed sound back out of a .webm file. A
* webm file 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 plain data. The sound
* itself sits in boxes called blocks, gathered into larger boxes called
* clusters, and what kind of sound it is sits in a separate box near the front
* of the file. Reading the file means walking the boxes, remembering the one
* that says what the sound is, and handing back the sound out of the blocks.
* This is the kind Chrome and Firefox both write when a page asks them to
* record, so it is the kind most recordings arrive in. A block may hold several
* pieces of sound at once, packed nose to tail with their lengths written in
* front in one of three ways. All three are read here, though a recording of
* sound alone normally uses none of them and puts one piece in each block. The
* layout of the boxes is Matroska, which webm is a cut down form of; the box
* lengths and numbers follow the way a WebM file is built out of numbered
* parts, which is called EBML, the binary markup language Matroska is
* written in.
*/
class WebmDemuxer
{
/**
* SEGMENT_BOX is box holding everything else in the file.
*/
const SEGMENT_BOX = 0x18538067;
/**
* INFO_BOX is box holding facts about the file as a whole, such as how its
* times are counted.
*/
const INFO_BOX = 0x1549A966;
/**
* TIME_SCALE_BOX is box saying what one tick of the file's clock is worth
* in billionths of a second.
*/
const TIME_SCALE_BOX = 0x2AD7B1;
/**
* TRACKS_BOX is box holding the description of each sound or picture in the
* file.
*/
const TRACKS_BOX = 0x1654AE6B;
/**
* TRACK_BOX is the number naming the part of the file that describes
* one track, whether that track carries sound or pictures.
*/
const TRACK_BOX = 0xAE;
/**
* TRACK_NUMBER_BOX is box giving the number blocks of this sound are
* labeled with.
*/
const TRACK_NUMBER_BOX = 0xD7;
/**
* TRACK_KIND_BOX is box saying whether this is sound, picture, or something
* else.
*/
const TRACK_KIND_BOX = 0x83;
/**
* SOUND_KIND is value of the box above that means sound.
*/
const SOUND_KIND = 2;
/**
* CODEC_NAME_BOX is box naming how the sound was compressed.
*/
const CODEC_NAME_BOX = 0x86;
/**
* CODEC_SETUP_BOX is box holding the settings a player needs before it can
* make sense of the compressed sound.
*/
const CODEC_SETUP_BOX = 0x63A2;
/**
* CODEC_DELAY_BOX is box saying how much sound at the start is only there
* to let the decoder settle, counted in billionths of a second.
*/
const CODEC_DELAY_BOX = 0x56AA;
/**
* SOUND_BOX is box holding facts about the sound itself.
*/
const SOUND_BOX = 0xE1;
/**
* SAMPLE_RATE_BOX is box giving how many samples a second the sound was
* taken at.
*/
const SAMPLE_RATE_BOX = 0xB5;
/**
* CHANNEL_COUNT_BOX is box giving how many channels the sound has.
*/
const CHANNEL_COUNT_BOX = 0x9F;
/**
* CLUSTER_BOX is box gathering a run of blocks that share a starting time.
*/
const CLUSTER_BOX = 0x1F43B675;
/**
* CLUSTER_TIME_BOX is box giving the starting time a cluster's blocks count
* from.
*/
const CLUSTER_TIME_BOX = 0xE7;
/**
* SIMPLE_BLOCK_BOX is box holding sound with nothing else attached to it.
*/
const SIMPLE_BLOCK_BOX = 0xA3;
/**
* BLOCK_GROUP_BOX is box holding sound together with extra facts about it.
*/
const BLOCK_GROUP_BOX = 0xA0;
/**
* BLOCK_BOX is box holding the sound inside the group above.
*/
const BLOCK_BOX = 0xA1;
/**
* OPUS_CODEC_NAME is what the sound is named when it was compressed with
* Opus.
*/
const OPUS_CODEC_NAME = "A_OPUS";
/**
* DEFAULT_TIME_SCALE is ticks of the file's clock in one second when the
* file does not say otherwise, in billionths of a second.
*/
const DEFAULT_TIME_SCALE = 1000000;
/**
* TIME_UNITS_PER_SECOND is billionths of a second in one second, used to
* turn the file's times into a count of samples.
*/
const TIME_UNITS_PER_SECOND = 1000000000;
/**
* SAMPLE_RATE is samples a second the sound is counted in once decoded.
*/
const SAMPLE_RATE = 48000;
/**
* MAX_FILE_SIZE is largest file this will take in. A recording sent in a
* message is small; anything past this is refused rather than read whole
* into memory.
*/
const MAX_FILE_SIZE = 268435456;
/**
* NO_LACING is how the lengths of several pieces packed into one block are
* written, when they are not written at all.
*/
const NO_LACING = 0;
/**
* XIPH_LACING is lengths written as runs of 255 that add up, the oldest of
* the three ways.
*/
const XIPH_LACING = 1;
/**
* FIXED_LACING is every piece the same length, so only the count is
* written.
*/
const FIXED_LACING = 2;
/**
* EBML_LACING is the packing where each length is written the way a
* part's length is written elsewhere in the file, and each
* length after
* the first as a difference from the one before.
*/
const EBML_LACING = 3;
/**
* LACING_SHIFT is where in a block's flags the way lengths are written
* sits.
*/
const LACING_SHIFT = 1;
/**
* LACING_MASK is how much of the flags to look at to read the way above.
*/
const LACING_MASK = 0x03;
/**
* data stores the whole file, held in memory.
* @var string
*/
public $data;
/**
* track_number stores which number the sound's blocks are labeled with.
* @var int
*/
public $track_number;
/**
* codec_name stores how the sound was compressed, as the file names it.
* @var string
*/
public $codec_name;
/**
* codec_setup stores the settings a decoder needs before it can read the
* sound, as the file gave them.
* @var string
*/
public $codec_setup;
/**
* codec_delay stores sound at the start that is only there to let the
* decoder settle, in billionths of a second.
* @var int
*/
public $codec_delay;
/**
* sample_rate stores how many samples a second the sound was taken at.
* @var float
*/
public $sample_rate;
/**
* channel_count stores how many channels the sound has.
* @var int
*/
public $channel_count;
/**
* time_scale stores what one tick of the file's clock is worth in
* billionths of a second.
* @var int
*/
public $time_scale;
/**
* block_count stores how many blocks of sound have been read.
* @var int
*/
public $block_count;
/**
* __construct sets up a read over the contents of a webm file
*
* @param string $data the whole file
*/
public function __construct($data)
{
$this->data = $data;
$this->track_number = -1;
$this->codec_name = "";
$this->codec_setup = "";
$this->codec_delay = 0;
$this->sample_rate = self::SAMPLE_RATE;
$this->channel_count = 1;
$this->time_scale = self::DEFAULT_TIME_SCALE;
$this->block_count = 0;
}
/**
* fromName opens a file by name and sets up a read over it
*
* @param string $name path of the file to read
* @return object a reader over that file
*/
public static function fromName($name)
{
$size = filesize($name);
if ($size === false) {
throw new \Exception("Could not measure $name");
}
if ($size > self::MAX_FILE_SIZE) {
throw new \Exception("$name is larger than this reads");
}
$data = file_get_contents($name);
if ($data === false) {
throw new \Exception("Could not read $name");
}
return new self($data);
}
/**
* packets hands back the pieces of sound in the file one at a time, in the
* order they were stored. Reading the description of the sound comes first,
* so what kind of sound it is is known before the first piece is given
* back.
*
* @return \Generator each piece of sound as a MediaPacket
*/
public function packets()
{
$length = strlen($this->data);
$at = 0;
$given = 0;
$cluster_time = 0;
while ($at < $length) {
$box = $this->readBoxStart($at);
if ($box === null) {
break;
}
$name = $box["name"];
$body_at = $box["body_at"];
$body_size = $box["body_size"];
if ($name == self::SEGMENT_BOX || $name == self::CLUSTER_BOX ||
$name == self::BLOCK_GROUP_BOX) {
/* These hold more boxes rather than plain data, so the
walk steps into them instead of over them. */
if ($name == self::CLUSTER_BOX) {
$cluster_time = 0;
}
$at = $body_at;
continue;
}
if ($name == self::INFO_BOX) {
$this->readInfo($body_at, $body_size);
} else if ($name == self::TRACKS_BOX) {
$this->readTracks($body_at, $body_size);
} else if ($name == self::CLUSTER_TIME_BOX) {
$cluster_time = $this->readNumber($body_at, $body_size);
} else if ($name == self::SIMPLE_BLOCK_BOX ||
$name == self::BLOCK_BOX) {
foreach ($this->readBlock($body_at, $body_size,
$cluster_time) as $piece) {
yield new MediaPacket($piece["data"], $this->track_number,
$piece["position"], $given, false);
$given++;
}
}
$at = $body_at + $body_size;
}
}
/**
* readInfo reads what the file says about itself, which is where the worth
* of one tick of its clock is written
*
* @param int $at where the box's contents begin
* @param int $size how long those contents are
*/
public function readInfo($at, $size)
{
$end = $at + $size;
while ($at < $end) {
$box = $this->readBoxStart($at);
if ($box === null) {
return;
}
if ($box["name"] == self::TIME_SCALE_BOX) {
$this->time_scale = $this->readNumber($box["body_at"],
$box["body_size"]);
}
$at = $box["body_at"] + $box["body_size"];
}
}
/**
* readTracks reads the description of each sound or picture in the file and
* keeps the first sound found
*
* @param int $at where the box's contents begin
* @param int $size how long those contents are
*/
public function readTracks($at, $size)
{
$end = $at + $size;
while ($at < $end) {
$box = $this->readBoxStart($at);
if ($box === null) {
return;
}
if ($box["name"] == self::TRACK_BOX && $this->track_number < 0) {
$this->readTrack($box["body_at"], $box["body_size"]);
}
$at = $box["body_at"] + $box["body_size"];
}
}
/**
* readTrack reads the description of one sound or picture, keeping it only
* where it turns out to be sound
*
* @param int $at where the box's contents begin
* @param int $size how long those contents are
*/
public function readTrack($at, $size)
{
$end = $at + $size;
$number = -1;
$kind = 0;
$name = "";
$setup = "";
$delay = 0;
$rate = self::SAMPLE_RATE;
$channels = 1;
while ($at < $end) {
$box = $this->readBoxStart($at);
if ($box === null) {
return;
}
$body_at = $box["body_at"];
$body_size = $box["body_size"];
if ($box["name"] == self::TRACK_NUMBER_BOX) {
$number = $this->readNumber($body_at, $body_size);
} else if ($box["name"] == self::TRACK_KIND_BOX) {
$kind = $this->readNumber($body_at, $body_size);
} else if ($box["name"] == self::CODEC_NAME_BOX) {
$name = rtrim(substr($this->data, $body_at, $body_size),
"\0");
} else if ($box["name"] == self::CODEC_SETUP_BOX) {
$setup = substr($this->data, $body_at, $body_size);
} else if ($box["name"] == self::CODEC_DELAY_BOX) {
$delay = $this->readNumber($body_at, $body_size);
} else if ($box["name"] == self::SOUND_BOX) {
$sound_end = $body_at + $body_size;
$sound_at = $body_at;
while ($sound_at < $sound_end) {
$inner = $this->readBoxStart($sound_at);
if ($inner === null) {
break;
}
if ($inner["name"] == self::SAMPLE_RATE_BOX) {
$rate = $this->readFloat($inner["body_at"],
$inner["body_size"]);
} else if ($inner["name"] == self::CHANNEL_COUNT_BOX) {
$channels = $this->readNumber($inner["body_at"],
$inner["body_size"]);
}
$sound_at = $inner["body_at"] + $inner["body_size"];
}
}
$at = $body_at + $body_size;
}
if ($kind == self::SOUND_KIND && $number >= 0) {
$this->track_number = $number;
$this->codec_name = $name;
$this->codec_setup = $setup;
$this->codec_delay = $delay;
$this->sample_rate = $rate;
$this->channel_count = $channels;
}
}
/**
* readBlock pulls the pieces of sound out of one block, working out where
* each belongs from the time the block carries
*
* @param int $at where the block's contents begin
* @param int $size how long those contents are
* @param int $cluster_time the starting time the block counts from
* @return array each piece of sound with where it belongs
*/
public function readBlock($at, $size, $cluster_time)
{
$end = $at + $size;
$label = $this->readVariableNumber($at, true);
if ($label === null) {
return [];
}
$at = $label["next_at"];
if ($label["value"] != $this->track_number || $at + 3 > $end) {
return [];
}
/* The time a block carries is a difference from its cluster's
starting time and may run backwards, so it is read as a
number that can be negative. */
$offset = (ord($this->data[$at]) << 8) | ord($this->data[$at + 1]);
if ($offset >= 0x8000) {
$offset -= 0x10000;
}
$flags = ord($this->data[$at + 2]);
$at += 3;
$lacing = ($flags >> self::LACING_SHIFT) & self::LACING_MASK;
$sizes = $this->readLacedSizes($at, $end, $lacing);
if ($sizes === null) {
return [];
}
$at = $sizes["next_at"];
$ticks = $cluster_time + $offset;
$position = (int)round($ticks * $this->time_scale *
self::SAMPLE_RATE / self::TIME_UNITS_PER_SECOND);
$pieces = [];
foreach ($sizes["sizes"] as $piece_size) {
if ($piece_size < 0 || $at + $piece_size > $end) {
break;
}
$pieces[] = ["data" => substr($this->data, $at, $piece_size),
"position" => $position];
$at += $piece_size;
$this->block_count++;
/* Only the first piece in a block carries a time; the rest
follow it, and how far apart they are is a question for
whatever reads the sound itself. */
$position = -1;
}
return $pieces;
}
/**
* readLacedSizes works out how long each piece packed into a block is, in
* whichever of the three ways the block wrote them where the lengths could
* not be read
*
* @param int $at where the lengths begin
* @param int $end where the block's contents end
* @param int $lacing which of the three ways was used
* @return mixed the lengths and where the sound begins, or null
*/
public function readLacedSizes($at, $end, $lacing)
{
if ($lacing == self::NO_LACING) {
return ["sizes" => [$end - $at], "next_at" => $at];
}
if ($at >= $end) {
return null;
}
$count = ord($this->data[$at]) + 1;
$at++;
if ($lacing == self::FIXED_LACING) {
$left = $end - $at;
if ($count < 1 || $left % $count != 0) {
return null;
}
return ["sizes" => array_fill(0, $count, intdiv($left, $count)),
"next_at" => $at];
}
$sizes = [];
if ($lacing == self::XIPH_LACING) {
for ($i = 0; $i < $count - 1; $i++) {
$piece_size = 0;
while (true) {
if ($at >= $end) {
return null;
}
$part = ord($this->data[$at]);
$at++;
$piece_size += $part;
if ($part != 255) {
break;
}
}
$sizes[] = $piece_size;
}
} else {
$first = $this->readVariableNumber($at, true);
if ($first === null) {
return null;
}
$piece_size = $first["value"];
$sizes[] = $piece_size;
$at = $first["next_at"];
for ($i = 1; $i < $count - 1; $i++) {
$step = $this->readVariableNumber($at, true);
if ($step === null) {
return null;
}
/* Each length after the first is written as a
difference from the one before, shifted so it can
run either way. */
$middle = (1 << ($step["width"] * 7 - 1)) - 1;
$piece_size += $step["value"] - $middle;
$sizes[] = $piece_size;
$at = $step["next_at"];
}
}
$used = array_sum($sizes);
$sizes[] = $end - $at - $used;
return ["sizes" => $sizes, "next_at" => $at];
}
/**
* readBoxStart reads the start of a box: what it is, and where and how long
* its contents are long they are, or null where it could not be read
*
* @param int $at where the box begins
* @return mixed the box's name, where its contents begin, and how
*/
public function readBoxStart($at)
{
$name = $this->readVariableNumber($at, false);
if ($name === null) {
return null;
}
$size = $this->readVariableNumber($name["next_at"], true);
if ($size === null) {
return null;
}
$body_at = $size["next_at"];
$body_size = $size["value"];
$length = strlen($this->data);
/* A box may say it does not know its own length, which a
recorder writes when it cannot go back and fill the length
in. Such a box runs to the end of what there is. */
if ($size["unstated"] || $body_at + $body_size > $length) {
$body_size = $length - $body_at;
}
return ["name" => $name["value"], "body_at" => $body_at,
"body_size" => $body_size];
}
/**
* readVariableNumber reads a number whose length is written into its own
* first byte, which is how box names and box lengths are both stored
* length, which box lengths do and box names do not and whether it was the
* all ones value meaning unstated, or null where it could not be read
*
* @param int $at where the number begins
* @param bool $strip whether to drop the bit that marked the
* @return mixed the number, how many bytes it took, where it ends,
*/
public function readVariableNumber($at, $strip)
{
$length = strlen($this->data);
if ($at < 0 || $at >= $length) {
return null;
}
$first = ord($this->data[$at]);
if ($first == 0) {
return null;
}
$width = 1;
$marker = 0x80;
while (!($first & $marker)) {
$marker >>= 1;
$width++;
}
if ($at + $width > $length) {
return null;
}
$value = $strip ? ($first & ($marker - 1)) : $first;
$all_ones = $strip ? ($marker - 1) : 0;
for ($i = 1; $i < $width; $i++) {
$value = ($value << 8) | ord($this->data[$at + $i]);
$all_ones = ($all_ones << 8) | 0xFF;
}
return ["value" => $value, "width" => $width,
"next_at" => $at + $width,
"unstated" => $strip && $value == $all_ones];
}
/**
* readNumber reads a plain number stored most significant byte first
*
* @param int $at where the number begins
* @param int $size how many bytes it takes
* @return int the number read
*/
public function readNumber($at, $size)
{
$value = 0;
for ($i = 0; $i < $size; $i++) {
$value = ($value << 8) | ord($this->data[$at + $i]);
}
return $value;
}
/**
* readFloat reads a number with a fractional part, which the file writes in
* either of two widths
*
* @param int $at where the number begins
* @param int $size how many bytes it takes
* @return float the number read
*/
public function readFloat($at, $size)
{
if ($size == 4) {
$read = unpack("G", substr($this->data, $at, 4));
return $read[1];
}
if ($size == 8) {
$read = unpack("E", substr($this->data, $at, 8));
return $read[1];
}
return (float)$this->readNumber($at, $size);
}
}