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

/**
 * SpeechSamples turns everything read out of a stretch of speech into
 * the samples that are played.
 *
 * The pulses are first turned into a rough sound: each pulse is nudged
 * back from the value the writer rounded it to, an offset is added, and
 * the whole sample is flipped up or down by a run of numbers that both
 * sides work out the same way from the starting number the stretch
 * carries. A stretch of speech made with the voice then has the sound it
 * made one pitch-length ago added back in through its five tap filter.
 * Last, each sample is passed through the sixteen term filter the shape
 * describes and scaled by the loudness of its quarter.
 *
 * A caller uses this once the opening, shape, pitch and pulses of a
 * stretch have all been read. What comes back is one whole number for
 * each sample, at sixteen thousand samples a second.
 *
 * @author Chris Pollett
 */
class SpeechSamples
{
    /**
     * NOISE_MULTIPLIER and NOISE_INCREMENT make the run of numbers that
     * decides which way each sample is flipped. Both sides work the run
     * out the same way from the stretch's starting number, so what
     * sounds like noise is written with no bits at all.
     */
    const NOISE_MULTIPLIER = 196314165;
    /**
     * NOISE_INCREMENT is added at each step of that run of numbers.
     */
    const NOISE_INCREMENT = 907633515;
    /**
     * ROUNDING_NUDGE is how far a pulse is moved back toward zero, since
     * the writer rounded it away from the value it stood for.
     */
    const ROUNDING_NUDGE = 80;
    /**
     * QUIET_OFFSET is added to every sample of a stretch whose pulses
     * were written the quieter way, and LOUD_OFFSET to one written the
     * louder way. The offset keeps a run of samples holding no pulses
     * from being exactly silent.
     */
    const QUIET_OFFSET = 100;
    /**
     * LOUD_OFFSET is added to every sample of a stretch whose pulses
     * were written the louder way.
     */
    const LOUD_OFFSET = 240;
    /**
     * VOICED_QUIET_OFFSET is the offset for a stretch of speech made
     * with the voice whose pulses were written the quieter way.
     */
    const VOICED_QUIET_OFFSET = 32;
    /**
     * VOICED_LOUD_OFFSET is the offset for a stretch of speech made with
     * the voice whose pulses were written the louder way.
     */
    const VOICED_LOUD_OFFSET = 100;
    /**
     * WHOLE_NUMBER is how many bits of fraction the sound is carried
     * with while it is being worked out. Keeping the fraction until the
     * end stops rounding from piling up over three hundred samples.
     */
    const WHOLE_NUMBER = 14;
    /**
     * TAPS_IN_PITCH_FILTER is how many taps the filter that adds back
     * the sound of one pitch-length ago holds.
     */
    const TAPS_IN_PITCH_FILTER = 5;
    /**
     * FILTER_BITS is how many bits of fraction a term of the shape
     * filter carries, so a term of one is written as 4096.
     */
    const FILTER_BITS = 12;
    /**
     * INVERSE_BITS is how many bits the one is shifted up by before it
     * is divided by a quarter's scale, so that the answer is a
     * multiplier the pitch filter can use without losing the small
     * values.
     */
    const INVERSE_BITS = 47;
    /**
     * LARGEST_WHOLE is the largest whole number a word holds, which the
     * division uses as the numerator of its rough inverse.
     */
    const LARGEST_WHOLE = 2147483647;
    /**
     * roughSound gives the sound a stretch's pulses stand for, before
     * either filter is applied. Each pulse is nudged back toward zero,
     * an offset is added, and the sample is flipped by the run of
     * numbers the stretch's starting number begins.
     *
     * @param array $pulses one number for each sample of the stretch
     * @param int $seed the starting number the stretch carries
     * @param string $kind which kind of sound the stretch holds
     * @param string $pulse_writing which of the two ways the pulses were
     *     written
     * @return array the rough sound, one number for each sample
     */
    public static function roughSound($pulses, $seed, $kind,
        $pulse_writing)
    {
        $offset = self::offsetFor($kind, $pulse_writing);
        $running = $seed;
        $sound = [];
        foreach ($pulses as $one) {
            $running = (self::heldInWord(self::NOISE_INCREMENT
                + $running * self::NOISE_MULTIPLIER));
            $sample = $one << self::WHOLE_NUMBER;
            if ($sample > 0) {
                $sample -= self::ROUNDING_NUDGE << 4;
            } else if ($sample < 0) {
                $sample += self::ROUNDING_NUDGE << 4;
            }
            $sample += $offset << 4;
            if ($running < 0) {
                $sample = -$sample;
            }
            $running = self::heldInWord($running + $one);
            $sound[] = $sample;
        }
        return $sound;
    }
    /**
     * offsetFor gives the offset added to every sample of a stretch,
     * which depends on the kind of sound and on which of the two ways
     * its pulses were written.
     *
     * @param string $kind which kind of sound the stretch holds
     * @param string $pulse_writing which of the two ways the pulses were
     *     written
     * @return int the offset, in thousandths of the sound's full range
     */
    public static function offsetFor($kind, $pulse_writing)
    {
        $louder = ($pulse_writing === SpeechTables::LOUDER_PULSES);
        if ($kind === SpeechTables::VOICED_KIND) {
            return $louder ? self::VOICED_LOUD_OFFSET :
                self::VOICED_QUIET_OFFSET;
        }
        return $louder ? self::LOUD_OFFSET : self::QUIET_OFFSET;
    }
    /**
     * heldInWord keeps a number inside the thirty-two bits the standard
     * works in, wrapping it around rather than letting it grow. PHP
     * counts far past that, so the wrapping has to be written out.
     *
     * @param int $number the number to hold
     * @return int the same number seen as a signed thirty-two bit whole
     *     number
     */
    public static function heldInWord($number)
    {
        $held = $number & 0xFFFFFFFF;
        if ($held >= 0x80000000) {
            $held -= 0x100000000;
        }
        return $held;
    }
    /**
     * soundRunBackward takes samples already played and runs them
     * backward through the filter the shape describes, giving the rough
     * sound that made them. A stretch of voiced speech reaches into the
     * sound before it for its pitch, and what it needs there is the
     * rough sound rather than the played samples, so the earlier
     * samples are turned back this way.
     *
     * @param array $played the samples already played, oldest first
     * @param array $terms the sixteen terms of the filter
     * @return array the rough sound behind those samples
     */
    public static function soundRunBackward($played, $terms)
    {
        $count = count($terms);
        $out = [];
        /* The first samples of the run have nothing behind them, so the
           run starts far enough in that every sample handed back has a
           full filter's worth of sound before it. */
        foreach ($played as $at => $one) {
            if ($at < $count) {
                continue;
            }
            $found = 0;
            for ($back = 0; $back < $count; $back++) {
                $found += $played[$at - $back - 1] * $terms[$back];
            }
            $left = ($one << self::FILTER_BITS) - $found;
            $rounded = ($left + (1 << (self::FILTER_BITS - 1))) >>
                self::FILTER_BITS;
            $out[] = max(-32768, min(32767, $rounded));
        }
        return $out;
    }
    /**
     * atFilterScale writes a rough sound at the scale the pitch filter
     * works in, which is the sound divided by the loudness it was played
     * at. A stretch that stands on its own also leans less on that
     * sound, by the amount it wrote, so that a lost packet does not
     * spoil the one after it.
     *
     * @param array $sound the rough sound behind the earlier samples
     * @param int $scale the multiplier those samples were played at
     * @param int $lean how much of the earlier sound to lean on,
     *     written out of 16384, or zero to lean on all of it
     * @return array the sound at the scale the pitch filter works in
     */
    public static function atFilterScale($sound, $scale, $lean = 0)
    {
        if ($scale <= 0) {
            return $sound;
        }
        /* The pitch filter works on sound that has not been scaled by
           loudness, so the earlier samples are divided by the scale
           they were played at. The division is done once, as a
           multiplier, and then applied to each sample. */
        $multiplier = intdiv(1 << self::INVERSE_BITS, $scale);
        if ($lean > 0) {
            $multiplier = (($multiplier * $lean) >> 16) << 2;
        }
        $out = [];
        foreach ($sound as $one) {
            $out[] = ($one * $multiplier) >> 16;
        }
        return $out;
    }
    /**
     * withPitchAdded adds back, for one quarter of a stretch of voiced
     * speech, the sound the speaker made one pitch-length earlier. The
     * five taps say how much of the sound around that moment to take.
     *
     * @param array $rough the rough sound of this quarter
     * @param array $before the sound already worked out, ending just
     *     before this quarter
     * @param int $lag how far back this quarter's pitch reaches
     * @param array $taps the five taps of this quarter's filter
     * @return array the sound of this quarter with its pitch added back,
     *     and the sound to carry on with, as sound and carried
     */
    public static function withPitchAdded($rough, $before, $lag, $taps)
    {
        $out = [];
        $carried = $before;
        $middle = intdiv(self::TAPS_IN_PITCH_FILTER, 2);
        foreach ($rough as $at => $one) {
            $found = 2;
            for ($tap = 0; $tap < self::TAPS_IN_PITCH_FILTER; $tap++) {
                $where = count($carried) - $lag + $middle - $tap;
                $earlier = ($where >= 0 && $where < count($carried)) ?
                    $carried[$where] : 0;
                $found += ($earlier * $taps[$tap]) >> 16;
            }
            $sample = $one + ($found << 1);
            $carried[] = $sample << 1;
            $out[] = $sample;
        }
        return ["sound" => $out, "carried" => $carried];
    }
    /**
     * throughShapeFilter passes one quarter of a stretch through the
     * sixteen term filter its shape describes, and scales what comes out
     * by the loudness of that quarter. This is the last step: what it
     * hands back are the samples that get played.
     *
     * @param array $sound the sound of this quarter after its pitch was
     *     added back
     * @param array $terms the sixteen terms of the filter
     * @param int $scale the multiplier for this quarter, written out of
     *     65536
     * @param array $state the sixteen values the filter ended the last
     *     quarter holding
     * @param int $last_scale the multiplier the quarter before this one
     *     used, so the held values can be moved to this quarter's
     *     scale; zero where there was no quarter before
     * @return array the samples of this quarter, and the state to carry
     *     on with, as samples and state
     */
    public static function throughShapeFilter($sound, $terms, $scale,
        $state, $last_scale = 0)
    {
        $count = count($terms);
        $held = self::stateAtNewScale($state, $last_scale, $scale);
        $samples = [];
        $loudness = $scale >> 6;
        foreach ($sound as $one) {
            $found = $count >> 1;
            for ($back = 0; $back < $count; $back++) {
                $where = count($held) - 1 - $back;
                $earlier = ($where >= 0) ? $held[$where] : 0;
                $found += ($earlier * $terms[$back]) >> 16;
            }
            $sample = $one + ($found << 4);
            $held[] = $sample;
            $played = (($sample * $loudness) >> 16);
            $samples[] = max(-32768, min(32767,
                ($played + 128) >> 8));
        }
        return ["samples" => $samples,
            "state" => array_slice($held, -$count)];
    }
    /**
     * stateAtNewScale moves the values a filter is holding from the
     * scale of the quarter before to the scale of this one. The filter
     * holds sound that was already scaled, so a quarter played louder
     * than the one before would otherwise start from values too quiet
     * and the sound would jump at the join.
     *
     * @param array $state the values the filter is holding
     * @param int $last_scale the multiplier of the quarter before, or
     *     zero where there was none
     * @param int $scale the multiplier of this quarter
     * @return array the held values at this quarter's scale
     */
    public static function stateAtNewScale($state, $last_scale, $scale)
    {
        if ($last_scale <= 0 || $last_scale == $scale || $scale <= 0) {
            return $state;
        }
        $moved = self::oneScaleOverAnother($last_scale, $scale);
        $out = [];
        foreach ($state as $one) {
            $out[] = ($one * $moved) >> 16;
        }
        return $out;
    }
    /**
     * oneScaleOverAnother divides one scale by another, giving the
     * answer with sixteen bits of fraction. The standard takes a rough
     * inverse of the second scale and mends it once rather than
     * dividing outright, and the two ways do not always agree in the
     * last place. Every later sample is built on this answer, so a
     * difference of one count here grows over a recording, and the
     * working is followed step for step.
     *
     * @param int $over the scale being divided
     * @param int $under the scale it is divided by
     * @return int the answer, with sixteen bits of fraction
     */
    public static function oneScaleOverAnother($over, $under)
    {
        $over_room = self::roomAbove(abs($over)) - 1;
        $under_room = self::roomAbove(abs($under)) - 1;
        $over_wide = $over << $over_room;
        $under_wide = $under << $under_room;
        $rough = intdiv(self::LARGEST_WHOLE >> 2, $under_wide >> 16);
        $answer = ((($over_wide >> 16) * $rough) + ((($over_wide &
            0xFFFF) * $rough) >> 16));
        $left = self::heldInWord($over_wide -
            ((self::heldInWord(($under_wide * $answer) >> 32)) << 3));
        $answer += ((($left >> 16) * $rough) + ((($left &
            0xFFFF) * $rough) >> 16));
        $shift = 29 + $over_room - $under_room - 16;
        if ($shift < 0) {
            return $answer << -$shift;
        }
        if ($shift < 32) {
            return $answer >> $shift;
        }
        return 0;
    }
    /**
     * roomAbove says how many bits of room a whole number has above it
     * before it fills a word, which the division uses so that it works
     * with as much of the word as it can.
     *
     * @param int $number the number to measure
     * @return int how many bits stand above it
     */
    public static function roomAbove($number)
    {
        $room = 0;
        $number = $number & 0xFFFFFFFF;
        while ($room < 32 && !($number & 0x80000000)) {
            $number = ($number << 1) & 0xFFFFFFFF;
            $room++;
        }
        return $room;
    }
}
X