Files
netris-nestri/packages/input/src/webrtc-stream.ts
Kristian Ollikainen d87a0b35dd feat: Fully use protobuf, fix controller issues and cleanup (#305)
## Description
### First commit
Restructured protobuf schemas to make them easier to use across
languages, switched to using them in-place of JSON for signaling as
well, so there's no 2 different message formats flying about. Few new
message types to deal with clients and nestri-servers better (not final
format, may see changes still).

General cleanup of dead/unused code along some bug squashing and package
updates.

TODO for future commits:
- [x] Fix additional controllers not doing inputs (possibly needs
vimputti changes)
- [x] ~~Restructure relay protocols code a bit, to reduce bloatiness of
the currently single file for them, more code re-use.~~
- Gonna keep this PR somewhat manageable without poking more at relay..
- [x] ~~Try to fix issue where with multiple clients, static stream
content causes video to freeze until there's some movement.~~
- Was caused by server tuned profile being `throughput-performance`,
causing CPU latency to be too high.
- [x] Ponder the orb


### Second + third commit
Redid the controller polling handling and fixed multi-controller
handling in vimputti and nestri code sides. Remove some dead relay code
as well to clean up the protocol source file, we'll revisit the meshing
functionality later.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added software rendering option and MangoHud runtime config;
controller sessions now support reconnection and batched state updates
with persistent session IDs.

* **Bug Fixes**
* Restored previously-filtered NES-like gamepads so they connect
correctly.

* **Chores**
* Modernized dependencies and protobuf tooling, migrated to
protobuf-based messaging and streaming, and removed obsolete CUDA build
steps.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
2025-11-08 13:10:28 +02:00

461 lines
14 KiB
TypeScript

import { webSockets } from "@libp2p/websockets";
import { webTransport } from "@libp2p/webtransport";
import { createLibp2p, Libp2p } from "libp2p";
import { noise } from "@chainsafe/libp2p-noise";
import { yamux } from "@chainsafe/libp2p-yamux";
import { identify } from "@libp2p/identify";
import { multiaddr } from "@multiformats/multiaddr";
import { Connection } from "@libp2p/interface";
import { ping } from "@libp2p/ping";
import { createMessage } from "./utils";
import { create } from "@bufbuild/protobuf";
import {
ProtoClientRequestRoomStream,
ProtoClientRequestRoomStreamSchema,
ProtoICE,
ProtoICESchema, ProtoRaw,
ProtoSDP,
ProtoSDPSchema
} from "./proto/types_pb";
import { P2PMessageStream } from "./streamwrapper";
const NESTRI_PROTOCOL_STREAM_REQUEST = "/nestri-relay/stream-request/1.0.0";
export class WebRTCStream {
private _sessionId: string | null = null;
private _p2p: Libp2p | undefined = undefined;
private _p2pConn: Connection | undefined = undefined;
private _msgStream: P2PMessageStream | undefined = undefined;
private _pc: RTCPeerConnection | undefined = undefined;
private _audioTrack: MediaStreamTrack | undefined = undefined;
private _videoTrack: MediaStreamTrack | undefined = undefined;
private _dataChannel: RTCDataChannel | undefined = undefined;
private _onConnected: ((stream: MediaStream | null) => void) | undefined =
undefined;
private _connectionTimer: NodeJS.Timeout | NodeJS.Timer | undefined =
undefined;
private _serverURL: string | undefined = undefined;
private _roomName: string | undefined = undefined;
private _isConnected: boolean = false;
private _dataChannelCallbacks: Array<(data: any) => void> = [];
currentFrameRate: number = 100;
constructor(
serverURL: string,
roomName: string,
connectedCallback: (stream: MediaStream | null) => void,
) {
if (roomName.length <= 0) {
console.error("Room name not provided");
return;
}
this._onConnected = connectedCallback;
this._serverURL = serverURL;
this._roomName = roomName;
this._setup(serverURL, roomName).catch(console.error);
}
private async _setup(serverURL: string, roomName: string) {
// Don't setup new connection if already connected
if (this._isConnected) {
console.log("Already connected, skipping setup");
return;
}
console.log("Setting up libp2p");
this._p2p = await createLibp2p({
transports: [webSockets(), webTransport()],
connectionEncrypters: [noise()],
streamMuxers: [yamux()],
connectionGater: {
denyDialMultiaddr: () => {
return false;
},
},
services: {
identify: identify(),
ping: ping(),
},
});
this._p2p.addEventListener("peer:connect", async (e) => {
console.debug("Peer connected:", e.detail);
});
this._p2p.addEventListener("peer:disconnect", (e) => {
console.debug("Peer disconnected:", e.detail);
});
const ma = multiaddr(serverURL);
console.debug("Dialing peer at:", ma.toString());
this._p2pConn = await this._p2p.dial(ma);
if (this._p2pConn) {
console.log("Stream is being established");
let stream = await this._p2pConn
.newStream(NESTRI_PROTOCOL_STREAM_REQUEST)
.catch(console.error);
if (stream) {
this._msgStream = new P2PMessageStream(stream);
console.log("Stream opened with peer");
let iceHolder: RTCIceCandidateInit[] = [];
this._msgStream.on("ice-candidate", (data: ProtoICE) => {
const cand: RTCIceCandidateInit = {
candidate: data.candidate.candidate,
sdpMLineIndex: data.candidate.sdpMLineIndex,
sdpMid: data.candidate.sdpMid,
usernameFragment: data.candidate.usernameFragment,
};
if (this._pc) {
if (this._pc.remoteDescription) {
this._pc.addIceCandidate(cand).catch((err) => {
console.error("Error adding ICE candidate:", err);
});
// Add held candidates
iceHolder.forEach((candidate) => {
this._pc!.addIceCandidate(candidate).catch((err) => {
console.error("Error adding held ICE candidate:", err);
});
});
iceHolder = [];
} else {
iceHolder.push(cand);
}
}
});
this._msgStream.on("session-assigned", (data: ProtoClientRequestRoomStream) => {
this._sessionId = data.sessionId;
localStorage.setItem("nestri-session-id", this._sessionId);
console.log("Session ID assigned:", this._sessionId, "for room:", data.roomName);
});
this._msgStream.on("offer", async (data: ProtoSDP) => {
if (!this._pc) {
// Setup peer connection now
this._setupPeerConnection();
}
await this._pc!.setRemoteDescription({
sdp: data.sdp.sdp,
type: data.sdp.type as RTCSdpType,
});
// Add held candidates
iceHolder.forEach((candidate) => {
this._pc!.addIceCandidate(candidate).catch((err) => {
console.error("Error adding held ICE candidate:", err);
});
});
iceHolder = [];
// Create our answer
const answer = await this._pc!.createAnswer();
// Force stereo in Chromium browsers
answer.sdp = this.forceOpusStereo(answer.sdp!);
await this._pc!.setLocalDescription(answer);
// Send answer back
const answerMsg = createMessage(
create(ProtoSDPSchema, {
sdp: answer,
}),
"answer",
);
await this._msgStream?.write(answerMsg);
});
this._msgStream.on("request-stream-offline", (msg: ProtoRaw) => {
console.warn("Stream is offline for room:", msg.data);
this._onConnected?.(null);
});
const clientId = this.getSessionID();
if (clientId) {
console.debug("Using existing session ID:", clientId);
}
// Send stream request
const requestMsg = createMessage(
create(ProtoClientRequestRoomStreamSchema, {
roomName: roomName,
sessionId: clientId ?? "",
}),
"request-stream-room",
);
await this._msgStream.write(requestMsg);
}
}
}
public getSessionID(): string | null {
if (this._sessionId === null)
this._sessionId = localStorage.getItem("nestri-session-id");
return this._sessionId;
}
// Forces opus to stereo in Chromium browsers, because of course
private forceOpusStereo(SDP: string): string {
// Look for "minptime=10;useinbandfec=1" and replace with "minptime=10;useinbandfec=1;stereo=1;sprop-stereo=1;"
return SDP.replace(
/(minptime=10;useinbandfec=1)/,
"$1;stereo=1;sprop-stereo=1;",
);
}
private _setupPeerConnection() {
if (this._pc) {
this._cleanupPeerConnection();
}
console.log("Setting up PeerConnection");
this._pc = new RTCPeerConnection({
iceServers: [
{
urls: "stun:stun.l.google.com:19302",
},
],
});
this._pc.ontrack = (e) => {
console.debug("Track received: ", e.track);
if (e.track.kind === "audio") this._audioTrack = e.track;
else if (e.track.kind === "video") this._videoTrack = e.track;
this._checkConnectionState();
};
this._pc.onconnectionstatechange = () => {
console.debug("Connection state changed to: ", this._pc!.connectionState);
this._checkConnectionState();
};
this._pc.oniceconnectionstatechange = () => {
console.debug(
"ICE connection state changed to: ",
this._pc!.iceConnectionState,
);
this._checkConnectionState();
};
this._pc.onicegatheringstatechange = () => {
console.debug(
"ICE gathering state changed to: ",
this._pc!.iceGatheringState,
);
this._checkConnectionState();
};
this._pc.onicecandidate = (e) => {
if (e.candidate) {
const iceMsg = createMessage(
create(ProtoICESchema, {
candidate: e.candidate,
}),
"ice-candidate",
);
if (this._msgStream) {
this._msgStream
.write(iceMsg)
.catch((err) => console.error("Error sending ICE candidate:", err));
} else {
console.warn("P2P stream not established, cannot send ICE candidate");
}
}
};
this._pc.ondatachannel = (e) => {
this._dataChannel = e.channel;
this._setupDataChannelEvents();
};
}
private _checkConnectionState() {
if (!this._pc || !this._p2p || !this._p2pConn) return;
console.debug("Checking connection state:", {
connectionState: this._pc.connectionState,
iceConnectionState: this._pc.iceConnectionState,
hasAudioTrack: !!this._audioTrack,
hasVideoTrack: !!this._videoTrack,
isConnected: this._isConnected,
});
if (
this._pc.connectionState === "connected" &&
this._audioTrack !== undefined &&
this._videoTrack !== undefined
) {
this._clearConnectionTimer();
if (!this._isConnected) {
// Only trigger callback if not already connected
this._isConnected = true;
if (this._onConnected !== undefined) {
this._onConnected(
new MediaStream([this._audioTrack, this._videoTrack]),
);
// Continuously set low-latency target
this._pc.getReceivers().forEach((receiver: RTCRtpReceiver) => {
let intervalLoop = setInterval(async () => {
if (
receiver.track.readyState !== "live" ||
(receiver.transport && receiver.transport.state !== "connected")
) {
clearInterval(intervalLoop);
return;
} else {
// @ts-ignore
receiver.jitterBufferTarget = receiver.jitterBufferDelayHint = receiver.playoutDelayHint = 0;
}
}, 50);
});
}
}
this._gatherFrameRate();
} else if (
this._pc.connectionState === "failed" ||
this._pc.connectionState === "closed" ||
this._pc.iceConnectionState === "failed"
) {
console.log("PeerConnection failed or closed");
//this._isConnected = false; // Reset connected state
//this._handleConnectionFailure();
}
}
private _handleConnectionFailure() {
this._clearConnectionTimer();
if (this._isConnected) {
// Only notify if previously connected
this._isConnected = false;
if (this._onConnected) {
this._onConnected(null);
}
}
this._cleanupPeerConnection();
// Attempt to reconnect only if not already connected
if (!this._isConnected && this._serverURL && this._roomName) {
this._setup(this._serverURL, this._roomName).catch((err) =>
console.error("Reconnection failed:", err),
);
}
}
private _cleanupPeerConnection() {
if (this._pc) {
try {
this._pc.close();
} catch (err) {
console.error("Error closing peer connection:", err);
}
this._pc = undefined;
}
if (this._audioTrack || this._videoTrack) {
try {
if (this._audioTrack) this._audioTrack.stop();
if (this._videoTrack) this._videoTrack.stop();
} catch (err) {
console.error("Error stopping media tracks:", err);
}
this._audioTrack = undefined;
this._videoTrack = undefined;
}
if (this._dataChannel) {
try {
this._dataChannel.close();
} catch (err) {
console.error("Error closing data channel:", err);
}
this._dataChannel = undefined;
this._dataChannelCallbacks = [];
}
this._isConnected = false; // Reset connected state during cleanup
}
private _clearConnectionTimer() {
if (this._connectionTimer) {
clearTimeout(this._connectionTimer as any);
this._connectionTimer = undefined;
}
}
public addDataChannelCallback(callback: (data: any) => void) {
this._dataChannelCallbacks.push(callback);
}
public removeDataChannelCallback(callback: (data: any) => void) {
this._dataChannelCallbacks = this._dataChannelCallbacks.filter(
(cb) => cb !== callback,
);
}
private _setupDataChannelEvents() {
if (!this._dataChannel) return;
this._dataChannel.onclose = () => console.log("sendChannel has closed");
this._dataChannel.onopen = () => console.log("sendChannel has opened");
this._dataChannel.onmessage = (event) => {
// Parse as ProtoBuf message
const data = event.data;
// Call registered callback if exists
this._dataChannelCallbacks.forEach((callback) => {
try {
callback(data);
} catch (err) {
console.error("Error in data channel callback:", err);
}
});
};
}
private _gatherFrameRate() {
if (this._pc === undefined || this._videoTrack === undefined) return;
const videoInfoPromise = new Promise<{ fps: number }>((resolve) => {
// Keep trying to get fps until it's found
const interval = setInterval(async () => {
if (this._pc === undefined) {
clearInterval(interval);
return;
}
const stats = await this._pc!.getStats(this._videoTrack);
stats.forEach((report) => {
if (report.type === "inbound-rtp") {
clearInterval(interval);
resolve({ fps: report.framesPerSecond });
}
});
}, 250);
});
videoInfoPromise.then((value) => {
this.currentFrameRate = value.fps;
});
}
// Send binary message through the data channel
public sendBinary(data: Uint8Array) {
if (this._dataChannel && this._dataChannel.readyState === "open")
this._dataChannel.send(data);
else console.log("Data channel not open or not established.");
}
public disconnect() {
this._clearConnectionTimer();
this._cleanupPeerConnection();
if (this._p2pConn) {
this._p2pConn
.close()
.catch((err) => console.error("Error closing P2P connection:", err));
this._p2pConn = undefined;
}
this._isConnected = false;
}
}