/ src / library / av_processing / PlainSound.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
 *
 * 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);
    }
}
X