mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
refactor(voice): remove screen share codec renegotiation (#2417)
This commit is contained in:
@@ -766,12 +766,6 @@
|
||||
}
|
||||
},
|
||||
"watchedStreams": {},
|
||||
"codecNegotiation": {
|
||||
"overrides": {},
|
||||
"localSupportedVideoCodecs": [],
|
||||
"remoteSupportedVideoCodecs": {},
|
||||
"streams": {}
|
||||
},
|
||||
"stats": {
|
||||
"rttMs": 42,
|
||||
"outbound": [
|
||||
|
||||
@@ -25,7 +25,6 @@ export {
|
||||
selectVoiceEngineV2StatsPresentationProjection,
|
||||
selectVoiceEngineV2StatsProjection,
|
||||
selectVoiceEngineV2StatsSummary,
|
||||
selectVoiceEngineV2StreamNegotiation,
|
||||
selectVoiceEngineV2WatchedStreams,
|
||||
} from './selectors';
|
||||
export type {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {planDesiredState} from './_plan';
|
||||
import {transitionCamera} from './camera';
|
||||
import {transitionCapabilities} from './capabilities';
|
||||
import {transitionCodecNegotiation} from './codecNegotiation';
|
||||
import {transitionCommand} from './command';
|
||||
import {transitionConnection} from './connection';
|
||||
import {transitionData} from './data';
|
||||
@@ -137,13 +136,6 @@ export function dispatchSessionEvent(
|
||||
case 'data.publishSucceeded':
|
||||
case 'data.publishFailed':
|
||||
return transitionData(snapshot, event);
|
||||
case 'codecNegotiation.overrideSetRequested':
|
||||
case 'codecNegotiation.localCapabilityChanged':
|
||||
case 'codecNegotiation.remoteCapabilityChanged':
|
||||
case 'codecNegotiation.streamRegistered':
|
||||
case 'codecNegotiation.streamUnregistered':
|
||||
case 'codecNegotiation.viewerChanged':
|
||||
return transitionCodecNegotiation(snapshot, event);
|
||||
case 'stats.collectRequested':
|
||||
case 'stats.collected':
|
||||
case 'stats.collectFailed':
|
||||
|
||||
@@ -105,7 +105,7 @@ function failCamera(
|
||||
};
|
||||
}
|
||||
|
||||
export function beginCameraEncodingUpdate(
|
||||
function beginCameraEncodingUpdate(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
options: VoiceEngineV2CameraEncodingOptions,
|
||||
): VoiceEngineV2Transition {
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import {transitionVoiceEngineV2} from '../reducer';
|
||||
import {
|
||||
availableVoiceEngineV2Capabilities,
|
||||
createVoiceEngineV2InitialSnapshot,
|
||||
type VoiceEngineV2Snapshot,
|
||||
} from '../state';
|
||||
|
||||
function applyEvents(snapshot: VoiceEngineV2Snapshot, events: Array<VoiceEngineV2Event>): VoiceEngineV2Snapshot {
|
||||
let next = snapshot;
|
||||
for (const event of events) {
|
||||
next = transitionVoiceEngineV2(next, event).snapshot;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function publishedCameraOn(codec: 'av1' | 'h264' | 'vp9'): VoiceEngineV2Snapshot {
|
||||
return applyEvents(createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities()), [
|
||||
{type: 'connection.connectRequested', options: {url: 'wss://voice', token: 'token'}},
|
||||
{type: 'connection.connectSucceeded', operationId: 1},
|
||||
{type: 'camera.publishRequested', options: {deviceId: 'cam-1', codec}},
|
||||
{type: 'camera.publishSucceeded', operationId: 2},
|
||||
{
|
||||
type: 'codecNegotiation.streamRegistered',
|
||||
source: 'camera',
|
||||
streamIdentity: 'cam-stream-1',
|
||||
preferredCodec: codec,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
describe('codec negotiation reducer', () => {
|
||||
it('keeps the preferred codec while no one is watching', () => {
|
||||
const snapshot = publishedCameraOn('av1');
|
||||
expect(snapshot.codecNegotiation.streams.camera?.negotiatedCodec).toBe('av1');
|
||||
expect(snapshot.camera.published?.codec).toBe('av1');
|
||||
});
|
||||
|
||||
it('downgrades and republishes when a viewer who cannot decode the codec starts watching', () => {
|
||||
const snapshot = publishedCameraOn('av1');
|
||||
const transition = transitionVoiceEngineV2(snapshot, {
|
||||
type: 'codecNegotiation.viewerChanged',
|
||||
source: 'camera',
|
||||
viewerIdentity: 'bob',
|
||||
watching: true,
|
||||
supportedVideoCodecs: ['h264', 'vp8'],
|
||||
});
|
||||
|
||||
expect(transition.commands).toEqual([
|
||||
{type: 'camera.publish', operationId: 3, options: {deviceId: 'cam-1', codec: 'h264'}},
|
||||
]);
|
||||
expect(transition.snapshot.codecNegotiation.streams.camera?.negotiatedCodec).toBe('h264');
|
||||
expect(transition.snapshot.codecNegotiation.streams.camera?.constrainedBy).toBe('bob');
|
||||
expect(transition.snapshot.camera.status).toBe('publishing');
|
||||
});
|
||||
|
||||
it('preserves the stream identity across the codec republish', () => {
|
||||
const snapshot = publishedCameraOn('av1');
|
||||
const downgraded = transitionVoiceEngineV2(snapshot, {
|
||||
type: 'codecNegotiation.viewerChanged',
|
||||
source: 'camera',
|
||||
viewerIdentity: 'bob',
|
||||
watching: true,
|
||||
supportedVideoCodecs: ['h264'],
|
||||
});
|
||||
expect(downgraded.snapshot.codecNegotiation.streams.camera?.streamIdentity).toBe('cam-stream-1');
|
||||
});
|
||||
|
||||
it('upgrades back toward the preferred codec when the constraining viewer stops watching', () => {
|
||||
const downgraded = applyEvents(publishedCameraOn('av1'), [
|
||||
{
|
||||
type: 'codecNegotiation.viewerChanged',
|
||||
source: 'camera',
|
||||
viewerIdentity: 'bob',
|
||||
watching: true,
|
||||
supportedVideoCodecs: ['h264'],
|
||||
},
|
||||
{type: 'camera.publishSucceeded', operationId: 3},
|
||||
]);
|
||||
expect(downgraded.camera.published?.codec).toBe('h264');
|
||||
|
||||
const upgraded = transitionVoiceEngineV2(downgraded, {
|
||||
type: 'codecNegotiation.viewerChanged',
|
||||
source: 'camera',
|
||||
viewerIdentity: 'bob',
|
||||
watching: false,
|
||||
supportedVideoCodecs: [],
|
||||
});
|
||||
expect(upgraded.commands).toEqual([
|
||||
{type: 'camera.publish', operationId: 4, options: {deviceId: 'cam-1', codec: 'av1'}},
|
||||
]);
|
||||
expect(upgraded.snapshot.codecNegotiation.streams.camera?.negotiatedCodec).toBe('av1');
|
||||
expect(upgraded.snapshot.codecNegotiation.streams.camera?.constrainedBy).toBeNull();
|
||||
});
|
||||
|
||||
it('does not republish when a newly watching viewer can already decode the codec', () => {
|
||||
const snapshot = publishedCameraOn('h264');
|
||||
const transition = transitionVoiceEngineV2(snapshot, {
|
||||
type: 'codecNegotiation.viewerChanged',
|
||||
source: 'camera',
|
||||
viewerIdentity: 'bob',
|
||||
watching: true,
|
||||
supportedVideoCodecs: ['av1', 'vp9', 'h264', 'vp8'],
|
||||
});
|
||||
expect(transition.commands).toEqual([]);
|
||||
expect(transition.snapshot.codecNegotiation.streams.camera?.negotiatedCodec).toBe('h264');
|
||||
});
|
||||
|
||||
it('clamps the manual override down to a current viewer that cannot decode it', () => {
|
||||
const watching = transitionVoiceEngineV2(publishedCameraOn('h264'), {
|
||||
type: 'codecNegotiation.viewerChanged',
|
||||
source: 'camera',
|
||||
viewerIdentity: 'bob',
|
||||
watching: true,
|
||||
supportedVideoCodecs: ['vp8'],
|
||||
}).snapshot;
|
||||
const settled = applyEvents(watching, [{type: 'camera.publishSucceeded', operationId: 3}]);
|
||||
expect(settled.camera.published?.codec).toBe('vp8');
|
||||
|
||||
const overridden = transitionVoiceEngineV2(settled, {
|
||||
type: 'codecNegotiation.overrideSetRequested',
|
||||
source: 'camera',
|
||||
codec: 'av1',
|
||||
});
|
||||
expect(overridden.commands).toEqual([]);
|
||||
expect(overridden.snapshot.codecNegotiation.overrides.camera).toBe('av1');
|
||||
expect(overridden.snapshot.codecNegotiation.streams.camera?.negotiatedCodec).toBe('vp8');
|
||||
expect(overridden.snapshot.codecNegotiation.streams.camera?.constrainedBy).toBe('bob');
|
||||
});
|
||||
|
||||
it('applies the manual override as a republish when no viewer constrains it', () => {
|
||||
const transition = transitionVoiceEngineV2(publishedCameraOn('h264'), {
|
||||
type: 'codecNegotiation.overrideSetRequested',
|
||||
source: 'camera',
|
||||
codec: 'vp9',
|
||||
});
|
||||
expect(transition.commands).toEqual([
|
||||
{type: 'camera.publish', operationId: 3, options: {deviceId: 'cam-1', codec: 'vp9'}},
|
||||
]);
|
||||
expect(transition.snapshot.codecNegotiation.streams.camera?.negotiatedCodec).toBe('vp9');
|
||||
});
|
||||
|
||||
it('clears a per-source override back to the published preferred codec', () => {
|
||||
const overridden = transitionVoiceEngineV2(publishedCameraOn('h264'), {
|
||||
type: 'codecNegotiation.overrideSetRequested',
|
||||
source: 'camera',
|
||||
codec: 'vp9',
|
||||
});
|
||||
const settled = applyEvents(overridden.snapshot, [{type: 'camera.publishSucceeded', operationId: 3}]);
|
||||
expect(settled.camera.published?.codec).toBe('vp9');
|
||||
|
||||
const cleared = transitionVoiceEngineV2(settled, {
|
||||
type: 'codecNegotiation.overrideSetRequested',
|
||||
source: 'camera',
|
||||
codec: null,
|
||||
});
|
||||
expect(cleared.snapshot.codecNegotiation.overrides.camera).toBeUndefined();
|
||||
expect(cleared.commands).toEqual([
|
||||
{type: 'camera.publish', operationId: 4, options: {deviceId: 'cam-1', codec: 'h264'}},
|
||||
]);
|
||||
expect(cleared.snapshot.codecNegotiation.streams.camera?.negotiatedCodec).toBe('h264');
|
||||
});
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
maxDecodableVoiceEngineV2VideoCodec,
|
||||
planVoiceEngineV2NegotiatedVideoCodec,
|
||||
type VoiceEngineV2CodecViewer,
|
||||
} from '../../policies/codecNegotiation';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {
|
||||
VoiceEngineV2CodecStreamNegotiation,
|
||||
VoiceEngineV2LocalStreamSource,
|
||||
VoiceEngineV2VideoCodec,
|
||||
} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {beginCameraEncodingUpdate} from './camera';
|
||||
import {beginScreenEncodingUpdate} from './screen';
|
||||
|
||||
type VoiceEngineV2CodecNegotiationEvent = Extract<VoiceEngineV2Event, {type: `codecNegotiation.${string}`}>;
|
||||
|
||||
function publishedCodecForSource(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
source: VoiceEngineV2LocalStreamSource,
|
||||
): VoiceEngineV2VideoCodec | null {
|
||||
const media = source === 'camera' ? snapshot.camera : snapshot.screen;
|
||||
if (media.status !== 'published') return null;
|
||||
return media.published?.codec ?? null;
|
||||
}
|
||||
|
||||
function viewersForPolicy(stream: VoiceEngineV2CodecStreamNegotiation): Array<VoiceEngineV2CodecViewer> {
|
||||
const viewers: Array<VoiceEngineV2CodecViewer> = [];
|
||||
for (const identity of Object.keys(stream.viewers).sort()) {
|
||||
viewers.push({identity, maxVideoCodec: stream.viewers[identity] ?? null});
|
||||
}
|
||||
return viewers;
|
||||
}
|
||||
|
||||
function setStream(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
source: VoiceEngineV2LocalStreamSource,
|
||||
stream: VoiceEngineV2CodecStreamNegotiation,
|
||||
): VoiceEngineV2Snapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
codecNegotiation: {
|
||||
...snapshot.codecNegotiation,
|
||||
streams: {...snapshot.codecNegotiation.streams, [source]: stream},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function recomputeStream(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
source: VoiceEngineV2LocalStreamSource,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'recomputeStream snapshot must not be null');
|
||||
const existing = snapshot.codecNegotiation.streams[source];
|
||||
if (!existing) return {snapshot, commands: []};
|
||||
const preferred = snapshot.codecNegotiation.overrides[source] ?? existing.preferredCodec;
|
||||
const plan = planVoiceEngineV2NegotiatedVideoCodec({preferred, viewers: viewersForPolicy(existing)});
|
||||
const updatedStream: VoiceEngineV2CodecStreamNegotiation = {
|
||||
...existing,
|
||||
negotiatedCodec: plan.codec,
|
||||
constrainedBy: plan.constrainedBy,
|
||||
};
|
||||
const withStream = setStream(snapshot, source, updatedStream);
|
||||
const publishedCodec = publishedCodecForSource(withStream, source);
|
||||
if (publishedCodec === null) return {snapshot: withStream, commands: []};
|
||||
if (publishedCodec === plan.codec) return {snapshot: withStream, commands: []};
|
||||
if (source === 'camera') {
|
||||
return beginCameraEncodingUpdate(withStream, {codec: plan.codec});
|
||||
}
|
||||
const published = withStream.screen.published;
|
||||
assert.ok(published != null, 'recomputeStream screen republish requires a published screen');
|
||||
return beginScreenEncodingUpdate(withStream, {
|
||||
captureId: published.captureId,
|
||||
width: published.width,
|
||||
height: published.height,
|
||||
codec: plan.codec,
|
||||
});
|
||||
}
|
||||
|
||||
function applyViewerChanged(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
source: VoiceEngineV2LocalStreamSource,
|
||||
viewerIdentity: string,
|
||||
watching: boolean,
|
||||
supportedVideoCodecs: Array<VoiceEngineV2VideoCodec>,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.equal(typeof viewerIdentity, 'string', 'applyViewerChanged viewerIdentity must be a string');
|
||||
assert.ok(viewerIdentity.length > 0, 'applyViewerChanged viewerIdentity must not be empty');
|
||||
const existing = snapshot.codecNegotiation.streams[source];
|
||||
if (!existing) return {snapshot, commands: []};
|
||||
const viewers = {...existing.viewers};
|
||||
if (watching) {
|
||||
viewers[viewerIdentity] = maxDecodableVoiceEngineV2VideoCodec(supportedVideoCodecs);
|
||||
} else {
|
||||
delete viewers[viewerIdentity];
|
||||
}
|
||||
const withViewers = setStream(snapshot, source, {...existing, viewers});
|
||||
return recomputeStream(withViewers, source);
|
||||
}
|
||||
|
||||
function registerStream(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
source: VoiceEngineV2LocalStreamSource,
|
||||
streamIdentity: string,
|
||||
preferredCodec: VoiceEngineV2VideoCodec,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.equal(typeof streamIdentity, 'string', 'registerStream streamIdentity must be a string');
|
||||
assert.ok(streamIdentity.length > 0, 'registerStream streamIdentity must not be empty');
|
||||
const existing = snapshot.codecNegotiation.streams[source];
|
||||
const stream: VoiceEngineV2CodecStreamNegotiation = {
|
||||
source,
|
||||
streamIdentity,
|
||||
preferredCodec,
|
||||
negotiatedCodec: existing?.negotiatedCodec ?? preferredCodec,
|
||||
constrainedBy: existing?.constrainedBy ?? null,
|
||||
viewers: existing?.viewers ?? {},
|
||||
};
|
||||
return recomputeStream(setStream(snapshot, source, stream), source);
|
||||
}
|
||||
|
||||
function unregisterStream(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
source: VoiceEngineV2LocalStreamSource,
|
||||
): VoiceEngineV2Transition {
|
||||
if (!snapshot.codecNegotiation.streams[source]) return {snapshot, commands: []};
|
||||
const streams = {...snapshot.codecNegotiation.streams};
|
||||
delete streams[source];
|
||||
return {snapshot: {...snapshot, codecNegotiation: {...snapshot.codecNegotiation, streams}}, commands: []};
|
||||
}
|
||||
|
||||
export function transitionCodecNegotiation(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2CodecNegotiationEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionCodecNegotiation snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionCodecNegotiation event must not be null');
|
||||
assert.ok(event.type.startsWith('codecNegotiation.'), 'codecNegotiation reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'codecNegotiation.overrideSetRequested': {
|
||||
const overrides = {...snapshot.codecNegotiation.overrides};
|
||||
if (event.codec === null || event.codec === '') {
|
||||
delete overrides[event.source];
|
||||
} else {
|
||||
overrides[event.source] = event.codec;
|
||||
}
|
||||
const withOverride = {...snapshot, codecNegotiation: {...snapshot.codecNegotiation, overrides}};
|
||||
return recomputeStream(withOverride, event.source);
|
||||
}
|
||||
case 'codecNegotiation.localCapabilityChanged':
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
codecNegotiation: {...snapshot.codecNegotiation, localSupportedVideoCodecs: [...event.supportedVideoCodecs]},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
case 'codecNegotiation.remoteCapabilityChanged':
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
codecNegotiation: {
|
||||
...snapshot.codecNegotiation,
|
||||
remoteSupportedVideoCodecs: {
|
||||
...snapshot.codecNegotiation.remoteSupportedVideoCodecs,
|
||||
[event.identity]: [...event.supportedVideoCodecs],
|
||||
},
|
||||
},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
case 'codecNegotiation.streamRegistered':
|
||||
return registerStream(snapshot, event.source, event.streamIdentity, event.preferredCodec);
|
||||
case 'codecNegotiation.streamUnregistered':
|
||||
return unregisterStream(snapshot, event.source);
|
||||
case 'codecNegotiation.viewerChanged':
|
||||
return applyViewerChanged(
|
||||
snapshot,
|
||||
event.source,
|
||||
event.viewerIdentity,
|
||||
event.watching,
|
||||
event.supportedVideoCodecs,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ function failScreen(
|
||||
};
|
||||
}
|
||||
|
||||
export function beginScreenEncodingUpdate(
|
||||
function beginScreenEncodingUpdate(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
options: VoiceEngineV2ScreenEncodingOptions,
|
||||
): VoiceEngineV2Transition {
|
||||
|
||||
@@ -19,13 +19,11 @@ import type {
|
||||
VoiceEngineV2Error,
|
||||
VoiceEngineV2HardwareEncoderCapabilities,
|
||||
VoiceEngineV2InboundVideoTrack,
|
||||
VoiceEngineV2LocalStreamSource,
|
||||
VoiceEngineV2MediaModel,
|
||||
VoiceEngineV2Model,
|
||||
VoiceEngineV2Participant,
|
||||
VoiceEngineV2PermissionResult,
|
||||
VoiceEngineV2Stats,
|
||||
VoiceEngineV2StreamNegotiationProjection,
|
||||
VoiceEngineV2Track,
|
||||
VoiceEngineV2WatchedStream,
|
||||
} from '../protocol/types';
|
||||
@@ -131,27 +129,6 @@ export function selectVoiceEngineV2WatchedStreams(snapshot: VoiceEngineV2Snapsho
|
||||
return Object.values(snapshot.watchedStreams);
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2StreamNegotiation(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
source: VoiceEngineV2LocalStreamSource,
|
||||
): VoiceEngineV2StreamNegotiationProjection | null {
|
||||
const stream = snapshot.codecNegotiation.streams[source];
|
||||
if (!stream) return null;
|
||||
const media = source === 'camera' ? snapshot.camera : snapshot.screen;
|
||||
const publishedCodec = media.status === 'published' ? (media.published?.codec ?? null) : null;
|
||||
const renegotiating =
|
||||
media.status === 'publishing' || (publishedCodec !== null && publishedCodec !== stream.negotiatedCodec);
|
||||
return {
|
||||
source,
|
||||
streamIdentity: stream.streamIdentity,
|
||||
negotiatedCodec: stream.negotiatedCodec,
|
||||
preferredCodec: stream.preferredCodec,
|
||||
constrainedBy: stream.constrainedBy,
|
||||
renegotiating,
|
||||
viewerCount: Object.keys(stream.viewers).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2DeviceProjection(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2DeviceProjection {
|
||||
const {inventory} = snapshot.devices;
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
VoiceEngineV2AudioControls,
|
||||
VoiceEngineV2CameraOptions,
|
||||
VoiceEngineV2Capabilities,
|
||||
VoiceEngineV2CodecNegotiationState,
|
||||
VoiceEngineV2ConnectionStatus,
|
||||
VoiceEngineV2ConnectOptions,
|
||||
VoiceEngineV2DeviceInventory,
|
||||
@@ -170,7 +169,6 @@ export interface VoiceEngineV2Snapshot {
|
||||
participantVolumes: Record<string, number>;
|
||||
remoteTrackSubscriptions: Record<string, VoiceEngineV2RemoteTrackSubscriptionOptions>;
|
||||
watchedStreams: Record<string, VoiceEngineV2WatchedStream>;
|
||||
codecNegotiation: VoiceEngineV2CodecNegotiationState;
|
||||
stats: VoiceEngineV2Stats | null;
|
||||
statsOperationId: VoiceEngineV2OperationId | null;
|
||||
statsFailure: VoiceEngineV2Error | null;
|
||||
@@ -338,12 +336,6 @@ export function createVoiceEngineV2InitialSnapshot(
|
||||
participantVolumes: {},
|
||||
remoteTrackSubscriptions: {},
|
||||
watchedStreams: {},
|
||||
codecNegotiation: {
|
||||
overrides: {},
|
||||
localSupportedVideoCodecs: [],
|
||||
remoteSupportedVideoCodecs: {},
|
||||
streams: {},
|
||||
},
|
||||
stats: null,
|
||||
statsOperationId: null,
|
||||
statsFailure: null,
|
||||
|
||||
@@ -46,7 +46,6 @@ export {
|
||||
selectVoiceEngineV2StatsPresentationProjection,
|
||||
selectVoiceEngineV2StatsProjection,
|
||||
selectVoiceEngineV2StatsSummary,
|
||||
selectVoiceEngineV2StreamNegotiation,
|
||||
selectVoiceEngineV2WatchedStreams,
|
||||
shouldApplyGatewayVoiceStateEcho,
|
||||
transitionVoiceEngineV2,
|
||||
@@ -105,19 +104,12 @@ export {
|
||||
hasVoiceEngineV2NativeHardwareEncoder,
|
||||
hasVoiceEngineV2NativeNvencEncoder,
|
||||
hasVoiceEngineV2ZeroCopyNativeInput,
|
||||
isMoreEfficientVoiceEngineV2VideoCodec,
|
||||
maxDecodableVoiceEngineV2VideoCodec,
|
||||
normalizeVoiceEngineV2HardwareEncoderCapabilities,
|
||||
planVoiceEngineV2CameraEncodingChange,
|
||||
planVoiceEngineV2NegotiatedVideoCodec,
|
||||
planVoiceEngineV2ScreenEncodingChange,
|
||||
summarizeVoiceEngineV2Stats,
|
||||
unavailableVoiceEngineV2HardwareEncoderCapabilities,
|
||||
VOICE_ENGINE_V2_VIDEO_CODEC_FLOOR,
|
||||
VOICE_ENGINE_V2_VIDEO_CODEC_PREFERENCE,
|
||||
VoiceEngineV2StatsTrackSource,
|
||||
voiceEngineV2VideoCodecRank,
|
||||
worseVoiceEngineV2VideoCodec,
|
||||
} from './policies';
|
||||
export type {
|
||||
CapabilitiesPort,
|
||||
@@ -149,9 +141,6 @@ export type {
|
||||
VoiceEngineV2CameraEncodingOptions,
|
||||
VoiceEngineV2CameraOptions,
|
||||
VoiceEngineV2Capabilities,
|
||||
VoiceEngineV2CodecGossipMessage,
|
||||
VoiceEngineV2CodecNegotiationState,
|
||||
VoiceEngineV2CodecStreamNegotiation,
|
||||
VoiceEngineV2Command,
|
||||
VoiceEngineV2CommandType,
|
||||
VoiceEngineV2ConnectionModel,
|
||||
@@ -217,7 +206,6 @@ export type {
|
||||
VoiceEngineV2SourceLifecycleTransitionKind,
|
||||
VoiceEngineV2Stats,
|
||||
VoiceEngineV2StatsSample,
|
||||
VoiceEngineV2StreamNegotiationProjection,
|
||||
VoiceEngineV2TimerOptions,
|
||||
VoiceEngineV2Track,
|
||||
VoiceEngineV2TrackKind,
|
||||
@@ -230,11 +218,8 @@ export type {
|
||||
VoiceEngineV2WatchedStreamKey,
|
||||
} from './protocol';
|
||||
export {
|
||||
decodeVoiceEngineV2CodecGossip,
|
||||
encodeVoiceEngineV2CodecGossip,
|
||||
getVoiceEngineV2CommandResourceKey,
|
||||
getVoiceEngineV2CommandTypeResourceKey,
|
||||
VOICE_ENGINE_V2_CODEC_GOSSIP_TOPIC,
|
||||
} from './protocol';
|
||||
export type {
|
||||
VoiceEngineV2EventLogEntry,
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
isMoreEfficientVoiceEngineV2VideoCodec,
|
||||
maxDecodableVoiceEngineV2VideoCodec,
|
||||
planVoiceEngineV2NegotiatedVideoCodec,
|
||||
VOICE_ENGINE_V2_VIDEO_CODEC_PREFERENCE,
|
||||
voiceEngineV2VideoCodecRank,
|
||||
worseVoiceEngineV2VideoCodec,
|
||||
} from './codecNegotiation';
|
||||
|
||||
describe('voice engine v2 codec negotiation policy', () => {
|
||||
it('orders codecs most-efficient to most-compatible', () => {
|
||||
expect(VOICE_ENGINE_V2_VIDEO_CODEC_PREFERENCE).toEqual(['av1', 'h265', 'vp9', 'h264', 'vp8']);
|
||||
expect(voiceEngineV2VideoCodecRank('av1')).toBeLessThan(voiceEngineV2VideoCodecRank('h265'));
|
||||
expect(voiceEngineV2VideoCodecRank('h265')).toBeLessThan(voiceEngineV2VideoCodecRank('vp9'));
|
||||
expect(voiceEngineV2VideoCodecRank('vp9')).toBeLessThan(voiceEngineV2VideoCodecRank('h264'));
|
||||
expect(voiceEngineV2VideoCodecRank('h264')).toBeLessThan(voiceEngineV2VideoCodecRank('vp8'));
|
||||
});
|
||||
|
||||
it('ranks unknown and empty codecs past the floor', () => {
|
||||
expect(voiceEngineV2VideoCodecRank('')).toBe(VOICE_ENGINE_V2_VIDEO_CODEC_PREFERENCE.length);
|
||||
expect(voiceEngineV2VideoCodecRank(undefined)).toBe(VOICE_ENGINE_V2_VIDEO_CODEC_PREFERENCE.length);
|
||||
});
|
||||
|
||||
it('compares efficiency and compatibility', () => {
|
||||
expect(isMoreEfficientVoiceEngineV2VideoCodec('av1', 'h264')).toBe(true);
|
||||
expect(isMoreEfficientVoiceEngineV2VideoCodec('vp8', 'vp9')).toBe(false);
|
||||
expect(worseVoiceEngineV2VideoCodec('av1', 'h264')).toBe('h264');
|
||||
expect(worseVoiceEngineV2VideoCodec('vp8', 'vp9')).toBe('vp8');
|
||||
});
|
||||
|
||||
it('computes a viewer max-decodable codec from its supported set', () => {
|
||||
expect(maxDecodableVoiceEngineV2VideoCodec(['h264', 'vp8', 'vp9'])).toBe('vp9');
|
||||
expect(maxDecodableVoiceEngineV2VideoCodec(['vp8', 'av1', 'h264'])).toBe('av1');
|
||||
expect(maxDecodableVoiceEngineV2VideoCodec([])).toBeNull();
|
||||
expect(maxDecodableVoiceEngineV2VideoCodec(['', 'totally-unknown' as never])).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the preferred codec when no viewer constrains it', () => {
|
||||
const plan = planVoiceEngineV2NegotiatedVideoCodec({
|
||||
preferred: 'av1',
|
||||
viewers: [
|
||||
{identity: 'alice', maxVideoCodec: 'av1'},
|
||||
{identity: 'bob', maxVideoCodec: 'av1'},
|
||||
],
|
||||
});
|
||||
expect(plan.codec).toBe('av1');
|
||||
expect(plan.reason).toBe('preferred');
|
||||
expect(plan.constrainedBy).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps down to the worst current viewer when one cannot decode the preferred codec', () => {
|
||||
const plan = planVoiceEngineV2NegotiatedVideoCodec({
|
||||
preferred: 'av1',
|
||||
viewers: [
|
||||
{identity: 'alice', maxVideoCodec: 'av1'},
|
||||
{identity: 'bob', maxVideoCodec: 'h264'},
|
||||
],
|
||||
});
|
||||
expect(plan.codec).toBe('h264');
|
||||
expect(plan.reason).toBe('clampedToViewer');
|
||||
expect(plan.constrainedBy).toBe('bob');
|
||||
});
|
||||
|
||||
it('is order-independent and clamps to the single worst viewer', () => {
|
||||
const viewers = [
|
||||
{identity: 'alice', maxVideoCodec: 'vp9' as const},
|
||||
{identity: 'carol', maxVideoCodec: 'h264' as const},
|
||||
{identity: 'bob', maxVideoCodec: 'h265' as const},
|
||||
];
|
||||
const forward = planVoiceEngineV2NegotiatedVideoCodec({preferred: 'av1', viewers});
|
||||
const reversed = planVoiceEngineV2NegotiatedVideoCodec({preferred: 'av1', viewers: [...viewers].reverse()});
|
||||
expect(forward.codec).toBe('h264');
|
||||
expect(forward.constrainedBy).toBe('carol');
|
||||
expect(reversed.codec).toBe('h264');
|
||||
expect(reversed.constrainedBy).toBe('carol');
|
||||
});
|
||||
|
||||
it('does not constrain on viewers with unknown capability (optimistic until they report)', () => {
|
||||
const plan = planVoiceEngineV2NegotiatedVideoCodec({
|
||||
preferred: 'av1',
|
||||
viewers: [
|
||||
{identity: 'alice', maxVideoCodec: null},
|
||||
{identity: 'bob', maxVideoCodec: 'vp9'},
|
||||
],
|
||||
});
|
||||
expect(plan.codec).toBe('vp9');
|
||||
expect(plan.constrainedBy).toBe('bob');
|
||||
});
|
||||
|
||||
it('never upgrades beyond the preferred codec even if every viewer supports better', () => {
|
||||
const plan = planVoiceEngineV2NegotiatedVideoCodec({
|
||||
preferred: 'h264',
|
||||
viewers: [
|
||||
{identity: 'alice', maxVideoCodec: 'av1'},
|
||||
{identity: 'bob', maxVideoCodec: 'av1'},
|
||||
],
|
||||
});
|
||||
expect(plan.codec).toBe('h264');
|
||||
expect(plan.reason).toBe('preferred');
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2VideoCodec} from '../protocol/types';
|
||||
|
||||
export const VOICE_ENGINE_V2_VIDEO_CODEC_PREFERENCE: ReadonlyArray<VoiceEngineV2VideoCodec> = [
|
||||
'av1',
|
||||
'h265',
|
||||
'vp9',
|
||||
'h264',
|
||||
'vp8',
|
||||
];
|
||||
|
||||
export const VOICE_ENGINE_V2_VIDEO_CODEC_FLOOR: VoiceEngineV2VideoCodec = 'vp8';
|
||||
|
||||
const VIDEO_CODEC_RANK: ReadonlyMap<VoiceEngineV2VideoCodec, number> = new Map(
|
||||
VOICE_ENGINE_V2_VIDEO_CODEC_PREFERENCE.map((codec, index) => [codec, index]),
|
||||
);
|
||||
|
||||
function isKnownVoiceEngineV2VideoCodec(codec: VoiceEngineV2VideoCodec | null | undefined): boolean {
|
||||
if (codec == null) return false;
|
||||
if (codec === '') return false;
|
||||
return VIDEO_CODEC_RANK.has(codec);
|
||||
}
|
||||
|
||||
export function voiceEngineV2VideoCodecRank(codec: VoiceEngineV2VideoCodec | null | undefined): number {
|
||||
if (!isKnownVoiceEngineV2VideoCodec(codec)) return VOICE_ENGINE_V2_VIDEO_CODEC_PREFERENCE.length;
|
||||
const rank = VIDEO_CODEC_RANK.get(codec as VoiceEngineV2VideoCodec);
|
||||
assert.ok(rank !== undefined, 'voiceEngineV2VideoCodecRank known codec must have a rank');
|
||||
return rank;
|
||||
}
|
||||
|
||||
export function isMoreEfficientVoiceEngineV2VideoCodec(
|
||||
candidate: VoiceEngineV2VideoCodec,
|
||||
reference: VoiceEngineV2VideoCodec,
|
||||
): boolean {
|
||||
return voiceEngineV2VideoCodecRank(candidate) < voiceEngineV2VideoCodecRank(reference);
|
||||
}
|
||||
|
||||
export function worseVoiceEngineV2VideoCodec(
|
||||
a: VoiceEngineV2VideoCodec,
|
||||
b: VoiceEngineV2VideoCodec,
|
||||
): VoiceEngineV2VideoCodec {
|
||||
return voiceEngineV2VideoCodecRank(b) > voiceEngineV2VideoCodecRank(a) ? b : a;
|
||||
}
|
||||
|
||||
export function maxDecodableVoiceEngineV2VideoCodec(
|
||||
supported: ReadonlyArray<VoiceEngineV2VideoCodec>,
|
||||
): VoiceEngineV2VideoCodec | null {
|
||||
assert.ok(Array.isArray(supported), 'maxDecodableVoiceEngineV2VideoCodec supported must be an array');
|
||||
let best: VoiceEngineV2VideoCodec | null = null;
|
||||
for (const codec of supported) {
|
||||
if (!isKnownVoiceEngineV2VideoCodec(codec)) continue;
|
||||
if (best === null || isMoreEfficientVoiceEngineV2VideoCodec(codec, best)) {
|
||||
best = codec;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2CodecViewer {
|
||||
identity: string;
|
||||
maxVideoCodec: VoiceEngineV2VideoCodec | null;
|
||||
}
|
||||
|
||||
export type VoiceEngineV2NegotiatedCodecReason = 'preferred' | 'clampedToViewer';
|
||||
|
||||
export interface VoiceEngineV2NegotiatedCodecPlan {
|
||||
codec: VoiceEngineV2VideoCodec;
|
||||
reason: VoiceEngineV2NegotiatedCodecReason;
|
||||
constrainedBy: string | null;
|
||||
}
|
||||
|
||||
interface VoiceEngineV2NegotiatedCodecInput {
|
||||
preferred: VoiceEngineV2VideoCodec;
|
||||
viewers: ReadonlyArray<VoiceEngineV2CodecViewer>;
|
||||
}
|
||||
|
||||
export function planVoiceEngineV2NegotiatedVideoCodec(
|
||||
input: VoiceEngineV2NegotiatedCodecInput,
|
||||
): VoiceEngineV2NegotiatedCodecPlan {
|
||||
assert.ok(input != null, 'planVoiceEngineV2NegotiatedVideoCodec input must not be null');
|
||||
assert.ok(Array.isArray(input.viewers), 'planVoiceEngineV2NegotiatedVideoCodec viewers must be an array');
|
||||
const preferred = isKnownVoiceEngineV2VideoCodec(input.preferred)
|
||||
? input.preferred
|
||||
: VOICE_ENGINE_V2_VIDEO_CODEC_FLOOR;
|
||||
let codec = preferred;
|
||||
let reason: VoiceEngineV2NegotiatedCodecReason = 'preferred';
|
||||
let constrainedBy: string | null = null;
|
||||
for (const viewer of input.viewers) {
|
||||
if (viewer.maxVideoCodec === null) continue;
|
||||
if (!isKnownVoiceEngineV2VideoCodec(viewer.maxVideoCodec)) continue;
|
||||
if (!isMoreEfficientVoiceEngineV2VideoCodec(codec, viewer.maxVideoCodec)) {
|
||||
continue;
|
||||
}
|
||||
codec = viewer.maxVideoCodec;
|
||||
reason = 'clampedToViewer';
|
||||
constrainedBy = viewer.identity;
|
||||
}
|
||||
return {codec, reason, constrainedBy};
|
||||
}
|
||||
@@ -7,20 +7,6 @@ export type {
|
||||
VoiceEngineV2CameraEncodingPlanReason,
|
||||
} from './cameraShare';
|
||||
export {applyVoiceEngineV2CameraEncodingOptions, planVoiceEngineV2CameraEncodingChange} from './cameraShare';
|
||||
export type {
|
||||
VoiceEngineV2CodecViewer,
|
||||
VoiceEngineV2NegotiatedCodecPlan,
|
||||
VoiceEngineV2NegotiatedCodecReason,
|
||||
} from './codecNegotiation';
|
||||
export {
|
||||
isMoreEfficientVoiceEngineV2VideoCodec,
|
||||
maxDecodableVoiceEngineV2VideoCodec,
|
||||
planVoiceEngineV2NegotiatedVideoCodec,
|
||||
VOICE_ENGINE_V2_VIDEO_CODEC_FLOOR,
|
||||
VOICE_ENGINE_V2_VIDEO_CODEC_PREFERENCE,
|
||||
voiceEngineV2VideoCodecRank,
|
||||
worseVoiceEngineV2VideoCodec,
|
||||
} from './codecNegotiation';
|
||||
export {
|
||||
hasVoiceEngineV2NativeHardwareEncoder,
|
||||
hasVoiceEngineV2NativeNvencEncoder,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
decodeVoiceEngineV2CodecGossip,
|
||||
encodeVoiceEngineV2CodecGossip,
|
||||
VOICE_ENGINE_V2_CODEC_GOSSIP_TOPIC,
|
||||
} from './codecGossip';
|
||||
import type {VoiceEngineV2CodecGossipMessage} from './types';
|
||||
|
||||
describe('voice engine v2 codec gossip wire protocol', () => {
|
||||
it('exposes a stable topic', () => {
|
||||
expect(VOICE_ENGINE_V2_CODEC_GOSSIP_TOPIC).toBe('fluxer.codec.v1');
|
||||
});
|
||||
|
||||
it('round-trips a capability message', () => {
|
||||
const message: VoiceEngineV2CodecGossipMessage = {
|
||||
kind: 'codec.capability',
|
||||
supportedVideoCodecs: ['av1', 'vp9', 'h264', 'vp8'],
|
||||
};
|
||||
const decoded = decodeVoiceEngineV2CodecGossip(encodeVoiceEngineV2CodecGossip(message));
|
||||
expect(decoded).toEqual(message);
|
||||
});
|
||||
|
||||
it('round-trips a viewing message', () => {
|
||||
const message: VoiceEngineV2CodecGossipMessage = {
|
||||
kind: 'codec.viewing',
|
||||
source: 'screen',
|
||||
watching: true,
|
||||
supportedVideoCodecs: ['h264', 'vp8'],
|
||||
};
|
||||
const decoded = decodeVoiceEngineV2CodecGossip(encodeVoiceEngineV2CodecGossip(message));
|
||||
expect(decoded).toEqual(message);
|
||||
});
|
||||
|
||||
it('decodes from a raw ArrayBuffer payload', () => {
|
||||
const bytes = encodeVoiceEngineV2CodecGossip({kind: 'codec.capability', supportedVideoCodecs: ['av1']});
|
||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(buffer).set(bytes);
|
||||
expect(decodeVoiceEngineV2CodecGossip(buffer)).toEqual({kind: 'codec.capability', supportedVideoCodecs: ['av1']});
|
||||
});
|
||||
|
||||
it('rejects malformed and adversarial payloads as null', () => {
|
||||
expect(decodeVoiceEngineV2CodecGossip('not json')).toBeNull();
|
||||
expect(decodeVoiceEngineV2CodecGossip('123')).toBeNull();
|
||||
expect(decodeVoiceEngineV2CodecGossip(JSON.stringify({kind: 'other'}))).toBeNull();
|
||||
expect(decodeVoiceEngineV2CodecGossip(JSON.stringify({kind: 'codec.capability'}))).toBeNull();
|
||||
expect(
|
||||
decodeVoiceEngineV2CodecGossip(JSON.stringify({kind: 'codec.capability', supportedVideoCodecs: ['fake']})),
|
||||
).toBeNull();
|
||||
expect(
|
||||
decodeVoiceEngineV2CodecGossip(
|
||||
JSON.stringify({kind: 'codec.viewing', source: 'mic', watching: true, supportedVideoCodecs: []}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
decodeVoiceEngineV2CodecGossip(
|
||||
JSON.stringify({kind: 'codec.viewing', source: 'camera', watching: 'yes', supportedVideoCodecs: []}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an oversized codec list', () => {
|
||||
const tooMany = JSON.stringify({
|
||||
kind: 'codec.capability',
|
||||
supportedVideoCodecs: ['av1', 'av1', 'av1', 'av1', 'av1', 'av1', 'av1', 'av1', 'av1'],
|
||||
});
|
||||
expect(decodeVoiceEngineV2CodecGossip(tooMany)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2CodecGossipMessage, VoiceEngineV2VideoCodec} from './types';
|
||||
|
||||
export const VOICE_ENGINE_V2_CODEC_GOSSIP_TOPIC = 'fluxer.codec.v1';
|
||||
|
||||
const KNOWN_VIDEO_CODECS: ReadonlySet<string> = new Set(['', 'vp8', 'vp9', 'h264', 'h265', 'av1']);
|
||||
const VIDEO_CODECS_CAP = 8;
|
||||
|
||||
function isVideoCodec(value: unknown): value is VoiceEngineV2VideoCodec {
|
||||
return typeof value === 'string' && KNOWN_VIDEO_CODECS.has(value);
|
||||
}
|
||||
|
||||
function normalizeSupportedVideoCodecs(value: unknown): Array<VoiceEngineV2VideoCodec> | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
if (value.length > VIDEO_CODECS_CAP) return null;
|
||||
const codecs: Array<VoiceEngineV2VideoCodec> = [];
|
||||
for (const entry of value) {
|
||||
if (!isVideoCodec(entry)) return null;
|
||||
codecs.push(entry);
|
||||
}
|
||||
return codecs;
|
||||
}
|
||||
|
||||
export function encodeVoiceEngineV2CodecGossip(message: VoiceEngineV2CodecGossipMessage): Uint8Array {
|
||||
assert.ok(message != null, 'encodeVoiceEngineV2CodecGossip message must not be null');
|
||||
assert.ok(message.kind === 'codec.capability' || message.kind === 'codec.viewing', 'unknown gossip message kind');
|
||||
return new TextEncoder().encode(JSON.stringify(message));
|
||||
}
|
||||
|
||||
export function decodeVoiceEngineV2CodecGossip(
|
||||
payload: ArrayBuffer | ArrayBufferView | string,
|
||||
): VoiceEngineV2CodecGossipMessage | null {
|
||||
const text = typeof payload === 'string' ? payload : decodeUtf8(payload);
|
||||
if (text === null) return null;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
const candidate = parsed as Record<string, unknown>;
|
||||
if (candidate.kind === 'codec.capability') {
|
||||
const supportedVideoCodecs = normalizeSupportedVideoCodecs(candidate.supportedVideoCodecs);
|
||||
if (supportedVideoCodecs === null) return null;
|
||||
return {kind: 'codec.capability', supportedVideoCodecs};
|
||||
}
|
||||
if (candidate.kind === 'codec.viewing') {
|
||||
if (candidate.source !== 'camera' && candidate.source !== 'screen') return null;
|
||||
if (typeof candidate.watching !== 'boolean') return null;
|
||||
const supportedVideoCodecs = normalizeSupportedVideoCodecs(candidate.supportedVideoCodecs);
|
||||
if (supportedVideoCodecs === null) return null;
|
||||
return {
|
||||
kind: 'codec.viewing',
|
||||
source: candidate.source,
|
||||
watching: candidate.watching,
|
||||
supportedVideoCodecs,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodeUtf8(payload: ArrayBuffer | ArrayBufferView): string | null {
|
||||
try {
|
||||
const view =
|
||||
payload instanceof ArrayBuffer
|
||||
? new Uint8Array(payload)
|
||||
: new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength);
|
||||
return new TextDecoder('utf-8', {fatal: false}).decode(view);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,6 @@ import type {
|
||||
VoiceEngineV2InboundVideoTrackSubscription,
|
||||
VoiceEngineV2LifecycleReason,
|
||||
VoiceEngineV2LiveKitRoomState,
|
||||
VoiceEngineV2LocalStreamSource,
|
||||
VoiceEngineV2MicrophoneOptions,
|
||||
VoiceEngineV2NativeAudioTapOptions,
|
||||
VoiceEngineV2NativeCaptureFrame,
|
||||
@@ -62,7 +61,6 @@ import type {
|
||||
VoiceEngineV2Stats,
|
||||
VoiceEngineV2TimerOptions,
|
||||
VoiceEngineV2Track,
|
||||
VoiceEngineV2VideoCodec,
|
||||
VoiceEngineV2WatchedStream,
|
||||
VoiceEngineV2WatchedStreamKey,
|
||||
} from './types';
|
||||
@@ -237,31 +235,6 @@ export type VoiceEngineV2Event =
|
||||
| {type: 'room.trackUnpublished'; trackSid: string}
|
||||
| {type: 'room.trackMuted'; trackSid: string}
|
||||
| {type: 'room.trackUnmuted'; trackSid: string}
|
||||
| {
|
||||
type: 'codecNegotiation.overrideSetRequested';
|
||||
source: VoiceEngineV2LocalStreamSource;
|
||||
codec: VoiceEngineV2VideoCodec | null;
|
||||
}
|
||||
| {type: 'codecNegotiation.localCapabilityChanged'; supportedVideoCodecs: Array<VoiceEngineV2VideoCodec>}
|
||||
| {
|
||||
type: 'codecNegotiation.streamRegistered';
|
||||
source: VoiceEngineV2LocalStreamSource;
|
||||
streamIdentity: string;
|
||||
preferredCodec: VoiceEngineV2VideoCodec;
|
||||
}
|
||||
| {type: 'codecNegotiation.streamUnregistered'; source: VoiceEngineV2LocalStreamSource}
|
||||
| {
|
||||
type: 'codecNegotiation.viewerChanged';
|
||||
source: VoiceEngineV2LocalStreamSource;
|
||||
viewerIdentity: string;
|
||||
watching: boolean;
|
||||
supportedVideoCodecs: Array<VoiceEngineV2VideoCodec>;
|
||||
}
|
||||
| {
|
||||
type: 'codecNegotiation.remoteCapabilityChanged';
|
||||
identity: string;
|
||||
supportedVideoCodecs: Array<VoiceEngineV2VideoCodec>;
|
||||
}
|
||||
| {type: 'watchedStream.watchRequested'; stream: VoiceEngineV2WatchedStream}
|
||||
| {type: 'watchedStream.unwatchRequested'; stream: VoiceEngineV2WatchedStreamKey}
|
||||
| {type: 'watchedStreams.replaced'; streams: Array<VoiceEngineV2WatchedStream>}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export {
|
||||
decodeVoiceEngineV2CodecGossip,
|
||||
encodeVoiceEngineV2CodecGossip,
|
||||
VOICE_ENGINE_V2_CODEC_GOSSIP_TOPIC,
|
||||
} from './codecGossip';
|
||||
export type {VoiceEngineV2Command, VoiceEngineV2CommandType} from './commands';
|
||||
export {getVoiceEngineV2CommandResourceKey, getVoiceEngineV2CommandTypeResourceKey} from './commands';
|
||||
export type {
|
||||
@@ -24,9 +19,6 @@ export type {
|
||||
VoiceEngineV2CameraEncodingOptions,
|
||||
VoiceEngineV2CameraOptions,
|
||||
VoiceEngineV2Capabilities,
|
||||
VoiceEngineV2CodecGossipMessage,
|
||||
VoiceEngineV2CodecNegotiationState,
|
||||
VoiceEngineV2CodecStreamNegotiation,
|
||||
VoiceEngineV2ConnectionModel,
|
||||
VoiceEngineV2ConnectionQuality,
|
||||
VoiceEngineV2ConnectionStatus,
|
||||
@@ -86,7 +78,6 @@ export type {
|
||||
VoiceEngineV2SendStats,
|
||||
VoiceEngineV2Stats,
|
||||
VoiceEngineV2StatsSample,
|
||||
VoiceEngineV2StreamNegotiationProjection,
|
||||
VoiceEngineV2TimerOptions,
|
||||
VoiceEngineV2Track,
|
||||
VoiceEngineV2TrackKind,
|
||||
|
||||
@@ -676,41 +676,6 @@ export interface VoiceEngineV2WatchedStreamKey {
|
||||
|
||||
export type VoiceEngineV2LocalStreamSource = 'camera' | 'screen';
|
||||
|
||||
export interface VoiceEngineV2CodecStreamNegotiation {
|
||||
source: VoiceEngineV2LocalStreamSource;
|
||||
streamIdentity: string;
|
||||
preferredCodec: VoiceEngineV2VideoCodec;
|
||||
negotiatedCodec: VoiceEngineV2VideoCodec;
|
||||
constrainedBy: string | null;
|
||||
viewers: Record<string, VoiceEngineV2VideoCodec | null>;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2CodecNegotiationState {
|
||||
overrides: Partial<Record<VoiceEngineV2LocalStreamSource, VoiceEngineV2VideoCodec>>;
|
||||
localSupportedVideoCodecs: Array<VoiceEngineV2VideoCodec>;
|
||||
remoteSupportedVideoCodecs: Record<string, Array<VoiceEngineV2VideoCodec>>;
|
||||
streams: Record<string, VoiceEngineV2CodecStreamNegotiation>;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2StreamNegotiationProjection {
|
||||
source: VoiceEngineV2LocalStreamSource;
|
||||
streamIdentity: string;
|
||||
negotiatedCodec: VoiceEngineV2VideoCodec;
|
||||
preferredCodec: VoiceEngineV2VideoCodec;
|
||||
constrainedBy: string | null;
|
||||
renegotiating: boolean;
|
||||
viewerCount: number;
|
||||
}
|
||||
|
||||
export type VoiceEngineV2CodecGossipMessage =
|
||||
| {kind: 'codec.capability'; supportedVideoCodecs: Array<VoiceEngineV2VideoCodec>}
|
||||
| {
|
||||
kind: 'codec.viewing';
|
||||
source: VoiceEngineV2LocalStreamSource;
|
||||
watching: boolean;
|
||||
supportedVideoCodecs: Array<VoiceEngineV2VideoCodec>;
|
||||
};
|
||||
|
||||
export interface VoiceEngineV2InboundVideoTrackSubscription {
|
||||
participantSid: string;
|
||||
participantIdentity?: string;
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
VoiceEngineV2GatewayDesiredVoiceState,
|
||||
VoiceEngineV2GatewayVoiceStateWrite,
|
||||
VoiceEngineV2LifecycleReason,
|
||||
VoiceEngineV2LocalStreamSource,
|
||||
VoiceEngineV2MicrophoneOptions,
|
||||
VoiceEngineV2Model,
|
||||
VoiceEngineV2NativeAudioTapOptions,
|
||||
@@ -27,7 +26,6 @@ import type {
|
||||
VoiceEngineV2ScreenEncodingOptions,
|
||||
VoiceEngineV2ScreenOptions,
|
||||
VoiceEngineV2TimerOptions,
|
||||
VoiceEngineV2VideoCodec,
|
||||
VoiceEngineV2WatchedStream,
|
||||
VoiceEngineV2WatchedStreamKey,
|
||||
} from '../protocol';
|
||||
@@ -113,45 +111,6 @@ export class VoiceEngineV2Controller {
|
||||
this.runtime.dispatch({type: 'camera.updateEncodingRequested', options});
|
||||
}
|
||||
|
||||
setVideoCodecOverride(source: VoiceEngineV2LocalStreamSource, codec: VoiceEngineV2VideoCodec | null): void {
|
||||
this.runtime.dispatch({type: 'codecNegotiation.overrideSetRequested', source, codec});
|
||||
}
|
||||
|
||||
setLocalVideoCodecCapability(supportedVideoCodecs: Array<VoiceEngineV2VideoCodec>): void {
|
||||
this.runtime.dispatch({type: 'codecNegotiation.localCapabilityChanged', supportedVideoCodecs});
|
||||
}
|
||||
|
||||
registerLocalStreamCodec(
|
||||
source: VoiceEngineV2LocalStreamSource,
|
||||
streamIdentity: string,
|
||||
preferredCodec: VoiceEngineV2VideoCodec,
|
||||
): void {
|
||||
this.runtime.dispatch({type: 'codecNegotiation.streamRegistered', source, streamIdentity, preferredCodec});
|
||||
}
|
||||
|
||||
unregisterLocalStreamCodec(source: VoiceEngineV2LocalStreamSource): void {
|
||||
this.runtime.dispatch({type: 'codecNegotiation.streamUnregistered', source});
|
||||
}
|
||||
|
||||
reportStreamViewer(
|
||||
source: VoiceEngineV2LocalStreamSource,
|
||||
viewerIdentity: string,
|
||||
watching: boolean,
|
||||
supportedVideoCodecs: Array<VoiceEngineV2VideoCodec>,
|
||||
): void {
|
||||
this.runtime.dispatch({
|
||||
type: 'codecNegotiation.viewerChanged',
|
||||
source,
|
||||
viewerIdentity,
|
||||
watching,
|
||||
supportedVideoCodecs,
|
||||
});
|
||||
}
|
||||
|
||||
reportRemoteVideoCodecCapability(identity: string, supportedVideoCodecs: Array<VoiceEngineV2VideoCodec>): void {
|
||||
this.runtime.dispatch({type: 'codecNegotiation.remoteCapabilityChanged', identity, supportedVideoCodecs});
|
||||
}
|
||||
|
||||
unpublishCamera(options?: VoiceEngineV2CameraOptions): void {
|
||||
this.runtime.dispatch({type: 'camera.unpublishRequested', options});
|
||||
}
|
||||
|
||||
@@ -84,40 +84,6 @@ export function runVoiceEngineV2ConformanceSuite(
|
||||
});
|
||||
});
|
||||
|
||||
it('renegotiates the publication codec in place when a viewer cannot decode it', async () => {
|
||||
const {implementation, driver} = createSubject();
|
||||
const runtime = createConformanceRuntime(implementation);
|
||||
|
||||
runtime.dispatch({type: 'connection.connectRequested', options: {url: 'wss://voice', token: 'token'}});
|
||||
await waitForRuntime();
|
||||
runtime.dispatch({type: 'camera.publishRequested', options: {deviceId: 'cam-1', codec: 'av1'}});
|
||||
await waitForRuntime();
|
||||
runtime.dispatch({
|
||||
type: 'codecNegotiation.streamRegistered',
|
||||
source: 'camera',
|
||||
streamIdentity: 'cam-stream-1',
|
||||
preferredCodec: 'av1',
|
||||
});
|
||||
await waitForRuntime();
|
||||
runtime.dispatch({
|
||||
type: 'codecNegotiation.viewerChanged',
|
||||
source: 'camera',
|
||||
viewerIdentity: 'bob',
|
||||
watching: true,
|
||||
supportedVideoCodecs: ['h264', 'vp8'],
|
||||
});
|
||||
await waitForRuntime();
|
||||
|
||||
expect(driver.calls).toEqual([
|
||||
{type: 'connect', options: {url: 'wss://voice', token: 'token'}},
|
||||
{type: 'publishCamera', options: {deviceId: 'cam-1', codec: 'av1'}},
|
||||
{type: 'publishCamera', options: {deviceId: 'cam-1', codec: 'h264'}},
|
||||
]);
|
||||
expect(runtime.snapshot.camera.published?.codec).toBe('h264');
|
||||
expect(runtime.snapshot.codecNegotiation.streams.camera?.streamIdentity).toBe('cam-stream-1');
|
||||
expect(runtime.snapshot.codecNegotiation.streams.camera?.constrainedBy).toBe('bob');
|
||||
});
|
||||
|
||||
it('adds and removes screen-share audio without republishing the screen video', async () => {
|
||||
const {implementation, driver} = createSubject();
|
||||
const runtime = createConformanceRuntime(implementation);
|
||||
|
||||
Reference in New Issue
Block a user