A game that stays silent is only half a game, so a sound is an asset like any other (#45)
game-maker / suite (push) Successful in 12m1s
game-maker / suite (push) Successful in 12m1s
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.
This commit is contained in:
1 parent
7babf88636
commit
5671a38044
47 files changed
+2066
-42
No files matched your search
+46
-3
@@ -21,20 +21,26 @@ declare(strict_types=1);
|
||||
* POST ?r=fonts&name=x.ttf raw font bytes → {file,name,bytes}
|
||||
* DELETE ?r=fonts/{file} → 204
|
||||
*
|
||||
* GET ?r=sounds → [{file,name,bytes,updatedAt}]
|
||||
* POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes}
|
||||
* DELETE ?r=sounds/{file} → 204
|
||||
*
|
||||
* `name` is the file's original name, kept beside it as `{file}.json` (see OriginalName) and
|
||||
* empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}`.
|
||||
*
|
||||
* Stored images and fonts are served straight from `data/textures/` and `data/fonts/` as
|
||||
* static files; the API only writes them.
|
||||
* Stored images, fonts and sounds are served straight from `data/textures/`, `data/fonts/` and
|
||||
* `data/sounds/` as static files; the API only writes them.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/lib/FontStore.php';
|
||||
require __DIR__ . '/lib/OriginalName.php';
|
||||
require __DIR__ . '/lib/Response.php';
|
||||
require __DIR__ . '/lib/SoundStore.php';
|
||||
require __DIR__ . '/lib/Store.php';
|
||||
require __DIR__ . '/lib/TextureStore.php';
|
||||
|
||||
const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||
/** Has to clear the largest single upload below, which is a sound; the web server has its own say. */
|
||||
const MAX_BODY_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
/** Upload ceiling for a single image. The editor downscales larger pictures before sending. */
|
||||
const MAX_TEXTURE_BYTES = 4 * 1024 * 1024;
|
||||
@@ -42,6 +48,9 @@ const MAX_TEXTURE_BYTES = 4 * 1024 * 1024;
|
||||
/** Upload ceiling for a single font. A font cannot be shrunk on the way, so this is a plain limit. */
|
||||
const MAX_FONT_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/** The same for a sound, four times as high: one music track is worth more than four typefaces. */
|
||||
const MAX_SOUND_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||
throw new ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
@@ -171,6 +180,39 @@ function routeFonts(array $parts, string $method): never
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routeSounds(array $parts, string $method): never
|
||||
{
|
||||
$store = new SoundStore(dirname(__DIR__) . '/data/sounds');
|
||||
|
||||
if (count($parts) === 1) {
|
||||
if ($method === 'GET') {
|
||||
Response::json($store->list());
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
$bytes = Response::rawBody();
|
||||
if (strlen($bytes) > MAX_SOUND_BYTES) {
|
||||
Response::error('Sound must be at most ' . (MAX_SOUND_BYTES / 1024 / 1024) . ' MB', 413);
|
||||
}
|
||||
try {
|
||||
Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201);
|
||||
} catch (InvalidSoundException $e) {
|
||||
Response::error($e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
if ($method === 'DELETE') {
|
||||
if (!$store->delete($parts[1])) {
|
||||
Response::error('Sound not found', 404);
|
||||
}
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
try {
|
||||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_BODY_BYTES) {
|
||||
Response::error('Request body too large', 413);
|
||||
@@ -188,6 +230,7 @@ try {
|
||||
'projects' => routeProjects($parts, $method),
|
||||
'textures' => routeTextures($parts, $method),
|
||||
'fonts' => routeFonts($parts, $method),
|
||||
'sounds' => routeSounds($parts, $method),
|
||||
default => Response::error('Not found', 404),
|
||||
};
|
||||
} catch (Throwable $e) {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<?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
|
||||
{
|
||||
}
|
||||
Reference in new issue
Block a user