webrtc_example-dev / index.html
cduss's picture
retroo support one directional audio
55f8404
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Reachy Mini - Pollen Robotics</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--pollen-coral: #FF6B35;
--pollen-coral-light: #FF8A5C;
--pollen-coral-dark: #E55A2B;
--pollen-dark: #1A1A2E;
--pollen-darker: #0F0F1A;
--pollen-card: #16213E;
--pollen-card-light: #1E2A4A;
--text-primary: #FFFFFF;
--text-secondary: #A0AEC0;
--text-muted: #718096;
--success: #48BB78;
--warning: #ECC94B;
--danger: #F56565;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: var(--pollen-darker);
color: var(--text-primary);
min-height: 100vh;
min-height: 100dvh;
overflow-x: hidden;
}
/* Header */
.header {
background: rgba(0,0,0,0.4);
backdrop-filter: blur(10px);
padding: 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255,107,53,0.2);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo img {
width: 32px;
height: 32px;
border-radius: 6px;
}
.logo-text {
font-weight: 700;
font-size: 1em;
color: var(--pollen-coral);
}
.logo-text span {
color: var(--text-secondary);
font-weight: 400;
font-size: 0.85em;
}
.user-section {
display: flex;
align-items: center;
gap: 8px;
}
.user-badge {
background: var(--pollen-card);
padding: 4px 12px;
border-radius: 16px;
font-size: 0.8em;
}
.btn-logout {
background: transparent;
border: 1px solid var(--text-muted);
color: var(--text-secondary);
padding: 4px 12px;
border-radius: 12px;
cursor: pointer;
font-size: 0.75em;
}
/* Main Layout - Single Column */
.app-container {
display: flex;
flex-direction: column;
padding: 8px;
gap: 8px;
max-width: 800px;
margin: 0 auto;
}
/* Video Section */
.video-container {
position: relative;
background: #000;
border-radius: 12px;
overflow: hidden;
aspect-ratio: 16/9;
width: 100%;
}
video {
width: 100%;
height: 100%;
object-fit: cover;
background: linear-gradient(135deg, #0a0a15 0%, #1a1a2e 100%);
}
.video-overlay-top {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 12px;
background: linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, transparent 100%);
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.connection-badge {
display: flex;
align-items: center;
gap: 6px;
background: rgba(0,0,0,0.5);
padding: 6px 12px;
border-radius: 16px;
font-size: 0.8em;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger);
}
.status-indicator.connected {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.status-indicator.connecting {
background: var(--warning);
animation: blink 0.8s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.robot-name {
background: rgba(0,0,0,0.5);
padding: 6px 12px;
border-radius: 16px;
font-size: 0.8em;
font-weight: 500;
}
.video-overlay-bottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 12px;
background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, transparent 100%);
}
.video-controls {
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 0.85em;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--pollen-coral);
color: white;
}
.btn-secondary {
background: rgba(255,255,255,0.15);
color: white;
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-mute {
background: rgba(255,255,255,0.15);
color: white;
display: flex;
align-items: center;
gap: 6px;
}
.btn-mute.muted {
background: var(--danger);
}
.btn-mute svg {
width: 16px;
height: 16px;
}
/* Panels */
.panel {
background: var(--pollen-card);
border-radius: 12px;
overflow: hidden;
}
.panel-header {
padding: 10px 14px;
background: rgba(0,0,0,0.2);
font-weight: 600;
font-size: 0.85em;
color: var(--pollen-coral);
}
.panel-content {
padding: 12px;
}
/* Sliders */
.slider-row {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.slider-row:last-child {
margin-bottom: 0;
}
.slider-label {
font-size: 0.8em;
color: var(--text-secondary);
min-width: 80px;
}
.slider {
flex: 1;
height: 8px;
-webkit-appearance: none;
background: var(--pollen-darker);
border-radius: 4px;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
background: var(--pollen-coral);
border-radius: 50%;
cursor: pointer;
}
.slider-value {
font-family: monospace;
font-size: 0.8em;
color: var(--pollen-coral);
min-width: 45px;
text-align: right;
}
/* Sound */
.sound-row {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.sound-input {
flex: 1;
padding: 8px 10px;
background: var(--pollen-darker);
border: 1px solid var(--pollen-card-light);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85em;
}
.sound-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.preset-chip {
padding: 4px 10px;
background: var(--pollen-darker);
border: 1px solid var(--pollen-card-light);
border-radius: 12px;
color: var(--text-secondary);
font-size: 0.7em;
cursor: pointer;
}
.preset-chip:hover {
border-color: var(--pollen-coral);
color: var(--pollen-coral);
}
/* Robot Selector */
.robot-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.robot-card {
padding: 10px 14px;
background: var(--pollen-darker);
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
}
.robot-card:hover {
background: var(--pollen-card-light);
}
.robot-card.selected {
border-color: var(--pollen-coral);
}
.robot-card .name {
font-weight: 600;
font-size: 0.9em;
}
.robot-card .id {
font-size: 0.75em;
color: var(--text-muted);
font-family: monospace;
}
/* Desktop Layout - Side by Side */
@media (min-width: 900px) {
.app-container {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto auto auto;
max-width: 1200px;
gap: 12px;
}
.video-container {
grid-column: 1;
grid-row: 1 / 3;
}
#robotSelector {
grid-column: 1;
grid-row: 3;
}
.panel:nth-of-type(1) { /* Head Control */
grid-column: 2;
grid-row: 1;
}
.panel:nth-of-type(2) { /* Antennas */
grid-column: 2;
grid-row: 2;
}
.panel:nth-of-type(3) { /* Sound */
grid-column: 2;
grid-row: 3;
}
}
/* Login View */
.login-view {
min-height: 100vh;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card {
background: var(--pollen-card);
padding: 40px;
border-radius: 16px;
text-align: center;
max-width: 380px;
}
.login-logo {
width: 72px;
height: 72px;
margin-bottom: 20px;
border-radius: 12px;
}
.login-card h2 {
color: var(--pollen-coral);
margin-bottom: 10px;
font-size: 1.5em;
}
.login-card p {
color: var(--text-secondary);
margin-bottom: 24px;
font-size: 0.9em;
line-height: 1.5;
}
.btn-hf {
background: #FFD21E;
color: #000;
border: none;
padding: 12px 28px;
border-radius: 8px;
font-size: 0.95em;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
}
.hidden { display: none !important; }
</style>
</head>
<body>
<!-- Login View -->
<div id="loginView" class="login-view">
<div class="login-card">
<img class="login-logo" src="https://raw.githubusercontent.com/pollen-robotics/reachy-mini-desktop-app/develop/src-tauri/icons/128x128.png" alt="Reachy Mini">
<h2>Reachy Mini</h2>
<p>Sign in with your HuggingFace account to connect and control your robot remotely.</p>
<button class="btn-hf" onclick="loginToHuggingFace()">
<svg width="18" height="18" viewBox="0 0 95 88" fill="currentColor">
<path d="M47.5 0C26.3 0 9.1 17.2 9.1 38.4v2.9c0 4.5 1.1 9 3.2 13L0 88h95L82.7 54.3c2.1-4 3.2-8.5 3.2-13v-2.9C85.9 17.2 68.7 0 47.5 0z"/>
</svg>
Sign in with Hugging Face
</button>
</div>
</div>
<!-- Main App -->
<div id="mainApp" class="hidden">
<header class="header">
<div class="logo">
<img src="https://raw.githubusercontent.com/pollen-robotics/reachy-mini-desktop-app/develop/src-tauri/icons/128x128.png" alt="Reachy Mini">
<div class="logo-text">Reachy Mini <span>by Pollen Robotics</span></div>
</div>
<div class="user-section">
<div class="user-badge"><span id="username">@user</span></div>
<button class="btn-logout" onclick="logout()">Sign out</button>
</div>
</header>
<div class="app-container">
<!-- Video -->
<div class="video-container">
<video id="remoteVideo" autoplay playsinline></video>
<div class="video-overlay-top">
<div class="connection-badge">
<div class="status-indicator" id="statusIndicator"></div>
<span id="statusText">Disconnected</span>
</div>
<div class="robot-name" id="robotName"></div>
</div>
<div class="video-overlay-bottom">
<div class="video-controls">
<button class="btn btn-secondary" id="connectBtn" onclick="connectSignaling()">Connect</button>
<button class="btn btn-primary" id="startBtn" onclick="startStream()" disabled>Start</button>
<button class="btn btn-danger" id="stopBtn" onclick="stopStream()" disabled>Stop</button>
<button class="btn btn-mute muted" id="muteBtn" onclick="toggleMute()" disabled>
<svg id="speakerOffIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<line x1="23" y1="9" x2="17" y2="15"></line>
<line x1="17" y1="9" x2="23" y2="15"></line>
</svg>
<svg id="speakerOnIcon" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
<span id="muteText">Unmute</span>
</button>
<button class="btn btn-mute muted" id="micBtn" onclick="toggleMic()" disabled>
<svg id="micOffIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"></line>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2c0 .76-.13 1.49-.35 2.17"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
<svg id="micOnIcon" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
<span id="micText">Mic Off</span>
</button>
</div>
</div>
</div>
<!-- Robot Selector -->
<div id="robotSelector" class="panel hidden">
<div class="panel-header">Available Robots</div>
<div class="panel-content">
<div id="robotList" class="robot-list">
<div style="color: var(--text-muted); font-size: 0.85em;">Searching...</div>
</div>
</div>
</div>
<!-- Head Control - RPY Sliders -->
<div class="panel">
<div class="panel-header">Head Orientation</div>
<div class="panel-content">
<div class="slider-row">
<span class="slider-label">Roll</span>
<input type="range" class="slider" id="rollSlider" min="-20" max="20" value="0" step="0.5">
<span class="slider-value" id="rollValue">0.0°</span>
</div>
<div class="slider-row">
<span class="slider-label">Pitch</span>
<input type="range" class="slider" id="pitchSlider" min="-30" max="30" value="0" step="0.5">
<span class="slider-value" id="pitchValue">0.0°</span>
</div>
<div class="slider-row">
<span class="slider-label">Yaw</span>
<input type="range" class="slider" id="yawSlider" min="-45" max="45" value="0" step="0.5">
<span class="slider-value" id="yawValue">0.0°</span>
</div>
</div>
</div>
<!-- Antennas -->
<div class="panel">
<div class="panel-header">Antennas</div>
<div class="panel-content">
<div class="slider-row">
<span class="slider-label">Right</span>
<input type="range" class="slider" id="rightAntSlider" min="-175" max="175" value="0">
<span class="slider-value" id="rightAntValue"></span>
</div>
<div class="slider-row">
<span class="slider-label">Left</span>
<input type="range" class="slider" id="leftAntSlider" min="-175" max="175" value="0">
<span class="slider-value" id="leftAntValue"></span>
</div>
</div>
</div>
<!-- Sound -->
<div class="panel">
<div class="panel-header">Sound</div>
<div class="panel-content">
<div class="sound-row">
<input type="text" class="sound-input" id="soundInput" placeholder="Sound file...">
<button class="btn btn-primary" id="btnPlaySound" onclick="playSound()" disabled>Play</button>
</div>
<div class="sound-presets">
<span class="preset-chip" onclick="playSoundPreset('wake_up.wav')">wake_up</span>
<span class="preset-chip" onclick="playSoundPreset('go_sleep.wav')">go_sleep</span>
<span class="preset-chip" onclick="playSoundPreset('yes.wav')">yes</span>
<span class="preset-chip" onclick="playSoundPreset('no.wav')">no</span>
</div>
</div>
</div>
</div>
</div>
<script type="module">
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "https://cdn.jsdelivr.net/npm/@huggingface/hub@0.15.2/+esm";
const SIGNALING_SERVER = 'https://cduss-reachy-mini-central.hf.space';
// Connection state
let peerConnection = null;
let dataChannel = null;
let selectedProducerId = null;
let myPeerId = null;
let currentSessionId = null;
let userToken = null;
let currentUser = null;
let sseAbortController = null;
let stateRefreshInterval = null;
// Head control state
let headSlidersActive = false; // True while user is dragging a slider
// Latency monitor
let latencyMonitorId = null;
// Audio mute state (for robot's audio playback)
let isMuted = true; // Default to muted
// Microphone state (for speaking through the robot)
let localMicStream = null;
let isMicMuted = true; // Default mic off
let robotSupportsMic = false; // Whether robot's SDP offers sendrecv audio
// Export functions
window.loginToHuggingFace = loginToHuggingFace;
window.logout = logout;
window.connectSignaling = connectSignaling;
window.startStream = startStream;
window.stopStream = stopStream;
window.playSound = playSound;
window.playSoundPreset = playSoundPreset;
window.toggleMute = toggleMute;
window.toggleMic = toggleMic;
document.addEventListener('DOMContentLoaded', () => {
initAuth();
initHeadSliders();
initAntennaSliders();
});
// ===================== Auth =====================
async function initAuth() {
try {
const oauthResult = await oauthHandleRedirectIfPresent();
if (oauthResult) {
currentUser = oauthResult.userInfo.name || oauthResult.userInfo.preferred_username;
userToken = oauthResult.accessToken;
sessionStorage.setItem('hf_token', userToken);
sessionStorage.setItem('hf_username', currentUser);
sessionStorage.setItem('hf_token_expires', oauthResult.accessTokenExpiresAt);
showMainApp();
} else {
const storedToken = sessionStorage.getItem('hf_token');
const storedUser = sessionStorage.getItem('hf_username');
const expires = sessionStorage.getItem('hf_token_expires');
if (storedToken && storedUser && expires && new Date(expires) > new Date()) {
userToken = storedToken;
currentUser = storedUser;
showMainApp();
} else {
showLogin();
}
}
} catch (e) {
console.error('Auth error:', e);
showLogin();
}
}
async function loginToHuggingFace() {
window.location.href = await oauthLoginUrl();
}
function logout() {
sessionStorage.clear();
userToken = null;
currentUser = null;
disconnectAll();
showLogin();
}
function showLogin() {
document.getElementById('loginView').classList.remove('hidden');
document.getElementById('mainApp').classList.add('hidden');
}
function showMainApp() {
document.getElementById('loginView').classList.add('hidden');
document.getElementById('mainApp').classList.remove('hidden');
document.getElementById('username').textContent = '@' + currentUser;
}
// ===================== Connection =====================
function updateStatus(status, text) {
document.getElementById('statusIndicator').className = 'status-indicator ' + status;
document.getElementById('statusText').textContent = text;
}
async function sendToServer(message) {
try {
const res = await fetch(`${SIGNALING_SERVER}/send?token=${encodeURIComponent(userToken)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
});
return await res.json();
} catch (e) {
console.error('Send error:', e);
return null;
}
}
function sendCommand(cmd) {
if (!dataChannel || dataChannel.readyState !== 'open') return false;
dataChannel.send(JSON.stringify(cmd));
return true;
}
async function connectSignaling() {
if (!userToken) return;
updateStatus('connecting', 'Connecting...');
document.getElementById('connectBtn').disabled = true;
sseAbortController = new AbortController();
try {
const res = await fetch(`${SIGNALING_SERVER}/events?token=${encodeURIComponent(userToken)}`, {
signal: sseAbortController.signal
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
updateStatus('connected', 'Connected');
document.getElementById('robotSelector').classList.remove('hidden');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith('data:')) {
try { handleSignalingMessage(JSON.parse(line.slice(5).trim())); } catch (e) {}
}
}
}
} catch (e) {
if (e.name !== 'AbortError') console.error('Connection failed:', e);
updateStatus('', 'Disconnected');
document.getElementById('connectBtn').disabled = false;
document.getElementById('robotSelector').classList.add('hidden');
}
}
function disconnectAll() {
if (sseAbortController) sseAbortController.abort();
stopStream();
document.getElementById('connectBtn').disabled = false;
}
async function handleSignalingMessage(msg) {
switch (msg.type) {
case 'welcome':
myPeerId = msg.peerId;
await sendToServer({ type: 'setPeerStatus', roles: ['listener'], meta: { name: 'Telepresence' } });
break;
case 'list':
displayRobots(msg.producers);
break;
case 'peerStatusChanged':
const list = await sendToServer({ type: 'list' });
if (list?.producers) displayRobots(list.producers);
break;
case 'sessionStarted':
currentSessionId = msg.sessionId;
break;
case 'peer':
handlePeerMessage(msg);
break;
}
}
function displayRobots(robots) {
const list = document.getElementById('robotList');
list.innerHTML = '';
if (!robots?.length) {
list.innerHTML = '<div style="color: var(--text-muted);">No robots online</div>';
document.getElementById('startBtn').disabled = true;
return;
}
for (const robot of robots) {
const div = document.createElement('div');
div.className = 'robot-card' + (robot.id === selectedProducerId ? ' selected' : '');
div.innerHTML = `<div class="name">${robot.meta?.name || 'Reachy Mini'}</div><div class="id">${robot.id.slice(0, 12)}...</div>`;
div.onclick = () => {
document.querySelectorAll('.robot-card').forEach(e => e.classList.remove('selected'));
div.classList.add('selected');
selectedProducerId = robot.id;
document.getElementById('robotName').textContent = robot.meta?.name || 'Reachy Mini';
document.getElementById('startBtn').disabled = false;
};
list.appendChild(div);
}
}
// ===================== WebRTC =====================
async function startStream() {
if (!selectedProducerId) return;
updateStatus('connecting', 'Connecting...');
// Ensure video is muted by default
const video = document.getElementById('remoteVideo');
video.muted = true;
isMuted = true;
updateMuteButton();
// Capture microphone eagerly (for permission prompt), but do NOT add
// the track to the PeerConnection yet. We only add it after seeing
// the robot's SDP offer to confirm it supports bidirectional audio.
try {
localMicStream = await navigator.mediaDevices.getUserMedia({ audio: true });
localMicStream.getAudioTracks().forEach(t => t.enabled = false); // Start muted
isMicMuted = true;
} catch (e) {
console.warn('Microphone not available, speak-through-robot disabled:', e);
localMicStream = null;
}
robotSupportsMic = false;
peerConnection = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
peerConnection.ontrack = (e) => {
if (e.track.kind === 'video') {
const video = document.getElementById('remoteVideo');
video.srcObject = e.streams[0];
video.playsInline = true;
if ('requestVideoFrameCallback' in video) {
startLatencyMonitor(video);
}
}
};
peerConnection.onicecandidate = async (e) => {
if (e.candidate && currentSessionId) {
await sendToServer({
type: 'peer',
sessionId: currentSessionId,
ice: { candidate: e.candidate.candidate, sdpMLineIndex: e.candidate.sdpMLineIndex, sdpMid: e.candidate.sdpMid }
});
}
};
peerConnection.oniceconnectionstatechange = () => {
const state = peerConnection.iceConnectionState;
if (state === 'connected' || state === 'completed') {
updateStatus('connected', 'Connected');
enableControls(true);
document.getElementById('robotSelector').classList.add('hidden');
stateRefreshInterval = setInterval(() => sendCommand({ get_state: true }), 500);
} else if (state === 'failed' || state === 'disconnected') {
updateStatus('', 'Connection lost');
}
};
peerConnection.ondatachannel = (e) => {
dataChannel = e.channel;
dataChannel.onopen = () => sendCommand({ get_state: true });
dataChannel.onmessage = (e) => handleRobotMessage(JSON.parse(e.data));
};
document.getElementById('startBtn').disabled = true;
document.getElementById('stopBtn').disabled = false;
const res = await sendToServer({ type: 'startSession', peerId: selectedProducerId });
if (res?.sessionId) currentSessionId = res.sessionId;
}
async function handlePeerMessage(msg) {
if (!peerConnection) return;
try {
if (msg.sdp) {
const sdp = msg.sdp;
if (sdp.type === 'offer') {
// Check if robot supports bidirectional audio (sendrecv)
robotSupportsMic = sdpHasAudioSendRecv(sdp.sdp);
// Add mic track BEFORE setRemoteDescription so the
// answer naturally includes sendrecv for audio.
if (robotSupportsMic && localMicStream) {
for (const track of localMicStream.getAudioTracks()) {
peerConnection.addTrack(track, localMicStream);
}
}
await peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
await sendToServer({ type: 'peer', sessionId: currentSessionId, sdp: { type: 'answer', sdp: answer.sdp } });
} else {
await peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
}
}
if (msg.ice) {
await peerConnection.addIceCandidate(new RTCIceCandidate(msg.ice));
}
} catch (e) {
console.error('WebRTC error:', e);
}
}
// Latency monitor - catch up to live if buffer grows too large
function startLatencyMonitor(video) {
if (latencyMonitorId) clearInterval(latencyMonitorId);
latencyMonitorId = setInterval(() => {
if (!video.srcObject || video.paused) return;
const buffered = video.buffered;
if (buffered.length > 0) {
const bufferedEnd = buffered.end(buffered.length - 1);
const lag = bufferedEnd - video.currentTime;
// If more than 0.5s behind live edge, catch up
if (lag > 0.5) {
console.log(`Latency correction: was ${lag.toFixed(2)}s behind`);
video.currentTime = bufferedEnd - 0.1;
}
}
}, 2000); // Check every 2 seconds
}
async function stopStream() {
if (latencyMonitorId) clearInterval(latencyMonitorId);
if (stateRefreshInterval) clearInterval(stateRefreshInterval);
// Notify server that session is ending
if (currentSessionId) {
await sendToServer({ type: 'endSession', sessionId: currentSessionId });
}
// Stop microphone
if (localMicStream) {
localMicStream.getTracks().forEach(t => t.stop());
localMicStream = null;
}
isMicMuted = true;
robotSupportsMic = false;
if (peerConnection) peerConnection.close();
if (dataChannel) dataChannel.close();
peerConnection = null;
dataChannel = null;
currentSessionId = null;
document.getElementById('remoteVideo').srcObject = null;
document.getElementById('startBtn').disabled = !selectedProducerId;
document.getElementById('stopBtn').disabled = true;
document.getElementById('robotSelector').classList.remove('hidden');
enableControls(false);
updateStatus('connected', 'Connected');
}
function enableControls(enabled) {
document.getElementById('btnPlaySound').disabled = !enabled;
document.getElementById('muteBtn').disabled = !enabled;
document.getElementById('micBtn').disabled = !enabled || !localMicStream || !robotSupportsMic;
}
function toggleMute() {
isMuted = !isMuted;
document.getElementById('remoteVideo').muted = isMuted;
updateMuteButton();
}
function updateMuteButton() {
const btn = document.getElementById('muteBtn');
const speakerOffIcon = document.getElementById('speakerOffIcon');
const speakerOnIcon = document.getElementById('speakerOnIcon');
const muteText = document.getElementById('muteText');
if (isMuted) {
btn.classList.add('muted');
speakerOffIcon.classList.remove('hidden');
speakerOnIcon.classList.add('hidden');
muteText.textContent = 'Unmute';
} else {
btn.classList.remove('muted');
speakerOffIcon.classList.add('hidden');
speakerOnIcon.classList.remove('hidden');
muteText.textContent = 'Mute';
}
}
function toggleMic() {
if (!localMicStream) return;
isMicMuted = !isMicMuted;
localMicStream.getAudioTracks().forEach(t => t.enabled = !isMicMuted);
updateMicButton();
}
function updateMicButton() {
const btn = document.getElementById('micBtn');
const micOffIcon = document.getElementById('micOffIcon');
const micOnIcon = document.getElementById('micOnIcon');
const micText = document.getElementById('micText');
if (isMicMuted) {
btn.classList.add('muted');
micOffIcon.classList.remove('hidden');
micOnIcon.classList.add('hidden');
micText.textContent = 'Mic Off';
} else {
btn.classList.remove('muted');
micOffIcon.classList.add('hidden');
micOnIcon.classList.remove('hidden');
micText.textContent = 'Mic On';
}
}
function sdpHasAudioSendRecv(sdp) {
// Check whether the robot's SDP offer has sendrecv for audio,
// meaning it supports bidirectional audio (updated daemon).
const lines = sdp.split('\r\n');
let inAudioSection = false;
for (const line of lines) {
if (line.startsWith('m=audio')) inAudioSection = true;
else if (line.startsWith('m=')) inAudioSection = false;
if (inAudioSection && line === 'a=sendrecv') return true;
}
return false;
}
// ===================== Robot State =====================
function handleRobotMessage(data) {
if (data.state) updateStateDisplay(data.state);
if (data.error) console.error('Robot error:', data.error);
}
function updateStateDisplay(state) {
if (state.head_pose) {
const m = state.head_pose;
const pitch = Math.asin(-m[2][0]) * 180 / Math.PI;
const yaw = Math.atan2(m[1][0], m[0][0]) * 180 / Math.PI;
const roll = Math.atan2(m[2][1], m[2][2]) * 180 / Math.PI;
// Update sliders with real position when not being controlled
if (!headSlidersActive) {
document.getElementById('rollSlider').value = roll;
document.getElementById('rollValue').textContent = roll.toFixed(1) + '°';
document.getElementById('pitchSlider').value = pitch;
document.getElementById('pitchValue').textContent = pitch.toFixed(1) + '°';
document.getElementById('yawSlider').value = yaw;
document.getElementById('yawValue').textContent = yaw.toFixed(1) + '°';
}
}
if (state.antennas) {
const r = (state.antennas[0] * 180 / Math.PI).toFixed(0);
const l = (state.antennas[1] * 180 / Math.PI).toFixed(0);
document.getElementById('rightAntSlider').value = r;
document.getElementById('rightAntValue').textContent = r + '°';
document.getElementById('leftAntSlider').value = l;
document.getElementById('leftAntValue').textContent = l + '°';
}
}
// ===================== Head Sliders =====================
function initHeadSliders() {
const rollSlider = document.getElementById('rollSlider');
const pitchSlider = document.getElementById('pitchSlider');
const yawSlider = document.getElementById('yawSlider');
const rollValue = document.getElementById('rollValue');
const pitchValue = document.getElementById('pitchValue');
const yawValue = document.getElementById('yawValue');
function sendHeadPose() {
const roll = parseFloat(rollSlider.value);
const pitch = parseFloat(pitchSlider.value);
const yaw = parseFloat(yawSlider.value);
sendCommand({ set_target: buildMatrix(yaw, pitch, roll) });
}
function onSliderStart() {
headSlidersActive = true;
}
function onSliderEnd() {
headSlidersActive = false;
}
// Roll slider
rollSlider.addEventListener('mousedown', onSliderStart);
rollSlider.addEventListener('touchstart', onSliderStart);
rollSlider.addEventListener('mouseup', onSliderEnd);
rollSlider.addEventListener('touchend', onSliderEnd);
rollSlider.addEventListener('input', () => {
rollValue.textContent = parseFloat(rollSlider.value).toFixed(1) + '°';
sendHeadPose();
});
// Pitch slider
pitchSlider.addEventListener('mousedown', onSliderStart);
pitchSlider.addEventListener('touchstart', onSliderStart);
pitchSlider.addEventListener('mouseup', onSliderEnd);
pitchSlider.addEventListener('touchend', onSliderEnd);
pitchSlider.addEventListener('input', () => {
pitchValue.textContent = parseFloat(pitchSlider.value).toFixed(1) + '°';
sendHeadPose();
});
// Yaw slider
yawSlider.addEventListener('mousedown', onSliderStart);
yawSlider.addEventListener('touchstart', onSliderStart);
yawSlider.addEventListener('mouseup', onSliderEnd);
yawSlider.addEventListener('touchend', onSliderEnd);
yawSlider.addEventListener('input', () => {
yawValue.textContent = parseFloat(yawSlider.value).toFixed(1) + '°';
sendHeadPose();
});
}
function buildMatrix(yawDeg, pitchDeg, rollDeg) {
const y = yawDeg * Math.PI / 180;
const p = pitchDeg * Math.PI / 180;
const r = rollDeg * Math.PI / 180;
const cy = Math.cos(y), sy = Math.sin(y);
const cp = Math.cos(p), sp = Math.sin(p);
const cr = Math.cos(r), sr = Math.sin(r);
return [
[cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr, 0],
[sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr, 0],
[-sp, cp * sr, cp * cr, 0],
[0, 0, 0, 1]
];
}
// ===================== Antennas =====================
function initAntennaSliders() {
const rightSlider = document.getElementById('rightAntSlider');
const leftSlider = document.getElementById('leftAntSlider');
const rightValue = document.getElementById('rightAntValue');
const leftValue = document.getElementById('leftAntValue');
function sendAntennas() {
const r = parseFloat(rightSlider.value) * Math.PI / 180;
const l = parseFloat(leftSlider.value) * Math.PI / 180;
sendCommand({ set_antennas: [r, l] });
}
rightSlider.addEventListener('input', () => {
rightValue.textContent = rightSlider.value + '°';
sendAntennas();
});
leftSlider.addEventListener('input', () => {
leftValue.textContent = leftSlider.value + '°';
sendAntennas();
});
}
// ===================== Sound =====================
function playSound() {
const file = document.getElementById('soundInput').value.trim();
if (file) sendCommand({ play_sound: file });
}
function playSoundPreset(file) {
document.getElementById('soundInput').value = file;
sendCommand({ play_sound: file });
}
</script>
</body>
</html>