Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
2059 lines
69 KiB
JavaScript
2059 lines
69 KiB
JavaScript
#!/usr/bin/env node
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {createHmac, randomUUID} from 'node:crypto';
|
|
import {writeFile} from 'node:fs/promises';
|
|
import {createRequire} from 'node:module';
|
|
import net from 'node:net';
|
|
import {setTimeout as delay} from 'node:timers/promises';
|
|
import {fileURLToPath} from 'node:url';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const {VoiceEngine, getHardwareEncoderCapabilities, isSupported, loadError} = require('../index.js');
|
|
|
|
const DEFAULT_URL = 'ws://localhost:7880';
|
|
const DEFAULT_API_KEY = 'devkey';
|
|
const DEFAULT_API_SECRET = 'secret';
|
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
const DEFAULT_CONNECT_TIMEOUT_MS = 30_000;
|
|
const DEFAULT_DISCONNECT_TIMEOUT_MS = 10_000;
|
|
const DEFAULT_PROBE_TIMEOUT_MS = 1_500;
|
|
const DEFAULT_WIDTH = 320;
|
|
const DEFAULT_HEIGHT = 180;
|
|
const DEFAULT_FPS = 15;
|
|
const DEFAULT_SAMPLE_RATE = 48_000;
|
|
const DEFAULT_CHANNELS = 1;
|
|
const DEFAULT_AUDIO_CHUNK_MS = 20;
|
|
const STRICT_DURATION_MS = 10 * 60 * 1000;
|
|
const STRICT_MIN_FPS_RATIO = 0.95;
|
|
const STRICT_MAX_FRAME_GAP_MS = 250;
|
|
const STRICT_MAX_AV_DRIFT_MS = 80;
|
|
const STRICT_MAX_AUDIO_FRAME_GAP_MS = 250;
|
|
const AUDIO_POLL_INTERVAL_MS = 100;
|
|
const MIN_INBOUND_AUDIO_FRAMES = 3;
|
|
const MIN_INBOUND_VIDEO_FRAMES = 3;
|
|
const VIDEO_PATTERNS = new Set(['gradient', 'fast']);
|
|
const VIDEO_QUALITIES = new Set(['low', 'medium', 'high']);
|
|
const VIDEO_INPUTS = new Set(['bgra', 'nv12']);
|
|
const LIVEKIT_SCREEN_SHARE_SOURCE = 'screen_share';
|
|
const LIVEKIT_SCREEN_SHARE_AUDIO_SOURCE = 'screen_share_audio';
|
|
|
|
function parseBooleanFlag(name, value) {
|
|
const normalized = value.trim();
|
|
if (/^(1|true|yes|on|required)$/i.test(normalized)) return true;
|
|
if (/^(0|false|no|off|disabled)$/i.test(normalized)) return false;
|
|
throw new Error(`${name} must be a boolean flag: 1/0, true/false, yes/no, or on/off`);
|
|
}
|
|
|
|
function envFlag(...names) {
|
|
for (const name of names) {
|
|
const value = process.env[name];
|
|
if (value !== undefined && value.trim() !== '') return parseBooleanFlag(name, value);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function envOptionalFlag(name, fallback) {
|
|
const value = process.env[name];
|
|
if (value === undefined || value.trim() === '') return fallback;
|
|
return parseBooleanFlag(name, value);
|
|
}
|
|
|
|
function envString(names, fallback) {
|
|
for (const name of names) {
|
|
const value = process.env[name];
|
|
if (value?.trim()) return value.trim();
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function envOptionalString(names) {
|
|
for (const name of names) {
|
|
const value = process.env[name];
|
|
if (value?.trim()) return value.trim();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function envOptionalStringConsistent(names) {
|
|
let selected = null;
|
|
let selectedName = null;
|
|
for (const name of names) {
|
|
const value = process.env[name];
|
|
if (!value?.trim()) continue;
|
|
const trimmed = value.trim();
|
|
if (selected !== null && trimmed !== selected) {
|
|
throw new Error(`${name} conflicts with ${selectedName}; use one value for ${names.join(', ')}`);
|
|
}
|
|
selected = trimmed;
|
|
selectedName = name;
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
function envInteger(name, fallback) {
|
|
const value = process.env[name];
|
|
if (value === undefined || value === '') return fallback;
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
throw new Error(`${name} must be a non-negative integer`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function envPositiveInteger(name, fallback) {
|
|
const parsed = envInteger(name, fallback);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
throw new Error(`${name} must be a positive integer`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function envNumber(name, fallback) {
|
|
const value = process.env[name];
|
|
if (value === undefined || value === '') return fallback;
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
throw new Error(`${name} must be a non-negative number`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function envPositiveNumber(name, fallback) {
|
|
const value = process.env[name];
|
|
if (value === undefined || value === '') return fallback;
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
throw new Error(`${name} must be a positive number`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function envUnitNumber(name, fallback) {
|
|
const parsed = envPositiveNumber(name, fallback);
|
|
if (parsed > 1) {
|
|
throw new Error(`${name} must be greater than 0 and less than or equal to 1`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function envOptionalNonNegativeInteger(name) {
|
|
const value = process.env[name];
|
|
if (value === undefined || value === '') return null;
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
throw new Error(`${name} must be a non-negative integer`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function envVideoPattern(name, fallback) {
|
|
const value = process.env[name]?.trim().toLowerCase() || fallback;
|
|
if (!VIDEO_PATTERNS.has(value)) {
|
|
throw new Error(`${name} must be one of: ${[...VIDEO_PATTERNS].join(', ')}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function envVideoInput(name, fallback) {
|
|
const value = process.env[name]?.trim().toLowerCase() || fallback;
|
|
if (!VIDEO_INPUTS.has(value)) {
|
|
throw new Error(`${name} must be one of: ${[...VIDEO_INPUTS].join(', ')}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function envOptionalVideoQuality(name) {
|
|
const value = process.env[name]?.trim().toLowerCase();
|
|
if (!value) return null;
|
|
if (!VIDEO_QUALITIES.has(value)) {
|
|
throw new Error(`${name} must be one of: ${[...VIDEO_QUALITIES].join(', ')}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function jwtSubject(token) {
|
|
const parts = token.split('.');
|
|
if (parts.length < 2) {
|
|
throw new Error('LiveKit token must be a JWT when identity is not supplied');
|
|
}
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
} catch (error) {
|
|
throw new Error(`failed to decode LiveKit token payload: ${error.message}`);
|
|
}
|
|
if (typeof payload.sub !== 'string' || payload.sub.length === 0) {
|
|
throw new Error('LiveKit token payload does not contain a subject identity');
|
|
}
|
|
return payload.sub;
|
|
}
|
|
|
|
function resolveIdentity({configuredIdentity, token, fallback}) {
|
|
if (configuredIdentity) return configuredIdentity;
|
|
if (token) return jwtSubject(token);
|
|
return fallback;
|
|
}
|
|
|
|
function parseCodecList(rawValue, fallback) {
|
|
const value = rawValue?.trim() ? rawValue : fallback;
|
|
const codecs = value
|
|
.split(',')
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
if (codecs.length === 0) {
|
|
throw new Error('at least one LiveKit screen codec must be configured');
|
|
}
|
|
for (const codec of codecs) {
|
|
expectedCodecMime(codec);
|
|
}
|
|
return codecs;
|
|
}
|
|
|
|
function parseConfig() {
|
|
const strict = envFlag('LIVEKIT_HARNESS_STRICT', 'FLUXER_NATIVE_MEDIA_STRICT');
|
|
const liveKitUrl = envString(['LIVEKIT_URL', 'LIVEKIT_WS_URL'], DEFAULT_URL);
|
|
const generatedPublisherIdentity = `fluxer-native-publisher-${process.pid}`;
|
|
const generatedSecondaryPublisherIdentity = `fluxer-native-publisher-2-${process.pid}`;
|
|
const generatedSubscriberIdentity = `fluxer-native-subscriber-${process.pid}`;
|
|
const publisherToken = envOptionalString(['LIVEKIT_PUBLISHER_TOKEN']);
|
|
const secondaryPublisherToken = envOptionalString(['LIVEKIT_SECONDARY_PUBLISHER_TOKEN']);
|
|
const subscriberToken = envOptionalString(['LIVEKIT_SUBSCRIBER_TOKEN']);
|
|
const externalTokens = publisherToken !== null || secondaryPublisherToken !== null || subscriberToken !== null;
|
|
const width = envPositiveInteger('LIVEKIT_SCREEN_WIDTH', DEFAULT_WIDTH);
|
|
const height = envPositiveInteger('LIVEKIT_SCREEN_HEIGHT', DEFAULT_HEIGHT);
|
|
if (width % 2 !== 0 || height % 2 !== 0) {
|
|
throw new Error('LIVEKIT_SCREEN_WIDTH and LIVEKIT_SCREEN_HEIGHT must be even');
|
|
}
|
|
const fps = envPositiveNumber('LIVEKIT_SCREEN_FPS', DEFAULT_FPS);
|
|
const minVideoFps = envPositiveNumber('LIVEKIT_MIN_VIDEO_FPS', 15);
|
|
if (minVideoFps > fps) {
|
|
throw new Error('LIVEKIT_MIN_VIDEO_FPS must be less than or equal to LIVEKIT_SCREEN_FPS');
|
|
}
|
|
const screenCodecs = parseCodecList(envOptionalString(['LIVEKIT_SCREEN_CODECS', 'LIVEKIT_SCREEN_CODEC']), 'vp8');
|
|
const configuredExpectedScreenCodecs = envOptionalString([
|
|
'LIVEKIT_EXPECT_SCREEN_CODECS',
|
|
'LIVEKIT_EXPECT_SCREEN_CODEC',
|
|
]);
|
|
const expectedScreenCodecs = parseCodecList(configuredExpectedScreenCodecs, screenCodecs.join(','));
|
|
if (configuredExpectedScreenCodecs !== null && expectedScreenCodecs.length !== screenCodecs.length) {
|
|
throw new Error('LIVEKIT_EXPECT_SCREEN_CODECS length must match LIVEKIT_SCREEN_CODECS length');
|
|
}
|
|
const secondaryPublisherRequested = envOptionalFlag('LIVEKIT_ENABLE_SECOND_PUBLISHER', strict);
|
|
const configuredSecondaryPublisherCodec = envOptionalStringConsistent([
|
|
'LIVEKIT_SECOND_PUBLISHER_CODEC',
|
|
'LIVEKIT_SECOND_PUBLISHER_SCREEN_CODEC',
|
|
'LIVEKIT_SECOND_PUBLISHER_SCREEN_CODECS',
|
|
'LIVEKIT_SECONDARY_PUBLISHER_SCREEN_CODEC',
|
|
'LIVEKIT_SECONDARY_PUBLISHER_SCREEN_CODECS',
|
|
]);
|
|
const configuredExpectedSecondaryPublisherCodec = envOptionalStringConsistent([
|
|
'LIVEKIT_EXPECT_SECOND_PUBLISHER_SCREEN_CODEC',
|
|
'LIVEKIT_EXPECT_SECOND_PUBLISHER_SCREEN_CODECS',
|
|
'LIVEKIT_EXPECT_SECONDARY_PUBLISHER_SCREEN_CODEC',
|
|
'LIVEKIT_EXPECT_SECONDARY_PUBLISHER_SCREEN_CODECS',
|
|
]);
|
|
const secondaryPublisherCodec =
|
|
configuredSecondaryPublisherCodec ?? (screenCodecs.length > 1 ? screenCodecs[1] : screenCodecs[0]);
|
|
const expectedSecondaryPublisherCodec = configuredExpectedSecondaryPublisherCodec ?? secondaryPublisherCodec;
|
|
const screenSimulcast = envOptionalFlag('LIVEKIT_SCREEN_SIMULCAST', true);
|
|
const microphone = envOptionalFlag('LIVEKIT_ENABLE_MICROPHONE', true);
|
|
const screenAudio = envOptionalFlag('LIVEKIT_ENABLE_SCREEN_AUDIO', strict);
|
|
if (externalTokens) {
|
|
if (!publisherToken || !subscriberToken) {
|
|
throw new Error(
|
|
'LIVEKIT_PUBLISHER_TOKEN and LIVEKIT_SUBSCRIBER_TOKEN are both required when using external tokens',
|
|
);
|
|
}
|
|
if (secondaryPublisherRequested && !secondaryPublisherToken) {
|
|
throw new Error('LIVEKIT_SECONDARY_PUBLISHER_TOKEN is required when LIVEKIT_ENABLE_SECOND_PUBLISHER=1');
|
|
}
|
|
}
|
|
return {
|
|
url: liveKitUrl,
|
|
serverApiUrl: envString(['LIVEKIT_API_URL', 'LIVEKIT_HTTP_URL'], liveKitApiUrl(liveKitUrl)),
|
|
apiKey: envString(['LIVEKIT_API_KEY'], DEFAULT_API_KEY),
|
|
apiSecret: envString(['LIVEKIT_API_SECRET', 'LIVEKIT_SECRET'], DEFAULT_API_SECRET),
|
|
room: envString(['LIVEKIT_ROOM'], `fluxer-webrtc-harness-${process.pid}-${randomUUID()}`),
|
|
externalTokens,
|
|
publisherToken,
|
|
secondaryPublisherToken,
|
|
subscriberToken,
|
|
publisherIdentity: resolveIdentity({
|
|
configuredIdentity: envOptionalString(['LIVEKIT_PUBLISHER_IDENTITY']),
|
|
token: publisherToken,
|
|
fallback: generatedPublisherIdentity,
|
|
}),
|
|
secondaryPublisherIdentity: resolveIdentity({
|
|
configuredIdentity: envOptionalString(['LIVEKIT_SECONDARY_PUBLISHER_IDENTITY']),
|
|
token: secondaryPublisherToken,
|
|
fallback: generatedSecondaryPublisherIdentity,
|
|
}),
|
|
subscriberIdentity: resolveIdentity({
|
|
configuredIdentity: envOptionalString(['LIVEKIT_SUBSCRIBER_IDENTITY']),
|
|
token: subscriberToken,
|
|
fallback: generatedSubscriberIdentity,
|
|
}),
|
|
required: envFlag('FLUXER_WEBRTC_SENDER_LIVEKIT_REQUIRED', 'LIVEKIT_REQUIRED'),
|
|
strict,
|
|
timeoutMs: envPositiveInteger('LIVEKIT_HARNESS_TIMEOUT_MS', DEFAULT_TIMEOUT_MS),
|
|
connectTimeoutMs: envPositiveInteger('LIVEKIT_CONNECT_TIMEOUT_MS', DEFAULT_CONNECT_TIMEOUT_MS),
|
|
disconnectTimeoutMs: envPositiveInteger('LIVEKIT_DISCONNECT_TIMEOUT_MS', DEFAULT_DISCONNECT_TIMEOUT_MS),
|
|
probeTimeoutMs: envPositiveInteger('LIVEKIT_PROBE_TIMEOUT_MS', DEFAULT_PROBE_TIMEOUT_MS),
|
|
durationMs: envInteger('LIVEKIT_HARNESS_DURATION_MS', strict ? STRICT_DURATION_MS : 0),
|
|
width,
|
|
height,
|
|
expectedWidth: envPositiveInteger('LIVEKIT_EXPECT_SCREEN_WIDTH', width),
|
|
expectedHeight: envPositiveInteger('LIVEKIT_EXPECT_SCREEN_HEIGHT', height),
|
|
fps,
|
|
minFpsRatio: envNumber('LIVEKIT_MIN_RECEIVED_FPS_RATIO', strict ? STRICT_MIN_FPS_RATIO : 0),
|
|
maxFrameGapMs: envNumber('LIVEKIT_MAX_FRAME_GAP_MS', strict ? STRICT_MAX_FRAME_GAP_MS : 0),
|
|
maxAudioFrameGapMs: envNumber('LIVEKIT_MAX_AUDIO_FRAME_GAP_MS', strict ? STRICT_MAX_AUDIO_FRAME_GAP_MS : 0),
|
|
maxAvDriftMs: envNumber('LIVEKIT_MAX_AV_DRIFT_MS', strict ? STRICT_MAX_AV_DRIFT_MS : 0),
|
|
maxPacketLoss: envOptionalNonNegativeInteger('LIVEKIT_MAX_PACKET_LOSS'),
|
|
requireStableResolution: envOptionalFlag('LIVEKIT_REQUIRE_STABLE_RESOLUTION', strict),
|
|
videoPattern: envVideoPattern('LIVEKIT_VIDEO_PATTERN', 'gradient'),
|
|
videoInput: envVideoInput('LIVEKIT_VIDEO_INPUT', 'bgra'),
|
|
subscriberVideoQuality: envOptionalVideoQuality('LIVEKIT_SUBSCRIBER_VIDEO_QUALITY'),
|
|
screenCodecs,
|
|
expectedScreenCodecs,
|
|
codec: screenCodecs[0],
|
|
expectedScreenCodec: expectedCodecMime(expectedScreenCodecs[0] ?? screenCodecs[0]),
|
|
secondaryPublisher: secondaryPublisherRequested,
|
|
secondaryPublisherCodec,
|
|
secondaryPublisherCodecExplicit: configuredSecondaryPublisherCodec !== null,
|
|
expectedSecondaryPublisherCodec: expectedCodecMime(expectedSecondaryPublisherCodec),
|
|
expectedSecondaryPublisherCodecExplicit: configuredExpectedSecondaryPublisherCodec !== null,
|
|
screenSimulcast,
|
|
secondaryPublisherScreenSimulcast: envOptionalFlag('LIVEKIT_SECOND_PUBLISHER_SCREEN_SIMULCAST', screenSimulcast),
|
|
adaptiveSend: envOptionalFlag('LIVEKIT_ADAPTIVE_SEND', true),
|
|
minVideoFps,
|
|
minResolutionScale: envUnitNumber('LIVEKIT_MIN_RESOLUTION_SCALE', 0.5),
|
|
maxAudioBufferMs: envPositiveInteger('LIVEKIT_MAX_AUDIO_BUFFER_MS', 750),
|
|
secondaryPublisherMicrophone: envOptionalFlag('LIVEKIT_SECOND_PUBLISHER_ENABLE_MICROPHONE', microphone),
|
|
secondaryPublisherScreenAudio: envOptionalFlag('LIVEKIT_SECOND_PUBLISHER_ENABLE_SCREEN_AUDIO', screenAudio),
|
|
maxBitrateBps: envNumber('LIVEKIT_SCREEN_MAX_BITRATE_BPS', 400_000),
|
|
sampleRate: envPositiveInteger('LIVEKIT_AUDIO_SAMPLE_RATE', DEFAULT_SAMPLE_RATE),
|
|
channels: envPositiveInteger('LIVEKIT_AUDIO_CHANNELS', DEFAULT_CHANNELS),
|
|
audioChunkMs: envPositiveInteger('LIVEKIT_AUDIO_CHUNK_MS', DEFAULT_AUDIO_CHUNK_MS),
|
|
microphone,
|
|
screenAudio,
|
|
dataPacket: envOptionalFlag('LIVEKIT_ENABLE_DATA_PACKET', strict),
|
|
subscriptionCycle: envOptionalFlag('LIVEKIT_ENABLE_SUBSCRIPTION_CYCLE', strict),
|
|
validateServerPublishing: envOptionalFlag('LIVEKIT_VALIDATE_SERVER_PUBLISHING', strict),
|
|
camera: envOptionalFlag('LIVEKIT_ENABLE_CAMERA', false),
|
|
cameraDeviceId: envOptionalString(['LIVEKIT_CAMERA_DEVICE_ID']),
|
|
expectedCameraCodec: expectedCodecMime(envString(['LIVEKIT_EXPECT_CAMERA_CODEC'], '')),
|
|
e2eeKey: envOptionalString(['LIVEKIT_E2EE_KEY']),
|
|
expectedHardwareEncoder: envOptionalString(['LIVEKIT_EXPECT_HARDWARE_ENCODER']),
|
|
reportPath: envOptionalString(['LIVEKIT_HARNESS_REPORT_PATH', 'FLUXER_NATIVE_MEDIA_REPORT']),
|
|
verbose: envFlag('FLUXER_WEBRTC_SENDER_LIVEKIT_VERBOSE', 'LIVEKIT_VERBOSE'),
|
|
};
|
|
}
|
|
|
|
function expectedCodecMime(codec) {
|
|
switch (codec.trim().toLowerCase()) {
|
|
case '':
|
|
return null;
|
|
case 'vp8':
|
|
return 'video/VP8';
|
|
case 'vp9':
|
|
return 'video/VP9';
|
|
case 'h264':
|
|
return 'video/H264';
|
|
case 'av1':
|
|
return 'video/AV1';
|
|
case 'h265':
|
|
case 'hevc':
|
|
return 'video/H265';
|
|
default:
|
|
throw new Error(`unsupported LIVEKIT_EXPECT_SCREEN_CODEC: ${codec}`);
|
|
}
|
|
}
|
|
|
|
function liveKitTcpTarget(rawUrl) {
|
|
let url;
|
|
try {
|
|
url = new URL(rawUrl);
|
|
} catch (error) {
|
|
throw new Error(`LIVEKIT_URL is not a valid URL: ${error.message}`);
|
|
}
|
|
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
|
|
throw new Error('LIVEKIT_URL must use ws:// or wss://');
|
|
}
|
|
return {
|
|
host: url.hostname.replace(/^\[(.*)\]$/, '$1'),
|
|
port: Number(url.port || (url.protocol === 'wss:' ? 443 : 80)),
|
|
};
|
|
}
|
|
|
|
function liveKitApiUrl(rawUrl) {
|
|
let url;
|
|
try {
|
|
url = new URL(rawUrl);
|
|
} catch (error) {
|
|
throw new Error(`LIVEKIT_URL is not a valid URL: ${error.message}`);
|
|
}
|
|
if (url.protocol === 'ws:') return `http://${url.host}`;
|
|
if (url.protocol === 'wss:') return `https://${url.host}`;
|
|
throw new Error('LIVEKIT_URL must use ws:// or wss://');
|
|
}
|
|
|
|
function probeTcp({host, port}, timeoutMs) {
|
|
return new Promise((resolve) => {
|
|
const socket = net.createConnection({host, port});
|
|
let done = false;
|
|
const finish = (result) => {
|
|
if (done) return;
|
|
done = true;
|
|
socket.destroy();
|
|
resolve(result);
|
|
};
|
|
socket.setTimeout(timeoutMs);
|
|
socket.once('connect', () => finish({ok: true}));
|
|
socket.once('timeout', () => finish({ok: false, reason: `timed out after ${timeoutMs}ms`}));
|
|
socket.once('error', (error) => finish({ok: false, reason: error.message}));
|
|
});
|
|
}
|
|
|
|
function base64urlJson(value) {
|
|
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
}
|
|
|
|
function createLiveKitToken({apiKey, apiSecret, room, identity, name}) {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const header = {alg: 'HS256', typ: 'JWT'};
|
|
const payload = {
|
|
iss: apiKey,
|
|
sub: identity,
|
|
name,
|
|
nbf: now - 5,
|
|
exp: now + 600,
|
|
jti: randomUUID(),
|
|
video: {
|
|
roomJoin: true,
|
|
room,
|
|
canPublish: true,
|
|
canSubscribe: true,
|
|
canPublishData: true,
|
|
canPublishSources: ['camera', 'microphone', 'screen_share', 'screen_share_audio'],
|
|
},
|
|
};
|
|
const body = `${base64urlJson(header)}.${base64urlJson(payload)}`;
|
|
const signature = createHmac('sha256', apiSecret).update(body).digest('base64url');
|
|
return `${body}.${signature}`;
|
|
}
|
|
|
|
function createLiveKitAdminToken({apiKey, apiSecret, room}) {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const header = {alg: 'HS256', typ: 'JWT'};
|
|
const payload = {
|
|
iss: apiKey,
|
|
sub: `fluxer-native-harness-admin-${process.pid}`,
|
|
nbf: now - 5,
|
|
exp: now + 600,
|
|
jti: randomUUID(),
|
|
video: {
|
|
room,
|
|
roomAdmin: true,
|
|
},
|
|
};
|
|
const body = `${base64urlJson(header)}.${base64urlJson(payload)}`;
|
|
const signature = createHmac('sha256', apiSecret).update(body).digest('base64url');
|
|
return `${body}.${signature}`;
|
|
}
|
|
|
|
function parsePayload(jsonPayload) {
|
|
try {
|
|
return JSON.parse(jsonPayload);
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function unpackNapiPair(args) {
|
|
if (args.length === 1 && Array.isArray(args[0])) {
|
|
return [args[0][0], args[0][1]];
|
|
}
|
|
return [args[0], args[1]];
|
|
}
|
|
|
|
function createEngineState(name, verbose) {
|
|
return {
|
|
name,
|
|
events: [],
|
|
stats: null,
|
|
statsSamples: [],
|
|
trackIndex: new Map(),
|
|
videoCallbacks: 0,
|
|
videoBytes: 0,
|
|
videoFrameTimes: [],
|
|
videoFrameRecords: [],
|
|
maxVideoFrameGapMs: 0,
|
|
lastVideoMeta: null,
|
|
videoResolutionCounts: new Map(),
|
|
lastVideoFrameAtMs: null,
|
|
audioFrameCounts: [],
|
|
audioFrameTimes: [],
|
|
lastAudioFrameAtMs: null,
|
|
maxAudioFrameGapMs: 0,
|
|
recordEvent(eventType, jsonPayload) {
|
|
const payload = parsePayload(jsonPayload);
|
|
this.events.push({eventType, payload, atMs: Date.now()});
|
|
if (typeof payload.trackSid === 'string' && payload.trackSid.length > 0) {
|
|
if (eventType === 'trackSubscribed') {
|
|
this.trackIndex.set(payload.trackSid, {
|
|
identity: payload.identity,
|
|
kind: normalizeTrackKind(payload.kind),
|
|
source: normalizeTrackSource(payload.source),
|
|
trackName: payload.trackName,
|
|
});
|
|
}
|
|
if (eventType === 'trackUnsubscribed') {
|
|
this.trackIndex.delete(payload.trackSid);
|
|
}
|
|
}
|
|
if (eventType === 'stats') {
|
|
this.stats = payload;
|
|
this.statsSamples.push({payload, atMs: Date.now()});
|
|
}
|
|
if (verbose) {
|
|
const suffix = eventType === 'stats' ? '' : ` ${JSON.stringify(payload)}`;
|
|
console.log(`[livekit-harness] ${name} event: ${eventType}${suffix}`);
|
|
}
|
|
},
|
|
recordVideoFrame(metaJson, data) {
|
|
const now = Date.now();
|
|
if (this.lastVideoFrameAtMs !== null) {
|
|
this.maxVideoFrameGapMs = Math.max(this.maxVideoFrameGapMs, now - this.lastVideoFrameAtMs);
|
|
}
|
|
this.lastVideoFrameAtMs = now;
|
|
this.videoFrameTimes.push(now);
|
|
this.videoCallbacks += 1;
|
|
this.videoBytes += data?.byteLength ?? data?.length ?? 0;
|
|
const parsedMeta = parsePayload(metaJson);
|
|
const indexedTrack =
|
|
typeof parsedMeta.trackSid === 'string' ? this.trackIndex.get(parsedMeta.trackSid) : undefined;
|
|
this.lastVideoMeta = {
|
|
...parsedMeta,
|
|
identity: parsedMeta.identity ?? indexedTrack?.identity,
|
|
kind: parsedMeta.kind !== undefined ? normalizeTrackKind(parsedMeta.kind) : indexedTrack?.kind,
|
|
source: parsedMeta.source !== undefined ? normalizeTrackSource(parsedMeta.source) : indexedTrack?.source,
|
|
trackName: parsedMeta.trackName ?? indexedTrack?.trackName,
|
|
};
|
|
if (Number.isFinite(this.lastVideoMeta.width) && Number.isFinite(this.lastVideoMeta.height)) {
|
|
const key = `${this.lastVideoMeta.width}x${this.lastVideoMeta.height}`;
|
|
this.videoResolutionCounts.set(key, (this.videoResolutionCounts.get(key) ?? 0) + 1);
|
|
this.videoFrameRecords.push({
|
|
atMs: now,
|
|
identity: this.lastVideoMeta.identity,
|
|
kind: this.lastVideoMeta.kind,
|
|
source: this.lastVideoMeta.source,
|
|
trackName: this.lastVideoMeta.trackName,
|
|
trackSid: this.lastVideoMeta.trackSid,
|
|
width: this.lastVideoMeta.width,
|
|
height: this.lastVideoMeta.height,
|
|
});
|
|
}
|
|
if (verbose && this.videoCallbacks === 1) {
|
|
console.log(`[livekit-harness] ${name} first video frame: ${metaJson}`);
|
|
}
|
|
},
|
|
recordAudioFrameCount(count) {
|
|
const now = Date.now();
|
|
const last = this.audioFrameCounts[this.audioFrameCounts.length - 1];
|
|
if (last && count > last.count) {
|
|
if (this.lastAudioFrameAtMs !== null) {
|
|
this.maxAudioFrameGapMs = Math.max(this.maxAudioFrameGapMs, now - this.lastAudioFrameAtMs);
|
|
}
|
|
this.lastAudioFrameAtMs = now;
|
|
this.audioFrameTimes.push(now);
|
|
}
|
|
this.audioFrameCounts.push({count, atMs: now});
|
|
},
|
|
};
|
|
}
|
|
|
|
function hasEvent(state, eventType, predicate = () => true) {
|
|
return state.events.some((event) => event.eventType === eventType && predicate(event.payload));
|
|
}
|
|
|
|
function statsHasOutbound(stats, kind, source) {
|
|
return (
|
|
Array.isArray(stats?.outbound) &&
|
|
stats.outbound.some((entry) => entry.kind === kind && normalizeTrackSource(entry.source) === source)
|
|
);
|
|
}
|
|
|
|
function statsHasInbound(stats, kind) {
|
|
return Array.isArray(stats?.inbound) && stats.inbound.some((entry) => entry.kind === kind);
|
|
}
|
|
|
|
function statsInboundCount(stats, kind) {
|
|
return Array.isArray(stats?.inbound) ? stats.inbound.filter((entry) => entry.kind === kind).length : 0;
|
|
}
|
|
|
|
function totalPacketsLost(stats) {
|
|
const outboundLoss = Array.isArray(stats?.outbound)
|
|
? stats.outbound.reduce((sum, entry) => sum + Math.max(0, Number(entry.packetsLost) || 0), 0)
|
|
: 0;
|
|
const inboundLoss = Array.isArray(stats?.inbound)
|
|
? stats.inbound.reduce((sum, entry) => sum + Math.max(0, Number(entry.packetsLost) || 0), 0)
|
|
: 0;
|
|
return outboundLoss + inboundLoss;
|
|
}
|
|
|
|
function maxObservedPacketsLost(...states) {
|
|
let max = 0;
|
|
for (const state of states) {
|
|
for (const sample of state?.statsSamples ?? []) {
|
|
max = Math.max(max, totalPacketsLost(sample.payload));
|
|
}
|
|
max = Math.max(max, totalPacketsLost(state?.stats));
|
|
}
|
|
return max;
|
|
}
|
|
|
|
function maxObservedPacketsLostDelta(startedAtMs, endedAtMs, ...states) {
|
|
let maxDelta = 0;
|
|
for (const state of states) {
|
|
const samples = [...(state?.statsSamples ?? [])];
|
|
if (state?.stats) samples.push({payload: state.stats, atMs: endedAtMs});
|
|
samples.sort((a, b) => a.atMs - b.atMs);
|
|
let baseline = 0;
|
|
let maxDuring = 0;
|
|
let hasWindowSample = false;
|
|
for (const sample of samples) {
|
|
const total = totalPacketsLost(sample.payload);
|
|
if (sample.atMs <= startedAtMs) {
|
|
baseline = total;
|
|
continue;
|
|
}
|
|
if (sample.atMs <= endedAtMs) {
|
|
hasWindowSample = true;
|
|
maxDuring = Math.max(maxDuring, total);
|
|
}
|
|
}
|
|
if (hasWindowSample) maxDelta = Math.max(maxDelta, Math.max(0, maxDuring - baseline));
|
|
}
|
|
return maxDelta;
|
|
}
|
|
|
|
function statsHasOutboundCodec(stats, kind, source, expectedCodec) {
|
|
return (
|
|
Array.isArray(stats?.outbound) &&
|
|
stats.outbound.some(
|
|
(entry) => entry.kind === kind && normalizeTrackSource(entry.source) === source && entry.codec === expectedCodec,
|
|
)
|
|
);
|
|
}
|
|
|
|
function hasTrackEvent(state, eventType, {identity, kind, source}) {
|
|
if (!state) return false;
|
|
return hasEvent(
|
|
state,
|
|
eventType,
|
|
(payload) =>
|
|
(identity === undefined || payload.identity === identity) &&
|
|
(kind === undefined || payload.kind === kind) &&
|
|
(source === undefined || normalizeTrackSource(payload.source) === source),
|
|
);
|
|
}
|
|
|
|
function statsHasInboundCodec(stats, kind, expectedCodec) {
|
|
return (
|
|
Array.isArray(stats?.inbound) && stats.inbound.some((entry) => entry.kind === kind && entry.codec === expectedCodec)
|
|
);
|
|
}
|
|
|
|
function startVideoPump(_engine, config, _pumpErrors) {
|
|
const stats = {
|
|
input: config.videoInput,
|
|
pattern: config.videoPattern,
|
|
targetFps: config.fps,
|
|
startedAtMs: Date.now(),
|
|
stoppedAtMs: null,
|
|
framesPushed: 0,
|
|
framesRejected: 0,
|
|
errors: 0,
|
|
maxPushGapMs: 0,
|
|
lastPushAtMs: null,
|
|
};
|
|
let stopped = false;
|
|
return {
|
|
stats() {
|
|
const endedAtMs = stats.stoppedAtMs ?? Date.now();
|
|
const elapsedMs = Math.max(1, endedAtMs - stats.startedAtMs);
|
|
return {
|
|
...stats,
|
|
elapsedMs,
|
|
producedFps: Math.round((stats.framesPushed / (elapsedMs / 1000)) * 100) / 100,
|
|
};
|
|
},
|
|
stop() {
|
|
if (stopped) return;
|
|
stopped = true;
|
|
stats.stoppedAtMs = Date.now();
|
|
},
|
|
};
|
|
}
|
|
|
|
function syntheticPcmFrame({sampleRate, channels, chunkMs, cursor}) {
|
|
const samplesPerChannel = Math.round((sampleRate * chunkMs) / 1000);
|
|
const buffer = Buffer.alloc(samplesPerChannel * channels * 2);
|
|
const frequencyHz = 440;
|
|
for (let i = 0; i < samplesPerChannel; i += 1) {
|
|
const sample = Math.round(Math.sin(((cursor + i) / sampleRate) * frequencyHz * Math.PI * 2) * 10_000);
|
|
for (let channel = 0; channel < channels; channel += 1) {
|
|
buffer.writeInt16LE(sample, (i * channels + channel) * 2);
|
|
}
|
|
}
|
|
return {buffer, nextCursor: cursor + samplesPerChannel};
|
|
}
|
|
|
|
function startAudioPump(label, pushFrame, config, pumpErrors) {
|
|
let stopped = false;
|
|
let cursor = 0;
|
|
const promise = (async () => {
|
|
while (!stopped) {
|
|
const frame = syntheticPcmFrame({
|
|
sampleRate: config.sampleRate,
|
|
channels: config.channels,
|
|
chunkMs: config.audioChunkMs,
|
|
cursor,
|
|
});
|
|
cursor = frame.nextCursor;
|
|
try {
|
|
const ok = await pushFrame(frame.buffer, config.sampleRate, config.channels);
|
|
if (!ok) pumpErrors.push(new Error(`${label} returned false`));
|
|
} catch (error) {
|
|
pumpErrors.push(error);
|
|
}
|
|
await delay(config.audioChunkMs);
|
|
}
|
|
})();
|
|
return {
|
|
stop() {
|
|
stopped = true;
|
|
},
|
|
async done() {
|
|
await promise;
|
|
},
|
|
};
|
|
}
|
|
|
|
function startAudioSampler(engine, state) {
|
|
const timer = setInterval(() => {
|
|
try {
|
|
state.recordAudioFrameCount(engine.inboundAudioFrames());
|
|
} catch {}
|
|
}, AUDIO_POLL_INTERVAL_MS);
|
|
timer.unref?.();
|
|
return {
|
|
stop() {
|
|
clearInterval(timer);
|
|
},
|
|
};
|
|
}
|
|
|
|
function receivedFpsFromTimes(frames) {
|
|
if (frames.length < 2) return 0;
|
|
const elapsedSec = Math.max(0.001, (frames[frames.length - 1] - frames[0]) / 1000);
|
|
return (frames.length - 1) / elapsedSec;
|
|
}
|
|
|
|
function maxGapMs(times, startedAtMs, endedAtMs) {
|
|
const windowTimes = times.filter((time) => time >= startedAtMs && time <= endedAtMs);
|
|
let maxGap = 0;
|
|
for (let index = 1; index < windowTimes.length; index += 1) {
|
|
maxGap = Math.max(maxGap, windowTimes[index] - windowTimes[index - 1]);
|
|
}
|
|
return maxGap;
|
|
}
|
|
|
|
function countWindowSamples(times, startedAtMs, endedAtMs) {
|
|
return times.filter((time) => time >= startedAtMs && time <= endedAtMs).length;
|
|
}
|
|
|
|
function primaryScreenVideoSelector(config) {
|
|
return {
|
|
identity: config.publisherIdentity,
|
|
kind: 'video',
|
|
source: LIVEKIT_SCREEN_SHARE_SOURCE,
|
|
};
|
|
}
|
|
|
|
function frameRecordMatches(record, selector) {
|
|
if (!record) return false;
|
|
if (selector.kind !== undefined && record.kind !== undefined && record.kind !== selector.kind) return false;
|
|
if (selector.source !== undefined && record.source !== undefined && record.source !== selector.source) return false;
|
|
if (selector.identity !== undefined && record.identity !== undefined && record.identity !== selector.identity) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function selectedVideoFrameRecords(state, startedAtMs, endedAtMs, selector) {
|
|
const records = [];
|
|
for (const record of state?.videoFrameRecords ?? []) {
|
|
if (record.atMs < startedAtMs || record.atMs > endedAtMs) continue;
|
|
if (!frameRecordMatches(record, selector)) continue;
|
|
records.push(record);
|
|
}
|
|
return records;
|
|
}
|
|
|
|
function selectedVideoFrameTimes(state, startedAtMs, endedAtMs, selector) {
|
|
return selectedVideoFrameRecords(state, startedAtMs, endedAtMs, selector).map((record) => record.atMs);
|
|
}
|
|
|
|
function lastVideoFrameRecord(state, startedAtMs, endedAtMs, selector) {
|
|
return selectedVideoFrameRecords(state, startedAtMs, endedAtMs, selector).at(-1) ?? null;
|
|
}
|
|
|
|
function videoResolutionCounts(state, startedAtMs, endedAtMs, selector) {
|
|
const counts = new Map();
|
|
for (const record of selectedVideoFrameRecords(state, startedAtMs, endedAtMs, selector)) {
|
|
const key = `${record.width}x${record.height}`;
|
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
}
|
|
return Object.fromEntries(counts);
|
|
}
|
|
|
|
function videoResolutionMismatchCount(state, expectedWidth, expectedHeight, startedAtMs, endedAtMs, selector) {
|
|
let mismatches = 0;
|
|
for (const record of selectedVideoFrameRecords(state, startedAtMs, endedAtMs, selector)) {
|
|
if (record.width !== expectedWidth || record.height !== expectedHeight) {
|
|
mismatches += 1;
|
|
}
|
|
}
|
|
return mismatches;
|
|
}
|
|
|
|
function currentAvDriftMs(state, startedAtMs, endedAtMs, selector) {
|
|
const videoTimes = selectedVideoFrameTimes(state, startedAtMs, endedAtMs, selector);
|
|
const audioTimes = (state?.audioFrameTimes ?? []).filter((time) => time >= startedAtMs && time <= endedAtMs);
|
|
const lastVideoFrameAtMs = videoTimes.at(-1) ?? null;
|
|
const lastAudioFrameAtMs = audioTimes.at(-1) ?? null;
|
|
if (lastVideoFrameAtMs === null || lastAudioFrameAtMs === null) return null;
|
|
return Math.abs(lastVideoFrameAtMs - lastAudioFrameAtMs);
|
|
}
|
|
|
|
function expectedInboundVideoTrackCount(config) {
|
|
let count = 1;
|
|
if (config.camera) count += 1;
|
|
if (config.secondaryPublisher) count += 1;
|
|
return count;
|
|
}
|
|
|
|
function expectedInboundAudioTrackCount(config) {
|
|
let count = 0;
|
|
if (config.microphone) count += 1;
|
|
if (config.screenAudio) count += 1;
|
|
if (config.secondaryPublisher && config.secondaryPublisherMicrophone) count += 1;
|
|
if (config.secondaryPublisher && config.secondaryPublisherScreenAudio) count += 1;
|
|
return count;
|
|
}
|
|
|
|
function normalizeTrackKind(kind) {
|
|
switch (String(kind ?? '').toLowerCase()) {
|
|
case '0':
|
|
case 'audio':
|
|
return 'audio';
|
|
case '1':
|
|
case 'video':
|
|
return 'video';
|
|
case '2':
|
|
case 'data':
|
|
return 'data';
|
|
default:
|
|
return String(kind ?? '').toLowerCase();
|
|
}
|
|
}
|
|
|
|
function normalizeTrackSource(source) {
|
|
switch (String(source ?? '').toLowerCase()) {
|
|
case '1':
|
|
case 'camera':
|
|
case 'camera_source':
|
|
return 'camera';
|
|
case '2':
|
|
case 'microphone':
|
|
case 'mic':
|
|
return 'microphone';
|
|
case '3':
|
|
case 'screen_share':
|
|
case 'screenshare':
|
|
case 'screen-share':
|
|
return LIVEKIT_SCREEN_SHARE_SOURCE;
|
|
case '4':
|
|
case 'screen_share_audio':
|
|
case 'screenshareaudio':
|
|
case 'screen-share-audio':
|
|
return LIVEKIT_SCREEN_SHARE_AUDIO_SOURCE;
|
|
default:
|
|
return String(source ?? '');
|
|
}
|
|
}
|
|
|
|
function findServerParticipant(serverState, identity) {
|
|
return serverState?.participants?.find((participant) => participant.identity === identity) ?? null;
|
|
}
|
|
|
|
function normalizeMimeType(mimeType) {
|
|
if (typeof mimeType !== 'string' || mimeType.trim() === '') return null;
|
|
return mimeType.trim().toLowerCase();
|
|
}
|
|
|
|
function serverTrackMatches(track, {kind, source, mimeType}) {
|
|
if (normalizeTrackKind(track.type ?? track.kind) !== kind) return false;
|
|
if (normalizeTrackSource(track.source) !== source) return false;
|
|
if (track.muted !== false) return false;
|
|
const expectedMimeType = normalizeMimeType(mimeType);
|
|
if (expectedMimeType !== null && normalizeMimeType(track.mimeType ?? track.mime_type) !== expectedMimeType) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function serverParticipantHasTrack(serverState, identity, expectedTrack) {
|
|
const participant = findServerParticipant(serverState, identity);
|
|
if (!participant) return false;
|
|
const tracks = participant.tracks ?? [];
|
|
return tracks.some((track) => serverTrackMatches(track, expectedTrack));
|
|
}
|
|
|
|
function expectedServerTracks(config, publisherIdentity, secondaryPublisherIdentity) {
|
|
const tracks = [
|
|
{
|
|
identity: publisherIdentity,
|
|
kind: 'video',
|
|
source: LIVEKIT_SCREEN_SHARE_SOURCE,
|
|
mimeType: config.expectedScreenCodec,
|
|
label: 'publisher screenshare',
|
|
},
|
|
];
|
|
if (config.microphone) {
|
|
tracks.push({identity: publisherIdentity, kind: 'audio', source: 'microphone', label: 'publisher microphone'});
|
|
}
|
|
if (config.screenAudio) {
|
|
tracks.push({
|
|
identity: publisherIdentity,
|
|
kind: 'audio',
|
|
source: LIVEKIT_SCREEN_SHARE_AUDIO_SOURCE,
|
|
label: 'publisher screen-share audio',
|
|
});
|
|
}
|
|
if (config.camera) {
|
|
tracks.push({
|
|
identity: publisherIdentity,
|
|
kind: 'video',
|
|
source: 'camera',
|
|
mimeType: config.expectedCameraCodec,
|
|
label: 'publisher camera',
|
|
});
|
|
}
|
|
if (config.secondaryPublisher) {
|
|
tracks.push({
|
|
identity: secondaryPublisherIdentity,
|
|
kind: 'video',
|
|
source: LIVEKIT_SCREEN_SHARE_SOURCE,
|
|
mimeType: config.expectedSecondaryPublisherCodec,
|
|
label: 'secondary publisher screenshare',
|
|
});
|
|
if (config.secondaryPublisherMicrophone) {
|
|
tracks.push({
|
|
identity: secondaryPublisherIdentity,
|
|
kind: 'audio',
|
|
source: 'microphone',
|
|
label: 'secondary publisher microphone',
|
|
});
|
|
}
|
|
if (config.secondaryPublisherScreenAudio) {
|
|
tracks.push({
|
|
identity: secondaryPublisherIdentity,
|
|
kind: 'audio',
|
|
source: LIVEKIT_SCREEN_SHARE_AUDIO_SOURCE,
|
|
label: 'secondary publisher screen-share audio',
|
|
});
|
|
}
|
|
}
|
|
return tracks;
|
|
}
|
|
|
|
function serverPublishedTrackChecks({config, serverState, publisherIdentity, secondaryPublisherIdentity}) {
|
|
if (!config.validateServerPublishing) return [];
|
|
const checks = [
|
|
{
|
|
name: 'server publishing API participants listed',
|
|
pass: Array.isArray(serverState?.participants) && !serverState?.error,
|
|
},
|
|
];
|
|
for (const track of expectedServerTracks(config, publisherIdentity, secondaryPublisherIdentity)) {
|
|
checks.push({
|
|
name: `server sees ${track.label} publication`,
|
|
pass: serverParticipantHasTrack(serverState, track.identity, {
|
|
kind: track.kind,
|
|
source: track.source,
|
|
mimeType: track.mimeType,
|
|
}),
|
|
});
|
|
}
|
|
return checks;
|
|
}
|
|
|
|
function currentChecks({
|
|
publisher,
|
|
subscriber,
|
|
publisherState,
|
|
subscriberState,
|
|
config,
|
|
publisherIdentity,
|
|
secondaryPublisher,
|
|
secondaryPublisherState,
|
|
secondaryPublisherIdentity,
|
|
serverState,
|
|
}) {
|
|
const expectedVideoTracks = expectedInboundVideoTrackCount(config);
|
|
const expectedAudioTracks = expectedInboundAudioTrackCount(config);
|
|
const screenVideoMeta = lastVideoFrameRecord(
|
|
subscriberState,
|
|
Number.NEGATIVE_INFINITY,
|
|
Number.POSITIVE_INFINITY,
|
|
primaryScreenVideoSelector(config),
|
|
);
|
|
const checks = [
|
|
{name: 'publisher connected', pass: publisher.isConnected()},
|
|
{name: 'subscriber connected', pass: subscriber.isConnected()},
|
|
{
|
|
name: 'publisher local screenshare publication',
|
|
pass: hasTrackEvent(publisherState, 'localTrackPublished', {
|
|
kind: 'video',
|
|
source: LIVEKIT_SCREEN_SHARE_SOURCE,
|
|
}),
|
|
},
|
|
{
|
|
name: 'subscriber remote screenshare subscription',
|
|
pass: hasTrackEvent(subscriberState, 'trackSubscribed', {
|
|
identity: publisherIdentity,
|
|
kind: 'video',
|
|
source: LIVEKIT_SCREEN_SHARE_SOURCE,
|
|
}),
|
|
},
|
|
{
|
|
name: `subscriber inbound video frames >= ${MIN_INBOUND_VIDEO_FRAMES * expectedVideoTracks}`,
|
|
pass: subscriber.inboundVideoFrames() >= MIN_INBOUND_VIDEO_FRAMES * expectedVideoTracks,
|
|
},
|
|
{
|
|
name: 'subscriber video frame callback',
|
|
pass: subscriberState.videoCallbacks > 0 && subscriberState.videoBytes > 0,
|
|
},
|
|
{
|
|
name: 'publisher outbound screenshare stats',
|
|
pass: statsHasOutbound(publisherState.stats, 'video', LIVEKIT_SCREEN_SHARE_SOURCE),
|
|
},
|
|
{
|
|
name: 'subscriber inbound video stats',
|
|
pass: statsHasInbound(subscriberState.stats, 'video'),
|
|
},
|
|
{
|
|
name: `subscriber inbound video stats count >= ${expectedVideoTracks}`,
|
|
pass: statsInboundCount(subscriberState.stats, 'video') >= expectedVideoTracks,
|
|
},
|
|
{
|
|
name: `subscriber video resolution ${config.expectedWidth}x${config.expectedHeight}`,
|
|
pass: screenVideoMeta?.width === config.expectedWidth && screenVideoMeta?.height === config.expectedHeight,
|
|
},
|
|
];
|
|
if (expectedAudioTracks > 0) {
|
|
checks.push(
|
|
{
|
|
name: `subscriber aggregate inbound audio frames >= ${MIN_INBOUND_AUDIO_FRAMES * expectedAudioTracks}`,
|
|
pass: subscriber.inboundAudioFrames() >= MIN_INBOUND_AUDIO_FRAMES * expectedAudioTracks,
|
|
},
|
|
{
|
|
name: `subscriber inbound audio stats count >= ${expectedAudioTracks}`,
|
|
pass: statsInboundCount(subscriberState.stats, 'audio') >= expectedAudioTracks,
|
|
},
|
|
);
|
|
}
|
|
if (config.microphone) {
|
|
checks.push(
|
|
{
|
|
name: 'publisher local microphone publication',
|
|
pass: hasTrackEvent(publisherState, 'localTrackPublished', {kind: 'audio', source: 'microphone'}),
|
|
},
|
|
{
|
|
name: 'subscriber remote microphone subscription',
|
|
pass: hasTrackEvent(subscriberState, 'trackSubscribed', {
|
|
identity: publisherIdentity,
|
|
kind: 'audio',
|
|
source: 'microphone',
|
|
}),
|
|
},
|
|
{
|
|
name: `subscriber inbound audio frames >= ${MIN_INBOUND_AUDIO_FRAMES * Math.max(1, expectedAudioTracks)}`,
|
|
pass: subscriber.inboundAudioFrames() >= MIN_INBOUND_AUDIO_FRAMES * Math.max(1, expectedAudioTracks),
|
|
},
|
|
{
|
|
name: 'publisher outbound microphone stats',
|
|
pass: statsHasOutbound(publisherState.stats, 'audio', 'microphone'),
|
|
},
|
|
{
|
|
name: 'subscriber inbound audio stats',
|
|
pass: statsHasInbound(subscriberState.stats, 'audio'),
|
|
},
|
|
);
|
|
}
|
|
if (config.screenAudio) {
|
|
checks.push(
|
|
{
|
|
name: 'publisher local screen-share audio publication',
|
|
pass: hasTrackEvent(publisherState, 'localTrackPublished', {
|
|
kind: 'audio',
|
|
source: LIVEKIT_SCREEN_SHARE_AUDIO_SOURCE,
|
|
}),
|
|
},
|
|
{
|
|
name: 'subscriber remote screen-share audio subscription',
|
|
pass: hasTrackEvent(subscriberState, 'trackSubscribed', {
|
|
identity: publisherIdentity,
|
|
kind: 'audio',
|
|
source: LIVEKIT_SCREEN_SHARE_AUDIO_SOURCE,
|
|
}),
|
|
},
|
|
{
|
|
name: 'publisher outbound screen-share audio stats',
|
|
pass: statsHasOutbound(publisherState.stats, 'audio', LIVEKIT_SCREEN_SHARE_AUDIO_SOURCE),
|
|
},
|
|
);
|
|
}
|
|
if (config.camera) {
|
|
checks.push(
|
|
{
|
|
name: 'publisher local camera publication',
|
|
pass: hasTrackEvent(publisherState, 'localTrackPublished', {kind: 'video', source: 'camera'}),
|
|
},
|
|
{
|
|
name: 'subscriber remote camera subscription',
|
|
pass: hasTrackEvent(subscriberState, 'trackSubscribed', {
|
|
identity: publisherIdentity,
|
|
kind: 'video',
|
|
source: 'camera',
|
|
}),
|
|
},
|
|
{
|
|
name: 'publisher outbound camera stats',
|
|
pass: statsHasOutbound(publisherState.stats, 'video', 'camera'),
|
|
},
|
|
);
|
|
if (config.expectedCameraCodec) {
|
|
checks.push({
|
|
name: `publisher outbound camera codec ${config.expectedCameraCodec}`,
|
|
pass: statsHasOutboundCodec(publisherState.stats, 'video', 'camera', config.expectedCameraCodec),
|
|
});
|
|
}
|
|
}
|
|
if (config.secondaryPublisher) {
|
|
checks.push(
|
|
{name: 'secondary publisher connected', pass: secondaryPublisher?.isConnected() === true},
|
|
{
|
|
name: 'secondary publisher local screenshare publication',
|
|
pass: hasTrackEvent(secondaryPublisherState, 'localTrackPublished', {
|
|
kind: 'video',
|
|
source: LIVEKIT_SCREEN_SHARE_SOURCE,
|
|
}),
|
|
},
|
|
{
|
|
name: 'subscriber remote secondary screenshare subscription',
|
|
pass: hasTrackEvent(subscriberState, 'trackSubscribed', {
|
|
identity: secondaryPublisherIdentity,
|
|
kind: 'video',
|
|
source: LIVEKIT_SCREEN_SHARE_SOURCE,
|
|
}),
|
|
},
|
|
{
|
|
name: 'secondary publisher outbound screenshare stats',
|
|
pass: statsHasOutbound(secondaryPublisherState?.stats, 'video', LIVEKIT_SCREEN_SHARE_SOURCE),
|
|
},
|
|
);
|
|
if (config.expectedSecondaryPublisherCodec) {
|
|
checks.push(
|
|
{
|
|
name: `secondary publisher outbound screenshare codec ${config.expectedSecondaryPublisherCodec}`,
|
|
pass: statsHasOutboundCodec(
|
|
secondaryPublisherState?.stats,
|
|
'video',
|
|
LIVEKIT_SCREEN_SHARE_SOURCE,
|
|
config.expectedSecondaryPublisherCodec,
|
|
),
|
|
},
|
|
{
|
|
name: `subscriber inbound secondary codec ${config.expectedSecondaryPublisherCodec}`,
|
|
pass: statsHasInboundCodec(subscriberState.stats, 'video', config.expectedSecondaryPublisherCodec),
|
|
},
|
|
);
|
|
}
|
|
if (config.secondaryPublisherMicrophone) {
|
|
checks.push(
|
|
{
|
|
name: 'secondary publisher local microphone publication',
|
|
pass: hasTrackEvent(secondaryPublisherState, 'localTrackPublished', {
|
|
kind: 'audio',
|
|
source: 'microphone',
|
|
}),
|
|
},
|
|
{
|
|
name: 'subscriber remote secondary microphone subscription',
|
|
pass: hasTrackEvent(subscriberState, 'trackSubscribed', {
|
|
identity: secondaryPublisherIdentity,
|
|
kind: 'audio',
|
|
source: 'microphone',
|
|
}),
|
|
},
|
|
{
|
|
name: 'secondary publisher outbound microphone stats',
|
|
pass: statsHasOutbound(secondaryPublisherState?.stats, 'audio', 'microphone'),
|
|
},
|
|
);
|
|
}
|
|
if (config.secondaryPublisherScreenAudio) {
|
|
checks.push(
|
|
{
|
|
name: 'secondary publisher local screen-share audio publication',
|
|
pass: hasTrackEvent(secondaryPublisherState, 'localTrackPublished', {
|
|
kind: 'audio',
|
|
source: LIVEKIT_SCREEN_SHARE_AUDIO_SOURCE,
|
|
}),
|
|
},
|
|
{
|
|
name: 'subscriber remote secondary screen-share audio subscription',
|
|
pass: hasTrackEvent(subscriberState, 'trackSubscribed', {
|
|
identity: secondaryPublisherIdentity,
|
|
kind: 'audio',
|
|
source: LIVEKIT_SCREEN_SHARE_AUDIO_SOURCE,
|
|
}),
|
|
},
|
|
{
|
|
name: 'secondary publisher outbound screen-share audio stats',
|
|
pass: statsHasOutbound(secondaryPublisherState?.stats, 'audio', LIVEKIT_SCREEN_SHARE_AUDIO_SOURCE),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
if (config.dataPacket) {
|
|
checks.push({
|
|
name: 'subscriber data packet received',
|
|
pass: hasEvent(subscriberState, 'dataReceived', (payload) => payload.topic === 'native-media-harness'),
|
|
});
|
|
if (config.secondaryPublisher) {
|
|
checks.push({
|
|
name: 'subscriber secondary data packet received',
|
|
pass: hasEvent(
|
|
subscriberState,
|
|
'dataReceived',
|
|
(payload) => payload.topic === 'native-media-harness-secondary',
|
|
),
|
|
});
|
|
}
|
|
}
|
|
if (config.expectedScreenCodec) {
|
|
checks.push(
|
|
{
|
|
name: `publisher outbound screenshare codec ${config.expectedScreenCodec}`,
|
|
pass: statsHasOutboundCodec(
|
|
publisherState.stats,
|
|
'video',
|
|
LIVEKIT_SCREEN_SHARE_SOURCE,
|
|
config.expectedScreenCodec,
|
|
),
|
|
},
|
|
{
|
|
name: `subscriber inbound video codec ${config.expectedScreenCodec}`,
|
|
pass: statsHasInboundCodec(subscriberState.stats, 'video', config.expectedScreenCodec),
|
|
},
|
|
);
|
|
}
|
|
if (config.expectedHardwareEncoder) {
|
|
const capabilities = getHardwareEncoderCapabilities();
|
|
checks.push({
|
|
name: `hardware encoder backend ${config.expectedHardwareEncoder}`,
|
|
pass:
|
|
capabilities.available === true &&
|
|
String(capabilities.backend).toLowerCase() === config.expectedHardwareEncoder.toLowerCase(),
|
|
});
|
|
}
|
|
checks.push(...serverPublishedTrackChecks({config, serverState, publisherIdentity, secondaryPublisherIdentity}));
|
|
return checks;
|
|
}
|
|
|
|
async function waitForHarnessSuccess(context, pumpErrors, timeoutMs) {
|
|
const start = Date.now();
|
|
let checks = currentChecks(context);
|
|
while (Date.now() - start < timeoutMs) {
|
|
if (pumpErrors.length > 0) {
|
|
throw pumpErrors[0];
|
|
}
|
|
await enforceSubscriberVideoQuality(context);
|
|
await refreshServerPublishingState(context);
|
|
checks = currentChecks(context);
|
|
if (checks.every((check) => check.pass)) return checks;
|
|
await delay(250);
|
|
}
|
|
const missing = checks.filter((check) => !check.pass).map((check) => check.name);
|
|
throw new Error(`timed out after ${timeoutMs}ms waiting for: ${missing.join(', ')}`);
|
|
}
|
|
|
|
async function holdStrictDuration(context, pumpErrors, durationMs) {
|
|
const startedAtMs = Date.now();
|
|
context.strictStartedAtMs = startedAtMs;
|
|
if (durationMs <= 0) {
|
|
context.strictEndedAtMs = startedAtMs;
|
|
return;
|
|
}
|
|
const endAtMs = startedAtMs + durationMs;
|
|
while (Date.now() < endAtMs) {
|
|
if (pumpErrors.length > 0) throw pumpErrors[0];
|
|
await delay(1000);
|
|
}
|
|
context.strictEndedAtMs = Date.now();
|
|
}
|
|
|
|
async function withTimeout(promise, timeoutMs, label) {
|
|
const timeout = delay(timeoutMs).then(() => {
|
|
throw new Error(`${label} timed out after ${timeoutMs}ms`);
|
|
});
|
|
return Promise.race([promise, timeout]);
|
|
}
|
|
|
|
async function disconnectEngine(engine, label, timeoutMs) {
|
|
if (!engine) return;
|
|
try {
|
|
await withTimeout(engine.disconnect(), timeoutMs, `${label} disconnect`);
|
|
} catch (error) {
|
|
console.warn(`[livekit-harness] ${label} disconnect failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function refreshServerPublishingState(context) {
|
|
const {config, serverState} = context;
|
|
if (!config.validateServerPublishing) return;
|
|
const now = Date.now();
|
|
if (serverState.inFlight || now - serverState.lastAttemptAtMs < 500) return;
|
|
serverState.inFlight = true;
|
|
serverState.lastAttemptAtMs = now;
|
|
try {
|
|
serverState.participants = await listServerParticipants(config);
|
|
serverState.error = null;
|
|
serverState.fetchedAtMs = Date.now();
|
|
} catch (error) {
|
|
serverState.error = String(error.message ?? error);
|
|
} finally {
|
|
serverState.inFlight = false;
|
|
}
|
|
}
|
|
|
|
async function listServerParticipants(config) {
|
|
const token = createLiveKitAdminToken({
|
|
apiKey: config.apiKey,
|
|
apiSecret: config.apiSecret,
|
|
room: config.room,
|
|
});
|
|
const response = await fetch(`${config.serverApiUrl}/twirp/livekit.RoomService/ListParticipants`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({room: config.room}),
|
|
});
|
|
const bodyText = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(`RoomService.ListParticipants failed with HTTP ${response.status}: ${bodyText.slice(0, 240)}`);
|
|
}
|
|
let body;
|
|
try {
|
|
body = bodyText ? JSON.parse(bodyText) : {};
|
|
} catch (error) {
|
|
throw new Error(`RoomService.ListParticipants returned invalid JSON: ${error.message}`);
|
|
}
|
|
if (!Array.isArray(body.participants)) {
|
|
throw new Error('RoomService.ListParticipants response did not contain participants');
|
|
}
|
|
return body.participants;
|
|
}
|
|
|
|
async function runSubscriptionCycle(subscriber, publisherIdentity) {
|
|
await subscriber.setRemoteTrackSubscription({
|
|
participantIdentity: publisherIdentity,
|
|
source: LIVEKIT_SCREEN_SHARE_SOURCE,
|
|
subscribed: true,
|
|
enabled: false,
|
|
quality: 'low',
|
|
});
|
|
await delay(500);
|
|
await subscriber.setRemoteTrackSubscription({
|
|
participantIdentity: publisherIdentity,
|
|
source: LIVEKIT_SCREEN_SHARE_SOURCE,
|
|
subscribed: true,
|
|
enabled: true,
|
|
quality: 'high',
|
|
});
|
|
}
|
|
|
|
async function setSubscriberVideoQuality(subscriber, publisherIdentity, quality) {
|
|
if (!quality) return;
|
|
await subscriber.setRemoteTrackSubscription({
|
|
participantIdentity: publisherIdentity,
|
|
source: LIVEKIT_SCREEN_SHARE_SOURCE,
|
|
subscribed: true,
|
|
enabled: true,
|
|
quality,
|
|
});
|
|
}
|
|
|
|
async function enforceSubscriberVideoQuality(context) {
|
|
await setSubscriberVideoQuality(context.subscriber, context.publisherIdentity, context.config.subscriberVideoQuality);
|
|
if (context.secondaryPublisher) {
|
|
await setSubscriberVideoQuality(
|
|
context.subscriber,
|
|
context.secondaryPublisherIdentity,
|
|
context.config.subscriberVideoQuality,
|
|
);
|
|
}
|
|
}
|
|
|
|
function sanitizeConfig(config) {
|
|
const {
|
|
apiSecret: _apiSecret,
|
|
e2eeKey: _e2eeKey,
|
|
publisherToken,
|
|
secondaryPublisherToken,
|
|
subscriberToken,
|
|
...safe
|
|
} = config;
|
|
return {
|
|
...safe,
|
|
url: sanitizeReportUrl(config.url),
|
|
serverApiUrl: sanitizeReportUrl(config.serverApiUrl),
|
|
apiSecret: '<redacted>',
|
|
e2eeKey: config.e2eeKey ? '<present>' : null,
|
|
publisherToken: publisherToken ? '<present>' : null,
|
|
secondaryPublisherToken: secondaryPublisherToken ? '<present>' : null,
|
|
subscriberToken: subscriberToken ? '<present>' : null,
|
|
};
|
|
}
|
|
|
|
function summarizeServerPublishing(serverState) {
|
|
if (!serverState) return null;
|
|
return {
|
|
fetchedAtMs: serverState.fetchedAtMs,
|
|
error: serverState.error,
|
|
participants: (serverState.participants ?? []).map((participant) => ({
|
|
identity: participant.identity,
|
|
trackCount: participant.tracks?.length ?? 0,
|
|
tracks: (participant.tracks ?? []).map((track) => ({
|
|
sid: track.sid ?? null,
|
|
name: track.name ?? null,
|
|
kind: normalizeTrackKind(track.type ?? track.kind),
|
|
source: normalizeTrackSource(track.source),
|
|
mimeType: track.mimeType ?? track.mime_type ?? null,
|
|
muted: track.muted ?? null,
|
|
})),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function sanitizeReportUrl(rawUrl) {
|
|
if (typeof rawUrl !== 'string') return rawUrl;
|
|
try {
|
|
const url = new URL(rawUrl);
|
|
if (!url.username && !url.password && !url.search && !url.hash) return rawUrl;
|
|
url.username = '';
|
|
url.password = '';
|
|
url.search = '';
|
|
url.hash = '';
|
|
return url.toString();
|
|
} catch {
|
|
return rawUrl.replace(/\/\/[^/@\s]+@/g, '//<redacted>@').replace(/[?][^\s#]*/g, '?<redacted>');
|
|
}
|
|
}
|
|
|
|
function scenarioRoomName(room, scenarioName, scenarioCount, externalTokens) {
|
|
if (scenarioCount <= 1 || externalTokens) return room;
|
|
const suffix = scenarioName.replace(/[^a-z0-9_.-]/gi, '-').replace(/-+/g, '-');
|
|
return `${room}-${suffix}`.slice(0, 128);
|
|
}
|
|
|
|
function buildScenarioConfigs(baseConfig) {
|
|
const scenarioCount = baseConfig.screenCodecs.length;
|
|
return baseConfig.screenCodecs.map((codec, index) => {
|
|
const expectedCodec = baseConfig.expectedScreenCodecs[index] ?? codec;
|
|
const secondaryCodec = baseConfig.secondaryPublisherCodecExplicit
|
|
? baseConfig.secondaryPublisherCodec
|
|
: (baseConfig.screenCodecs[(index + 1) % baseConfig.screenCodecs.length] ?? codec);
|
|
const expectedSecondaryCodec = baseConfig.expectedSecondaryPublisherCodecExplicit
|
|
? baseConfig.expectedSecondaryPublisherCodec
|
|
: expectedCodecMime(secondaryCodec);
|
|
const scenarioName = baseConfig.secondaryPublisher ? `codec-${codec}-with-${secondaryCodec}` : `codec-${codec}`;
|
|
return {
|
|
...baseConfig,
|
|
scenarioName,
|
|
room: scenarioRoomName(baseConfig.room, scenarioName, scenarioCount, baseConfig.externalTokens),
|
|
codec,
|
|
expectedScreenCodec: expectedCodecMime(expectedCodec),
|
|
secondaryPublisherCodec: secondaryCodec,
|
|
expectedSecondaryPublisherCodec: expectedSecondaryCodec,
|
|
reportPath: scenarioCount > 1 ? null : baseConfig.reportPath,
|
|
};
|
|
});
|
|
}
|
|
|
|
function buildReport({status, config, checks = [], error = null, skippedReason = null, context = null}) {
|
|
const now = Date.now();
|
|
const strictStartedAtMs = context?.strictStartedAtMs ?? context?.startedAtMs ?? now;
|
|
const strictEndedAtMs = context?.strictEndedAtMs ?? now;
|
|
const subscriberState = context?.subscriberState;
|
|
const subscriber = context?.subscriber;
|
|
const publisherState = context?.publisherState;
|
|
const secondaryPublisherState = context?.secondaryPublisherState;
|
|
const capabilities = typeof getHardwareEncoderCapabilities === 'function' ? getHardwareEncoderCapabilities() : null;
|
|
const strictVideoSelector = primaryScreenVideoSelector(config);
|
|
const inboundAudioFrames = subscriber ? subscriber.inboundAudioFrames() : 0;
|
|
const inboundVideoFrames = subscriber ? subscriber.inboundVideoFrames() : 0;
|
|
const strictVideoFrameTimes = subscriberState
|
|
? selectedVideoFrameTimes(subscriberState, strictStartedAtMs, strictEndedAtMs, strictVideoSelector)
|
|
: [];
|
|
const strictVideoFrames = strictVideoFrameTimes.length;
|
|
const strictAudioFrames = subscriberState
|
|
? countWindowSamples(subscriberState.audioFrameTimes, strictStartedAtMs, strictEndedAtMs)
|
|
: 0;
|
|
const measuredFps = receivedFpsFromTimes(strictVideoFrameTimes);
|
|
const avDriftMs = subscriberState
|
|
? currentAvDriftMs(subscriberState, strictStartedAtMs, strictEndedAtMs, strictVideoSelector)
|
|
: null;
|
|
const observedPacketLoss = maxObservedPacketsLost(publisherState, secondaryPublisherState, subscriberState);
|
|
const observedPacketLossDelta = maxObservedPacketsLostDelta(
|
|
strictStartedAtMs,
|
|
strictEndedAtMs,
|
|
publisherState,
|
|
secondaryPublisherState,
|
|
subscriberState,
|
|
);
|
|
return {
|
|
status,
|
|
startedAt: context?.startedAtIso ?? new Date(now).toISOString(),
|
|
endedAt: new Date(now).toISOString(),
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
config: sanitizeConfig(config),
|
|
checks,
|
|
skippedReason,
|
|
error: error ? String(error.stack ?? error.message ?? error) : null,
|
|
hardwareEncoder: capabilities,
|
|
metrics: {
|
|
inboundAudioFrames,
|
|
inboundVideoFrames,
|
|
strictAudioFrames,
|
|
strictVideoFrames,
|
|
videoCallbacks: subscriberState?.videoCallbacks ?? 0,
|
|
videoBytes: subscriberState?.videoBytes ?? 0,
|
|
receivedFps: Math.round(measuredFps * 100) / 100,
|
|
requiredFps: config.minFpsRatio > 0 ? Math.round(config.fps * config.minFpsRatio * 100) / 100 : null,
|
|
maxVideoFrameGapMs: maxGapMs(strictVideoFrameTimes, strictStartedAtMs, strictEndedAtMs),
|
|
maxAudioFrameGapMs: subscriberState
|
|
? maxGapMs(subscriberState.audioFrameTimes, strictStartedAtMs, strictEndedAtMs)
|
|
: 0,
|
|
maxVideoFrameGapOverallMs: subscriberState?.maxVideoFrameGapMs ?? 0,
|
|
maxAudioFrameGapOverallMs: subscriberState?.maxAudioFrameGapMs ?? 0,
|
|
avDriftMs,
|
|
maxObservedPacketLoss: observedPacketLoss,
|
|
maxObservedPacketLossDelta: observedPacketLossDelta,
|
|
videoResolutionCounts: videoResolutionCounts(
|
|
subscriberState,
|
|
strictStartedAtMs,
|
|
strictEndedAtMs,
|
|
strictVideoSelector,
|
|
),
|
|
videoResolutionMismatchCount: videoResolutionMismatchCount(
|
|
subscriberState,
|
|
config.expectedWidth,
|
|
config.expectedHeight,
|
|
strictStartedAtMs,
|
|
strictEndedAtMs,
|
|
strictVideoSelector,
|
|
),
|
|
droppedVideoFrameCallbacks: subscriber ? subscriber.droppedVideoFrameCallbacks() : 0,
|
|
publisherVideoPump: context?.videoPump?.stats?.() ?? null,
|
|
secondaryPublisherVideoPump: context?.secondaryVideoPump?.stats?.() ?? null,
|
|
serverPublishing: summarizeServerPublishing(context?.serverState),
|
|
lastVideoMeta: subscriberState?.lastVideoMeta ?? null,
|
|
lastStrictVideoMeta: lastVideoFrameRecord(
|
|
subscriberState,
|
|
strictStartedAtMs,
|
|
strictEndedAtMs,
|
|
strictVideoSelector,
|
|
),
|
|
publisherStats: publisherState?.stats ?? null,
|
|
secondaryPublisherStats: secondaryPublisherState?.stats ?? null,
|
|
subscriberStats: subscriberState?.stats ?? null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function strictFailures(report, config) {
|
|
const failures = [];
|
|
if (config.minFpsRatio > 0 && report.metrics.receivedFps < config.fps * config.minFpsRatio) {
|
|
failures.push(`received FPS ${report.metrics.receivedFps} below ${config.fps * config.minFpsRatio}`);
|
|
}
|
|
if (config.maxFrameGapMs > 0) {
|
|
if (report.metrics.strictVideoFrames < 2) {
|
|
failures.push(
|
|
'video frame gap unavailable because fewer than two video frames were observed during strict window',
|
|
);
|
|
} else if (report.metrics.maxVideoFrameGapMs > config.maxFrameGapMs) {
|
|
failures.push(`max video frame gap ${report.metrics.maxVideoFrameGapMs}ms exceeds ${config.maxFrameGapMs}ms`);
|
|
}
|
|
}
|
|
if (config.maxAudioFrameGapMs > 0 && expectedInboundAudioTrackCount(config) > 0) {
|
|
if (report.metrics.strictAudioFrames < 2) {
|
|
failures.push(
|
|
'audio frame gap unavailable because fewer than two audio frames were observed during strict window',
|
|
);
|
|
} else if (report.metrics.maxAudioFrameGapMs > config.maxAudioFrameGapMs) {
|
|
failures.push(
|
|
`max audio frame gap ${report.metrics.maxAudioFrameGapMs}ms exceeds ${config.maxAudioFrameGapMs}ms`,
|
|
);
|
|
}
|
|
}
|
|
if (config.maxAvDriftMs > 0) {
|
|
if (report.metrics.avDriftMs === null) {
|
|
failures.push('A/V drift unavailable because video or audio timing was not observed');
|
|
} else if (report.metrics.avDriftMs > config.maxAvDriftMs) {
|
|
failures.push(`A/V drift ${report.metrics.avDriftMs}ms exceeds ${config.maxAvDriftMs}ms`);
|
|
}
|
|
}
|
|
if (config.maxPacketLoss !== null && report.metrics.maxObservedPacketLossDelta > config.maxPacketLoss) {
|
|
failures.push(
|
|
`observed packet loss delta ${report.metrics.maxObservedPacketLossDelta} exceeds ${config.maxPacketLoss}`,
|
|
);
|
|
}
|
|
if (config.requireStableResolution && report.metrics.videoResolutionMismatchCount > 0) {
|
|
failures.push(
|
|
`observed ${report.metrics.videoResolutionMismatchCount} video frames outside ${config.expectedWidth}x${config.expectedHeight}`,
|
|
);
|
|
}
|
|
if (report.metrics.droppedVideoFrameCallbacks > 0) {
|
|
failures.push(`dropped video frame callbacks ${report.metrics.droppedVideoFrameCallbacks}`);
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
async function writeReportIfRequested(report, reportPath) {
|
|
if (!reportPath) return;
|
|
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
}
|
|
|
|
async function runHarness(config) {
|
|
if (!isSupported()) {
|
|
throw new Error(`native binding unavailable: ${loadError?.message ?? 'unknown error'}`);
|
|
}
|
|
|
|
const target = liveKitTcpTarget(config.url);
|
|
const probe = await probeTcp(target, config.probeTimeoutMs);
|
|
if (!probe.ok) {
|
|
const message = `LiveKit is unreachable at ${config.url} (${probe.reason})`;
|
|
if (!config.required) {
|
|
console.log(`[livekit-harness] SKIP: ${message}. Set FLUXER_WEBRTC_SENDER_LIVEKIT_REQUIRED=1 to fail instead.`);
|
|
return {skipped: true, skippedReason: message};
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
|
|
const publisherIdentity = config.publisherIdentity;
|
|
const secondaryPublisherIdentity = config.secondaryPublisherIdentity;
|
|
const subscriberIdentity = config.subscriberIdentity;
|
|
const publisherToken =
|
|
config.publisherToken ??
|
|
createLiveKitToken({
|
|
apiKey: config.apiKey,
|
|
apiSecret: config.apiSecret,
|
|
room: config.room,
|
|
identity: publisherIdentity,
|
|
name: 'Fluxer Native Publisher',
|
|
});
|
|
const secondaryPublisherToken = config.secondaryPublisher
|
|
? (config.secondaryPublisherToken ??
|
|
createLiveKitToken({
|
|
apiKey: config.apiKey,
|
|
apiSecret: config.apiSecret,
|
|
room: config.room,
|
|
identity: secondaryPublisherIdentity,
|
|
name: 'Fluxer Native Publisher 2',
|
|
}))
|
|
: null;
|
|
const subscriberToken =
|
|
config.subscriberToken ??
|
|
createLiveKitToken({
|
|
apiKey: config.apiKey,
|
|
apiSecret: config.apiSecret,
|
|
room: config.room,
|
|
identity: subscriberIdentity,
|
|
name: 'Fluxer Native Subscriber',
|
|
});
|
|
|
|
const publisher = new VoiceEngine();
|
|
const secondaryPublisher = config.secondaryPublisher ? new VoiceEngine() : null;
|
|
const subscriber = new VoiceEngine();
|
|
const publisherState = createEngineState('publisher', config.verbose);
|
|
const secondaryPublisherState = config.secondaryPublisher ? createEngineState('publisher-2', config.verbose) : null;
|
|
const subscriberState = createEngineState('subscriber', config.verbose);
|
|
const pumpErrors = [];
|
|
const context = {
|
|
publisher,
|
|
secondaryPublisher,
|
|
subscriber,
|
|
publisherState,
|
|
secondaryPublisherState,
|
|
subscriberState,
|
|
serverState: {
|
|
participants: null,
|
|
error: null,
|
|
fetchedAtMs: null,
|
|
lastAttemptAtMs: 0,
|
|
inFlight: false,
|
|
},
|
|
config,
|
|
publisherIdentity,
|
|
secondaryPublisherIdentity,
|
|
subscriberIdentity,
|
|
startedAtMs: Date.now(),
|
|
startedAtIso: new Date().toISOString(),
|
|
strictStartedAtMs: null,
|
|
strictEndedAtMs: null,
|
|
};
|
|
let videoPump = null;
|
|
let secondaryVideoPump = null;
|
|
let micPump = null;
|
|
let secondaryMicPump = null;
|
|
let screenAudioPump = null;
|
|
let secondaryScreenAudioPump = null;
|
|
let audioSampler = null;
|
|
|
|
publisher.setEventCallback((...args) => {
|
|
const [eventType, payload] = unpackNapiPair(args);
|
|
publisherState.recordEvent(eventType, payload);
|
|
});
|
|
secondaryPublisher?.setEventCallback((...args) => {
|
|
const [eventType, payload] = unpackNapiPair(args);
|
|
secondaryPublisherState.recordEvent(eventType, payload);
|
|
});
|
|
subscriber.setEventCallback((...args) => {
|
|
const [eventType, payload] = unpackNapiPair(args);
|
|
subscriberState.recordEvent(eventType, payload);
|
|
});
|
|
subscriber.setVideoFrameCallback((...args) => {
|
|
const [metaJson, data] = unpackNapiPair(args);
|
|
subscriberState.recordVideoFrame(metaJson, data);
|
|
});
|
|
subscriber.setCountInboundAudio(true);
|
|
audioSampler = startAudioSampler(subscriber, subscriberState);
|
|
|
|
try {
|
|
const e2eeKey = config.e2eeKey ? Buffer.from(config.e2eeKey, 'utf8') : undefined;
|
|
console.log(`[livekit-harness] connecting to ${config.url} room=${config.room}`);
|
|
await withTimeout(
|
|
Promise.all(
|
|
[
|
|
publisher.connect(config.url, publisherToken, e2eeKey),
|
|
secondaryPublisher && secondaryPublisherToken
|
|
? secondaryPublisher.connect(config.url, secondaryPublisherToken, e2eeKey)
|
|
: null,
|
|
subscriber.connect(config.url, subscriberToken, e2eeKey),
|
|
].filter(Boolean),
|
|
),
|
|
config.connectTimeoutMs,
|
|
'connect',
|
|
);
|
|
|
|
await withTimeout(
|
|
publisher.publishScreenShare(
|
|
config.width,
|
|
config.height,
|
|
config.codec,
|
|
config.maxBitrateBps,
|
|
config.fps,
|
|
config.screenSimulcast,
|
|
{
|
|
adaptiveSend: config.adaptiveSend,
|
|
minVideoFps: config.minVideoFps,
|
|
minResolutionScale: config.minResolutionScale,
|
|
maxAudioBufferMs: config.maxAudioBufferMs,
|
|
captureId: 'livekit-harness-primary-screen',
|
|
trackName: 'livekit-harness-primary-screen',
|
|
},
|
|
),
|
|
10_000,
|
|
'publish screenshare',
|
|
);
|
|
if (secondaryPublisher) {
|
|
await withTimeout(
|
|
secondaryPublisher.publishScreenShare(
|
|
config.width,
|
|
config.height,
|
|
config.secondaryPublisherCodec,
|
|
config.maxBitrateBps,
|
|
config.fps,
|
|
config.secondaryPublisherScreenSimulcast,
|
|
{
|
|
adaptiveSend: config.adaptiveSend,
|
|
minVideoFps: config.minVideoFps,
|
|
minResolutionScale: config.minResolutionScale,
|
|
maxAudioBufferMs: config.maxAudioBufferMs,
|
|
captureId: 'livekit-harness-secondary-screen',
|
|
trackName: 'livekit-harness-secondary-screen',
|
|
},
|
|
),
|
|
10_000,
|
|
'publish secondary screenshare',
|
|
);
|
|
}
|
|
if (config.microphone) {
|
|
await withTimeout(publisher.publishMicrophone(config.sampleRate, config.channels), 10_000, 'publish microphone');
|
|
}
|
|
if (secondaryPublisher && config.secondaryPublisherMicrophone) {
|
|
await withTimeout(
|
|
secondaryPublisher.publishMicrophone(config.sampleRate, config.channels),
|
|
10_000,
|
|
'publish secondary microphone',
|
|
);
|
|
}
|
|
if (config.screenAudio) {
|
|
await withTimeout(
|
|
publisher.publishScreenShareAudio(config.sampleRate, config.channels),
|
|
10_000,
|
|
'publish screen-share audio',
|
|
);
|
|
}
|
|
if (secondaryPublisher && config.secondaryPublisherScreenAudio) {
|
|
await withTimeout(
|
|
secondaryPublisher.publishScreenShareAudio(config.sampleRate, config.channels),
|
|
10_000,
|
|
'publish secondary screen-share audio',
|
|
);
|
|
}
|
|
if (config.camera) {
|
|
await withTimeout(
|
|
publisher.publishCamera({
|
|
deviceId: config.cameraDeviceId ?? undefined,
|
|
width: config.width,
|
|
height: config.height,
|
|
frameRate: config.fps,
|
|
}),
|
|
10_000,
|
|
'publish camera',
|
|
);
|
|
}
|
|
|
|
videoPump = startVideoPump(publisher, config, pumpErrors);
|
|
context.videoPump = videoPump;
|
|
if (secondaryPublisher) {
|
|
secondaryVideoPump = startVideoPump(secondaryPublisher, config, pumpErrors);
|
|
context.secondaryVideoPump = secondaryVideoPump;
|
|
}
|
|
if (config.microphone) {
|
|
micPump = startAudioPump(
|
|
'pushPcm',
|
|
(buffer, sampleRate, channels) => publisher.pushPcm(buffer, sampleRate, channels),
|
|
config,
|
|
pumpErrors,
|
|
);
|
|
}
|
|
if (secondaryPublisher && config.secondaryPublisherMicrophone) {
|
|
secondaryMicPump = startAudioPump(
|
|
'secondary pushPcm',
|
|
(buffer, sampleRate, channels) => secondaryPublisher.pushPcm(buffer, sampleRate, channels),
|
|
config,
|
|
pumpErrors,
|
|
);
|
|
}
|
|
if (config.screenAudio) {
|
|
screenAudioPump = startAudioPump(
|
|
'pushScreenSharePcm',
|
|
(buffer, sampleRate, channels) => publisher.pushScreenSharePcm(buffer, sampleRate, channels),
|
|
config,
|
|
pumpErrors,
|
|
);
|
|
}
|
|
if (secondaryPublisher && config.secondaryPublisherScreenAudio) {
|
|
secondaryScreenAudioPump = startAudioPump(
|
|
'secondary pushScreenSharePcm',
|
|
(buffer, sampleRate, channels) => secondaryPublisher.pushScreenSharePcm(buffer, sampleRate, channels),
|
|
config,
|
|
pumpErrors,
|
|
);
|
|
}
|
|
if (config.dataPacket) {
|
|
await publisher.publishData(Buffer.from('fluxer-native-media-harness'), {
|
|
reliable: true,
|
|
topic: 'native-media-harness',
|
|
destinationIdentities: [subscriberIdentity],
|
|
});
|
|
if (secondaryPublisher) {
|
|
await secondaryPublisher.publishData(Buffer.from('fluxer-native-media-harness-secondary'), {
|
|
reliable: true,
|
|
topic: 'native-media-harness-secondary',
|
|
destinationIdentities: [subscriberIdentity],
|
|
});
|
|
}
|
|
}
|
|
await setSubscriberVideoQuality(subscriber, publisherIdentity, config.subscriberVideoQuality);
|
|
if (secondaryPublisher) {
|
|
await setSubscriberVideoQuality(subscriber, secondaryPublisherIdentity, config.subscriberVideoQuality);
|
|
}
|
|
if (config.subscriptionCycle) {
|
|
await runSubscriptionCycle(subscriber, publisherIdentity);
|
|
if (secondaryPublisher) {
|
|
await runSubscriptionCycle(subscriber, secondaryPublisherIdentity);
|
|
}
|
|
}
|
|
|
|
const checks = await waitForHarnessSuccess(context, pumpErrors, config.timeoutMs);
|
|
await holdStrictDuration(context, pumpErrors, config.durationMs);
|
|
const report = buildReport({status: 'pass', config, checks, context});
|
|
const failures = strictFailures(report, config);
|
|
if (failures.length > 0) {
|
|
const failedReport = {...report, status: 'fail', error: failures.join('; ')};
|
|
await writeReportIfRequested(failedReport, config.reportPath);
|
|
const error = new Error(failures.join('; '));
|
|
error.report = failedReport;
|
|
throw error;
|
|
}
|
|
await writeReportIfRequested(report, config.reportPath);
|
|
const producedFps = report.metrics.publisherVideoPump?.producedFps ?? null;
|
|
console.log(
|
|
`[livekit-harness] PASS: ${checks.length} checks, inboundAudioFrames=${report.metrics.inboundAudioFrames}, inboundVideoFrames=${report.metrics.inboundVideoFrames}, videoCallbacks=${report.metrics.videoCallbacks}, receivedFps=${report.metrics.receivedFps}, producedFps=${producedFps}, maxVideoFrameGapMs=${report.metrics.maxVideoFrameGapMs}, maxAudioFrameGapMs=${report.metrics.maxAudioFrameGapMs}, maxObservedPacketLoss=${report.metrics.maxObservedPacketLoss}, maxObservedPacketLossDelta=${report.metrics.maxObservedPacketLossDelta}, resolutionMismatches=${report.metrics.videoResolutionMismatchCount}, droppedVideoFrameCallbacks=${report.metrics.droppedVideoFrameCallbacks}`,
|
|
);
|
|
return {skipped: false, report};
|
|
} catch (error) {
|
|
if (!error.report) {
|
|
let checks = [];
|
|
try {
|
|
checks = currentChecks(context);
|
|
} catch {}
|
|
error.report = buildReport({status: 'fail', config, checks, error, context});
|
|
}
|
|
throw error;
|
|
} finally {
|
|
videoPump?.stop();
|
|
secondaryVideoPump?.stop();
|
|
micPump?.stop();
|
|
secondaryMicPump?.stop();
|
|
screenAudioPump?.stop();
|
|
secondaryScreenAudioPump?.stop();
|
|
audioSampler?.stop();
|
|
await micPump?.done();
|
|
await secondaryMicPump?.done();
|
|
await screenAudioPump?.done();
|
|
await secondaryScreenAudioPump?.done();
|
|
await disconnectEngine(subscriber, 'subscriber', config.disconnectTimeoutMs);
|
|
await disconnectEngine(secondaryPublisher, 'publisher-2', config.disconnectTimeoutMs);
|
|
await disconnectEngine(publisher, 'publisher', config.disconnectTimeoutMs);
|
|
}
|
|
}
|
|
|
|
function buildAggregateReport({status, config, scenarioReports, startedAtMs}) {
|
|
return {
|
|
status,
|
|
startedAt: new Date(startedAtMs).toISOString(),
|
|
endedAt: new Date().toISOString(),
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
config: sanitizeConfig(config),
|
|
scenarioCount: scenarioReports.length,
|
|
scenarios: scenarioReports,
|
|
};
|
|
}
|
|
|
|
async function runScenarioSuite(baseConfig, scenarios) {
|
|
const startedAtMs = Date.now();
|
|
const scenarioReports = [];
|
|
for (let index = 0; index < scenarios.length; index += 1) {
|
|
const scenario = scenarios[index];
|
|
console.log(
|
|
`[livekit-harness] scenario ${index + 1}/${scenarios.length}: ${scenario.scenarioName} room=${scenario.room}`,
|
|
);
|
|
try {
|
|
const result = await runHarness(scenario);
|
|
if (result.skipped) {
|
|
scenarioReports.push(buildReport({status: 'skip', config: scenario, skippedReason: result.skippedReason}));
|
|
} else {
|
|
scenarioReports.push(result.report);
|
|
}
|
|
} catch (error) {
|
|
scenarioReports.push(error.report ?? buildReport({status: 'fail', config: scenario, error}));
|
|
}
|
|
}
|
|
const failed = scenarioReports.filter((report) => report.status === 'fail');
|
|
const skipped = scenarioReports.filter((report) => report.status === 'skip');
|
|
const status = failed.length > 0 ? 'fail' : skipped.length === scenarioReports.length ? 'skip' : 'pass';
|
|
const aggregate = buildAggregateReport({status, config: baseConfig, scenarioReports, startedAtMs});
|
|
await writeReportIfRequested(aggregate, baseConfig.reportPath);
|
|
if (status === 'fail') {
|
|
const failedNames = failed.map((report) => report.config.scenarioName ?? report.config.codec).join(', ');
|
|
const error = new Error(`LiveKit scenario suite failed: ${failedNames}`);
|
|
error.report = aggregate;
|
|
throw error;
|
|
}
|
|
return aggregate;
|
|
}
|
|
|
|
async function main() {
|
|
const baseConfig = parseConfig();
|
|
const scenarios = buildScenarioConfigs(baseConfig);
|
|
try {
|
|
if (scenarios.length > 1) {
|
|
const aggregate = await runScenarioSuite(baseConfig, scenarios);
|
|
console.log(`[livekit-harness] ${aggregate.status.toUpperCase()}: ${aggregate.scenarioCount} codec scenarios`);
|
|
return 0;
|
|
}
|
|
const config = scenarios[0];
|
|
const result = await runHarness(config);
|
|
if (result.skipped) {
|
|
const report = buildReport({status: 'skip', config, skippedReason: result.skippedReason});
|
|
await writeReportIfRequested(report, baseConfig.reportPath);
|
|
}
|
|
return 0;
|
|
} catch (error) {
|
|
const report = error.report ?? buildReport({status: 'fail', config: baseConfig, error});
|
|
await writeReportIfRequested(report, baseConfig.reportPath);
|
|
console.error(`[livekit-harness] FAIL: ${error.stack ?? error.message}`);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
export {
|
|
buildAggregateReport,
|
|
buildReport,
|
|
buildScenarioConfigs,
|
|
createLiveKitAdminToken,
|
|
createLiveKitToken,
|
|
expectedCodecMime,
|
|
jwtSubject,
|
|
liveKitApiUrl,
|
|
liveKitTcpTarget,
|
|
parseCodecList,
|
|
parseConfig,
|
|
sanitizeConfig,
|
|
scenarioRoomName,
|
|
serverPublishedTrackChecks,
|
|
statsHasOutbound,
|
|
statsHasOutboundCodec,
|
|
strictFailures,
|
|
};
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
const exitCode = await main();
|
|
setImmediate(() => process.exit(exitCode));
|
|
}
|