> */ 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 */ 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 */ 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); } }