<?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
*
* PlainSound reads and writes the two sound files that store their
* samples as they are, rather than compressing them.
*/
namespace seekquarry\yioop\library\av_processing;
/**
* PlainSound reads and writes sound files that keep their samples as
* they are: the WAV file a recorder or an editor writes, and the AIFF
* file that does the same on Apple machines. The two differ in the
* order their bytes run and in how they name their parts, and in
* nothing else that matters here.
*
* A caller reads a file a piece at a time and writes one the same way,
* so a recording of any length passes through without being held in
* memory. AudioConverter uses this class as both a source of samples
* and a place to put them.
*
* @author Chris Pollett
*/
class PlainSound
{
/**
* HEADER_BYTES is how many bytes the description at the front of a
* WAV file takes when this class writes one. A player reads the
* length of the sound out of it, so it is written again once the
* sound is finished and its length is known.
* @var int
*/
const HEADER_BYTES = 44;
/**
* AIFF_HEADER_BYTES is the same count for an AIFF file, which
* names its parts differently and so takes a few more bytes.
* @var int
*/
const AIFF_HEADER_BYTES = 54;
/**
* SAMPLE_BYTES is how many bytes one sample takes in the files this
* class reads and writes. Two bytes hold a sample finely enough
* that a listener hears no difference from the recording.
* @var int
*/
const SAMPLE_BYTES = 2;
/**
* FULL_SCALE is the value a sample takes at the loudest a file of
* this kind can hold. A sample arrives as a fraction of one and is
* multiplied by this before it is written.
* @var int
*/
const FULL_SCALE = 32767;
/**
* READ_SAMPLES is how many samples are read from a file at a time.
* A few thousand keeps the memory a reading needs flat, whatever
* the length of the file.
* @var int
*/
const READ_SAMPLES = 24000;
/**
* kindOf says which kind of plain sound file a file holds, reading
* its first bytes rather than trusting its name. A caller uses this
* before opening a file, so that a WAV named as an AIFF is still
* read correctly.
*
* @param string $path The file to look at.
* @return string The word wav or aiff, or an empty string where the
* file is neither.
*/
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, 0, 4) === "RIFF"
&& substr($head, 8, 4) === "WAVE") {
return "wav";
}
if (substr($head, 0, 4) === "FORM"
&& (substr($head, 8, 4) === "AIFF"
|| substr($head, 8, 4) === "AIFC")) {
return "aiff";
}
return "";
}
/**
* describe says what a plain sound file holds: how it is stored,
* how many samples a second it was recorded at, how many channels
* it carries, and how long it runs. Nothing is decoded, so this is
* quick whatever the length of the file.
*
* @param string $path The file to look at.
* @param string $kind The word wav or aiff, as kindOf gave it.
* @return array What the file says about itself, keyed by
* container, rate, channels and seconds.
*/
public static function describe($path, $kind)
{
$said = self::readHeader($path, $kind);
$each = max(1, $said["rate"] * $said["channels"]);
return ["container" => $kind, "rate" => $said["rate"],
"channels" => $said["channels"],
"seconds" => round($said["samples"] / $each, 3)];
}
/**
* readHeader reads the description at the front of a plain sound
* file: where its samples begin, how many there are, how fast they
* were recorded, and in how many channels. A reader needs all four
* before it can hand back a single sample.
*
* @param string $path The file to read.
* @param string $kind The word wav or aiff, as kindOf gave it.
* @return array The description, keyed by at, samples, rate,
* channels and bits.
*/
public static function readHeader($path, $kind)
{
$handle = fopen($path, "rb");
if ($handle === false) {
throw new \RuntimeException("$path cannot be read");
}
$said = ($kind === "wav") ? self::readWavHeader($handle)
: self::readAiffHeader($handle);
fclose($handle);
return $said;
}
/**
* readWavHeader walks the named parts of a WAV file until it has
* found the one describing the sound and the one holding it. A file
* may carry other parts, such as a title or a marker, and those are
* stepped over.
*
* @param resource $handle The open file, at its start.
* @return array The description, keyed by at, samples, rate,
* channels and bits.
*/
public static function readWavHeader($handle)
{
fseek($handle, 12);
$rate = 0;
$channels = 1;
$bits = 16;
while (!feof($handle)) {
$head = fread($handle, 8);
if (strlen($head) < 8) {
break;
}
$name = substr($head, 0, 4);
$length = unpack("V", substr($head, 4, 4))[1];
if ($name === "fmt ") {
$said = fread($handle, $length);
$channels = max(1, unpack("v", substr($said, 2, 2))[1]);
$rate = unpack("V", substr($said, 4, 4))[1];
$bits = unpack("v", substr($said, 14, 2))[1];
continue;
}
if ($name === "data") {
return ["at" => ftell($handle),
"samples" => intdiv($length, max(1, intdiv($bits, 8))),
"rate" => $rate, "channels" => $channels,
"bits" => $bits];
}
fseek($handle, $length + ($length & 1), SEEK_CUR);
}
throw new \RuntimeException("this WAV file holds no sound");
}
/**
* readAiffHeader does the same walk for an AIFF file, whose parts
* carry different names and whose numbers run the other way round.
* The rate is written as a wide floating point number of a shape no
* other part of the file uses, so it is read here by hand.
*
* @param resource $handle The open file, at its start.
* @return array The description, keyed by at, samples, rate,
* channels and bits.
*/
public static function readAiffHeader($handle)
{
fseek($handle, 12);
$rate = 0;
$channels = 1;
$bits = 16;
$samples = 0;
while (!feof($handle)) {
$head = fread($handle, 8);
if (strlen($head) < 8) {
break;
}
$name = substr($head, 0, 4);
$length = unpack("N", substr($head, 4, 4))[1];
if ($name === "COMM") {
$said = fread($handle, $length);
if (strlen($said) < 18) {
break;
}
$channels = max(1, unpack("n", substr($said, 0, 2))[1]);
$samples = unpack("N", substr($said, 2, 4))[1] * $channels;
$bits = unpack("n", substr($said, 6, 2))[1];
$rate = self::readWideRate(substr($said, 8, 10));
continue;
}
if ($name === "SSND") {
$ahead = fread($handle, 8);
$skip = unpack("N", substr($ahead, 0, 4))[1];
fseek($handle, $skip, SEEK_CUR);
return ["at" => ftell($handle), "samples" => $samples,
"rate" => $rate, "channels" => $channels,
"bits" => $bits];
}
fseek($handle, $length + ($length & 1), SEEK_CUR);
}
throw new \RuntimeException("this AIFF file holds no sound");
}
/**
* readWideRate turns the ten byte number an AIFF file writes its
* recording rate as into an ordinary one. The first two bytes hold
* how far the point has moved and the other eight hold the digits,
* a shape from older machines that no other part of these files
* uses.
*
* @param string $held The ten bytes as the file stored them.
* @return int How many samples a second the file was recorded at.
*/
public static function readWideRate($held)
{
if (strlen($held) < 10) {
return 0;
}
$moved = unpack("n", substr($held, 0, 2))[1];
$upper = unpack("N", substr($held, 2, 4))[1];
$lower = unpack("N", substr($held, 6, 4))[1];
$whole = $upper * 4294967296.0 + $lower;
$shift = $moved - 16383 - 63;
return (int)round($whole * pow(2.0, $shift));
}
/**
* eachPiece hands back the samples of a plain sound file a few
* thousand at a time, as fractions of one. Where a file carries
* more than one channel they are averaged into one, since what this
* folder writes carries a single channel.
*
* @param string $path The file to read.
* @param string $kind The word wav or aiff, as kindOf gave it.
* @return Generator Runs of samples, each between one below zero
* and one above it.
*/
public static function eachPiece($path, $kind)
{
$said = self::readHeader($path, $kind);
$handle = fopen($path, "rb");
if ($handle === false) {
throw new \RuntimeException("$path cannot be read");
}
fseek($handle, $said["at"]);
$wide = ($kind === "wav") ? "v" : "n";
$left = $said["samples"];
$channels = $said["channels"];
while ($left > 0 && !feof($handle)) {
$wanted = min($left, self::READ_SAMPLES);
$bytes = fread($handle, $wanted * self::SAMPLE_BYTES);
if ($bytes === false || $bytes === "") {
break;
}
$read = unpack($wide . "*", $bytes);
$run = [];
$held = 0.0;
$seen = 0;
foreach ($read as $value) {
if ($value >= 32768) {
$value -= 65536;
}
$held += $value / self::FULL_SCALE;
$seen++;
if ($seen === $channels) {
$run[] = $held / $channels;
$held = 0.0;
$seen = 0;
}
}
$left -= $wanted;
yield $run;
}
fclose($handle);
}
/**
* startWriting opens a plain sound file and writes room for its
* description, which cannot be filled in until the length of the
* sound is known. A caller adds samples with addPiece and closes
* the file with finishWriting.
*
* @param string $path Where to write the file.
* @param string $kind The word wav or aiff.
* @param int $rate How many samples a second the sound runs at.
* @return resource The open file, ready for samples.
*/
public static function startWriting($path, $kind, $rate)
{
$handle = fopen($path, "wb");
if ($handle === false) {
throw new \RuntimeException("$path cannot be written");
}
$room = ($kind === "wav") ? self::HEADER_BYTES
: self::AIFF_HEADER_BYTES;
fwrite($handle, str_repeat("\x00", $room));
return $handle;
}
/**
* addPiece writes a run of samples to a file already opened by
* startWriting. Each sample arrives as a fraction of one and is
* written as a whole number, held at the edge where it goes past
* what a sample can hold.
*
* @param resource $handle The open file.
* @param array $run The samples to write.
* @param string $kind The word wav or aiff.
* @return int How many samples were written.
*/
public static function addPiece($handle, $run, $kind)
{
$wide = ($kind === "wav") ? "v" : "n";
$written = "";
foreach ($run as $sample) {
$value = (int)round($sample * self::FULL_SCALE);
if ($value > self::FULL_SCALE) {
$value = self::FULL_SCALE;
}
if ($value < -self::FULL_SCALE) {
$value = -self::FULL_SCALE;
}
$written .= pack($wide, $value & 0xFFFF);
}
fwrite($handle, $written);
return count($run);
}
/**
* finishWriting fills in the description at the front of the file
* now that the length of the sound is known, and closes the file. A
* player reads that description first, so a file left without it
* cannot be played.
*
* @param resource $handle The open file.
* @param string $path The file, so its length can be read.
* @param string $kind The word wav or aiff.
* @param int $rate How many samples a second the sound runs at.
* @param int $count How many samples were written.
*/
public static function finishWriting($handle, $path, $kind, $rate,
$count)
{
$sound_length = $count * self::SAMPLE_BYTES;
fseek($handle, 0);
if ($kind === "wav") {
fwrite($handle, self::wavHeader($rate, $sound_length));
} else {
fwrite($handle,
("FORM" . pack("N", 46 + $sound_length) . "AIFF" . "COMM" .
pack("N", 18) . pack("n", 1) . pack("N", $count)
. pack("n", self::SAMPLE_BYTES * 8)
. self::wideRate($rate) . "SSND"
. pack("N", $sound_length + 8)
. pack("N", 0) . pack("N", 0)));
}
fclose($handle);
}
/**
* wavHeader builds the description that goes at the front of a WAV
* file: what the file is, how the sound was recorded, and how many
* bytes of it follow.
*
* @param int $rate How many samples a second the sound runs at.
* @param int $sound_length How many bytes of sound follow.
* @return string The description, ready to write.
*/
public static function wavHeader($rate, $sound_length)
{
$bytes_a_second = $rate * self::SAMPLE_BYTES;
return "RIFF" . pack("V", 36 + $sound_length) . "WAVE"
. "fmt " . pack("V", 16) . pack("v", 1) . pack("v", 1)
. pack("V", $rate) . pack("V", $bytes_a_second)
. pack("v", self::SAMPLE_BYTES)
. pack("v", self::SAMPLE_BYTES * 8)
. "data" . pack("V", $sound_length);
}
/**
* wideRate turns a recording rate into the ten byte number an AIFF
* file writes it as, which is the shape readWideRate reads back.
*
* @param int $rate How many samples a second the sound runs at.
* @return string The ten bytes, ready to write.
*/
public static function wideRate($rate)
{
if ($rate <= 0) {
return str_repeat("\x00", 10);
}
$moved = 0;
$whole = (float)$rate;
while ($whole < 9223372036854775808.0 / 2.0) {
$whole *= 2.0;
$moved++;
}
$held = (int)$whole;
return pack("n", 16383 + 63 - $moved)
. pack("N", ($held >> 32) & 0xFFFFFFFF)
. pack("N", $held & 0xFFFFFFFF);
}
}