From 9c791a8bbaa2583affe4286c7aff36d536c26de8 Mon Sep 17 00:00:00 2001 From: Blax Software Date: Wed, 8 Jul 2026 11:22:53 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20transport-agnostic=20domain=20layer=20?= =?UTF-8?q?=E2=80=94=20run=20the=20relay=20on=20any=20WebSocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rooms / recording / realtime bridge no longer require the bundled ReactPHP server, so a host (e.g. a fork-per-message Laravel WS) can consume the package on its own transport: - RoomStore contract + ArrayRoomStore; RoomManager takes a store so membership can be Cache/Redis-backed and survive across workers - Realtime\OpenAiRealtimeBridge (+ RealtimeTransport / TextalkRealtimeTransport / RealtimeTurn): the OpenAI GA realtime WS mechanism, turn-based, unit-tested via a scripted transport - Support\Audio (PCM16 -> WAV); RecordingStore::discard() - provider binds RoomStore/RoomManager/RecordingStore/OpenAiRealtimeBridge and guards webrtc:serve behind class_exists(laravel-ws + reactphp-kernel) - laravel-ws + reactphp-kernel + react/* -> require-dev; runtime needs only illuminate + textalk/websocket - 40 tests green Co-Authored-By: Claude Opus 4.8 --- README.md | 58 ++++++-- composer.json | 13 +- config/webrtc.php | 21 ++- src/Contracts/RealtimeTransport.php | 24 ++++ src/Contracts/RecordingStore.php | 3 + src/Contracts/RoomStore.php | 58 ++++++++ src/Realtime/OpenAiRealtimeBridge.php | 136 +++++++++++++++++++ src/Realtime/RealtimeTurn.php | 26 ++++ src/Realtime/TextalkRealtimeTransport.php | 53 ++++++++ src/Recording/FileRecordingStore.php | 14 ++ src/Rooms/RoomManager.php | 68 ++++++---- src/Rooms/Stores/ArrayRoomStore.php | 56 ++++++++ src/Support/Audio.php | 31 +++++ src/WebRtcServiceProvider.php | 48 ++++++- tests/Fixtures/MemoryRecordingStore.php | 5 + tests/Fixtures/ScriptedRealtimeTransport.php | 40 ++++++ tests/OpenAiRealtimeBridgeTest.php | 69 ++++++++++ tests/RoomStoreTest.php | 46 +++++++ 18 files changed, 724 insertions(+), 45 deletions(-) create mode 100644 src/Contracts/RealtimeTransport.php create mode 100644 src/Contracts/RoomStore.php create mode 100644 src/Realtime/OpenAiRealtimeBridge.php create mode 100644 src/Realtime/RealtimeTurn.php create mode 100644 src/Realtime/TextalkRealtimeTransport.php create mode 100644 src/Rooms/Stores/ArrayRoomStore.php create mode 100644 src/Support/Audio.php create mode 100644 tests/Fixtures/ScriptedRealtimeTransport.php create mode 100644 tests/OpenAiRealtimeBridgeTest.php create mode 100644 tests/RoomStoreTest.php diff --git a/README.md b/README.md index a17fc23..388b2cb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ WebRTC for Laravel on the shared [`reactphp-kernel`](https://github.com/blax-sof - ⏺️ **Record the full call** β€” the browser streams its mic over the WebSocket and the server stores each participant's audio (`FileRecordingStore` β†’ `/.webm`); + a call-event log seam for auditing even unrecorded calls - 🧩 **On the shared kernel** β€” runs on `reactphp-kernel` over the [`laravel-ws`](https://github.com/blax-software/laravel-ws) WebSocket transport; one process, one loop, alongside your other realtime servers - πŸŽ™οΈ **Server-side media, when you need it** β€” a pluggable `MediaEngine` terminates media for **recording / intercept**, **AI-realtime bridging**, and **SFU** group calls -- πŸ€– **AI realtime, provider hidden** β€” bridge a caller to an external model (e.g. OpenAI Realtime) server-side; the browser only ever talks to *you*, and the model is a config swap +- πŸ€– **AI realtime, provider hidden** β€” `OpenAiRealtimeBridge` relays a caller to OpenAI's Realtime API **server-side** (PCM in β†’ spoken reply out, recordable), so the browser only ever talks to *you* and the model is a config swap +- πŸ”€ **Use it on any transport** β€” the domain layer (rooms, recording, the turn-based realtime bridge) is transport-agnostic: run it on the bundled ReactPHP server *or* drive it from a WebSocket you already have; a swappable `RoomStore` makes membership Cache-backed for fork-per-message hosts - πŸ”Œ **Pluggable engine** β€” `NullMediaEngine` (signaling-only) now; `Str0mMediaEngine` (Rust `str0m` via `ext-php-rs`, shipped in `rust/`) for real server-terminated media ## Installation @@ -29,7 +30,7 @@ composer require blax-software/laravel-webrtc php artisan vendor:publish --tag="webrtc-config" ``` -It depends on [`blax-software/reactphp-kernel`](https://github.com/blax-software/reactphp-kernel) + [`blax-software/laravel-ws`](https://github.com/blax-software/laravel-ws), resolved from git.blax.at via the `repositories` entry in `composer.json`. +The domain layer (rooms, recording, the realtime bridge) needs only `illuminate/*` + `textalk/websocket`. The **bundled ReactPHP server** (`webrtc:serve`) additionally uses [`reactphp-kernel`](https://github.com/blax-software/reactphp-kernel) + [`laravel-ws`](https://github.com/blax-software/laravel-ws) β€” these are `require-dev`, so a host that runs the relay on its own WebSocket doesn't pull them (see [Use it on your own WebSocket](#use-it-on-your-own-websocket-no-bundled-server)). ## Quick Start @@ -133,6 +134,38 @@ WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\Str0mMediaEngine" The engine-backed path uses `Signaling\SignalingHandler` (offer/ice/record/connect/bridge/close) + `Server\SignalingServer`; it's independent of the browser relay above and shares the same `RoomManager`. +### Use it on your own WebSocket (no bundled server) + +The domain layer is transport-agnostic, so a host that already runs a WebSocket β€” including a classic **fork-per-message** Laravel WS β€” can drive the relay itself and skip this package's ReactPHP server entirely. Resolve the pieces and relay with your own transport: + +```php +use Blax\WebRtc\Rooms\RoomManager; +use Blax\WebRtc\Contracts\RecordingStore; +use Blax\WebRtc\Realtime\OpenAiRealtimeBridge; + +// Typed rooms + membership β€” join() returns the peers already there to notify: +$others = app(RoomManager::class)->join($room, $peerId); +foreach ($others as $peer) $yourWs->sendTo($peer, ['type' => 'peer-joined', 'peer' => $peerId]); + +// Store the call (your transport streams the mic to you however it likes): +app(RecordingStore::class)->append($room, $peerId, $chunk); + +// An AI joins the call β€” provider hidden, turn-based (one round-trip per push-to-talk): +$turn = app(OpenAiRealtimeBridge::class)->exchange($pcm, [ + 'api_key' => config('services.openai.key'), 'voice' => 'alloy', 'instructions' => '…', +]); +// $turn->pcm = PCM16 24kHz reply, $turn->transcript = text β€” play + record it. +``` + +Membership lives in a swappable `RoomStore`. The default `ArrayRoomStore` keeps it in process memory (right for the long-lived bundled server); a fork-per-message host, which has no shared memory across frames, binds a Cache/Redis-backed one so state survives across workers: + +```php +// config/webrtc.php +'room_store' => \App\Support\CacheRoomStore::class, // implements Blax\WebRtc\Contracts\RoomStore +``` + +`OpenAiRealtimeBridge` talks to a `RealtimeTransport` (textalk/websocket by default), so it's unit-testable with a scripted fake. This is exactly how learn-atc runs the relay + a recorded OpenAI ATC participant on its existing WebSocket, without `laravel-ws`/`reactphp-kernel` installed. + ## Configuration `config/webrtc.php` covers the signaling bind (`host`/`port`/`tls`), `recording` (`enabled`/`path`/`format`), the `authorizer` (room join gate) and `events` (call log) bindings, the `media_engine`, advertised `ice_servers`, and the external `bridge` (e.g. OpenAI model + key). @@ -144,14 +177,23 @@ The engine-backed path uses `Signaling\SignalingHandler` (offer/ice/record/conne ## Architecture ``` -reactphp-kernel shared backbone (loop, IPC, signals) - β”œβ”€ laravel-ws WebSocket transport (RFC6455) - └─ laravel-webrtc (this) - β”œβ”€ RelaySignalingHandler browser P2P calls (no media engine needed) - └─ MediaEngine (optional) server-terminated media - └─ Str0mMediaEngine Rust/str0m via ext-php-rs (rust/) +laravel-webrtc (this) + β”œβ”€ Domain layer (transport-agnostic β€” needs no bundled server) + β”‚ β”œβ”€ RoomManager + RoomStore typed rooms, membership, presence + β”‚ β”œβ”€ RecordingStore per-participant call recording + β”‚ └─ OpenAiRealtimeBridge AI joins the call, provider hidden + β”‚ + β”œβ”€ Bundled server (require-dev) run it for you… + β”‚ reactphp-kernel shared backbone (loop, IPC, signals) + β”‚ └─ laravel-ws WebSocket transport (RFC6455) + β”‚ └─ RelaySignalingHandler browser P2P calls over the loop + β”‚ + └─ MediaEngine (optional) …or terminate media server-side + └─ Str0mMediaEngine Rust/str0m via ext-php-rs (rust/) ``` +The domain layer runs on **any** transport (the bundled server, or a host WS like learn-atc's fork-per-message WebSocket). The bundled server + media engine are opt-in. + ## Testing ```bash diff --git a/composer.json b/composer.json index ef26566..a383966 100644 --- a/composer.json +++ b/composer.json @@ -12,16 +12,17 @@ ], "require": { "php": "^8.1|^8.2|^8.3|^8.4", - "blax-software/laravel-ws": "dev-master", - "blax-software/reactphp-kernel": "dev-master", - "react/event-loop": "^1.5", - "react/socket": "^1.15", + "illuminate/console": "^10.0|^11.0|^12.0", "illuminate/support": "^10.0|^11.0|^12.0", - "illuminate/console": "^10.0|^11.0|^12.0" + "textalk/websocket": "^1.5|^1.6" }, "require-dev": { + "blax-software/laravel-ws": "dev-master", + "blax-software/reactphp-kernel": "dev-master", + "laravel/pint": "^1.13", "phpunit/phpunit": "^10.5|^11.0", - "laravel/pint": "^1.13" + "react/event-loop": "^1.5", + "react/socket": "^1.15" }, "suggest": { "ext-ffi": "Required by the Str0m media engine (Rust core exposed to PHP)." diff --git a/config/webrtc.php b/config/webrtc.php index 115952a..2c54554 100644 --- a/config/webrtc.php +++ b/config/webrtc.php @@ -57,6 +57,18 @@ return [ 'authorizer' => env('WEBRTC_AUTHORIZER', DefaultRoomAuthorizer::class), 'events' => env('WEBRTC_EVENTS', NullCallEventListener::class), + /* + |-------------------------------------------------------------------------- + | Room membership store + |-------------------------------------------------------------------------- + | Where room membership + presence lives. The default ArrayRoomStore keeps it + | in process memory (right for the long-lived bundled server). A fork-per- + | message host (a classic Laravel WebSocket handling each frame in a fresh + | worker) binds a Cache/Redis-backed RoomStore so state survives across + | workers β€” that is how a host runs this relay on its existing WS. + */ + 'room_store' => env('WEBRTC_ROOM_STORE', \Blax\WebRtc\Rooms\Stores\ArrayRoomStore::class), + /* |-------------------------------------------------------------------------- | ICE servers (STUN/TURN) advertised to browsers @@ -71,13 +83,18 @@ return [ |-------------------------------------------------------------------------- | External realtime bridge (provider hidden from the browser) |-------------------------------------------------------------------------- - | The media engine dials these; the browser only ever talks to us, so the AI - | provider is invisible and the model is swappable server-side. + | OpenAiRealtimeBridge (or the media engine) dials these; the browser only ever + | talks to us, so the AI provider is invisible and the model is swappable + | server-side. `instructions` is the persona (the host injects its own β€” e.g. + | an ATC prompt) and `voice` the spoken voice. */ 'bridge' => [ 'openai' => [ + 'endpoint' => env('WEBRTC_OPENAI_ENDPOINT', 'wss://api.openai.com/v1/realtime'), 'model' => env('WEBRTC_OPENAI_MODEL', 'gpt-realtime'), 'api_key' => env('OPENAI_API_KEY'), + 'voice' => env('WEBRTC_OPENAI_VOICE', 'alloy'), + 'instructions' => null, ], ], ]; diff --git a/src/Contracts/RealtimeTransport.php b/src/Contracts/RealtimeTransport.php new file mode 100644 index 0000000..f6cf865 --- /dev/null +++ b/src/Contracts/RealtimeTransport.php @@ -0,0 +1,24 @@ + + */ + public function members(string $roomId): array; + + /** + * Presence info a peer published on join (empty array if none / not a presence room). + * + * @return array + */ + public function info(string $roomId, string $peerId): array; + + /** The room a peer is currently in, or null. */ + public function roomOf(string $peerId): ?string; + + /** + * Add/update a peer (with presence info) in a room and set its reverse index. + * + * @param array $info + */ + public function add(string $roomId, string $peerId, array $info): void; + + /** Remove a peer from a room, clear its reverse index, and drop the room if it empties. */ + public function remove(string $roomId, string $peerId): void; + + /** + * All non-empty room ids. Best-effort: a store that can't enumerate (e.g. a bare + * Cache backend) may return an empty array β€” only {@see RoomManager::count()} relies on it. + * + * @return array + */ + public function roomIds(): array; +} diff --git a/src/Realtime/OpenAiRealtimeBridge.php b/src/Realtime/OpenAiRealtimeBridge.php new file mode 100644 index 0000000..f2eee5e --- /dev/null +++ b/src/Realtime/OpenAiRealtimeBridge.php @@ -0,0 +1,136 @@ +,int):RealtimeTransport */ + private \Closure $transportFactory; + + public function __construct( + ?callable $transportFactory = null, + private string $endpoint = 'wss://api.openai.com/v1/realtime', + ) { + $this->transportFactory = $transportFactory !== null + ? \Closure::fromCallable($transportFactory) + : static fn (string $url, array $headers, int $timeout): RealtimeTransport => new TextalkRealtimeTransport($url, $headers, $timeout); + } + + /** + * Run one turn: send $inputPcm to OpenAI realtime and return the spoken reply. + * + * @param string $inputPcm Raw PCM16 mono 24kHz little-endian. + * @param array{api_key?:string,model?:string,voice?:string,instructions?:string,timeout?:int} $opts + */ + public function exchange(string $inputPcm, array $opts): RealtimeTurn + { + $key = (string) ($opts['api_key'] ?? ''); + if ($key === '') { + return new RealtimeTurn(error: 'missing api_key'); + } + if ($inputPcm === '') { + return new RealtimeTurn(error: 'empty input audio'); + } + + $model = (string) ($opts['model'] ?? 'gpt-realtime'); + $voice = (string) ($opts['voice'] ?? 'alloy'); + $instructions = (string) ($opts['instructions'] ?? ''); + $timeout = (int) ($opts['timeout'] ?? 30); + + try { + $transport = ($this->transportFactory)( + "{$this->endpoint}?model={$model}", + ['Authorization' => 'Bearer '.$key], + $timeout, + ); + } catch (\Throwable $e) { + return new RealtimeTurn(error: 'connect failed: '.$e->getMessage()); + } + + $pcm = ''; + $transcript = ''; + $error = null; + $seen = []; + + try { + // Manual push-to-talk: turn_detection null, we commit the buffer. Audio + // in/out default to PCM16 24kHz mono, so no explicit format is needed. + $session = [ + 'type' => 'realtime', + 'audio' => [ + 'input' => ['turn_detection' => null], + 'output' => ['voice' => $voice], + ], + 'output_modalities' => ['audio'], + ]; + if ($instructions !== '') { + $session['instructions'] = $instructions; + } + $transport->send((string) json_encode(['type' => 'session.update', 'session' => $session])); + + // Stream the input audio in. base64 is split on 4-char boundaries + // (65536 is a multiple of 4) so each frame stays valid. + foreach (str_split(base64_encode($inputPcm), 65536) as $chunk) { + $transport->send((string) json_encode(['type' => 'input_audio_buffer.append', 'audio' => $chunk])); + } + $transport->send((string) json_encode(['type' => 'input_audio_buffer.commit'])); + $transport->send((string) json_encode(['type' => 'response.create'])); + + $deadline = time() + $timeout; + while (time() < $deadline) { + $raw = $transport->receive(); + if ($raw === null) { + break; // timeout / closed + } + if ($raw === '') { + continue; + } + + $evt = json_decode($raw, true); + if (! is_array($evt)) { + continue; + } + $type = (string) ($evt['type'] ?? '?'); + $seen[$type] = ($seen[$type] ?? 0) + 1; + + if (in_array($type, ['response.audio.delta', 'response.output_audio.delta'], true)) { + $pcm .= base64_decode((string) ($evt['delta'] ?? '')); + } elseif (in_array($type, ['response.audio_transcript.delta', 'response.output_audio_transcript.delta'], true)) { + $transcript .= (string) ($evt['delta'] ?? ''); + } elseif ($type === 'error') { + $error = $evt['error']['message'] ?? json_encode($evt['error'] ?? $evt); + break; + } elseif ($type === 'response.done') { + break; + } + } + } catch (\Throwable $e) { + $error = $e->getMessage(); + } finally { + $transport->close(); + } + + return new RealtimeTurn(pcm: $pcm, transcript: trim($transcript), error: $error, events: $seen); + } +} diff --git a/src/Realtime/RealtimeTurn.php b/src/Realtime/RealtimeTurn.php new file mode 100644 index 0000000..2984a04 --- /dev/null +++ b/src/Realtime/RealtimeTurn.php @@ -0,0 +1,26 @@ + $events event type => count */ + public function __construct( + public readonly string $pcm = '', + public readonly string $transcript = '', + public readonly ?string $error = null, + public readonly array $events = [], + ) {} + + public function ok(): bool + { + return $this->error === null && $this->pcm !== ''; + } +} diff --git a/src/Realtime/TextalkRealtimeTransport.php b/src/Realtime/TextalkRealtimeTransport.php new file mode 100644 index 0000000..e59ed24 --- /dev/null +++ b/src/Realtime/TextalkRealtimeTransport.php @@ -0,0 +1,53 @@ + $headers */ + public function __construct(string $url, array $headers, int $timeout) + { + $this->client = new WebSocketClient($url, [ + 'headers' => $headers, + 'timeout' => $timeout, + ]); + } + + public function send(string $text): void + { + $this->client->text($text); + } + + public function receive(): ?string + { + try { + $raw = $this->client->receive(); + } catch (\Throwable) { + return null; // timeout / closed + } + + return is_string($raw) ? $raw : null; + } + + public function close(): void + { + try { + $this->client->close(); + } catch (\Throwable) { + // ignore + } + } +} diff --git a/src/Recording/FileRecordingStore.php b/src/Recording/FileRecordingStore.php index 7757062..da60c47 100644 --- a/src/Recording/FileRecordingStore.php +++ b/src/Recording/FileRecordingStore.php @@ -54,6 +54,20 @@ final class FileRecordingStore implements RecordingStore return $this->path($roomId, $peerId); } + public function discard(string $roomId, string $peerId): void + { + $key = $this->key($roomId, $peerId); + if (isset($this->handles[$key])) { + fclose($this->handles[$key]); + unset($this->handles[$key]); + } + + $path = $this->path($roomId, $peerId); + if (is_file($path)) { + @unlink($path); + } + } + /** The path a recording is (or will be) written to. */ public function path(string $roomId, string $peerId): string { diff --git a/src/Rooms/RoomManager.php b/src/Rooms/RoomManager.php index bd60abf..92a9bed 100644 --- a/src/Rooms/RoomManager.php +++ b/src/Rooms/RoomManager.php @@ -4,23 +4,37 @@ declare(strict_types=1); namespace Blax\WebRtc\Rooms; +use Blax\WebRtc\Contracts\RoomStore; +use Blax\WebRtc\Rooms\Stores\ArrayRoomStore; + /** * Tracks which peers are in which room for party-to-party / group calls, and - * answers the mesh question the SignalingHandler needs: "who is a new joiner + * answers the mesh question the signaling layer needs: "who is a new joiner * already in a call with?" Empty rooms are dropped automatically. + * + * Membership lives in a pluggable {@see RoomStore}: the default {@see ArrayRoomStore} + * keeps it in memory (bundled server), while a fork-per-message host binds a + * Cache/Redis-backed store so state survives across workers. The public API is + * unchanged either way. */ final class RoomManager { - /** @var array roomId => Room */ - private array $rooms = []; + private RoomStore $store; - /** @var array peerId => roomId */ - private array $peerRoom = []; + public function __construct(?RoomStore $store = null) + { + $this->store = $store ?? new ArrayRoomStore; + } + + public function store(): RoomStore + { + return $this->store; + } /** * Join $peerId to $roomId (with optional presence $info), returning the OTHER * peers already present so the caller can mesh / notify them. The room's type - * is derived from its name (RoomType::fromName) on first join. + * is derived from its name (RoomType::fromName) by {@see room()}. * * @param array $info * @return array @@ -28,13 +42,13 @@ final class RoomManager public function join(string $roomId, string $peerId, array $info = []): array { // Moving between rooms: leave the old one first. - if (($this->peerRoom[$peerId] ?? null) !== null && $this->peerRoom[$peerId] !== $roomId) { + $current = $this->store->roomOf($peerId); + if ($current !== null && $current !== $roomId) { $this->leave($peerId); } - $room = $this->rooms[$roomId] ??= new Room($roomId, RoomType::fromName($roomId)); - $others = $room->add($peerId, $info); - $this->peerRoom[$peerId] = $roomId; + $others = $this->store->members($roomId); + $this->store->add($roomId, $peerId, $info); return $others; } @@ -47,42 +61,44 @@ final class RoomManager */ public function leave(string $peerId): array { - $roomId = $this->peerRoom[$peerId] ?? null; - if ($roomId === null || ! isset($this->rooms[$roomId])) { + $roomId = $this->store->roomOf($peerId); + if ($roomId === null) { return []; } - $room = $this->rooms[$roomId]; - $room->remove($peerId); - unset($this->peerRoom[$peerId]); + $this->store->remove($roomId, $peerId); - if ($room->isEmpty()) { - unset($this->rooms[$roomId]); - - return []; - } - - return $room->peers(); + return $this->store->members($roomId); } public function roomOf(string $peerId): ?string { - return $this->peerRoom[$peerId] ?? null; + return $this->store->roomOf($peerId); } public function room(string $roomId): ?Room { - return $this->rooms[$roomId] ?? null; + $members = $this->store->members($roomId); + if ($members === []) { + return null; + } + + $room = new Room($roomId, RoomType::fromName($roomId)); + foreach ($members as $peerId) { + $room->add($peerId, $this->store->info($roomId, $peerId)); + } + + return $room; } /** @return array */ public function peersIn(string $roomId): array { - return isset($this->rooms[$roomId]) ? $this->rooms[$roomId]->peers() : []; + return $this->store->members($roomId); } public function count(): int { - return count($this->rooms); + return count($this->store->roomIds()); } } diff --git a/src/Rooms/Stores/ArrayRoomStore.php b/src/Rooms/Stores/ArrayRoomStore.php new file mode 100644 index 0000000..39ef0a5 --- /dev/null +++ b/src/Rooms/Stores/ArrayRoomStore.php @@ -0,0 +1,56 @@ +>> roomId => (peerId => presence info), insertion-ordered */ + private array $rooms = []; + + /** @var array peerId => roomId */ + private array $peerRoom = []; + + public function members(string $roomId): array + { + return array_keys($this->rooms[$roomId] ?? []); + } + + public function info(string $roomId, string $peerId): array + { + return $this->rooms[$roomId][$peerId] ?? []; + } + + public function roomOf(string $peerId): ?string + { + return $this->peerRoom[$peerId] ?? null; + } + + public function add(string $roomId, string $peerId, array $info): void + { + $this->rooms[$roomId][$peerId] = $info; + $this->peerRoom[$peerId] = $roomId; + } + + public function remove(string $roomId, string $peerId): void + { + unset($this->rooms[$roomId][$peerId], $this->peerRoom[$peerId]); + + if (($this->rooms[$roomId] ?? []) === []) { + unset($this->rooms[$roomId]); + } + } + + public function roomIds(): array + { + return array_keys($this->rooms); + } +} diff --git a/src/Support/Audio.php b/src/Support/Audio.php new file mode 100644 index 0000000..499088d --- /dev/null +++ b/src/Support/Audio.php @@ -0,0 +1,31 @@ + tag or + * a data: URL with no client-side decoding. + */ +final class Audio +{ + /** Wrap raw PCM16 little-endian as a WAV container (default 24kHz mono, the realtime format). */ + public static function wav(string $pcm, int $sampleRate = 24000, int $channels = 1, int $bits = 16): string + { + $byteRate = $sampleRate * $channels * intdiv($bits, 8); + $blockAlign = $channels * intdiv($bits, 8); + + return 'RIFF'.pack('V', 36 + strlen($pcm)).'WAVE' + .'fmt '.pack('V', 16).pack('v', 1).pack('v', $channels) + .pack('V', $sampleRate).pack('V', $byteRate).pack('v', $blockAlign).pack('v', $bits) + .'data'.pack('V', strlen($pcm)).$pcm; + } + + /** A `data:audio/wav;base64,…` URL for raw PCM16, ready to hand a browser