/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 */ private array $notes = []; /** @var array 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 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 */ 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)); } } }