Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,12 @@ Same principle as Steam Link, Moonlight, or Stadia: capture, encode, transmit, d
└─────────────────────────────────────────────────────────────────────────┼──┼───┘
│ │
Browser ───── WebSocket (input) ────────────────────────┘ │
◄──── WebRTC video+audio (WHEP) ───────────────────┘
◄──── WebRTC video (WHEP) ────────────────────────┘
◄──── WebRTC audio (WHEP, separate connection) ────┘
```

Video and audio are fetched over **two independent WHEP requests / `RTCPeerConnection`s**, not two tracks on one connection — see "Why two WebRTC connections" below.

---

## Components
Expand All @@ -66,8 +69,26 @@ Same principle as Steam Link, Moonlight, or Stadia: capture, encode, transmit, d
- `-bf 0` is set on **all** encoder profiles. WebRTC rejects H.264 streams that contain B-frames ("WebRTC doesn't support H264 streams with B-frames"). `-tune zerolatency` in libx264 is supposed to disable them but is not guaranteed across all builds — explicit `-bf 0` is the reliable fix.
- `-fflags nobuffer -flags low_delay` and `-fps_mode passthrough` reduce buffering latency.
- GOP of 15 frames (`-g 15`) was validated to reduce jitter buffer from ~200 ms to ~100 ms on the Pi.
- `-af aresample=async=1000:first_pts=0` resamples audio to a constant rate anchored at PTS 0, compensating for drift introduced by the FIFO/Matroska path now that audio and video are consumed by two independent WebRTC connections (see below) with no shared clock to resync them.
- Implemented in `backend/app/streaming/ffmpeg_webrtc.py` (`FFmpegStreamingProvider`).

### 2.1 Why two WebRTC connections (video/audio split)

Initially, a single `RTCPeerConnection` carried both the video and audio tracks (two transceivers on one connection), matching how WHEP is normally used. Measured with `RTCPeerConnection.getStats()` on the Pi 3, this configuration showed a **video jitter buffer of ~289-459 ms** — far above what the encoder alone accounts for (see "Known hardware limits" below).

Root cause: when audio and video share one `RTCPeerConnection`, the browser's jitter buffer logic holds the video queue back to preserve lip-sync with the audio track's own (larger) buffering requirements — inflating video latency to match audio, not the other way around.

**Fix**: the frontend opens two independent WHEP requests against the same `whep_url`, each negotiating a single `recvonly` transceiver (`connectWhepTrack("video", ...)` and `connectWhepTrack("audio", ...)` in `frontend/app.js`), resulting in two separate `RTCPeerConnection`s and two `<video>`/`<audio>` elements. Measured result on the same Pi 3 hardware:

| Connection | buffer_ms | jitter_ms |
| --- | --- | --- |
| video | ~16 | ~7 |
| audio | ~89 | ~10 |

Video latency dropped roughly 94% (289-459 ms → ~16 ms). Audio keeps a higher buffer (expected — audio glitches are more perceptible than a few extra ms of buffering), and the two connections are no longer synchronized by the browser. The ~70 ms gap between video and audio buffers is below the ITU-R BT.1359 perceptibility threshold (~125 ms) and was not noticeable in manual play-testing, but there is no hard guarantee against drift over a long play session — if this becomes noticeable, periodic resync (e.g. comparing `HTMLMediaElement.currentTime` between the two elements) would be the next step.

Audio failing to connect (`connectWhepTrack("audio", ...)` throwing) is treated as non-fatal — the game keeps running with video only rather than failing `/play` entirely.

### 3. MediaMTX (WebRTC/WHEP server)

- Receives RTSP from ffmpeg, exposes the stream via WHEP for browsers.
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# HomeGames — imagem containerizada AGNÓSTICA DE GPU para Linux x86_64.
# RetroHost — imagem containerizada AGNÓSTICA DE GPU para Linux x86_64.
#
# Replica o pipeline do Raspberry Pi (RetroArch headless -> FIFO -> ffmpeg ->
# mediamtx -> WebRTC + input + FastAPI), mas a MESMA imagem roda em qualquer
Expand Down Expand Up @@ -73,7 +73,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# mas não foi validado — ver SECURITY.md / ARCHITECTURE.md.
RUN arch="$(uname -m)"; \
if [ "$arch" != "x86_64" ]; then \
echo "ERRO: HomeGames container é x86_64-only; arquitetura detectada: $arch." >&2; \
echo "ERRO: RetroHost container é x86_64-only; arquitetura detectada: $arch." >&2; \
echo "Builde numa máquina/plataforma amd64 (ou use --platform linux/amd64)." >&2; \
exit 1; \
fi
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Projects such as [Sunshine](https://github.com/LizardByte/Sunshine), [Wolf](http

## How it works

RetroHost is a **server-side emulation streaming** system. The emulator (RetroArch + libretro core) runs headless on the server and writes raw video + audio to a named pipe. FFmpeg reads that pipe, encodes H.264 + Opus, and publishes RTSP to MediaMTX, which delivers the stream to any browser via WebRTC (WHEP). The browser captures keyboard/gamepad input and sends it back over a WebSocket, which the server injects as a virtual input device.
RetroHost is a **server-side emulation streaming** system. The emulator (RetroArch + libretro core) runs headless on the server and writes raw video + audio to a named pipe. FFmpeg reads that pipe, encodes H.264 + Opus, and publishes RTSP to MediaMTX, which delivers the stream to any browser via WebRTC (WHEP) — over two independent connections, one for video and one for audio (see [ARCHITECTURE.md](ARCHITECTURE.md#21-why-two-webrtc-connections-videoaudio-split)). The browser captures keyboard/gamepad input and sends it back over a WebSocket, which the server injects as a virtual input device.

No browser plugin. No client app. No JavaScript framework. Just native browser APIs: `RTCPeerConnection`, `Gamepad API`, `WebSocket`.

Expand Down Expand Up @@ -95,11 +95,13 @@ For a full component-by-component breakdown, design decisions, and replication g

Measured via `RTCPeerConnection.getStats()` on the client browser.

Video and audio are delivered over **two independent WebRTC (WHEP) connections** rather than two tracks on one connection — see [ARCHITECTURE.md](ARCHITECTURE.md#21-why-two-webrtc-connections-videoaudio-split) for why. The numbers below are for the **video** connection, which is what drives perceived input latency.

| Hardware | Encoder | jitter buffer delay | Notes |
|---|---|---|---|
| Raspberry Pi 3 | h264_v4l2m2m (HW) | ~145 ms | Hardware limit; residual input delay ~376 ms |
| x86_64 + NVIDIA RTX 4050 | h264_nvenc (NVENC) | ~93 ms | ~36% lower latency vs Pi 3 |
| x86_64 (no GPU) | libx264 (CPU) | ~120–160 ms | Varies by CPU; functional but heavier load |
| --- | --- | --- | --- |
| Raspberry Pi 3 | h264_v4l2m2m (HW) | ~16 ms | Measured after the video/audio WHEP split; audio connection buffers separately at ~89 ms, not synchronized with video |
| x86_64 + NVIDIA RTX 4050 | h264_nvenc (NVENC) | ~93 ms | Measured before the video/audio split (single connection); expected to drop similarly with the split, not yet re-measured |
| x86_64 (no GPU) | libx264 (CPU) | ~120–160 ms | Varies by CPU; functional but heavier load; measured before the video/audio split |

Input round-trip (WebSocket send → emulator reaction) is sub-millisecond on the server side; perceived input latency is dominated by the video pipeline delay above.

Expand Down
8 changes: 4 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Segurança, limitações e uso responsável

O HomeGames é um projeto **doméstico**, pensado para rodar **dentro da sua rede local (LAN)** — um servidor de jogos retrô que você acessa pelo navegador de outros dispositivos da sua casa. Esta seção é honesta sobre o que isso implica em segurança, quais são os riscos conhecidos, e o que você **não** deve fazer.
O RetroHost é um projeto **doméstico**, pensado para rodar **dentro da sua rede local (LAN)** — um servidor de jogos retrô que você acessa pelo navegador de outros dispositivos da sua casa. Esta seção é honesta sobre o que isso implica em segurança, quais são os riscos conhecidos, e o que você **não** deve fazer.

## ⚠️ Não exponha à internet / não suba em cloud pública

**Não há autenticação em nenhuma rota.** Qualquer pessoa que alcance a porta `8000` (e as portas de mídia) pode listar/iniciar/parar jogos, configurar storage e enviar input. Isso é aceitável numa LAN doméstica de confiança, mas significa:

- **Nunca exponha as portas do HomeGames diretamente à internet** (port forwarding no roteador, IP público, etc.).
- **Nunca exponha as portas do RetroHost diretamente à internet** (port forwarding no roteador, IP público, etc.).
- **Nunca suba este container em um provedor de cloud público** (AWS, GCP, Azure, VPS, etc.) com as portas acessíveis. Sem autenticação e com privilégios elevados (ver abaixo), seria um alvo trivial.
- Se você *precisa* acessar de fora de casa, use uma **VPN** para entrar na sua LAN — não exponha o serviço.

Expand Down Expand Up @@ -36,10 +36,10 @@ O backend monta CIFS executando `mount` com parâmetros vindos da interface web.

**Este projeto não distribui, não inclui e não fornece BIOS ou ROMs de jogos.**

- A **BIOS** de consoles (ex: PS1) é propriedade do fabricante. Você deve **extraí-la do seu próprio console** ou obtê-la de forma legal. O HomeGames apenas lê a BIOS que *você* colocar no storage.
- A **BIOS** de consoles (ex: PS1) é propriedade do fabricante. Você deve **extraí-la do seu próprio console** ou obtê-la de forma legal. O RetroHost apenas lê a BIOS que *você* colocar no storage.
- As **ROMs** dos jogos são propriedade dos respectivos detentores de direitos. Você deve **possuir uma cópia legal** do jogo (geralmente, fazer o *dump* da sua própria mídia física). Baixar ROMs de jogos que você não possui é ilegal na maioria das jurisdições.

O HomeGames é uma ferramenta de **streaming/emulação da sua própria biblioteca legal** — não um meio de distribuir conteúdo protegido. O uso para pirataria não é apoiado e é de inteira responsabilidade de quem o faz.
O RetroHost é uma ferramenta de **streaming/emulação da sua própria biblioteca legal** — não um meio de distribuir conteúdo protegido. O uso para pirataria não é apoiado e é de inteira responsabilidade de quem o faz.

## Resumo: uso recomendado

Expand Down
2 changes: 1 addition & 1 deletion backend/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Configuração central do HomeGames.
"""Configuração central do RetroHost.

Todos os caminhos derivam de PROJECT_ROOT, que por padrão assume o layout
padrão do Raspberry Pi (/home/YOUR_USER/retrohost). Pode ser sobrescrito pela
Expand Down
2 changes: 1 addition & 1 deletion backend/app/input/uinput_keyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class UInputNotAvailableError(RuntimeError):


class UInputKeyboardProvider(InputProvider):
def __init__(self, device_name: str = "HomeGames Virtual Keyboard") -> None:
def __init__(self, device_name: str = "RetroHost Virtual Keyboard") -> None:
self._device_name = device_name
self._device = None
self._pressed_keys: set[str] = set()
Expand Down
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from app.core.config import settings
from app.db.database import init_db

app = FastAPI(title="HomeGames")
app = FastAPI(title="RetroHost")

init_db()

Expand Down
2 changes: 1 addition & 1 deletion backend/app/services/storage.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Configura de onde o HomeGames lê ROMs: disco local (padrão) ou um share
"""Configura de onde o RetroHost lê ROMs: disco local (padrão) ou um share
de rede CIFS/Samba montado sob demanda no sistema operacional do Pi.

RetroArchDriver/PlayerService não sabem nem precisam saber qual modo está
Expand Down
1 change: 1 addition & 0 deletions backend/app/streaming/ffmpeg_webrtc.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def start(self, game: Any) -> None:
"-fps_mode", "passthrough",
*profile.filter_args,
*profile.codec_args,
"-af", "aresample=async=1000:first_pts=0",
"-c:a", "libopus", "-b:a", "96k",
"-pkt_size", "1200",
"-f", "rtsp", settings.MEDIAMTX_RTSP_URL,
Expand Down
5 changes: 3 additions & 2 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ services:

volumes:
- retrohost-data:/data
# To use local ROMs instead of CIFS network storage:
# - /path/to/your/roms:/mnt/retrohost-roms:ro
# To use local ROMs instead of CIFS network storage, mount your ROMs
# directory at the path the backend scans in local mode (ROMS_DIR):
# - /path/to/your/roms:/app/emulator/roms:ro

volumes:
retrohost-data:
2 changes: 1 addition & 1 deletion config/mediamtx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ authJWTAudience:
# Global settings -> Control API

# Enable the control API server, which allows to control the server.
# Habilitado: o backend HomeGames usa GET /v3/paths/get/live para saber
# Habilitado: o backend RetroHost usa GET /v3/paths/get/live para saber
# quando o ffmpeg terminou de publicar antes de liberar o WHEP ao frontend.
api: true
# Address of the TCP/HTTP listener.
Expand Down
2 changes: 1 addition & 1 deletion container/sdl_input_preload.c
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* LD_PRELOAD para o RetroArch no container HomeGames.
/* LD_PRELOAD para o RetroArch no container RetroHost.
*
* Motivo: no WSL2 não há udevd, então o input via uinput/udev não funciona
* (o libudev não enumera os devices). Este preload contorna isso criando um
Expand Down
84 changes: 60 additions & 24 deletions frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const scanBtn = document.getElementById("scan-btn");
const idleView = document.getElementById("idle-view");
const playView = document.getElementById("play-view");
const gameVideo = document.getElementById("game-video");
const gameAudio = document.getElementById("game-audio");
const fullscreenBtn = document.getElementById("fullscreen-btn");
const endGameBtn = document.getElementById("end-game-btn");
const backgroundGameBanner = document.getElementById("background-game-banner");
Expand All @@ -20,7 +21,10 @@ const cifsUsernameInput = document.getElementById("cifs-username");
const cifsPasswordInput = document.getElementById("cifs-password");

let whepUrl = null;
let peerConnection = null;
//let peerConnection = null; --

let videoPeerConnection = null;
let audioPeerConnection = null;

// Mesmo vocabulario do backend (backend/app/input/keymap.py) - nomes
// logicos que correspondem aos binds input_player1_* do RetroArch.
Expand Down Expand Up @@ -211,6 +215,35 @@ async function scanLibrary() {
}
}

async function connectWhepTrack(kind, onTrack) {
const pc = new RTCPeerConnection();

pc.ontrack = onTrack;
pc.addTransceiver(kind, { direction: "recvonly" });

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

const response = await fetch(whepUrl, {
method: "POST",
headers: { "Content-Type": "application/sdp" },
body: offer.sdp,
});

if (!response.ok) {
pc.close();
throw new Error(`WHEP ${kind} falhou: ${response.status}`);
}

const answerSdp = await response.text();
await pc.setRemoteDescription({
type: "answer",
sdp: answerSdp,
});

return pc;
}

function showPlayView() {
idleView.classList.add("hidden");
playView.classList.remove("hidden");
Expand All @@ -225,38 +258,41 @@ async function connectWhep() {
if (!whepUrl) {
await loadConfig();
}
const pc = new RTCPeerConnection();
peerConnection = pc;

pc.ontrack = (event) => {
gameVideo.srcObject = event.streams[0];
};
pc.addTransceiver("video", { direction: "recvonly" });
pc.addTransceiver("audio", { direction: "recvonly" });

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

const resp = await fetch(whepUrl, {
method: "POST",
headers: { "Content-Type": "application/sdp" },
body: offer.sdp,
videoPeerConnection = await connectWhepTrack("video", (event) => {
gameVideo.srcObject = new MediaStream([event.track]);
});

if (!resp.ok) {
throw new Error(`WHEP falhou: ${resp.status}`);
}
try {
audioPeerConnection = await connectWhepTrack("audio", (event) => {
gameAudio.srcObject = new MediaStream([event.track]);

const answerSdp = await resp.text();
await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
gameAudio.play().catch((error) => {
console.warn("Autoplay do áudio bloqueado:", error);
});
});
} catch (error) {
// Falha do áudio não deve impedir o jogo.
console.warn("Conexão de áudio indisponível:", error);
audioPeerConnection = null;
}
}

function disconnectWhep() {
if (peerConnection) {
peerConnection.close();
peerConnection = null;
if (videoPeerConnection) {
videoPeerConnection.close();
videoPeerConnection = null;
}

if (audioPeerConnection) {
audioPeerConnection.close();
audioPeerConnection = null;
}

gameVideo.srcObject = null;

gameAudio.pause();
gameAudio.srcObject = null;
}

function connectInputSocket() {
Expand Down
1 change: 1 addition & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ <h1>RetroHost</h1>
<main id="play-view" class="hidden">
<div id="video-wrap">
<video id="game-video" autoplay playsinline></video>
<audio id="game-audio" autoplay></audio>
</div>
<div class="play-controls">
<button id="fullscreen-btn">Tela cheia</button>
Expand Down
2 changes: 1 addition & 1 deletion scripts/docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Entrypoint do container HomeGames (ver Dockerfile / ARCHITECTURE.md).
# Entrypoint do container RetroHost (ver Dockerfile / ARCHITECTURE.md).
#
# Substitui os dois systemd units do Pi (mediamtx.service + homegames.service)
# por um supervisor leve: sobe o mediamtx em background e o uvicorn em foreground,
Expand Down
2 changes: 1 addition & 1 deletion scripts/setup_streaming.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ if ! grep -q "^hdmi_force_hotplug=1" "$BOOT_CONFIG"; then
sudo cp "$BOOT_CONFIG" "${BOOT_CONFIG}.bak.$(date +%s)"
sudo tee -a "$BOOT_CONFIG" > /dev/null <<'EOF'

# HomeGames: forca deteccao de display mesmo sem TV/monitor conectado fisicamente
# RetroHost: forca deteccao de display mesmo sem TV/monitor conectado fisicamente
# (necessario para RetroArch renderizar offscreen para captura/streaming headless)
hdmi_force_hotplug=1
hdmi_group=2
Expand Down
2 changes: 1 addition & 1 deletion scripts/whep_test.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
-->
<html>
<head>
<title>HomeGames - teste WHEP</title>
<title>RetroHost - teste WHEP</title>
<style>
body { margin: 0; background: #111; color: #eee; font-family: sans-serif; }
#controls { padding: 8px; display: flex; gap: 8px; align-items: center; }
Expand Down
Loading