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

/**
 * SpeechShape reads the shape of a stretch of speech: the mouth and
 * throat that made the sound, written as sixteen frequencies that rise
 * in order.
 *
 * A stretch does not carry those frequencies outright. It names one
 * guess out of a book of thirty-two, then writes a small mending for
 * each of the guess's frequencies, and the mendings lean on one another
 * so that a run of them costs few bits. The frequencies that come out
 * are then pushed apart where two of them sit too close, since two
 * frequencies that crossed would describe a shape no mouth can make.
 *
 * A caller reads the shape after the opening of a stretch and before its
 * pulses, since the pulses are filtered by the shape to make sound.
 *
 * @author Chris Pollett
 */
class SpeechShape
{
    /**
     * $guess stores which guess out of the book the stretch named.
     * @var int
     */
    public $guess = 0;
    /**
     * $mendings stores the mending written for each frequency of that
     * guess, in the order the frequencies rise.
     * @var array
     */
    public $mendings = [];
    /**
     * $frequencies stores the shape itself: sixteen frequencies rising
     * in order, each a fraction of the highest frequency the sound can
     * hold, written out of 32768.
     * @var array
     */
    public $frequencies = [];
    /**
     * $blend stores how much of the shape of the stretch before this one
     * is blended into this one, as the number the stretch wrote.
     * @var int
     */
    public $blend = 0;
    /**
     * MENDING_SCALE is how much one step of mending moves a frequency,
     * written in the same units as a frequency.
     */
    const MENDING_SCALE = 1 << 14;
    /**
     * WHOLE_TURN is the number a frequency of half the sampling rate is
     * written as. A frequency is a fraction of that.
     */
    const WHOLE_TURN = 1 << 15;
    /**
     * LARGEST_MENDING is the mending furthest from no change at all, in
     * steps. A mending at either end is written with extra bits, since
     * the writer may have wanted to move further still.
     */
    const LARGEST_MENDING = 4;
    /**
     * PUSHING_ROUNDS is how many times the tightest gap between two
     * frequencies is opened up before the run is simply put in order
     * and swept from each end.
     */
    const PUSHING_ROUNDS = 20;
    /**
     * read gives the shape a stretch of speech carries: the guess it
     * named, the mending of each frequency, and the frequencies those
     * two make between them.
     *
     * @param object $reader the range decoder reading the stretch
     * @param string $kind which kind of sound the stretch holds, since
     *     speech made with the voice uses different likelihoods from
     *     speech made without it
     * @return object a shape holding the guess, the mendings and the
     *     frequencies
     */
    public static function read($reader, $kind)
    {
        $shape = new self();
        $voiced = ($kind === SpeechTables::VOICED_KIND);
        $shape->guess = self::readGuess($reader, $voiced);
        $shape->mendings = self::readMendings($reader, $shape->guess);
        $shape->frequencies = self::frequenciesFrom($shape->guess,
            $shape->mendings, $voiced);
        /* A stretch of four quarters says how much of the shape before
           it to blend in. The number is not used yet, but the bits have
           to be taken so that what follows is read from the right
           place. */
        $shape->blend = $reader->decodeFromTable(
            SpeechShapeTables::SHAPE_BLEND_CHANCES,
            SpeechShapeTables::WHOLE_BITS);
        return $shape;
    }
    /**
     * readGuess gives the number of the guess a stretch names out of the
     * book of shapes. Speech made with the voice and speech made without
     * it name their guesses from different halves of the same table of
     * likelihoods.
     *
     * @param object $reader the range decoder reading the stretch
     * @param bool $voiced whether the stretch holds speech made with the
     *     voice
     * @return int which guess of the book the stretch named
     */
    public static function readGuess($reader, $voiced)
    {
        $chances = SpeechShapeTables::SHAPE_GUESS_CHANCES;
        $from = $voiced ? SpeechShapeTables::GUESSES_IN_BOOK : 0;
        $table = array_slice($chances, $from,
            SpeechShapeTables::GUESSES_IN_BOOK);
        return $reader->decodeFromTable($table,
            SpeechShapeTables::WHOLE_BITS);
    }
    /**
     * readMendings gives the mending written for each frequency of a
     * guess. Each frequency is mended with its own set of likelihoods,
     * chosen by how crowded that part of the book is, and a mending at
     * either end of its range is followed by extra bits saying how much
     * further the writer wanted to move.
     *
     * @param object $reader the range decoder reading the stretch
     * @param int $guess which guess of the book the stretch named
     * @return array the mending of each frequency, in steps
     */
    public static function readMendings($reader, $guess)
    {
        $mendings = [];
        $count = SpeechShapeTables::FREQUENCIES_IN_SHAPE;
        for ($at = 0; $at < $count; $at++) {
            /* One number of the table carries the fineness for two
               frequencies and, in its lowest bit of each half, which
               neighbor that frequency leans on. So the fineness of the
               first is bits one to three and of the second bits five to
               seven; reading whole halves put every second frequency in
               the wrong table. */
            $which = $guess * $count + $at;
            $packed = SpeechShapeTables::SHAPE_MEND_TABLE_FOR[
                intdiv($which, 2)];
            $fineness = ($which % 2 == 0) ? (($packed >> 1) & 7) :
                (($packed >> 5) & 7);
            /* The eight sets of likelihoods hold nine entries each, and
               the fineness names which set to walk. A fineness past the
               eighth would leave the reader with no table at all. */
            $sets = intdiv(count(SpeechShapeTables::SHAPE_MEND_CHANCES),
                SpeechShapeTables::MENDING_STEPS - 1);
            $fineness = $fineness % $sets;
            $table = array_slice(SpeechShapeTables::SHAPE_MEND_CHANCES,
                $fineness * (SpeechShapeTables::MENDING_STEPS - 1),
                SpeechShapeTables::MENDING_STEPS - 1);
            $raw = $reader->decodeFromTable($table,
                SpeechShapeTables::WHOLE_BITS);
            /* A mending at either end of its range is followed by how
               much further the writer wanted to move, read with a table
               of its own. */
            if ($raw == 0) {
                $raw -= $reader->decodeFromTable(
                    SpeechShapeTables::MENDING_BEYOND_CHANCES,
                    SpeechShapeTables::WHOLE_BITS);
            } else if ($raw == self::LARGEST_MENDING * 2) {
                $raw += $reader->decodeFromTable(
                    SpeechShapeTables::MENDING_BEYOND_CHANCES,
                    SpeechShapeTables::WHOLE_BITS);
            }
            $mendings[] = $raw - self::LARGEST_MENDING;
        }
        return $mendings;
    }
    /**
     * frequenciesFrom works out the shape itself from the guess and its
     * mendings. Each mending is scaled and added to its frequency, part
     * of it is carried into the next frequency, and the result is pushed
     * apart wherever two frequencies sit closer than the least gap.
     *
     * @param int $guess which guess of the book the stretch named
     * @param array $mendings the mending of each frequency, in steps
     * @param bool $voiced whether the stretch holds speech made with the
     *     voice
     * @return array sixteen frequencies rising in order
     */
    public static function frequenciesFrom($guess, $mendings, $voiced)
    {
        $count = SpeechShapeTables::FREQUENCIES_IN_SHAPE;
        $moved = self::mendingsMoved($guess, $mendings);
        $frequencies = [];
        for ($at = 0; $at < $count; $at++) {
            $guessed = SpeechShapeTables::SHAPE_FIRST_GUESSES[
                $guess * $count + $at];
            $weight = SpeechShapeTables::SHAPE_GUESS_WEIGHTS[
                $guess * $count + $at];
            /* A mending is divided by how tightly the book holds that
               frequency, so a frequency the book pins down closely moves
               less for the same mending. */
            $one = intdiv($moved[$at] << 14, $weight) + ($guessed << 7);
            $frequencies[] = max(0, min($one, 32767));
        }
        return self::pushedApart($frequencies);
    }
    /**
     * mendingsMoved works out how far each frequency of a guess is
     * moved, from the mendings the stretch wrote. Each mending leans on
     * the one above it, so the run is worked out from the top down, and
     * a mending that is not zero is nudged back toward zero because the
     * writer rounded it away.
     *
     * @param int $guess which guess of the book the stretch named
     * @param array $mendings the mending of each frequency, in steps
     * @return array how far each frequency moves, written out of 1024
     */
    public static function mendingsMoved($guess, $mendings)
    {
        $count = SpeechShapeTables::FREQUENCIES_IN_SHAPE;
        $leaning = self::leaningFor($guess);
        $moved = array_fill(0, $count, 0);
        $so_far = 0;
        for ($at = $count - 1; $at >= 0; $at--) {
            $leant = ($so_far * $leaning[$at]) >> 8;
            $step = $mendings[$at] << 10;
            if ($step > 0) {
                $step -= SpeechShapeTables::MENDING_LEVEL_ADJUST;
            } else if ($step < 0) {
                $step += SpeechShapeTables::MENDING_LEVEL_ADJUST;
            }
            $so_far = $leant + (($step *
                SpeechShapeTables::MENDING_STEP_SIZE) >> 16);
            $moved[$at] = $so_far;
        }
        return $moved;
    }
    /**
     * leaningFor gives, for each frequency of a guess, how much of the
     * mending above it is carried down into it. There are two sets of
     * these, and which one a frequency uses is written in the lowest
     * bit of its half of the same number that names its fineness.
     *
     * @param int $guess which guess of the book the stretch named
     * @return array how much each frequency leans on the one above it
     */
    public static function leaningFor($guess)
    {
        $count = SpeechShapeTables::FREQUENCIES_IN_SHAPE;
        $table = SpeechShapeTables::SHAPE_LEAN_ON_NEIGHBOUR;
        $leaning = [];
        for ($at = 0; $at < $count; $at++) {
            $which = $guess * $count + $at;
            $packed = SpeechShapeTables::SHAPE_MEND_TABLE_FOR[
                intdiv($which, 2)];
            $set = ($at % 2 == 0) ? ($packed & 1) : (($packed >> 4) & 1);
            $where = $at + $set * ($count - 1);
            $leaning[] = $table[$where] ?? 0;
        }
        return $leaning;
    }
    /**
     * pushedApart moves frequencies that sit too close to one another
     * until each is at least the least gap from its neighbors, and
     * holds them all within the range a frequency may take. Frequencies
     * that crossed would describe a shape no mouth can make, and the
     * filter built from them would not settle.
     *
     * @param array $frequencies the frequencies as the mendings left
     *     them
     * @return array the same frequencies, rising in order and far enough
     *     apart
     */
    public static function pushedApart($frequencies)
    {
        $count = count($frequencies);
        $gaps = SpeechShapeTables::SHAPE_LEAST_GAP;
        /* The tightest place is found and opened up, and that is done
           again until every gap is wide enough. Opening the tightest
           place first keeps the two frequencies around it centered
           where they were, which a single sweep from one end does not.
         */
        for ($round = 0; $round < self::PUSHING_ROUNDS; $round++) {
            $smallest = $frequencies[0] - $gaps[0];
            $where = 0;
            for ($at = 1; $at <= $count - 1; $at++) {
                $gap = $frequencies[$at] - ($frequencies[$at - 1] +
                    $gaps[$at]);
                if ($gap < $smallest) {
                    $smallest = $gap;
                    $where = $at;
                }
            }
            $gap = self::WHOLE_TURN - ($frequencies[$count - 1] +
                $gaps[$count]);
            if ($gap < $smallest) {
                $smallest = $gap;
                $where = $count;
            }
            if ($smallest >= 0) {
                return $frequencies;
            }
            if ($where == 0) {
                $frequencies[0] = $gaps[0];
            } else if ($where == $count) {
                $frequencies[$count - 1] = self::WHOLE_TURN -
                    $gaps[$count];
            } else {
                $lowest = 0;
                for ($at = 0; $at < $where; $at++) {
                    $lowest += $gaps[$at];
                }
                $lowest += $gaps[$where] >> 1;
                $highest = self::WHOLE_TURN;
                for ($at = $count; $at > $where; $at--) {
                    $highest -= $gaps[$at];
                }
                $highest -= $gaps[$where] >> 1;
                $middle = ($frequencies[$where - 1] +
                    $frequencies[$where] + 1) >> 1;
                $middle = max($lowest, min($middle, $highest));
                $frequencies[$where - 1] = $middle - ($gaps[$where] >> 1);
                $frequencies[$where] = $frequencies[$where - 1] +
                    $gaps[$where];
            }
        }
        /* Where that many rounds did not settle it, the frequencies are
           put in order and each gap opened once from each end. */
        sort($frequencies);
        $frequencies[0] = max($frequencies[0], $gaps[0]);
        for ($at = 1; $at < $count; $at++) {
            $frequencies[$at] = max($frequencies[$at],
                $frequencies[$at - 1] + $gaps[$at]);
        }
        $frequencies[$count - 1] = min($frequencies[$count - 1],
            self::WHOLE_TURN - $gaps[$count]);
        for ($at = $count - 2; $at >= 0; $at--) {
            $frequencies[$at] = min($frequencies[$at],
                $frequencies[$at + 1] - $gaps[$at + 1]);
        }
        return $frequencies;
    }
}
X