Files
feuerwerk/api/lib/SoundStore.php
T
admin 5671a38044
game-maker / suite (push) Successful in 12m1s
A game that stays silent is only half a game, so a sound is an asset like any other (#45)
Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.

Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.

Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.

The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
2026-09-15 07:54:28 +02:00

158 lines
6.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Content-addressed sound store: `data/sounds/{sha256}.{ext}` – the font store's twin (see
* FontStore), for the same reasons: one file per sound however often it is uploaded, a URL that
* can be cached forever, and deleting a sound asset stays undoable because nothing here is
* removed when the last project stops referencing it.
*
* A sound is stored byte for byte. There is no re-encoding step as there is for images: the
* browser decodes what was picked, and nothing here could encode audio anyway.
*/
final class SoundStore
{
/**
* Accepted formats, keyed by the bytes they start with. MP3 is the exception with no single
* signature: a file either opens with an ID3 tag or straight with a frame header, whose first
* byte is `0xFF` and whose second has the top three bits set (see `mp3Extension`).
*/
private const SIGNATURES = [
'OggS' => 'ogg',
'ID3' => 'mp3',
];
public function __construct(private readonly string $dir)
{
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
throw new RuntimeException("Cannot create sound directory {$this->dir}");
}
}
/** A stored file name, as it appears in a project document. */
public static function isValidName(string $name): bool
{
return (bool) preg_match('/^[a-f0-9]{64}\.(mp3|ogg|wav|m4a)$/', $name);
}
/**
* Stores the raw bytes of a sound and returns its record.
*
* The format is read from the file's own signature, never from anything the client says –
* browsers report a dropped `.m4a` as `audio/mp4`, `audio/x-m4a` or nothing at all, and an
* `.ogg` as either `audio/ogg` or `video/ogg`.
*
* `$originalName` is kept beside the file as a caption (see OriginalName): the hash says
* nothing, and there is no tag in here that is worth trusting for a label.
*
* @return array{file:string,name:string,bytes:int}
* @throws InvalidSoundException when the bytes are not a supported sound
*/
public function store(string $bytes, string $originalName = ''): array
{
$extension = self::extensionOf($bytes);
if ($extension === '') {
throw new InvalidSoundException('Not a sound file – use MP3, OGG, WAV or M4A');
}
$file = hash('sha256', $bytes) . '.' . $extension;
$path = $this->dir . '/' . $file;
if (!is_file($path)) {
$this->writeFile($path, $bytes);
}
OriginalName::write($this->dir, $file, $originalName);
return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), 'bytes' => strlen($bytes)];
}
/**
* Every stored file, newest first – what the sound picker offers. A sound has nothing to show,
* so the picker plays it instead; length is not read here, since only a decoder knows it.
*
* @return list<array{file:string,name:string,bytes:int,updatedAt:string}>
*/
public function list(): array
{
$items = [];
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
$file = basename($path);
// Sidecars share the directory and are skipped: `{hash}.mp3.json` is not a valid name.
if (!self::isValidName($file)) {
continue;
}
$items[] = [
'file' => $file,
'name' => OriginalName::read($this->dir, $file),
'bytes' => (int) filesize($path),
'updatedAt' => gmdate('c', (int) filemtime($path)),
];
}
usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt']));
return $items;
}
public function delete(string $file): bool
{
if (!self::isValidName($file)) {
return false;
}
$path = $this->dir . '/' . $file;
if (!is_file($path) || !unlink($path)) {
return false;
}
OriginalName::delete($this->dir, $file);
return true;
}
/** The format these bytes are, `''` when they are none we take. */
private static function extensionOf(string $bytes): string
{
// The shortest thing that can still carry a header of any of the four formats.
if (strlen($bytes) < 12) {
return '';
}
foreach (self::SIGNATURES as $magic => $extension) {
if (str_starts_with($bytes, $magic)) {
return $extension;
}
}
// Both are RIFF/ISO container formats, so the type sits past the size field, not at byte 0.
if (str_starts_with($bytes, 'RIFF') && substr($bytes, 8, 4) === 'WAVE') {
return 'wav';
}
if (substr($bytes, 4, 4) === 'ftyp') {
return 'm4a';
}
return self::mp3Extension($bytes);
}
/**
* A tagless MP3: a frame header is eleven set bits (`0xFF 0xEx`/`0xFx`), and the two bits below
* them name the MPEG version, of which `01` is reserved – demanding that much keeps a random
* binary that happens to start with `0xFF` out.
*/
private static function mp3Extension(string $bytes): string
{
$first = ord($bytes[0]);
$second = ord($bytes[1]);
$version = ($second >> 3) & 0b11;
return $first === 0xFF && ($second & 0xE0) === 0xE0 && $version !== 0b01 ? 'mp3' : '';
}
/** Temp file plus rename, so a reader never sees a half-written sound (see TextureStore). */
private function writeFile(string $path, string $bytes): void
{
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) {
@unlink($tmp);
throw new RuntimeException('Cannot write sound file');
}
@chmod($path, 0664);
}
}
/** Thrown by SoundStore::store when the uploaded bytes are not a usable sound. */
final class InvalidSoundException extends RuntimeException
{
}