/ src / library / av_processing / CeltSynthesis.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;

/**
 * CeltSynthesis turns the tones of a stretch back into samples, and joins each
 * stretch to the one before it. The transform that does the turning has already
 * been written and checked. What is left is the joining, and the shape of the
 * fade the joining uses. That fade is unusual. An ordinary one rises across a
 * whole stretch and falls across the next, so neighboring stretches overlap by
 * their whole length and half the sound is being faded at any moment. Opus
 * cannot afford that, because the overlap is delay: a player cannot give out a
 * sample until both stretches covering it have arrived. So the fade here is
 * flat across nearly all of a stretch and tapers only over a short run at each
 * end, which is two and a half milliseconds rather than twenty. The stretches
 * still cancel each other's errors exactly, because the taper satisfies the
 * same condition an ordinary fade does. The last step undoes a tilt the
 * recording was given before it was compressed. Sound has more of its energy at
 * low pitches than high, and compressing it evenly would spend too much on the
 * low end, so the high end is lifted before compressing and let back down here.
 * This follows RFC 6716, the specification of the Opus audio codec, in its
 * section on the transform layer.
 */
class CeltSynthesis
{
    /**
     * TAPER is how long the taper at each end of a stretch is, in samples at
     * the shortest stretch's rate.
     */
    const TAPER = 120;
    /**
     * TILT_BACK is how much of the tilt is let back down at each sample.
     */
    const TILT_BACK = 0.8500061035;
    /**
     * fades stores the fade used to join stretches, worked out once for each
     * length and kept
     *
     * @var array
     */
    public static $fades = [];
    /**
     * fadeFor the fade for a stretch of a given length. It is nothing at all
     * outside a middle region, rises across a short taper, holds flat, and
     * falls again, so that only the tapers overlap between one stretch and the
     * next. half how many samples the fade covers
     *
     * @param int $tones how many tones the stretch holds, which is
     * @param int $taper how long each taper is
     * @return array the fade, one value per sample
     */
    public static function fadeFor($tones, $taper)
    {
        $key = $tones . ":" . $taper;
        if (isset(self::$fades[$key])) {
            return self::$fades[$key];
        }
        $length = 2 * $tones;
        $fade = array_fill(0, $length, 0.0);
        $begins = intdiv($tones - $taper, 2);
        for ($i = 0; $i < $tones; $i++) {
            if ($i < $begins) {
                $value = 0.0;
            } else if ($i < $begins + $taper) {
                $value = self::taperAt($i - $begins, $taper);
            } else {
                $value = 1.0;
            }
            $fade[$i] = $value;
            /* The second half is the first half backwards, which is
               what makes neighboring stretches cancel. */
            $fade[$length - 1 - $i] = $value;
        }
        self::$fades[$key] = $fade;
        return $fade;
    }
    /**
     * taperAt how far up the taper has risen at a given point
     *
     * @param int $at how far along the taper
     * @param int $taper how long the taper is
     * @return float how far up it has risen, from nothing to one
     */
    public static function taperAt($at, $taper)
    {
        $along = sin(0.5 * M_PI * ($at + 0.5) / $taper);
        return sin(0.5 * M_PI * $along * $along);
    }
    /**
     * toSamples turns a stretch of tones into samples and joins it to what came
     * before which is more than one where the sound changed suddenly before,
     * replaced by what this one leaves hanging
     *
     * @param array $tones the stretch's tones
     * @param int $blocks how many blocks the stretch is split into,
     * @param array $tail what was left hanging over from the stretch
     * @return array the samples this stretch settles
     */
    public static function toSamples($tones, $blocks, &$tail)
    {
        $length = count($tones);
        $each = intdiv($length, $blocks);
        $taper = self::TAPER;
        $half = intdiv($taper, 2);
        $running = array_fill(0, $length + $taper, 0.0);
        for ($i = 0; $i < $half; $i++) {
            $running[$i] = $tail[$i] ?? 0.0;
        }
        $rise = [];
        for ($i = 0; $i < $taper; $i++) {
            $rise[] = self::taperAt($i, $taper);
        }
        $middle = intdiv($each, 2);
        $scale = $each / 2.0;
        for ($block = 0; $block < $blocks; $block++) {
            /* A stretch split into blocks keeps its tones interleaved,
               so a block's own tones are every so many along. */
            $mine = [];
            for ($tone = 0; $tone < $each; $tone++) {
                $mine[] = $tones[$tone * $blocks + $block];
            }
            $samples = Mdct::forSize($each)->inverse($mine);
            $at = $block * $each;
            /* The middle of the transform's result is laid down as it
               is, reaching a little past this block's end. */
            for ($i = 0; $i < $each; $i++) {
                $running[$at + $half + $i] = $samples[$middle + $i] *
                    $scale;
            }
            /* The start is folded against the raw end the block before
               left lying there, which is what makes the two cancel
               each other's aliasing exactly. */
            for ($i = 0; $i < $half; $i++) {
                $near = $running[$at + $i];
                $far = $running[$at + $taper - 1 - $i];
                $low = $rise[$i];
                $high = $rise[$taper - 1 - $i];
                $running[$at + $i] = $high * $near - $low * $far;
                $running[$at + $taper - 1 - $i] = $low * $near +
                    $high * $far;
            }
        }
        $settled = array_slice($running, 0, $length);
        /* The raw end of the last block is carried to the next
           stretch, which folds its own start against it. */
        $tail = [];
        for ($i = 0; $i < $half; $i++) {
            $tail[] = $running[$length + $i];
        }
        return $settled;
    }
    /**
     * letDownTilt lets back down the tilt the recording was given before it was
     * compressed stretch before, replaced by what it holds after
     *
     * @param array $samples the samples to let down
     * @param float $carried what the filter was holding from the
     * @return array the samples with the tilt let down
     */
    public static function letDownTilt($samples, &$carried)
    {
        $out = [];
        $held = $carried;
        foreach ($samples as $sample) {
            $now = $sample + $held;
            $held = self::TILT_BACK * $now;
            $out[] = $now;
        }
        $carried = $held;
        return $out;
    }
}
X