ES
games/satisfying-bounce / src / Lib / Shorts / Recorder.php · commit 8bdd6e4
<?phpnamespace JumPHP\Shorts;use JumPHP\Audio\SoundManager;use JumPHP\Kernel\Platform;/** * Notes a scene plays (a collision, the next note of a Melody): heard at once through SoundManager, and, while * bin/jumphp video:make renders the scene (JUMPHP_VIDEO_OUT), written to <out>/notes.json for the offline mix. * reset() in the scene's enter(); note($time, $freq) with the scene's own clock (seconds since enter()); pump() once per frame. * * Played live, a voice is generated in PHP the first time a note is asked for, which costs a few milliseconds (a meow, 15 or more): * done in the middle of a bounce it makes a frame late, and in a phone's browser several times that. So the live path is cheap and * spread out: the voices are made at a half sample rate (Voice::LIVE_RATE), warm() queues the ones a scene will need, and pump() * makes at most a few milliseconds' worth of them per frame (the one a bounce needs right now first, then the others); a note * whose voice is not ready yet is played as soon as it is, if that is still soon enough to match its bounce. */final class Recorder{    /** the live notes kept for notes() (tests, tools): the oldest are dropped */    private const KEEP = 2000;    /** a note whose voice came more than this late (seconds) is not played any more */    private const LATE = 0.18;    /** @var list<array{time: float, freq: float, length: float, wave: string, volume: float}> */    private array $notes = [];    /** @var array<string, array{0: string, 1: float, 2: float, 3: float, 4: ?float}> name => wave, freq, length, volume, asked-at (null: warm-up only) */    private array $pending = [];    /** voices made (live), for measuring */    private int $made = 0;    /** @var array<string, true> voices made (SoundManager only remembers them when it has an audio device) */    private array $known = [];    /** a scene that calls pump() gets the spread-out making of voices; one that never does has them made at once (as before) */    private bool $pumped = false;    private ?string $outDir = null;    private bool $outKnown = false;    public function __construct(private SoundManager $sound)    {    }    /** The folder video:make writes into, or null when the scene is just played. */    public function out(): ?string    {        if (!$this->outKnown) {            $out = Platform::setting('JUMPHP_VIDEO_OUT');            $this->outDir = $out === null || $out === '' ? null : (string)$out;            $this->outKnown = true;        }        return $this->outDir;    }    public function isRecording(): bool    {        return $this->out() !== null;    }    public function reset(float $fadeOut = 0.0): void    {        $this->notes = [];        $this->pending = [];        $this->save();        $out = $this->out();        if ($out !== null) {            file_put_contents("$out/meta.json", json_encode(['fadeOut' => $fadeOut]));        }    }    public function note(float $time, float $freq, float $length = 0.35, string $wave = 'pluck', float $volume = 0.35, bool $live = true): void    {        $this->notes[] = ['time' => round($time, 4), 'freq' => round($freq, 3), 'length' => $length, 'wave' => $wave, 'volume' => $volume];        if ($live && !$this->isRecording()) {          // a film is mixed offline: nothing to play (and nothing to write in the temp folder)            $name = self::name($wave, $freq, $length);            if ($this->ready($name)) {                $this->sound->playSfx($name);            } elseif ($this->pumped) {                $this->pending[$name] = [$wave, $freq, $length, $volume, microtime(true)];            } else {                $this->sound->synth($name, Voice::wav($wave, $freq, $length, $volume));                $this->known[$name] = true;                $this->made++;                $this->sound->playSfx($name);            }        }        if (count($this->notes) > self::KEEP * 2 && !$this->isRecording()) {            $this->notes = array_slice($this->notes, -self::KEEP);        }        $this->save();    }    /** Silences every voice playing and forgets the ones waiting (leaving a scene). */    public function stopAll(): void    {        $this->pending = [];        foreach (array_keys($this->known) as $name) {            $this->sound->stopSfx($name);        }    }    /** Queues a voice to be made in the background (one of the notes the scene is going to play). */    public function warm(string $wave, float $freq, float $length = 0.35, float $volume = 0.35): void    {        if ($this->isRecording()) {            return;        }        $name = self::name($wave, $freq, $length);        if (!$this->ready($name) && !isset($this->pending[$name])) {            $this->pending[$name] = [$wave, $freq, $length, $volume, null];        }    }    /**     * Makes the voices that wait, at most $budgetMs milliseconds of them (always one when a bounce is waiting for its note, one     * background voice per frame otherwise). Call it once a frame, at the end of the scene's update().     *     * @return int how many voices were made     */    public function pump(float $budgetMs = 3.0): int    {        $this->pumped = true;        if ($this->pending === []) {            return 0;        }        $started = microtime(true);        $made = 0;        // the notes asked for now first        $order = array_keys($this->pending);        usort($order, fn (string $a, string $b): int => ($this->pending[$a][4] === null) <=> ($this->pending[$b][4] === null));        foreach ($order as $name) {            [$wave, $freq, $length, $volume, $asked] = $this->pending[$name];            if ($made > 0 && (microtime(true) - $started) * 1000 >= $budgetMs) {                break;            }            if ($asked === null && $made > 0) {                break;            }            unset($this->pending[$name]);            $this->sound->synth($name, Voice::wav($wave, $freq, $length, $volume));            $this->known[$name] = true;            $this->made++;            $made++;            if ($asked !== null && microtime(true) - $asked < self::LATE) {                $this->sound->playSfx($name);            }        }        return $made;    }    /** How many voices wait to be made. */    public function waiting(): int    {        return count($this->pending);    }    /** How many voices were made live since the start. */    public function made(): int    {        return $this->made;    }    /** @return list<array{time: float, freq: float, length: float, wave: string, volume: float}> */    public function notes(): array    {        return $this->notes;    }    private function ready(string $name): bool    {        return isset($this->known[$name]) || $this->sound->hasSfx($name);    }    private static function name(string $wave, float $freq, float $length): string    {        return 'shorts-' . $wave . '-' . (int)round($freq) . '-' . (int)round(min(1.0, $length) * 100);    }    private function save(): void    {        $out = $this->out();        if ($out !== null) {            @mkdir($out, 0777, true);            file_put_contents("$out/notes.json", json_encode($this->notes));        }    }}