/ src / library / av_processing / OpusDecoder.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
 */
namespace seekquarry\yioop\library\av_processing;

/**
 * OpusDecoder turns a whole recording into samples, and writes samples out as a
 * sound file anything can play. This is what puts every other part together end
 * to end: the file is taken apart into pieces, each piece into stretches, each
 * stretch read, and each stretch's tones turned into samples and joined to the
 * ones before. What comes out is not right yet. A recording of a single 440
 * cycle tone comes back as a tone near 510 cycles, which says the shape within
 * a band is wrong even though the band itself is right. The fault is in how a
 * wide band is cut in half and put back together. This is here so that the
 * state of the work can be listened to rather than only read about, and so that
 * the next fix has something to be measured against.
 */
class OpusDecoder
{
    /**
     * SAMPLE_RATE is how many samples a second the decoder hands back.
     * Opus always decodes at this rate, whatever rate the sound was
     * recorded at.
     */
    const SAMPLE_RATE = 48000;
    /**
     * USUAL_TONES is how many tones a stretch of the usual length holds.
     */
    const USUAL_TONES = 960;
    /**
     * USUAL_DOUBLINGS is how many times the shortest stretch has been doubled
     * to reach the usual one.
     */
    const USUAL_DOUBLINGS = 3;
    /**
     * SUDDEN_BLOCKS is how many blocks a stretch is split into where the sound
     * changed suddenly.
     */
    const SUDDEN_BLOCKS = 8;
    /**
     * BANDS is how many frequency bands the sound is split into,
     * from the lowest to the highest a recording carries.
     */
    const BANDS = 21;
    /**
     * VERY_QUIET is the loudness a band is taken to have had before the
     * recording began, quiet enough that nothing is filled back toward it.
     */
    const VERY_QUIET = -28.0;
    /**
     * LOUDEST is how loud a sample may be before it is held back, so that a
     * fault cannot produce something painful to listen to.
     */
    const LOUDEST = 1.0;
    /**
     * eachStretch turns every stretch of a recording into samples, handing each
     * stretch's worth over as it is made rather than keeping them all. A
     * recording of any length has to work, and holding every sample of it at
     * once does not: four minutes of sound comes to eleven million of them,
     * which is most of a gigabyte once each is a number in memory. So the
     * samples are given out a stretch at a time and whatever wants them decides
     * what to keep.
     *
     * @param array $stretches the compressed stretches, in order
     * @param int $channels how many channels the recording carries
     * @return \Generator each stretch's worth of samples in turn
     */
    public static function eachStretch($stretches, $channels = 1)
    {
        $loudness = CeltEnergy::nothingYet($channels);
        $quiet = [];
        for ($channel = 0; $channel < $channels; $channel++) {
            $quiet[] = array_fill(0, self::BANDS, self::VERY_QUIET);
        }
        $previous_one = $quiet;
        $previous_two = $quiet;
        $seed = 0;
        $hanging = null;
        $carried = 0.0;
        $past = array_fill(0, CeltPitchFilter::LONGEST_REACH, 0.0);
        $was = (["reach" => 0, "loudness" => 0.0, "shape" => 0]);
        foreach ($stretches as $stretch) {
            $reader = new RangeDecoder($stretch);
            $read = CeltStretch::readFrom($reader, self::USUAL_DOUBLINGS,
                $loudness, $seed, 0, self::BANDS, $previous_one, $previous_two);
            $loudness = $read["loudness"];
            $previous_two = $previous_one;
            $previous_one = $loudness;
            /* The number used to fill empty bands carries on from where
               the reader itself ended, not from nothing. */
            $seed = $reader->range;
            $blocks = $read["header"]->sudden ? self::SUDDEN_BLOCKS : 1;
            if ($read["header"]->silent) {
                $tones = array_fill(0, self::USUAL_TONES, 0.0);
            } else {
                /* Where there are two channels the shapes have been
                   averaged already, so the louder of the two loudnesses
                   is the one to bring them up by. */
                $level = $read["loudness"][0];
                if (count($read["loudness"]) > 1) {
                    foreach ($level as $band => $one) {
                        $level[$band] = max($one,
                            $read["loudness"][1][$band]);
                    }
                }
                $tones = CeltShape::toTones($read["slots"], $level,
                    self::USUAL_DOUBLINGS, 0, self::BANDS,
                    self::USUAL_TONES);
            }
            if ($hanging === null) {
                $hanging = array_fill(0,
                    intdiv(CeltSynthesis::TAPER, 2), 0.0);
            }
            $settled = CeltSynthesis::toSamples($tones, $blocks, $hanging);
            /* The filter reaches back as much as a thousand samples, so
               the sound before this stretch is kept in front of it. */
            $now = ["reach" => $read["header"]->filter_pitch,
                "loudness" => $read["header"]->filter_strength,
                "shape" => $read["header"]->filter_shape];
            $with_past = array_merge($past, $settled);
            $settled = CeltPitchFilter::apply($with_past, count($past),
                count($settled), $was, $now);
            $was = $now;
            $past = array_slice(array_merge($past, $settled),
                -CeltPitchFilter::LONGEST_REACH);
            yield CeltSynthesis::letDownTilt($settled, $carried);
        }
    }
    /**
     * toSamples turns a recording into samples and keeps them all, which suits
     * a short recording and nothing longer
     *
     * @param array $stretches the compressed stretches, in order
     * @return array the samples, at 48000 a second
     */
    public static function toSamples($stretches)
    {
        $wave = [];
        foreach (self::eachStretch($stretches) as $settled) {
            foreach ($settled as $sample) {
                $wave[] = $sample;
            }
        }
        return $wave;
    }
}
X