Compare commits
6 Commits
a413c56a6f
...
554bb5d9ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 554bb5d9ee | |||
| 68712fba8c | |||
| ac16fa85e9 | |||
| a30c74f8da | |||
| 0d8a567c95 | |||
| 44f4b30313 |
80
client/public/media-config.js
Normal file
80
client/public/media-config.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
export const AUDIO_CONFIG = {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VAD_CONFIG = {
|
||||||
|
threshold: 15,
|
||||||
|
debounceTime: 500,
|
||||||
|
fftSize: 256
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MEDIA_CONSTRAINTS = {
|
||||||
|
video: {
|
||||||
|
width: { ideal: 1920, max: 1920 },
|
||||||
|
height: { ideal: 1080, max: 1080 },
|
||||||
|
frameRate: { ideal: 30, max: 30 }
|
||||||
|
},
|
||||||
|
audio: AUDIO_CONFIG
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VIDEO_ONLY_CONSTRAINT = {
|
||||||
|
video: {
|
||||||
|
width: { ideal: 1920, max: 1920 },
|
||||||
|
height: { ideal: 1080, max: 1080 },
|
||||||
|
frameRate: { ideal: 30, max: 30 }
|
||||||
|
},
|
||||||
|
audio: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const TARGET_RESOLUTION_BITRATE_MAP = {
|
||||||
|
270: 1000000,
|
||||||
|
480: 1500000,
|
||||||
|
720: 2500000,
|
||||||
|
1080: 4000000,
|
||||||
|
1440: 6000000
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildVideoConstraints(savedResolution) {
|
||||||
|
if (!savedResolution) {
|
||||||
|
return MEDIA_CONSTRAINTS.video;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: { ideal: savedResolution.width, max: savedResolution.width },
|
||||||
|
height: { ideal: savedResolution.height, max: savedResolution.height },
|
||||||
|
frameRate: { ideal: 30, max: 30 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResolutionLabel(height) {
|
||||||
|
if (height >= 1440) {
|
||||||
|
return '2K 1440p';
|
||||||
|
}
|
||||||
|
if (height >= 1080) {
|
||||||
|
return '1080p 超清';
|
||||||
|
}
|
||||||
|
if (height >= 720) {
|
||||||
|
return '720p 高清';
|
||||||
|
}
|
||||||
|
return '480p 流畅';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTargetResolutionBitrate(height) {
|
||||||
|
return TARGET_RESOLUTION_BITRATE_MAP[height] || 2500000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdaptiveVideoBitrate(height) {
|
||||||
|
const supportedHeights = Object.keys(TARGET_RESOLUTION_BITRATE_MAP).map(Number).sort((a, b) => a - b);
|
||||||
|
let maxBitrate = TARGET_RESOLUTION_BITRATE_MAP[1080];
|
||||||
|
|
||||||
|
for (const supportedHeight of supportedHeights) {
|
||||||
|
if (height <= supportedHeight) {
|
||||||
|
return TARGET_RESOLUTION_BITRATE_MAP[supportedHeight];
|
||||||
|
}
|
||||||
|
maxBitrate = TARGET_RESOLUTION_BITRATE_MAP[supportedHeight];
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxBitrate;
|
||||||
|
}
|
||||||
62
client/public/media-monitoring.js
Normal file
62
client/public/media-monitoring.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export function createAudioAnalyser(stream, fftSize) {
|
||||||
|
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||||
|
const audioContext = new AudioContextCtor();
|
||||||
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = fftSize;
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioContext,
|
||||||
|
analyser,
|
||||||
|
dataArray: new Uint8Array(analyser.frequencyBinCount)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAudioLevel(analyser, dataArray) {
|
||||||
|
analyser.getByteTimeDomainData(dataArray);
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < dataArray.length; i++) {
|
||||||
|
const amplitude = dataArray[i] - 128;
|
||||||
|
sum += amplitude * amplitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rms = Math.sqrt(sum / dataArray.length);
|
||||||
|
return rms / 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPacketLossRate(packetsLost, packetsReceived) {
|
||||||
|
if (packetsReceived <= 0) {
|
||||||
|
return '0%';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${((packetsLost / (packetsLost + packetsReceived)) * 100).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStatsLogPayload(networkQuality, statsSummary) {
|
||||||
|
return {
|
||||||
|
networkQuality,
|
||||||
|
video: {
|
||||||
|
'Packets Lost': statsSummary.video.packetsLost,
|
||||||
|
'Packets Received': statsSummary.video.packetsReceived,
|
||||||
|
'Packet Loss Rate': formatPacketLossRate(
|
||||||
|
statsSummary.video.packetsLost,
|
||||||
|
statsSummary.video.packetsReceived
|
||||||
|
),
|
||||||
|
'Jitter': `${(statsSummary.video.jitter * 1000).toFixed(2)}ms`,
|
||||||
|
'Round Trip Time': `${(statsSummary.video.roundTripTime * 1000).toFixed(2)}ms`,
|
||||||
|
'FPS': statsSummary.video.fps.toFixed(1),
|
||||||
|
'Bitrate': `${statsSummary.video.bitrate}kbps`
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
'Packets Lost': statsSummary.audio.packetsLost,
|
||||||
|
'Packets Received': statsSummary.audio.packetsReceived,
|
||||||
|
'Packet Loss Rate': formatPacketLossRate(
|
||||||
|
statsSummary.audio.packetsLost,
|
||||||
|
statsSummary.audio.packetsReceived
|
||||||
|
),
|
||||||
|
'Jitter': `${(statsSummary.audio.jitter * 1000).toFixed(2)}ms`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
77
client/public/participants.js
Normal file
77
client/public/participants.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
export const DEFAULT_PARTICIPANT_NAME = '参与者';
|
||||||
|
export const DEFAULT_PARTICIPANT_AVATAR = '/images/p2.png';
|
||||||
|
|
||||||
|
const DEFAULT_MEDIA_STATE = Object.freeze({
|
||||||
|
audio: false,
|
||||||
|
video: true,
|
||||||
|
isSpeaking: false
|
||||||
|
});
|
||||||
|
|
||||||
|
function createParticipantRecord(current = {}, patch = {}) {
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
name: DEFAULT_PARTICIPANT_NAME,
|
||||||
|
avatar: DEFAULT_PARTICIPANT_AVATAR,
|
||||||
|
mediaState: { ...DEFAULT_MEDIA_STATE },
|
||||||
|
status: 'online',
|
||||||
|
...current,
|
||||||
|
...patch,
|
||||||
|
mediaState: {
|
||||||
|
...DEFAULT_MEDIA_STATE,
|
||||||
|
...(current.mediaState || {}),
|
||||||
|
...(patch.mediaState || {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertParticipant(participants, participantId, patch = {}) {
|
||||||
|
if (!participantId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
participants[participantId] = createParticipantRecord(participants[participantId], patch);
|
||||||
|
return participants[participantId];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeParticipant(participants, participantId) {
|
||||||
|
if (!participantId || !participants[participantId]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete participants[participantId];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function omitParticipant(participants, excludedParticipantId) {
|
||||||
|
const filtered = {};
|
||||||
|
|
||||||
|
for (const [participantId, participant] of Object.entries(participants || {})) {
|
||||||
|
if (participantId !== excludedParticipantId) {
|
||||||
|
filtered[participantId] = participant;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildParticipantsSyncData(localUser, participants) {
|
||||||
|
const memberList = {
|
||||||
|
host: {
|
||||||
|
id: localUser.id,
|
||||||
|
name: localUser.name,
|
||||||
|
avatar: localUser.avatar,
|
||||||
|
mediaState: { ...localUser.mediaState },
|
||||||
|
status: 'online',
|
||||||
|
role: 'host'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [participantId, participant] of Object.entries(participants || {})) {
|
||||||
|
memberList[participantId] = {
|
||||||
|
...createParticipantRecord(participant),
|
||||||
|
role: 'participant'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberList;
|
||||||
|
}
|
||||||
99
client/public/signaling-session.js
Normal file
99
client/public/signaling-session.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Signaling, WebSocketSignaling } from "../../module/signaling.js";
|
||||||
|
|
||||||
|
const INVITE_EVENT_NAMES = Object.freeze([
|
||||||
|
'invite-call',
|
||||||
|
'invite-accepted',
|
||||||
|
'invite-rejected',
|
||||||
|
'invite-failed'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DEFAULT_SOCKET_USER_NAME = '?';
|
||||||
|
const DEFAULT_SOCKET_USER_AVATAR = '/images/p1.png';
|
||||||
|
|
||||||
|
export function createSignalingInstance(useWebSocket) {
|
||||||
|
return useWebSocket ? new WebSocketSignaling() : new Signaling();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSignalingStarted(existingSignaling, useWebSocket) {
|
||||||
|
if (existingSignaling) {
|
||||||
|
return {
|
||||||
|
signaling: existingSignaling,
|
||||||
|
reused: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const signaling = createSignalingInstance(useWebSocket);
|
||||||
|
await signaling.start();
|
||||||
|
|
||||||
|
return {
|
||||||
|
signaling,
|
||||||
|
reused: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindInviteSocketEvents(signaling, eventHandlers, boundSignaling = null) {
|
||||||
|
if (!signaling || signaling === boundSignaling || typeof signaling.addEventListener !== 'function') {
|
||||||
|
return boundSignaling;
|
||||||
|
}
|
||||||
|
|
||||||
|
INVITE_EVENT_NAMES.forEach((eventName) => {
|
||||||
|
signaling.addEventListener(eventName, (event) => {
|
||||||
|
const handler = eventHandlers[eventName];
|
||||||
|
if (typeof handler === 'function') {
|
||||||
|
handler(event.detail);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return signaling;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveSignalingInstance(preconnectedSignaling, renderstreaming) {
|
||||||
|
if (preconnectedSignaling) {
|
||||||
|
return preconnectedSignaling;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderstreaming && renderstreaming._signaling) {
|
||||||
|
return renderstreaming._signaling;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendInviteSignal(signaling, methodName, payload) {
|
||||||
|
if (!signaling || typeof signaling[methodName] !== 'function') {
|
||||||
|
throw new Error('Invite signaling is not ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
signaling[methodName](payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredSocketUserInfo() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem('userSettings') || '{}');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing user settings:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSocketUserInfoPayload(userInfo, localUser) {
|
||||||
|
const settings = userInfo || readStoredSocketUserInfo();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: settings.id || settings.userId || localUser.id || '',
|
||||||
|
name: settings.name || localUser.name || DEFAULT_SOCKET_USER_NAME,
|
||||||
|
avatar: settings.avatar || localUser.avatar || DEFAULT_SOCKET_USER_AVATAR
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendSocketUserInfo(signaling, payload) {
|
||||||
|
if (!signaling || typeof signaling.sendMessage !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
signaling.sendMessage('', {
|
||||||
|
type: 'user-info',
|
||||||
|
data: payload
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
1013
client/public/store.js.tmp
Normal file
1013
client/public/store.js.tmp
Normal file
File diff suppressed because it is too large
Load Diff
96
client/public/webrtc-stats.js
Normal file
96
client/public/webrtc-stats.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
function createStatsSummary() {
|
||||||
|
return {
|
||||||
|
video: {
|
||||||
|
packetsLost: 0,
|
||||||
|
packetsReceived: 0,
|
||||||
|
bytesReceived: 0,
|
||||||
|
jitter: 0,
|
||||||
|
roundTripTime: 0,
|
||||||
|
fps: 0,
|
||||||
|
bitrate: 0
|
||||||
|
},
|
||||||
|
audio: {
|
||||||
|
packetsLost: 0,
|
||||||
|
packetsReceived: 0,
|
||||||
|
bytesReceived: 0,
|
||||||
|
jitter: 0
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
totalPacketsLost: 0,
|
||||||
|
totalPacketsReceived: 0,
|
||||||
|
inboundRtpCount: 0,
|
||||||
|
jitter: 0,
|
||||||
|
roundTripTime: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeInboundStats(stats) {
|
||||||
|
const summary = createStatsSummary();
|
||||||
|
|
||||||
|
stats.forEach((report) => {
|
||||||
|
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
|
||||||
|
summary.network.inboundRtpCount++;
|
||||||
|
|
||||||
|
if (report.packetsLost !== undefined && report.packetsReceived !== undefined) {
|
||||||
|
summary.network.totalPacketsLost += report.packetsLost;
|
||||||
|
summary.network.totalPacketsReceived += report.packetsReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.jitter !== undefined) {
|
||||||
|
summary.network.jitter = Math.max(summary.network.jitter, report.jitter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.roundTripTime !== undefined) {
|
||||||
|
summary.network.roundTripTime = Math.max(summary.network.roundTripTime, report.roundTripTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.video.packetsLost = report.packetsLost || 0;
|
||||||
|
summary.video.packetsReceived = report.packetsReceived || 0;
|
||||||
|
summary.video.bytesReceived = report.bytesReceived || 0;
|
||||||
|
summary.video.jitter = report.jitter || 0;
|
||||||
|
summary.video.roundTripTime = report.roundTripTime || 0;
|
||||||
|
summary.video.fps = report.framesPerSecond || 0;
|
||||||
|
|
||||||
|
if (report.bytesReceived && report.timestamp) {
|
||||||
|
const duration = report.timestamp / 1000;
|
||||||
|
summary.video.bitrate = duration > 0
|
||||||
|
? Math.round((report.bytesReceived * 8) / (duration * 1000))
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
} else if (report.type === 'inbound-rtp' && report.mediaType === 'audio') {
|
||||||
|
summary.audio.packetsLost = report.packetsLost || 0;
|
||||||
|
summary.audio.packetsReceived = report.packetsReceived || 0;
|
||||||
|
summary.audio.bytesReceived = report.bytesReceived || 0;
|
||||||
|
summary.audio.jitter = report.jitter || 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNetworkQualityFromSummary(summary) {
|
||||||
|
const { totalPacketsLost, totalPacketsReceived, inboundRtpCount, jitter, roundTripTime } = summary.network;
|
||||||
|
|
||||||
|
if (inboundRtpCount === 0) {
|
||||||
|
return 'no_signal';
|
||||||
|
}
|
||||||
|
|
||||||
|
const packetLossRate = totalPacketsReceived > 0
|
||||||
|
? totalPacketsLost / (totalPacketsLost + totalPacketsReceived)
|
||||||
|
: 0;
|
||||||
|
const jitterMs = jitter * 1000;
|
||||||
|
const rttMs = roundTripTime * 1000;
|
||||||
|
|
||||||
|
if (packetLossRate > 0.05 || jitterMs > 100 || rttMs > 300) {
|
||||||
|
return 'poor';
|
||||||
|
}
|
||||||
|
if (packetLossRate > 0.02 || jitterMs > 50 || rttMs > 150) {
|
||||||
|
return 'fair';
|
||||||
|
}
|
||||||
|
if (packetLossRate > 0.01 || jitterMs > 30 || rttMs > 100) {
|
||||||
|
return 'good';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'excellent';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user