commit 4d79aee2ccde992d106b0950ad1cb8b293a1fcc6 Author: Blax Software Date: Tue Jul 7 13:07:59 2026 +0200 feat: initial laravel-webrtc scaffold on the reactphp-kernel WebRTC for Laravel on blax-software/reactphp-kernel. PHP owns signaling + orchestration; a pluggable MediaEngine owns real-time media, with the production engine backed by a Rust core (str0m via ext-php-rs) so PHP never runs per-20ms DSP. - WebRtcServiceProvider + config/webrtc.php + webrtc:serve command - Contracts\MediaEngine (offer/ice/record/connect/bridge/close) - Media\NullMediaEngine (signaling-only) + Media\Str0mMediaEngine (Rust seam) - Signaling\SignalingHandler (transport-agnostic) + Server\SignalingServer (on the kernel) - rust/ crate stub (blax_webrtc: str0m + ext-php-rs) documenting the media plan Enables: server-side record/intercept, provider-hidden AI-realtime bridging, and party-to-party/group calls. rel learn-atc #1055 #1051 #1019 Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6aacbc0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/vendor/ +composer.lock +.phpunit.result.cache +.phpunit.cache/ +*.log +.DS_Store +.idea/ +.vscode/ + +# Rust core build artifacts +/rust/target/ +/rust/Cargo.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9d3a4f5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Blax Software + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce66501 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# blax-software/laravel-webrtc + +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. + +``` +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/) +``` + +## What it's for + +One media path that terminates server-side unlocks all of: + +- **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. + +## 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 + +```bash +composer require blax-software/laravel-webrtc:dev-master +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`). + +## Run + +```bash +php artisan webrtc:serve # signaling on config(webrtc.host:port) +``` + +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. + +## Signaling wire shape (JSON, newline-delimited) + +```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": "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. + +## License + +MIT © Blax Software diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2b69ce2 --- /dev/null +++ b/composer.json @@ -0,0 +1,56 @@ +{ + "name": "blax-software/laravel-webrtc", + "description": "WebRTC for Laravel on the blax ReactPHP kernel: signaling on the shared loop + a pluggable media engine (Rust/str0m via ext-php-rs) for recording, AI-realtime bridging, and party-to-party calls.", + "type": "library", + "license": "MIT", + "keywords": ["laravel", "webrtc", "reactphp", "sfu", "recording", "realtime", "openai", "voice"], + "authors": [ + { + "name": "Blax Software", + "email": "office@blax.at" + } + ], + "require": { + "php": ">=8.1", + "blax-software/reactphp-kernel": "dev-master", + "react/event-loop": "^1.5", + "react/socket": "^1.15", + "illuminate/support": "^10.0|^11.0|^12.0", + "illuminate/console": "^10.0|^11.0|^12.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "suggest": { + "ext-ffi": "Required by the Str0m media engine (Rust core exposed to PHP).", + "blax-software/laravel-websockets": "Ride its WebSocket transport for browser signaling instead of the built-in raw signaling server." + }, + "repositories": [ + { + "type": "vcs", + "url": "ssh://git@git.blax.at:222/blax-software/reactphp-kernel.git" + } + ], + "autoload": { + "psr-4": { + "BlaxSoftware\\LaravelWebRtc\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "BlaxSoftware\\LaravelWebRtc\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "BlaxSoftware\\LaravelWebRtc\\WebRtcServiceProvider" + ] + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/webrtc.php b/config/webrtc.php new file mode 100644 index 0000000..9f83a3f --- /dev/null +++ b/config/webrtc.php @@ -0,0 +1,68 @@ + env('WEBRTC_HOST', '127.0.0.1'), + 'port' => (int) env('WEBRTC_PORT', 8090), + + // ReactPHP TLS context; leave both unset for plain TCP. + 'tls' => array_filter([ + 'local_cert' => env('WEBRTC_TLS_CERT'), + 'local_pk' => env('WEBRTC_TLS_KEY'), + ]), + + /* + |-------------------------------------------------------------------------- + | Media engine + |-------------------------------------------------------------------------- + | The backend that terminates media (ICE/DTLS/SRTP/RTP/Opus), records, and + | bridges to external realtime providers. NullMediaEngine = signaling only; + | Str0mMediaEngine requires the blax_webrtc Rust extension (see rust/). + */ + 'media_engine' => env('WEBRTC_MEDIA_ENGINE', NullMediaEngine::class), + + /* + |-------------------------------------------------------------------------- + | Recording + |-------------------------------------------------------------------------- + | Where server-side call recordings are written (attach them to your own + | transmission rows afterwards). + */ + 'recording' => [ + 'disk' => env('WEBRTC_RECORDING_DISK', 'local'), + 'path' => env('WEBRTC_RECORDING_PATH', 'webrtc/recordings'), + ], + + /* + |-------------------------------------------------------------------------- + | ICE servers (STUN/TURN) advertised to browsers + |-------------------------------------------------------------------------- + */ + 'ice_servers' => [ + // ['urls' => 'stun:stun.l.google.com:19302'], + // ['urls' => 'turn:turn.example.com:3478', 'username' => '...', 'credential' => '...'], + ], + + /* + |-------------------------------------------------------------------------- + | 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. + */ + 'bridge' => [ + 'openai' => [ + 'model' => env('WEBRTC_OPENAI_MODEL', 'gpt-realtime'), + 'api_key' => env('OPENAI_API_KEY'), + ], + ], +]; diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..e6343cb --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "blax_webrtc" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Rust media core (str0m) for blax-software/laravel-webrtc, exposed to PHP via ext-php-rs." +publish = false + +# Built as a PHP extension (cdylib). `cargo php install` loads it into your PHP. +[lib] +crate-type = ["cdylib"] + +[dependencies] +# Sans-IO WebRTC (ICE/DTLS/SRTP/RTP), built for SFU + recording use cases. +str0m = "0.6" +# Zend bindings so PHP can drive the core (SDP/ICE/control across FFI). +ext-php-rs = "0.12" + +# Added as the core grows: +# opus = "..." # audio (de)coding for recording / transcoding +# hound / ogg = "..." # write recordings to disk +# tokio = "..." # the OpenAI-realtime bridge (server-side WebSocket) + +[profile.release] +lto = true +codegen-units = 1 diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..c5a4d57 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,39 @@ +# blax_webrtc — Rust media core + +The timing-critical WebRTC media path for `blax-software/laravel-webrtc`, so that +PHP never runs per-20ms real-time DSP. + +## Why Rust here + +Handling **connections** concurrently in PHP is a solved problem (Swoole, +FrankenPHP, ReactPHP). What is *not* proven is sustained per-packet media DSP +(decrypt SRTP + Opus + RTP jitter every 20ms) glitch-free in single-threaded PHP. +That work belongs in a language built for it. `str0m` is **sans-IO** (no threads +or async of its own — you feed it bytes), which makes it ideal to embed and drive +from PHP. + +- `str0m` owns ICE / DTLS / SRTP / RTP / Opus. +- `ext-php-rs` exposes a thin function surface to PHP. +- PHP (`Str0mMediaEngine`) forwards SDP/ICE/control across the FFI boundary and + calls back into Laravel for persistence (recordings, transmission rows). + +## Build + +```bash +# one-time: cargo + the ext-php-rs helper +cargo install cargo-php + +cd rust +cargo php install --release # compiles the cdylib, installs it as the `blax_webrtc` extension +php -m | grep blax_webrtc # verify it loaded +``` + +Then set `WEBRTC_MEDIA_ENGINE=BlaxSoftware\LaravelWebRtc\Media\Str0mMediaEngine`. + +## Status + +Scaffold. `src/lib.rs` documents the intended FFI functions +(`blax_webrtc_offer`, `..._add_ice_candidate`, `..._start_recording`, +`..._connect_peers`, `..._bridge`, `..._close`) that `Str0mMediaEngine` forwards +to. Implement them against `str0m`'s RTP/Frame API, then flip the PHP engine off +`NullMediaEngine`. diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..4d34092 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,57 @@ +//! blax_webrtc — the Rust media core for `blax-software/laravel-webrtc`. +//! +//! Owns the timing-critical WebRTC media path (ICE / DTLS / SRTP / RTP / Opus) +//! via `str0m` (a sans-IO Rust WebRTC implementation built for SFU + recording), +//! and exposes a thin surface to PHP via `ext-php-rs`. PHP drives signaling and +//! orchestration; the per-20ms media DSP runs here in Rust, so it never stalls +//! the PHP event loop or fights PHP's GC. +//! +//! STATUS: scaffold. The functions below are the intended FFI surface that +//! `BlaxSoftware\LaravelWebRtc\Media\Str0mMediaEngine` forwards to. They are not +//! implemented yet — this file documents the seam and keeps the crate buildable +//! once the dependencies are added. +//! +//! Build (once implemented): +//! cd rust && cargo php install --release +//! which compiles the cdylib and installs it as the `blax_webrtc` PHP extension. + +// use ext_php_rs::prelude::*; +// +// /// Accept a browser SDP offer for `peer_id`; return the answer SDP. +// #[php_function] +// pub fn blax_webrtc_offer(peer_id: &str, sdp_offer: &str) -> String { +// todo!("create a str0m Rtc, apply the remote offer, return the local answer") +// } +// +// /// Feed a trickle-ICE candidate for `peer_id`. +// #[php_function] +// pub fn blax_webrtc_add_ice_candidate(peer_id: &str, candidate: &str) { +// todo!("parse + add the candidate to the peer's str0m Rtc") +// } +// +// /// Start recording `peer_id`'s inbound audio to `path` (Opus/WAV). +// #[php_function] +// pub fn blax_webrtc_start_recording(peer_id: &str, path: &str) { +// todo!("tee the peer's decoded audio frames to a file writer") +// } +// +// #[php_function] +// pub fn blax_webrtc_stop_recording(peer_id: &str) { todo!() } +// +// /// Forward media between two peers (party-to-party / group calls). +// #[php_function] +// pub fn blax_webrtc_connect_peers(peer_a: &str, peer_b: &str) { todo!() } +// +// /// Bridge `peer_id` to an external realtime provider (e.g. OpenAI Realtime over +// /// a server-side WebSocket). The browser never sees the provider. +// #[php_function] +// pub fn blax_webrtc_bridge(peer_id: &str, options_json: &str) { todo!() } +// +// /// Close + free a peer. +// #[php_function] +// pub fn blax_webrtc_close(peer_id: &str) { todo!() } +// +// #[php_module] +// pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { +// module +// } diff --git a/src/Console/Commands/StartWebRtcServer.php b/src/Console/Commands/StartWebRtcServer.php new file mode 100644 index 0000000..9b58ee9 --- /dev/null +++ b/src/Console/Commands/StartWebRtcServer.php @@ -0,0 +1,46 @@ +option('host') ?: config('webrtc.host', '127.0.0.1')); + $port = (int) ($this->option('port') ?: config('webrtc.port', 8090)); + + $engine = app(config('webrtc.media_engine', NullMediaEngine::class)); + if (! $engine instanceof MediaEngine) { + $this->error('Configured webrtc.media_engine is not a ' . MediaEngine::class); + + return self::FAILURE; + } + + $kernel = (new Kernel())->reapChildren(); + $kernel->register(new SignalingServer( + $host, + $port, + new SignalingHandler($engine), + (array) config('webrtc.tls', []), + )); + + $this->info("blax WebRTC signaling on {$host}:{$port} (engine: {$engine->name()}) — Ctrl-C to stop"); + + $kernel->run(); + + return self::SUCCESS; + } +} diff --git a/src/Contracts/MediaEngine.php b/src/Contracts/MediaEngine.php new file mode 100644 index 0000000..55f7a46 --- /dev/null +++ b/src/Contracts/MediaEngine.php @@ -0,0 +1,71 @@ + $candidate {candidate, sdpMid, sdpMLineIndex} + */ + public function addIceCandidate(string $peerId, array $candidate): void; + + /** + * Start recording a peer's inbound audio to $path (e.g. an Opus/WAV file the + * app then attaches to a transmission row). + */ + public function startRecording(string $peerId, string $path): void; + + /** Stop an in-progress recording for a peer (if any). */ + public function stopRecording(string $peerId): void; + + /** + * Route a peer's audio to/from another peer (party-to-party / group calls). + * For an N-party room this is called per pair the engine must forward. + */ + public function connectPeers(string $peerA, string $peerB): void; + + /** + * Bridge a peer to an external realtime endpoint (e.g. OpenAI Realtime), + * hiding the provider from the browser. Options are engine-specific + * (model, voice, instructions, api credentials handle, ...). + * + * @param array $options + */ + public function bridge(string $peerId, array $options): void; + + /** Close and clean up a peer (stops recording/bridging, frees the RTC state). */ + public function close(string $peerId): void; +} diff --git a/src/Media/NullMediaEngine.php b/src/Media/NullMediaEngine.php new file mode 100644 index 0000000..c0c1067 --- /dev/null +++ b/src/Media/NullMediaEngine.php @@ -0,0 +1,59 @@ + $tls ReactPHP TLS context, or [] for plain TCP + */ + public function __construct( + private string $host, + private int $port, + private SignalingHandler $handler, + private array $tls = [], + ) { + } + + public function name(): string + { + return 'webrtc-signaling'; + } + + public function boot(LoopInterface $loop): void + { + $this->socket = SocketServerFactory::create("{$this->host}:{$this->port}", $loop, $this->tls); + $this->socket->on('connection', fn (ConnectionInterface $conn) => $this->onConnection($conn)); + } + + private function onConnection(ConnectionInterface $conn): void + { + $buffer = ''; + + $conn->on('data', function ($chunk) use (&$buffer, $conn) { + $buffer .= $chunk; + + while (($pos = strpos($buffer, "\n")) !== false) { + $line = trim(substr($buffer, 0, $pos)); + $buffer = substr($buffer, $pos + 1); + + if ($line === '') { + continue; + } + + $message = json_decode($line, true); + if (! is_array($message)) { + $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"); + } + } + }); + } + + public function shutdown(): void + { + $this->socket?->close(); + $this->socket = null; + } +} diff --git a/src/Signaling/SignalingHandler.php b/src/Signaling/SignalingHandler.php new file mode 100644 index 0000000..3351769 --- /dev/null +++ b/src/Signaling/SignalingHandler.php @@ -0,0 +1,73 @@ + $message + * @return array|null + */ + public function handle(array $message): ?array + { + $type = (string) ($message['type'] ?? ''); + $peer = (string) ($message['peer'] ?? ''); + + if ($peer === '') { + return ['type' => 'error', 'error' => 'missing peer']; + } + + try { + return match ($type) { + 'offer' => [ + 'type' => 'answer', + 'peer' => $peer, + 'sdp' => $this->engine->offer($peer, (string) ($message['sdp'] ?? '')), + ], + 'ice' => $this->trickle($peer, (array) ($message['candidate'] ?? [])), + 'record' => $this->ack($peer, fn () => $this->engine->startRecording($peer, (string) ($message['path'] ?? ''))), + 'connect' => $this->ack($peer, fn () => $this->engine->connectPeers($peer, (string) ($message['other'] ?? ''))), + 'bridge' => $this->ack($peer, fn () => $this->engine->bridge($peer, (array) ($message['options'] ?? []))), + 'close' => $this->ack($peer, fn () => $this->engine->close($peer)), + default => ['type' => 'error', 'error' => "unknown type: {$type}"], + }; + } catch (\Throwable $e) { + return ['type' => 'error', 'peer' => $peer, 'error' => $e->getMessage()]; + } + } + + /** @param array $candidate */ + private function trickle(string $peer, array $candidate): ?array + { + $this->engine->addIceCandidate($peer, $candidate); + + return null; // trickle ICE needs no reply + } + + /** @return array */ + private function ack(string $peer, callable $action): array + { + $action(); + + return ['type' => 'ack', 'peer' => $peer]; + } +} diff --git a/src/WebRtcServiceProvider.php b/src/WebRtcServiceProvider.php new file mode 100644 index 0000000..bc1ac7c --- /dev/null +++ b/src/WebRtcServiceProvider.php @@ -0,0 +1,38 @@ +mergeConfigFrom(__DIR__ . '/../config/webrtc.php', 'webrtc'); + + // Resolve the configured media engine wherever a MediaEngine is type-hinted. + $this->app->bind(MediaEngine::class, function ($app) { + $class = (string) config('webrtc.media_engine', NullMediaEngine::class); + + return $app->make($class); + }); + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__ . '/../config/webrtc.php' => $this->app->configPath('webrtc.php'), + ], 'webrtc-config'); + + $this->commands([ + StartWebRtcServer::class, + ]); + } + } +}