Anmelden
DE
games/satisfying-bounce / src / Lib / Shorts / Voice.php · commit 8bdd6e4
<?phpnamespace JumPHP\Shorts;/** * The voices of the videos, rendered as float samples (-1..1) at RATE Hz: soft plucks, bells, marimba, water drops, pads, and * synthesized cat sounds (a meow: a glide in pitch through moving vowel formants; a purr: a low rumble pulsed at 25 Hz). * Everything is generated here, no recording. Phase is integrated, so pitch glides never click. */final class Voice{    public const RATE = 44100;    /** the rate of the sounds a game plays live: half the work, plenty for a speaker */    public const LIVE_RATE = 22050;    public const WAVES = ['pluck', 'bell', 'marimba', 'sine', 'pad', 'drop', 'meow', 'purr'];    /** @var array<string, list<float>> */    private static array $cache = [];    /**     * The samples of a note at the film's rate (44100 Hz), cached: the offline mix asks for the same notes again and again.     *     * @return list<float>     */    public static function render(string $wave, float $freq, float $length, float $volume = 0.35): array    {        $key = $wave . '|' . round($freq, 2) . '|' . round($length, 3) . '|' . round($volume, 3);        return self::$cache[$key] ??= self::build($wave, $freq, $length, $volume, self::RATE);    }    /**     * One sample loop per voice (no switch inside the loop: this runs in the player's browser too). $rate: RATE for the film, LIVE_RATE     * for a game played live (half the samples to compute, plenty for a phone's speaker).     *     * @return list<float>     */    private static function build(string $wave, float $f, float $len, float $vol, int $rate): array    {        $n = max(1, (int)round($len * $rate));        $out = [];        $two = 2 * M_PI;        $p = 0.0;        $seed = 12345.0;        $lp = 0.0;        switch ($wave) {            case 'bell':                $k0 = 4.2 / max(0.2, $len) * 1.6;                for ($i = 0; $i < $n; $i++) {                    $t = $i / $rate;                    $env = min(1.0, $t / 0.004) * exp(-$t * $k0);                    $v = sin($two * $f * $t) + 0.45 * sin($two * $f * 2.76 * $t) * exp(-$t * 5) + 0.22 * sin($two * $f * 5.4 * $t) * exp(-$t * 9)                        + 0.5 * sin($two * $f * 2.0 * $t) * exp(-$t * 3);                    $out[] = $v * 0.55 * $env * $vol;                }                break;            case 'marimba':                $k0 = 7 / max(0.15, $len) * 1.3;                for ($i = 0; $i < $n; $i++) {                    $t = $i / $rate;                    $env = min(1.0, $t / 0.004) * exp(-$t * $k0);                    $out[] = (sin($two * $f * $t) + 0.3 * sin($two * $f * 4 * $t) * exp(-$t * 18)) * $env * $vol;                }                break;            case 'sine':                $k0 = 4.5 / max(0.15, $len);                for ($i = 0; $i < $n; $i++) {                    $t = $i / $rate;                    $env = min(1.0, $t / 0.012) * exp(-$t * $k0) * min(1.0, ($n - $i) / (0.03 * $rate));                    $out[] = sin($two * $f * $t) * $env * $vol;                }                break;            case 'pad':                $a = $len * 0.4;                for ($i = 0; $i < $n; $i++) {                    $t = $i / $rate;                    $env = max(0.0, min($t / $a, ($len - $t) / ($len - $a), 1.0));                    $env *= $env * (3 - 2 * min(1.0, $env + 0.0));                    $v = 0.5 * sin($two * $f * $t) + 0.35 * sin($two * $f * 1.004 * $t) + 0.25 * sin($two * $f * 0.5 * $t) + 0.12 * sin($two * $f * 2.0 * $t);                    $out[] = $v * $env * $vol;                }                break;            case 'drop':                // a water drop: a short upward chirp, then a soft ring                $k0 = 9 / max(0.15, $len) * 1.4;                for ($i = 0; $i < $n; $i++) {                    $t = $i / $rate;                    $p += $two * $f * (1 + 0.75 * (1 - exp(-$t * 38))) / $rate;                    $env = min(1.0, $t / 0.003) * exp(-$t * $k0);                    $out[] = (sin($p) + 0.18 * sin(2 * $p)) * $env * $vol;                }                break;            case 'meow':                // me-ow: pitch up then down, the vowel moves from "ee" to "ow"; a little vibrato in the middle                for ($i = 0; $i < $n; $i++) {                    $t = $i / $rate;                    $u = $i / $n;                    $glide = $u < 0.28 ? 0.86 + 0.5 * ($u / 0.28) : 1.36 - 0.62 * (($u - 0.28) / 0.72) ** 1.3;                    $fr = $f * $glide * (1 + 0.012 * sin($two * 6 * $t) * min(1.0, $u * 3));                    $p += $two * $fr / $rate;                    $env = min(1.0, $t / 0.035) * min(1.0, ($n - $i) / (0.12 * $rate)) * (0.75 + 0.25 * sin(M_PI * min(1.0, $u * 1.2)));                    $f1 = 650 - 250 * sin(M_PI * min(1.0, $u * 1.4));                    $f2 = 1100 + 1350 * sin(M_PI * min(1.0, $u * 1.3)) ** 2;                    $v = 0.0;                    $maxH = (int)min($rate < self::RATE ? 8 : 14, floor(5000 / max(80.0, $fr)));                    for ($h = 1; $h <= $maxH; $h++) {                        $fh = $h * $fr;                        $w = exp(-(($fh - $f1) / 330) ** 2) + 0.75 * exp(-(($fh - $f2) / 520) ** 2) + 0.06;                        $v += $w * sin($h * $p) / $h ** 0.7;                    }                    $out[] = $v * 0.45 * $env * $vol;                }                break;            case 'purr':                for ($i = 0; $i < $n; $i++) {                    $t = $i / $rate;                    $env = min(1.0, $t / 0.25) * min(1.0, ($n - $i) / (0.25 * $rate));                    $seed = fmod($seed * 16807.0, 2147483647.0);                    $noise = $seed / 2147483647.0 * 2 - 1;                    $lp += 0.08 * ($noise - $lp);                    $mod = 0.5 + 0.5 * sin($two * 25 * $t);                    $out[] = $mod * (0.6 * sin($two * 52 * $t) + 0.3 * sin($two * 104 * $t) + 1.6 * $lp) * $env * $vol;                }                break;            default:                       // pluck                $k0 = 5.0 / max(0.12, $len);                for ($i = 0; $i < $n; $i++) {                    $t = $i / $rate;                    $p = fmod($t * $f, 1.0);                    $env = min(1.0, $t / 0.008) * exp(-$t * $k0) * min(1.0, ($n - $i) / (0.02 * $rate));                    $v = 0.6 * sin($two * $p) + 0.28 * sin(2 * $two * $p) + 0.08 * sin(3 * $two * $p) + 0.04 * (4 * abs($p - 0.5) - 1);                    $out[] = $v * $env * $vol;                }        }        return $out;    }    /**     * A WAV (mono, 16 bits) of a note, for live playback. Not cached here (SoundManager keeps the bytes it loaded) and at LIVE_RATE     * unless $rate says otherwise: the film's mix uses render() at RATE.     */    public static function wav(string $wave, float $freq, float $length, float $volume = 0.35, int $rate = self::LIVE_RATE): string    {        $ints = [];        foreach (self::build($wave, $freq, min(1.0, $length), $volume, $rate) as $v) {            $ints[] = (int)(32767 * max(-1.0, min(1.0, $v))) & 0xFFFF;        }        $data = pack('v*', ...$ints);        return self::header(strlen($data), $rate) . $data;    }    public static function header(int $bytes, int $rate = self::RATE): string    {        return 'RIFF' . pack('V', 36 + $bytes) . 'WAVE' . 'fmt ' . pack('VvvVVvv', 16, 1, 1, $rate, $rate * 2, 2, 16) . 'data' . pack('V', $bytes);    }}