mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
vip audio card
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
## WebUI frontend
|
||||
- Split large JSX/components and large backing JS files into folderized modules.
|
||||
- Keep modules clear and focused, with title comments.
|
||||
- Each component must live entirely inside its own folder; do not leave wrapper/compatibility component files outside that folder.
|
||||
- Remove stale compatibility/leftover code only after usage verification.
|
||||
|
||||
# REFACTOR TRACKING
|
||||
@@ -58,7 +59,7 @@
|
||||
### BIGGEST OFFENDERS
|
||||
- [x] mini summary app
|
||||
- [x] spectator app
|
||||
- [ ] vip audio upload card
|
||||
- [x] vip audio upload card
|
||||
- [ ] admin panel
|
||||
- [ ] drive dock action
|
||||
- [ ] gamepad mapping settings
|
||||
@@ -71,11 +72,13 @@
|
||||
- mini summary app
|
||||
- spectator app
|
||||
- video tile
|
||||
- vip audio upload card
|
||||
|
||||
### LARGE CHANGES
|
||||
- Split `webui/src/mini/MiniSummaryApp.jsx` into folderized modules under `webui/src/mini/MiniSummaryApp/` with a compatibility entrypoint preserved.
|
||||
- Split `webui/src/spectate/SpectatorApp.jsx` into folderized modules under `webui/src/spectate/SpectatorApp/` with a compatibility entrypoint preserved.
|
||||
- Split `webui/src/components/VideoTile.jsx` by extracting HUD, overlays, chat input, and constants into `webui/src/components/VideoTile/` while preserving the existing `VideoTile.jsx` public component API.
|
||||
- Split `webui/src/components/vip/VipAudioUploadCard.jsx` by extracting transport/audio helpers and UI atoms into `webui/src/components/vip/VipAudioUploadCard/` while preserving the existing `VipAudioUploadCard.jsx` import/export API.
|
||||
|
||||
## Done criteria (per item)
|
||||
- [ ] Folderized structure created.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-CtJOO8C0.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BB1PfW7u.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DVTOmRBl.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { COOKIE_KEY_REGEX, flowWrapClass } from './vip/constants.js';
|
||||
import VipAudioUploadCard from './vip/VipAudioUploadCard.jsx';
|
||||
import VipAudioUploadCard from './vip/VipAudioUploadCard/index.jsx';
|
||||
import VipVerificationCard from './vip/VipVerificationCard.jsx';
|
||||
import VipIdentityCard from './vip/VipIdentityCard.jsx';
|
||||
import VipPrivateRoverAccessCard from './vip/VipPrivateRoverAccessCard.jsx';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Keybinding label pill for PTT hints.
|
||||
export default function KeyPill({ label }) {
|
||||
if (!label) return null;
|
||||
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Compact status tile used throughout VIP audio card.
|
||||
export default function StatusIndicator({ label, active, detail = '' }) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md px-0.5 py-0.5 text-xs text-slate-100 ${
|
||||
active ? 'bg-emerald-500' : 'bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="text-center font-medium">{label}</div>
|
||||
<div className="text-center text-[0.72rem] opacity-90">{detail || (active ? 'active' : 'idle')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+15
-228
@@ -1,232 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fieldClass } from './constants.js';
|
||||
import { useControlSystem } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
|
||||
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
const TARGET_SAMPLE_RATE = 16000;
|
||||
const RTC_CONFIG = {
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
bundlePolicy: 'max-bundle',
|
||||
rtcpMuxPolicy: 'require',
|
||||
};
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function encodeBase64(value) {
|
||||
if (typeof btoa === 'function') return btoa(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildAuthHeader(token) {
|
||||
if (!token) return {};
|
||||
const encoded = encodeBase64(`${token}:${token}`);
|
||||
return encoded ? { Authorization: `Basic ${encoded}` } : {};
|
||||
}
|
||||
|
||||
function waitForIceGatheringComplete(pc, timeoutMs = 1500) {
|
||||
return new Promise((resolve) => {
|
||||
if (!pc || pc.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
pc.removeEventListener('icegatheringstatechange', onChange);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
function onChange() {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(timer);
|
||||
pc.removeEventListener('icegatheringstatechange', onChange);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
pc.addEventListener('icegatheringstatechange', onChange);
|
||||
});
|
||||
}
|
||||
|
||||
function isPeerTransportReady(pc) {
|
||||
if (!pc) return false;
|
||||
const conn = pc.connectionState;
|
||||
const ice = pc.iceConnectionState;
|
||||
if (conn === 'connected') return true;
|
||||
if (ice === 'connected' || ice === 'completed') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function waitForPeerConnected(pc, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!pc) {
|
||||
reject(new Error('Peer connection missing'));
|
||||
return;
|
||||
}
|
||||
if (isPeerTransportReady(pc)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Peer connection timeout'));
|
||||
}, timeoutMs);
|
||||
const onState = () => {
|
||||
if (isPeerTransportReady(pc)) {
|
||||
cleanup();
|
||||
resolve();
|
||||
} else if (
|
||||
pc.connectionState === 'failed' ||
|
||||
pc.connectionState === 'closed' ||
|
||||
pc.iceConnectionState === 'failed'
|
||||
) {
|
||||
cleanup();
|
||||
reject(new Error(`Peer connection ${pc.connectionState || pc.iceConnectionState}`));
|
||||
}
|
||||
};
|
||||
function cleanup() {
|
||||
clearTimeout(timer);
|
||||
pc.removeEventListener('connectionstatechange', onState);
|
||||
pc.removeEventListener('iceconnectionstatechange', onState);
|
||||
}
|
||||
pc.addEventListener('connectionstatechange', onState);
|
||||
pc.addEventListener('iceconnectionstatechange', onState);
|
||||
});
|
||||
}
|
||||
|
||||
async function configureSenderForLowLatency(sender) {
|
||||
if (!sender?.getParameters || !sender?.setParameters) return;
|
||||
const params = sender.getParameters() || {};
|
||||
const first = (params.encodings && params.encodings[0]) || {};
|
||||
params.encodings = [
|
||||
{
|
||||
...first,
|
||||
maxBitrate: 64000,
|
||||
dtx: 'disabled',
|
||||
},
|
||||
];
|
||||
try {
|
||||
await sender.setParameters(params);
|
||||
} catch {
|
||||
// Browser support varies; keep defaults if rejected.
|
||||
}
|
||||
}
|
||||
|
||||
function waitForOutboundAudioFlow(pc, timeoutMs = 6000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!pc) {
|
||||
reject(new Error('Peer connection missing'));
|
||||
return;
|
||||
}
|
||||
const start = Date.now();
|
||||
let baseline = -1;
|
||||
const timer = setInterval(async () => {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
clearInterval(timer);
|
||||
reject(new Error('WHIP connected but no outbound audio flow'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const senders = pc.getSenders().filter((s) => s.track?.kind === 'audio');
|
||||
for (const sender of senders) {
|
||||
const stats = await sender.getStats();
|
||||
for (const report of stats.values()) {
|
||||
if (report.type !== 'outbound-rtp' || report.kind !== 'audio') continue;
|
||||
const sent = Number(report.bytesSent || 0);
|
||||
const packets = Number(report.packetsSent || 0);
|
||||
if (baseline < 0) {
|
||||
baseline = sent;
|
||||
} else if (sent > baseline + 200 || packets > 5) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep polling until timeout.
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
function StatusIndicator({ label, active, detail = '' }) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md px-0.5 py-0.5 text-xs text-slate-100 ${
|
||||
active ? 'bg-emerald-500' : 'bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div className="text-center font-medium">{label}</div>
|
||||
<div className="text-center text-[0.72rem] opacity-90">{detail || (active ? 'active' : 'idle')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyPill({ label }) {
|
||||
if (!label) return null;
|
||||
return <span className="rounded border border-white/40 px-1 text-[0.7rem] text-white">{label}</span>;
|
||||
}
|
||||
|
||||
function mergeFloatChunks(chunks) {
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const merged = new Float32Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function pcm16FromFloat32(samples) {
|
||||
const out = new Int16Array(samples.length);
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||
out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeWavMono16(samples, sampleRate) {
|
||||
const pcm = pcm16FromFloat32(samples);
|
||||
const dataSize = pcm.length * 2;
|
||||
const buffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
const writeString = (offset, value) => {
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
view.setUint8(offset + i, value.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
writeString(0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeString(8, 'WAVE');
|
||||
writeString(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeString(36, 'data');
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
let offset = 44;
|
||||
for (let i = 0; i < pcm.length; i += 1, offset += 2) {
|
||||
view.setInt16(offset, pcm[i], true);
|
||||
}
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
import { fieldClass } from '../constants.js';
|
||||
import { useControlSystem } from '../../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../../controls/keymapUtils.js';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE, RTC_CONFIG } from './constants.js';
|
||||
import { bytesToBase64, buildAuthHeader } from './base64.js';
|
||||
import {
|
||||
waitForIceGatheringComplete,
|
||||
waitForPeerConnected,
|
||||
configureSenderForLowLatency,
|
||||
waitForOutboundAudioFlow,
|
||||
} from './whipTransport.js';
|
||||
import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js';
|
||||
import StatusIndicator from './StatusIndicator.jsx';
|
||||
import KeyPill from './KeyPill.jsx';
|
||||
|
||||
export default function VipAudioUploadCard({
|
||||
ownRoverId = '',
|
||||
@@ -0,0 +1,54 @@
|
||||
// PCM/WAV helpers for clip-mode recording payloads.
|
||||
export function mergeFloatChunks(chunks) {
|
||||
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const merged = new Float32Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function pcm16FromFloat32(samples) {
|
||||
const out = new Int16Array(samples.length);
|
||||
for (let i = 0; i < samples.length; i += 1) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||
out[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function encodeWavMono16(samples, sampleRate) {
|
||||
const pcm = pcm16FromFloat32(samples);
|
||||
const dataSize = pcm.length * 2;
|
||||
const buffer = new ArrayBuffer(44 + dataSize);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
const writeString = (offset, value) => {
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
view.setUint8(offset + i, value.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
writeString(0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataSize, true);
|
||||
writeString(8, 'WAVE');
|
||||
writeString(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeString(36, 'data');
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
let offset = 44;
|
||||
for (let i = 0; i < pcm.length; i += 1, offset += 2) {
|
||||
view.setInt16(offset, pcm[i], true);
|
||||
}
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Base64 and auth helpers for upload and WHIP requests.
|
||||
export function bytesToBase64(bytes) {
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function encodeBase64(value) {
|
||||
if (typeof btoa === 'function') return btoa(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildAuthHeader(token) {
|
||||
if (!token) return {};
|
||||
const encoded = encodeBase64(`${token}:${token}`);
|
||||
return encoded ? { Authorization: `Basic ${encoded}` } : {};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// VIP audio upload/mic forwarding constants.
|
||||
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
export const TARGET_SAMPLE_RATE = 16000;
|
||||
export const RTC_CONFIG = {
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
bundlePolicy: 'max-bundle',
|
||||
rtcpMuxPolicy: 'require',
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import VipAudioUploadCardContent from './VipAudioUploadCardContent.jsx';
|
||||
|
||||
export default VipAudioUploadCardContent;
|
||||
@@ -0,0 +1,123 @@
|
||||
// WHIP/WebRTC transport helpers for microphone forwarding.
|
||||
export function waitForIceGatheringComplete(pc, timeoutMs = 1500) {
|
||||
return new Promise((resolve) => {
|
||||
if (!pc || pc.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
pc.removeEventListener('icegatheringstatechange', onChange);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
function onChange() {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(timer);
|
||||
pc.removeEventListener('icegatheringstatechange', onChange);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
pc.addEventListener('icegatheringstatechange', onChange);
|
||||
});
|
||||
}
|
||||
|
||||
function isPeerTransportReady(pc) {
|
||||
if (!pc) return false;
|
||||
const conn = pc.connectionState;
|
||||
const ice = pc.iceConnectionState;
|
||||
if (conn === 'connected') return true;
|
||||
if (ice === 'connected' || ice === 'completed') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function waitForPeerConnected(pc, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!pc) {
|
||||
reject(new Error('Peer connection missing'));
|
||||
return;
|
||||
}
|
||||
if (isPeerTransportReady(pc)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Peer connection timeout'));
|
||||
}, timeoutMs);
|
||||
const onState = () => {
|
||||
if (isPeerTransportReady(pc)) {
|
||||
cleanup();
|
||||
resolve();
|
||||
} else if (
|
||||
pc.connectionState === 'failed' ||
|
||||
pc.connectionState === 'closed' ||
|
||||
pc.iceConnectionState === 'failed'
|
||||
) {
|
||||
cleanup();
|
||||
reject(new Error(`Peer connection ${pc.connectionState || pc.iceConnectionState}`));
|
||||
}
|
||||
};
|
||||
function cleanup() {
|
||||
clearTimeout(timer);
|
||||
pc.removeEventListener('connectionstatechange', onState);
|
||||
pc.removeEventListener('iceconnectionstatechange', onState);
|
||||
}
|
||||
pc.addEventListener('connectionstatechange', onState);
|
||||
pc.addEventListener('iceconnectionstatechange', onState);
|
||||
});
|
||||
}
|
||||
|
||||
export async function configureSenderForLowLatency(sender) {
|
||||
if (!sender?.getParameters || !sender?.setParameters) return;
|
||||
const params = sender.getParameters() || {};
|
||||
const first = (params.encodings && params.encodings[0]) || {};
|
||||
params.encodings = [
|
||||
{
|
||||
...first,
|
||||
maxBitrate: 64000,
|
||||
dtx: 'disabled',
|
||||
},
|
||||
];
|
||||
try {
|
||||
await sender.setParameters(params);
|
||||
} catch {
|
||||
// Browser support varies; keep defaults if rejected.
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForOutboundAudioFlow(pc, timeoutMs = 6000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!pc) {
|
||||
reject(new Error('Peer connection missing'));
|
||||
return;
|
||||
}
|
||||
const start = Date.now();
|
||||
let baseline = -1;
|
||||
const timer = setInterval(async () => {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
clearInterval(timer);
|
||||
reject(new Error('WHIP connected but no outbound audio flow'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const senders = pc.getSenders().filter((s) => s.track?.kind === 'audio');
|
||||
for (const sender of senders) {
|
||||
const stats = await sender.getStats();
|
||||
for (const report of stats.values()) {
|
||||
if (report.type !== 'outbound-rtp' || report.kind !== 'audio') continue;
|
||||
const sent = Number(report.bytesSent || 0);
|
||||
const packets = Number(report.packetsSent || 0);
|
||||
if (baseline < 0) {
|
||||
baseline = sent;
|
||||
} else if (sent > baseline + 200 || packets > 5) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep polling until timeout.
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user