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 <noreply@anthropic.com>
This commit is contained in:
commit
4d79aee2cc
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine;
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Signaling bind
|
||||
|--------------------------------------------------------------------------
|
||||
| Where the signaling server (on the shared ReactPHP kernel) listens. For
|
||||
| browser clients you will typically front this with the laravel-websockets
|
||||
| WS transport instead; this raw endpoint drives + tests the media engine.
|
||||
*/
|
||||
'host' => 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'),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
@ -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
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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
|
||||
// }
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlaxSoftware\LaravelWebRtc\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 Illuminate\Console\Command;
|
||||
|
||||
class StartWebRtcServer extends Command
|
||||
{
|
||||
protected $signature = 'webrtc:serve {--host= : Bind host (defaults to config webrtc.host)} {--port= : Bind port (defaults to config webrtc.port)}';
|
||||
|
||||
protected $description = 'Start the blax WebRTC signaling server on the shared ReactPHP kernel.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$host = (string) ($this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlaxSoftware\LaravelWebRtc\Contracts;
|
||||
|
||||
/**
|
||||
* The media backend for a WebRTC call.
|
||||
*
|
||||
* PHP (this package) owns SIGNALING + orchestration; the MediaEngine owns the
|
||||
* real-time media path (ICE, DTLS, SRTP, RTP, Opus — the per-20ms DSP). The
|
||||
* production engine (Str0mMediaEngine) delegates that to a Rust core (str0m,
|
||||
* sans-IO) exposed to PHP via ext-php-rs, so the timing-critical work never runs
|
||||
* in PHP itself. See the package `rust/` directory.
|
||||
*
|
||||
* Because the engine terminates media server-side, it is also the single place
|
||||
* that can:
|
||||
* - RECORD each participant's audio (startRecording),
|
||||
* - BRIDGE a participant to an external realtime model, hiding the provider
|
||||
* (bridge — e.g. OpenAI Realtime over a WebSocket the browser never sees),
|
||||
* - route audio between participants for party-to-party / group calls.
|
||||
*/
|
||||
interface MediaEngine
|
||||
{
|
||||
/** Human-readable engine id (diagnostics). */
|
||||
public function name(): string;
|
||||
|
||||
/**
|
||||
* Accept a browser's SDP offer for a peer and return the SDP answer. The
|
||||
* engine sets up the peer connection and begins ICE.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
public function offer(string $peerId, string $sdpOffer): string;
|
||||
|
||||
/**
|
||||
* Feed a trickle-ICE candidate received from the browser for a peer.
|
||||
*
|
||||
* @param array<string,mixed> $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<string,mixed> $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;
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlaxSoftware\LaravelWebRtc\Media;
|
||||
|
||||
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
|
||||
|
||||
/**
|
||||
* A no-op media engine so the signaling stack is runnable and testable before
|
||||
* the Rust media core exists. It accepts signaling and logs, but does NOT
|
||||
* terminate media — any `offer()` fails loudly so a misconfigured deployment is
|
||||
* obvious rather than silently medialess.
|
||||
*/
|
||||
final class NullMediaEngine implements MediaEngine
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'null';
|
||||
}
|
||||
|
||||
public function offer(string $peerId, string $sdpOffer): string
|
||||
{
|
||||
throw new \RuntimeException(
|
||||
'NullMediaEngine cannot terminate media. Configure webrtc.media_engine ' .
|
||||
'to Str0mMediaEngine (requires the blax_webrtc Rust extension) for real calls.'
|
||||
);
|
||||
}
|
||||
|
||||
public function addIceCandidate(string $peerId, array $candidate): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
|
||||
public function startRecording(string $peerId, string $path): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
|
||||
public function stopRecording(string $peerId): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
|
||||
public function connectPeers(string $peerA, string $peerB): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
|
||||
public function bridge(string $peerId, array $options): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
|
||||
public function close(string $peerId): void
|
||||
{
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlaxSoftware\LaravelWebRtc\Media;
|
||||
|
||||
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
|
||||
|
||||
/**
|
||||
* Production media engine backed by a RUST core.
|
||||
*
|
||||
* The core is `str0m` (a sans-IO Rust WebRTC implementation built for SFU +
|
||||
* recording) compiled into a PHP extension via `ext-php-rs` and shipped in this
|
||||
* package's `rust/` directory (extension name: `blax_webrtc`). Rust owns the
|
||||
* timing-critical media path (ICE/DTLS/SRTP/RTP/Opus, the per-20ms DSP); this
|
||||
* PHP adapter is a thin forwarder that carries SDP/ICE/control across the FFI
|
||||
* boundary and calls back into Laravel for persistence (recordings, transmissions).
|
||||
*
|
||||
* This is the SEAM, not the implementation. Until `rust/` is built and the
|
||||
* extension is loaded, constructing this engine fails with a clear message.
|
||||
* Each method documents the `blax_webrtc_*` call it will forward to.
|
||||
*/
|
||||
final class Str0mMediaEngine implements MediaEngine
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
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), ' .
|
||||
'or set webrtc.media_engine to NullMediaEngine for signaling-only runs.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'str0m';
|
||||
}
|
||||
|
||||
public function offer(string $peerId, string $sdpOffer): string
|
||||
{
|
||||
// return blax_webrtc_offer($peerId, $sdpOffer);
|
||||
throw new \LogicException('TODO: forward to blax_webrtc_offer() once the Rust core is built.');
|
||||
}
|
||||
|
||||
public function addIceCandidate(string $peerId, array $candidate): void
|
||||
{
|
||||
// blax_webrtc_add_ice_candidate($peerId, $candidate);
|
||||
throw new \LogicException('TODO: forward to blax_webrtc_add_ice_candidate().');
|
||||
}
|
||||
|
||||
public function startRecording(string $peerId, string $path): void
|
||||
{
|
||||
// blax_webrtc_start_recording($peerId, $path);
|
||||
throw new \LogicException('TODO: forward to blax_webrtc_start_recording().');
|
||||
}
|
||||
|
||||
public function stopRecording(string $peerId): void
|
||||
{
|
||||
// blax_webrtc_stop_recording($peerId);
|
||||
throw new \LogicException('TODO: forward to blax_webrtc_stop_recording().');
|
||||
}
|
||||
|
||||
public function connectPeers(string $peerA, string $peerB): void
|
||||
{
|
||||
// blax_webrtc_connect_peers($peerA, $peerB);
|
||||
throw new \LogicException('TODO: forward to blax_webrtc_connect_peers().');
|
||||
}
|
||||
|
||||
public function bridge(string $peerId, array $options): void
|
||||
{
|
||||
// blax_webrtc_bridge($peerId, $options); // e.g. OpenAI Realtime over a server-side WS
|
||||
throw new \LogicException('TODO: forward to blax_webrtc_bridge().');
|
||||
}
|
||||
|
||||
public function close(string $peerId): void
|
||||
{
|
||||
// blax_webrtc_close($peerId);
|
||||
throw new \LogicException('TODO: forward to blax_webrtc_close().');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlaxSoftware\LaravelWebRtc\Server;
|
||||
|
||||
use BlaxSoftware\LaravelWebRtc\Signaling\SignalingHandler;
|
||||
use BlaxSoftware\ReactPhpKernel\Contracts\Server;
|
||||
use BlaxSoftware\ReactPhpKernel\Server\SocketServerFactory;
|
||||
use React\EventLoop\LoopInterface;
|
||||
use React\Socket\ConnectionInterface;
|
||||
use React\Socket\SocketServer;
|
||||
|
||||
/**
|
||||
* A minimal signaling server that attaches to the shared ReactPHP Kernel: it
|
||||
* accepts newline-delimited JSON signaling frames and routes them through the
|
||||
* SignalingHandler to the MediaEngine.
|
||||
*
|
||||
* The raw-TCP/JSON transport here is enough to drive the media engine and to
|
||||
* test the seam. For browsers, point the client at the WebSocket transport from
|
||||
* blax-software/laravel-websockets and feed decoded frames into the SAME
|
||||
* SignalingHandler — the routing/media wiring does not change.
|
||||
*/
|
||||
final class SignalingServer implements Server
|
||||
{
|
||||
private ?SocketServer $socket = null;
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlaxSoftware\LaravelWebRtc\Signaling;
|
||||
|
||||
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
|
||||
|
||||
/**
|
||||
* Transport-agnostic signaling router: decodes a signaling message and drives
|
||||
* the MediaEngine, returning the reply to send back (or null when a message
|
||||
* needs no reply, e.g. trickle ICE).
|
||||
*
|
||||
* Kept free of any socket/WS concern so it can be exercised in unit tests and so
|
||||
* the browser-facing transport (this package's raw SignalingServer today, or the
|
||||
* laravel-websockets WS layer later) is a swappable detail.
|
||||
*
|
||||
* Wire shape (JSON): { "type": "offer|ice|record|connect|bridge|close", "peer": "...", ... }
|
||||
*/
|
||||
final class SignalingHandler
|
||||
{
|
||||
public function __construct(private MediaEngine $engine)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $message
|
||||
* @return array<string,mixed>|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<string,mixed> $candidate */
|
||||
private function trickle(string $peer, array $candidate): ?array
|
||||
{
|
||||
$this->engine->addIceCandidate($peer, $candidate);
|
||||
|
||||
return null; // trickle ICE needs no reply
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function ack(string $peer, callable $action): array
|
||||
{
|
||||
$action();
|
||||
|
||||
return ['type' => 'ack', 'peer' => $peer];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlaxSoftware\LaravelWebRtc;
|
||||
|
||||
use BlaxSoftware\LaravelWebRtc\Console\Commands\StartWebRtcServer;
|
||||
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
|
||||
use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class WebRtcServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$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) {
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue