vip audio card

This commit is contained in:
legop3
2026-04-28 14:33:39 -04:00
parent 4dc930f64b
commit 7e13d44458
12 changed files with 249 additions and 232 deletions
+1 -1
View File
@@ -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>
);
}
@@ -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);
});
}