diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..b8d1cdd
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,7 @@
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/phpunit.xml.dist export-ignore
+/pint.json export-ignore
+/test.sh export-ignore
+/tests export-ignore
+/.github export-ignore
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..9a8243d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,24 @@
+# Changelog
+
+All notable changes to `blax-software/laravel-webrtc` are documented here. This
+project adheres to [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
+Blax Software's backward-compatibility guarantee.
+
+## [Unreleased]
+
+### Added
+
+- `WebRtcServiceProvider` + `config/webrtc.php` + the `webrtc:serve` command
+ (boots signaling on the shared `blax-software/reactphp-kernel`).
+- `Contracts\MediaEngine` — the pluggable media backend
+ (`offer`/`addIceCandidate`/`startRecording`/`stopRecording`/`connectPeers`/`bridge`/`close`).
+- `Media\NullMediaEngine` — signaling-only engine for dev/tests.
+- `Media\Str0mMediaEngine` — seam for the Rust media core (str0m via ext-php-rs);
+ throws until the `blax_webrtc` extension is built (see `rust/`).
+- `Signaling\SignalingHandler` — transport-agnostic router from a signaling
+ message to the media engine.
+- `Server\SignalingServer` — a minimal kernel-attached (newline-JSON) signaling
+ transport; browsers ride the `laravel-websockets` WS transport into the same
+ handler.
+- `rust/` — the `blax_webrtc` crate scaffold (str0m + ext-php-rs) documenting the
+ media plan.
diff --git a/README.md b/README.md
index ce66501..045e2dc 100644
--- a/README.md
+++ b/README.md
@@ -1,73 +1,98 @@
-# blax-software/laravel-webrtc
+[](https://github.com/blax-software)
-WebRTC for Laravel, built on the shared
-[`blax-software/reactphp-kernel`](https://git.blax.at/blax-software/reactphp-kernel)
-backbone. PHP owns **signaling + orchestration**; a pluggable **media engine**
-owns the real-time media (ICE/DTLS/SRTP/RTP/Opus) — with the production engine
-backed by a **Rust core (`str0m`) via `ext-php-rs`**, so PHP never runs per-20ms
-DSP.
+# Laravel WebRTC
-```
-reactphp-kernel shared backbone (loop, IPC, signals)
- └─ laravel-webrtc (this) signaling on the kernel
- ├─ NullMediaEngine signaling-only (dev/tests)
- └─ Str0mMediaEngine Rust/str0m core via ext-php-rs (rust/)
-```
+[](https://php.net)
+[](https://laravel.com)
+[](https://github.com/blax-software/reactphp-kernel)
+[](#testing)
+[](#testing)
+[](LICENSE)
-## What it's for
+WebRTC for Laravel on the shared [`reactphp-kernel`](https://github.com/blax-software/reactphp-kernel) backbone. PHP owns **signaling + orchestration**; a pluggable **media engine** owns the real-time media (ICE/DTLS/SRTP/RTP/Opus) — with the production engine backed by a **Rust core (`str0m`) via `ext-php-rs`**, so PHP never runs per-20ms DSP.
-One media path that terminates server-side unlocks all of:
+## Features
-- **Record / intercept / log** every participant's audio (human or AI).
-- **AI realtime, provider hidden** — the engine bridges a caller to an external
- realtime model (e.g. OpenAI Realtime) over a server-side socket; the browser
- only ever talks to *us*, and the model is a server-side config swap.
-- **Party-to-party & group calls** — instructor↔student, controller↔pilot,
- guest↔admin: participants are just peers the engine routes between.
+- 🎙️ **Record / intercept / log** — the engine terminates media server-side, so every participant's audio (human or AI) can be captured
+- 🤖 **AI realtime, provider hidden** — bridge a caller to an external realtime model (e.g. OpenAI Realtime) over a server-side socket; the browser only ever talks to *you*, and the model is a config swap
+- 👥 **Party-to-party & group calls** — instructor↔student, controller↔pilot, guest↔admin are just peers the engine routes between
+- 🧩 **On the shared kernel** — signaling runs on `reactphp-kernel` alongside your WebSockets, one process, one loop
+- 🔌 **Pluggable `MediaEngine`** — `NullMediaEngine` for signaling-only dev; `Str0mMediaEngine` for the real Rust-backed media
+- 🦀 **Rust media core** — `str0m` (sans-IO WebRTC) via `ext-php-rs`, shipped in `rust/`
+- 🎛️ **Transport-agnostic signaling** — route browser frames from this package's raw server or from `laravel-websockets` into the same handler
-## Status
-
-**Scaffold.** Signaling + the engine abstraction are real and runnable with
-`NullMediaEngine`; the `Str0mMediaEngine` + `rust/` core are the seam to build
-next (see `rust/README.md`). This package is the home for the WebRTC half of the
-blax realtime stack (rel learn-atc #1051/#1019).
-
-## Install
+## Installation
```bash
-composer require blax-software/laravel-webrtc:dev-master
-php artisan vendor:publish --tag=webrtc-config
+composer require blax-software/laravel-webrtc
+php artisan vendor:publish --tag="webrtc-config"
```
-It depends on `blax-software/reactphp-kernel` (resolved from git.blax.at via the
-`repositories` entry in `composer.json`).
+It depends on [`blax-software/reactphp-kernel`](https://github.com/blax-software/reactphp-kernel), resolved from git.blax.at via the `repositories` entry in `composer.json`.
-## Run
+## Quick Start
+
+Run the signaling server (on the shared kernel):
```bash
-php artisan webrtc:serve # signaling on config(webrtc.host:port)
+php artisan webrtc:serve
```
-With `NullMediaEngine` (default) signaling runs and the media engine refuses
-`offer()` loudly. Build the Rust core and set
-`WEBRTC_MEDIA_ENGINE=BlaxSoftware\LaravelWebRtc\Media\Str0mMediaEngine` for real
-calls.
+With the default `NullMediaEngine`, signaling runs and the engine refuses `offer()` loudly. Build the Rust core (see [`rust/README.md`](rust/README.md)) and switch the engine for real calls:
-## Signaling wire shape (JSON, newline-delimited)
+```dotenv
+WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\Str0mMediaEngine"
+```
+
+### Signaling wire shape (newline-delimited JSON)
```jsonc
-{ "type": "offer", "peer": "p1", "sdp": "..." } // -> { "type": "answer", "peer": "p1", "sdp": "..." }
-{ "type": "ice", "peer": "p1", "candidate": {...} } // (no reply)
-{ "type": "record", "peer": "p1", "path": "..." } // -> { "type": "ack", ... }
-{ "type": "connect", "peer": "p1", "other": "p2" } // party-to-party
-{ "type": "bridge", "peer": "p1", "options": {...} } // dial an external realtime model
+{ "type": "offer", "peer": "p1", "sdp": "..." } // -> { "type": "answer", "peer": "p1", "sdp": "..." }
+{ "type": "ice", "peer": "p1", "candidate": {...} } // (no reply)
+{ "type": "record", "peer": "p1", "path": "..." } // -> { "type": "ack", ... }
+{ "type": "connect", "peer": "p1", "other": "p2" } // party-to-party
+{ "type": "bridge", "peer": "p1", "options": {...} } // dial an external realtime model
{ "type": "close", "peer": "p1" }
```
-`SignalingHandler` is transport-agnostic — feed it frames from this package's raw
-`SignalingServer` today, or from the `laravel-websockets` WS transport (for
-browsers) later, without changing the media wiring.
+`SignalingHandler` is transport-agnostic — feed it frames from the built-in raw `SignalingServer` today, or from the `laravel-websockets` WS transport (for browsers) later, without changing the media wiring.
+
+## Configuration
+
+`config/webrtc.php` covers the signaling bind (`host`/`port`/`tls`), the `media_engine` binding, `recording` disk/path, advertised `ice_servers`, and the external `bridge` (e.g. OpenAI model + key). Swapping the realtime model — or the whole provider — is a server-side config change the browser never sees.
+
+## Status
+
+**MVP scaffold.** Signaling + the engine abstraction are real, tested, and runnable with `NullMediaEngine`; the `Str0mMediaEngine` + `rust/` core are the seam to build next. This is the WebRTC half of the blax realtime stack.
+
+## Architecture
+
+```
+reactphp-kernel shared backbone (loop, IPC, signals)
+ └─ laravel-webrtc (this) signaling on the kernel
+ ├─ NullMediaEngine signaling-only (dev/tests)
+ └─ Str0mMediaEngine Rust/str0m core via ext-php-rs (rust/)
+```
+
+## Testing
+
+```bash
+composer install
+composer test
+```
+
+The signaling router and engines are unit-tested against a fake media engine — no browser, sockets, or external services required.
## License
-MIT © Blax Software
+MIT. See [LICENSE](LICENSE).
+
+## Star History
+
+
+
+
+
+
+
+
diff --git a/composer.json b/composer.json
index 2b69ce2..ea4ecc0 100644
--- a/composer.json
+++ b/composer.json
@@ -11,7 +11,7 @@
}
],
"require": {
- "php": ">=8.1",
+ "php": "^8.1|^8.2|^8.3|^8.4",
"blax-software/reactphp-kernel": "dev-master",
"react/event-loop": "^1.5",
"react/socket": "^1.15",
@@ -19,7 +19,8 @@
"illuminate/console": "^10.0|^11.0|^12.0"
},
"require-dev": {
- "phpunit/phpunit": "^10.5"
+ "phpunit/phpunit": "^10.5|^11.0",
+ "laravel/pint": "^1.13"
},
"suggest": {
"ext-ffi": "Required by the Str0m media engine (Rust core exposed to PHP).",
@@ -33,18 +34,23 @@
],
"autoload": {
"psr-4": {
- "BlaxSoftware\\LaravelWebRtc\\": "src/"
+ "Blax\\WebRtc\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
- "BlaxSoftware\\LaravelWebRtc\\Tests\\": "tests/"
+ "Blax\\WebRtc\\Tests\\": "tests/"
}
},
+ "scripts": {
+ "test": "vendor/bin/phpunit",
+ "lint": "vendor/bin/pint",
+ "lint-test": "vendor/bin/pint --test"
+ },
"extra": {
"laravel": {
"providers": [
- "BlaxSoftware\\LaravelWebRtc\\WebRtcServiceProvider"
+ "Blax\\WebRtc\\WebRtcServiceProvider"
]
}
},
diff --git a/config/webrtc.php b/config/webrtc.php
index 9f83a3f..ee73397 100644
--- a/config/webrtc.php
+++ b/config/webrtc.php
@@ -1,6 +1,6 @@
+
+
+
+ tests
+
+
+
+
+ ./src
+
+
+
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..71e1065
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,4 @@
+{
+ "preset": "laravel",
+ "rules": {}
+}
diff --git a/src/Console/Commands/StartWebRtcServer.php b/src/Console/Commands/StartWebRtcServer.php
index 9b58ee9..048a360 100644
--- a/src/Console/Commands/StartWebRtcServer.php
+++ b/src/Console/Commands/StartWebRtcServer.php
@@ -2,13 +2,13 @@
declare(strict_types=1);
-namespace BlaxSoftware\LaravelWebRtc\Console\Commands;
+namespace Blax\WebRtc\Console\Commands;
-use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
-use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine;
-use BlaxSoftware\LaravelWebRtc\Server\SignalingServer;
-use BlaxSoftware\LaravelWebRtc\Signaling\SignalingHandler;
-use BlaxSoftware\ReactPhpKernel\Kernel;
+use Blax\ReactPhpKernel\Kernel;
+use Blax\WebRtc\Contracts\MediaEngine;
+use Blax\WebRtc\Media\NullMediaEngine;
+use Blax\WebRtc\Server\SignalingServer;
+use Blax\WebRtc\Signaling\SignalingHandler;
use Illuminate\Console\Command;
class StartWebRtcServer extends Command
@@ -24,12 +24,12 @@ class StartWebRtcServer extends Command
$engine = app(config('webrtc.media_engine', NullMediaEngine::class));
if (! $engine instanceof MediaEngine) {
- $this->error('Configured webrtc.media_engine is not a ' . MediaEngine::class);
+ $this->error('Configured webrtc.media_engine is not a '.MediaEngine::class);
return self::FAILURE;
}
- $kernel = (new Kernel())->reapChildren();
+ $kernel = (new Kernel)->reapChildren();
$kernel->register(new SignalingServer(
$host,
$port,
diff --git a/src/Contracts/MediaEngine.php b/src/Contracts/MediaEngine.php
index 55f7a46..4c9aec8 100644
--- a/src/Contracts/MediaEngine.php
+++ b/src/Contracts/MediaEngine.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace BlaxSoftware\LaravelWebRtc\Contracts;
+namespace Blax\WebRtc\Contracts;
/**
* The media backend for a WebRTC call.
@@ -31,7 +31,7 @@ interface MediaEngine
*
* @param string $peerId caller-assigned id, unique within a room/call
* @param string $sdpOffer the browser's offer SDP
- * @return string the answer SDP to send back through signaling
+ * @return string the answer SDP to send back through signaling
*/
public function offer(string $peerId, string $sdpOffer): string;
diff --git a/src/Media/NullMediaEngine.php b/src/Media/NullMediaEngine.php
index c0c1067..45b55b7 100644
--- a/src/Media/NullMediaEngine.php
+++ b/src/Media/NullMediaEngine.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-namespace BlaxSoftware\LaravelWebRtc\Media;
+namespace Blax\WebRtc\Media;
-use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
+use Blax\WebRtc\Contracts\MediaEngine;
/**
* A no-op media engine so the signaling stack is runnable and testable before
@@ -22,7 +22,7 @@ final class NullMediaEngine implements MediaEngine
public function offer(string $peerId, string $sdpOffer): string
{
throw new \RuntimeException(
- 'NullMediaEngine cannot terminate media. Configure webrtc.media_engine ' .
+ 'NullMediaEngine cannot terminate media. Configure webrtc.media_engine '.
'to Str0mMediaEngine (requires the blax_webrtc Rust extension) for real calls.'
);
}
diff --git a/src/Media/Str0mMediaEngine.php b/src/Media/Str0mMediaEngine.php
index 88a8528..96fa84f 100644
--- a/src/Media/Str0mMediaEngine.php
+++ b/src/Media/Str0mMediaEngine.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-namespace BlaxSoftware\LaravelWebRtc\Media;
+namespace Blax\WebRtc\Media;
-use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
+use Blax\WebRtc\Contracts\MediaEngine;
/**
* Production media engine backed by a RUST core.
@@ -26,8 +26,8 @@ final class Str0mMediaEngine implements MediaEngine
{
if (! \extension_loaded('blax_webrtc')) {
throw new \RuntimeException(
- 'The blax_webrtc extension (Rust/str0m media core) is not loaded. ' .
- 'Build it from this package\'s rust/ directory (see rust/README.md), ' .
+ 'The blax_webrtc extension (Rust/str0m media core) is not loaded. '.
+ 'Build it from this package\'s rust/ directory (see rust/README.md), '.
'or set webrtc.media_engine to NullMediaEngine for signaling-only runs.'
);
}
diff --git a/src/Server/SignalingServer.php b/src/Server/SignalingServer.php
index e258c38..0e5417d 100644
--- a/src/Server/SignalingServer.php
+++ b/src/Server/SignalingServer.php
@@ -2,11 +2,11 @@
declare(strict_types=1);
-namespace BlaxSoftware\LaravelWebRtc\Server;
+namespace Blax\WebRtc\Server;
-use BlaxSoftware\LaravelWebRtc\Signaling\SignalingHandler;
-use BlaxSoftware\ReactPhpKernel\Contracts\Server;
-use BlaxSoftware\ReactPhpKernel\Server\SocketServerFactory;
+use Blax\ReactPhpKernel\Contracts\Server;
+use Blax\ReactPhpKernel\Server\SocketServerFactory;
+use Blax\WebRtc\Signaling\SignalingHandler;
use React\EventLoop\LoopInterface;
use React\Socket\ConnectionInterface;
use React\Socket\SocketServer;
@@ -33,8 +33,7 @@ final class SignalingServer implements Server
private int $port,
private SignalingHandler $handler,
private array $tls = [],
- ) {
- }
+ ) {}
public function name(): string
{
@@ -64,14 +63,14 @@ final class SignalingServer implements Server
$message = json_decode($line, true);
if (! is_array($message)) {
- $conn->write(json_encode(['type' => 'error', 'error' => 'invalid json']) . "\n");
+ $conn->write(json_encode(['type' => 'error', 'error' => 'invalid json'])."\n");
continue;
}
$response = $this->handler->handle($message);
if ($response !== null) {
- $conn->write(json_encode($response) . "\n");
+ $conn->write(json_encode($response)."\n");
}
}
});
diff --git a/src/Signaling/SignalingHandler.php b/src/Signaling/SignalingHandler.php
index 3351769..56831a0 100644
--- a/src/Signaling/SignalingHandler.php
+++ b/src/Signaling/SignalingHandler.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-namespace BlaxSoftware\LaravelWebRtc\Signaling;
+namespace Blax\WebRtc\Signaling;
-use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
+use Blax\WebRtc\Contracts\MediaEngine;
/**
* Transport-agnostic signaling router: decodes a signaling message and drives
@@ -19,9 +19,7 @@ use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
*/
final class SignalingHandler
{
- public function __construct(private MediaEngine $engine)
- {
- }
+ public function __construct(private MediaEngine $engine) {}
/**
* @param array $message
diff --git a/src/WebRtcServiceProvider.php b/src/WebRtcServiceProvider.php
index bc1ac7c..9a1d7f2 100644
--- a/src/WebRtcServiceProvider.php
+++ b/src/WebRtcServiceProvider.php
@@ -2,18 +2,18 @@
declare(strict_types=1);
-namespace BlaxSoftware\LaravelWebRtc;
+namespace Blax\WebRtc;
-use BlaxSoftware\LaravelWebRtc\Console\Commands\StartWebRtcServer;
-use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
-use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine;
+use Blax\WebRtc\Console\Commands\StartWebRtcServer;
+use Blax\WebRtc\Contracts\MediaEngine;
+use Blax\WebRtc\Media\NullMediaEngine;
use Illuminate\Support\ServiceProvider;
class WebRtcServiceProvider extends ServiceProvider
{
public function register(): void
{
- $this->mergeConfigFrom(__DIR__ . '/../config/webrtc.php', 'webrtc');
+ $this->mergeConfigFrom(__DIR__.'/../config/webrtc.php', 'webrtc');
// Resolve the configured media engine wherever a MediaEngine is type-hinted.
$this->app->bind(MediaEngine::class, function ($app) {
@@ -27,7 +27,7 @@ class WebRtcServiceProvider extends ServiceProvider
{
if ($this->app->runningInConsole()) {
$this->publishes([
- __DIR__ . '/../config/webrtc.php' => $this->app->configPath('webrtc.php'),
+ __DIR__.'/../config/webrtc.php' => $this->app->configPath('webrtc.php'),
], 'webrtc-config');
$this->commands([
diff --git a/test.sh b/test.sh
new file mode 100644
index 0000000..f6e6fae
--- /dev/null
+++ b/test.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p php83 php83Extensions.sockets php83Extensions.mbstring php83Extensions.xml php83Extensions.xmlwriter php83Extensions.tokenizer
+
+# Test script for NixOS — runs PHPUnit for the WebRTC signaling + engine layer.
+
+echo "Running Laravel WebRTC tests..."
+echo "PHP version: $(php --version | head -n 1)"
+echo ""
+
+vendor/bin/phpunit "$@"
diff --git a/tests/Fixtures/FakeMediaEngine.php b/tests/Fixtures/FakeMediaEngine.php
new file mode 100644
index 0000000..8d48653
--- /dev/null
+++ b/tests/Fixtures/FakeMediaEngine.php
@@ -0,0 +1,66 @@
+> */
+ public array $calls = [];
+
+ public ?\Throwable $throwOnOffer = null;
+
+ public function name(): string
+ {
+ return 'fake';
+ }
+
+ public function offer(string $peerId, string $sdpOffer): string
+ {
+ $this->calls[] = ['offer', $peerId, $sdpOffer];
+
+ if ($this->throwOnOffer !== null) {
+ throw $this->throwOnOffer;
+ }
+
+ return "answer-for-{$peerId}";
+ }
+
+ public function addIceCandidate(string $peerId, array $candidate): void
+ {
+ $this->calls[] = ['ice', $peerId, $candidate];
+ }
+
+ public function startRecording(string $peerId, string $path): void
+ {
+ $this->calls[] = ['record', $peerId, $path];
+ }
+
+ public function stopRecording(string $peerId): void
+ {
+ $this->calls[] = ['stopRecording', $peerId];
+ }
+
+ public function connectPeers(string $peerA, string $peerB): void
+ {
+ $this->calls[] = ['connect', $peerA, $peerB];
+ }
+
+ public function bridge(string $peerId, array $options): void
+ {
+ $this->calls[] = ['bridge', $peerId, $options];
+ }
+
+ public function close(string $peerId): void
+ {
+ $this->calls[] = ['close', $peerId];
+ }
+}
diff --git a/tests/NullMediaEngineTest.php b/tests/NullMediaEngineTest.php
new file mode 100644
index 0000000..bb0373f
--- /dev/null
+++ b/tests/NullMediaEngineTest.php
@@ -0,0 +1,32 @@
+expectException(\RuntimeException::class);
+
+ (new NullMediaEngine)->offer('p1', 'sdp');
+ }
+
+ public function test_its_other_operations_are_harmless_no_ops(): void
+ {
+ $engine = new NullMediaEngine;
+
+ $engine->addIceCandidate('p1', ['candidate' => 'x']);
+ $engine->startRecording('p1', '/tmp/x.opus');
+ $engine->stopRecording('p1');
+ $engine->connectPeers('p1', 'p2');
+ $engine->bridge('p1', []);
+ $engine->close('p1');
+
+ $this->assertSame('null', $engine->name());
+ }
+}
diff --git a/tests/SignalingHandlerTest.php b/tests/SignalingHandlerTest.php
new file mode 100644
index 0000000..a36d87a
--- /dev/null
+++ b/tests/SignalingHandlerTest.php
@@ -0,0 +1,79 @@
+handle([
+ 'type' => 'offer',
+ 'peer' => 'p1',
+ 'sdp' => 'OFFER-SDP',
+ ]);
+
+ $this->assertSame(['type' => 'answer', 'peer' => 'p1', 'sdp' => 'answer-for-p1'], $result);
+ $this->assertSame(['offer', 'p1', 'OFFER-SDP'], $engine->calls[0]);
+ }
+
+ public function test_ice_is_forwarded_and_needs_no_reply(): void
+ {
+ $engine = new FakeMediaEngine;
+ $result = (new SignalingHandler($engine))->handle([
+ 'type' => 'ice',
+ 'peer' => 'p1',
+ 'candidate' => ['candidate' => 'a=x'],
+ ]);
+
+ $this->assertNull($result);
+ $this->assertSame(['ice', 'p1', ['candidate' => 'a=x']], $engine->calls[0]);
+ }
+
+ public function test_record_connect_bridge_close_are_acked_and_routed(): void
+ {
+ $engine = new FakeMediaEngine;
+ $handler = new SignalingHandler($engine);
+
+ $this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'record', 'peer' => 'p1', 'path' => '/tmp/a.opus']));
+ $this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'connect', 'peer' => 'p1', 'other' => 'p2']));
+ $this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'bridge', 'peer' => 'p1', 'options' => ['model' => 'gpt-realtime']]));
+ $this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'close', 'peer' => 'p1']));
+
+ $this->assertSame(['record', 'p1', '/tmp/a.opus'], $engine->calls[0]);
+ $this->assertSame(['connect', 'p1', 'p2'], $engine->calls[1]);
+ $this->assertSame(['bridge', 'p1', ['model' => 'gpt-realtime']], $engine->calls[2]);
+ $this->assertSame(['close', 'p1'], $engine->calls[3]);
+ }
+
+ public function test_a_missing_peer_is_an_error(): void
+ {
+ $result = (new SignalingHandler(new FakeMediaEngine))->handle(['type' => 'offer']);
+
+ $this->assertSame('error', $result['type']);
+ }
+
+ public function test_an_unknown_type_is_an_error(): void
+ {
+ $result = (new SignalingHandler(new FakeMediaEngine))->handle(['type' => 'wat', 'peer' => 'p1']);
+
+ $this->assertSame('error', $result['type']);
+ $this->assertStringContainsString('wat', $result['error']);
+ }
+
+ public function test_an_engine_exception_is_caught_as_an_error_reply(): void
+ {
+ $engine = new FakeMediaEngine;
+ $engine->throwOnOffer = new \RuntimeException('boom');
+
+ $result = (new SignalingHandler($engine))->handle(['type' => 'offer', 'peer' => 'p1', 'sdp' => 'x']);
+
+ $this->assertSame(['type' => 'error', 'peer' => 'p1', 'error' => 'boom'], $result);
+ }
+}