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