Add native self-hosted instance connection to fluxer_desktop
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.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"measured_at": "85e057a273fd",
|
||||
"host": "darwin-arm64-apple-silicon",
|
||||
"regression_budget_percent": 5.0,
|
||||
"note": "Vitest bench medians captured as `mean_ms` (mean over samples). check-regression.sh treats `mean_ms` like `median_ns` after scaling. p75 captured as a secondary stability hint.",
|
||||
"benches": {
|
||||
"src/runtime/VoiceEngineV2Runtime.bench.ts > dispatch inboundVideo.frameReceived": {
|
||||
"mean_ms": 0.3517,
|
||||
"p75_ms": 0.4266,
|
||||
"hz": 2843.2
|
||||
},
|
||||
"src/runtime/VoiceEngineV2Runtime.bench.ts > dispatch microphone.publishRequested": {
|
||||
"mean_ms": 0.4786,
|
||||
"p75_ms": 0.534,
|
||||
"hz": 2089.33
|
||||
},
|
||||
"src/runtime/VoiceEngineV2Runtime.bench.ts > dispatch screen.publishRequested": {
|
||||
"mean_ms": 0.474,
|
||||
"p75_ms": 0.5327,
|
||||
"hz": 2109.75
|
||||
},
|
||||
"src/runtime/VoiceEngineV2Runtime.bench.ts > dispatch room.participantJoined": {
|
||||
"mean_ms": 0.344,
|
||||
"p75_ms": 0.4121,
|
||||
"hz": 2906.83
|
||||
},
|
||||
"src/runtime/VoiceEngineV2Runtime.bench.ts > dispatch stats.collected": {
|
||||
"mean_ms": 0.3451,
|
||||
"p75_ms": 0.4295,
|
||||
"hz": 2897.74
|
||||
},
|
||||
"src/core/reducer.bench.ts > inboundVideo.frameReceived": {
|
||||
"mean_ms": 0.0001,
|
||||
"p75_ms": 0.0001,
|
||||
"hz": 7877275.4
|
||||
},
|
||||
"src/core/reducer.bench.ts > microphone.publishRequested": {
|
||||
"mean_ms": 0.0002,
|
||||
"p75_ms": 0.0002,
|
||||
"hz": 5768974.56
|
||||
},
|
||||
"src/core/reducer.bench.ts > screen.publishRequested": {
|
||||
"mean_ms": 0.0002,
|
||||
"p75_ms": 0.0002,
|
||||
"hz": 6149233.82
|
||||
},
|
||||
"src/core/reducer.bench.ts > room.participantJoined": {
|
||||
"mean_ms": 0.0001,
|
||||
"p75_ms": 0.0001,
|
||||
"hz": 11101158.62
|
||||
},
|
||||
"src/core/reducer.bench.ts > stats.collected": {
|
||||
"mean_ms": 0.0,
|
||||
"p75_ms": 0.0,
|
||||
"hz": 28019021.94
|
||||
},
|
||||
"src/runtime/snapshot.bench.ts > JSON.stringify snapshot (8 participants × 4 tracks)": {
|
||||
"mean_ms": 0.0174,
|
||||
"p75_ms": 0.018,
|
||||
"hz": 57366.32
|
||||
},
|
||||
"src/runtime/snapshot.bench.ts > JSON.parse snapshot (8 participants × 4 tracks)": {
|
||||
"mean_ms": 0.0168,
|
||||
"p75_ms": 0.0167,
|
||||
"hz": 59607.57
|
||||
},
|
||||
"src/runtime/snapshot.bench.ts > JSON.stringify + JSON.parse round-trip": {
|
||||
"mean_ms": 0.0358,
|
||||
"p75_ms": 0.0356,
|
||||
"hz": 27897.14
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "basic_session",
|
||||
"events": [
|
||||
{
|
||||
"type": "microphone.publishRequested",
|
||||
"options": {
|
||||
"deviceId": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "connection.connectRequested",
|
||||
"options": {
|
||||
"url": "wss://voice.example.test",
|
||||
"token": "token-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "connection.connectSucceeded",
|
||||
"operationId": 1
|
||||
},
|
||||
{
|
||||
"type": "microphone.publishSucceeded",
|
||||
"operationId": 2
|
||||
},
|
||||
{
|
||||
"type": "gateway.voiceStateUpdated",
|
||||
"voiceState": {
|
||||
"guildId": "guild-1",
|
||||
"channelId": "channel-1",
|
||||
"userId": "user-1",
|
||||
"sessionId": "session-1",
|
||||
"selfMute": false,
|
||||
"selfDeaf": false,
|
||||
"selfVideo": false,
|
||||
"selfStream": false,
|
||||
"suppress": false,
|
||||
"requestToSpeakTimestamp": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 3,
|
||||
"connectionStatus": "connected",
|
||||
"activeUrl": "wss://voice.example.test",
|
||||
"microphoneStatus": "published",
|
||||
"microphoneDeviceId": "default",
|
||||
"gatewayChannelId": "channel-1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"version": 18,
|
||||
"methods": [
|
||||
"isSupported",
|
||||
"getCapabilities",
|
||||
"prewarm",
|
||||
"getHardwareEncoderCapabilities",
|
||||
"connect",
|
||||
"disconnect",
|
||||
"isConnected",
|
||||
"publishMicrophone",
|
||||
"pushPcm",
|
||||
"publishScreen",
|
||||
"updateScreenShareEncoding",
|
||||
"unpublishScreen",
|
||||
"publishScreenAudio",
|
||||
"pushScreenAudioPcm",
|
||||
"pushScreenAudioFloat",
|
||||
"unpublishScreenAudio",
|
||||
"setMicEnabled",
|
||||
"setSpeakingDetection",
|
||||
"listAudioInputDevices",
|
||||
"listAudioOutputDevices",
|
||||
"setAudioOutputDevice",
|
||||
"setParticipantVolume",
|
||||
"setRemoteTrackSubscription",
|
||||
"publishData",
|
||||
"listCameraDevices",
|
||||
"publishCamera",
|
||||
"updateCameraCapture",
|
||||
"publishNativeCameraSink",
|
||||
"publishProcessedCamera",
|
||||
"pushProcessedCameraFrame",
|
||||
"pushCameraBackgroundFrame",
|
||||
"clearCameraBackgroundFrame",
|
||||
"publishDeviceScreenShare",
|
||||
"unpublishCamera",
|
||||
"isPublishingCamera",
|
||||
"startCameraPreview",
|
||||
"stopCameraPreview",
|
||||
"getConnectionStats",
|
||||
"getVoiceEngineReadiness",
|
||||
"getAudioDeviceModuleState",
|
||||
"onEvent",
|
||||
"onVideoFrame"
|
||||
],
|
||||
"ipcChannels": {
|
||||
"isSupported": "voice-engine-v2:is-supported",
|
||||
"getCapabilities": "voice-engine-v2:get-capabilities",
|
||||
"prewarm": "voice-engine-v2:prewarm",
|
||||
"getHardwareEncoderCapabilities": "voice-engine-v2:get-hardware-encoder-capabilities",
|
||||
"connect": "voice-engine-v2:connect",
|
||||
"disconnect": "voice-engine-v2:disconnect",
|
||||
"isConnected": "voice-engine-v2:is-connected",
|
||||
"publishMicrophone": "voice-engine-v2:publish-microphone",
|
||||
"pushPcm": "voice-engine-v2:push-pcm",
|
||||
"publishScreen": "voice-engine-v2:publish-screen",
|
||||
"updateScreenShareEncoding": "voice-engine-v2:update-screen-share-encoding",
|
||||
"unpublishScreen": "voice-engine-v2:unpublish-screen",
|
||||
"publishScreenAudio": "voice-engine-v2:publish-screen-audio",
|
||||
"pushScreenAudioPcm": "voice-engine-v2:push-screen-audio-pcm",
|
||||
"pushScreenAudioFloat": "voice-engine-v2:push-screen-audio-float",
|
||||
"unpublishScreenAudio": "voice-engine-v2:unpublish-screen-audio",
|
||||
"setMicEnabled": "voice-engine-v2:set-mic-enabled",
|
||||
"setSpeakingDetection": "voice-engine-v2:set-speaking-detection",
|
||||
"listAudioInputDevices": "voice-engine-v2:list-audio-input-devices",
|
||||
"listAudioOutputDevices": "voice-engine-v2:list-audio-output-devices",
|
||||
"setAudioOutputDevice": "voice-engine-v2:set-audio-output-device",
|
||||
"setParticipantVolume": "voice-engine-v2:set-participant-volume",
|
||||
"setRemoteTrackSubscription": "voice-engine-v2:set-remote-track-subscription",
|
||||
"publishData": "voice-engine-v2:publish-data",
|
||||
"listCameraDevices": "voice-engine-v2:list-camera-devices",
|
||||
"publishCamera": "voice-engine-v2:publish-camera",
|
||||
"updateCameraCapture": "voice-engine-v2:update-camera-capture",
|
||||
"publishNativeCameraSink": "voice-engine-v2:publish-native-camera-sink",
|
||||
"publishProcessedCamera": "voice-engine-v2:publish-processed-camera",
|
||||
"pushProcessedCameraFrame": "voice-engine-v2:push-processed-camera-frame",
|
||||
"pushCameraBackgroundFrame": "voice-engine-v2:push-camera-background-frame",
|
||||
"clearCameraBackgroundFrame": "voice-engine-v2:clear-camera-background-frame",
|
||||
"publishDeviceScreenShare": "voice-engine-v2:publish-device-screen-share",
|
||||
"unpublishCamera": "voice-engine-v2:unpublish-camera",
|
||||
"isPublishingCamera": "voice-engine-v2:is-publishing-camera",
|
||||
"startCameraPreview": "voice-engine-v2:start-camera-preview",
|
||||
"stopCameraPreview": "voice-engine-v2:stop-camera-preview",
|
||||
"getConnectionStats": "voice-engine-v2:get-connection-stats",
|
||||
"getVoiceEngineReadiness": "voice-engine-v2:get-readiness",
|
||||
"getAudioDeviceModuleState": "voice-engine-v2:get-audio-device-module-state"
|
||||
},
|
||||
"eventChannels": {
|
||||
"event": "voice-engine-v2:event",
|
||||
"videoFrame": "voice-engine-v2:video-frame"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "camera_in_place_update",
|
||||
"events": [
|
||||
{
|
||||
"type": "connection.connectRequested",
|
||||
"options": {"url": "wss://voice.example.test", "token": "token-camera"}
|
||||
},
|
||||
{"type": "connection.connectSucceeded", "operationId": 1},
|
||||
{
|
||||
"type": "camera.publishRequested",
|
||||
"options": {"deviceId": "camera-1", "width": 1280, "height": 720, "mirror": false}
|
||||
},
|
||||
{"type": "camera.publishSucceeded", "operationId": 2},
|
||||
{
|
||||
"type": "camera.updateEncodingRequested",
|
||||
"options": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"frameRate": 60,
|
||||
"mirror": true,
|
||||
"backgroundMode": "blur",
|
||||
"backgroundBlurStrength": 60
|
||||
}
|
||||
},
|
||||
{"type": "camera.updateEncodingSucceeded", "operationId": 3}
|
||||
],
|
||||
"expectedCommands": [
|
||||
{
|
||||
"type": "connection.connect",
|
||||
"operationId": 1,
|
||||
"options": {"url": "wss://voice.example.test", "token": "token-camera"}
|
||||
},
|
||||
{
|
||||
"type": "camera.publish",
|
||||
"operationId": 2,
|
||||
"options": {"deviceId": "camera-1", "width": 1280, "height": 720, "mirror": false}
|
||||
},
|
||||
{
|
||||
"type": "camera.updateEncoding",
|
||||
"operationId": 3,
|
||||
"options": {
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"frameRate": 60,
|
||||
"mirror": true,
|
||||
"backgroundMode": "blur",
|
||||
"backgroundBlurStrength": 60
|
||||
}
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 4,
|
||||
"cameraStatus": "published",
|
||||
"cameraDeviceId": "camera-1",
|
||||
"cameraWidth": 1920,
|
||||
"cameraFrameRate": 60,
|
||||
"cameraMirror": true,
|
||||
"cameraBackgroundMode": "blur",
|
||||
"lastFailureCode": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "capabilities_lifecycle",
|
||||
"events": [
|
||||
{
|
||||
"type": "capabilities.changed",
|
||||
"capabilities": {
|
||||
"connect": true,
|
||||
"microphone": true,
|
||||
"camera": true,
|
||||
"screen": true,
|
||||
"screenAudio": true,
|
||||
"outputDevice": true,
|
||||
"participantVolume": true,
|
||||
"remoteTrackSubscription": true,
|
||||
"dataChannel": true,
|
||||
"stats": true,
|
||||
"nativeVideoFrames": true,
|
||||
"hardwareEncoding": true,
|
||||
"zeroCopyScreenTransport": true,
|
||||
"nativeAudioTaps": true
|
||||
}
|
||||
},
|
||||
{"type": "capabilities.hardwareEncoderQueryRequested"},
|
||||
{
|
||||
"type": "capabilities.hardwareEncoderChanged",
|
||||
"operationId": 1,
|
||||
"capabilities": {
|
||||
"available": true,
|
||||
"backend": "nvenc",
|
||||
"compiled": true,
|
||||
"runtime": true,
|
||||
"codecs": ["h264", "hevc"],
|
||||
"zeroCopy": true,
|
||||
"nativeInputs": ["d3d11-texture"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"expectedCommands": [[], [{"type": "capabilities.queryHardwareEncoder", "operationId": 1}], []],
|
||||
"expected": {
|
||||
"nextOperationId": 2,
|
||||
"hardwareEncoderOperationId": null,
|
||||
"hardwareEncoderFailureCode": null,
|
||||
"hardwareEncoderBackend": "nvenc",
|
||||
"hasZeroCopyNativeInput": true,
|
||||
"hasNativeNvencH264": true,
|
||||
"hasNativeNvencH265": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "codec_republish",
|
||||
"events": [
|
||||
{
|
||||
"type": "connection.connectRequested",
|
||||
"options": {
|
||||
"url": "wss://voice.example.test",
|
||||
"token": "token-codec"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "connection.connectSucceeded",
|
||||
"operationId": 1
|
||||
},
|
||||
{
|
||||
"type": "screen.publishRequested",
|
||||
"options": {
|
||||
"captureId": "capture-screen-1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"codec": "h264",
|
||||
"hardwareEncoding": true,
|
||||
"zeroCopyRequired": true,
|
||||
"maxFramerate": 60
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "screen.publishSucceeded",
|
||||
"operationId": 2
|
||||
},
|
||||
{
|
||||
"type": "screen.updateEncodingRequested",
|
||||
"options": {
|
||||
"captureId": "capture-screen-1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"codec": "h265",
|
||||
"hardwareEncoding": true,
|
||||
"zeroCopyRequired": true,
|
||||
"frameRate": 60
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "screen.publishSucceeded",
|
||||
"operationId": 3
|
||||
}
|
||||
],
|
||||
"expectedCommands": [
|
||||
{
|
||||
"type": "connection.connect",
|
||||
"operationId": 1,
|
||||
"options": {
|
||||
"url": "wss://voice.example.test",
|
||||
"token": "token-codec"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "screen.publish",
|
||||
"operationId": 2,
|
||||
"options": {
|
||||
"captureId": "capture-screen-1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"codec": "h264",
|
||||
"hardwareEncoding": true,
|
||||
"zeroCopyRequired": true,
|
||||
"maxFramerate": 60
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "screen.publish",
|
||||
"operationId": 3,
|
||||
"options": {
|
||||
"captureId": "capture-screen-1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"codec": "h265",
|
||||
"hardwareEncoding": true,
|
||||
"zeroCopyRequired": true,
|
||||
"maxFramerate": 60
|
||||
}
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 4,
|
||||
"screenStatus": "published",
|
||||
"screenCaptureId": "capture-screen-1",
|
||||
"screenCodec": "h265",
|
||||
"screenHardwareEncoding": true,
|
||||
"screenZeroCopyRequired": true,
|
||||
"lastFailureCode": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "audio input selection and hotplug republish microphone when connected",
|
||||
"events": [
|
||||
{"type": "connection.connectRequested", "options": {"url": "wss://voice", "token": "token"}},
|
||||
{"type": "connection.connectSucceeded", "operationId": 1},
|
||||
{"type": "microphone.publishRequested", "options": {"deviceId": "mic-a"}},
|
||||
{"type": "microphone.publishSucceeded", "operationId": 2},
|
||||
{"type": "devices.selectAudioInputRequested", "deviceId": "mic-b"},
|
||||
{"type": "command.succeeded", "operationId": 3, "commandType": "devices.selectAudioInput"},
|
||||
{"type": "microphone.publishSucceeded", "operationId": 4},
|
||||
{
|
||||
"type": "devices.changed",
|
||||
"operationId": null,
|
||||
"reason": "hotplug",
|
||||
"devices": {
|
||||
"audioInputs": [
|
||||
{"deviceId": "mic-b", "label": "Desk mic", "isDefault": false},
|
||||
{"deviceId": "mic-c", "label": "Headset mic", "isDefault": true}
|
||||
],
|
||||
"audioOutputs": [],
|
||||
"cameras": [],
|
||||
"selectedAudioInputId": "mic-c",
|
||||
"selectedAudioOutputId": null,
|
||||
"selectedCameraId": null
|
||||
}
|
||||
},
|
||||
{"type": "microphone.publishSucceeded", "operationId": 5}
|
||||
],
|
||||
"expectedCommands": [
|
||||
{"type": "connection.connect", "operationId": 1, "options": {"url": "wss://voice", "token": "token"}},
|
||||
{"type": "microphone.publish", "operationId": 2, "options": {"deviceId": "mic-a"}},
|
||||
{"type": "devices.selectAudioInput", "operationId": 3, "deviceId": "mic-b"},
|
||||
{"type": "microphone.publish", "operationId": 4, "options": {"deviceId": "mic-b"}},
|
||||
{"type": "microphone.publish", "operationId": 5, "options": {"deviceId": "mic-c"}}
|
||||
],
|
||||
"checkpoints": [
|
||||
{
|
||||
"afterEvent": 5,
|
||||
"microphoneStatus": "publishing",
|
||||
"desiredDeviceId": "mic-b",
|
||||
"selectedAudioInputId": "mic-b"
|
||||
},
|
||||
{
|
||||
"afterEvent": 8,
|
||||
"microphoneStatus": "publishing",
|
||||
"desiredDeviceId": "mic-c",
|
||||
"selectedAudioInputId": "mic-c"
|
||||
}
|
||||
],
|
||||
"expectedSnapshot": {
|
||||
"nextOperationId": 6,
|
||||
"connectionStatus": "connected",
|
||||
"microphoneStatus": "published",
|
||||
"microphoneEnabled": true,
|
||||
"localSpeakingOverride": null,
|
||||
"publishedDeviceId": "mic-c",
|
||||
"desiredDeviceId": "mic-c",
|
||||
"selectedAudioInputId": "mic-c",
|
||||
"lastFailureCode": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "device_inventory",
|
||||
"events": [
|
||||
{"type": "devices.enumerateRequested"},
|
||||
{
|
||||
"type": "devices.changed",
|
||||
"operationId": 1,
|
||||
"reason": "initial",
|
||||
"devices": {
|
||||
"audioInputs": [
|
||||
{"deviceId": "mic-1", "label": "Built-in Microphone", "isDefault": true, "role": "default"},
|
||||
{
|
||||
"deviceId": "mic-2",
|
||||
"label": "USB Microphone",
|
||||
"isDefault": false,
|
||||
"role": "communications",
|
||||
"endpointLabel": "USB Audio",
|
||||
"isDefaultRoute": true
|
||||
}
|
||||
],
|
||||
"audioOutputs": [{"deviceId": "speaker-1", "label": "Built-in Speakers", "isDefault": true, "role": "default"}],
|
||||
"cameras": [{"deviceId": "camera-1", "label": "FaceTime Camera", "description": "Integrated camera"}],
|
||||
"selectedAudioInputId": "mic-1",
|
||||
"selectedAudioOutputId": "speaker-1",
|
||||
"selectedCameraId": "camera-1"
|
||||
}
|
||||
},
|
||||
{"type": "devices.selectAudioInputRequested", "deviceId": "mic-2"},
|
||||
{"type": "command.succeeded", "operationId": 2, "commandType": "devices.selectAudioInput"},
|
||||
{
|
||||
"type": "devices.changed",
|
||||
"operationId": null,
|
||||
"reason": "hotplug",
|
||||
"devices": {
|
||||
"audioInputs": [
|
||||
{
|
||||
"deviceId": "mic-2",
|
||||
"label": "USB Microphone",
|
||||
"isDefault": true,
|
||||
"role": "communications",
|
||||
"endpointLabel": "USB Audio",
|
||||
"isDefaultRoute": true
|
||||
}
|
||||
],
|
||||
"audioOutputs": [
|
||||
{"deviceId": "speaker-1", "label": "Built-in Speakers", "isDefault": true, "role": "default"},
|
||||
{"deviceId": "headset-1", "label": "USB Headset", "isDefault": false, "role": "endpoint"}
|
||||
],
|
||||
"cameras": [
|
||||
{"deviceId": "camera-1", "label": "FaceTime Camera", "description": "Integrated camera"},
|
||||
{"deviceId": "camera-2", "label": "Capture Card", "description": "USB capture"}
|
||||
],
|
||||
"selectedAudioInputId": "mic-2",
|
||||
"selectedAudioOutputId": "speaker-1",
|
||||
"selectedCameraId": "camera-1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expectedCommands": [
|
||||
[{"type": "devices.enumerate", "operationId": 1}],
|
||||
[],
|
||||
[{"type": "devices.selectAudioInput", "operationId": 2, "deviceId": "mic-2"}],
|
||||
[],
|
||||
[]
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 3,
|
||||
"deviceOperationId": null,
|
||||
"failureCode": null,
|
||||
"selectedAudioInputId": "mic-2",
|
||||
"selectedAudioInputLabel": "USB Microphone",
|
||||
"selectedAudioInputRole": "communications",
|
||||
"audioOutputCount": 2,
|
||||
"cameraCount": 2,
|
||||
"operationStatuses": {"1": "succeeded", "2": "succeeded"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "diagnostics_lifecycle",
|
||||
"events": [
|
||||
{
|
||||
"type": "diagnostics.logRequested",
|
||||
"entry": {
|
||||
"id": "diag-1",
|
||||
"atMs": 1000,
|
||||
"level": "info",
|
||||
"code": "voice.session.started",
|
||||
"message": "Voice session started"
|
||||
}
|
||||
},
|
||||
{"type": "command.succeeded", "operationId": 1, "commandType": "diagnostics.log"},
|
||||
{
|
||||
"type": "diagnostics.logged",
|
||||
"operationId": null,
|
||||
"entry": {
|
||||
"id": "diag-2",
|
||||
"atMs": 1010,
|
||||
"level": "warn",
|
||||
"code": "voice.stats.delayed",
|
||||
"message": "Stats collection was delayed",
|
||||
"detail": {"delayMs": 250}
|
||||
}
|
||||
}
|
||||
],
|
||||
"expectedCommands": [
|
||||
[
|
||||
{
|
||||
"type": "diagnostics.log",
|
||||
"operationId": 1,
|
||||
"entry": {
|
||||
"id": "diag-1",
|
||||
"atMs": 1000,
|
||||
"level": "info",
|
||||
"code": "voice.session.started",
|
||||
"message": "Voice session started"
|
||||
}
|
||||
}
|
||||
],
|
||||
[],
|
||||
[]
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 2,
|
||||
"entryIds": ["diag-1", "diag-2"],
|
||||
"lastCode": "voice.stats.delayed",
|
||||
"operationStatuses": {"1": "succeeded"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "e2ee_lifecycle",
|
||||
"events": [
|
||||
{"type": "e2ee.setEnabledRequested", "enabled": true, "keyId": "key-a"},
|
||||
{"type": "e2ee.setEnabledRequested", "enabled": false},
|
||||
{"type": "e2ee.enabled", "operationId": 1, "keyId": "key-a"},
|
||||
{"type": "e2ee.disabled", "operationId": 2},
|
||||
{"type": "e2ee.enabled", "operationId": null, "keyId": "key-b"},
|
||||
{
|
||||
"type": "e2ee.failed",
|
||||
"operationId": null,
|
||||
"error": {
|
||||
"code": "implementationError",
|
||||
"message": "Remote participant key material could not be applied",
|
||||
"capability": "e2ee"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expectedCommands": [
|
||||
[{"type": "e2ee.setEnabled", "operationId": 1, "enabled": true, "keyId": "key-a"}],
|
||||
[{"type": "e2ee.setEnabled", "operationId": 2, "enabled": false}],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[]
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 3,
|
||||
"status": "failed",
|
||||
"keyId": "key-b",
|
||||
"operationId": null,
|
||||
"failureCode": "implementationError"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "gateway_connection",
|
||||
"description": "Join writes gateway voice state, observes gateway state, then connects the media session.",
|
||||
"steps": [
|
||||
{
|
||||
"name": "request gateway join",
|
||||
"event": {
|
||||
"type": "gateway.voiceStateWriteRequested",
|
||||
"options": {
|
||||
"guildId": "guild-1",
|
||||
"channelId": "channel-1",
|
||||
"selfMute": false,
|
||||
"selfDeaf": false
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"type": "gateway.voiceState.write",
|
||||
"operationId": 1,
|
||||
"options": {
|
||||
"guildId": "guild-1",
|
||||
"channelId": "channel-1",
|
||||
"selfMute": false,
|
||||
"selfDeaf": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gateway join write succeeds",
|
||||
"event": {
|
||||
"type": "gateway.voiceStateWriteSucceeded",
|
||||
"operationId": 1
|
||||
},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "gateway voice state arrives",
|
||||
"event": {
|
||||
"type": "gateway.voiceStateUpdated",
|
||||
"voiceState": {
|
||||
"guildId": "guild-1",
|
||||
"channelId": "channel-1",
|
||||
"userId": "user-1",
|
||||
"sessionId": "session-1",
|
||||
"selfMute": false,
|
||||
"selfDeaf": false,
|
||||
"selfVideo": false,
|
||||
"selfStream": false,
|
||||
"suppress": false,
|
||||
"requestToSpeakTimestamp": null
|
||||
}
|
||||
},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "request media connection",
|
||||
"event": {
|
||||
"type": "connection.connectRequested",
|
||||
"options": {
|
||||
"url": "wss://voice-one.example.test",
|
||||
"token": "token-1"
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"type": "connection.connect",
|
||||
"operationId": 2,
|
||||
"options": {
|
||||
"url": "wss://voice-one.example.test",
|
||||
"token": "token-1"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "media connection succeeds",
|
||||
"event": {
|
||||
"type": "connection.connectSucceeded",
|
||||
"operationId": 2
|
||||
},
|
||||
"commands": []
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 3,
|
||||
"connectionStatus": "connected",
|
||||
"activeUrl": "wss://voice-one.example.test",
|
||||
"desiredUrl": "wss://voice-one.example.test",
|
||||
"gatewayChannelId": "channel-1",
|
||||
"lifecycleTearingDown": false,
|
||||
"operationStatuses": {
|
||||
"1": "succeeded",
|
||||
"2": "succeeded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "inbound video frame stats projection",
|
||||
"events": [
|
||||
{
|
||||
"type": "inboundVideo.trackSubscribed",
|
||||
"track": {
|
||||
"participantSid": "PA_alice",
|
||||
"participantIdentity": "alice",
|
||||
"trackSid": "TR_screen",
|
||||
"source": "screen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "inboundVideo.frameReceived",
|
||||
"frame": {
|
||||
"participantSid": "PA_alice",
|
||||
"participantIdentity": "alice",
|
||||
"trackSid": "TR_screen",
|
||||
"width": 640,
|
||||
"height": 360,
|
||||
"timestampUs": 1000,
|
||||
"byteLength": 345600
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "inboundVideo.frameStats",
|
||||
"stats": {
|
||||
"participantSid": "PA_alice",
|
||||
"participantIdentity": "alice",
|
||||
"trackSid": "TR_screen",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"frameCount": 31,
|
||||
"lastFrameTimestampUs": 1033000,
|
||||
"lastFrameByteLength": 1382400
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "inboundVideo.frameStats",
|
||||
"stats": {
|
||||
"participantSid": "PA_alice",
|
||||
"participantIdentity": "alice",
|
||||
"trackSid": "TR_screen",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"frameCount": 61,
|
||||
"lastFrameTimestampUs": 2033000,
|
||||
"lastFrameByteLength": 1382400
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "inboundVideo.frameStats",
|
||||
"stats": {
|
||||
"participantSid": "PA_bob",
|
||||
"trackSid": "TR_unknown",
|
||||
"width": 320,
|
||||
"height": 180,
|
||||
"frameCount": 5,
|
||||
"lastFrameTimestampUs": 3000000,
|
||||
"lastFrameByteLength": 86400
|
||||
}
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"inboundVideoTracks": [
|
||||
{
|
||||
"participantSid": "PA_alice",
|
||||
"participantIdentity": "alice",
|
||||
"trackSid": "TR_screen",
|
||||
"source": "screen",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"frameCount": 61,
|
||||
"lastFrameTimestampUs": 2033000,
|
||||
"lastFrameByteLength": 1382400
|
||||
}
|
||||
],
|
||||
"droppedFrameCount": 1,
|
||||
"commands": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "inbound native video frame projection",
|
||||
"events": [
|
||||
{
|
||||
"type": "inboundVideo.trackSubscribed",
|
||||
"track": {
|
||||
"participantSid": "PA_alice",
|
||||
"participantIdentity": "alice",
|
||||
"trackSid": "TR_screen",
|
||||
"source": "screen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "inboundVideo.frameReceived",
|
||||
"frame": {
|
||||
"participantSid": "PA_alice",
|
||||
"participantIdentity": "alice",
|
||||
"trackSid": "TR_screen",
|
||||
"width": 640,
|
||||
"height": 360,
|
||||
"timestampUs": 1000,
|
||||
"byteLength": 345600
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "inboundVideo.frameReceived",
|
||||
"frame": {
|
||||
"participantSid": "PA_alice",
|
||||
"participantIdentity": "alice",
|
||||
"trackSid": "TR_screen",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"timestampUs": 2000,
|
||||
"byteLength": 1382400
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "inboundVideo.frameReceived",
|
||||
"frame": {
|
||||
"participantSid": "PA_alice",
|
||||
"trackSid": "TR_unknown",
|
||||
"width": 320,
|
||||
"height": 180,
|
||||
"timestampUs": 3000,
|
||||
"byteLength": 86400
|
||||
}
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"inboundVideoTracks": [
|
||||
{
|
||||
"participantSid": "PA_alice",
|
||||
"participantIdentity": "alice",
|
||||
"trackSid": "TR_screen",
|
||||
"source": "screen",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"frameCount": 2,
|
||||
"lastFrameTimestampUs": 2000,
|
||||
"lastFrameByteLength": 1382400
|
||||
}
|
||||
],
|
||||
"droppedFrameCount": 1,
|
||||
"commands": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "local media mute unmute deafen undeafen and microphone publish lifecycle",
|
||||
"events": [
|
||||
{"type": "connection.connectRequested", "options": {"url": "wss://voice", "token": "token"}},
|
||||
{"type": "connection.connectSucceeded", "operationId": 1},
|
||||
{"type": "microphone.publishRequested", "options": {"deviceId": "mic-a"}},
|
||||
{"type": "microphone.publishSucceeded", "operationId": 2},
|
||||
{"type": "connection.reconnectRequested"},
|
||||
{"type": "connection.disconnectSucceeded", "operationId": 3},
|
||||
{"type": "connection.connectSucceeded", "operationId": 4},
|
||||
{"type": "microphone.publishSucceeded", "operationId": 5},
|
||||
{"type": "localAudio.muteRequested", "muted": true},
|
||||
{"type": "microphone.setEnabledSucceeded", "operationId": 6},
|
||||
{"type": "localAudio.muteRequested", "muted": false},
|
||||
{"type": "microphone.setEnabledSucceeded", "operationId": 7},
|
||||
{"type": "localAudio.deafenRequested", "deafened": true},
|
||||
{"type": "microphone.setEnabledSucceeded", "operationId": 8},
|
||||
{"type": "localAudio.deafenRequested", "deafened": false},
|
||||
{"type": "microphone.setEnabledSucceeded", "operationId": 9},
|
||||
{"type": "microphone.unpublishRequested"},
|
||||
{"type": "microphone.unpublishSucceeded", "operationId": 10}
|
||||
],
|
||||
"expectedCommands": [
|
||||
{"type": "connection.connect", "operationId": 1, "options": {"url": "wss://voice", "token": "token"}},
|
||||
{"type": "microphone.publish", "operationId": 2, "options": {"deviceId": "mic-a"}},
|
||||
{"type": "connection.disconnect", "operationId": 3, "reason": "network"},
|
||||
{"type": "connection.connect", "operationId": 4, "options": {"url": "wss://voice", "token": "token"}},
|
||||
{"type": "microphone.publish", "operationId": 5, "options": {"deviceId": "mic-a"}},
|
||||
{"type": "microphone.setEnabled", "operationId": 6, "enabled": false},
|
||||
{"type": "microphone.setEnabled", "operationId": 7, "enabled": true},
|
||||
{"type": "microphone.setEnabled", "operationId": 8, "enabled": false},
|
||||
{"type": "microphone.setEnabled", "operationId": 9, "enabled": true},
|
||||
{"type": "microphone.unpublish", "operationId": 10}
|
||||
],
|
||||
"checkpoints": [
|
||||
{
|
||||
"afterEvent": 5,
|
||||
"connectionStatus": "reconnecting",
|
||||
"microphoneStatus": "idle",
|
||||
"desiredDeviceId": "mic-a",
|
||||
"publishedDeviceId": null
|
||||
},
|
||||
{
|
||||
"afterEvent": 9,
|
||||
"microphoneStatus": "published",
|
||||
"microphoneEnabled": false,
|
||||
"localSpeakingOverride": false,
|
||||
"locallyMuted": true,
|
||||
"locallyDeafened": false
|
||||
},
|
||||
{
|
||||
"afterEvent": 13,
|
||||
"microphoneStatus": "published",
|
||||
"microphoneEnabled": false,
|
||||
"localSpeakingOverride": false,
|
||||
"locallyMuted": true,
|
||||
"locallyDeafened": true,
|
||||
"shouldUnmuteOnUndeafen": true
|
||||
},
|
||||
{
|
||||
"afterEvent": 15,
|
||||
"microphoneStatus": "published",
|
||||
"microphoneEnabled": true,
|
||||
"localSpeakingOverride": null,
|
||||
"locallyMuted": false,
|
||||
"locallyDeafened": false
|
||||
}
|
||||
],
|
||||
"expectedSnapshot": {
|
||||
"nextOperationId": 11,
|
||||
"connectionStatus": "connected",
|
||||
"microphoneStatus": "idle",
|
||||
"microphoneEnabled": true,
|
||||
"localSpeakingOverride": false,
|
||||
"locallyMuted": false,
|
||||
"locallyDeafened": false,
|
||||
"mutedByPermission": false,
|
||||
"publishedDeviceId": null,
|
||||
"desiredDeviceId": null,
|
||||
"lastFailureCode": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "microphone device failure mutes locally then device hotswap and unmute recover",
|
||||
"events": [
|
||||
{"type": "connection.connectRequested", "options": {"url": "wss://voice", "token": "token"}},
|
||||
{"type": "connection.connectSucceeded", "operationId": 1},
|
||||
{"type": "microphone.publishRequested", "options": {"deviceId": "mic-a"}},
|
||||
{
|
||||
"type": "microphone.publishFailed",
|
||||
"operationId": 2,
|
||||
"error": {"code": "deviceUnavailable", "message": "device unavailable", "capability": "microphoneCapture"}
|
||||
},
|
||||
{"type": "devices.selectAudioInputRequested", "deviceId": "mic-b"},
|
||||
{"type": "command.succeeded", "operationId": 3, "commandType": "devices.selectAudioInput"},
|
||||
{"type": "microphone.publishSucceeded", "operationId": 4},
|
||||
{"type": "microphone.setEnabledSucceeded", "operationId": 5},
|
||||
{"type": "localAudio.muteRequested", "muted": false},
|
||||
{"type": "microphone.setEnabledSucceeded", "operationId": 6}
|
||||
],
|
||||
"expectedCommands": [
|
||||
{"type": "connection.connect", "operationId": 1, "options": {"url": "wss://voice", "token": "token"}},
|
||||
{"type": "microphone.publish", "operationId": 2, "options": {"deviceId": "mic-a"}},
|
||||
{"type": "devices.selectAudioInput", "operationId": 3, "deviceId": "mic-b"},
|
||||
{"type": "microphone.publish", "operationId": 4, "options": {"deviceId": "mic-b"}},
|
||||
{"type": "microphone.setEnabled", "operationId": 5, "enabled": false},
|
||||
{"type": "microphone.setEnabled", "operationId": 6, "enabled": true}
|
||||
],
|
||||
"checkpoints": [
|
||||
{
|
||||
"afterEvent": 4,
|
||||
"microphoneStatus": "failed",
|
||||
"microphoneEnabled": false,
|
||||
"locallyMuted": true,
|
||||
"preferredLocallyMuted": true,
|
||||
"lastFailureCode": "deviceUnavailable"
|
||||
},
|
||||
{
|
||||
"afterEvent": 7,
|
||||
"microphoneStatus": "published",
|
||||
"microphoneEnabled": false,
|
||||
"localSpeakingOverride": false,
|
||||
"publishedDeviceId": "mic-b",
|
||||
"desiredDeviceId": "mic-b"
|
||||
}
|
||||
],
|
||||
"expectedSnapshot": {
|
||||
"nextOperationId": 7,
|
||||
"connectionStatus": "connected",
|
||||
"microphoneStatus": "published",
|
||||
"microphoneEnabled": true,
|
||||
"localSpeakingOverride": null,
|
||||
"locallyMuted": false,
|
||||
"preferredLocallyMuted": false,
|
||||
"publishedDeviceId": "mic-b",
|
||||
"desiredDeviceId": "mic-b",
|
||||
"selectedAudioInputId": "mic-b",
|
||||
"lastFailureCode": "deviceUnavailable"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "native_frame_sink",
|
||||
"events": [
|
||||
{
|
||||
"type": "nativeFrameSink.attachRequested",
|
||||
"options": {
|
||||
"sinkId": "sink-screen-1",
|
||||
"captureId": "capture-native-1",
|
||||
"trackSid": "TR_screen_1",
|
||||
"zeroCopyRequired": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "nativeFrameSink.detachRequested",
|
||||
"sinkId": "sink-screen-1"
|
||||
}
|
||||
],
|
||||
"expectedCommands": [
|
||||
{
|
||||
"type": "nativeFrameSink.attach",
|
||||
"operationId": 1,
|
||||
"options": {
|
||||
"sinkId": "sink-screen-1",
|
||||
"captureId": "capture-native-1",
|
||||
"trackSid": "TR_screen_1",
|
||||
"zeroCopyRequired": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "nativeFrameSink.detach",
|
||||
"operationId": 2,
|
||||
"sinkId": "sink-screen-1"
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 3,
|
||||
"nativeFrameSinkCount": 0,
|
||||
"lastFailureCode": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"name": "native_zero_copy_screen",
|
||||
"events": [
|
||||
{
|
||||
"type": "nativeCapture.startRequested",
|
||||
"options": {
|
||||
"captureId": "capture-native-1",
|
||||
"source": {
|
||||
"id": "display-1",
|
||||
"kind": "screen",
|
||||
"title": "Display 1",
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"displayId": "display-1"
|
||||
},
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"frameRate": 60,
|
||||
"includeCursor": true,
|
||||
"includeAudio": false,
|
||||
"zeroCopyRequired": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "nativeCapture.started",
|
||||
"operationId": 1,
|
||||
"captureId": "capture-native-1"
|
||||
},
|
||||
{
|
||||
"type": "nativeCapture.updateRequested",
|
||||
"options": {
|
||||
"captureId": "capture-native-1",
|
||||
"source": {
|
||||
"id": "display-1",
|
||||
"kind": "screen",
|
||||
"title": "Display 1",
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"displayId": "display-1"
|
||||
},
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"frameRate": 30,
|
||||
"includeCursor": false,
|
||||
"includeAudio": false,
|
||||
"zeroCopyRequired": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "nativeCapture.started",
|
||||
"operationId": 2,
|
||||
"captureId": "capture-native-1"
|
||||
},
|
||||
{
|
||||
"type": "nativeCapture.stopRequested",
|
||||
"captureId": "capture-native-1"
|
||||
},
|
||||
{
|
||||
"type": "nativeCapture.stopped",
|
||||
"operationId": 3,
|
||||
"captureId": "capture-native-1"
|
||||
}
|
||||
],
|
||||
"expectedCommands": [
|
||||
{
|
||||
"type": "nativeCapture.start",
|
||||
"operationId": 1,
|
||||
"options": {
|
||||
"captureId": "capture-native-1",
|
||||
"source": {
|
||||
"id": "display-1",
|
||||
"kind": "screen",
|
||||
"title": "Display 1",
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"displayId": "display-1"
|
||||
},
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"frameRate": 60,
|
||||
"includeCursor": true,
|
||||
"includeAudio": false,
|
||||
"zeroCopyRequired": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "nativeCapture.update",
|
||||
"operationId": 2,
|
||||
"options": {
|
||||
"captureId": "capture-native-1",
|
||||
"source": {
|
||||
"id": "display-1",
|
||||
"kind": "screen",
|
||||
"title": "Display 1",
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"displayId": "display-1"
|
||||
},
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"frameRate": 30,
|
||||
"includeCursor": false,
|
||||
"includeAudio": false,
|
||||
"zeroCopyRequired": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "nativeCapture.stop",
|
||||
"operationId": 3,
|
||||
"captureId": "capture-native-1"
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 4,
|
||||
"nativeCaptureCount": 0,
|
||||
"lastFailureCode": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "participants and tracks projection",
|
||||
"events": [
|
||||
{
|
||||
"type": "room.participantJoined",
|
||||
"participant": {"sid": "PA_alice", "identity": "alice", "name": "Alice"}
|
||||
},
|
||||
{
|
||||
"type": "room.trackPublished",
|
||||
"track": {
|
||||
"participantIdentity": "alice",
|
||||
"participantSid": "PA_alice",
|
||||
"trackSid": "TR_camera",
|
||||
"trackName": "camera",
|
||||
"kind": "video",
|
||||
"source": "camera",
|
||||
"muted": false
|
||||
}
|
||||
},
|
||||
{"type": "room.trackMuted", "trackSid": "TR_camera"},
|
||||
{
|
||||
"type": "room.trackPublished",
|
||||
"track": {
|
||||
"participantIdentity": "alice",
|
||||
"participantSid": "PA_alice",
|
||||
"trackSid": "TR_mic",
|
||||
"trackName": "microphone",
|
||||
"kind": "audio",
|
||||
"source": "microphone",
|
||||
"muted": false
|
||||
}
|
||||
},
|
||||
{"type": "room.trackUnpublished", "trackSid": "TR_mic"},
|
||||
{
|
||||
"type": "room.participantJoined",
|
||||
"participant": {"sid": "PA_bob", "identity": "bob", "name": "Bob"}
|
||||
},
|
||||
{"type": "room.participantLeft", "participantIdentity": "alice", "participantSid": "PA_alice"}
|
||||
],
|
||||
"expected": {
|
||||
"participants": [{"sid": "PA_bob", "identity": "bob", "name": "Bob"}],
|
||||
"tracks": [],
|
||||
"inboundVideoTracks": [],
|
||||
"commands": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "microphone permission denial mutes and grant restores requested unmute",
|
||||
"events": [
|
||||
{"type": "connection.connectRequested", "options": {"url": "wss://voice", "token": "token"}},
|
||||
{"type": "connection.connectSucceeded", "operationId": 1},
|
||||
{"type": "microphone.publishRequested", "options": {"deviceId": "mic-a"}},
|
||||
{"type": "microphone.publishSucceeded", "operationId": 2},
|
||||
{
|
||||
"type": "permissions.result",
|
||||
"operationId": null,
|
||||
"result": {"name": "microphone", "status": "denied", "canPrompt": false}
|
||||
},
|
||||
{"type": "microphone.setEnabledSucceeded", "operationId": 3},
|
||||
{"type": "localAudio.muteRequested", "muted": false},
|
||||
{
|
||||
"type": "permissions.result",
|
||||
"operationId": null,
|
||||
"result": {"name": "microphone", "status": "granted", "canPrompt": false}
|
||||
},
|
||||
{"type": "microphone.setEnabledSucceeded", "operationId": 4}
|
||||
],
|
||||
"expectedCommands": [
|
||||
{"type": "connection.connect", "operationId": 1, "options": {"url": "wss://voice", "token": "token"}},
|
||||
{"type": "microphone.publish", "operationId": 2, "options": {"deviceId": "mic-a"}},
|
||||
{"type": "microphone.setEnabled", "operationId": 3, "enabled": false},
|
||||
{"type": "microphone.setEnabled", "operationId": 4, "enabled": true}
|
||||
],
|
||||
"checkpoints": [
|
||||
{
|
||||
"afterEvent": 5,
|
||||
"microphoneStatus": "published",
|
||||
"microphoneEnabled": false,
|
||||
"localSpeakingOverride": false,
|
||||
"locallyMuted": true,
|
||||
"mutedByPermission": true,
|
||||
"permissionStatus": "denied"
|
||||
},
|
||||
{
|
||||
"afterEvent": 7,
|
||||
"microphoneStatus": "published",
|
||||
"microphoneEnabled": false,
|
||||
"locallyMuted": true,
|
||||
"preferredLocallyMuted": false,
|
||||
"mutedByPermission": true
|
||||
}
|
||||
],
|
||||
"expectedSnapshot": {
|
||||
"nextOperationId": 5,
|
||||
"connectionStatus": "connected",
|
||||
"microphoneStatus": "published",
|
||||
"microphoneEnabled": true,
|
||||
"localSpeakingOverride": null,
|
||||
"locallyMuted": false,
|
||||
"preferredLocallyMuted": false,
|
||||
"mutedByPermission": false,
|
||||
"permissionStatus": "granted",
|
||||
"publishedDeviceId": "mic-a",
|
||||
"desiredDeviceId": "mic-a",
|
||||
"lastFailureCode": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "push to talk and push to mute gate microphone enabled state",
|
||||
"events": [
|
||||
{"type": "connection.connectRequested", "options": {"url": "wss://voice", "token": "token"}},
|
||||
{"type": "connection.connectSucceeded", "operationId": 1},
|
||||
{"type": "microphone.publishRequested", "options": {"deviceId": "mic-a"}},
|
||||
{"type": "microphone.publishSucceeded", "operationId": 2},
|
||||
{"type": "audioControls.changed", "controls": {"mode": "pushToTalk", "pushToTalkActive": false}},
|
||||
{"type": "microphone.setEnabledSucceeded", "operationId": 3},
|
||||
{"type": "audioControls.changed", "controls": {"pushToTalkActive": true}},
|
||||
{"type": "microphone.setEnabledSucceeded", "operationId": 4},
|
||||
{
|
||||
"type": "audioControls.changed",
|
||||
"controls": {"mode": "pushToMute", "pushToTalkActive": false, "pushToMuteActive": true}
|
||||
},
|
||||
{"type": "microphone.setEnabledSucceeded", "operationId": 5},
|
||||
{"type": "audioControls.changed", "controls": {"pushToMuteActive": false}},
|
||||
{"type": "microphone.setEnabledSucceeded", "operationId": 6}
|
||||
],
|
||||
"expectedCommands": [
|
||||
{"type": "connection.connect", "operationId": 1, "options": {"url": "wss://voice", "token": "token"}},
|
||||
{"type": "microphone.publish", "operationId": 2, "options": {"deviceId": "mic-a"}},
|
||||
{"type": "microphone.setEnabled", "operationId": 3, "enabled": false},
|
||||
{"type": "microphone.setEnabled", "operationId": 4, "enabled": true},
|
||||
{"type": "microphone.setEnabled", "operationId": 5, "enabled": false},
|
||||
{"type": "microphone.setEnabled", "operationId": 6, "enabled": true}
|
||||
],
|
||||
"checkpoints": [
|
||||
{
|
||||
"afterEvent": 5,
|
||||
"microphoneStatus": "published",
|
||||
"microphoneEnabled": false,
|
||||
"localSpeakingOverride": false,
|
||||
"audioMode": "pushToTalk"
|
||||
},
|
||||
{
|
||||
"afterEvent": 7,
|
||||
"microphoneStatus": "published",
|
||||
"microphoneEnabled": true,
|
||||
"localSpeakingOverride": true,
|
||||
"audioMode": "pushToTalk"
|
||||
},
|
||||
{
|
||||
"afterEvent": 9,
|
||||
"microphoneStatus": "published",
|
||||
"microphoneEnabled": false,
|
||||
"localSpeakingOverride": false,
|
||||
"audioMode": "pushToMute"
|
||||
}
|
||||
],
|
||||
"expectedSnapshot": {
|
||||
"nextOperationId": 7,
|
||||
"connectionStatus": "connected",
|
||||
"microphoneStatus": "published",
|
||||
"microphoneEnabled": true,
|
||||
"localSpeakingOverride": null,
|
||||
"audioMode": "pushToMute",
|
||||
"publishedDeviceId": "mic-a",
|
||||
"desiredDeviceId": "mic-a",
|
||||
"lastFailureCode": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"name": "reconnect_channel_move",
|
||||
"description": "A channel move writes the new gateway voice state, disconnects the old media session, then reconnects to the new room.",
|
||||
"steps": [
|
||||
{
|
||||
"name": "connect first room",
|
||||
"event": {
|
||||
"type": "connection.connectRequested",
|
||||
"options": {
|
||||
"url": "wss://voice-one.example.test",
|
||||
"token": "token-1"
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"type": "connection.connect",
|
||||
"operationId": 1,
|
||||
"options": {
|
||||
"url": "wss://voice-one.example.test",
|
||||
"token": "token-1"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "first room connected",
|
||||
"event": {
|
||||
"type": "connection.connectSucceeded",
|
||||
"operationId": 1
|
||||
},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "old gateway channel observed",
|
||||
"event": {
|
||||
"type": "gateway.voiceStateUpdated",
|
||||
"voiceState": {
|
||||
"guildId": "guild-1",
|
||||
"channelId": "channel-1",
|
||||
"userId": "user-1",
|
||||
"sessionId": "session-1",
|
||||
"selfMute": false,
|
||||
"selfDeaf": false,
|
||||
"selfVideo": false,
|
||||
"selfStream": false,
|
||||
"suppress": false,
|
||||
"requestToSpeakTimestamp": null
|
||||
}
|
||||
},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "write moved gateway channel",
|
||||
"event": {
|
||||
"type": "gateway.voiceStateWriteRequested",
|
||||
"options": {
|
||||
"guildId": "guild-1",
|
||||
"channelId": "channel-2",
|
||||
"selfMute": false,
|
||||
"selfDeaf": false
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"type": "gateway.voiceState.write",
|
||||
"operationId": 2,
|
||||
"options": {
|
||||
"guildId": "guild-1",
|
||||
"channelId": "channel-2",
|
||||
"selfMute": false,
|
||||
"selfDeaf": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "moved gateway write succeeds",
|
||||
"event": {
|
||||
"type": "gateway.voiceStateWriteSucceeded",
|
||||
"operationId": 2
|
||||
},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "new gateway channel observed",
|
||||
"event": {
|
||||
"type": "gateway.voiceStateUpdated",
|
||||
"voiceState": {
|
||||
"guildId": "guild-1",
|
||||
"channelId": "channel-2",
|
||||
"userId": "user-1",
|
||||
"sessionId": "session-2",
|
||||
"selfMute": false,
|
||||
"selfDeaf": false,
|
||||
"selfVideo": false,
|
||||
"selfStream": false,
|
||||
"suppress": false,
|
||||
"requestToSpeakTimestamp": null
|
||||
}
|
||||
},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "request second media connection",
|
||||
"event": {
|
||||
"type": "connection.connectRequested",
|
||||
"options": {
|
||||
"url": "wss://voice-two.example.test",
|
||||
"token": "token-2"
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"type": "connection.disconnect",
|
||||
"operationId": 3,
|
||||
"reason": "replaced"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "old media connection disconnects",
|
||||
"event": {
|
||||
"type": "connection.disconnectSucceeded",
|
||||
"operationId": 3
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"type": "connection.connect",
|
||||
"operationId": 4,
|
||||
"options": {
|
||||
"url": "wss://voice-two.example.test",
|
||||
"token": "token-2"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "second media connection succeeds",
|
||||
"event": {
|
||||
"type": "connection.connectSucceeded",
|
||||
"operationId": 4
|
||||
},
|
||||
"commands": []
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 5,
|
||||
"connectionStatus": "connected",
|
||||
"activeUrl": "wss://voice-two.example.test",
|
||||
"desiredUrl": "wss://voice-two.example.test",
|
||||
"gatewayChannelId": "channel-2",
|
||||
"lifecycleTearingDown": false,
|
||||
"operationStatuses": {
|
||||
"1": "succeeded",
|
||||
"2": "succeeded",
|
||||
"3": "succeeded",
|
||||
"4": "succeeded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "resume_preserves_media",
|
||||
"steps": [
|
||||
{
|
||||
"name": "external establishment",
|
||||
"event": {"type": "connection.externallyEstablished", "options": {"url": "wss://voice.fixture", "token": ""}},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "camera publish requested",
|
||||
"event": {"type": "camera.publishRequested", "options": {"deviceId": "camera-1"}},
|
||||
"commands": [{"type": "camera.publish", "operationId": 1, "options": {"deviceId": "camera-1"}}]
|
||||
},
|
||||
{
|
||||
"name": "camera publish succeeded",
|
||||
"event": {"type": "camera.publishSucceeded", "operationId": 1},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "resume re-establishes the same endpoint without replaying media",
|
||||
"event": {"type": "connection.externallyEstablished", "options": {"url": "wss://voice.fixture", "token": ""}},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "camera unpublish after resume plans a real command",
|
||||
"event": {"type": "camera.unpublishRequested"},
|
||||
"commands": [{"type": "camera.unpublish", "operationId": 2}]
|
||||
},
|
||||
{
|
||||
"name": "camera unpublish succeeded",
|
||||
"event": {"type": "camera.unpublishSucceeded", "operationId": 2},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "true remote disconnect resets the session",
|
||||
"event": {"type": "connection.remoteDisconnected", "reason": "network"},
|
||||
"commands": []
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 3,
|
||||
"connectionStatus": "idle",
|
||||
"activeUrl": null,
|
||||
"desiredUrl": null,
|
||||
"gatewayChannelId": null,
|
||||
"lifecycleTearingDown": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "screen_audio_inclusion_toggle",
|
||||
"events": [
|
||||
{
|
||||
"type": "connection.connectRequested",
|
||||
"options": {"url": "wss://voice.example.test", "token": "token-screen-audio"}
|
||||
},
|
||||
{"type": "connection.connectSucceeded", "operationId": 1},
|
||||
{
|
||||
"type": "screen.publishRequested",
|
||||
"options": {"captureId": "capture-1", "width": 1920, "height": 1080, "codec": "h264", "maxFramerate": 30}
|
||||
},
|
||||
{"type": "screen.publishSucceeded", "operationId": 2},
|
||||
{"type": "screenAudio.publishRequested", "options": {"sampleRate": 48000, "numChannels": 2}},
|
||||
{"type": "screenAudio.publishSucceeded", "operationId": 3},
|
||||
{
|
||||
"type": "screen.updateEncodingRequested",
|
||||
"options": {"captureId": "capture-1", "width": 1920, "height": 1080, "frameRate": 60}
|
||||
},
|
||||
{"type": "screen.updateEncodingSucceeded", "operationId": 4},
|
||||
{"type": "screenAudio.unpublishRequested"},
|
||||
{"type": "screenAudio.unpublishSucceeded", "operationId": 5},
|
||||
{"type": "screenAudio.publishRequested", "options": {"sampleRate": 48000, "numChannels": 2}},
|
||||
{"type": "screenAudio.publishSucceeded", "operationId": 6}
|
||||
],
|
||||
"expectedCommands": [
|
||||
{
|
||||
"type": "connection.connect",
|
||||
"operationId": 1,
|
||||
"options": {"url": "wss://voice.example.test", "token": "token-screen-audio"}
|
||||
},
|
||||
{
|
||||
"type": "screen.publish",
|
||||
"operationId": 2,
|
||||
"options": {"captureId": "capture-1", "width": 1920, "height": 1080, "codec": "h264", "maxFramerate": 30}
|
||||
},
|
||||
{"type": "screenAudio.publish", "operationId": 3, "options": {"sampleRate": 48000, "numChannels": 2}},
|
||||
{
|
||||
"type": "screen.updateEncoding",
|
||||
"operationId": 4,
|
||||
"options": {"captureId": "capture-1", "width": 1920, "height": 1080, "frameRate": 60}
|
||||
},
|
||||
{"type": "screenAudio.unpublish", "operationId": 5},
|
||||
{"type": "screenAudio.publish", "operationId": 6, "options": {"sampleRate": 48000, "numChannels": 2}}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 7,
|
||||
"screenStatus": "published",
|
||||
"screenCaptureId": "capture-1",
|
||||
"screenAudioStatus": "published",
|
||||
"lastFailureCode": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "screen_audio_routing",
|
||||
"events": [
|
||||
{
|
||||
"type": "connection.connectRequested",
|
||||
"options": {
|
||||
"url": "wss://voice.example.test",
|
||||
"token": "token-audio"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "connection.connectSucceeded",
|
||||
"operationId": 1
|
||||
},
|
||||
{
|
||||
"type": "screenAudio.publishRequested",
|
||||
"options": {
|
||||
"sampleRate": 48000,
|
||||
"numChannels": 2,
|
||||
"route": "native",
|
||||
"captureId": "capture-screen-1",
|
||||
"tapId": "tap-screen-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "screenAudio.publishSucceeded",
|
||||
"operationId": 2
|
||||
}
|
||||
],
|
||||
"expectedCommands": [
|
||||
{
|
||||
"type": "connection.connect",
|
||||
"operationId": 1,
|
||||
"options": {
|
||||
"url": "wss://voice.example.test",
|
||||
"token": "token-audio"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "screenAudio.publish",
|
||||
"operationId": 2,
|
||||
"options": {
|
||||
"sampleRate": 48000,
|
||||
"numChannels": 2,
|
||||
"route": "native",
|
||||
"captureId": "capture-screen-1",
|
||||
"tapId": "tap-screen-1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 3,
|
||||
"screenAudioStatus": "published",
|
||||
"screenAudioRoute": "native",
|
||||
"screenAudioTapId": "tap-screen-1",
|
||||
"lastFailureCode": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"name": "screen_share_start_update_stop",
|
||||
"events": [
|
||||
{
|
||||
"type": "connection.connectRequested",
|
||||
"options": {
|
||||
"url": "wss://voice.example.test",
|
||||
"token": "token-screen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "connection.connectSucceeded",
|
||||
"operationId": 1
|
||||
},
|
||||
{
|
||||
"type": "screen.publishRequested",
|
||||
"options": {
|
||||
"captureId": "capture-screen-1",
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"codec": "h264",
|
||||
"maxFramerate": 60,
|
||||
"maxBitrateBps": 8000000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "screen.publishSucceeded",
|
||||
"operationId": 2
|
||||
},
|
||||
{
|
||||
"type": "screen.updateEncodingRequested",
|
||||
"options": {
|
||||
"captureId": "capture-screen-1",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"frameRate": 30,
|
||||
"maxBitrateBps": 3000000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "screen.updateEncodingSucceeded",
|
||||
"operationId": 3
|
||||
},
|
||||
{
|
||||
"type": "screen.unpublishRequested"
|
||||
},
|
||||
{
|
||||
"type": "screen.unpublishSucceeded",
|
||||
"operationId": 4
|
||||
}
|
||||
],
|
||||
"expectedCommands": [
|
||||
{
|
||||
"type": "connection.connect",
|
||||
"operationId": 1,
|
||||
"options": {
|
||||
"url": "wss://voice.example.test",
|
||||
"token": "token-screen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "screen.publish",
|
||||
"operationId": 2,
|
||||
"options": {
|
||||
"captureId": "capture-screen-1",
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"codec": "h264",
|
||||
"maxFramerate": 60,
|
||||
"maxBitrateBps": 8000000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "screen.updateEncoding",
|
||||
"operationId": 3,
|
||||
"options": {
|
||||
"captureId": "capture-screen-1",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"frameRate": 30,
|
||||
"maxBitrateBps": 3000000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "screen.unpublish",
|
||||
"operationId": 4
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 5,
|
||||
"screenStatus": "idle",
|
||||
"screenCaptureId": null,
|
||||
"lastFailureCode": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "stale_connection",
|
||||
"description": "A stale connect completion is rejected after a newer connect operation supersedes it.",
|
||||
"steps": [
|
||||
{
|
||||
"name": "request first connection",
|
||||
"event": {
|
||||
"type": "connection.connectRequested",
|
||||
"options": {
|
||||
"url": "wss://voice-one.example.test",
|
||||
"token": "token-1"
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"type": "connection.connect",
|
||||
"operationId": 1,
|
||||
"options": {
|
||||
"url": "wss://voice-one.example.test",
|
||||
"token": "token-1"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "request replacement connection",
|
||||
"event": {
|
||||
"type": "connection.connectRequested",
|
||||
"options": {
|
||||
"url": "wss://voice-two.example.test",
|
||||
"token": "token-2"
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"type": "connection.connect",
|
||||
"operationId": 2,
|
||||
"options": {
|
||||
"url": "wss://voice-two.example.test",
|
||||
"token": "token-2"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stale first completion arrives",
|
||||
"event": {
|
||||
"type": "connection.connectSucceeded",
|
||||
"operationId": 1
|
||||
},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "runtime rejects stale first completion",
|
||||
"event": {
|
||||
"type": "command.staleCompletionRejected",
|
||||
"operationId": 1,
|
||||
"commandType": "connection.connect",
|
||||
"resourceKey": "connection"
|
||||
},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "replacement completion succeeds",
|
||||
"event": {
|
||||
"type": "connection.connectSucceeded",
|
||||
"operationId": 2
|
||||
},
|
||||
"commands": []
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 3,
|
||||
"connectionStatus": "connected",
|
||||
"activeUrl": "wss://voice-two.example.test",
|
||||
"desiredUrl": "wss://voice-two.example.test",
|
||||
"gatewayChannelId": null,
|
||||
"lifecycleTearingDown": false,
|
||||
"operationStatuses": {
|
||||
"1": "stale",
|
||||
"2": "succeeded"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"name": "stats_lifecycle",
|
||||
"events": [
|
||||
{
|
||||
"type": "connection.connectRequested",
|
||||
"options": {
|
||||
"url": "wss://voice.example.test",
|
||||
"token": "token-1"
|
||||
}
|
||||
},
|
||||
{"type": "connection.connectSucceeded", "operationId": 1},
|
||||
{"type": "stats.collectRequested"},
|
||||
{
|
||||
"type": "stats.collected",
|
||||
"operationId": 2,
|
||||
"stats": {
|
||||
"rttMs": 24,
|
||||
"outbound": [
|
||||
{
|
||||
"trackSid": "mic-track",
|
||||
"source": "microphone",
|
||||
"kind": "audio",
|
||||
"bitrateKbps": 42,
|
||||
"packetsLost": 1,
|
||||
"packetsSent": 100
|
||||
},
|
||||
{
|
||||
"trackSid": "screen-track",
|
||||
"source": "screenshare",
|
||||
"kind": "video",
|
||||
"codec": "video/H265",
|
||||
"bitrateKbps": 3500,
|
||||
"packetsLost": 0,
|
||||
"effectiveFps": 58.5,
|
||||
"configuredFps": 60,
|
||||
"targetFps": 60,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"zeroCopy": true
|
||||
}
|
||||
],
|
||||
"inbound": [
|
||||
{
|
||||
"participantIdentity": "remote-user",
|
||||
"participantSid": "participant-1",
|
||||
"trackSid": "remote-audio",
|
||||
"kind": "audio",
|
||||
"bitrateKbps": 36,
|
||||
"packetsLost": 3,
|
||||
"packetsReceived": 97,
|
||||
"jitterMs": 7
|
||||
}
|
||||
],
|
||||
"droppedNativeVideoFrames": 2,
|
||||
"droppedVideoFrameCallbacks": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"expectedCommands": [
|
||||
[
|
||||
{
|
||||
"type": "connection.connect",
|
||||
"operationId": 1,
|
||||
"options": {"url": "wss://voice.example.test", "token": "token-1"}
|
||||
}
|
||||
],
|
||||
[],
|
||||
[{"type": "stats.collect", "operationId": 2}],
|
||||
[]
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 3,
|
||||
"statsOperationId": null,
|
||||
"statsFailureCode": null,
|
||||
"summary": {
|
||||
"network": {
|
||||
"audioSendBitrateKbps": 42,
|
||||
"audioRecvBitrateKbps": 36,
|
||||
"videoSendBitrateKbps": 3500,
|
||||
"videoRecvBitrateKbps": 0,
|
||||
"audioPacketLossPercent": 3,
|
||||
"videoPacketLossPercent": 0,
|
||||
"jitterMs": 7,
|
||||
"rttMs": 24
|
||||
},
|
||||
"localAudio": {"trackIdentifier": "mic-track"},
|
||||
"localScreenShare": {
|
||||
"trackIdentifier": "screen-track",
|
||||
"codec": "video/H265",
|
||||
"effectiveFramesPerSecond": 58.5,
|
||||
"configuredFramesPerSecond": 60,
|
||||
"targetFramesPerSecond": 60,
|
||||
"frameWidth": 1920,
|
||||
"frameHeight": 1080
|
||||
},
|
||||
"remoteAudio": {"trackIdentifier": "remote-audio"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "remote subscription quality and participant volume commands",
|
||||
"events": [
|
||||
{"type": "connection.connectRequested", "options": {"url": "wss://voice", "token": "token"}},
|
||||
{"type": "connection.connectSucceeded", "operationId": 1},
|
||||
{
|
||||
"type": "remoteTrackSubscription.setRequested",
|
||||
"options": {
|
||||
"participantIdentity": "alice",
|
||||
"source": "screen",
|
||||
"subscribed": true,
|
||||
"enabled": true,
|
||||
"quality": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "remoteTrackSubscription.setRequested",
|
||||
"options": {
|
||||
"participantIdentity": "alice",
|
||||
"source": "screen",
|
||||
"subscribed": true,
|
||||
"enabled": true,
|
||||
"quality": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "remoteTrackSubscription.setRequested",
|
||||
"options": {
|
||||
"participantIdentity": "alice",
|
||||
"source": "screen",
|
||||
"subscribed": true,
|
||||
"enabled": true,
|
||||
"quality": "low"
|
||||
}
|
||||
},
|
||||
{"type": "participantVolume.setRequested", "options": {"participantIdentity": "alice", "volume": 0.35}},
|
||||
{"type": "participantVolume.setRequested", "options": {"participantIdentity": "alice", "volume": 0.35}}
|
||||
],
|
||||
"expected": {
|
||||
"remoteTrackSubscriptions": {
|
||||
"alice:screen": {
|
||||
"participantIdentity": "alice",
|
||||
"source": "screen",
|
||||
"subscribed": true,
|
||||
"enabled": true,
|
||||
"quality": "low"
|
||||
}
|
||||
},
|
||||
"participantVolumes": {"alice": 0.35},
|
||||
"commands": [
|
||||
{"type": "connection.connect", "operationId": 1, "options": {"url": "wss://voice", "token": "token"}},
|
||||
{
|
||||
"type": "remoteTrackSubscription.set",
|
||||
"operationId": 2,
|
||||
"options": {
|
||||
"participantIdentity": "alice",
|
||||
"source": "screen",
|
||||
"subscribed": true,
|
||||
"enabled": true,
|
||||
"quality": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "remoteTrackSubscription.set",
|
||||
"operationId": 3,
|
||||
"options": {
|
||||
"participantIdentity": "alice",
|
||||
"source": "screen",
|
||||
"subscribed": true,
|
||||
"enabled": true,
|
||||
"quality": "low"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "participantVolume.set",
|
||||
"operationId": 4,
|
||||
"options": {"participantIdentity": "alice", "volume": 0.35}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"name": "teardown_renderer_window",
|
||||
"description": "Renderer teardown clears gateway state, disconnects media, then runs host lifecycle teardown.",
|
||||
"steps": [
|
||||
{
|
||||
"name": "gateway channel observed",
|
||||
"event": {
|
||||
"type": "gateway.voiceStateUpdated",
|
||||
"voiceState": {
|
||||
"guildId": "guild-1",
|
||||
"channelId": "channel-1",
|
||||
"userId": "user-1",
|
||||
"sessionId": "session-1",
|
||||
"selfMute": false,
|
||||
"selfDeaf": false,
|
||||
"selfVideo": false,
|
||||
"selfStream": false,
|
||||
"suppress": false,
|
||||
"requestToSpeakTimestamp": null
|
||||
}
|
||||
},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "media connection requested",
|
||||
"event": {
|
||||
"type": "connection.connectRequested",
|
||||
"options": {
|
||||
"url": "wss://voice-one.example.test",
|
||||
"token": "token-1"
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"type": "connection.connect",
|
||||
"operationId": 1,
|
||||
"options": {
|
||||
"url": "wss://voice-one.example.test",
|
||||
"token": "token-1"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "media connection succeeds",
|
||||
"event": {
|
||||
"type": "connection.connectSucceeded",
|
||||
"operationId": 1
|
||||
},
|
||||
"commands": []
|
||||
},
|
||||
{
|
||||
"name": "renderer teardown requested",
|
||||
"event": {
|
||||
"type": "lifecycle.teardownRequested",
|
||||
"reason": "rendererDisposed"
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"type": "gateway.voiceState.clear",
|
||||
"operationId": 2,
|
||||
"guildId": "guild-1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gateway clear succeeds",
|
||||
"event": {
|
||||
"type": "gateway.voiceStateClearSucceeded",
|
||||
"operationId": 2
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"type": "connection.disconnect",
|
||||
"operationId": 3,
|
||||
"reason": "shutdown"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "media disconnect succeeds",
|
||||
"event": {
|
||||
"type": "connection.disconnectSucceeded",
|
||||
"operationId": 3
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"type": "lifecycle.teardown",
|
||||
"operationId": 4,
|
||||
"reason": "rendererDisposed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "host teardown succeeds",
|
||||
"event": {
|
||||
"type": "lifecycle.teardownSucceeded",
|
||||
"operationId": 4
|
||||
},
|
||||
"commands": []
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 5,
|
||||
"connectionStatus": "idle",
|
||||
"activeUrl": null,
|
||||
"desiredUrl": null,
|
||||
"gatewayChannelId": null,
|
||||
"lifecycleTearingDown": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "watched streams drive subscription commands",
|
||||
"events": [
|
||||
{"type": "connection.connectRequested", "options": {"url": "wss://voice", "token": "token"}},
|
||||
{"type": "connection.connectSucceeded", "operationId": 1},
|
||||
{
|
||||
"type": "watchedStream.watchRequested",
|
||||
"stream": {
|
||||
"participantIdentity": "alice",
|
||||
"source": "screen",
|
||||
"trackSid": null,
|
||||
"quality": "high",
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "room.trackPublished",
|
||||
"track": {
|
||||
"participantIdentity": "alice",
|
||||
"participantSid": "PA_alice",
|
||||
"trackSid": "TR_screen",
|
||||
"trackName": "screen",
|
||||
"kind": "video",
|
||||
"source": "screen",
|
||||
"muted": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "watchedStream.watchRequested",
|
||||
"stream": {
|
||||
"participantIdentity": "alice",
|
||||
"source": "screen",
|
||||
"trackSid": "TR_screen",
|
||||
"quality": "low",
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
{"type": "watchedStream.unwatchRequested", "stream": {"participantIdentity": "alice", "source": "screen"}}
|
||||
],
|
||||
"expected": {
|
||||
"watchedStreams": [],
|
||||
"remoteTrackSubscriptions": {
|
||||
"alice:screen": {
|
||||
"participantIdentity": "alice",
|
||||
"source": "screen",
|
||||
"subscribed": false,
|
||||
"enabled": false,
|
||||
"quality": "low"
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{"type": "connection.connect", "operationId": 1, "options": {"url": "wss://voice", "token": "token"}},
|
||||
{
|
||||
"type": "remoteTrackSubscription.set",
|
||||
"operationId": 2,
|
||||
"options": {
|
||||
"participantIdentity": "alice",
|
||||
"source": "screen",
|
||||
"subscribed": true,
|
||||
"enabled": true,
|
||||
"quality": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "remoteTrackSubscription.set",
|
||||
"operationId": 3,
|
||||
"options": {
|
||||
"participantIdentity": "alice",
|
||||
"source": "screen",
|
||||
"subscribed": true,
|
||||
"enabled": true,
|
||||
"quality": "low"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "remoteTrackSubscription.set",
|
||||
"operationId": 4,
|
||||
"options": {
|
||||
"participantIdentity": "alice",
|
||||
"source": "screen",
|
||||
"subscribed": false,
|
||||
"enabled": false,
|
||||
"quality": "low"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "externally_established_session",
|
||||
"events": [
|
||||
{
|
||||
"type": "microphone.publishRequested",
|
||||
"options": {
|
||||
"deviceId": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "connection.externallyEstablished",
|
||||
"options": {
|
||||
"url": "wss://voice.example.test",
|
||||
"token": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "microphone.publishSucceeded",
|
||||
"operationId": 1
|
||||
},
|
||||
{
|
||||
"type": "connection.remoteDisconnected",
|
||||
"reason": "network"
|
||||
},
|
||||
{
|
||||
"type": "microphone.publishRequested",
|
||||
"options": {
|
||||
"deviceId": "default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "connection.externallyEstablished",
|
||||
"options": {
|
||||
"url": "wss://voice-reconnect.example.test",
|
||||
"token": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "microphone.publishSucceeded",
|
||||
"operationId": 2
|
||||
},
|
||||
{
|
||||
"type": "gateway.voiceStateUpdated",
|
||||
"voiceState": {
|
||||
"guildId": "guild-1",
|
||||
"channelId": "channel-1",
|
||||
"userId": "user-1",
|
||||
"sessionId": "session-1",
|
||||
"selfMute": false,
|
||||
"selfDeaf": false,
|
||||
"selfVideo": false,
|
||||
"selfStream": false,
|
||||
"suppress": false,
|
||||
"requestToSpeakTimestamp": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"expected": {
|
||||
"nextOperationId": 3,
|
||||
"connectionStatus": "connected",
|
||||
"activeUrl": "wss://voice-reconnect.example.test",
|
||||
"microphoneStatus": "published",
|
||||
"microphoneDeviceId": "default",
|
||||
"gatewayChannelId": "channel-1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
[
|
||||
{
|
||||
"name": "valid zero-copy native capture start",
|
||||
"command": {
|
||||
"type": "nativeCapture.start",
|
||||
"operationId": 1,
|
||||
"options": {
|
||||
"captureId": "capture-1",
|
||||
"source": {
|
||||
"id": "display-1",
|
||||
"kind": "screen",
|
||||
"title": "Display 1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"displayId": "display-1"
|
||||
},
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"frameRate": 60,
|
||||
"includeCursor": true,
|
||||
"includeAudio": false,
|
||||
"zeroCopyRequired": true
|
||||
}
|
||||
},
|
||||
"expectedOk": true
|
||||
},
|
||||
{
|
||||
"name": "reject native capture start without zero-copy requirement",
|
||||
"command": {
|
||||
"type": "nativeCapture.start",
|
||||
"operationId": 2,
|
||||
"options": {
|
||||
"captureId": "capture-2",
|
||||
"source": {
|
||||
"id": "window-1",
|
||||
"kind": "window",
|
||||
"title": "Window 1",
|
||||
"windowId": "window-1"
|
||||
},
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"frameRate": 60,
|
||||
"includeCursor": true,
|
||||
"includeAudio": false,
|
||||
"zeroCopyRequired": false
|
||||
}
|
||||
},
|
||||
"expectedOk": false,
|
||||
"expectedError": "zeroCopyRequired"
|
||||
},
|
||||
{
|
||||
"name": "reject native frame sink without zero-copy requirement",
|
||||
"command": {
|
||||
"type": "nativeFrameSink.attach",
|
||||
"operationId": 3,
|
||||
"options": {
|
||||
"sinkId": "sink-1",
|
||||
"captureId": "capture-1",
|
||||
"trackSid": "TR_screen",
|
||||
"zeroCopyRequired": false
|
||||
}
|
||||
},
|
||||
"expectedOk": false,
|
||||
"expectedError": "zeroCopyRequired"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,78 @@
|
||||
[
|
||||
{
|
||||
"name": "mirror toggle updates in place",
|
||||
"input": {
|
||||
"published": {"deviceId": "camera-1", "width": 1280, "height": 720, "mirror": false},
|
||||
"desired": null,
|
||||
"update": {"mirror": true}
|
||||
},
|
||||
"expectedAction": "updateEncoding",
|
||||
"expectedReason": "effectsOrEncodingChanged",
|
||||
"expectedMirror": true
|
||||
},
|
||||
{
|
||||
"name": "background mode and blur strength update in place",
|
||||
"input": {
|
||||
"published": {"deviceId": "camera-1", "width": 1280, "height": 720, "backgroundMode": "none"},
|
||||
"desired": null,
|
||||
"update": {"backgroundMode": "blur", "backgroundBlurStrength": 70}
|
||||
},
|
||||
"expectedAction": "updateEncoding",
|
||||
"expectedReason": "effectsOrEncodingChanged",
|
||||
"expectedBackgroundMode": "blur"
|
||||
},
|
||||
{
|
||||
"name": "resolution and frame rate update in place",
|
||||
"input": {
|
||||
"published": {"deviceId": "camera-1", "width": 1280, "height": 720, "frameRate": 30},
|
||||
"desired": null,
|
||||
"update": {"width": 1920, "height": 1080, "frameRate": 60}
|
||||
},
|
||||
"expectedAction": "updateEncoding",
|
||||
"expectedReason": "effectsOrEncodingChanged",
|
||||
"expectedWidth": 1920,
|
||||
"expectedFrameRate": 60
|
||||
},
|
||||
{
|
||||
"name": "device change republishes",
|
||||
"input": {
|
||||
"published": {"deviceId": "camera-1", "width": 1280, "height": 720},
|
||||
"desired": null,
|
||||
"update": {"deviceId": "camera-2"}
|
||||
},
|
||||
"expectedAction": "republish",
|
||||
"expectedReason": "deviceChanged"
|
||||
},
|
||||
{
|
||||
"name": "codec change republishes",
|
||||
"input": {
|
||||
"published": {"deviceId": "camera-1", "width": 1280, "height": 720, "codec": "vp8"},
|
||||
"desired": null,
|
||||
"update": {"codec": "vp9"}
|
||||
},
|
||||
"expectedAction": "republish",
|
||||
"expectedReason": "codecChanged",
|
||||
"expectedCodec": "vp9"
|
||||
},
|
||||
{
|
||||
"name": "identical options is a no-op",
|
||||
"input": {
|
||||
"published": {"deviceId": "camera-1", "width": 1280, "height": 720, "mirror": true},
|
||||
"desired": null,
|
||||
"update": {"width": 1280, "height": 720, "mirror": true}
|
||||
},
|
||||
"expectedAction": "noop",
|
||||
"expectedReason": "unchanged"
|
||||
},
|
||||
{
|
||||
"name": "missing published camera rejects",
|
||||
"input": {
|
||||
"published": null,
|
||||
"desired": null,
|
||||
"update": {"mirror": true}
|
||||
},
|
||||
"expectedAction": "reject",
|
||||
"expectedReason": "missingPublishedCamera",
|
||||
"expectedErrorCode": "invalidArgument"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{"raw": "encrypted", "expected": "encrypted"},
|
||||
{"raw": "ok", "expected": "encrypted"},
|
||||
{"raw": "decryption_failed", "expected": "decryption_failed"},
|
||||
{"raw": "decryptionFailed", "expected": "decryption_failed"},
|
||||
{"raw": "error", "expected": "decryption_failed"},
|
||||
{"raw": "broken", "expected": "decryption_failed"},
|
||||
{"raw": "unencrypted", "expected": "unencrypted"},
|
||||
{"raw": "missing_key", "expected": "unencrypted"},
|
||||
{"raw": "missingKey", "expected": "unencrypted"},
|
||||
{"raw": "internal_error", "expected": "unencrypted"},
|
||||
{"raw": "unexpected", "expected": "unknown"},
|
||||
{"raw": null, "expected": "unknown"}
|
||||
]
|
||||
@@ -0,0 +1,134 @@
|
||||
[
|
||||
{
|
||||
"name": "invalid native addon result is unavailable",
|
||||
"input": null,
|
||||
"codec": "h265",
|
||||
"expectedCapabilities": {
|
||||
"available": false,
|
||||
"backend": "none",
|
||||
"compiled": false,
|
||||
"runtime": false,
|
||||
"codecs": [],
|
||||
"zeroCopy": false,
|
||||
"nativeInputs": [],
|
||||
"reason": "query-failed",
|
||||
"detail": "Native addon returned an invalid result"
|
||||
},
|
||||
"expectedNativeNvenc": false
|
||||
},
|
||||
{
|
||||
"name": "zero-copy native NVENC H265 is usable",
|
||||
"input": {
|
||||
"available": true,
|
||||
"backend": "nvenc",
|
||||
"compiled": true,
|
||||
"runtime": true,
|
||||
"codecs": ["h264", "h265"],
|
||||
"zeroCopy": true,
|
||||
"nativeInputs": ["dmabuf"]
|
||||
},
|
||||
"codec": "h265",
|
||||
"expectedCapabilities": {
|
||||
"available": true,
|
||||
"backend": "nvenc",
|
||||
"compiled": true,
|
||||
"runtime": true,
|
||||
"codecs": ["h264", "h265"],
|
||||
"zeroCopy": true,
|
||||
"nativeInputs": ["dmabuf"]
|
||||
},
|
||||
"expectedNativeNvenc": true
|
||||
},
|
||||
{
|
||||
"name": "HEVC codec alias enables H265 when zero-copy NVENC is available",
|
||||
"input": {
|
||||
"available": true,
|
||||
"backend": "nvenc",
|
||||
"compiled": true,
|
||||
"runtime": true,
|
||||
"codecs": ["hevc"],
|
||||
"zeroCopy": true,
|
||||
"nativeInputs": ["d3d11-texture"]
|
||||
},
|
||||
"codec": "h265",
|
||||
"expectedCapabilities": {
|
||||
"available": true,
|
||||
"backend": "nvenc",
|
||||
"compiled": true,
|
||||
"runtime": true,
|
||||
"codecs": ["hevc"],
|
||||
"zeroCopy": true,
|
||||
"nativeInputs": ["d3d11-texture"]
|
||||
},
|
||||
"expectedNativeNvenc": true
|
||||
},
|
||||
{
|
||||
"name": "native NVENC without zero-copy is not supported",
|
||||
"input": {
|
||||
"available": true,
|
||||
"backend": "nvenc",
|
||||
"compiled": true,
|
||||
"runtime": true,
|
||||
"codecs": ["h264", "h265"],
|
||||
"zeroCopy": false,
|
||||
"nativeInputs": ["dmabuf"]
|
||||
},
|
||||
"codec": "h265",
|
||||
"expectedCapabilities": {
|
||||
"available": true,
|
||||
"backend": "nvenc",
|
||||
"compiled": true,
|
||||
"runtime": true,
|
||||
"codecs": ["h264", "h265"],
|
||||
"zeroCopy": false,
|
||||
"nativeInputs": ["dmabuf"]
|
||||
},
|
||||
"expectedNativeNvenc": false
|
||||
},
|
||||
{
|
||||
"name": "native NVENC without native zero-copy input handles is not supported",
|
||||
"input": {
|
||||
"available": true,
|
||||
"backend": "nvenc",
|
||||
"compiled": true,
|
||||
"runtime": true,
|
||||
"codecs": ["h264", "h265"],
|
||||
"zeroCopy": true,
|
||||
"nativeInputs": []
|
||||
},
|
||||
"codec": "h264",
|
||||
"expectedCapabilities": {
|
||||
"available": true,
|
||||
"backend": "nvenc",
|
||||
"compiled": true,
|
||||
"runtime": true,
|
||||
"codecs": ["h264", "h265"],
|
||||
"zeroCopy": true,
|
||||
"nativeInputs": []
|
||||
},
|
||||
"expectedNativeNvenc": false
|
||||
},
|
||||
{
|
||||
"name": "non-NVENC backends are not selected by NVENC policy",
|
||||
"input": {
|
||||
"available": true,
|
||||
"backend": "videotoolbox",
|
||||
"compiled": true,
|
||||
"runtime": true,
|
||||
"codecs": ["h264", "h265"],
|
||||
"zeroCopy": true,
|
||||
"nativeInputs": ["cvPixelBuffer"]
|
||||
},
|
||||
"codec": "h265",
|
||||
"expectedCapabilities": {
|
||||
"available": true,
|
||||
"backend": "videotoolbox",
|
||||
"compiled": true,
|
||||
"runtime": true,
|
||||
"codecs": ["h264", "h265"],
|
||||
"zeroCopy": true,
|
||||
"nativeInputs": ["cvPixelBuffer"]
|
||||
},
|
||||
"expectedNativeNvenc": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
[
|
||||
{
|
||||
"name": "camera unpublish suppressed during reconnect when video restore is pending",
|
||||
"input": {"source": "camera", "enabled": false, "reconnecting": true, "restoreVideo": true, "restoreStream": false},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"name": "screen share unpublish suppressed during reconnect when stream restore is pending",
|
||||
"input": {
|
||||
"source": "screen_share",
|
||||
"enabled": false,
|
||||
"reconnecting": true,
|
||||
"restoreVideo": false,
|
||||
"restoreStream": true
|
||||
},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"name": "publish events are not suppressed",
|
||||
"input": {
|
||||
"source": "screen_share",
|
||||
"enabled": true,
|
||||
"reconnecting": true,
|
||||
"restoreVideo": false,
|
||||
"restoreStream": true
|
||||
},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"name": "inactive reconnect is not suppressed",
|
||||
"input": {
|
||||
"source": "screen_share",
|
||||
"enabled": false,
|
||||
"reconnecting": false,
|
||||
"restoreVideo": false,
|
||||
"restoreStream": true
|
||||
},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"name": "camera without video restore is not suppressed",
|
||||
"input": {"source": "camera", "enabled": false, "reconnecting": true, "restoreVideo": false, "restoreStream": true},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"name": "microphone is not suppressed",
|
||||
"input": {
|
||||
"source": "microphone",
|
||||
"enabled": false,
|
||||
"reconnecting": true,
|
||||
"restoreVideo": true,
|
||||
"restoreStream": true
|
||||
},
|
||||
"expected": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,175 @@
|
||||
[
|
||||
{
|
||||
"name": "successful operation",
|
||||
"result": {"ok": true},
|
||||
"requestedEnabled": true,
|
||||
"expected": "none"
|
||||
},
|
||||
{
|
||||
"name": "disabled microphone not connected",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "not-connected",
|
||||
"message": "Native voice engine is not connected"
|
||||
}
|
||||
},
|
||||
"requestedEnabled": false,
|
||||
"expected": "none"
|
||||
},
|
||||
{
|
||||
"name": "unsupported microphone capture",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "unsupported-capability",
|
||||
"capability": "microphoneCapture",
|
||||
"message": "Native voice engine addon does not support device microphone capture"
|
||||
}
|
||||
},
|
||||
"requestedEnabled": true,
|
||||
"expected": "crash"
|
||||
},
|
||||
{
|
||||
"name": "invalid args",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "invalid-args",
|
||||
"message": "Invalid voice-engine publish-microphone args"
|
||||
}
|
||||
},
|
||||
"requestedEnabled": true,
|
||||
"expected": "crash"
|
||||
},
|
||||
{
|
||||
"name": "modern not connected",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "not-connected",
|
||||
"capability": "microphoneCapture",
|
||||
"message": "Native voice engine is not connected"
|
||||
}
|
||||
},
|
||||
"requestedEnabled": true,
|
||||
"expected": "defer"
|
||||
},
|
||||
{
|
||||
"name": "legacy not connected",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "native-error",
|
||||
"capability": "microphoneCapture",
|
||||
"message": "not connected"
|
||||
}
|
||||
},
|
||||
"requestedEnabled": true,
|
||||
"expected": "defer"
|
||||
},
|
||||
{
|
||||
"name": "device unavailable",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "native-error",
|
||||
"capability": "microphoneCapture",
|
||||
"message": "device unavailable"
|
||||
}
|
||||
},
|
||||
"requestedEnabled": true,
|
||||
"expected": "mute"
|
||||
},
|
||||
{
|
||||
"name": "timeout failure defers without muting",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "timeout",
|
||||
"message": "Voice engine v2 microphone enable timed out"
|
||||
}
|
||||
},
|
||||
"requestedEnabled": true,
|
||||
"expected": "defer"
|
||||
},
|
||||
{
|
||||
"name": "cancelled failure defers without muting",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "cancelled",
|
||||
"message": "Voice engine v2 microphone enable cancelled"
|
||||
}
|
||||
},
|
||||
"requestedEnabled": true,
|
||||
"expected": "defer"
|
||||
},
|
||||
{
|
||||
"name": "livekit transport failure defers without muting",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "liveKitError",
|
||||
"message": "could not establish pc connection"
|
||||
}
|
||||
},
|
||||
"requestedEnabled": true,
|
||||
"expected": "defer"
|
||||
},
|
||||
{
|
||||
"name": "opaque failure during reconnect defers without muting",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "native-error",
|
||||
"capability": "microphoneCapture",
|
||||
"message": "engine resume in progress"
|
||||
}
|
||||
},
|
||||
"requestedEnabled": true,
|
||||
"context": {"reconnecting": true},
|
||||
"expected": "defer"
|
||||
},
|
||||
{
|
||||
"name": "opaque failure while stable still mutes",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "native-error",
|
||||
"capability": "microphoneCapture",
|
||||
"message": "engine resume in progress"
|
||||
}
|
||||
},
|
||||
"requestedEnabled": true,
|
||||
"context": {"reconnecting": false},
|
||||
"expected": "mute"
|
||||
},
|
||||
{
|
||||
"name": "invalid args during reconnect still crashes",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "invalid-args",
|
||||
"message": "Invalid voice-engine publish-microphone args"
|
||||
}
|
||||
},
|
||||
"requestedEnabled": true,
|
||||
"context": {"reconnecting": true},
|
||||
"expected": "crash"
|
||||
},
|
||||
{
|
||||
"name": "disable failure during reconnect stays none",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "native-error",
|
||||
"capability": "microphoneCapture",
|
||||
"message": "engine resume in progress"
|
||||
}
|
||||
},
|
||||
"requestedEnabled": false,
|
||||
"context": {"reconnecting": true},
|
||||
"expected": "none"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,98 @@
|
||||
[
|
||||
{
|
||||
"name": "retry while accepted connection is still connecting before gateway echo",
|
||||
"input": {
|
||||
"connectionId": "connection-a",
|
||||
"guildId": "guild-a",
|
||||
"channelId": "channel-a",
|
||||
"voiceState": null,
|
||||
"connectionState": {
|
||||
"connectionId": "connection-a",
|
||||
"guildId": "guild-a",
|
||||
"channelId": "channel-a",
|
||||
"connecting": true,
|
||||
"connected": false,
|
||||
"reconnecting": false
|
||||
}
|
||||
},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"name": "retry when gateway voice state confirms channel",
|
||||
"input": {
|
||||
"connectionId": "connection-a",
|
||||
"guildId": "guild-a",
|
||||
"channelId": "channel-a",
|
||||
"voiceState": {
|
||||
"guild_id": "guild-a",
|
||||
"channel_id": "channel-a"
|
||||
},
|
||||
"connectionState": {
|
||||
"connectionId": "stale-connection",
|
||||
"guildId": "guild-a",
|
||||
"channelId": "channel-a",
|
||||
"connecting": false,
|
||||
"connected": false,
|
||||
"reconnecting": false
|
||||
}
|
||||
},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"name": "reject stale local connection",
|
||||
"input": {
|
||||
"connectionId": "connection-a",
|
||||
"guildId": "guild-a",
|
||||
"channelId": "channel-a",
|
||||
"voiceState": null,
|
||||
"connectionState": {
|
||||
"connectionId": "connection-b",
|
||||
"guildId": "guild-a",
|
||||
"channelId": "channel-a",
|
||||
"connecting": true,
|
||||
"connected": false,
|
||||
"reconnecting": false
|
||||
}
|
||||
},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"name": "reject inactive accepted connection",
|
||||
"input": {
|
||||
"connectionId": "connection-a",
|
||||
"guildId": "guild-a",
|
||||
"channelId": "channel-a",
|
||||
"voiceState": null,
|
||||
"connectionState": {
|
||||
"connectionId": "connection-a",
|
||||
"guildId": "guild-a",
|
||||
"channelId": "channel-a",
|
||||
"connecting": false,
|
||||
"connected": false,
|
||||
"reconnecting": false
|
||||
}
|
||||
},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"name": "treat null guild ids as equivalent private voice scope",
|
||||
"input": {
|
||||
"connectionId": "connection-a",
|
||||
"guildId": null,
|
||||
"channelId": "dm-channel-a",
|
||||
"voiceState": {
|
||||
"guild_id": null,
|
||||
"channel_id": "dm-channel-a"
|
||||
},
|
||||
"connectionState": {
|
||||
"connectionId": "connection-b",
|
||||
"guildId": null,
|
||||
"channelId": "dm-channel-a",
|
||||
"connecting": false,
|
||||
"connected": false,
|
||||
"reconnecting": false
|
||||
}
|
||||
},
|
||||
"expected": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"name": "local mute wins",
|
||||
"input": {"userVolumePercent": 200, "outputVolumePercent": 100, "locallyMuted": true},
|
||||
"expected": 0
|
||||
},
|
||||
{
|
||||
"name": "unity gain",
|
||||
"input": {"userVolumePercent": 100, "outputVolumePercent": 100, "locallyMuted": false},
|
||||
"expected": 1
|
||||
},
|
||||
{
|
||||
"name": "max user volume reaches ceiling",
|
||||
"input": {"userVolumePercent": 200, "outputVolumePercent": 100, "locallyMuted": false},
|
||||
"expected": 2
|
||||
},
|
||||
{
|
||||
"name": "quiet participant below unity",
|
||||
"input": {"userVolumePercent": 50, "outputVolumePercent": 100, "locallyMuted": false},
|
||||
"expectedRange": {"minExclusive": 0, "maxExclusive": 1}
|
||||
},
|
||||
{
|
||||
"name": "zero composed volume",
|
||||
"input": {"userVolumePercent": 0, "outputVolumePercent": 100, "locallyMuted": false},
|
||||
"expected": 0
|
||||
},
|
||||
{
|
||||
"name": "non finite values fall back to unity inputs",
|
||||
"input": {"userVolumePercent": null, "outputVolumePercent": null, "locallyMuted": false},
|
||||
"expected": 1
|
||||
},
|
||||
{
|
||||
"name": "oversized values clamp to native range",
|
||||
"input": {"userVolumePercent": 999, "outputVolumePercent": 999, "locallyMuted": false},
|
||||
"expectedRange": {"minInclusive": 0, "maxInclusive": 2}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
[
|
||||
{
|
||||
"name": "dimensions and bitrate update in place",
|
||||
"input": {
|
||||
"published": {
|
||||
"captureId": "capture-1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"codec": "h264",
|
||||
"maxFramerate": 60,
|
||||
"maxBitrateBps": 8000000
|
||||
},
|
||||
"desired": null,
|
||||
"update": {
|
||||
"captureId": "capture-1",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"frameRate": 30,
|
||||
"maxBitrateBps": 3000000
|
||||
}
|
||||
},
|
||||
"expectedAction": "updateEncoding",
|
||||
"expectedReason": "dimensionsOrBitrateChanged",
|
||||
"expectedCodec": "h264"
|
||||
},
|
||||
{
|
||||
"name": "codec change republishes",
|
||||
"input": {
|
||||
"published": {
|
||||
"captureId": "capture-1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"codec": "h264"
|
||||
},
|
||||
"desired": null,
|
||||
"update": {
|
||||
"captureId": "capture-1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"codec": "h265"
|
||||
}
|
||||
},
|
||||
"expectedAction": "republish",
|
||||
"expectedReason": "codecChanged",
|
||||
"expectedCodec": "h265"
|
||||
},
|
||||
{
|
||||
"name": "hardware encoder change republishes",
|
||||
"input": {
|
||||
"published": {
|
||||
"captureId": "capture-1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"codec": "h264",
|
||||
"hardwareEncoding": false
|
||||
},
|
||||
"desired": null,
|
||||
"update": {
|
||||
"captureId": "capture-1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"hardwareEncoding": true,
|
||||
"zeroCopyRequired": true
|
||||
}
|
||||
},
|
||||
"expectedAction": "republish",
|
||||
"expectedReason": "hardwareEncoderChanged",
|
||||
"expectedHardwareEncoding": true
|
||||
},
|
||||
{
|
||||
"name": "zero-copy requirement change republishes",
|
||||
"input": {
|
||||
"published": {
|
||||
"captureId": "capture-1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"codec": "h264",
|
||||
"zeroCopyRequired": false
|
||||
},
|
||||
"desired": null,
|
||||
"update": {
|
||||
"captureId": "capture-1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"zeroCopyRequired": true
|
||||
}
|
||||
},
|
||||
"expectedAction": "republish",
|
||||
"expectedReason": "zeroCopyRequirementChanged",
|
||||
"expectedZeroCopyRequired": true
|
||||
},
|
||||
{
|
||||
"name": "capture mismatch rejects",
|
||||
"input": {
|
||||
"published": {
|
||||
"captureId": "capture-1",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"codec": "h264"
|
||||
},
|
||||
"desired": null,
|
||||
"update": {
|
||||
"captureId": "capture-2",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
}
|
||||
},
|
||||
"expectedAction": "reject",
|
||||
"expectedReason": "captureMismatch",
|
||||
"expectedErrorCode": "invalidArgument"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,188 @@
|
||||
[
|
||||
{
|
||||
"name": "coerces malformed sparse payload to safe defaults",
|
||||
"input": {
|
||||
"rttMs": "bad",
|
||||
"droppedVideoFrameCallbacks": -2,
|
||||
"droppedNativeVideoFrames": 3.8,
|
||||
"outbound": [
|
||||
null,
|
||||
{
|
||||
"trackSid": "",
|
||||
"source": 42,
|
||||
"kind": "data",
|
||||
"bitrateKbps": "bad",
|
||||
"packetsLost": null,
|
||||
"width": 1920.9,
|
||||
"framesDropped": -4.2,
|
||||
"zeroCopy": false
|
||||
}
|
||||
],
|
||||
"inbound": [
|
||||
null,
|
||||
{
|
||||
"participantSid": "PA_1",
|
||||
"trackSid": "TR_audio",
|
||||
"kind": "audio",
|
||||
"bitrateKbps": 42.5,
|
||||
"packetsLost": 2,
|
||||
"jitterMs": "bad",
|
||||
"audioLevel": 0.5
|
||||
}
|
||||
],
|
||||
"send": null
|
||||
},
|
||||
"expected": {
|
||||
"rttMs": null,
|
||||
"droppedVideoFrameCallbacks": 0,
|
||||
"droppedNativeVideoFrames": 3,
|
||||
"outbound": [
|
||||
{
|
||||
"trackSid": "",
|
||||
"source": "",
|
||||
"kind": "audio",
|
||||
"bitrateKbps": 0,
|
||||
"packetsLost": 0,
|
||||
"width": 1920,
|
||||
"framesDropped": 0
|
||||
}
|
||||
],
|
||||
"inbound": [
|
||||
{
|
||||
"participantSid": "PA_1",
|
||||
"trackSid": "TR_audio",
|
||||
"kind": "audio",
|
||||
"bitrateKbps": 42.5,
|
||||
"packetsLost": 2,
|
||||
"audioLevel": 0.5
|
||||
}
|
||||
],
|
||||
"send": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preserves native video diagnostics and clamps send stats",
|
||||
"input": {
|
||||
"rttMs": 27,
|
||||
"droppedVideoFrameCallbacks": 2,
|
||||
"outbound": [
|
||||
{
|
||||
"trackSid": "TR_screen",
|
||||
"source": "screen",
|
||||
"kind": "video",
|
||||
"codec": "video/H265",
|
||||
"bitrateKbps": 2500.25,
|
||||
"packetsLost": 1,
|
||||
"fps": 59.5,
|
||||
"audioLevel": 0.1,
|
||||
"width": 2176,
|
||||
"height": 1200,
|
||||
"sourceWidth": 2176,
|
||||
"sourceHeight": 1200,
|
||||
"targetBitrateKbps": 50000,
|
||||
"configuredFps": 60,
|
||||
"targetFps": 60,
|
||||
"effectiveFps": 58.7,
|
||||
"framesProduced": 120.8,
|
||||
"framesAccepted": 119.4,
|
||||
"framesDropped": 1.9,
|
||||
"framesCoalesced": 0,
|
||||
"framesCaptured": 118.2,
|
||||
"captureFailures": 0,
|
||||
"maxQueueAgeMs": 12.5,
|
||||
"maxPushLatencyMs": 8.25,
|
||||
"adaptiveSendTier": "full",
|
||||
"adaptiveSendReason": "disabled",
|
||||
"zeroCopy": true
|
||||
}
|
||||
],
|
||||
"inbound": [],
|
||||
"send": {
|
||||
"outgoingVideoQueueDepth": 4,
|
||||
"outgoingVideoQueueCapacity": 8,
|
||||
"outgoingVideoMaxQueueDepth": 6,
|
||||
"outgoingVideoFramesProduced": 120,
|
||||
"outgoingVideoFramesAccepted": 119,
|
||||
"outgoingVideoFramesDropped": 1,
|
||||
"outgoingVideoFramesCoalesced": 0,
|
||||
"outgoingVideoFramesCaptured": 118,
|
||||
"outgoingVideoCaptureFailures": -1,
|
||||
"outgoingVideoEffectiveFps": 58.7,
|
||||
"outgoingVideoTargetFps": 60,
|
||||
"outgoingVideoPacingTargetFps": 60,
|
||||
"outgoingVideoMaxQueueAgeMs": 12.5,
|
||||
"outgoingVideoMaxPushLatencyMs": 8.25,
|
||||
"outgoingVideoPacingMode": "source",
|
||||
"outgoingVideoBusActive": true,
|
||||
"outgoingAudioBufferTargetMs": 20,
|
||||
"outgoingAudioBufferMaxMs": 80,
|
||||
"outgoingAudioUnderruns": 0,
|
||||
"outgoingAudioRebuffers": 1,
|
||||
"outgoingAudioMaxFrameGapMs": 24,
|
||||
"adaptiveSendTier": "full",
|
||||
"adaptiveSendReason": "disabled"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"rttMs": 27,
|
||||
"droppedVideoFrameCallbacks": 2,
|
||||
"outbound": [
|
||||
{
|
||||
"trackSid": "TR_screen",
|
||||
"source": "screen",
|
||||
"kind": "video",
|
||||
"codec": "video/H265",
|
||||
"bitrateKbps": 2500.25,
|
||||
"packetsLost": 1,
|
||||
"fps": 59.5,
|
||||
"audioLevel": 0.1,
|
||||
"width": 2176,
|
||||
"height": 1200,
|
||||
"sourceWidth": 2176,
|
||||
"sourceHeight": 1200,
|
||||
"targetBitrateKbps": 50000,
|
||||
"configuredFps": 60,
|
||||
"targetFps": 60,
|
||||
"effectiveFps": 58.7,
|
||||
"framesProduced": 120,
|
||||
"framesAccepted": 119,
|
||||
"framesDropped": 1,
|
||||
"framesCoalesced": 0,
|
||||
"framesCaptured": 118,
|
||||
"captureFailures": 0,
|
||||
"maxQueueAgeMs": 12.5,
|
||||
"maxPushLatencyMs": 8.25,
|
||||
"adaptiveSendTier": "full",
|
||||
"adaptiveSendReason": "disabled",
|
||||
"zeroCopy": true
|
||||
}
|
||||
],
|
||||
"inbound": [],
|
||||
"send": {
|
||||
"outgoingVideoQueueDepth": 4,
|
||||
"outgoingVideoQueueCapacity": 8,
|
||||
"outgoingVideoMaxQueueDepth": 6,
|
||||
"outgoingVideoFramesProduced": 120,
|
||||
"outgoingVideoFramesAccepted": 119,
|
||||
"outgoingVideoFramesDropped": 1,
|
||||
"outgoingVideoFramesCoalesced": 0,
|
||||
"outgoingVideoFramesCaptured": 118,
|
||||
"outgoingVideoCaptureFailures": 0,
|
||||
"outgoingVideoEffectiveFps": 58.7,
|
||||
"outgoingVideoTargetFps": 60,
|
||||
"outgoingVideoPacingTargetFps": 60,
|
||||
"outgoingVideoMaxQueueAgeMs": 12.5,
|
||||
"outgoingVideoMaxPushLatencyMs": 8.25,
|
||||
"outgoingVideoPacingMode": "source",
|
||||
"outgoingVideoBusActive": true,
|
||||
"outgoingAudioBufferTargetMs": 20,
|
||||
"outgoingAudioBufferMaxMs": 80,
|
||||
"outgoingAudioUnderruns": 0,
|
||||
"outgoingAudioRebuffers": 1,
|
||||
"outgoingAudioMaxFrameGapMs": 24,
|
||||
"adaptiveSendTier": "full",
|
||||
"adaptiveSendReason": "disabled"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,382 @@
|
||||
[
|
||||
{
|
||||
"name": "splits outbound camera and screen-share video by source",
|
||||
"input": {
|
||||
"rttMs": null,
|
||||
"outbound": [
|
||||
{
|
||||
"trackSid": "cam",
|
||||
"source": "camera",
|
||||
"kind": "video",
|
||||
"bitrateKbps": 500,
|
||||
"packetsLost": 0,
|
||||
"packetsSent": 500,
|
||||
"fps": 30
|
||||
},
|
||||
{
|
||||
"trackSid": "ss",
|
||||
"source": "screen_share",
|
||||
"kind": "video",
|
||||
"bitrateKbps": 3000,
|
||||
"packetsLost": 1,
|
||||
"packetsSent": 100,
|
||||
"fps": 60
|
||||
}
|
||||
],
|
||||
"inbound": []
|
||||
},
|
||||
"expectedSummary": {
|
||||
"network": {
|
||||
"audioSendBitrateKbps": 0,
|
||||
"audioRecvBitrateKbps": 0,
|
||||
"videoSendBitrateKbps": 3500,
|
||||
"videoRecvBitrateKbps": 0,
|
||||
"audioPacketLossPercent": 0,
|
||||
"videoPacketLossPercent": 1,
|
||||
"jitterMs": 0,
|
||||
"rttMs": null
|
||||
},
|
||||
"localVideo": {"trackIdentifier": "cam", "framesPerSecond": 30},
|
||||
"localScreenShare": {"trackIdentifier": "ss", "framesPerSecond": 60}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "normalizes screenshare alias without underscore",
|
||||
"input": {
|
||||
"rttMs": null,
|
||||
"outbound": [{"trackSid": "ss", "source": "screenshare", "kind": "video", "bitrateKbps": 1000, "packetsLost": 0}],
|
||||
"inbound": []
|
||||
},
|
||||
"expectedSummary": {
|
||||
"network": {
|
||||
"audioSendBitrateKbps": 0,
|
||||
"audioRecvBitrateKbps": 0,
|
||||
"videoSendBitrateKbps": 1000,
|
||||
"videoRecvBitrateKbps": 0,
|
||||
"audioPacketLossPercent": 0,
|
||||
"videoPacketLossPercent": 0,
|
||||
"jitterMs": 0,
|
||||
"rttMs": null
|
||||
},
|
||||
"localVideo": null,
|
||||
"localScreenShare": {"trackIdentifier": "ss"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preserves native screen-share codec dimensions fps and diagnostics",
|
||||
"input": {
|
||||
"rttMs": 1,
|
||||
"outbound": [
|
||||
{
|
||||
"trackSid": "TR_screen",
|
||||
"source": "screenshare",
|
||||
"kind": "video",
|
||||
"codec": "video/H265",
|
||||
"bitrateKbps": 2500,
|
||||
"packetsLost": 0,
|
||||
"fps": 14,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"sourceWidth": 2176,
|
||||
"sourceHeight": 1200,
|
||||
"targetBitrateKbps": 50000,
|
||||
"configuredFps": 60,
|
||||
"targetFps": 60,
|
||||
"effectiveFps": 58.7,
|
||||
"framesProduced": 120,
|
||||
"framesAccepted": 119,
|
||||
"framesDropped": 1,
|
||||
"framesCoalesced": 0,
|
||||
"framesCaptured": 118,
|
||||
"captureFailures": 0,
|
||||
"maxQueueAgeMs": 12.5,
|
||||
"maxPushLatencyMs": 8.25,
|
||||
"adaptiveSendTier": "full",
|
||||
"adaptiveSendReason": "disabled"
|
||||
}
|
||||
],
|
||||
"inbound": []
|
||||
},
|
||||
"expectedSummary": {
|
||||
"network": {
|
||||
"audioSendBitrateKbps": 0,
|
||||
"audioRecvBitrateKbps": 0,
|
||||
"videoSendBitrateKbps": 2500,
|
||||
"videoRecvBitrateKbps": 0,
|
||||
"audioPacketLossPercent": 0,
|
||||
"videoPacketLossPercent": 0,
|
||||
"jitterMs": 0,
|
||||
"rttMs": 1
|
||||
},
|
||||
"localScreenShare": {
|
||||
"trackIdentifier": "TR_screen",
|
||||
"codec": "video/H265",
|
||||
"framesPerSecond": 58.7,
|
||||
"configuredFramesPerSecond": 60,
|
||||
"targetFramesPerSecond": 60,
|
||||
"effectiveFramesPerSecond": 58.7,
|
||||
"frameWidth": 1280,
|
||||
"frameHeight": 720,
|
||||
"sourceFrameWidth": 2176,
|
||||
"sourceFrameHeight": 1200,
|
||||
"targetBitrateKbps": 50000,
|
||||
"framesProduced": 120,
|
||||
"framesAccepted": 119,
|
||||
"framesDropped": 1,
|
||||
"framesCaptured": 118,
|
||||
"maxQueueAgeMs": 12.5,
|
||||
"maxPushLatencyMs": 8.25,
|
||||
"adaptiveSendTier": "full",
|
||||
"adaptiveSendReason": "disabled"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "coalesces duplicate native outbound rows for one screen-share publication",
|
||||
"input": {
|
||||
"rttMs": null,
|
||||
"outbound": [
|
||||
{
|
||||
"trackSid": "TR_screen",
|
||||
"source": "screen_share",
|
||||
"kind": "video",
|
||||
"codec": "video/H265",
|
||||
"bitrateKbps": 269.9,
|
||||
"packetsLost": 0,
|
||||
"fps": 3,
|
||||
"effectiveFps": 53.9,
|
||||
"configuredFps": 60,
|
||||
"targetFps": 60,
|
||||
"width": 2176,
|
||||
"height": 1200,
|
||||
"sourceWidth": 2176,
|
||||
"sourceHeight": 1200,
|
||||
"targetBitrateKbps": 50000,
|
||||
"framesProduced": 1904,
|
||||
"framesAccepted": 1904,
|
||||
"framesDropped": 64,
|
||||
"framesCoalesced": 64,
|
||||
"framesCaptured": 1839,
|
||||
"captureFailures": 0,
|
||||
"maxQueueAgeMs": 18,
|
||||
"maxPushLatencyMs": 18,
|
||||
"adaptiveSendTier": "full",
|
||||
"adaptiveSendReason": "adaptiveDisabled"
|
||||
},
|
||||
{
|
||||
"trackSid": "TR_screen",
|
||||
"source": "screenshare",
|
||||
"kind": "video",
|
||||
"codec": "video/H265",
|
||||
"bitrateKbps": 4614.1,
|
||||
"packetsLost": 0,
|
||||
"fps": 30
|
||||
}
|
||||
],
|
||||
"inbound": []
|
||||
},
|
||||
"expectedSummary": {
|
||||
"network": {
|
||||
"audioSendBitrateKbps": 0,
|
||||
"audioRecvBitrateKbps": 0,
|
||||
"videoSendBitrateKbps": 4884,
|
||||
"videoRecvBitrateKbps": 0,
|
||||
"audioPacketLossPercent": 0,
|
||||
"videoPacketLossPercent": 0,
|
||||
"jitterMs": 0,
|
||||
"rttMs": null
|
||||
},
|
||||
"localScreenShare": {
|
||||
"trackIdentifier": "TR_screen",
|
||||
"codec": "video/H265",
|
||||
"bitrateKbps": 4884,
|
||||
"framesPerSecond": 53.9,
|
||||
"configuredFramesPerSecond": 60,
|
||||
"targetFramesPerSecond": 60,
|
||||
"frameWidth": 2176,
|
||||
"frameHeight": 1200,
|
||||
"targetBitrateKbps": 50000,
|
||||
"adaptiveSendTier": "full",
|
||||
"adaptiveSendReason": "adaptiveDisabled"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "splits screen-share audio from microphone audio",
|
||||
"input": {
|
||||
"rttMs": null,
|
||||
"outbound": [
|
||||
{"trackSid": "mic", "source": "microphone", "kind": "audio", "bitrateKbps": 48, "packetsLost": 0},
|
||||
{"trackSid": "ss-audio", "source": "screen_share_audio", "kind": "audio", "bitrateKbps": 96, "packetsLost": 0}
|
||||
],
|
||||
"inbound": []
|
||||
},
|
||||
"expectedSummary": {
|
||||
"network": {
|
||||
"audioSendBitrateKbps": 144,
|
||||
"audioRecvBitrateKbps": 0,
|
||||
"videoSendBitrateKbps": 0,
|
||||
"videoRecvBitrateKbps": 0,
|
||||
"audioPacketLossPercent": 0,
|
||||
"videoPacketLossPercent": 0,
|
||||
"jitterMs": 0,
|
||||
"rttMs": null
|
||||
},
|
||||
"localAudio": {"trackIdentifier": "mic"},
|
||||
"localScreenShareAudio": {"trackIdentifier": "ss-audio"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rolls up inbound audio video jitter rtt and callback drops",
|
||||
"input": {
|
||||
"rttMs": 25,
|
||||
"droppedVideoFrameCallbacks": 3,
|
||||
"outbound": [],
|
||||
"inbound": [
|
||||
{
|
||||
"participantSid": "PA_1",
|
||||
"trackSid": "a",
|
||||
"kind": "audio",
|
||||
"bitrateKbps": 32,
|
||||
"packetsLost": 2,
|
||||
"packetsReceived": 98,
|
||||
"jitterMs": 8
|
||||
},
|
||||
{
|
||||
"participantSid": "PA_1",
|
||||
"trackSid": "v",
|
||||
"kind": "video",
|
||||
"bitrateKbps": 900,
|
||||
"packetsLost": 0,
|
||||
"packetsReceived": 1500
|
||||
}
|
||||
]
|
||||
},
|
||||
"expectedSummary": {
|
||||
"network": {
|
||||
"audioSendBitrateKbps": 0,
|
||||
"audioRecvBitrateKbps": 32,
|
||||
"videoSendBitrateKbps": 0,
|
||||
"videoRecvBitrateKbps": 900,
|
||||
"audioPacketLossPercent": 2,
|
||||
"videoPacketLossPercent": 0,
|
||||
"jitterMs": 8,
|
||||
"rttMs": 25
|
||||
},
|
||||
"remoteAudio": {"trackIdentifier": "a"},
|
||||
"remoteVideo": {"trackIdentifier": "v"},
|
||||
"remoteScreenShare": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "uses worst case audio packet loss across send and receive",
|
||||
"input": {
|
||||
"rttMs": null,
|
||||
"outbound": [
|
||||
{
|
||||
"trackSid": "a",
|
||||
"source": "microphone",
|
||||
"kind": "audio",
|
||||
"bitrateKbps": 30,
|
||||
"packetsLost": 5,
|
||||
"packetsSent": 100
|
||||
}
|
||||
],
|
||||
"inbound": [
|
||||
{
|
||||
"participantSid": "PA_1",
|
||||
"trackSid": "b",
|
||||
"kind": "audio",
|
||||
"bitrateKbps": 30,
|
||||
"packetsLost": 12,
|
||||
"packetsReceived": 88
|
||||
}
|
||||
]
|
||||
},
|
||||
"expectedSummary": {
|
||||
"network": {
|
||||
"audioSendBitrateKbps": 30,
|
||||
"audioRecvBitrateKbps": 30,
|
||||
"videoSendBitrateKbps": 0,
|
||||
"videoRecvBitrateKbps": 0,
|
||||
"audioPacketLossPercent": 12,
|
||||
"videoPacketLossPercent": 0,
|
||||
"jitterMs": 0,
|
||||
"rttMs": null
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reports inbound video loss as a windowed percentage not a raw packet count",
|
||||
"input": {
|
||||
"rttMs": 1,
|
||||
"outbound": [],
|
||||
"inbound": [
|
||||
{
|
||||
"participantSid": "PA_1",
|
||||
"trackSid": "v",
|
||||
"kind": "video",
|
||||
"bitrateKbps": 147,
|
||||
"packetsLost": 13,
|
||||
"packetsReceived": 4000
|
||||
}
|
||||
]
|
||||
},
|
||||
"expectedSummary": {
|
||||
"network": {
|
||||
"audioPacketLossPercent": 0,
|
||||
"videoPacketLossPercent": 0.3,
|
||||
"rttMs": 1
|
||||
},
|
||||
"remoteVideo": {"trackIdentifier": "v"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "handles empty stats as zero network and null tracks",
|
||||
"input": {
|
||||
"rttMs": null,
|
||||
"outbound": [],
|
||||
"inbound": []
|
||||
},
|
||||
"expectedSummary": {
|
||||
"network": {
|
||||
"audioSendBitrateKbps": 0,
|
||||
"audioRecvBitrateKbps": 0,
|
||||
"videoSendBitrateKbps": 0,
|
||||
"videoRecvBitrateKbps": 0,
|
||||
"audioPacketLossPercent": 0,
|
||||
"videoPacketLossPercent": 0,
|
||||
"jitterMs": 0,
|
||||
"rttMs": null
|
||||
},
|
||||
"localAudio": null,
|
||||
"localVideo": null,
|
||||
"remoteAudio": null,
|
||||
"remoteVideo": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "prefers first non-zero bitrate track when several candidates exist",
|
||||
"input": {
|
||||
"rttMs": null,
|
||||
"outbound": [
|
||||
{"trackSid": "silent", "source": "microphone", "kind": "audio", "bitrateKbps": 0, "packetsLost": 0},
|
||||
{"trackSid": "live", "source": "microphone", "kind": "audio", "bitrateKbps": 40, "packetsLost": 0}
|
||||
],
|
||||
"inbound": []
|
||||
},
|
||||
"expectedSummary": {
|
||||
"network": {
|
||||
"audioSendBitrateKbps": 40,
|
||||
"audioRecvBitrateKbps": 0,
|
||||
"videoSendBitrateKbps": 0,
|
||||
"videoRecvBitrateKbps": 0,
|
||||
"audioPacketLossPercent": 0,
|
||||
"videoPacketLossPercent": 0,
|
||||
"jitterMs": 0,
|
||||
"rttMs": null
|
||||
},
|
||||
"localAudio": {"trackIdentifier": "live"}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,123 @@
|
||||
[
|
||||
{
|
||||
"name": "classifies all explicit local and remote publication ids",
|
||||
"input": {
|
||||
"tracks": [
|
||||
{"direction": "send", "kind": "video", "trackIdentifier": "local-camera", "bitrateKbps": 500},
|
||||
{"direction": "send", "kind": "audio", "trackIdentifier": "local-mic", "bitrateKbps": 48},
|
||||
{"direction": "send", "kind": "video", "trackIdentifier": "local-screen", "bitrateKbps": 1200},
|
||||
{"direction": "send", "kind": "audio", "trackIdentifier": "local-screen-audio", "bitrateKbps": 96},
|
||||
{"direction": "recv", "kind": "audio", "trackIdentifier": "remote-mic", "bitrateKbps": 64},
|
||||
{"direction": "recv", "kind": "video", "trackIdentifier": "remote-camera", "bitrateKbps": 900},
|
||||
{"direction": "recv", "kind": "video", "trackIdentifier": "remote-screen", "bitrateKbps": 1800},
|
||||
{"direction": "recv", "kind": "audio", "trackIdentifier": "remote-screen-audio", "bitrateKbps": 128}
|
||||
],
|
||||
"publications": {
|
||||
"localCameraTrackId": "local-camera",
|
||||
"localMicrophoneTrackId": "local-mic",
|
||||
"localScreenShareTrackId": "local-screen",
|
||||
"localScreenShareAudioTrackId": "local-screen-audio",
|
||||
"remoteMicrophoneTrackIds": ["remote-mic"],
|
||||
"remoteScreenShareTrackIds": ["remote-screen"],
|
||||
"remoteScreenShareAudioTrackIds": ["remote-screen-audio"]
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"localVideoTrackIndex": 0,
|
||||
"localAudioTrackIndex": 1,
|
||||
"localScreenShareTrackIndex": 2,
|
||||
"localScreenShareAudioTrackIndex": 3,
|
||||
"remoteVideoTrackIndex": 5,
|
||||
"remoteAudioTrackIndex": 4,
|
||||
"remoteScreenShareTrackIndex": 6,
|
||||
"remoteScreenShareAudioTrackIndex": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "falls back to rid screen-share and non-rid camera when local ids are missing",
|
||||
"input": {
|
||||
"tracks": [
|
||||
{"direction": "send", "kind": "video", "trackIdentifier": "camera-fallback", "bitrateKbps": 700},
|
||||
{"direction": "send", "kind": "video", "rid": "h", "trackIdentifier": "screen-fallback", "bitrateKbps": 1600},
|
||||
{"direction": "send", "kind": "audio", "trackIdentifier": "local-mic", "bitrateKbps": 48}
|
||||
],
|
||||
"publications": {
|
||||
"localCameraTrackId": null,
|
||||
"localMicrophoneTrackId": null,
|
||||
"localScreenShareTrackId": null,
|
||||
"localScreenShareAudioTrackId": null,
|
||||
"remoteMicrophoneTrackIds": [],
|
||||
"remoteScreenShareTrackIds": [],
|
||||
"remoteScreenShareAudioTrackIds": []
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"localVideoTrackIndex": 0,
|
||||
"localAudioTrackIndex": 2,
|
||||
"localScreenShareTrackIndex": 1,
|
||||
"localScreenShareAudioTrackIndex": null,
|
||||
"remoteVideoTrackIndex": null,
|
||||
"remoteAudioTrackIndex": null,
|
||||
"remoteScreenShareTrackIndex": null,
|
||||
"remoteScreenShareAudioTrackIndex": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "does not classify remote screen-share audio as remote microphone",
|
||||
"input": {
|
||||
"tracks": [
|
||||
{"direction": "recv", "kind": "audio", "trackIdentifier": "remote-screen-audio", "bitrateKbps": 4},
|
||||
{"direction": "recv", "kind": "video", "trackIdentifier": "remote-screen", "bitrateKbps": 1800}
|
||||
],
|
||||
"publications": {
|
||||
"localCameraTrackId": null,
|
||||
"localMicrophoneTrackId": null,
|
||||
"localScreenShareTrackId": null,
|
||||
"localScreenShareAudioTrackId": null,
|
||||
"remoteMicrophoneTrackIds": [],
|
||||
"remoteScreenShareTrackIds": ["remote-screen"],
|
||||
"remoteScreenShareAudioTrackIds": ["remote-screen-audio"]
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"localVideoTrackIndex": null,
|
||||
"localAudioTrackIndex": null,
|
||||
"localScreenShareTrackIndex": null,
|
||||
"localScreenShareAudioTrackIndex": null,
|
||||
"remoteVideoTrackIndex": null,
|
||||
"remoteAudioTrackIndex": null,
|
||||
"remoteScreenShareTrackIndex": 1,
|
||||
"remoteScreenShareAudioTrackIndex": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "chooses active remote screen-share and camera candidates over silent rows",
|
||||
"input": {
|
||||
"tracks": [
|
||||
{"direction": "recv", "kind": "video", "trackIdentifier": "remote-camera-silent", "bitrateKbps": 0},
|
||||
{"direction": "recv", "kind": "video", "trackIdentifier": "remote-camera-live", "bitrateKbps": 900},
|
||||
{"direction": "recv", "kind": "video", "trackIdentifier": "remote-screen-silent", "bitrateKbps": 0},
|
||||
{"direction": "recv", "kind": "video", "trackIdentifier": "remote-screen-live", "bitrateKbps": 1600}
|
||||
],
|
||||
"publications": {
|
||||
"localCameraTrackId": null,
|
||||
"localMicrophoneTrackId": null,
|
||||
"localScreenShareTrackId": null,
|
||||
"localScreenShareAudioTrackId": null,
|
||||
"remoteMicrophoneTrackIds": [],
|
||||
"remoteScreenShareTrackIds": ["remote-screen-silent", "remote-screen-live"],
|
||||
"remoteScreenShareAudioTrackIds": []
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"localVideoTrackIndex": null,
|
||||
"localAudioTrackIndex": null,
|
||||
"localScreenShareTrackIndex": null,
|
||||
"localScreenShareAudioTrackIndex": null,
|
||||
"remoteVideoTrackIndex": 1,
|
||||
"remoteAudioTrackIndex": null,
|
||||
"remoteScreenShareTrackIndex": 3,
|
||||
"remoteScreenShareAudioTrackIndex": null
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@fluxer/voice_engine_v2",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./bridge": "./src/bridge/index.ts",
|
||||
"./core": "./src/core/index.ts",
|
||||
"./implementations": "./src/implementations/index.ts",
|
||||
"./policies": "./src/policies/index.ts",
|
||||
"./ports": "./src/ports/index.ts",
|
||||
"./protocol": "./src/protocol/index.ts",
|
||||
"./runtime": "./src/runtime/index.ts",
|
||||
"./testing": "./src/testing/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"bench": "vitest bench --run",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
runVoiceEngineV2AdmWarmup,
|
||||
VOICE_ENGINE_V2_ADM_WARMUP_POLL_ATTEMPTS_MAX,
|
||||
VOICE_ENGINE_V2_ADM_WARMUP_POLL_INTERVAL_MS,
|
||||
} from './admWarmup';
|
||||
|
||||
describe('runVoiceEngineV2AdmWarmup', () => {
|
||||
it('resolves ready when the first probe succeeds', async () => {
|
||||
let probeCalls = 0;
|
||||
const state = await runVoiceEngineV2AdmWarmup({
|
||||
probe: async () => {
|
||||
probeCalls += 1;
|
||||
return true;
|
||||
},
|
||||
delay: async () => {},
|
||||
});
|
||||
expect(state).toEqual({status: 'ready'});
|
||||
expect(probeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('polls at the fixed cadence until the probe reports ready', async () => {
|
||||
let probeCalls = 0;
|
||||
const delays: Array<number> = [];
|
||||
const state = await runVoiceEngineV2AdmWarmup({
|
||||
probe: async () => {
|
||||
probeCalls += 1;
|
||||
return probeCalls >= 5;
|
||||
},
|
||||
delay: async (durationMs) => {
|
||||
delays.push(durationMs);
|
||||
},
|
||||
});
|
||||
expect(state).toEqual({status: 'ready'});
|
||||
expect(probeCalls).toBe(5);
|
||||
expect(delays.every((durationMs) => durationMs === VOICE_ENGINE_V2_ADM_WARMUP_POLL_INTERVAL_MS)).toBe(true);
|
||||
});
|
||||
|
||||
it('fails after the bounded attempt count when the probe never reports ready', async () => {
|
||||
let probeCalls = 0;
|
||||
const state = await runVoiceEngineV2AdmWarmup({
|
||||
probe: async () => {
|
||||
probeCalls += 1;
|
||||
return false;
|
||||
},
|
||||
delay: async () => {},
|
||||
});
|
||||
expect(state.status).toBe('failed');
|
||||
expect(state.detail).toContain('deadline');
|
||||
expect(probeCalls).toBe(VOICE_ENGINE_V2_ADM_WARMUP_POLL_ATTEMPTS_MAX);
|
||||
});
|
||||
|
||||
it('fails with the probe error detail when the probe throws', async () => {
|
||||
let probeCalls = 0;
|
||||
const state = await runVoiceEngineV2AdmWarmup({
|
||||
probe: async () => {
|
||||
probeCalls += 1;
|
||||
throw new Error('addon exploded');
|
||||
},
|
||||
delay: async () => {},
|
||||
});
|
||||
expect(state.status).toBe('failed');
|
||||
expect(state.detail).toContain('addon exploded');
|
||||
expect(probeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('fails at the deadline without stacking probes when the probe hangs forever', async () => {
|
||||
let probeCalls = 0;
|
||||
const state = await runVoiceEngineV2AdmWarmup({
|
||||
probe: () => {
|
||||
probeCalls += 1;
|
||||
return new Promise<boolean>(() => {});
|
||||
},
|
||||
delay: async () => {},
|
||||
});
|
||||
expect(state.status).toBe('failed');
|
||||
expect(state.detail).toContain('deadline');
|
||||
expect(probeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('resolves ready when a slow probe settles after several poll intervals', async () => {
|
||||
let probeCalls = 0;
|
||||
let resolveProbe: ((ready: boolean) => void) | null = null;
|
||||
let delayCalls = 0;
|
||||
const state = await runVoiceEngineV2AdmWarmup({
|
||||
probe: () => {
|
||||
probeCalls += 1;
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveProbe = resolve;
|
||||
});
|
||||
},
|
||||
delay: async () => {
|
||||
delayCalls += 1;
|
||||
if (delayCalls === 3) {
|
||||
resolveProbe?.(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
expect(state).toEqual({status: 'ready'});
|
||||
expect(probeCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {VoiceEngineV2BridgeAudioDeviceModuleState} from './types';
|
||||
|
||||
export const VOICE_ENGINE_V2_ADM_WARMUP_POLL_INTERVAL_MS = 250;
|
||||
export const VOICE_ENGINE_V2_ADM_WARMUP_DEADLINE_MS = 60_000;
|
||||
export const VOICE_ENGINE_V2_ADM_WARMUP_POLL_ATTEMPTS_MAX = Math.floor(
|
||||
VOICE_ENGINE_V2_ADM_WARMUP_DEADLINE_MS / VOICE_ENGINE_V2_ADM_WARMUP_POLL_INTERVAL_MS,
|
||||
);
|
||||
|
||||
export interface VoiceEngineV2AdmWarmupPorts {
|
||||
probe(): Promise<boolean>;
|
||||
delay(durationMs: number): Promise<void>;
|
||||
}
|
||||
|
||||
type AdmProbeOutcome = {kind: 'ready'; ready: boolean} | {kind: 'error'; detail: string};
|
||||
|
||||
const ADM_PROBE_STILL_PENDING = Symbol('voice-engine-v2-adm-probe-still-pending');
|
||||
|
||||
function startBoundedAdmProbe(ports: VoiceEngineV2AdmWarmupPorts): Promise<AdmProbeOutcome> {
|
||||
return ports.probe().then(
|
||||
(ready) => ({kind: 'ready', ready}),
|
||||
(error) => {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
return {kind: 'error', detail: `audio device module probe failed: ${detail}`};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function runVoiceEngineV2AdmWarmup(
|
||||
ports: VoiceEngineV2AdmWarmupPorts,
|
||||
): Promise<VoiceEngineV2BridgeAudioDeviceModuleState> {
|
||||
if (VOICE_ENGINE_V2_ADM_WARMUP_POLL_ATTEMPTS_MAX < 1) {
|
||||
throw new Error('ADM warmup poll attempt bound must be at least 1');
|
||||
}
|
||||
let pendingProbe: Promise<AdmProbeOutcome> | null = null;
|
||||
for (let attempt = 1; attempt <= VOICE_ENGINE_V2_ADM_WARMUP_POLL_ATTEMPTS_MAX; attempt++) {
|
||||
if (pendingProbe === null) {
|
||||
pendingProbe = startBoundedAdmProbe(ports);
|
||||
}
|
||||
const attemptTimeout = ports.delay(VOICE_ENGINE_V2_ADM_WARMUP_POLL_INTERVAL_MS).then(() => ADM_PROBE_STILL_PENDING);
|
||||
const outcome = await Promise.race([pendingProbe, attemptTimeout]);
|
||||
if (outcome === ADM_PROBE_STILL_PENDING) {
|
||||
continue;
|
||||
}
|
||||
pendingProbe = null;
|
||||
const probeOutcome = outcome as AdmProbeOutcome;
|
||||
if (probeOutcome.kind === 'error') {
|
||||
return {status: 'failed', detail: probeOutcome.detail};
|
||||
}
|
||||
if (probeOutcome.ready) {
|
||||
return {status: 'ready'};
|
||||
}
|
||||
if (attempt < VOICE_ENGINE_V2_ADM_WARMUP_POLL_ATTEMPTS_MAX) {
|
||||
await ports.delay(VOICE_ENGINE_V2_ADM_WARMUP_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 'failed',
|
||||
detail: `audio device module warmup exceeded ${VOICE_ENGINE_V2_ADM_WARMUP_DEADLINE_MS}ms deadline`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import fixtureJson from '../../fixtures/bridge/bridge_contract.json';
|
||||
import {
|
||||
assertAudioFrameInvariants,
|
||||
assertSchemaVersion,
|
||||
assertVideoFrameInvariants,
|
||||
VOICE_ENGINE_V2_AUDIO_FRAME_BYTES_MAX,
|
||||
VOICE_ENGINE_V2_VIDEO_DIMENSION_MAX,
|
||||
VOICE_ENGINE_V2_VIDEO_FRAME_BYTES_MAX,
|
||||
VoiceEngineV2FfiAssertError,
|
||||
} from './ffi_assertions';
|
||||
import {
|
||||
assertVoiceEngineV2BridgeAudioOptionsInvariants,
|
||||
assertVoiceEngineV2BridgeEnvelopeSchema,
|
||||
assertVoiceEngineV2BridgeVideoOptionsInvariants,
|
||||
getVoiceEngineV2BridgeDroppedEventCounts,
|
||||
getVoiceEngineV2BridgeProcessedCameraI420ByteLength,
|
||||
isVoiceEngineV2BridgeFloatPcmFrame,
|
||||
isVoiceEngineV2BridgeKnownEventType,
|
||||
isVoiceEngineV2BridgeProcessedCameraFrame,
|
||||
isVoiceEngineV2BridgePublishCameraOptions,
|
||||
isVoiceEngineV2BridgePublishProcessedCameraOptions,
|
||||
isVoiceEngineV2BridgeReadiness,
|
||||
resetVoiceEngineV2BridgeDroppedEventCounts,
|
||||
translateVoiceEngineV2BridgeEventToEvents,
|
||||
translateVoiceEngineV2BridgeVideoFrameToEvent,
|
||||
VOICE_ENGINE_V2_ADM_STATUS_EVENT_TYPE,
|
||||
VOICE_ENGINE_V2_BRIDGE_METHODS,
|
||||
VOICE_ENGINE_V2_BRIDGE_VERSION,
|
||||
VOICE_ENGINE_V2_ENGINE_READY_EVENT_TYPE,
|
||||
VOICE_ENGINE_V2_EVENT_CHANNELS,
|
||||
VOICE_ENGINE_V2_IPC_CHANNELS,
|
||||
} from './index';
|
||||
|
||||
interface VoiceEngineV2BridgeContractFixture {
|
||||
version: number;
|
||||
methods: Array<string>;
|
||||
ipcChannels: Record<string, string>;
|
||||
eventChannels: Record<string, string>;
|
||||
}
|
||||
|
||||
describe('voice engine v2 bridge contract', () => {
|
||||
it('matches the shared bridge contract fixture', () => {
|
||||
const fixture = fixtureJson as VoiceEngineV2BridgeContractFixture;
|
||||
|
||||
expect(VOICE_ENGINE_V2_BRIDGE_VERSION).toBe(fixture.version);
|
||||
expect(VOICE_ENGINE_V2_BRIDGE_METHODS).toEqual(fixture.methods);
|
||||
expect(VOICE_ENGINE_V2_IPC_CHANNELS).toEqual(fixture.ipcChannels);
|
||||
expect(VOICE_ENGINE_V2_EVENT_CHANNELS).toEqual(fixture.eventChannels);
|
||||
expect(new Set(VOICE_ENGINE_V2_BRIDGE_METHODS).size).toBe(VOICE_ENGINE_V2_BRIDGE_METHODS.length);
|
||||
expect(new Set(Object.values(VOICE_ENGINE_V2_IPC_CHANNELS)).size).toBe(
|
||||
Object.values(VOICE_ENGINE_V2_IPC_CHANNELS).length,
|
||||
);
|
||||
expect(new Set(Object.values(VOICE_ENGINE_V2_EVENT_CHANNELS)).size).toBe(
|
||||
Object.values(VOICE_ENGINE_V2_EVENT_CHANNELS).length,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts the canonical schema version on the FFI boundary', () => {
|
||||
expect(() => assertSchemaVersion(VOICE_ENGINE_V2_BRIDGE_VERSION)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a schema version older than the bridge', () => {
|
||||
try {
|
||||
assertSchemaVersion(VOICE_ENGINE_V2_BRIDGE_VERSION - 1);
|
||||
expect.fail('expected schema mismatch to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('schemaVersionMismatch');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a schema version newer than the bridge', () => {
|
||||
try {
|
||||
assertSchemaVersion(VOICE_ENGINE_V2_BRIDGE_VERSION + 1);
|
||||
expect.fail('expected schema mismatch to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('schemaVersionMismatch');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('voice engine v2 readiness contract', () => {
|
||||
it('registers the readiness method and channel in the shared contract', () => {
|
||||
expect(VOICE_ENGINE_V2_BRIDGE_METHODS).toContain('getVoiceEngineReadiness');
|
||||
expect(VOICE_ENGINE_V2_IPC_CHANNELS.getVoiceEngineReadiness).toBe('voice-engine-v2:get-readiness');
|
||||
});
|
||||
|
||||
it('classifies engineReady as a known bridge event type', () => {
|
||||
expect(VOICE_ENGINE_V2_ENGINE_READY_EVENT_TYPE).toBe('engineReady');
|
||||
expect(isVoiceEngineV2BridgeKnownEventType(VOICE_ENGINE_V2_ENGINE_READY_EVENT_TYPE)).toBe(true);
|
||||
});
|
||||
|
||||
it('translates engineReady as a renderer-gating event with no reducer output', () => {
|
||||
resetVoiceEngineV2BridgeDroppedEventCounts();
|
||||
const events = translateVoiceEngineV2BridgeEventToEvents({
|
||||
type: 'engineReady',
|
||||
payload: {ready: true},
|
||||
});
|
||||
|
||||
expect(events).toEqual([]);
|
||||
const counts = getVoiceEngineV2BridgeDroppedEventCounts();
|
||||
expect(counts.malformedPayloadCount).toBe(0);
|
||||
expect(counts.unknownTypeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('translates audio device module status into a runtime event', () => {
|
||||
resetVoiceEngineV2BridgeDroppedEventCounts();
|
||||
const events = translateVoiceEngineV2BridgeEventToEvents({
|
||||
type: VOICE_ENGINE_V2_ADM_STATUS_EVENT_TYPE,
|
||||
payload: {status: 'warming'},
|
||||
});
|
||||
|
||||
expect(events).toEqual([{type: 'nativeAudioDeviceModule.statusChanged', status: 'warming'}]);
|
||||
const counts = getVoiceEngineV2BridgeDroppedEventCounts();
|
||||
expect(counts.malformedPayloadCount).toBe(0);
|
||||
expect(counts.unknownTypeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('accepts ready and not-ready readiness payloads', () => {
|
||||
expect(isVoiceEngineV2BridgeReadiness({ready: true})).toBe(true);
|
||||
expect(isVoiceEngineV2BridgeReadiness({ready: false})).toBe(true);
|
||||
expect(isVoiceEngineV2BridgeReadiness({ready: false, reason: 'native voice engine prewarm pending'})).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed readiness payloads', () => {
|
||||
expect(isVoiceEngineV2BridgeReadiness(null)).toBe(false);
|
||||
expect(isVoiceEngineV2BridgeReadiness({})).toBe(false);
|
||||
expect(isVoiceEngineV2BridgeReadiness({ready: 'yes'})).toBe(false);
|
||||
expect(isVoiceEngineV2BridgeReadiness({ready: false, reason: 42})).toBe(false);
|
||||
expect(isVoiceEngineV2BridgeReadiness({ready: true, reason: 'ready engines carry no reason'})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('voice engine v2 audio frame invariants', () => {
|
||||
const canonicalAudioFrame = {
|
||||
sampleRateHz: 48000,
|
||||
numChannels: 2,
|
||||
frameBytes: 1920,
|
||||
timestampNs: 1000,
|
||||
};
|
||||
|
||||
it('accepts a canonical audio frame with no previous timestamp', () => {
|
||||
expect(() => assertAudioFrameInvariants(canonicalAudioFrame)).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a canonical audio frame with an older previous timestamp', () => {
|
||||
expect(() => assertAudioFrameInvariants(canonicalAudioFrame, 500)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an audio frame with zero bytes', () => {
|
||||
try {
|
||||
assertAudioFrameInvariants({...canonicalAudioFrame, frameBytes: 0});
|
||||
expect.fail('expected size assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('audioFrameBytesOutOfRange');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an audio frame over the byte cap', () => {
|
||||
try {
|
||||
assertAudioFrameInvariants({...canonicalAudioFrame, frameBytes: VOICE_ENGINE_V2_AUDIO_FRAME_BYTES_MAX + 1});
|
||||
expect.fail('expected size assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('audioFrameBytesOutOfRange');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an audio frame with an unsupported sample rate', () => {
|
||||
try {
|
||||
assertAudioFrameInvariants({...canonicalAudioFrame, sampleRateHz: 44100});
|
||||
expect.fail('expected sample-rate assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('audioSampleRateInvalid');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an audio frame with an unsupported channel count', () => {
|
||||
try {
|
||||
assertAudioFrameInvariants({...canonicalAudioFrame, numChannels: 3});
|
||||
expect.fail('expected channels assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('audioChannelsInvalid');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an audio frame whose timestamp does not advance', () => {
|
||||
try {
|
||||
assertAudioFrameInvariants(canonicalAudioFrame, canonicalAudioFrame.timestampNs);
|
||||
expect.fail('expected timestamp assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('audioTimestampRegressed');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an audio frame whose timestamp moves backwards', () => {
|
||||
try {
|
||||
assertAudioFrameInvariants(canonicalAudioFrame, canonicalAudioFrame.timestampNs + 1);
|
||||
expect.fail('expected timestamp assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('audioTimestampRegressed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('voice engine v2 video frame invariants', () => {
|
||||
const canonicalVideoFrame = {
|
||||
widthPx: 1920,
|
||||
heightPx: 1080,
|
||||
frameBytes: (1920 * 1080 * 3) / 2,
|
||||
timestampNs: 1000,
|
||||
};
|
||||
|
||||
it('accepts a canonical video frame with no previous timestamp', () => {
|
||||
expect(() => assertVideoFrameInvariants(canonicalVideoFrame)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a video frame with zero bytes', () => {
|
||||
try {
|
||||
assertVideoFrameInvariants({...canonicalVideoFrame, frameBytes: 0});
|
||||
expect.fail('expected size assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('videoFrameBytesOutOfRange');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a video frame over the byte cap', () => {
|
||||
try {
|
||||
assertVideoFrameInvariants({...canonicalVideoFrame, frameBytes: VOICE_ENGINE_V2_VIDEO_FRAME_BYTES_MAX + 1});
|
||||
expect.fail('expected size assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('videoFrameBytesOutOfRange');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a video frame with a zero dimension', () => {
|
||||
try {
|
||||
assertVideoFrameInvariants({...canonicalVideoFrame, widthPx: 0});
|
||||
expect.fail('expected dimension assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('videoDimensionOutOfRange');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a video frame with an oversized dimension', () => {
|
||||
try {
|
||||
assertVideoFrameInvariants({...canonicalVideoFrame, heightPx: VOICE_ENGINE_V2_VIDEO_DIMENSION_MAX + 1});
|
||||
expect.fail('expected dimension assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('videoDimensionOutOfRange');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a video frame whose timestamp regresses', () => {
|
||||
try {
|
||||
assertVideoFrameInvariants(canonicalVideoFrame, canonicalVideoFrame.timestampNs);
|
||||
expect.fail('expected timestamp assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('videoTimestampRegressed');
|
||||
}
|
||||
});
|
||||
|
||||
it('documents that inboundVideo.frameReceived will activate this guard once the port leaves shadow', () => {
|
||||
const hotPathTypes = ['inboundVideo.frameReceived', 'nativeCapture.frame'];
|
||||
expect(hotPathTypes.length).toBeGreaterThan(0);
|
||||
for (const type of hotPathTypes) {
|
||||
expect(typeof type).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('voice engine v2 bridge schema version literal', () => {
|
||||
it('pins the TS bridge version to the literal 18 to pair with the native addon bridge', () => {
|
||||
expect(VOICE_ENGINE_V2_BRIDGE_VERSION).toBe(18);
|
||||
});
|
||||
|
||||
it('reuses the schema-version airlock helper from bridge exports', () => {
|
||||
expect(() => assertVoiceEngineV2BridgeEnvelopeSchema(VOICE_ENGINE_V2_BRIDGE_VERSION)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects bridge schema mismatch via the exported airlock helper', () => {
|
||||
try {
|
||||
assertVoiceEngineV2BridgeEnvelopeSchema(VOICE_ENGINE_V2_BRIDGE_VERSION + 1);
|
||||
expect.fail('expected schema mismatch to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('schemaVersionMismatch');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('voice engine v2 float pcm bridge frames', () => {
|
||||
it('accepts Float32Array payloads for native-side PCM conversion', () => {
|
||||
expect(
|
||||
isVoiceEngineV2BridgeFloatPcmFrame({
|
||||
sampleRate: 48_000,
|
||||
numChannels: 1,
|
||||
samples: new Float32Array([0, 0.5, -0.5]),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-float payloads on the float PCM bridge', () => {
|
||||
expect(
|
||||
isVoiceEngineV2BridgeFloatPcmFrame({
|
||||
sampleRate: 48_000,
|
||||
numChannels: 1,
|
||||
samples: new Uint8Array([0, 1, 2, 3]).buffer,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('voice engine v2 bridge translator airlock', () => {
|
||||
function makeFrame(width: number, height: number, byteLength: number, timestampUs: number) {
|
||||
return {
|
||||
meta: {participantSid: 'PA', trackSid: 'TR', width, height, timestampUs},
|
||||
data: new ArrayBuffer(byteLength),
|
||||
};
|
||||
}
|
||||
|
||||
it('translates a canonical video frame received from Rust', () => {
|
||||
const event = translateVoiceEngineV2BridgeVideoFrameToEvent(makeFrame(1920, 1080, 4096, 1));
|
||||
expect(event.type).toBe('inboundVideo.frameReceived');
|
||||
});
|
||||
|
||||
it('panics through the airlock when an inbound video frame has zero dimensions', () => {
|
||||
try {
|
||||
translateVoiceEngineV2BridgeVideoFrameToEvent(makeFrame(0, 1080, 4096, 1));
|
||||
expect.fail('expected dimension assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('videoDimensionOutOfRange');
|
||||
}
|
||||
});
|
||||
|
||||
it('panics through the airlock when an inbound video frame has zero bytes', () => {
|
||||
try {
|
||||
translateVoiceEngineV2BridgeVideoFrameToEvent(makeFrame(1920, 1080, 0, 1));
|
||||
expect.fail('expected size assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('videoFrameBytesOutOfRange');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('voice engine v2 processed camera bridge airlock', () => {
|
||||
it('accepts native camera background modes', () => {
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({backgroundMode: 'none'})).toBe(true);
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({backgroundMode: 'non'})).toBe(true);
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({backgroundMode: 'blur'})).toBe(true);
|
||||
expect(
|
||||
isVoiceEngineV2BridgePublishCameraOptions({
|
||||
backgroundMode: 'custom',
|
||||
backgroundCustomMediaPath: '/tmp/background.webm',
|
||||
backgroundCustomMediaKind: 'video',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('registers the camera capture update channel in the shared contract', () => {
|
||||
expect(VOICE_ENGINE_V2_BRIDGE_METHODS).toContain('updateCameraCapture');
|
||||
expect(VOICE_ENGINE_V2_IPC_CHANNELS.updateCameraCapture).toBe('voice-engine-v2:update-camera-capture');
|
||||
});
|
||||
|
||||
it('accepts native camera mirroring as a typed boolean only', () => {
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({mirror: true})).toBe(true);
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({mirror: false})).toBe(true);
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({mirror: 'true'})).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts camera effect strengths across the full integer range', () => {
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({backgroundBlurStrength: 0})).toBe(true);
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({backgroundBlurStrength: 100})).toBe(true);
|
||||
expect(
|
||||
isVoiceEngineV2BridgePublishCameraOptions({
|
||||
backgroundMode: 'blur',
|
||||
backgroundBlurStrength: 75,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects out-of-range or non-integer camera effect strengths', () => {
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({backgroundBlurStrength: -1})).toBe(false);
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({backgroundBlurStrength: 101})).toBe(false);
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({backgroundBlurStrength: 49.5})).toBe(false);
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({backgroundBlurStrength: '50'})).toBe(false);
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({backgroundBlurStrength: Number.NaN})).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid native camera background modes', () => {
|
||||
expect(isVoiceEngineV2BridgePublishCameraOptions({backgroundMode: 'sepia'})).toBe(false);
|
||||
expect(
|
||||
isVoiceEngineV2BridgePublishCameraOptions({
|
||||
backgroundMode: 'custom',
|
||||
backgroundCustomMediaPath: '/tmp/background.webm',
|
||||
backgroundCustomMediaKind: 'audio',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts canonical processed camera publish options', () => {
|
||||
expect(isVoiceEngineV2BridgePublishProcessedCameraOptions({width: 1280, height: 720, frameRate: 30})).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects processed camera publish options with odd dimensions', () => {
|
||||
expect(isVoiceEngineV2BridgePublishProcessedCameraOptions({width: 1279, height: 720, frameRate: 30})).toBe(false);
|
||||
});
|
||||
|
||||
it('calculates tight i420 payload byte length', () => {
|
||||
expect(getVoiceEngineV2BridgeProcessedCameraI420ByteLength(4, 2)).toBe(12);
|
||||
});
|
||||
|
||||
it('accepts tight i420 processed camera frames', () => {
|
||||
const data = new Uint8Array(getVoiceEngineV2BridgeProcessedCameraI420ByteLength(4, 2));
|
||||
expect(
|
||||
isVoiceEngineV2BridgeProcessedCameraFrame({
|
||||
format: 'i420',
|
||||
width: 4,
|
||||
height: 2,
|
||||
timestampUs: 1,
|
||||
data,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects processed camera frames with loose byte lengths', () => {
|
||||
expect(
|
||||
isVoiceEngineV2BridgeProcessedCameraFrame({
|
||||
format: 'i420',
|
||||
width: 4,
|
||||
height: 2,
|
||||
timestampUs: 1,
|
||||
data: new ArrayBuffer(11),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('voice engine v2 bridge option airlock helpers', () => {
|
||||
it('accepts canonical audio options', () => {
|
||||
expect(() => assertVoiceEngineV2BridgeAudioOptionsInvariants({sampleRate: 48_000, numChannels: 2})).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects audio options with an unsupported sample rate', () => {
|
||||
try {
|
||||
assertVoiceEngineV2BridgeAudioOptionsInvariants({sampleRate: 44_100, numChannels: 2});
|
||||
expect.fail('expected sample rate assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('audioSampleRateInvalid');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects audio options with an unsupported channel count', () => {
|
||||
try {
|
||||
assertVoiceEngineV2BridgeAudioOptionsInvariants({sampleRate: 48_000, numChannels: 5});
|
||||
expect.fail('expected channels assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('audioChannelsInvalid');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts canonical video options', () => {
|
||||
expect(() => assertVoiceEngineV2BridgeVideoOptionsInvariants({width: 1280, height: 720})).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects video options with a zero width', () => {
|
||||
try {
|
||||
assertVoiceEngineV2BridgeVideoOptionsInvariants({width: 0, height: 720});
|
||||
expect.fail('expected dimension assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('videoDimensionOutOfRange');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects video options with an oversized height', () => {
|
||||
try {
|
||||
assertVoiceEngineV2BridgeVideoOptionsInvariants({width: 1280, height: 1 << 20});
|
||||
expect.fail('expected dimension assertion to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(VoiceEngineV2FfiAssertError);
|
||||
expect((error as VoiceEngineV2FfiAssertError).code).toBe('videoDimensionOutOfRange');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('voice engine v2 bridge event translation drop accounting', () => {
|
||||
beforeEach(() => {
|
||||
resetVoiceEngineV2BridgeDroppedEventCounts();
|
||||
});
|
||||
|
||||
it('returns no events for a known-but-ignored type without counting a drop', () => {
|
||||
const events = translateVoiceEngineV2BridgeEventToEvents({
|
||||
type: 'stats',
|
||||
payload: {rttMs: 12, outbound: [], inbound: []},
|
||||
});
|
||||
|
||||
expect(events).toEqual([]);
|
||||
const counts = getVoiceEngineV2BridgeDroppedEventCounts();
|
||||
expect(counts.malformedPayloadCount).toBe(0);
|
||||
expect(counts.unknownTypeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('counts a malformed payload for a translated known type', () => {
|
||||
const events = translateVoiceEngineV2BridgeEventToEvents({
|
||||
type: 'trackMuted',
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(events).toEqual([]);
|
||||
const counts = getVoiceEngineV2BridgeDroppedEventCounts();
|
||||
expect(counts.malformedPayloadCount).toBe(1);
|
||||
expect(counts.unknownTypeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('counts an unknown event type as a dropped event', () => {
|
||||
const events = translateVoiceEngineV2BridgeEventToEvents({
|
||||
type: 'someFutureEvent',
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(events).toEqual([]);
|
||||
const counts = getVoiceEngineV2BridgeDroppedEventCounts();
|
||||
expect(counts.malformedPayloadCount).toBe(0);
|
||||
expect(counts.unknownTypeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('classifies every payload key as a known bridge event type', () => {
|
||||
expect(isVoiceEngineV2BridgeKnownEventType('trackSubscribed')).toBe(true);
|
||||
expect(isVoiceEngineV2BridgeKnownEventType('audioPlaybackUnavailable')).toBe(true);
|
||||
expect(isVoiceEngineV2BridgeKnownEventType('someFutureEvent')).toBe(false);
|
||||
});
|
||||
|
||||
it('still translates a well-formed known event after counting drops', () => {
|
||||
translateVoiceEngineV2BridgeEventToEvents({type: 'trackMuted', payload: {}});
|
||||
const events = translateVoiceEngineV2BridgeEventToEvents({
|
||||
type: 'trackMuted',
|
||||
payload: {trackSid: 'TR_1'},
|
||||
});
|
||||
|
||||
expect(events).toEqual([{type: 'room.trackMuted', trackSid: 'TR_1'}]);
|
||||
expect(getVoiceEngineV2BridgeDroppedEventCounts().malformedPayloadCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {VOICE_ENGINE_V2_BRIDGE_VERSION} from './index';
|
||||
|
||||
export const VOICE_ENGINE_V2_AUDIO_FRAME_BYTES_MAX = 1 << 20;
|
||||
export const VOICE_ENGINE_V2_VIDEO_FRAME_BYTES_MAX = 64 << 20;
|
||||
export const VOICE_ENGINE_V2_VIDEO_DIMENSION_MAX = 16384;
|
||||
const VOICE_ENGINE_V2_AUDIO_SAMPLE_RATES_HZ = [16000, 32000, 48000] as const;
|
||||
const VOICE_ENGINE_V2_AUDIO_CHANNELS_VALID = [1, 2] as const;
|
||||
|
||||
interface VoiceEngineV2AudioFrameInvariants {
|
||||
sampleRateHz: number;
|
||||
numChannels: number;
|
||||
frameBytes: number;
|
||||
timestampNs: number;
|
||||
}
|
||||
|
||||
interface VoiceEngineV2VideoFrameInvariants {
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
frameBytes: number;
|
||||
timestampNs: number;
|
||||
}
|
||||
|
||||
export class VoiceEngineV2FfiAssertError extends Error {
|
||||
readonly code:
|
||||
| 'schemaVersionMismatch'
|
||||
| 'audioFrameBytesOutOfRange'
|
||||
| 'audioSampleRateInvalid'
|
||||
| 'audioChannelsInvalid'
|
||||
| 'audioTimestampRegressed'
|
||||
| 'videoFrameBytesOutOfRange'
|
||||
| 'videoDimensionOutOfRange'
|
||||
| 'videoTimestampRegressed';
|
||||
|
||||
constructor(code: VoiceEngineV2FfiAssertError['code'], message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.name = 'VoiceEngineV2FfiAssertError';
|
||||
}
|
||||
}
|
||||
|
||||
function isFiniteInteger(value: number): boolean {
|
||||
return Number.isFinite(value) && Number.isInteger(value);
|
||||
}
|
||||
|
||||
export function assertSchemaVersion(received: number): void {
|
||||
if (!isFiniteInteger(received)) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'schemaVersionMismatch',
|
||||
`schema version must be a finite integer (received ${received})`,
|
||||
);
|
||||
}
|
||||
if (received !== VOICE_ENGINE_V2_BRIDGE_VERSION) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'schemaVersionMismatch',
|
||||
`schema version mismatch: expected ${VOICE_ENGINE_V2_BRIDGE_VERSION}, received ${received}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAudioFrameBytes(bytes: number): void {
|
||||
if (!isFiniteInteger(bytes)) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'audioFrameBytesOutOfRange',
|
||||
`audio frame bytes must be a finite integer (received ${bytes})`,
|
||||
);
|
||||
}
|
||||
if (bytes <= 0) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'audioFrameBytesOutOfRange',
|
||||
`audio frame bytes must be positive (received ${bytes})`,
|
||||
);
|
||||
}
|
||||
if (bytes > VOICE_ENGINE_V2_AUDIO_FRAME_BYTES_MAX) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'audioFrameBytesOutOfRange',
|
||||
`audio frame bytes ${bytes} exceeds cap ${VOICE_ENGINE_V2_AUDIO_FRAME_BYTES_MAX}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAudioSampleRate(hz: number): void {
|
||||
if (!(VOICE_ENGINE_V2_AUDIO_SAMPLE_RATES_HZ as ReadonlyArray<number>).includes(hz)) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'audioSampleRateInvalid',
|
||||
`audio sample rate ${hz} not in [16000, 32000, 48000]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAudioChannels(channels: number): void {
|
||||
if (!(VOICE_ENGINE_V2_AUDIO_CHANNELS_VALID as ReadonlyArray<number>).includes(channels)) {
|
||||
throw new VoiceEngineV2FfiAssertError('audioChannelsInvalid', `audio channel count ${channels} not in [1, 2]`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAudioTimestampMonotonic(timestampNs: number, previousTimestampNs: number | null): void {
|
||||
if (previousTimestampNs === null) return;
|
||||
if (timestampNs <= previousTimestampNs) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'audioTimestampRegressed',
|
||||
`audio timestamp ${timestampNs} did not exceed previous ${previousTimestampNs}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAudioFrameInvariants(
|
||||
frame: VoiceEngineV2AudioFrameInvariants,
|
||||
previousTimestampNs: number | null = null,
|
||||
): void {
|
||||
assertAudioFrameBytes(frame.frameBytes);
|
||||
assertAudioSampleRate(frame.sampleRateHz);
|
||||
assertAudioChannels(frame.numChannels);
|
||||
assertAudioTimestampMonotonic(frame.timestampNs, previousTimestampNs);
|
||||
}
|
||||
|
||||
function assertVideoFrameBytes(bytes: number): void {
|
||||
if (!isFiniteInteger(bytes)) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'videoFrameBytesOutOfRange',
|
||||
`video frame bytes must be a finite integer (received ${bytes})`,
|
||||
);
|
||||
}
|
||||
if (bytes <= 0) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'videoFrameBytesOutOfRange',
|
||||
`video frame bytes must be positive (received ${bytes})`,
|
||||
);
|
||||
}
|
||||
if (bytes > VOICE_ENGINE_V2_VIDEO_FRAME_BYTES_MAX) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'videoFrameBytesOutOfRange',
|
||||
`video frame bytes ${bytes} exceeds cap ${VOICE_ENGINE_V2_VIDEO_FRAME_BYTES_MAX}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertVideoDimension(pixels: number, label: string): void {
|
||||
if (!isFiniteInteger(pixels)) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'videoDimensionOutOfRange',
|
||||
`video ${label} must be a finite integer (received ${pixels})`,
|
||||
);
|
||||
}
|
||||
if (pixels <= 0) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'videoDimensionOutOfRange',
|
||||
`video ${label} must be positive (received ${pixels})`,
|
||||
);
|
||||
}
|
||||
if (pixels > VOICE_ENGINE_V2_VIDEO_DIMENSION_MAX) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'videoDimensionOutOfRange',
|
||||
`video ${label} ${pixels} exceeds cap ${VOICE_ENGINE_V2_VIDEO_DIMENSION_MAX}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertVideoTimestampMonotonic(timestampNs: number, previousTimestampNs: number | null): void {
|
||||
if (previousTimestampNs === null) return;
|
||||
if (timestampNs <= previousTimestampNs) {
|
||||
throw new VoiceEngineV2FfiAssertError(
|
||||
'videoTimestampRegressed',
|
||||
`video timestamp ${timestampNs} did not exceed previous ${previousTimestampNs}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertVideoFrameInvariants(
|
||||
frame: VoiceEngineV2VideoFrameInvariants,
|
||||
previousTimestampNs: number | null = null,
|
||||
): void {
|
||||
assertVideoFrameBytes(frame.frameBytes);
|
||||
assertVideoDimension(frame.widthPx, 'width');
|
||||
assertVideoDimension(frame.heightPx, 'height');
|
||||
assertVideoTimestampMonotonic(frame.timestampNs, previousTimestampNs);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {assertAudioFrameInvariants, assertSchemaVersion, assertVideoFrameInvariants} from './ffi_assertions';
|
||||
import type {
|
||||
VoiceEngineV2BridgeCameraBackgroundCustomMediaKind,
|
||||
VoiceEngineV2BridgeCameraBackgroundMode,
|
||||
VoiceEngineV2BridgeCameraPreviewInfo,
|
||||
VoiceEngineV2BridgeCapabilities,
|
||||
VoiceEngineV2BridgeConnectOptions,
|
||||
VoiceEngineV2BridgeFloatPcmFrame,
|
||||
VoiceEngineV2BridgeHardwareEncoderCapabilities,
|
||||
VoiceEngineV2BridgeParticipantVolumeOptions,
|
||||
VoiceEngineV2BridgePcmFrame,
|
||||
VoiceEngineV2BridgeProcessedCameraFrame,
|
||||
VoiceEngineV2BridgePublishCameraOptions,
|
||||
VoiceEngineV2BridgePublishDataOptions,
|
||||
VoiceEngineV2BridgePublishDeviceScreenShareOptions,
|
||||
VoiceEngineV2BridgePublishMicrophoneOptions,
|
||||
VoiceEngineV2BridgePublishProcessedCameraOptions,
|
||||
VoiceEngineV2BridgePublishProcessedCameraResult,
|
||||
VoiceEngineV2BridgePublishScreenAudioOptions,
|
||||
VoiceEngineV2BridgePublishScreenOptions,
|
||||
VoiceEngineV2BridgeReadiness,
|
||||
VoiceEngineV2BridgeRemoteTrackSubscriptionOptions,
|
||||
VoiceEngineV2BridgeRemoteTrackSubscriptionQuality,
|
||||
VoiceEngineV2BridgeSpeakingDetectionOptions,
|
||||
VoiceEngineV2BridgeUpdateScreenShareEncodingOptions,
|
||||
} from './types';
|
||||
import {VOICE_ENGINE_V2_CAMERA_EFFECT_STRENGTH_MAX, VOICE_ENGINE_V2_CAMERA_EFFECT_STRENGTH_MIN} from './types';
|
||||
|
||||
export function assertVoiceEngineV2BridgeAudioOptionsInvariants(options: {
|
||||
sampleRate: number;
|
||||
numChannels: number;
|
||||
}): void {
|
||||
assertAudioFrameInvariants({
|
||||
sampleRateHz: options.sampleRate,
|
||||
numChannels: options.numChannels,
|
||||
frameBytes: 1,
|
||||
timestampNs: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function assertVoiceEngineV2BridgeVideoOptionsInvariants(options: {width: number; height: number}): void {
|
||||
assertVideoFrameInvariants({
|
||||
widthPx: options.width,
|
||||
heightPx: options.height,
|
||||
frameBytes: 1,
|
||||
timestampNs: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function assertVoiceEngineV2BridgeEnvelopeSchema(received: number): void {
|
||||
assertSchemaVersion(received);
|
||||
}
|
||||
|
||||
export function unavailableVoiceEngineV2BridgeCapabilities(): VoiceEngineV2BridgeCapabilities {
|
||||
return {
|
||||
microphoneCapture: false,
|
||||
syntheticMicrophonePcm: false,
|
||||
cameraCapture: false,
|
||||
nativeCameraBackgrounds: false,
|
||||
screenShare: false,
|
||||
screenShareEncodingUpdate: false,
|
||||
screenShareAudio: false,
|
||||
deviceLists: false,
|
||||
outputDeviceSelection: false,
|
||||
participantVolume: false,
|
||||
remoteTrackSubscription: false,
|
||||
dataChannel: false,
|
||||
connectionStats: false,
|
||||
nativeVideoFrames: false,
|
||||
hardwareEncoderCapabilities: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeVoiceEngineV2BridgeCapabilities(value: unknown): VoiceEngineV2BridgeCapabilities {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return unavailableVoiceEngineV2BridgeCapabilities();
|
||||
}
|
||||
const candidate = value as Partial<VoiceEngineV2BridgeCapabilities> & {
|
||||
microphone?: boolean;
|
||||
camera?: boolean;
|
||||
cameraBackgrounds?: boolean;
|
||||
screen?: boolean;
|
||||
screenAudio?: boolean;
|
||||
outputDevice?: boolean;
|
||||
dataChannel?: boolean;
|
||||
stats?: boolean;
|
||||
hardwareEncoding?: boolean;
|
||||
};
|
||||
return {
|
||||
microphoneCapture: candidate.microphoneCapture === true || candidate.microphone === true,
|
||||
syntheticMicrophonePcm: candidate.syntheticMicrophonePcm === true,
|
||||
cameraCapture: candidate.cameraCapture === true || candidate.camera === true,
|
||||
nativeCameraBackgrounds: candidate.nativeCameraBackgrounds === true || candidate.cameraBackgrounds === true,
|
||||
screenShare: candidate.screenShare === true || candidate.screen === true,
|
||||
screenShareEncodingUpdate: candidate.screenShareEncodingUpdate === true,
|
||||
screenShareAudio: candidate.screenShareAudio === true || candidate.screenAudio === true,
|
||||
deviceLists: candidate.deviceLists === true,
|
||||
outputDeviceSelection: candidate.outputDeviceSelection === true || candidate.outputDevice === true,
|
||||
participantVolume: candidate.participantVolume === true,
|
||||
remoteTrackSubscription: candidate.remoteTrackSubscription === true,
|
||||
dataChannel: candidate.dataChannel === true,
|
||||
connectionStats: candidate.connectionStats === true || candidate.stats === true,
|
||||
nativeVideoFrames: candidate.nativeVideoFrames === true,
|
||||
hardwareEncoderCapabilities: candidate.hardwareEncoderCapabilities === true || candidate.hardwareEncoding === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function unavailableVoiceEngineV2BridgeHardwareEncoderCapabilities(
|
||||
reason: NonNullable<VoiceEngineV2BridgeHardwareEncoderCapabilities['reason']>,
|
||||
detail?: string,
|
||||
): VoiceEngineV2BridgeHardwareEncoderCapabilities {
|
||||
return {
|
||||
available: false,
|
||||
backend: 'none',
|
||||
compiled: false,
|
||||
runtime: false,
|
||||
codecs: [],
|
||||
zeroCopy: false,
|
||||
nativeInputs: [],
|
||||
reason,
|
||||
...(detail ? {detail} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeVoiceEngineV2BridgeHardwareEncoderCapabilities(
|
||||
value: unknown,
|
||||
): VoiceEngineV2BridgeHardwareEncoderCapabilities {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return unavailableVoiceEngineV2BridgeHardwareEncoderCapabilities(
|
||||
'query-failed',
|
||||
'Native addon returned an invalid result',
|
||||
);
|
||||
}
|
||||
const candidate = value as Partial<VoiceEngineV2BridgeHardwareEncoderCapabilities>;
|
||||
const backend = typeof candidate.backend === 'string' && candidate.backend.length > 0 ? candidate.backend : 'none';
|
||||
return {
|
||||
available: candidate.available === true,
|
||||
backend,
|
||||
compiled: candidate.compiled === true,
|
||||
runtime: candidate.runtime === true,
|
||||
codecs: Array.isArray(candidate.codecs) ? candidate.codecs.filter((codec) => typeof codec === 'string') : [],
|
||||
zeroCopy: candidate.zeroCopy === true,
|
||||
nativeInputs: Array.isArray(candidate.nativeInputs)
|
||||
? candidate.nativeInputs.filter((input) => typeof input === 'string')
|
||||
: [],
|
||||
...(typeof candidate.reason === 'string' ? {reason: candidate.reason} : {}),
|
||||
...(typeof candidate.detail === 'string' ? {detail: candidate.detail} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function isPositiveFiniteNumber(value: unknown): value is number {
|
||||
return isFiniteNumber(value) && value > 0;
|
||||
}
|
||||
|
||||
function isOptionalPositiveFiniteNumber(value: unknown): value is number | undefined {
|
||||
return value === undefined || isPositiveFiniteNumber(value);
|
||||
}
|
||||
|
||||
function isOptionalDeepFilterNoiseReductionLevel(value: unknown): value is number | undefined {
|
||||
if (value === undefined) return true;
|
||||
return isFiniteNumber(value) && value >= 0 && value <= 100;
|
||||
}
|
||||
|
||||
function isOptionalBoolean(value: unknown): value is boolean | undefined {
|
||||
return value === undefined || typeof value === 'boolean';
|
||||
}
|
||||
|
||||
function isOptionalString(value: unknown): value is string | undefined {
|
||||
return value === undefined || typeof value === 'string';
|
||||
}
|
||||
|
||||
function isOptionalCameraBackgroundMode(value: unknown): value is VoiceEngineV2BridgeCameraBackgroundMode | undefined {
|
||||
return value === undefined || value === 'none' || value === 'non' || value === 'blur' || value === 'custom';
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgeCameraEffectStrength(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
value >= VOICE_ENGINE_V2_CAMERA_EFFECT_STRENGTH_MIN &&
|
||||
value <= VOICE_ENGINE_V2_CAMERA_EFFECT_STRENGTH_MAX
|
||||
);
|
||||
}
|
||||
|
||||
function isOptionalCameraEffectStrength(value: unknown): value is number | undefined {
|
||||
return value === undefined || isVoiceEngineV2BridgeCameraEffectStrength(value);
|
||||
}
|
||||
|
||||
function isOptionalCameraBackgroundCustomMediaKind(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgeCameraBackgroundCustomMediaKind | undefined {
|
||||
return value === undefined || value === 'static' || value === 'animated' || value === 'video';
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is Array<string> {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
||||
}
|
||||
|
||||
function isOptionalStringArray(value: unknown): value is Array<string> | undefined {
|
||||
return value === undefined || isStringArray(value);
|
||||
}
|
||||
|
||||
function isArrayBuffer(value: unknown): value is ArrayBuffer {
|
||||
return value instanceof ArrayBuffer || Object.prototype.toString.call(value) === '[object ArrayBuffer]';
|
||||
}
|
||||
|
||||
function isFloat32Array(value: unknown): value is Float32Array {
|
||||
return value instanceof Float32Array || Object.prototype.toString.call(value) === '[object Float32Array]';
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgeBinaryPayload(value: unknown): value is ArrayBuffer | ArrayBufferView {
|
||||
return isArrayBuffer(value) || ArrayBuffer.isView(value);
|
||||
}
|
||||
|
||||
export function getVoiceEngineV2BridgeProcessedCameraI420ByteLength(width: number, height: number): number {
|
||||
return (width * height * 3) / 2;
|
||||
}
|
||||
|
||||
function isPositiveFiniteInteger(value: unknown): value is number {
|
||||
return isPositiveFiniteNumber(value) && Number.isInteger(value);
|
||||
}
|
||||
|
||||
function isPositiveEvenFiniteInteger(value: unknown): value is number {
|
||||
return isPositiveFiniteInteger(value) && value % 2 === 0;
|
||||
}
|
||||
|
||||
function getVoiceEngineV2BridgeBinaryPayloadByteLength(value: ArrayBuffer | ArrayBufferView): number {
|
||||
return value.byteLength;
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgeRemoteTrackSubscriptionQuality(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgeRemoteTrackSubscriptionQuality {
|
||||
return value === 'low' || value === 'medium' || value === 'high';
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgeConnectOptions(value: unknown): value is VoiceEngineV2BridgeConnectOptions {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
typeof value.url === 'string' &&
|
||||
value.url.length > 0 &&
|
||||
typeof value.token === 'string' &&
|
||||
value.token.length > 0 &&
|
||||
(value.e2eeKey === undefined || value.e2eeKey === null || isArrayBuffer(value.e2eeKey))
|
||||
);
|
||||
}
|
||||
|
||||
const VOICE_ENGINE_V2_SCREEN_CODECS = new Set(['', 'vp8', 'vp9', 'h264', 'h265', 'av1']);
|
||||
const VOICE_ENGINE_V2_SCREEN_PACING_MODES = new Set(['sender', 'source']);
|
||||
|
||||
export function isVoiceEngineV2BridgePublishScreenOptions(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgePublishScreenOptions {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
typeof value.captureId === 'string' &&
|
||||
value.captureId.length > 0 &&
|
||||
isPositiveFiniteNumber(value.width) &&
|
||||
isPositiveFiniteNumber(value.height) &&
|
||||
(value.codec === undefined ||
|
||||
(typeof value.codec === 'string' && VOICE_ENGINE_V2_SCREEN_CODECS.has(value.codec))) &&
|
||||
isOptionalBoolean(value.hardwareEncoding) &&
|
||||
isOptionalBoolean(value.zeroCopyRequired) &&
|
||||
isOptionalPositiveFiniteNumber(value.maxBitrateBps) &&
|
||||
isOptionalPositiveFiniteNumber(value.maxFramerate) &&
|
||||
isOptionalBoolean(value.adaptiveSend) &&
|
||||
isOptionalPositiveFiniteNumber(value.minVideoFps) &&
|
||||
isOptionalPositiveFiniteNumber(value.maxAudioBufferMs) &&
|
||||
(value.trackName === undefined || (typeof value.trackName === 'string' && value.trackName.length > 0)) &&
|
||||
(value.pacing === undefined ||
|
||||
(typeof value.pacing === 'string' && VOICE_ENGINE_V2_SCREEN_PACING_MODES.has(value.pacing)))
|
||||
);
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgeUpdateScreenShareEncodingOptions(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgeUpdateScreenShareEncodingOptions {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
typeof value.captureId === 'string' &&
|
||||
value.captureId.length > 0 &&
|
||||
isPositiveFiniteNumber(value.width) &&
|
||||
isPositiveFiniteNumber(value.height) &&
|
||||
isOptionalPositiveFiniteNumber(value.frameRate) &&
|
||||
isOptionalPositiveFiniteNumber(value.maxBitrateBps) &&
|
||||
(value.codec === undefined ||
|
||||
(typeof value.codec === 'string' && VOICE_ENGINE_V2_SCREEN_CODECS.has(value.codec))) &&
|
||||
isOptionalBoolean(value.hardwareEncoding) &&
|
||||
isOptionalBoolean(value.zeroCopyRequired)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgePublishMicrophoneOptions(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgePublishMicrophoneOptions {
|
||||
if (!isRecord(value)) return false;
|
||||
const mode = value.mode ?? 'device';
|
||||
const pcmTestValuesAreValid =
|
||||
mode !== 'pcm-test' || (isPositiveFiniteNumber(value.sampleRate) && isPositiveFiniteNumber(value.numChannels));
|
||||
return (
|
||||
(mode === 'device' || mode === 'pcm-test') &&
|
||||
isOptionalString(value.deviceId) &&
|
||||
isOptionalBoolean(value.echoCancellation) &&
|
||||
isOptionalBoolean(value.noiseSuppression) &&
|
||||
isOptionalBoolean(value.autoGainControl) &&
|
||||
isOptionalBoolean(value.deepFilter) &&
|
||||
isOptionalDeepFilterNoiseReductionLevel(value.deepFilterNoiseReductionLevel) &&
|
||||
isOptionalPositiveFiniteNumber(value.sampleRate) &&
|
||||
isOptionalPositiveFiniteNumber(value.numChannels) &&
|
||||
isOptionalPositiveFiniteNumber(value.maxBitrateBps) &&
|
||||
pcmTestValuesAreValid
|
||||
);
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgePcmFrame(value: unknown): value is VoiceEngineV2BridgePcmFrame {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isPositiveFiniteNumber(value.sampleRate) &&
|
||||
isPositiveFiniteNumber(value.numChannels) &&
|
||||
isArrayBuffer(value.samples)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgeFloatPcmFrame(value: unknown): value is VoiceEngineV2BridgeFloatPcmFrame {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isPositiveFiniteNumber(value.sampleRate) &&
|
||||
isPositiveFiniteNumber(value.numChannels) &&
|
||||
isFloat32Array(value.samples)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgePublishScreenAudioOptions(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgePublishScreenAudioOptions {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isPositiveFiniteNumber(value.sampleRate) &&
|
||||
isPositiveFiniteNumber(value.numChannels) &&
|
||||
(value.route === undefined || value.route === 'browser' || value.route === 'native') &&
|
||||
isOptionalString(value.captureId) &&
|
||||
isOptionalString(value.tapId)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2ParticipantVolumeOptions(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgeParticipantVolumeOptions {
|
||||
if (!isRecord(value)) return false;
|
||||
return typeof value.participantSid === 'string' && value.participantSid.length > 0 && isFiniteNumber(value.volume);
|
||||
}
|
||||
|
||||
export function clampVoiceEngineV2ParticipantVolume(volume: number): number {
|
||||
if (!Number.isFinite(volume)) return 1;
|
||||
return Math.max(0, Math.min(2, volume));
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgeSpeakingDetectionOptions(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgeSpeakingDetectionOptions {
|
||||
if (!isRecord(value)) return false;
|
||||
return isPositiveFiniteNumber(value.localThresholdRms) && isPositiveFiniteNumber(value.remoteThresholdRms);
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgeRemoteTrackSubscriptionOptions(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgeRemoteTrackSubscriptionOptions {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
typeof value.participantIdentity === 'string' &&
|
||||
value.participantIdentity.length > 0 &&
|
||||
typeof value.source === 'string' &&
|
||||
value.source.length > 0 &&
|
||||
typeof value.subscribed === 'boolean' &&
|
||||
isOptionalBoolean(value.enabled) &&
|
||||
(value.quality === undefined || isVoiceEngineV2BridgeRemoteTrackSubscriptionQuality(value.quality))
|
||||
);
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgePublishDataOptions(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgePublishDataOptions {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isVoiceEngineV2BridgeBinaryPayload(value.payload) &&
|
||||
isOptionalBoolean(value.reliable) &&
|
||||
isOptionalString(value.topic) &&
|
||||
isOptionalStringArray(value.destinationIdentities)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgePublishCameraOptions(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgePublishCameraOptions {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isOptionalString(value.deviceId) &&
|
||||
isOptionalPositiveFiniteNumber(value.width) &&
|
||||
isOptionalPositiveFiniteNumber(value.height) &&
|
||||
isOptionalPositiveFiniteNumber(value.frameRate) &&
|
||||
isOptionalBoolean(value.mirror) &&
|
||||
isOptionalCameraBackgroundMode(value.backgroundMode) &&
|
||||
isOptionalString(value.backgroundCustomMediaPath) &&
|
||||
isOptionalCameraBackgroundCustomMediaKind(value.backgroundCustomMediaKind) &&
|
||||
isOptionalCameraEffectStrength(value.backgroundBlurStrength)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgePublishProcessedCameraOptions(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgePublishProcessedCameraOptions {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isPositiveEvenFiniteInteger(value.width) &&
|
||||
isPositiveEvenFiniteInteger(value.height) &&
|
||||
isPositiveFiniteInteger(value.frameRate)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgePublishProcessedCameraResult(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgePublishProcessedCameraResult {
|
||||
if (!isRecord(value)) return false;
|
||||
return typeof value.trackSid === 'string' && value.trackSid.length > 0;
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgeCameraPreviewInfo(value: unknown): value is VoiceEngineV2BridgeCameraPreviewInfo {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
typeof value.trackSid === 'string' &&
|
||||
value.trackSid.length > 0 &&
|
||||
isPositiveEvenFiniteInteger(value.width) &&
|
||||
isPositiveEvenFiniteInteger(value.height) &&
|
||||
isPositiveFiniteInteger(value.frameRate)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgeProcessedCameraFrame(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgeProcessedCameraFrame {
|
||||
if (!isRecord(value)) return false;
|
||||
if (value.format !== 'i420') return false;
|
||||
if (!isPositiveEvenFiniteInteger(value.width)) return false;
|
||||
if (!isPositiveEvenFiniteInteger(value.height)) return false;
|
||||
if (!isPositiveFiniteInteger(value.timestampUs)) return false;
|
||||
if (!isVoiceEngineV2BridgeBinaryPayload(value.data)) return false;
|
||||
const expectedBytes = getVoiceEngineV2BridgeProcessedCameraI420ByteLength(value.width, value.height);
|
||||
return getVoiceEngineV2BridgeBinaryPayloadByteLength(value.data) === expectedBytes;
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgeReadiness(value: unknown): value is VoiceEngineV2BridgeReadiness {
|
||||
if (!isRecord(value)) return false;
|
||||
if (typeof value.ready !== 'boolean') return false;
|
||||
if (value.ready) return value.reason === undefined;
|
||||
return isOptionalString(value.reason);
|
||||
}
|
||||
|
||||
export function isVoiceEngineV2BridgePublishDeviceScreenShareOptions(
|
||||
value: unknown,
|
||||
): value is VoiceEngineV2BridgePublishDeviceScreenShareOptions {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isOptionalString(value.deviceId) &&
|
||||
isOptionalPositiveFiniteNumber(value.width) &&
|
||||
isOptionalPositiveFiniteNumber(value.height) &&
|
||||
isOptionalPositiveFiniteNumber(value.frameRate) &&
|
||||
(value.codec === undefined ||
|
||||
(typeof value.codec === 'string' && VOICE_ENGINE_V2_SCREEN_CODECS.has(value.codec))) &&
|
||||
isOptionalPositiveFiniteNumber(value.maxBitrateBps) &&
|
||||
isOptionalPositiveFiniteNumber(value.maxFramerate)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export * from './admWarmup';
|
||||
export * from './guards';
|
||||
export * from './stats';
|
||||
export * from './translate';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
/// <reference types="node" />
|
||||
|
||||
import {readdirSync, readFileSync, statSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '../../../..');
|
||||
const BLOCKED_IMPORT = '@fluxer/' + 'voice_' + 'engine_contract';
|
||||
const BLOCKED_PATH = 'voice_' + 'engine_contract';
|
||||
const BLOCKED_MARKERS = [
|
||||
BLOCKED_IMPORT,
|
||||
BLOCKED_PATH,
|
||||
'Voice' + 'EngineApi',
|
||||
'VOICE_ENGINE_' + 'CONTRACT_VERSION',
|
||||
'contract' + 'Version',
|
||||
];
|
||||
const SCAN_TARGETS = ['fluxer_app', 'fluxer_desktop', 'packages', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'];
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'.cjs',
|
||||
'.cts',
|
||||
'.js',
|
||||
'.json',
|
||||
'.jsx',
|
||||
'.mjs',
|
||||
'.mts',
|
||||
'.rs',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.toml',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
]);
|
||||
const SKIPPED_DIRECTORIES = new Set(['.git', 'coverage', 'dist', 'node_modules', 'target', 'vendor']);
|
||||
|
||||
function shouldScanFile(filePath: string): boolean {
|
||||
if (path.basename(filePath) === 'package.json') return true;
|
||||
return TEXT_EXTENSIONS.has(path.extname(filePath));
|
||||
}
|
||||
|
||||
function collectFiles(targetPath: string, files: Array<string> = []): Array<string> {
|
||||
const stat = statSync(targetPath);
|
||||
if (stat.isFile()) {
|
||||
if (shouldScanFile(targetPath)) files.push(targetPath);
|
||||
return files;
|
||||
}
|
||||
if (!stat.isDirectory()) return files;
|
||||
if (SKIPPED_DIRECTORIES.has(path.basename(targetPath))) return files;
|
||||
for (const entry of readdirSync(targetPath)) {
|
||||
collectFiles(path.join(targetPath, entry), files);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
describe('voice engine v2 package boundary', () => {
|
||||
it('does not reintroduce the deleted v1 voice engine contract', () => {
|
||||
const matches = SCAN_TARGETS.flatMap((target) => collectFiles(path.join(REPO_ROOT, target))).flatMap((filePath) => {
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
const blockedMarker = BLOCKED_MARKERS.find((marker) => source.includes(marker));
|
||||
if (!blockedMarker) return [];
|
||||
return [`${path.relative(REPO_ROOT, filePath)} -> ${blockedMarker}`];
|
||||
});
|
||||
|
||||
expect(matches).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
VoiceEngineV2BridgeInboundStat,
|
||||
VoiceEngineV2BridgeOutboundStat,
|
||||
VoiceEngineV2BridgeSendStats,
|
||||
VoiceEngineV2BridgeStats,
|
||||
VoiceEngineV2BridgeTrackKind,
|
||||
} from './types';
|
||||
|
||||
function asTrackKind(value: unknown): VoiceEngineV2BridgeTrackKind {
|
||||
return value === 'video' ? 'video' : 'audio';
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function asNonNegativeNumber(value: unknown): number {
|
||||
return Math.max(0, asNumber(value));
|
||||
}
|
||||
|
||||
function asOptionalNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function asOptionalCount(value: unknown): number | undefined {
|
||||
const number = asOptionalNumber(value);
|
||||
return number == null ? undefined : Math.max(0, Math.trunc(number));
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function coerceVoiceEngineV2BridgeSendStats(value: unknown): VoiceEngineV2BridgeSendStats | null | undefined {
|
||||
if (value == null || typeof value !== 'object') return value === null ? null : undefined;
|
||||
const payload = value as Record<string, unknown>;
|
||||
return {
|
||||
outgoingVideoQueueDepth: asNonNegativeNumber(payload.outgoingVideoQueueDepth),
|
||||
outgoingVideoQueueCapacity: asNonNegativeNumber(payload.outgoingVideoQueueCapacity),
|
||||
outgoingVideoMaxQueueDepth: asNonNegativeNumber(payload.outgoingVideoMaxQueueDepth),
|
||||
outgoingVideoFramesProduced: asNonNegativeNumber(payload.outgoingVideoFramesProduced),
|
||||
outgoingVideoFramesAccepted: asNonNegativeNumber(payload.outgoingVideoFramesAccepted),
|
||||
outgoingVideoFramesDropped: asNonNegativeNumber(payload.outgoingVideoFramesDropped),
|
||||
outgoingVideoFramesCoalesced: asNonNegativeNumber(payload.outgoingVideoFramesCoalesced),
|
||||
outgoingVideoFramesCaptured: asNonNegativeNumber(payload.outgoingVideoFramesCaptured),
|
||||
outgoingVideoCaptureFailures: asNonNegativeNumber(payload.outgoingVideoCaptureFailures),
|
||||
outgoingVideoEffectiveFps: asNonNegativeNumber(payload.outgoingVideoEffectiveFps),
|
||||
outgoingVideoTargetFps: asNonNegativeNumber(payload.outgoingVideoTargetFps),
|
||||
outgoingVideoPacingTargetFps: asNonNegativeNumber(payload.outgoingVideoPacingTargetFps),
|
||||
outgoingVideoMaxQueueAgeMs: asNonNegativeNumber(payload.outgoingVideoMaxQueueAgeMs),
|
||||
outgoingVideoMaxPushLatencyMs: asNonNegativeNumber(payload.outgoingVideoMaxPushLatencyMs),
|
||||
outgoingVideoPacingMode: asString(payload.outgoingVideoPacingMode) ?? 'unknown',
|
||||
outgoingVideoBusActive: payload.outgoingVideoBusActive === true,
|
||||
outgoingAudioBufferTargetMs: asNonNegativeNumber(payload.outgoingAudioBufferTargetMs),
|
||||
outgoingAudioBufferMaxMs: asNonNegativeNumber(payload.outgoingAudioBufferMaxMs),
|
||||
outgoingAudioUnderruns: asNonNegativeNumber(payload.outgoingAudioUnderruns),
|
||||
outgoingAudioRebuffers: asNonNegativeNumber(payload.outgoingAudioRebuffers),
|
||||
outgoingAudioMaxFrameGapMs: asNonNegativeNumber(payload.outgoingAudioMaxFrameGapMs),
|
||||
adaptiveSendTier: asString(payload.adaptiveSendTier) ?? 'unknown',
|
||||
adaptiveSendReason: asString(payload.adaptiveSendReason) ?? 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
function coerceVoiceEngineV2BridgeOutboundStat(entry: unknown): Array<VoiceEngineV2BridgeOutboundStat> {
|
||||
if (entry == null || typeof entry !== 'object') return [];
|
||||
const e = entry as Record<string, unknown>;
|
||||
const zeroCopy = e.zeroCopy === true ? true : undefined;
|
||||
return [
|
||||
{
|
||||
trackSid: asString(e.trackSid) ?? '',
|
||||
source: asString(e.source) ?? '',
|
||||
kind: asTrackKind(e.kind),
|
||||
codec: asString(e.codec),
|
||||
bitrateKbps: asNumber(e.bitrateKbps),
|
||||
packetsLost: asNumber(e.packetsLost),
|
||||
fps: asOptionalNumber(e.fps),
|
||||
audioLevel: asOptionalNumber(e.audioLevel),
|
||||
width: asOptionalCount(e.width),
|
||||
height: asOptionalCount(e.height),
|
||||
sourceWidth: asOptionalCount(e.sourceWidth),
|
||||
sourceHeight: asOptionalCount(e.sourceHeight),
|
||||
targetBitrateKbps: asOptionalNumber(e.targetBitrateKbps),
|
||||
configuredFps: asOptionalNumber(e.configuredFps),
|
||||
targetFps: asOptionalNumber(e.targetFps),
|
||||
effectiveFps: asOptionalNumber(e.effectiveFps),
|
||||
framesProduced: asOptionalCount(e.framesProduced),
|
||||
framesAccepted: asOptionalCount(e.framesAccepted),
|
||||
framesDropped: asOptionalCount(e.framesDropped),
|
||||
framesCoalesced: asOptionalCount(e.framesCoalesced),
|
||||
framesCaptured: asOptionalCount(e.framesCaptured),
|
||||
captureFailures: asOptionalCount(e.captureFailures),
|
||||
maxQueueAgeMs: asOptionalNumber(e.maxQueueAgeMs),
|
||||
maxPushLatencyMs: asOptionalNumber(e.maxPushLatencyMs),
|
||||
adaptiveSendTier: asString(e.adaptiveSendTier),
|
||||
adaptiveSendReason: asString(e.adaptiveSendReason),
|
||||
...(zeroCopy === true ? {zeroCopy} : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function coerceVoiceEngineV2BridgeInboundStat(entry: unknown): Array<VoiceEngineV2BridgeInboundStat> {
|
||||
if (entry == null || typeof entry !== 'object') return [];
|
||||
const e = entry as Record<string, unknown>;
|
||||
const participantIdentity = asString(e.participantIdentity);
|
||||
return [
|
||||
{
|
||||
...(participantIdentity ? {participantIdentity} : {}),
|
||||
participantSid: asString(e.participantSid) ?? '',
|
||||
trackSid: asString(e.trackSid) ?? '',
|
||||
source: asString(e.source),
|
||||
kind: asTrackKind(e.kind),
|
||||
codec: asString(e.codec),
|
||||
bitrateKbps: asNumber(e.bitrateKbps),
|
||||
packetsLost: asNumber(e.packetsLost),
|
||||
jitterMs: asOptionalNumber(e.jitterMs),
|
||||
audioLevel: asOptionalNumber(e.audioLevel),
|
||||
width: asOptionalCount(e.width),
|
||||
height: asOptionalCount(e.height),
|
||||
fps: asOptionalNumber(e.fps),
|
||||
sourceWidth: asOptionalCount(e.sourceWidth),
|
||||
sourceHeight: asOptionalCount(e.sourceHeight),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function coerceVoiceEngineV2BridgeStats(payload: Record<string, unknown>): VoiceEngineV2BridgeStats {
|
||||
const rttRaw = payload.rttMs;
|
||||
const rttMs = typeof rttRaw === 'number' && Number.isFinite(rttRaw) ? rttRaw : null;
|
||||
const droppedNativeVideoFrames = asOptionalCount(payload.droppedNativeVideoFrames);
|
||||
const droppedVideoFrameCallbacks = asOptionalCount(payload.droppedVideoFrameCallbacks);
|
||||
const outbound = Array.isArray(payload.outbound)
|
||||
? payload.outbound.flatMap((entry) => coerceVoiceEngineV2BridgeOutboundStat(entry))
|
||||
: [];
|
||||
const inbound = Array.isArray(payload.inbound)
|
||||
? payload.inbound.flatMap((entry) => coerceVoiceEngineV2BridgeInboundStat(entry))
|
||||
: [];
|
||||
const send = coerceVoiceEngineV2BridgeSendStats(payload.send);
|
||||
return {rttMs, outbound, inbound, droppedNativeVideoFrames, droppedVideoFrameCallbacks, send};
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
VoiceEngineV2Event,
|
||||
VoiceEngineV2NativeAudioDeviceModuleStatus,
|
||||
VoiceEngineV2TrackKind,
|
||||
VoiceEngineV2TrackSource,
|
||||
} from '../protocol';
|
||||
import {assertVideoFrameInvariants} from './ffi_assertions';
|
||||
import type {VoiceEngineV2BridgeEvent, VoiceEngineV2BridgeKnownEventType, VoiceEngineV2BridgeVideoFrame} from './types';
|
||||
|
||||
type VoiceEngineV2RoomTrackPublishedEvent = Extract<VoiceEngineV2Event, {type: 'room.trackPublished'}>;
|
||||
|
||||
type VoiceEngineV2BridgeRecord = Record<string, unknown>;
|
||||
|
||||
function bridgeString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function bridgeBoolean(value: unknown): boolean {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function bridgeAudioDeviceModuleStatus(value: unknown): VoiceEngineV2NativeAudioDeviceModuleStatus | null {
|
||||
if (value === 'warming' || value === 'ready' || value === 'failed') return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeVoiceEngineV2BridgeTrackSource(value: unknown): VoiceEngineV2TrackSource | string {
|
||||
switch (value) {
|
||||
case 'microphone':
|
||||
return 'microphone';
|
||||
case 'camera':
|
||||
return 'camera';
|
||||
case 'screen':
|
||||
case 'screen_share':
|
||||
case 'screenshare':
|
||||
return 'screen';
|
||||
case 'screenAudio':
|
||||
case 'screen_share_audio':
|
||||
case 'screenshareAudio':
|
||||
case 'screenshare_audio':
|
||||
case 'screen_audio':
|
||||
case 'system_audio':
|
||||
return 'screenAudio';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVoiceEngineV2BridgeTrackKind(value: unknown): VoiceEngineV2TrackKind | null {
|
||||
return value === 'audio' || value === 'video' ? value : null;
|
||||
}
|
||||
|
||||
function participantEventFromBridgePayload(payload: VoiceEngineV2BridgeRecord): VoiceEngineV2Event | null {
|
||||
const identity = bridgeString(payload.identity);
|
||||
const sid = bridgeString(payload.sid);
|
||||
if (!identity || !sid) return null;
|
||||
return {
|
||||
type: 'room.participantJoined',
|
||||
participant: {
|
||||
identity,
|
||||
sid,
|
||||
name: bridgeString(payload.name) ?? identity,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function trackEventFromBridgePayload(payload: VoiceEngineV2BridgeRecord): VoiceEngineV2RoomTrackPublishedEvent | null {
|
||||
const participantIdentity = bridgeString(payload.identity) ?? bridgeString(payload.participantIdentity);
|
||||
const participantSid = bridgeString(payload.participantSid);
|
||||
const trackSid = bridgeString(payload.trackSid);
|
||||
const kind = normalizeVoiceEngineV2BridgeTrackKind(payload.kind);
|
||||
if (!participantIdentity || !participantSid || !trackSid || !kind) return null;
|
||||
const source = normalizeVoiceEngineV2BridgeTrackSource(payload.source);
|
||||
return {
|
||||
type: 'room.trackPublished',
|
||||
track: {
|
||||
participantIdentity,
|
||||
participantSid,
|
||||
trackSid,
|
||||
trackName: bridgeString(payload.trackName) ?? String(source),
|
||||
kind,
|
||||
source,
|
||||
muted: bridgeBoolean(payload.muted),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function nativeAudioDeviceModuleStatusEventFromBridgePayload(
|
||||
payload: VoiceEngineV2BridgeRecord,
|
||||
): VoiceEngineV2Event | null {
|
||||
const status = bridgeAudioDeviceModuleStatus(payload.status);
|
||||
if (!status) return null;
|
||||
const detail = bridgeString(payload.detail);
|
||||
return {
|
||||
type: 'nativeAudioDeviceModule.statusChanged',
|
||||
status,
|
||||
...(detail ? {detail} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const VOICE_ENGINE_V2_BRIDGE_KNOWN_EVENT_TYPE_FLAGS: {[Type in VoiceEngineV2BridgeKnownEventType]: true} = {
|
||||
connected: true,
|
||||
connectionState: true,
|
||||
disconnected: true,
|
||||
participantJoined: true,
|
||||
participantLeft: true,
|
||||
participantNameChanged: true,
|
||||
participantMetadataChanged: true,
|
||||
participantAttributesChanged: true,
|
||||
trackPublished: true,
|
||||
trackUnpublished: true,
|
||||
trackSubscribed: true,
|
||||
trackUnsubscribed: true,
|
||||
trackSubscriptionFailed: true,
|
||||
trackMuted: true,
|
||||
trackUnmuted: true,
|
||||
localTrackPublished: true,
|
||||
localTrackUnpublished: true,
|
||||
localTrackRepublished: true,
|
||||
activeSpeakers: true,
|
||||
speakingChanged: true,
|
||||
connectionQuality: true,
|
||||
dataReceived: true,
|
||||
e2eeState: true,
|
||||
stats: true,
|
||||
audioPlaybackUnavailable: true,
|
||||
engineReady: true,
|
||||
audioDeviceModuleStatus: true,
|
||||
};
|
||||
|
||||
const VOICE_ENGINE_V2_BRIDGE_KNOWN_EVENT_TYPE_SET: ReadonlySet<string> = new Set(
|
||||
Object.keys(VOICE_ENGINE_V2_BRIDGE_KNOWN_EVENT_TYPE_FLAGS),
|
||||
);
|
||||
|
||||
export function isVoiceEngineV2BridgeKnownEventType(value: string): value is VoiceEngineV2BridgeKnownEventType {
|
||||
return VOICE_ENGINE_V2_BRIDGE_KNOWN_EVENT_TYPE_SET.has(value);
|
||||
}
|
||||
|
||||
export const VOICE_ENGINE_V2_BRIDGE_DROPPED_EVENTS_COUNT_MAX = 1_000_000_000;
|
||||
|
||||
export interface VoiceEngineV2BridgeDroppedEventCounts {
|
||||
malformedPayloadCount: number;
|
||||
unknownTypeCount: number;
|
||||
}
|
||||
|
||||
let bridgeMalformedPayloadCount = 0;
|
||||
let bridgeUnknownTypeCount = 0;
|
||||
|
||||
function saturatingDroppedEventCount(count: number): number {
|
||||
return Math.min(count + 1, VOICE_ENGINE_V2_BRIDGE_DROPPED_EVENTS_COUNT_MAX);
|
||||
}
|
||||
|
||||
export function getVoiceEngineV2BridgeDroppedEventCounts(): VoiceEngineV2BridgeDroppedEventCounts {
|
||||
return {
|
||||
malformedPayloadCount: bridgeMalformedPayloadCount,
|
||||
unknownTypeCount: bridgeUnknownTypeCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetVoiceEngineV2BridgeDroppedEventCounts(): void {
|
||||
bridgeMalformedPayloadCount = 0;
|
||||
bridgeUnknownTypeCount = 0;
|
||||
}
|
||||
|
||||
type VoiceEngineV2BridgeTranslationResult = Array<VoiceEngineV2Event> | 'ignored' | 'malformed';
|
||||
|
||||
export function translateVoiceEngineV2BridgeEventToEvents(event: VoiceEngineV2BridgeEvent): Array<VoiceEngineV2Event> {
|
||||
if (!isVoiceEngineV2BridgeKnownEventType(event.type)) {
|
||||
bridgeUnknownTypeCount = saturatingDroppedEventCount(bridgeUnknownTypeCount);
|
||||
return [];
|
||||
}
|
||||
const payload = event.payload as VoiceEngineV2BridgeRecord;
|
||||
const translated = translateKnownVoiceEngineV2BridgeEvent(event.type, payload);
|
||||
if (translated === 'ignored') return [];
|
||||
if (translated === 'malformed') {
|
||||
bridgeMalformedPayloadCount = saturatingDroppedEventCount(bridgeMalformedPayloadCount);
|
||||
return [];
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
|
||||
function translateKnownVoiceEngineV2BridgeEvent(
|
||||
type: VoiceEngineV2BridgeKnownEventType,
|
||||
payload: VoiceEngineV2BridgeRecord,
|
||||
): VoiceEngineV2BridgeTranslationResult {
|
||||
switch (type) {
|
||||
case 'participantJoined':
|
||||
case 'participantNameChanged':
|
||||
case 'participantMetadataChanged':
|
||||
case 'participantAttributesChanged': {
|
||||
const participantEvent = participantEventFromBridgePayload(payload);
|
||||
return participantEvent ? [participantEvent] : 'malformed';
|
||||
}
|
||||
case 'participantLeft':
|
||||
return participantLeftEventsFromBridgePayload(payload);
|
||||
case 'trackPublished':
|
||||
case 'localTrackPublished': {
|
||||
const trackEvent = trackEventFromBridgePayload(payload);
|
||||
return trackEvent ? [trackEvent] : 'malformed';
|
||||
}
|
||||
case 'trackSubscribed':
|
||||
return trackSubscribedEventsFromBridgePayload(payload);
|
||||
case 'trackUnpublished':
|
||||
case 'localTrackUnpublished': {
|
||||
const trackSid = bridgeString(payload.trackSid);
|
||||
return trackSid
|
||||
? [
|
||||
{type: 'room.trackUnpublished', trackSid},
|
||||
{type: 'inboundVideo.trackUnsubscribed', trackSid},
|
||||
]
|
||||
: 'malformed';
|
||||
}
|
||||
case 'trackUnsubscribed':
|
||||
case 'trackSubscriptionFailed': {
|
||||
const trackSid = bridgeString(payload.trackSid);
|
||||
return trackSid ? [{type: 'inboundVideo.trackUnsubscribed', trackSid}] : 'malformed';
|
||||
}
|
||||
case 'trackMuted': {
|
||||
const trackSid = bridgeString(payload.trackSid);
|
||||
return trackSid ? [{type: 'room.trackMuted', trackSid}] : 'malformed';
|
||||
}
|
||||
case 'trackUnmuted': {
|
||||
const trackSid = bridgeString(payload.trackSid);
|
||||
return trackSid ? [{type: 'room.trackUnmuted', trackSid}] : 'malformed';
|
||||
}
|
||||
case 'activeSpeakers':
|
||||
return Array.isArray(payload.participants)
|
||||
? payload.participants.flatMap((participant) =>
|
||||
typeof participant === 'object' && participant !== null
|
||||
? (participantEventFromBridgePayload(participant as VoiceEngineV2BridgeRecord) ?? [])
|
||||
: [],
|
||||
)
|
||||
: 'malformed';
|
||||
case 'connected':
|
||||
case 'connectionState':
|
||||
case 'disconnected':
|
||||
case 'localTrackRepublished':
|
||||
case 'speakingChanged':
|
||||
case 'connectionQuality':
|
||||
case 'dataReceived':
|
||||
case 'e2eeState':
|
||||
case 'stats':
|
||||
case 'audioPlaybackUnavailable':
|
||||
case 'engineReady':
|
||||
return 'ignored';
|
||||
case 'audioDeviceModuleStatus': {
|
||||
const event = nativeAudioDeviceModuleStatusEventFromBridgePayload(payload);
|
||||
return event ? [event] : 'malformed';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function participantLeftEventsFromBridgePayload(
|
||||
payload: VoiceEngineV2BridgeRecord,
|
||||
): VoiceEngineV2BridgeTranslationResult {
|
||||
const participantIdentity = bridgeString(payload.identity);
|
||||
const participantSid = bridgeString(payload.sid);
|
||||
if (!participantIdentity && !participantSid) return 'malformed';
|
||||
return [
|
||||
{
|
||||
type: 'room.participantLeft',
|
||||
...(participantIdentity ? {participantIdentity} : {}),
|
||||
...(participantSid ? {participantSid} : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function trackSubscribedEventsFromBridgePayload(
|
||||
payload: VoiceEngineV2BridgeRecord,
|
||||
): VoiceEngineV2BridgeTranslationResult {
|
||||
const trackEvent = trackEventFromBridgePayload(payload);
|
||||
if (!trackEvent) return 'malformed';
|
||||
if (trackEvent.track.kind !== 'video') return [trackEvent];
|
||||
return [
|
||||
trackEvent,
|
||||
{
|
||||
type: 'inboundVideo.trackSubscribed',
|
||||
track: {
|
||||
participantSid: trackEvent.track.participantSid,
|
||||
participantIdentity: trackEvent.track.participantIdentity,
|
||||
trackSid: trackEvent.track.trackSid,
|
||||
source: trackEvent.track.source,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function translateVoiceEngineV2BridgeVideoFrameToEvent(
|
||||
frame: VoiceEngineV2BridgeVideoFrame,
|
||||
): VoiceEngineV2Event {
|
||||
assertVideoFrameInvariants({
|
||||
widthPx: frame.meta.width,
|
||||
heightPx: frame.meta.height,
|
||||
frameBytes: frame.data.byteLength,
|
||||
timestampNs: frame.meta.timestampUs * 1000,
|
||||
});
|
||||
return {
|
||||
type: 'inboundVideo.frameReceived',
|
||||
frame: {
|
||||
participantSid: frame.meta.participantSid,
|
||||
trackSid: frame.meta.trackSid,
|
||||
width: frame.meta.width,
|
||||
height: frame.meta.height,
|
||||
timestampUs: frame.meta.timestampUs,
|
||||
byteLength: frame.data.byteLength,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// biome-ignore-all lint/suspicious/noConfusingVoidType: IPC result metadata mirrors void-returning bridge methods.
|
||||
|
||||
import type {
|
||||
VoiceEngineV2CameraOptions,
|
||||
VoiceEngineV2ConnectOptions,
|
||||
VoiceEngineV2DataOptions,
|
||||
VoiceEngineV2HardwareEncoderCapabilities,
|
||||
VoiceEngineV2PcmFrame,
|
||||
VoiceEngineV2RemoteTrackQuality,
|
||||
VoiceEngineV2RemoteTrackSubscriptionOptions,
|
||||
VoiceEngineV2ScreenAudioOptions,
|
||||
VoiceEngineV2ScreenOptions,
|
||||
VoiceEngineV2SendStats,
|
||||
VoiceEngineV2TrackKind,
|
||||
VoiceEngineV2TrackSource,
|
||||
VoiceEngineV2VideoCodec,
|
||||
} from '../protocol';
|
||||
|
||||
export const VOICE_ENGINE_V2_BRIDGE_VERSION = 18;
|
||||
|
||||
export type VoiceEngineV2BridgeMethodName =
|
||||
| 'isSupported'
|
||||
| 'getCapabilities'
|
||||
| 'prewarm'
|
||||
| 'getHardwareEncoderCapabilities'
|
||||
| 'connect'
|
||||
| 'disconnect'
|
||||
| 'isConnected'
|
||||
| 'publishMicrophone'
|
||||
| 'pushPcm'
|
||||
| 'publishScreen'
|
||||
| 'updateScreenShareEncoding'
|
||||
| 'unpublishScreen'
|
||||
| 'publishScreenAudio'
|
||||
| 'pushScreenAudioPcm'
|
||||
| 'pushScreenAudioFloat'
|
||||
| 'unpublishScreenAudio'
|
||||
| 'setMicEnabled'
|
||||
| 'setSpeakingDetection'
|
||||
| 'listAudioInputDevices'
|
||||
| 'listAudioOutputDevices'
|
||||
| 'setAudioOutputDevice'
|
||||
| 'setParticipantVolume'
|
||||
| 'setRemoteTrackSubscription'
|
||||
| 'publishData'
|
||||
| 'listCameraDevices'
|
||||
| 'publishCamera'
|
||||
| 'updateCameraCapture'
|
||||
| 'publishNativeCameraSink'
|
||||
| 'publishProcessedCamera'
|
||||
| 'pushProcessedCameraFrame'
|
||||
| 'pushCameraBackgroundFrame'
|
||||
| 'clearCameraBackgroundFrame'
|
||||
| 'publishDeviceScreenShare'
|
||||
| 'unpublishCamera'
|
||||
| 'isPublishingCamera'
|
||||
| 'startCameraPreview'
|
||||
| 'stopCameraPreview'
|
||||
| 'getConnectionStats'
|
||||
| 'getVoiceEngineReadiness'
|
||||
| 'getAudioDeviceModuleState'
|
||||
| 'onEvent'
|
||||
| 'onVideoFrame';
|
||||
|
||||
export type VoiceEngineV2InvokeMethodName = Exclude<VoiceEngineV2BridgeMethodName, 'onEvent' | 'onVideoFrame'>;
|
||||
|
||||
export type VoiceEngineV2BridgeConnectOptions = VoiceEngineV2ConnectOptions;
|
||||
|
||||
export type VoiceEngineV2BridgeScreenPacing = 'sender' | 'source';
|
||||
|
||||
export interface VoiceEngineV2BridgePublishScreenOptions extends VoiceEngineV2ScreenOptions {
|
||||
codec?: VoiceEngineV2VideoCodec;
|
||||
pacing?: VoiceEngineV2BridgeScreenPacing;
|
||||
trackName?: string;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeUpdateScreenShareEncodingOptions {
|
||||
captureId: string;
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate?: number;
|
||||
maxBitrateBps?: number;
|
||||
codec?: VoiceEngineV2VideoCodec;
|
||||
hardwareEncoding?: boolean;
|
||||
zeroCopyRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgePublishMicrophoneOptions {
|
||||
mode?: 'device' | 'pcm-test';
|
||||
deviceId?: string;
|
||||
echoCancellation?: boolean;
|
||||
noiseSuppression?: boolean;
|
||||
autoGainControl?: boolean;
|
||||
deepFilter?: boolean;
|
||||
deepFilterNoiseReductionLevel?: number;
|
||||
sampleRate?: number;
|
||||
numChannels?: number;
|
||||
maxBitrateBps?: number;
|
||||
}
|
||||
|
||||
export type VoiceEngineV2BridgePcmFrame = VoiceEngineV2PcmFrame;
|
||||
export interface VoiceEngineV2BridgeFloatPcmFrame {
|
||||
sampleRate: number;
|
||||
numChannels: number;
|
||||
samples: Float32Array;
|
||||
}
|
||||
export type VoiceEngineV2BridgePublishScreenAudioOptions = VoiceEngineV2ScreenAudioOptions;
|
||||
export type VoiceEngineV2BridgePublishDataOptions = VoiceEngineV2DataOptions;
|
||||
|
||||
export type VoiceEngineV2BridgeTrackKind = VoiceEngineV2TrackKind;
|
||||
export type VoiceEngineV2BridgeTrackSource =
|
||||
| VoiceEngineV2TrackSource
|
||||
| 'screen_share'
|
||||
| 'screen_share_audio'
|
||||
| 'screenshare'
|
||||
| 'screenshareAudio';
|
||||
export type VoiceEngineV2BridgeSubscriptionStatus = 'desired' | 'subscribed' | 'unsubscribed';
|
||||
export type VoiceEngineV2BridgeConnectionQuality = 'excellent' | 'good' | 'poor' | 'lost';
|
||||
|
||||
export interface VoiceEngineV2BridgeParticipantEventPayload {
|
||||
sid: string;
|
||||
identity: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeTrackEventPayload {
|
||||
participantSid: string;
|
||||
identity: string;
|
||||
participantName: string;
|
||||
trackSid: string;
|
||||
trackName: string;
|
||||
kind: VoiceEngineV2BridgeTrackKind;
|
||||
source: VoiceEngineV2BridgeTrackSource;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeSubscribedTrackEventPayload extends VoiceEngineV2BridgeTrackEventPayload {
|
||||
subscribed: boolean;
|
||||
subscriptionStatus: VoiceEngineV2BridgeSubscriptionStatus;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeTrackSubscriptionFailedEventPayload {
|
||||
participantSid: string;
|
||||
identity: string;
|
||||
participantName: string;
|
||||
trackSid: string;
|
||||
error: string;
|
||||
trackName?: string;
|
||||
kind?: VoiceEngineV2BridgeTrackKind;
|
||||
source?: VoiceEngineV2BridgeTrackSource;
|
||||
muted?: boolean;
|
||||
subscribed?: boolean;
|
||||
subscriptionStatus?: VoiceEngineV2BridgeSubscriptionStatus;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeLocalTrackRepublishedEventPayload extends VoiceEngineV2BridgeTrackEventPayload {
|
||||
previousTrackSid: string;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeEventPayloads {
|
||||
connected: Record<string, never>;
|
||||
connectionState: {state: string};
|
||||
disconnected: {reason: string};
|
||||
participantJoined: VoiceEngineV2BridgeParticipantEventPayload;
|
||||
participantLeft: VoiceEngineV2BridgeParticipantEventPayload;
|
||||
participantNameChanged: {sid: string; identity: string; oldName: string; name: string};
|
||||
participantMetadataChanged: {
|
||||
sid: string;
|
||||
identity: string;
|
||||
name: string;
|
||||
oldMetadata: string;
|
||||
metadata: string;
|
||||
attributes: Record<string, string>;
|
||||
};
|
||||
participantAttributesChanged: {
|
||||
sid: string;
|
||||
identity: string;
|
||||
name: string;
|
||||
attributes: Record<string, string>;
|
||||
changedAttributes: Record<string, string>;
|
||||
};
|
||||
trackPublished: VoiceEngineV2BridgeSubscribedTrackEventPayload;
|
||||
trackUnpublished: VoiceEngineV2BridgeSubscribedTrackEventPayload;
|
||||
trackSubscribed: VoiceEngineV2BridgeSubscribedTrackEventPayload;
|
||||
trackUnsubscribed: VoiceEngineV2BridgeSubscribedTrackEventPayload;
|
||||
trackSubscriptionFailed: VoiceEngineV2BridgeTrackSubscriptionFailedEventPayload;
|
||||
trackMuted: VoiceEngineV2BridgeTrackEventPayload;
|
||||
trackUnmuted: VoiceEngineV2BridgeTrackEventPayload;
|
||||
localTrackPublished: VoiceEngineV2BridgeTrackEventPayload;
|
||||
localTrackUnpublished: VoiceEngineV2BridgeTrackEventPayload;
|
||||
localTrackRepublished: VoiceEngineV2BridgeLocalTrackRepublishedEventPayload;
|
||||
activeSpeakers: {
|
||||
sids: Array<string>;
|
||||
participants: Array<VoiceEngineV2BridgeParticipantEventPayload>;
|
||||
};
|
||||
speakingChanged: {
|
||||
participantSid: string;
|
||||
identity: string;
|
||||
trackSid: string;
|
||||
source: VoiceEngineV2BridgeTrackSource;
|
||||
isLocal: boolean;
|
||||
speaking: boolean;
|
||||
};
|
||||
connectionQuality: {
|
||||
sid: string;
|
||||
identity: string;
|
||||
name: string;
|
||||
quality: VoiceEngineV2BridgeConnectionQuality;
|
||||
};
|
||||
dataReceived: {
|
||||
payloadBytes: Array<number>;
|
||||
payloadText?: string;
|
||||
topic?: string;
|
||||
reliable: boolean;
|
||||
kind: 'reliable' | 'lossy';
|
||||
participantSid?: string;
|
||||
identity?: string;
|
||||
participantName?: string;
|
||||
};
|
||||
e2eeState: {sid: string; identity: string; name: string; state: string};
|
||||
stats: VoiceEngineV2BridgeStats;
|
||||
audioPlaybackUnavailable: {message: string};
|
||||
engineReady: {ready: true};
|
||||
audioDeviceModuleStatus: VoiceEngineV2BridgeAudioDeviceModuleState;
|
||||
}
|
||||
|
||||
export const VOICE_ENGINE_V2_ENGINE_READY_EVENT_TYPE = 'engineReady';
|
||||
export const VOICE_ENGINE_V2_ADM_STATUS_EVENT_TYPE = 'audioDeviceModuleStatus';
|
||||
|
||||
export interface VoiceEngineV2BridgeReadiness {
|
||||
ready: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type VoiceEngineV2BridgeAudioDeviceModuleStatus = 'warming' | 'ready' | 'failed';
|
||||
|
||||
export interface VoiceEngineV2BridgeAudioDeviceModuleState {
|
||||
status: VoiceEngineV2BridgeAudioDeviceModuleStatus;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export type VoiceEngineV2BridgeKnownEventType = keyof VoiceEngineV2BridgeEventPayloads;
|
||||
export type VoiceEngineV2BridgeEventType = VoiceEngineV2BridgeKnownEventType | (string & {});
|
||||
export type VoiceEngineV2BridgeKnownEvent = {
|
||||
[Type in VoiceEngineV2BridgeKnownEventType]: {type: Type; payload: VoiceEngineV2BridgeEventPayloads[Type]};
|
||||
}[VoiceEngineV2BridgeKnownEventType];
|
||||
export type VoiceEngineV2BridgeEvent =
|
||||
| VoiceEngineV2BridgeKnownEvent
|
||||
| {type: string & {}; payload: Record<string, unknown>};
|
||||
|
||||
export interface VoiceEngineV2BridgeVideoFrameMeta {
|
||||
bridgeVersion?: number;
|
||||
participantSid: string;
|
||||
participantIdentity?: string;
|
||||
trackSid: string;
|
||||
trackName?: string;
|
||||
source?: VoiceEngineV2BridgeTrackSource;
|
||||
width: number;
|
||||
height: number;
|
||||
timestampUs: number;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeVideoFrame {
|
||||
meta: VoiceEngineV2BridgeVideoFrameMeta;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
export type VoiceEngineV2BridgeAudioDeviceRole = 'default' | 'communications' | 'endpoint';
|
||||
|
||||
export interface VoiceEngineV2BridgeAudioOutputDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
isDefault: boolean;
|
||||
role?: VoiceEngineV2BridgeAudioDeviceRole;
|
||||
endpointLabel?: string;
|
||||
isDefaultRoute?: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeAudioInputDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
isDefault: boolean;
|
||||
role?: VoiceEngineV2BridgeAudioDeviceRole;
|
||||
endpointLabel?: string;
|
||||
isDefaultRoute?: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeCameraDevice {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
description: string;
|
||||
index?: number | null;
|
||||
deviceIdAliases?: Array<string>;
|
||||
}
|
||||
|
||||
export const VOICE_ENGINE_V2_CAMERA_EFFECT_STRENGTH_MIN = 0;
|
||||
export const VOICE_ENGINE_V2_CAMERA_EFFECT_STRENGTH_MAX = 100;
|
||||
export const VOICE_ENGINE_V2_CAMERA_EFFECT_STRENGTH_DEFAULT = 50;
|
||||
|
||||
export interface VoiceEngineV2BridgePublishCameraOptions extends VoiceEngineV2CameraOptions {
|
||||
mirror?: boolean;
|
||||
backgroundBlurStrength?: number;
|
||||
}
|
||||
|
||||
export type VoiceEngineV2BridgeUpdateCameraCaptureOptions = VoiceEngineV2BridgePublishCameraOptions;
|
||||
export type VoiceEngineV2BridgeCameraBackgroundMode = 'none' | 'non' | 'blur' | 'custom';
|
||||
export type VoiceEngineV2BridgeCameraBackgroundCustomMediaKind = 'static' | 'animated' | 'video';
|
||||
|
||||
export interface VoiceEngineV2BridgePublishProcessedCameraOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate: number;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgePublishProcessedCameraResult {
|
||||
trackSid: string;
|
||||
}
|
||||
|
||||
export type VoiceEngineV2BridgePublishNativeCameraSinkResult = VoiceEngineV2BridgePublishProcessedCameraResult;
|
||||
|
||||
export type VoiceEngineV2BridgeStartCameraPreviewOptions = VoiceEngineV2BridgePublishCameraOptions;
|
||||
|
||||
export interface VoiceEngineV2BridgeCameraPreviewInfo {
|
||||
trackSid: string;
|
||||
width: number;
|
||||
height: number;
|
||||
frameRate: number;
|
||||
}
|
||||
|
||||
export type VoiceEngineV2BridgeProcessedCameraFrameFormat = 'i420';
|
||||
|
||||
export interface VoiceEngineV2BridgeProcessedCameraFrame {
|
||||
format: VoiceEngineV2BridgeProcessedCameraFrameFormat;
|
||||
width: number;
|
||||
height: number;
|
||||
timestampUs: number;
|
||||
data: ArrayBuffer | ArrayBufferView;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgePublishDeviceScreenShareOptions {
|
||||
deviceId?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
frameRate?: number;
|
||||
codec?: VoiceEngineV2VideoCodec;
|
||||
maxBitrateBps?: number;
|
||||
maxFramerate?: number;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeOutboundStat {
|
||||
trackSid: string;
|
||||
source: string;
|
||||
kind: VoiceEngineV2BridgeTrackKind;
|
||||
codec?: string;
|
||||
bitrateKbps: number;
|
||||
packetsLost: number;
|
||||
fps?: number;
|
||||
audioLevel?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
sourceWidth?: number;
|
||||
sourceHeight?: number;
|
||||
targetBitrateKbps?: number;
|
||||
configuredFps?: number;
|
||||
targetFps?: number;
|
||||
effectiveFps?: number;
|
||||
framesProduced?: number;
|
||||
framesAccepted?: number;
|
||||
framesDropped?: number;
|
||||
framesCoalesced?: number;
|
||||
framesCaptured?: number;
|
||||
captureFailures?: number;
|
||||
maxQueueAgeMs?: number;
|
||||
maxPushLatencyMs?: number;
|
||||
adaptiveSendTier?: string;
|
||||
adaptiveSendReason?: string;
|
||||
zeroCopy?: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeInboundStat {
|
||||
participantSid: string;
|
||||
participantIdentity?: string;
|
||||
trackSid: string;
|
||||
source?: string;
|
||||
kind: VoiceEngineV2BridgeTrackKind;
|
||||
codec?: string;
|
||||
bitrateKbps: number;
|
||||
packetsLost: number;
|
||||
jitterMs?: number;
|
||||
audioLevel?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
sourceWidth?: number;
|
||||
sourceHeight?: number;
|
||||
}
|
||||
|
||||
export type VoiceEngineV2BridgeSendStats = VoiceEngineV2SendStats;
|
||||
|
||||
export interface VoiceEngineV2BridgeStats {
|
||||
rttMs: number | null;
|
||||
outbound: Array<VoiceEngineV2BridgeOutboundStat>;
|
||||
inbound: Array<VoiceEngineV2BridgeInboundStat>;
|
||||
droppedNativeVideoFrames?: number;
|
||||
droppedVideoFrameCallbacks?: number;
|
||||
send?: VoiceEngineV2BridgeSendStats | null;
|
||||
}
|
||||
|
||||
export type VoiceEngineV2BridgeRemoteTrackSubscriptionQuality = VoiceEngineV2RemoteTrackQuality;
|
||||
export type VoiceEngineV2BridgeRemoteTrackSubscriptionOptions = VoiceEngineV2RemoteTrackSubscriptionOptions;
|
||||
|
||||
export type VoiceEngineV2BridgeHardwareEncoderBackend = 'nvenc' | 'videotoolbox' | 'none' | string;
|
||||
export type VoiceEngineV2BridgeHardwareEncoderCapabilities = VoiceEngineV2HardwareEncoderCapabilities;
|
||||
|
||||
export interface VoiceEngineV2BridgeCapabilities {
|
||||
microphoneCapture: boolean;
|
||||
syntheticMicrophonePcm: boolean;
|
||||
cameraCapture: boolean;
|
||||
nativeCameraBackgrounds: boolean;
|
||||
screenShare: boolean;
|
||||
screenShareEncodingUpdate: boolean;
|
||||
screenShareAudio: boolean;
|
||||
deviceLists: boolean;
|
||||
outputDeviceSelection: boolean;
|
||||
participantVolume: boolean;
|
||||
remoteTrackSubscription: boolean;
|
||||
dataChannel: boolean;
|
||||
connectionStats: boolean;
|
||||
nativeVideoFrames: boolean;
|
||||
hardwareEncoderCapabilities: boolean;
|
||||
}
|
||||
|
||||
export type VoiceEngineV2BridgeOperationErrorCode =
|
||||
| 'unsupported-capability'
|
||||
| 'not-connected'
|
||||
| 'invalid-args'
|
||||
| 'native-error';
|
||||
|
||||
export interface VoiceEngineV2BridgeOperationError {
|
||||
code: VoiceEngineV2BridgeOperationErrorCode;
|
||||
message: string;
|
||||
capability?: keyof VoiceEngineV2BridgeCapabilities | string;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeOperationSuccess {
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeOperationFailure {
|
||||
ok: false;
|
||||
error: VoiceEngineV2BridgeOperationError;
|
||||
}
|
||||
|
||||
export type VoiceEngineV2BridgeOperationResult =
|
||||
| VoiceEngineV2BridgeOperationSuccess
|
||||
| VoiceEngineV2BridgeOperationFailure;
|
||||
|
||||
export const VOICE_ENGINE_V2_OPERATION_SUCCESS: VoiceEngineV2BridgeOperationSuccess = {ok: true};
|
||||
|
||||
export function createVoiceEngineV2OperationFailure(
|
||||
error: VoiceEngineV2BridgeOperationError,
|
||||
): VoiceEngineV2BridgeOperationFailure {
|
||||
return {ok: false, error};
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeParticipantVolumeOptions {
|
||||
participantSid: string;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeSpeakingDetectionOptions {
|
||||
localThresholdRms: number;
|
||||
remoteThresholdRms: number;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeIpcMethods {
|
||||
isSupported: {args: []; result: boolean};
|
||||
getCapabilities: {args: []; result: VoiceEngineV2BridgeCapabilities};
|
||||
prewarm: {args: []; result: void};
|
||||
getHardwareEncoderCapabilities: {args: []; result: VoiceEngineV2BridgeHardwareEncoderCapabilities};
|
||||
connect: {args: [VoiceEngineV2BridgeConnectOptions]; result: void};
|
||||
disconnect: {args: []; result: void};
|
||||
isConnected: {args: []; result: boolean};
|
||||
publishMicrophone: {args: [VoiceEngineV2BridgePublishMicrophoneOptions]; result: VoiceEngineV2BridgeOperationResult};
|
||||
pushPcm: {args: [VoiceEngineV2BridgePcmFrame]; result: boolean};
|
||||
publishScreen: {args: [VoiceEngineV2BridgePublishScreenOptions]; result: void};
|
||||
updateScreenShareEncoding: {args: [VoiceEngineV2BridgeUpdateScreenShareEncodingOptions]; result: void};
|
||||
unpublishScreen: {args: []; result: void};
|
||||
publishScreenAudio: {args: [VoiceEngineV2BridgePublishScreenAudioOptions]; result: void};
|
||||
pushScreenAudioPcm: {args: [VoiceEngineV2BridgePcmFrame]; result: boolean};
|
||||
pushScreenAudioFloat: {args: [VoiceEngineV2BridgeFloatPcmFrame]; result: boolean};
|
||||
unpublishScreenAudio: {args: []; result: void};
|
||||
setMicEnabled: {args: [boolean]; result: VoiceEngineV2BridgeOperationResult};
|
||||
setSpeakingDetection: {args: [VoiceEngineV2BridgeSpeakingDetectionOptions]; result: void};
|
||||
listAudioInputDevices: {args: []; result: Array<VoiceEngineV2BridgeAudioInputDevice>};
|
||||
listAudioOutputDevices: {args: []; result: Array<VoiceEngineV2BridgeAudioOutputDevice>};
|
||||
setAudioOutputDevice: {args: [string]; result: void};
|
||||
setParticipantVolume: {args: [VoiceEngineV2BridgeParticipantVolumeOptions]; result: void};
|
||||
setRemoteTrackSubscription: {args: [VoiceEngineV2BridgeRemoteTrackSubscriptionOptions]; result: void};
|
||||
publishData: {args: [VoiceEngineV2BridgePublishDataOptions]; result: void};
|
||||
listCameraDevices: {args: []; result: Array<VoiceEngineV2BridgeCameraDevice>};
|
||||
publishCamera: {args: [VoiceEngineV2BridgePublishCameraOptions]; result: void};
|
||||
updateCameraCapture: {args: [VoiceEngineV2BridgeUpdateCameraCaptureOptions]; result: void};
|
||||
publishNativeCameraSink: {
|
||||
args: [VoiceEngineV2BridgePublishCameraOptions];
|
||||
result: VoiceEngineV2BridgePublishNativeCameraSinkResult;
|
||||
};
|
||||
publishProcessedCamera: {
|
||||
args: [VoiceEngineV2BridgePublishProcessedCameraOptions];
|
||||
result: VoiceEngineV2BridgePublishProcessedCameraResult;
|
||||
};
|
||||
pushProcessedCameraFrame: {args: [VoiceEngineV2BridgeProcessedCameraFrame]; result: boolean};
|
||||
pushCameraBackgroundFrame: {args: [VoiceEngineV2BridgeProcessedCameraFrame]; result: boolean};
|
||||
clearCameraBackgroundFrame: {args: []; result: void};
|
||||
publishDeviceScreenShare: {args: [VoiceEngineV2BridgePublishDeviceScreenShareOptions]; result: void};
|
||||
unpublishCamera: {args: []; result: void};
|
||||
isPublishingCamera: {args: []; result: boolean};
|
||||
startCameraPreview: {
|
||||
args: [VoiceEngineV2BridgeStartCameraPreviewOptions];
|
||||
result: VoiceEngineV2BridgeCameraPreviewInfo;
|
||||
};
|
||||
stopCameraPreview: {args: []; result: void};
|
||||
getConnectionStats: {args: []; result: VoiceEngineV2BridgeStats};
|
||||
getVoiceEngineReadiness: {args: []; result: VoiceEngineV2BridgeReadiness};
|
||||
getAudioDeviceModuleState: {args: []; result: VoiceEngineV2BridgeAudioDeviceModuleState};
|
||||
}
|
||||
|
||||
export const VOICE_ENGINE_V2_BRIDGE_METHODS = [
|
||||
'isSupported',
|
||||
'getCapabilities',
|
||||
'prewarm',
|
||||
'getHardwareEncoderCapabilities',
|
||||
'connect',
|
||||
'disconnect',
|
||||
'isConnected',
|
||||
'publishMicrophone',
|
||||
'pushPcm',
|
||||
'publishScreen',
|
||||
'updateScreenShareEncoding',
|
||||
'unpublishScreen',
|
||||
'publishScreenAudio',
|
||||
'pushScreenAudioPcm',
|
||||
'pushScreenAudioFloat',
|
||||
'unpublishScreenAudio',
|
||||
'setMicEnabled',
|
||||
'setSpeakingDetection',
|
||||
'listAudioInputDevices',
|
||||
'listAudioOutputDevices',
|
||||
'setAudioOutputDevice',
|
||||
'setParticipantVolume',
|
||||
'setRemoteTrackSubscription',
|
||||
'publishData',
|
||||
'listCameraDevices',
|
||||
'publishCamera',
|
||||
'updateCameraCapture',
|
||||
'publishNativeCameraSink',
|
||||
'publishProcessedCamera',
|
||||
'pushProcessedCameraFrame',
|
||||
'pushCameraBackgroundFrame',
|
||||
'clearCameraBackgroundFrame',
|
||||
'publishDeviceScreenShare',
|
||||
'unpublishCamera',
|
||||
'isPublishingCamera',
|
||||
'startCameraPreview',
|
||||
'stopCameraPreview',
|
||||
'getConnectionStats',
|
||||
'getVoiceEngineReadiness',
|
||||
'getAudioDeviceModuleState',
|
||||
'onEvent',
|
||||
'onVideoFrame',
|
||||
] as const satisfies ReadonlyArray<VoiceEngineV2BridgeMethodName>;
|
||||
|
||||
export const VOICE_ENGINE_V2_IPC_CHANNELS = {
|
||||
isSupported: 'voice-engine-v2:is-supported',
|
||||
getCapabilities: 'voice-engine-v2:get-capabilities',
|
||||
prewarm: 'voice-engine-v2:prewarm',
|
||||
getHardwareEncoderCapabilities: 'voice-engine-v2:get-hardware-encoder-capabilities',
|
||||
connect: 'voice-engine-v2:connect',
|
||||
disconnect: 'voice-engine-v2:disconnect',
|
||||
isConnected: 'voice-engine-v2:is-connected',
|
||||
publishMicrophone: 'voice-engine-v2:publish-microphone',
|
||||
pushPcm: 'voice-engine-v2:push-pcm',
|
||||
publishScreen: 'voice-engine-v2:publish-screen',
|
||||
updateScreenShareEncoding: 'voice-engine-v2:update-screen-share-encoding',
|
||||
unpublishScreen: 'voice-engine-v2:unpublish-screen',
|
||||
publishScreenAudio: 'voice-engine-v2:publish-screen-audio',
|
||||
pushScreenAudioPcm: 'voice-engine-v2:push-screen-audio-pcm',
|
||||
pushScreenAudioFloat: 'voice-engine-v2:push-screen-audio-float',
|
||||
unpublishScreenAudio: 'voice-engine-v2:unpublish-screen-audio',
|
||||
setMicEnabled: 'voice-engine-v2:set-mic-enabled',
|
||||
setSpeakingDetection: 'voice-engine-v2:set-speaking-detection',
|
||||
listAudioInputDevices: 'voice-engine-v2:list-audio-input-devices',
|
||||
listAudioOutputDevices: 'voice-engine-v2:list-audio-output-devices',
|
||||
setAudioOutputDevice: 'voice-engine-v2:set-audio-output-device',
|
||||
setParticipantVolume: 'voice-engine-v2:set-participant-volume',
|
||||
setRemoteTrackSubscription: 'voice-engine-v2:set-remote-track-subscription',
|
||||
publishData: 'voice-engine-v2:publish-data',
|
||||
listCameraDevices: 'voice-engine-v2:list-camera-devices',
|
||||
publishCamera: 'voice-engine-v2:publish-camera',
|
||||
updateCameraCapture: 'voice-engine-v2:update-camera-capture',
|
||||
publishNativeCameraSink: 'voice-engine-v2:publish-native-camera-sink',
|
||||
publishProcessedCamera: 'voice-engine-v2:publish-processed-camera',
|
||||
pushProcessedCameraFrame: 'voice-engine-v2:push-processed-camera-frame',
|
||||
pushCameraBackgroundFrame: 'voice-engine-v2:push-camera-background-frame',
|
||||
clearCameraBackgroundFrame: 'voice-engine-v2:clear-camera-background-frame',
|
||||
publishDeviceScreenShare: 'voice-engine-v2:publish-device-screen-share',
|
||||
unpublishCamera: 'voice-engine-v2:unpublish-camera',
|
||||
isPublishingCamera: 'voice-engine-v2:is-publishing-camera',
|
||||
startCameraPreview: 'voice-engine-v2:start-camera-preview',
|
||||
stopCameraPreview: 'voice-engine-v2:stop-camera-preview',
|
||||
getConnectionStats: 'voice-engine-v2:get-connection-stats',
|
||||
getVoiceEngineReadiness: 'voice-engine-v2:get-readiness',
|
||||
getAudioDeviceModuleState: 'voice-engine-v2:get-audio-device-module-state',
|
||||
} as const satisfies {[Method in VoiceEngineV2InvokeMethodName]: string};
|
||||
|
||||
export const VOICE_ENGINE_V2_EVENT_CHANNELS = {
|
||||
event: 'voice-engine-v2:event',
|
||||
videoFrame: 'voice-engine-v2:video-frame',
|
||||
} as const satisfies {
|
||||
event: string;
|
||||
videoFrame: string;
|
||||
};
|
||||
|
||||
export interface VoiceEngineV2BridgePushedEvents {
|
||||
event: VoiceEngineV2BridgeEvent;
|
||||
videoFrame: VoiceEngineV2BridgeVideoFrame;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2BridgeApi {
|
||||
bridgeVersion: number;
|
||||
isSupported(): Promise<boolean>;
|
||||
getCapabilities(): Promise<VoiceEngineV2BridgeCapabilities>;
|
||||
prewarm(): Promise<void>;
|
||||
getHardwareEncoderCapabilities(): Promise<VoiceEngineV2BridgeHardwareEncoderCapabilities>;
|
||||
connect(options: VoiceEngineV2BridgeConnectOptions): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
isConnected(): Promise<boolean>;
|
||||
publishMicrophone(options: VoiceEngineV2BridgePublishMicrophoneOptions): Promise<VoiceEngineV2BridgeOperationResult>;
|
||||
pushPcm(frame: VoiceEngineV2BridgePcmFrame): Promise<boolean>;
|
||||
publishScreen(options: VoiceEngineV2BridgePublishScreenOptions): Promise<void>;
|
||||
updateScreenShareEncoding(options: VoiceEngineV2BridgeUpdateScreenShareEncodingOptions): Promise<void>;
|
||||
unpublishScreen(): Promise<void>;
|
||||
publishScreenAudio(options: VoiceEngineV2BridgePublishScreenAudioOptions): Promise<void>;
|
||||
pushScreenAudioPcm(frame: VoiceEngineV2BridgePcmFrame): Promise<boolean>;
|
||||
pushScreenAudioFloat(frame: VoiceEngineV2BridgeFloatPcmFrame): Promise<boolean>;
|
||||
unpublishScreenAudio(): Promise<void>;
|
||||
setMicEnabled(enabled: boolean): Promise<VoiceEngineV2BridgeOperationResult>;
|
||||
setSpeakingDetection(options: VoiceEngineV2BridgeSpeakingDetectionOptions): Promise<void>;
|
||||
listAudioInputDevices(): Promise<Array<VoiceEngineV2BridgeAudioInputDevice>>;
|
||||
listAudioOutputDevices(): Promise<Array<VoiceEngineV2BridgeAudioOutputDevice>>;
|
||||
setAudioOutputDevice(deviceId: string): Promise<void>;
|
||||
setParticipantVolume(participantSid: string, volume: number): Promise<void>;
|
||||
setRemoteTrackSubscription(options: VoiceEngineV2BridgeRemoteTrackSubscriptionOptions): Promise<void>;
|
||||
publishData(options: VoiceEngineV2BridgePublishDataOptions): Promise<void>;
|
||||
listCameraDevices(): Promise<Array<VoiceEngineV2BridgeCameraDevice>>;
|
||||
publishCamera(options: VoiceEngineV2BridgePublishCameraOptions): Promise<void>;
|
||||
updateCameraCapture(options: VoiceEngineV2BridgeUpdateCameraCaptureOptions): Promise<void>;
|
||||
publishNativeCameraSink(
|
||||
options: VoiceEngineV2BridgePublishCameraOptions,
|
||||
): Promise<VoiceEngineV2BridgePublishNativeCameraSinkResult>;
|
||||
publishProcessedCamera(
|
||||
options: VoiceEngineV2BridgePublishProcessedCameraOptions,
|
||||
): Promise<VoiceEngineV2BridgePublishProcessedCameraResult>;
|
||||
pushProcessedCameraFrame(frame: VoiceEngineV2BridgeProcessedCameraFrame): Promise<boolean>;
|
||||
pushCameraBackgroundFrame(frame: VoiceEngineV2BridgeProcessedCameraFrame): Promise<boolean>;
|
||||
clearCameraBackgroundFrame(): Promise<void>;
|
||||
publishDeviceScreenShare(options: VoiceEngineV2BridgePublishDeviceScreenShareOptions): Promise<void>;
|
||||
unpublishCamera(): Promise<void>;
|
||||
isPublishingCamera(): Promise<boolean>;
|
||||
startCameraPreview(
|
||||
options: VoiceEngineV2BridgeStartCameraPreviewOptions,
|
||||
): Promise<VoiceEngineV2BridgeCameraPreviewInfo>;
|
||||
stopCameraPreview(): Promise<void>;
|
||||
getConnectionStats(): Promise<VoiceEngineV2BridgeStats>;
|
||||
getVoiceEngineReadiness(): Promise<VoiceEngineV2BridgeReadiness>;
|
||||
getAudioDeviceModuleState?(): Promise<VoiceEngineV2BridgeAudioDeviceModuleState>;
|
||||
onEvent(callback: (event: VoiceEngineV2BridgeEvent) => void): () => void;
|
||||
onVideoFrame(callback: (frame: VoiceEngineV2BridgeVideoFrame) => void): () => void;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Command} from '../protocol/commands';
|
||||
import type {VoiceEngineV2Snapshot} from './state';
|
||||
|
||||
export function isVoiceEngineV2CommandCompletionStale(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
command: VoiceEngineV2Command,
|
||||
): boolean {
|
||||
assert.ok(snapshot != null, 'isVoiceEngineV2CommandCompletionStale snapshot must not be null');
|
||||
assert.ok(command != null, 'isVoiceEngineV2CommandCompletionStale command must not be null');
|
||||
assert.equal(typeof command.type, 'string', 'command.type must be a string');
|
||||
const local = staleLocalMediaCommand(snapshot, command);
|
||||
if (local !== null) return local;
|
||||
const session = staleSessionCommand(snapshot, command);
|
||||
if (session !== null) return session;
|
||||
const platform = stalePlatformCommand(snapshot, command);
|
||||
if (platform !== null) return platform;
|
||||
return staleAlwaysFreshCommand(command);
|
||||
}
|
||||
|
||||
function staleLocalMediaCommand(snapshot: VoiceEngineV2Snapshot, command: VoiceEngineV2Command): boolean | null {
|
||||
assert.ok(snapshot != null, 'staleLocalMediaCommand snapshot must not be null');
|
||||
assert.ok(command != null, 'staleLocalMediaCommand command must not be null');
|
||||
switch (command.type) {
|
||||
case 'connection.connect':
|
||||
case 'connection.disconnect':
|
||||
return snapshot.connection.operationId !== command.operationId;
|
||||
case 'microphone.publish':
|
||||
case 'microphone.unpublish':
|
||||
return snapshot.microphone.operationId !== command.operationId;
|
||||
case 'microphone.setEnabled':
|
||||
return snapshot.microphone.setEnabledOperationId !== command.operationId;
|
||||
case 'camera.publish':
|
||||
case 'camera.updateEncoding':
|
||||
case 'camera.unpublish':
|
||||
return snapshot.camera.operationId !== command.operationId;
|
||||
case 'screen.publish':
|
||||
case 'screen.updateEncoding':
|
||||
case 'screen.unpublish':
|
||||
return snapshot.screen.operationId !== command.operationId;
|
||||
case 'screenAudio.publish':
|
||||
case 'screenAudio.unpublish':
|
||||
return snapshot.screenAudio.operationId !== command.operationId;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function staleSessionCommand(snapshot: VoiceEngineV2Snapshot, command: VoiceEngineV2Command): boolean | null {
|
||||
assert.ok(snapshot != null, 'staleSessionCommand snapshot must not be null');
|
||||
assert.ok(command != null, 'staleSessionCommand command must not be null');
|
||||
switch (command.type) {
|
||||
case 'outputDevice.set':
|
||||
return snapshot.outputDevice.operationId !== command.operationId;
|
||||
case 'stats.collect':
|
||||
return snapshot.statsOperationId !== command.operationId;
|
||||
case 'capabilities.queryHardwareEncoder':
|
||||
return snapshot.hardwareEncoder.operationId !== command.operationId;
|
||||
case 'gateway.voiceState.write':
|
||||
case 'gateway.voiceState.clear':
|
||||
return snapshot.gateway.operationId !== command.operationId;
|
||||
case 'lifecycle.teardown':
|
||||
return snapshot.lifecycle.operationId !== command.operationId;
|
||||
case 'e2ee.setEnabled':
|
||||
return snapshot.e2ee.operationId !== command.operationId;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stalePlatformCommand(snapshot: VoiceEngineV2Snapshot, command: VoiceEngineV2Command): boolean | null {
|
||||
assert.ok(snapshot != null, 'stalePlatformCommand snapshot must not be null');
|
||||
assert.ok(command != null, 'stalePlatformCommand command must not be null');
|
||||
switch (command.type) {
|
||||
case 'permissions.check':
|
||||
case 'permissions.request':
|
||||
return snapshot.permissions.operationIds[String(command.name)] !== command.operationId;
|
||||
case 'devices.enumerate':
|
||||
case 'devices.selectAudioInput':
|
||||
case 'devices.selectAudioOutput':
|
||||
case 'devices.selectCamera':
|
||||
return snapshot.devices.operationId !== command.operationId;
|
||||
case 'nativeCapture.start':
|
||||
case 'nativeCapture.update':
|
||||
return snapshot.nativeCapture.operationIds[command.options.captureId] !== command.operationId;
|
||||
case 'nativeCapture.stop':
|
||||
return snapshot.nativeCapture.operationIds[command.captureId] !== command.operationId;
|
||||
case 'nativeAudioTap.start':
|
||||
return snapshot.nativeAudioTap.operationIds[command.options.tapId] !== command.operationId;
|
||||
case 'nativeAudioTap.stop':
|
||||
return snapshot.nativeAudioTap.operationIds[command.tapId] !== command.operationId;
|
||||
case 'nativeFrameSink.attach':
|
||||
return snapshot.nativeFrameSink.operationIds[command.options.sinkId] !== command.operationId;
|
||||
case 'nativeFrameSink.detach':
|
||||
return snapshot.nativeFrameSink.operationIds[command.sinkId] !== command.operationId;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function staleAlwaysFreshCommand(command: VoiceEngineV2Command): boolean {
|
||||
assert.ok(command != null, 'staleAlwaysFreshCommand command must not be null');
|
||||
assert.equal(typeof command.type, 'string', 'command.type must be a string');
|
||||
switch (command.type) {
|
||||
case 'implementation.prewarm':
|
||||
case 'participantVolume.set':
|
||||
case 'remoteTrackSubscription.set':
|
||||
case 'data.publish':
|
||||
case 'timer.schedule':
|
||||
case 'timer.cancel':
|
||||
case 'diagnostics.log':
|
||||
case 'operation.cancel':
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import gatewayConnectionFixtureJson from '../../fixtures/event_logs/gateway_connection.json';
|
||||
import reconnectChannelMoveFixtureJson from '../../fixtures/event_logs/reconnect_channel_move.json';
|
||||
import resumePreservesMediaFixtureJson from '../../fixtures/event_logs/resume_preserves_media.json';
|
||||
import staleConnectionFixtureJson from '../../fixtures/event_logs/stale_connection.json';
|
||||
import teardownRendererWindowFixtureJson from '../../fixtures/event_logs/teardown_renderer_window.json';
|
||||
import type {VoiceEngineV2Command} from '../protocol/commands';
|
||||
import type {VoiceEngineV2Event} from '../protocol/events';
|
||||
import {transitionVoiceEngineV2} from './reducer';
|
||||
import {
|
||||
availableVoiceEngineV2Capabilities,
|
||||
createVoiceEngineV2InitialSnapshot,
|
||||
type VoiceEngineV2OperationStatus,
|
||||
type VoiceEngineV2Snapshot,
|
||||
} from './state';
|
||||
|
||||
interface VoiceEngineV2ConnectionGatewayFixtureStep {
|
||||
name: string;
|
||||
event: VoiceEngineV2Event;
|
||||
commands: Array<VoiceEngineV2Command>;
|
||||
}
|
||||
|
||||
interface VoiceEngineV2ConnectionGatewayFixtureExpected {
|
||||
nextOperationId: number;
|
||||
connectionStatus: string;
|
||||
activeUrl: string | null;
|
||||
desiredUrl: string | null;
|
||||
gatewayChannelId: string | null;
|
||||
lifecycleTearingDown: boolean;
|
||||
operationStatuses?: Record<string, VoiceEngineV2OperationStatus>;
|
||||
}
|
||||
|
||||
interface VoiceEngineV2ConnectionGatewayFixture {
|
||||
name: string;
|
||||
steps: Array<VoiceEngineV2ConnectionGatewayFixtureStep>;
|
||||
expected: VoiceEngineV2ConnectionGatewayFixtureExpected;
|
||||
}
|
||||
|
||||
const fixtures = [
|
||||
gatewayConnectionFixtureJson,
|
||||
reconnectChannelMoveFixtureJson,
|
||||
resumePreservesMediaFixtureJson,
|
||||
staleConnectionFixtureJson,
|
||||
teardownRendererWindowFixtureJson,
|
||||
] as Array<unknown>;
|
||||
|
||||
function initialSnapshot(): VoiceEngineV2Snapshot {
|
||||
return createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities());
|
||||
}
|
||||
|
||||
function replayFixture(fixture: VoiceEngineV2ConnectionGatewayFixture): VoiceEngineV2Snapshot {
|
||||
let snapshot = initialSnapshot();
|
||||
for (const step of fixture.steps) {
|
||||
const transition = transitionVoiceEngineV2(snapshot, step.event);
|
||||
expect(transition.commands, `${fixture.name}: ${step.name}`).toEqual(step.commands);
|
||||
snapshot = transition.snapshot;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
describe('connection and gateway event-log fixtures', () => {
|
||||
it.each(fixtures)('replays %s as pure snapshot transitions', (fixtureJson) => {
|
||||
const fixture = fixtureJson as VoiceEngineV2ConnectionGatewayFixture;
|
||||
const snapshot = replayFixture(fixture);
|
||||
|
||||
expect(snapshot.nextOperationId).toBe(fixture.expected.nextOperationId);
|
||||
expect(snapshot.connection.status).toBe(fixture.expected.connectionStatus);
|
||||
expect(snapshot.connection.active?.url ?? null).toBe(fixture.expected.activeUrl);
|
||||
expect(snapshot.connection.desired?.url ?? null).toBe(fixture.expected.desiredUrl);
|
||||
expect(snapshot.gateway.selfVoiceState?.channelId ?? null).toBe(fixture.expected.gatewayChannelId);
|
||||
expect(snapshot.lifecycle.tearingDown).toBe(fixture.expected.lifecycleTearingDown);
|
||||
|
||||
for (const [operationId, status] of Object.entries(fixture.expected.operationStatuses ?? {})) {
|
||||
expect(snapshot.operations[operationId]?.status, `${fixture.name}: operation ${operationId}`).toBe(status);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not mutate the previous snapshot when planning a connection command', () => {
|
||||
const snapshot = initialSnapshot();
|
||||
const transition = transitionVoiceEngineV2(snapshot, {
|
||||
type: 'connection.connectRequested',
|
||||
options: {url: 'wss://voice.example.test', token: 'token'},
|
||||
});
|
||||
|
||||
expect(snapshot.connection.status).toBe('idle');
|
||||
expect(snapshot.connection.operationId).toBeNull();
|
||||
expect(snapshot.nextOperationId).toBe(1);
|
||||
expect(transition.snapshot).not.toBe(snapshot);
|
||||
expect(transition.snapshot.connection.status).toBe('connecting');
|
||||
expect(transition.commands).toEqual([
|
||||
{
|
||||
type: 'connection.connect',
|
||||
operationId: 1,
|
||||
options: {url: 'wss://voice.example.test', token: 'token'},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import fixtureJson from '../../fixtures/basic_session.json';
|
||||
import cameraInPlaceUpdateFixtureJson from '../../fixtures/event_logs/camera_in_place_update.json';
|
||||
import codecRepublishFixtureJson from '../../fixtures/event_logs/codec_republish.json';
|
||||
import nativeFrameSinkFixtureJson from '../../fixtures/event_logs/native_frame_sink.json';
|
||||
import nativeZeroCopyScreenFixtureJson from '../../fixtures/event_logs/native_zero_copy_screen.json';
|
||||
import screenAudioInclusionToggleFixtureJson from '../../fixtures/event_logs/screen_audio_inclusion_toggle.json';
|
||||
import screenAudioRoutingFixtureJson from '../../fixtures/event_logs/screen_audio_routing.json';
|
||||
import screenShareStartUpdateStopFixtureJson from '../../fixtures/event_logs/screen_share_start_update_stop.json';
|
||||
import externallyEstablishedFixtureJson from '../../fixtures/externally_established_session.json';
|
||||
import type {VoiceEngineV2Command, VoiceEngineV2Event} from '../protocol';
|
||||
import {transitionVoiceEngineV2} from './reducer';
|
||||
import {availableVoiceEngineV2Capabilities, createVoiceEngineV2InitialSnapshot} from './state';
|
||||
|
||||
interface VoiceEngineV2FixtureExpected {
|
||||
nextOperationId: number;
|
||||
connectionStatus: string;
|
||||
activeUrl: string;
|
||||
microphoneStatus: string;
|
||||
microphoneDeviceId: string;
|
||||
gatewayChannelId: string;
|
||||
}
|
||||
|
||||
interface VoiceEngineV2Fixture {
|
||||
name: string;
|
||||
events: Array<VoiceEngineV2Event>;
|
||||
expected: VoiceEngineV2FixtureExpected;
|
||||
}
|
||||
|
||||
interface VoiceEngineV2ScreenFixtureExpected {
|
||||
nextOperationId: number;
|
||||
cameraStatus?: string;
|
||||
cameraDeviceId?: string | null;
|
||||
cameraWidth?: number;
|
||||
cameraFrameRate?: number;
|
||||
cameraMirror?: boolean;
|
||||
cameraBackgroundMode?: string;
|
||||
screenStatus?: string;
|
||||
screenCaptureId?: string | null;
|
||||
screenCodec?: string;
|
||||
screenHardwareEncoding?: boolean;
|
||||
screenZeroCopyRequired?: boolean;
|
||||
screenAudioStatus?: string;
|
||||
screenAudioRoute?: string;
|
||||
screenAudioTapId?: string;
|
||||
nativeCaptureCount?: number;
|
||||
nativeFrameSinkCount?: number;
|
||||
lastFailureCode?: string | null;
|
||||
}
|
||||
|
||||
interface VoiceEngineV2CommandReplayFixture {
|
||||
name: string;
|
||||
events: Array<VoiceEngineV2Event>;
|
||||
expectedCommands: Array<VoiceEngineV2Command>;
|
||||
expected: VoiceEngineV2ScreenFixtureExpected;
|
||||
}
|
||||
|
||||
const screenEventLogFixtures = [
|
||||
screenShareStartUpdateStopFixtureJson,
|
||||
cameraInPlaceUpdateFixtureJson,
|
||||
screenAudioInclusionToggleFixtureJson,
|
||||
screenAudioRoutingFixtureJson,
|
||||
codecRepublishFixtureJson,
|
||||
nativeZeroCopyScreenFixtureJson,
|
||||
nativeFrameSinkFixtureJson,
|
||||
] as Array<VoiceEngineV2CommandReplayFixture>;
|
||||
|
||||
const sessionFixtures = [fixtureJson, externallyEstablishedFixtureJson] as Array<VoiceEngineV2Fixture>;
|
||||
|
||||
describe('voice engine v2 shared fixtures', () => {
|
||||
it.each(sessionFixtures)('replays the session event log fixture: $name', (fixture) => {
|
||||
let snapshot = createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities());
|
||||
|
||||
for (const event of fixture.events) {
|
||||
snapshot = transitionVoiceEngineV2(snapshot, event).snapshot;
|
||||
}
|
||||
|
||||
expect(snapshot.nextOperationId).toBe(fixture.expected.nextOperationId);
|
||||
expect(snapshot.connection.status).toBe(fixture.expected.connectionStatus);
|
||||
expect(snapshot.connection.active?.url).toBe(fixture.expected.activeUrl);
|
||||
expect(snapshot.microphone.status).toBe(fixture.expected.microphoneStatus);
|
||||
expect(snapshot.microphone.published?.deviceId).toBe(fixture.expected.microphoneDeviceId);
|
||||
expect(snapshot.gateway.selfVoiceState?.channelId).toBe(fixture.expected.gatewayChannelId);
|
||||
});
|
||||
|
||||
it.each(screenEventLogFixtures)('replays screen/native event log fixture: $name', (fixture) => {
|
||||
let snapshot = createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities());
|
||||
const commands: Array<VoiceEngineV2Command> = [];
|
||||
|
||||
for (const event of fixture.events) {
|
||||
const transition = transitionVoiceEngineV2(snapshot, event);
|
||||
commands.push(...transition.commands);
|
||||
snapshot = transition.snapshot;
|
||||
}
|
||||
|
||||
expect(commands).toEqual(fixture.expectedCommands);
|
||||
expect(snapshot.nextOperationId).toBe(fixture.expected.nextOperationId);
|
||||
if (fixture.expected.cameraStatus !== undefined) {
|
||||
expect(snapshot.camera.status).toBe(fixture.expected.cameraStatus);
|
||||
}
|
||||
if (fixture.expected.cameraDeviceId !== undefined) {
|
||||
expect(snapshot.camera.published?.deviceId ?? null).toBe(fixture.expected.cameraDeviceId);
|
||||
}
|
||||
if (fixture.expected.cameraWidth !== undefined) {
|
||||
expect(snapshot.camera.published?.width).toBe(fixture.expected.cameraWidth);
|
||||
}
|
||||
if (fixture.expected.cameraFrameRate !== undefined) {
|
||||
expect(snapshot.camera.published?.frameRate).toBe(fixture.expected.cameraFrameRate);
|
||||
}
|
||||
if (fixture.expected.cameraMirror !== undefined) {
|
||||
expect(snapshot.camera.published?.mirror).toBe(fixture.expected.cameraMirror);
|
||||
}
|
||||
if (fixture.expected.cameraBackgroundMode !== undefined) {
|
||||
expect(snapshot.camera.published?.backgroundMode).toBe(fixture.expected.cameraBackgroundMode);
|
||||
}
|
||||
if (fixture.expected.screenStatus !== undefined) {
|
||||
expect(snapshot.screen.status).toBe(fixture.expected.screenStatus);
|
||||
}
|
||||
if (fixture.expected.screenCaptureId !== undefined) {
|
||||
expect(snapshot.screen.published?.captureId ?? null).toBe(fixture.expected.screenCaptureId);
|
||||
}
|
||||
if (fixture.expected.screenCodec !== undefined) {
|
||||
expect(snapshot.screen.published?.codec).toBe(fixture.expected.screenCodec);
|
||||
}
|
||||
if (fixture.expected.screenHardwareEncoding !== undefined) {
|
||||
expect(snapshot.screen.published?.hardwareEncoding).toBe(fixture.expected.screenHardwareEncoding);
|
||||
}
|
||||
if (fixture.expected.screenZeroCopyRequired !== undefined) {
|
||||
expect(snapshot.screen.published?.zeroCopyRequired).toBe(fixture.expected.screenZeroCopyRequired);
|
||||
}
|
||||
if (fixture.expected.screenAudioStatus !== undefined) {
|
||||
expect(snapshot.screenAudio.status).toBe(fixture.expected.screenAudioStatus);
|
||||
}
|
||||
if (fixture.expected.screenAudioRoute !== undefined) {
|
||||
expect(snapshot.screenAudio.published?.route).toBe(fixture.expected.screenAudioRoute);
|
||||
}
|
||||
if (fixture.expected.screenAudioTapId !== undefined) {
|
||||
expect(snapshot.screenAudio.published?.tapId).toBe(fixture.expected.screenAudioTapId);
|
||||
}
|
||||
if (fixture.expected.nativeCaptureCount !== undefined) {
|
||||
expect(Object.keys(snapshot.nativeCapture.captures)).toHaveLength(fixture.expected.nativeCaptureCount);
|
||||
}
|
||||
if (fixture.expected.nativeFrameSinkCount !== undefined) {
|
||||
expect(Object.keys(snapshot.nativeFrameSink.sinks)).toHaveLength(fixture.expected.nativeFrameSinkCount);
|
||||
}
|
||||
if (fixture.expected.lastFailureCode !== undefined) {
|
||||
expect(snapshot.lastFailure?.code ?? null).toBe(fixture.expected.lastFailureCode);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export {isVoiceEngineV2CommandCompletionStale, transitionVoiceEngineV2} from './reducer';
|
||||
export {shouldApplyGatewayVoiceStateEcho} from './reducers/gateway';
|
||||
export type {
|
||||
VoiceEngineV2CapabilitiesProjection,
|
||||
VoiceEngineV2DeviceProjection,
|
||||
VoiceEngineV2DiagnosticsProjection,
|
||||
VoiceEngineV2ParticipantProjection,
|
||||
VoiceEngineV2StatsPresentationNetworkSummary,
|
||||
VoiceEngineV2StatsPresentationProjection,
|
||||
VoiceEngineV2StatsProjection,
|
||||
} from './selectors';
|
||||
export {
|
||||
selectVoiceEngineV2CapabilitiesProjection,
|
||||
selectVoiceEngineV2ConnectionProjection,
|
||||
selectVoiceEngineV2DeviceProjection,
|
||||
selectVoiceEngineV2DiagnosticsProjection,
|
||||
selectVoiceEngineV2E2eeProjection,
|
||||
selectVoiceEngineV2FailedSourceIds,
|
||||
selectVoiceEngineV2MediaProjection,
|
||||
selectVoiceEngineV2Model,
|
||||
selectVoiceEngineV2ParticipantProjection,
|
||||
selectVoiceEngineV2SourceLifecycle,
|
||||
selectVoiceEngineV2StatsPresentationProjection,
|
||||
selectVoiceEngineV2StatsProjection,
|
||||
selectVoiceEngineV2StatsSummary,
|
||||
selectVoiceEngineV2StreamNegotiation,
|
||||
selectVoiceEngineV2WatchedStreams,
|
||||
} from './selectors';
|
||||
export type {
|
||||
SourceLifecycleState,
|
||||
VoiceEngineV2ConnectionState,
|
||||
VoiceEngineV2DeviceState,
|
||||
VoiceEngineV2E2eeLifecycleState,
|
||||
VoiceEngineV2GatewayState,
|
||||
VoiceEngineV2HardwareEncoderState,
|
||||
VoiceEngineV2InboundVideoState,
|
||||
VoiceEngineV2LifecycleState,
|
||||
VoiceEngineV2LiveKitState,
|
||||
VoiceEngineV2LocalMediaState,
|
||||
VoiceEngineV2MicrophoneState,
|
||||
VoiceEngineV2NativeAudioTapState,
|
||||
VoiceEngineV2NativeCaptureState,
|
||||
VoiceEngineV2NativeFrameSinkState,
|
||||
VoiceEngineV2OperationState,
|
||||
VoiceEngineV2OperationStatus,
|
||||
VoiceEngineV2OutputDeviceState,
|
||||
VoiceEngineV2PermissionState,
|
||||
VoiceEngineV2RoomState,
|
||||
VoiceEngineV2Snapshot,
|
||||
VoiceEngineV2Transition,
|
||||
} from './state';
|
||||
export {
|
||||
availableVoiceEngineV2Capabilities,
|
||||
createVoiceEngineV2InitialSnapshot,
|
||||
createVoiceEngineV2PermissionResult,
|
||||
emptyVoiceEngineV2DeviceInventory,
|
||||
unavailableVoiceEngineV2Capabilities,
|
||||
} from './state';
|
||||
@@ -0,0 +1,110 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import deviceHotswapFixtureJson from '../../fixtures/event_logs/device_hotswap.json';
|
||||
import localMediaFixtureJson from '../../fixtures/event_logs/local_media.json';
|
||||
import microphoneFailureRecoveryFixtureJson from '../../fixtures/event_logs/microphone_failure_recovery.json';
|
||||
import permissionsRecoveryFixtureJson from '../../fixtures/event_logs/permissions_recovery.json';
|
||||
import pttPtmFixtureJson from '../../fixtures/event_logs/ptt_ptm.json';
|
||||
import type {VoiceEngineV2Command, VoiceEngineV2Event} from '../protocol';
|
||||
import {transitionVoiceEngineV2} from './reducer';
|
||||
import {
|
||||
availableVoiceEngineV2Capabilities,
|
||||
createVoiceEngineV2InitialSnapshot,
|
||||
type VoiceEngineV2Snapshot,
|
||||
} from './state';
|
||||
|
||||
interface LocalMediaSnapshotExpectation {
|
||||
nextOperationId?: number;
|
||||
connectionStatus?: string;
|
||||
microphoneStatus?: string;
|
||||
microphoneEnabled?: boolean;
|
||||
localSpeakingOverride?: boolean | null;
|
||||
audioMode?: string;
|
||||
locallyMuted?: boolean;
|
||||
preferredLocallyMuted?: boolean;
|
||||
locallyDeafened?: boolean;
|
||||
mutedByPermission?: boolean;
|
||||
shouldUnmuteOnUndeafen?: boolean;
|
||||
publishedDeviceId?: string | null;
|
||||
desiredDeviceId?: string | null;
|
||||
selectedAudioInputId?: string | null;
|
||||
permissionStatus?: string;
|
||||
lastFailureCode?: string | null;
|
||||
}
|
||||
|
||||
interface LocalMediaCheckpoint extends LocalMediaSnapshotExpectation {
|
||||
afterEvent: number;
|
||||
}
|
||||
|
||||
interface LocalMediaFixture {
|
||||
name: string;
|
||||
events: Array<VoiceEngineV2Event>;
|
||||
expectedCommands: Array<VoiceEngineV2Command>;
|
||||
checkpoints?: Array<LocalMediaCheckpoint>;
|
||||
expectedSnapshot: LocalMediaSnapshotExpectation;
|
||||
}
|
||||
|
||||
const fixtures = [
|
||||
localMediaFixtureJson,
|
||||
pttPtmFixtureJson,
|
||||
deviceHotswapFixtureJson,
|
||||
permissionsRecoveryFixtureJson,
|
||||
microphoneFailureRecoveryFixtureJson,
|
||||
] as Array<LocalMediaFixture>;
|
||||
|
||||
function applyExpectation(snapshot: VoiceEngineV2Snapshot, expected: LocalMediaSnapshotExpectation): void {
|
||||
if (expected.nextOperationId !== undefined) expect(snapshot.nextOperationId).toBe(expected.nextOperationId);
|
||||
if (expected.connectionStatus !== undefined) expect(snapshot.connection.status).toBe(expected.connectionStatus);
|
||||
if (expected.microphoneStatus !== undefined) expect(snapshot.microphone.status).toBe(expected.microphoneStatus);
|
||||
if (expected.microphoneEnabled !== undefined) expect(snapshot.microphone.enabled).toBe(expected.microphoneEnabled);
|
||||
if (expected.localSpeakingOverride !== undefined) {
|
||||
expect(snapshot.microphone.localSpeakingOverride).toBe(expected.localSpeakingOverride);
|
||||
}
|
||||
if (expected.audioMode !== undefined) expect(snapshot.audioControls.mode).toBe(expected.audioMode);
|
||||
if (expected.locallyMuted !== undefined) expect(snapshot.audioControls.locallyMuted).toBe(expected.locallyMuted);
|
||||
if (expected.preferredLocallyMuted !== undefined) {
|
||||
expect(snapshot.audioControls.preferredLocallyMuted).toBe(expected.preferredLocallyMuted);
|
||||
}
|
||||
if (expected.locallyDeafened !== undefined) {
|
||||
expect(snapshot.audioControls.locallyDeafened).toBe(expected.locallyDeafened);
|
||||
}
|
||||
if (expected.mutedByPermission !== undefined) {
|
||||
expect(snapshot.audioControls.mutedByPermission).toBe(expected.mutedByPermission);
|
||||
}
|
||||
if (expected.shouldUnmuteOnUndeafen !== undefined) {
|
||||
expect(snapshot.audioControls.shouldUnmuteOnUndeafen).toBe(expected.shouldUnmuteOnUndeafen);
|
||||
}
|
||||
if (expected.publishedDeviceId !== undefined) {
|
||||
expect(snapshot.microphone.published?.deviceId ?? null).toBe(expected.publishedDeviceId);
|
||||
}
|
||||
if (expected.desiredDeviceId !== undefined) {
|
||||
expect(snapshot.microphone.desired?.deviceId ?? null).toBe(expected.desiredDeviceId);
|
||||
}
|
||||
if (expected.selectedAudioInputId !== undefined) {
|
||||
expect(snapshot.devices.inventory.selectedAudioInputId).toBe(expected.selectedAudioInputId);
|
||||
}
|
||||
if (expected.permissionStatus !== undefined) {
|
||||
expect(snapshot.permissions.results.microphone?.status).toBe(expected.permissionStatus);
|
||||
}
|
||||
if (expected.lastFailureCode !== undefined) expect(snapshot.lastFailure?.code ?? null).toBe(expected.lastFailureCode);
|
||||
}
|
||||
|
||||
describe('voice engine v2 local media fixture replay', () => {
|
||||
it.each(fixtures)('replays local media fixture: $name', (fixture) => {
|
||||
let snapshot = createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities());
|
||||
const commands: Array<VoiceEngineV2Command> = [];
|
||||
|
||||
fixture.events.forEach((event, index) => {
|
||||
const transition = transitionVoiceEngineV2(snapshot, event);
|
||||
snapshot = transition.snapshot;
|
||||
commands.push(...transition.commands);
|
||||
for (const checkpoint of fixture.checkpoints ?? []) {
|
||||
if (checkpoint.afterEvent === index + 1) applyExpectation(snapshot, checkpoint);
|
||||
}
|
||||
});
|
||||
|
||||
expect(commands).toEqual(fixture.expectedCommands);
|
||||
applyExpectation(snapshot, fixture.expectedSnapshot);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import capabilitiesLifecycleFixtureJson from '../../fixtures/event_logs/capabilities_lifecycle.json';
|
||||
import deviceInventoryFixtureJson from '../../fixtures/event_logs/device_inventory.json';
|
||||
import diagnosticsLifecycleFixtureJson from '../../fixtures/event_logs/diagnostics_lifecycle.json';
|
||||
import e2eeLifecycleFixtureJson from '../../fixtures/event_logs/e2ee_lifecycle.json';
|
||||
import statsLifecycleFixtureJson from '../../fixtures/event_logs/stats_lifecycle.json';
|
||||
import type {VoiceEngineV2Command} from '../protocol/commands';
|
||||
import type {VoiceEngineV2Event} from '../protocol/events';
|
||||
import {transitionVoiceEngineV2} from './reducer';
|
||||
import {
|
||||
selectVoiceEngineV2CapabilitiesProjection,
|
||||
selectVoiceEngineV2DeviceProjection,
|
||||
selectVoiceEngineV2DiagnosticsProjection,
|
||||
selectVoiceEngineV2E2eeProjection,
|
||||
selectVoiceEngineV2StatsProjection,
|
||||
} from './selectors';
|
||||
import {
|
||||
availableVoiceEngineV2Capabilities,
|
||||
createVoiceEngineV2InitialSnapshot,
|
||||
type VoiceEngineV2Snapshot,
|
||||
} from './state';
|
||||
|
||||
interface VoiceEngineV2EventLogFixture<Expected> {
|
||||
name: string;
|
||||
events: Array<VoiceEngineV2Event>;
|
||||
expectedCommands: Array<Array<VoiceEngineV2Command>>;
|
||||
expected: Expected;
|
||||
}
|
||||
|
||||
interface StatsLifecycleExpected {
|
||||
nextOperationId: number;
|
||||
statsOperationId: number | null;
|
||||
statsFailureCode: string | null;
|
||||
summary: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DiagnosticsLifecycleExpected {
|
||||
nextOperationId: number;
|
||||
entryIds: Array<string>;
|
||||
lastCode: string;
|
||||
operationStatuses: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CapabilitiesLifecycleExpected {
|
||||
nextOperationId: number;
|
||||
hardwareEncoderOperationId: number | null;
|
||||
hardwareEncoderFailureCode: string | null;
|
||||
hardwareEncoderBackend: string;
|
||||
hasZeroCopyNativeInput: boolean;
|
||||
hasNativeNvencH264: boolean;
|
||||
hasNativeNvencH265: boolean;
|
||||
}
|
||||
|
||||
interface E2eeLifecycleExpected {
|
||||
nextOperationId: number;
|
||||
status: string;
|
||||
keyId: string | null;
|
||||
operationId: number | null;
|
||||
failureCode: string | null;
|
||||
}
|
||||
|
||||
interface DeviceInventoryExpected {
|
||||
nextOperationId: number;
|
||||
deviceOperationId: number | null;
|
||||
failureCode: string | null;
|
||||
selectedAudioInputId: string | null;
|
||||
selectedAudioInputLabel: string | null;
|
||||
selectedAudioInputRole: string | null;
|
||||
audioOutputCount: number;
|
||||
cameraCount: number;
|
||||
operationStatuses: Record<string, string>;
|
||||
}
|
||||
|
||||
function replayFixture<Expected>(
|
||||
fixture: VoiceEngineV2EventLogFixture<Expected>,
|
||||
initialSnapshot: VoiceEngineV2Snapshot = createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities()),
|
||||
): VoiceEngineV2Snapshot {
|
||||
let snapshot = initialSnapshot;
|
||||
fixture.events.forEach((event, index) => {
|
||||
const transition = transitionVoiceEngineV2(snapshot, event);
|
||||
expect(transition.commands).toEqual(fixture.expectedCommands[index]);
|
||||
snapshot = transition.snapshot;
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
describe('voice engine v2 observability lifecycle fixtures', () => {
|
||||
it('replays stats collection into the stats summary projection', () => {
|
||||
const fixture = statsLifecycleFixtureJson as VoiceEngineV2EventLogFixture<StatsLifecycleExpected>;
|
||||
const snapshot = replayFixture(fixture);
|
||||
const statsProjection = selectVoiceEngineV2StatsProjection(snapshot);
|
||||
|
||||
expect(snapshot.nextOperationId).toBe(fixture.expected.nextOperationId);
|
||||
expect(snapshot.statsOperationId).toBe(fixture.expected.statsOperationId);
|
||||
expect(snapshot.statsFailure?.code ?? null).toBe(fixture.expected.statsFailureCode);
|
||||
expect(statsProjection.summary).toMatchObject(fixture.expected.summary);
|
||||
});
|
||||
|
||||
it('replays diagnostics logging into append-only diagnostics projection', () => {
|
||||
const fixture = diagnosticsLifecycleFixtureJson as VoiceEngineV2EventLogFixture<DiagnosticsLifecycleExpected>;
|
||||
const snapshot = replayFixture(fixture);
|
||||
const diagnosticsProjection = selectVoiceEngineV2DiagnosticsProjection(snapshot);
|
||||
|
||||
expect(snapshot.nextOperationId).toBe(fixture.expected.nextOperationId);
|
||||
expect(diagnosticsProjection.entries.map((entry) => entry.id)).toEqual(fixture.expected.entryIds);
|
||||
expect(diagnosticsProjection.entries.at(-1)?.code).toBe(fixture.expected.lastCode);
|
||||
expect(
|
||||
Object.fromEntries(Object.entries(snapshot.operations).map(([id, operation]) => [id, operation.status])),
|
||||
).toEqual(fixture.expected.operationStatuses);
|
||||
});
|
||||
|
||||
it('replays hardware capability discovery into the capabilities projection', () => {
|
||||
const fixture = capabilitiesLifecycleFixtureJson as VoiceEngineV2EventLogFixture<CapabilitiesLifecycleExpected>;
|
||||
const snapshot = replayFixture(fixture);
|
||||
const capabilitiesProjection = selectVoiceEngineV2CapabilitiesProjection(snapshot);
|
||||
|
||||
expect(snapshot.nextOperationId).toBe(fixture.expected.nextOperationId);
|
||||
expect(snapshot.hardwareEncoder.operationId).toBe(fixture.expected.hardwareEncoderOperationId);
|
||||
expect(snapshot.hardwareEncoder.failure?.code ?? null).toBe(fixture.expected.hardwareEncoderFailureCode);
|
||||
expect(capabilitiesProjection.hardwareEncoderCapabilities?.backend).toBe(fixture.expected.hardwareEncoderBackend);
|
||||
expect(capabilitiesProjection.hasZeroCopyNativeInput).toBe(fixture.expected.hasZeroCopyNativeInput);
|
||||
expect(capabilitiesProjection.hasNativeNvencH264).toBe(fixture.expected.hasNativeNvencH264);
|
||||
expect(capabilitiesProjection.hasNativeNvencH265).toBe(fixture.expected.hasNativeNvencH265);
|
||||
});
|
||||
|
||||
it('rejects stale E2EE completions and projects public E2EE state without reducer bookkeeping', () => {
|
||||
const fixture = e2eeLifecycleFixtureJson as VoiceEngineV2EventLogFixture<E2eeLifecycleExpected>;
|
||||
const snapshot = replayFixture(fixture);
|
||||
const e2eeProjection = selectVoiceEngineV2E2eeProjection(snapshot);
|
||||
|
||||
expect(snapshot.nextOperationId).toBe(fixture.expected.nextOperationId);
|
||||
expect(e2eeProjection.status).toBe(fixture.expected.status);
|
||||
expect(e2eeProjection.keyId).toBe(fixture.expected.keyId);
|
||||
expect(e2eeProjection.failure?.code ?? null).toBe(fixture.expected.failureCode);
|
||||
expect('operationId' in e2eeProjection).toBe(false);
|
||||
expect(snapshot.e2ee.operationId).toBe(fixture.expected.operationId);
|
||||
});
|
||||
|
||||
it('replays typed device inventory and clears selection operations', () => {
|
||||
const fixture = deviceInventoryFixtureJson as VoiceEngineV2EventLogFixture<DeviceInventoryExpected>;
|
||||
const snapshot = replayFixture(fixture);
|
||||
const deviceProjection = selectVoiceEngineV2DeviceProjection(snapshot);
|
||||
|
||||
expect(snapshot.nextOperationId).toBe(fixture.expected.nextOperationId);
|
||||
expect(snapshot.devices.operationId).toBe(fixture.expected.deviceOperationId);
|
||||
expect(snapshot.devices.failure?.code ?? null).toBe(fixture.expected.failureCode);
|
||||
expect(deviceProjection.devices.selectedAudioInputId).toBe(fixture.expected.selectedAudioInputId);
|
||||
expect(deviceProjection.selectedAudioInput?.label ?? null).toBe(fixture.expected.selectedAudioInputLabel);
|
||||
expect(deviceProjection.selectedAudioInput?.role ?? null).toBe(fixture.expected.selectedAudioInputRole);
|
||||
expect(deviceProjection.devices.audioOutputs).toHaveLength(fixture.expected.audioOutputCount);
|
||||
expect(deviceProjection.devices.cameras).toHaveLength(fixture.expected.cameraCount);
|
||||
expect(
|
||||
Object.fromEntries(Object.entries(snapshot.operations).map(([id, operation]) => [id, operation.status])),
|
||||
).toEqual(fixture.expected.operationStatuses);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import inboundVideoFrameStatsFixtureJson from '../../fixtures/event_logs/inbound_video_frame_stats.json';
|
||||
import inboundVideoFramesFixtureJson from '../../fixtures/event_logs/inbound_video_frames.json';
|
||||
import participantsTracksFixtureJson from '../../fixtures/event_logs/participants_tracks.json';
|
||||
import subscriptionQualityFixtureJson from '../../fixtures/event_logs/subscription_quality.json';
|
||||
import watchedStreamsFixtureJson from '../../fixtures/event_logs/watched_streams.json';
|
||||
import {translateVoiceEngineV2BridgeEventToEvents, translateVoiceEngineV2BridgeVideoFrameToEvent} from '../bridge';
|
||||
import type {VoiceEngineV2Command} from '../protocol/commands';
|
||||
import type {VoiceEngineV2Event} from '../protocol/events';
|
||||
import type {
|
||||
VoiceEngineV2InboundVideoTrack,
|
||||
VoiceEngineV2Participant,
|
||||
VoiceEngineV2RemoteTrackSubscriptionOptions,
|
||||
VoiceEngineV2Track,
|
||||
VoiceEngineV2WatchedStream,
|
||||
} from '../protocol/types';
|
||||
import {transitionVoiceEngineV2} from './reducer';
|
||||
import {selectVoiceEngineV2ParticipantProjection, selectVoiceEngineV2WatchedStreams} from './selectors';
|
||||
import {
|
||||
availableVoiceEngineV2Capabilities,
|
||||
createVoiceEngineV2InitialSnapshot,
|
||||
type VoiceEngineV2Snapshot,
|
||||
} from './state';
|
||||
|
||||
interface ParticipantsTracksFixtureExpected {
|
||||
participants?: Array<VoiceEngineV2Participant>;
|
||||
tracks?: Array<VoiceEngineV2Track>;
|
||||
inboundVideoTracks?: Array<VoiceEngineV2InboundVideoTrack>;
|
||||
droppedFrameCount?: number;
|
||||
watchedStreams?: Array<VoiceEngineV2WatchedStream>;
|
||||
remoteTrackSubscriptions?: Record<string, VoiceEngineV2RemoteTrackSubscriptionOptions>;
|
||||
participantVolumes?: Record<string, number>;
|
||||
commands?: Array<VoiceEngineV2Command>;
|
||||
}
|
||||
|
||||
interface ParticipantsTracksFixture {
|
||||
name: string;
|
||||
events: Array<VoiceEngineV2Event>;
|
||||
expected: ParticipantsTracksFixtureExpected;
|
||||
}
|
||||
|
||||
function replayFixture(fixture: ParticipantsTracksFixture): {
|
||||
snapshot: VoiceEngineV2Snapshot;
|
||||
commands: Array<VoiceEngineV2Command>;
|
||||
} {
|
||||
let snapshot = createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities());
|
||||
const commands: Array<VoiceEngineV2Command> = [];
|
||||
for (const event of fixture.events) {
|
||||
const transition = transitionVoiceEngineV2(snapshot, event);
|
||||
snapshot = transition.snapshot;
|
||||
commands.push(...transition.commands);
|
||||
}
|
||||
return {snapshot, commands};
|
||||
}
|
||||
|
||||
function assertFixture(fixture: ParticipantsTracksFixture): void {
|
||||
const {snapshot, commands} = replayFixture(fixture);
|
||||
const participantProjection = selectVoiceEngineV2ParticipantProjection(snapshot);
|
||||
if (fixture.expected.participants) {
|
||||
expect(participantProjection.participants).toEqual(fixture.expected.participants);
|
||||
}
|
||||
if (fixture.expected.tracks) {
|
||||
expect(participantProjection.tracks).toEqual(fixture.expected.tracks);
|
||||
}
|
||||
if (fixture.expected.inboundVideoTracks) {
|
||||
expect(participantProjection.inboundVideoTracks).toEqual(fixture.expected.inboundVideoTracks);
|
||||
}
|
||||
if (fixture.expected.droppedFrameCount !== undefined) {
|
||||
expect(snapshot.inboundVideo.droppedFrameCount).toBe(fixture.expected.droppedFrameCount);
|
||||
}
|
||||
if (fixture.expected.watchedStreams) {
|
||||
expect(selectVoiceEngineV2WatchedStreams(snapshot)).toEqual(fixture.expected.watchedStreams);
|
||||
}
|
||||
if (fixture.expected.remoteTrackSubscriptions) {
|
||||
expect(snapshot.remoteTrackSubscriptions).toEqual(fixture.expected.remoteTrackSubscriptions);
|
||||
}
|
||||
if (fixture.expected.participantVolumes) {
|
||||
expect(snapshot.participantVolumes).toEqual(fixture.expected.participantVolumes);
|
||||
}
|
||||
if (fixture.expected.commands) {
|
||||
expect(commands).toEqual(fixture.expected.commands);
|
||||
}
|
||||
}
|
||||
|
||||
describe('voice engine v2 participants, subscriptions, and inbound video fixtures', () => {
|
||||
for (const fixture of [
|
||||
participantsTracksFixtureJson,
|
||||
subscriptionQualityFixtureJson,
|
||||
watchedStreamsFixtureJson,
|
||||
inboundVideoFramesFixtureJson,
|
||||
inboundVideoFrameStatsFixtureJson,
|
||||
] as Array<ParticipantsTracksFixture>) {
|
||||
it(`replays ${fixture.name}`, () => {
|
||||
assertFixture(fixture);
|
||||
});
|
||||
}
|
||||
|
||||
it('removes participant-owned room and inbound-video state when a participant leaves by sid', () => {
|
||||
const fixture: ParticipantsTracksFixture = {
|
||||
name: 'participant sid cleanup',
|
||||
events: [
|
||||
{type: 'room.participantJoined', participant: {sid: 'PA_alice', identity: 'alice', name: 'Alice'}},
|
||||
{
|
||||
type: 'room.trackPublished',
|
||||
track: {
|
||||
participantIdentity: 'alice',
|
||||
participantSid: 'PA_alice',
|
||||
trackSid: 'TR_screen',
|
||||
trackName: 'screen',
|
||||
kind: 'video',
|
||||
source: 'screen',
|
||||
muted: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'inboundVideo.trackSubscribed',
|
||||
track: {
|
||||
participantSid: 'PA_alice',
|
||||
participantIdentity: 'alice',
|
||||
trackSid: 'TR_screen',
|
||||
source: 'screen',
|
||||
},
|
||||
},
|
||||
{type: 'room.participantLeft', participantSid: 'PA_alice'},
|
||||
],
|
||||
expected: {
|
||||
participants: [],
|
||||
tracks: [],
|
||||
inboundVideoTracks: [],
|
||||
commands: [],
|
||||
},
|
||||
};
|
||||
|
||||
assertFixture(fixture);
|
||||
});
|
||||
|
||||
it('translates native bridge participant, track, and frame events into v2 projections', () => {
|
||||
let snapshot = createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities());
|
||||
const bridgeEvents = translateVoiceEngineV2BridgeEventToEvents({
|
||||
type: 'trackSubscribed',
|
||||
payload: {
|
||||
participantSid: 'PA_alice',
|
||||
identity: 'alice',
|
||||
participantName: 'Alice',
|
||||
trackSid: 'TR_screen',
|
||||
trackName: 'screen_share',
|
||||
kind: 'video',
|
||||
source: 'screen_share',
|
||||
muted: false,
|
||||
subscribed: true,
|
||||
subscriptionStatus: 'subscribed',
|
||||
},
|
||||
});
|
||||
for (const event of bridgeEvents) {
|
||||
snapshot = transitionVoiceEngineV2(snapshot, event).snapshot;
|
||||
}
|
||||
snapshot = transitionVoiceEngineV2(
|
||||
snapshot,
|
||||
translateVoiceEngineV2BridgeVideoFrameToEvent({
|
||||
meta: {
|
||||
participantSid: 'PA_alice',
|
||||
trackSid: 'TR_screen',
|
||||
width: 320,
|
||||
height: 180,
|
||||
timestampUs: 100,
|
||||
},
|
||||
data: new ArrayBuffer(86_400),
|
||||
}),
|
||||
).snapshot;
|
||||
|
||||
expect(snapshot.room.tracks.TR_screen).toMatchObject({
|
||||
participantIdentity: 'alice',
|
||||
participantSid: 'PA_alice',
|
||||
source: 'screen',
|
||||
kind: 'video',
|
||||
});
|
||||
expect(snapshot.inboundVideo.tracks.TR_screen).toMatchObject({
|
||||
participantSid: 'PA_alice',
|
||||
participantIdentity: 'alice',
|
||||
source: 'screen',
|
||||
width: 320,
|
||||
height: 180,
|
||||
frameCount: 1,
|
||||
lastFrameByteLength: 86_400,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {bench, describe} from 'vitest';
|
||||
import basicSessionFixture from '../../fixtures/basic_session.json';
|
||||
import type {VoiceEngineV2Event} from '../protocol/events';
|
||||
import type {
|
||||
VoiceEngineV2InboundVideoFrame,
|
||||
VoiceEngineV2InboundVideoFrameStats,
|
||||
VoiceEngineV2MicrophoneOptions,
|
||||
VoiceEngineV2Participant,
|
||||
VoiceEngineV2ScreenOptions,
|
||||
VoiceEngineV2Stats,
|
||||
} from '../protocol/types';
|
||||
import {transitionVoiceEngineV2} from './reducer';
|
||||
import {
|
||||
availableVoiceEngineV2Capabilities,
|
||||
createVoiceEngineV2InitialSnapshot,
|
||||
type VoiceEngineV2Snapshot,
|
||||
} from './state';
|
||||
|
||||
interface VoiceEngineV2BenchFixture {
|
||||
events: Array<VoiceEngineV2Event>;
|
||||
}
|
||||
|
||||
function buildConnectedSnapshotWithVideoTrack(): VoiceEngineV2Snapshot {
|
||||
const fixture = basicSessionFixture as VoiceEngineV2BenchFixture;
|
||||
let snapshot = createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities());
|
||||
for (const event of fixture.events) {
|
||||
snapshot = transitionVoiceEngineV2(snapshot, event).snapshot;
|
||||
}
|
||||
snapshot = transitionVoiceEngineV2(snapshot, {
|
||||
type: 'inboundVideo.trackSubscribed',
|
||||
track: {
|
||||
participantSid: 'sid-remote',
|
||||
participantIdentity: 'identity-remote',
|
||||
trackSid: 'track-video-1',
|
||||
source: 'camera',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
}).snapshot;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const connectedSnapshot = buildConnectedSnapshotWithVideoTrack();
|
||||
|
||||
const frameEvent: VoiceEngineV2Event = {
|
||||
type: 'inboundVideo.frameReceived',
|
||||
frame: Object.freeze({
|
||||
participantSid: 'sid-remote',
|
||||
participantIdentity: 'identity-remote',
|
||||
trackSid: 'track-video-1',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
timestampUs: 1_000_000,
|
||||
byteLength: 320_000,
|
||||
}) satisfies VoiceEngineV2InboundVideoFrame,
|
||||
};
|
||||
|
||||
const frameStatsEvent: VoiceEngineV2Event = {
|
||||
type: 'inboundVideo.frameStats',
|
||||
stats: Object.freeze({
|
||||
participantSid: 'sid-remote',
|
||||
participantIdentity: 'identity-remote',
|
||||
trackSid: 'track-video-1',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frameCount: 900,
|
||||
lastFrameTimestampUs: 30_000_000,
|
||||
lastFrameByteLength: 320_000,
|
||||
}) satisfies VoiceEngineV2InboundVideoFrameStats,
|
||||
};
|
||||
|
||||
const microphoneEvent: VoiceEngineV2Event = {
|
||||
type: 'microphone.publishRequested',
|
||||
options: Object.freeze({deviceId: 'mic-alt', echoCancellation: true}) satisfies VoiceEngineV2MicrophoneOptions,
|
||||
};
|
||||
|
||||
const screenEvent: VoiceEngineV2Event = {
|
||||
type: 'screen.publishRequested',
|
||||
options: Object.freeze({
|
||||
captureId: 'capture-1',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
codec: 'h264',
|
||||
maxFramerate: 30,
|
||||
maxBitrateBps: 4_000_000,
|
||||
}) satisfies VoiceEngineV2ScreenOptions,
|
||||
};
|
||||
|
||||
const participantEvent: VoiceEngineV2Event = {
|
||||
type: 'room.participantJoined',
|
||||
participant: Object.freeze({
|
||||
sid: 'sid-joiner',
|
||||
identity: 'identity-joiner',
|
||||
name: 'Joiner',
|
||||
}) satisfies VoiceEngineV2Participant,
|
||||
};
|
||||
|
||||
const statsPayload: VoiceEngineV2Stats = Object.freeze({rttMs: 42, outbound: [], inbound: []});
|
||||
const statsEvent: VoiceEngineV2Event = {type: 'stats.collected', operationId: 9_999_999, stats: statsPayload};
|
||||
|
||||
describe('voice engine v2 reducer hot paths', () => {
|
||||
bench('inboundVideo.frameReceived', () => {
|
||||
transitionVoiceEngineV2(connectedSnapshot, frameEvent);
|
||||
});
|
||||
|
||||
bench('inboundVideo.frameStats', () => {
|
||||
transitionVoiceEngineV2(connectedSnapshot, frameStatsEvent);
|
||||
});
|
||||
|
||||
bench('microphone.publishRequested', () => {
|
||||
transitionVoiceEngineV2(connectedSnapshot, microphoneEvent);
|
||||
});
|
||||
|
||||
bench('screen.publishRequested', () => {
|
||||
transitionVoiceEngineV2(connectedSnapshot, screenEvent);
|
||||
});
|
||||
|
||||
bench('room.participantJoined', () => {
|
||||
transitionVoiceEngineV2(connectedSnapshot, participantEvent);
|
||||
});
|
||||
|
||||
bench('stats.collected', () => {
|
||||
transitionVoiceEngineV2(connectedSnapshot, statsEvent);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../protocol/events';
|
||||
import {
|
||||
dispatchLocalMediaEvent,
|
||||
dispatchObservabilityEvent,
|
||||
dispatchPlatformEvent,
|
||||
dispatchRuntimeEvent,
|
||||
dispatchSessionEvent,
|
||||
} from './reducers/_dispatch';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from './state';
|
||||
|
||||
export {isVoiceEngineV2CommandCompletionStale} from './commandCompletion';
|
||||
|
||||
export function transitionVoiceEngineV2(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2Event,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionVoiceEngineV2 snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionVoiceEngineV2 event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'event.type must be a string');
|
||||
assert.ok(event.type.length > 0, 'event.type must not be empty');
|
||||
const local = dispatchLocalMediaEvent(snapshot, event);
|
||||
if (local) return local;
|
||||
const session = dispatchSessionEvent(snapshot, event);
|
||||
if (session) return session;
|
||||
const platform = dispatchPlatformEvent(snapshot, event);
|
||||
if (platform) return platform;
|
||||
const runtime = dispatchRuntimeEvent(snapshot, event);
|
||||
if (runtime) return runtime;
|
||||
const observability = dispatchObservabilityEvent(snapshot, event);
|
||||
if (observability) return observability;
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
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';
|
||||
import {transitionDevices} from './devices';
|
||||
import {transitionE2ee} from './e2ee';
|
||||
import {transitionGateway} from './gateway';
|
||||
import {transitionImplementation} from './implementation';
|
||||
import {transitionInboundVideo} from './inboundVideo';
|
||||
import {transitionLifecycle} from './lifecycle';
|
||||
import {transitionMicrophone} from './microphone';
|
||||
import {transitionNativeAudioDeviceModule} from './nativeAudioDeviceModule';
|
||||
import {transitionNativeAudioTap} from './nativeAudioTap';
|
||||
import {transitionNativeCapture} from './nativeCapture';
|
||||
import {transitionNativeFrameSink} from './nativeFrameSink';
|
||||
import {transitionOutputDevice} from './outputDevice';
|
||||
import {transitionParticipantVolume} from './participantVolume';
|
||||
import {transitionPermissions} from './permissions';
|
||||
import {transitionRemoteTrackSubscription} from './remoteTrackSubscription';
|
||||
import {transitionRoom} from './room';
|
||||
import {transitionScreen} from './screen';
|
||||
import {transitionScreenAudio} from './screenAudio';
|
||||
import {transitionSourceLifecycles} from './sourceLifecycles';
|
||||
import {transitionStats} from './stats';
|
||||
import {transitionUtilityPorts} from './utilityPorts';
|
||||
|
||||
export function dispatchLocalMediaEvent(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2Event,
|
||||
): VoiceEngineV2Transition | null {
|
||||
assert.ok(snapshot != null, 'dispatchLocalMediaEvent snapshot must not be null');
|
||||
assert.ok(event != null, 'dispatchLocalMediaEvent event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'dispatchLocalMediaEvent event.type must be a string');
|
||||
switch (event.type) {
|
||||
case 'implementation.prewarmRequested':
|
||||
case 'implementation.prewarmSucceeded':
|
||||
case 'implementation.prewarmFailed':
|
||||
return transitionImplementation(snapshot, event);
|
||||
case 'connection.connectRequested':
|
||||
case 'connection.connectSucceeded':
|
||||
case 'connection.connectFailed':
|
||||
case 'connection.disconnectRequested':
|
||||
case 'connection.disconnectSucceeded':
|
||||
case 'connection.disconnectFailed':
|
||||
case 'connection.remoteDisconnected':
|
||||
case 'connection.reconnectRequested':
|
||||
case 'connection.externallyEstablished':
|
||||
return transitionConnection(snapshot, event, planDesiredState);
|
||||
case 'microphone.publishRequested':
|
||||
case 'microphone.publishSucceeded':
|
||||
case 'microphone.publishFailed':
|
||||
case 'microphone.unpublishRequested':
|
||||
case 'microphone.unpublishSucceeded':
|
||||
case 'microphone.unpublishFailed':
|
||||
case 'microphone.setEnabledRequested':
|
||||
case 'microphone.setEnabledSucceeded':
|
||||
case 'microphone.setEnabledFailed':
|
||||
case 'localAudio.muteRequested':
|
||||
case 'localAudio.deafenRequested':
|
||||
case 'audioControls.changed':
|
||||
return transitionMicrophone(snapshot, event);
|
||||
case 'camera.publishRequested':
|
||||
case 'camera.publishSucceeded':
|
||||
case 'camera.publishFailed':
|
||||
case 'camera.updateEncodingRequested':
|
||||
case 'camera.updateEncodingSucceeded':
|
||||
case 'camera.updateEncodingFailed':
|
||||
case 'camera.unpublishRequested':
|
||||
case 'camera.unpublishSucceeded':
|
||||
case 'camera.unpublishFailed':
|
||||
return transitionCamera(snapshot, event);
|
||||
default:
|
||||
return dispatchScreenEvent(snapshot, event);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchScreenEvent(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2Event,
|
||||
): VoiceEngineV2Transition | null {
|
||||
assert.ok(snapshot != null, 'dispatchScreenEvent snapshot must not be null');
|
||||
assert.ok(event != null, 'dispatchScreenEvent event must not be null');
|
||||
switch (event.type) {
|
||||
case 'screen.publishRequested':
|
||||
case 'screen.publishSucceeded':
|
||||
case 'screen.publishFailed':
|
||||
case 'screen.updateEncodingRequested':
|
||||
case 'screen.updateEncodingSucceeded':
|
||||
case 'screen.updateEncodingFailed':
|
||||
case 'screen.unpublishRequested':
|
||||
case 'screen.unpublishSucceeded':
|
||||
case 'screen.unpublishFailed':
|
||||
return transitionScreen(snapshot, event);
|
||||
case 'screenAudio.publishRequested':
|
||||
case 'screenAudio.publishSucceeded':
|
||||
case 'screenAudio.publishFailed':
|
||||
case 'screenAudio.unpublishRequested':
|
||||
case 'screenAudio.unpublishSucceeded':
|
||||
case 'screenAudio.unpublishFailed':
|
||||
return transitionScreenAudio(snapshot, event);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchSessionEvent(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2Event,
|
||||
): VoiceEngineV2Transition | null {
|
||||
assert.ok(snapshot != null, 'dispatchSessionEvent snapshot must not be null');
|
||||
assert.ok(event != null, 'dispatchSessionEvent event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'dispatchSessionEvent event.type must be a string');
|
||||
switch (event.type) {
|
||||
case 'outputDevice.setRequested':
|
||||
case 'outputDevice.setSucceeded':
|
||||
case 'outputDevice.setFailed':
|
||||
return transitionOutputDevice(snapshot, event);
|
||||
case 'participantVolume.setRequested':
|
||||
case 'participantVolume.setSucceeded':
|
||||
case 'participantVolume.setFailed':
|
||||
return transitionParticipantVolume(snapshot, event);
|
||||
case 'remoteTrackSubscription.setRequested':
|
||||
case 'remoteTrackSubscription.setSucceeded':
|
||||
case 'remoteTrackSubscription.setFailed':
|
||||
case 'watchedStream.watchRequested':
|
||||
case 'watchedStream.unwatchRequested':
|
||||
case 'watchedStreams.replaced':
|
||||
return transitionRemoteTrackSubscription(snapshot, event);
|
||||
case 'data.publishRequested':
|
||||
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':
|
||||
return transitionStats(snapshot, event);
|
||||
case 'capabilities.changed':
|
||||
case 'capabilities.hardwareEncoderQueryRequested':
|
||||
case 'capabilities.hardwareEncoderChanged':
|
||||
case 'capabilities.hardwareEncoderQueryFailed':
|
||||
return transitionCapabilities(snapshot, event);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchPlatformEvent(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2Event,
|
||||
): VoiceEngineV2Transition | null {
|
||||
assert.ok(snapshot != null, 'dispatchPlatformEvent snapshot must not be null');
|
||||
assert.ok(event != null, 'dispatchPlatformEvent event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'dispatchPlatformEvent event.type must be a string');
|
||||
switch (event.type) {
|
||||
case 'permissions.checkRequested':
|
||||
case 'permissions.requestRequested':
|
||||
case 'permissions.result':
|
||||
case 'permissions.failed':
|
||||
return transitionPermissions(snapshot, event);
|
||||
case 'devices.enumerateRequested':
|
||||
case 'devices.changed':
|
||||
case 'devices.enumerateFailed':
|
||||
case 'devices.selectAudioInputRequested':
|
||||
case 'devices.selectAudioOutputRequested':
|
||||
case 'devices.selectCameraRequested':
|
||||
return transitionDevices(snapshot, event);
|
||||
case 'nativeAudioDeviceModule.statusChanged':
|
||||
return transitionNativeAudioDeviceModule(snapshot, event);
|
||||
case 'nativeCapture.startRequested':
|
||||
case 'nativeCapture.updateRequested':
|
||||
case 'nativeCapture.stopRequested':
|
||||
case 'nativeCapture.started':
|
||||
case 'nativeCapture.stopped':
|
||||
case 'nativeCapture.failed':
|
||||
case 'nativeCapture.frame':
|
||||
return transitionNativeCapture(snapshot, event);
|
||||
case 'nativeAudioTap.startRequested':
|
||||
case 'nativeAudioTap.stopRequested':
|
||||
return transitionNativeAudioTap(snapshot, event);
|
||||
case 'nativeFrameSink.attachRequested':
|
||||
case 'nativeFrameSink.detachRequested':
|
||||
return transitionNativeFrameSink(snapshot, event);
|
||||
case 'e2ee.setEnabledRequested':
|
||||
case 'e2ee.enabled':
|
||||
case 'e2ee.disabled':
|
||||
case 'e2ee.failed':
|
||||
return transitionE2ee(snapshot, event);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchRuntimeEvent(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2Event,
|
||||
): VoiceEngineV2Transition | null {
|
||||
assert.ok(snapshot != null, 'dispatchRuntimeEvent snapshot must not be null');
|
||||
assert.ok(event != null, 'dispatchRuntimeEvent event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'dispatchRuntimeEvent event.type must be a string');
|
||||
switch (event.type) {
|
||||
case 'timer.scheduleRequested':
|
||||
case 'timer.cancelRequested':
|
||||
case 'timer.fired':
|
||||
case 'diagnostics.logRequested':
|
||||
case 'diagnostics.logged':
|
||||
return transitionUtilityPorts(snapshot, event);
|
||||
case 'lifecycle.teardownRequested':
|
||||
case 'lifecycle.teardownSucceeded':
|
||||
case 'lifecycle.teardownFailed':
|
||||
return transitionLifecycle(snapshot, event);
|
||||
case 'room.participantJoined':
|
||||
case 'room.participantLeft':
|
||||
case 'room.trackPublished':
|
||||
case 'room.trackUnpublished':
|
||||
case 'room.trackMuted':
|
||||
case 'room.trackUnmuted':
|
||||
return transitionRoom(snapshot, event);
|
||||
case 'inboundVideo.trackSubscribed':
|
||||
case 'inboundVideo.trackUnsubscribed':
|
||||
case 'inboundVideo.frameReceived':
|
||||
case 'inboundVideo.frameStats':
|
||||
return transitionInboundVideo(snapshot, event);
|
||||
default:
|
||||
return dispatchControlEvent(snapshot, event);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchControlEvent(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2Event,
|
||||
): VoiceEngineV2Transition | null {
|
||||
assert.ok(snapshot != null, 'dispatchControlEvent snapshot must not be null');
|
||||
assert.ok(event != null, 'dispatchControlEvent event must not be null');
|
||||
switch (event.type) {
|
||||
case 'command.succeeded':
|
||||
case 'command.failed':
|
||||
case 'command.staleCompletionRejected':
|
||||
case 'operation.cancelRequested':
|
||||
case 'operation.cancelled':
|
||||
return transitionCommand(snapshot, event);
|
||||
case 'gateway.desiredVoiceStateChanged':
|
||||
case 'gateway.voiceStateReconcileRequested':
|
||||
case 'gateway.voiceStateWriteRequested':
|
||||
case 'gateway.voiceStateWriteSucceeded':
|
||||
case 'gateway.voiceStateWriteFailed':
|
||||
case 'gateway.voiceStateClearRequested':
|
||||
case 'gateway.voiceStateClearSucceeded':
|
||||
case 'gateway.voiceStateClearFailed':
|
||||
case 'gateway.voiceStateUpdated':
|
||||
case 'gateway.voiceServerUpdated':
|
||||
case 'livekit.roomStateChanged':
|
||||
return transitionGateway(snapshot, event);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchObservabilityEvent(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2Event,
|
||||
): VoiceEngineV2Transition | null {
|
||||
assert.ok(snapshot != null, 'dispatchObservabilityEvent snapshot must not be null');
|
||||
assert.ok(event != null, 'dispatchObservabilityEvent event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'dispatchObservabilityEvent event.type must be a string');
|
||||
switch (event.type) {
|
||||
case 'sourceLifecycle.transitioned':
|
||||
return transitionSourceLifecycles(snapshot, event);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {getVoiceEngineV2CommandResourceKey, type VoiceEngineV2Command} from '../../protocol/commands';
|
||||
import type {
|
||||
VoiceEngineV2DataOptions,
|
||||
VoiceEngineV2DiagnosticEntry,
|
||||
VoiceEngineV2Error,
|
||||
VoiceEngineV2OperationId,
|
||||
VoiceEngineV2OutputDeviceOptions,
|
||||
VoiceEngineV2ParticipantVolumeOptions,
|
||||
VoiceEngineV2RemoteTrackSubscriptionOptions,
|
||||
VoiceEngineV2ResourceKey,
|
||||
} from '../../protocol/types';
|
||||
import type {
|
||||
VoiceEngineV2OperationState,
|
||||
VoiceEngineV2OperationStatus,
|
||||
VoiceEngineV2Snapshot,
|
||||
VoiceEngineV2Transition,
|
||||
} from '../state';
|
||||
|
||||
export const VOICE_ENGINE_V2_TERMINAL_OPERATIONS_KEPT_MAX = 64;
|
||||
const VOICE_ENGINE_V2_DIAGNOSTICS_KEPT_MAX = 200;
|
||||
|
||||
const VOICE_ENGINE_V2_TERMINAL_OPERATION_STATUSES: ReadonlySet<VoiceEngineV2OperationStatus> = new Set([
|
||||
'succeeded',
|
||||
'failed',
|
||||
'cancelled',
|
||||
'stale',
|
||||
]);
|
||||
|
||||
interface OperationAllocation {
|
||||
snapshot: VoiceEngineV2Snapshot;
|
||||
operationId: VoiceEngineV2OperationId;
|
||||
}
|
||||
|
||||
function isTerminalVoiceEngineV2OperationStatus(status: VoiceEngineV2OperationStatus): boolean {
|
||||
assert.equal(typeof status, 'string', 'isTerminalVoiceEngineV2OperationStatus status must be a string');
|
||||
assert.ok(status.length > 0, 'isTerminalVoiceEngineV2OperationStatus status must not be empty');
|
||||
return VOICE_ENGINE_V2_TERMINAL_OPERATION_STATUSES.has(status);
|
||||
}
|
||||
|
||||
function pruneTerminalOperations(
|
||||
operations: Record<string, VoiceEngineV2OperationState>,
|
||||
): Record<string, VoiceEngineV2OperationState> {
|
||||
assert.ok(operations != null, 'pruneTerminalOperations operations must not be null');
|
||||
const terminalOperationIds: Array<number> = [];
|
||||
for (const operation of Object.values(operations)) {
|
||||
if (isTerminalVoiceEngineV2OperationStatus(operation.status)) {
|
||||
terminalOperationIds.push(operation.operationId);
|
||||
}
|
||||
}
|
||||
if (terminalOperationIds.length <= VOICE_ENGINE_V2_TERMINAL_OPERATIONS_KEPT_MAX) return operations;
|
||||
terminalOperationIds.sort((a, b) => a - b);
|
||||
const dropCount = terminalOperationIds.length - VOICE_ENGINE_V2_TERMINAL_OPERATIONS_KEPT_MAX;
|
||||
assert.ok(dropCount >= 1, 'pruneTerminalOperations over cap must drop at least one entry');
|
||||
const pruned = {...operations};
|
||||
for (let index = 0; index < dropCount; index += 1) {
|
||||
const operationId = terminalOperationIds[index];
|
||||
assert.ok(operationId !== undefined, 'pruneTerminalOperations ids must not contain holes');
|
||||
delete pruned[String(operationId)];
|
||||
}
|
||||
assert.ok(
|
||||
Object.keys(pruned).length === Object.keys(operations).length - dropCount,
|
||||
'pruneTerminalOperations must remove exactly the dropped entries',
|
||||
);
|
||||
return pruned;
|
||||
}
|
||||
|
||||
function assertVoiceEngineV2Transition(result: VoiceEngineV2Transition): void {
|
||||
assert.ok(result != null, 'transition result must not be null');
|
||||
assert.ok(result.snapshot != null, 'transition snapshot must not be null');
|
||||
assert.ok(Array.isArray(result.commands), 'transition commands must be an array');
|
||||
}
|
||||
|
||||
export function implementationError(message: string, capability?: string): VoiceEngineV2Error {
|
||||
assert.equal(typeof message, 'string', 'implementationError message must be a string');
|
||||
assert.ok(message.length > 0, 'implementationError message must not be empty');
|
||||
return {
|
||||
code: 'implementationError',
|
||||
message,
|
||||
...(capability ? {capability} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function invalidArgument(message: string, capability?: string): VoiceEngineV2Error {
|
||||
assert.equal(typeof message, 'string', 'invalidArgument message must be a string');
|
||||
assert.ok(message.length > 0, 'invalidArgument message must not be empty');
|
||||
return {
|
||||
code: 'invalidArgument',
|
||||
message,
|
||||
...(capability ? {capability} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function unsupportedCapability(capability: string): VoiceEngineV2Error {
|
||||
assert.equal(typeof capability, 'string', 'unsupportedCapability capability must be a string');
|
||||
assert.ok(capability.length > 0, 'unsupportedCapability capability must not be empty');
|
||||
return {
|
||||
code: 'unsupportedCapability',
|
||||
capability,
|
||||
message: `Voice engine v2 implementation does not support ${capability}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function allocateOperation(snapshot: VoiceEngineV2Snapshot): OperationAllocation {
|
||||
assert.ok(snapshot != null, 'allocateOperation snapshot must not be null');
|
||||
assert.ok(Number.isInteger(snapshot.nextOperationId), 'nextOperationId must be an integer');
|
||||
assert.ok(snapshot.nextOperationId >= 1, 'nextOperationId must be positive');
|
||||
assert.ok(snapshot.nextOperationId <= Number.MAX_SAFE_INTEGER, 'nextOperationId past safe-integer cap');
|
||||
return {
|
||||
operationId: snapshot.nextOperationId,
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
nextOperationId: snapshot.nextOperationId + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function recordQueuedCommand(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
command: VoiceEngineV2Command,
|
||||
): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'recordQueuedCommand snapshot must not be null');
|
||||
assert.ok(command != null, 'recordQueuedCommand command must not be null');
|
||||
assert.ok(Number.isInteger(command.operationId), 'command.operationId must be an integer');
|
||||
return {
|
||||
...snapshot,
|
||||
operations: {
|
||||
...snapshot.operations,
|
||||
[String(command.operationId)]: {
|
||||
operationId: command.operationId,
|
||||
commandType: command.type,
|
||||
resourceKey: getVoiceEngineV2CommandResourceKey(command),
|
||||
status: 'queued',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function queueCommand(snapshot: VoiceEngineV2Snapshot, command: VoiceEngineV2Command): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'queueCommand snapshot must not be null');
|
||||
assert.ok(command != null, 'queueCommand command must not be null');
|
||||
const result: VoiceEngineV2Transition = {
|
||||
snapshot: recordQueuedCommand(snapshot, command),
|
||||
commands: [command],
|
||||
};
|
||||
assertVoiceEngineV2Transition(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function markOperation(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
status: VoiceEngineV2OperationStatus,
|
||||
error: VoiceEngineV2Error | null = null,
|
||||
): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'markOperation snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'markOperation operationId must be an integer');
|
||||
assert.equal(typeof status, 'string', 'markOperation status must be a string');
|
||||
const operation = snapshot.operations[String(operationId)];
|
||||
if (!operation) return snapshot;
|
||||
const operations: Record<string, VoiceEngineV2OperationState> = {
|
||||
...snapshot.operations,
|
||||
[String(operationId)]: {
|
||||
...operation,
|
||||
status,
|
||||
error,
|
||||
},
|
||||
};
|
||||
return {
|
||||
...snapshot,
|
||||
operations: isTerminalVoiceEngineV2OperationStatus(status) ? pruneTerminalOperations(operations) : operations,
|
||||
};
|
||||
}
|
||||
|
||||
export function isConnected(snapshot: VoiceEngineV2Snapshot): boolean {
|
||||
assert.ok(snapshot != null, 'isConnected snapshot must not be null');
|
||||
assert.ok(snapshot.connection != null, 'isConnected snapshot.connection must not be null');
|
||||
return snapshot.connection.status === 'connected';
|
||||
}
|
||||
|
||||
export function appendTransition(
|
||||
base: VoiceEngineV2Transition,
|
||||
next: VoiceEngineV2Transition,
|
||||
): VoiceEngineV2Transition {
|
||||
assertVoiceEngineV2Transition(base);
|
||||
assertVoiceEngineV2Transition(next);
|
||||
const result: VoiceEngineV2Transition = {
|
||||
snapshot: next.snapshot,
|
||||
commands: [...base.commands, ...next.commands],
|
||||
};
|
||||
assertVoiceEngineV2Transition(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function appendDiagnostic(
|
||||
diagnostics: Array<VoiceEngineV2DiagnosticEntry>,
|
||||
entry: VoiceEngineV2DiagnosticEntry,
|
||||
): Array<VoiceEngineV2DiagnosticEntry> {
|
||||
assert.ok(Array.isArray(diagnostics), 'appendDiagnostic diagnostics must be an array');
|
||||
assert.ok(entry != null, 'appendDiagnostic entry must not be null');
|
||||
return [...diagnostics, entry].slice(-VOICE_ENGINE_V2_DIAGNOSTICS_KEPT_MAX);
|
||||
}
|
||||
|
||||
export function recordFailure(snapshot: VoiceEngineV2Snapshot, error: VoiceEngineV2Error): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'recordFailure snapshot must not be null');
|
||||
assert.ok(error != null, 'recordFailure error must not be null');
|
||||
const result: VoiceEngineV2Transition = {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
assertVoiceEngineV2Transition(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function clearOperationForResource(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
resourceKey: VoiceEngineV2ResourceKey,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'clearOperationForResource snapshot must not be null');
|
||||
assert.equal(typeof resourceKey, 'string', 'clearOperationForResource resourceKey must be a string');
|
||||
assert.ok(Number.isInteger(operationId), 'clearOperationForResource operationId must be an integer');
|
||||
if (resourceKey === 'connection' && snapshot.connection.operationId === operationId) {
|
||||
return {...snapshot, connection: {...snapshot.connection, operationId: null}};
|
||||
}
|
||||
if (resourceKey === 'microphone' && snapshot.microphone.operationId === operationId) {
|
||||
return {...snapshot, microphone: {...snapshot.microphone, operationId: null}};
|
||||
}
|
||||
if (resourceKey === 'camera' && snapshot.camera.operationId === operationId) {
|
||||
return {...snapshot, camera: {...snapshot.camera, operationId: null}};
|
||||
}
|
||||
if (resourceKey === 'screen' && snapshot.screen.operationId === operationId) {
|
||||
return {...snapshot, screen: {...snapshot.screen, operationId: null}};
|
||||
}
|
||||
if (resourceKey === 'screenAudio' && snapshot.screenAudio.operationId === operationId) {
|
||||
return {...snapshot, screenAudio: {...snapshot.screenAudio, operationId: null}};
|
||||
}
|
||||
if (resourceKey === 'outputDevice' && snapshot.outputDevice.operationId === operationId) {
|
||||
return {...snapshot, outputDevice: {...snapshot.outputDevice, operationId: null}};
|
||||
}
|
||||
if (resourceKey === 'stats' && snapshot.statsOperationId === operationId) {
|
||||
return {...snapshot, statsOperationId: null};
|
||||
}
|
||||
if (resourceKey === 'capabilities' && snapshot.hardwareEncoder.operationId === operationId) {
|
||||
return {...snapshot, hardwareEncoder: {...snapshot.hardwareEncoder, operationId: null}};
|
||||
}
|
||||
if (resourceKey === 'gateway' && snapshot.gateway.operationId === operationId) {
|
||||
return {...snapshot, gateway: {...snapshot.gateway, operationId: null}};
|
||||
}
|
||||
if (resourceKey === 'devices' && snapshot.devices.operationId === operationId) {
|
||||
return {...snapshot, devices: {...snapshot.devices, operationId: null}};
|
||||
}
|
||||
if (resourceKey === 'e2ee' && snapshot.e2ee.operationId === operationId) {
|
||||
return {...snapshot, e2ee: {...snapshot.e2ee, operationId: null}};
|
||||
}
|
||||
if (resourceKey === 'lifecycle' && snapshot.lifecycle.operationId === operationId) {
|
||||
return {...snapshot, lifecycle: {...snapshot.lifecycle, operationId: null}};
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
type ImmediateCommandDraft =
|
||||
| {type: 'outputDevice.set'; options: VoiceEngineV2OutputDeviceOptions}
|
||||
| {type: 'participantVolume.set'; options: VoiceEngineV2ParticipantVolumeOptions}
|
||||
| {type: 'remoteTrackSubscription.set'; options: VoiceEngineV2RemoteTrackSubscriptionOptions}
|
||||
| {type: 'data.publish'; options: VoiceEngineV2DataOptions}
|
||||
| {type: 'stats.collect'};
|
||||
|
||||
export function commandIfConnected(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
capability: keyof VoiceEngineV2Snapshot['capabilities'],
|
||||
draft: ImmediateCommandDraft,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'commandIfConnected snapshot must not be null');
|
||||
assert.ok(draft != null, 'commandIfConnected draft must not be null');
|
||||
assert.equal(typeof draft.type, 'string', 'commandIfConnected draft.type must be a string');
|
||||
if (!snapshot.capabilities[capability]) {
|
||||
const error = unsupportedCapability(capability);
|
||||
return {snapshot: {...snapshot, lastFailure: error}, commands: []};
|
||||
}
|
||||
if (!isConnected(snapshot)) return {snapshot, commands: []};
|
||||
const allocated = allocateOperation(snapshot);
|
||||
const command = {...draft, operationId: allocated.operationId} as VoiceEngineV2Command;
|
||||
if (draft.type === 'outputDevice.set') {
|
||||
return {
|
||||
snapshot: {
|
||||
...allocated.snapshot,
|
||||
outputDevice: {...allocated.snapshot.outputDevice, operationId: allocated.operationId, failure: null},
|
||||
},
|
||||
commands: [command],
|
||||
};
|
||||
}
|
||||
if (draft.type === 'stats.collect') {
|
||||
return {
|
||||
snapshot: {
|
||||
...allocated.snapshot,
|
||||
statsOperationId: allocated.operationId,
|
||||
statsFailure: null,
|
||||
},
|
||||
commands: [command],
|
||||
};
|
||||
}
|
||||
return {snapshot: allocated.snapshot, commands: [command]};
|
||||
}
|
||||
|
||||
type UnpublishableMediaKey = 'microphone' | 'camera' | 'screen' | 'screenAudio';
|
||||
type UnpublishCommandType = 'microphone.unpublish' | 'camera.unpublish' | 'screen.unpublish' | 'screenAudio.unpublish';
|
||||
|
||||
export function beginUnpublish(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
key: UnpublishableMediaKey,
|
||||
commandType: UnpublishCommandType,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'beginUnpublish snapshot must not be null');
|
||||
assert.equal(typeof key, 'string', 'beginUnpublish key must be a string');
|
||||
assert.equal(typeof commandType, 'string', 'beginUnpublish commandType must be a string');
|
||||
const state = snapshot[key];
|
||||
const base = {
|
||||
...snapshot,
|
||||
[key]: {
|
||||
...state,
|
||||
desired: null,
|
||||
},
|
||||
};
|
||||
if (!isConnected(base) || (state.status === 'idle' && state.published == null)) return {snapshot: base, commands: []};
|
||||
const allocated = allocateOperation(base);
|
||||
return {
|
||||
snapshot: {
|
||||
...allocated.snapshot,
|
||||
[key]: {
|
||||
...allocated.snapshot[key],
|
||||
status: 'unpublishing',
|
||||
operationId: allocated.operationId,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
commands: [{type: commandType, operationId: allocated.operationId} as VoiceEngineV2Command],
|
||||
};
|
||||
}
|
||||
|
||||
export function failUnpublish(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
error: VoiceEngineV2Error,
|
||||
key: UnpublishableMediaKey,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'failUnpublish snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'failUnpublish operationId must be an integer');
|
||||
assert.ok(error != null, 'failUnpublish error must not be null');
|
||||
assert.equal(typeof key, 'string', 'failUnpublish key must be a string');
|
||||
const state = snapshot[key];
|
||||
if (state.operationId !== operationId) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
[key]: {
|
||||
...state,
|
||||
status: 'failed',
|
||||
operationId: null,
|
||||
failure: error,
|
||||
},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Error, VoiceEngineV2OperationId} from '../../protocol/types';
|
||||
import type {VoiceEngineV2LocalMediaState, VoiceEngineV2Snapshot} from '../state';
|
||||
|
||||
export function applyMediaSuccess<Options>(
|
||||
state: VoiceEngineV2LocalMediaState<Options>,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
): VoiceEngineV2LocalMediaState<Options> {
|
||||
assert.ok(state != null, 'applyMediaSuccess state must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'applyMediaSuccess operationId must be an integer');
|
||||
if (state.operationId !== operationId) return state;
|
||||
return {
|
||||
...state,
|
||||
status: state.desired ? 'published' : 'idle',
|
||||
published: state.desired,
|
||||
operationId: null,
|
||||
failure: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMediaFailure<Options>(
|
||||
state: VoiceEngineV2LocalMediaState<Options>,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
error: VoiceEngineV2Error,
|
||||
): VoiceEngineV2LocalMediaState<Options> {
|
||||
assert.ok(state != null, 'applyMediaFailure state must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'applyMediaFailure operationId must be an integer');
|
||||
assert.ok(error != null, 'applyMediaFailure error must not be null');
|
||||
if (state.operationId !== operationId) return state;
|
||||
return {
|
||||
...state,
|
||||
status: 'failed',
|
||||
published: null,
|
||||
operationId: null,
|
||||
failure: error,
|
||||
};
|
||||
}
|
||||
|
||||
export function completeUnpublish<Options>(
|
||||
state: VoiceEngineV2LocalMediaState<Options>,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
): VoiceEngineV2LocalMediaState<Options> {
|
||||
assert.ok(state != null, 'completeUnpublish state must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'completeUnpublish operationId must be an integer');
|
||||
if (state.operationId !== operationId) return state;
|
||||
return {
|
||||
...state,
|
||||
status: 'idle',
|
||||
published: null,
|
||||
operationId: null,
|
||||
failure: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetPublishedMedia(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'resetPublishedMedia snapshot must not be null');
|
||||
assert.ok(snapshot.microphone != null, 'resetPublishedMedia snapshot.microphone must not be null');
|
||||
return {
|
||||
...snapshot,
|
||||
microphone: {
|
||||
...snapshot.microphone,
|
||||
status: 'idle',
|
||||
published: null,
|
||||
operationId: null,
|
||||
failure: null,
|
||||
setEnabledOperationId: null,
|
||||
localSpeakingOverride: false,
|
||||
},
|
||||
camera: {
|
||||
...snapshot.camera,
|
||||
status: 'idle',
|
||||
published: null,
|
||||
operationId: null,
|
||||
failure: null,
|
||||
},
|
||||
screen: {
|
||||
...snapshot.screen,
|
||||
status: 'idle',
|
||||
published: null,
|
||||
operationId: null,
|
||||
failure: null,
|
||||
},
|
||||
screenAudio: {
|
||||
...snapshot.screenAudio,
|
||||
status: 'idle',
|
||||
published: null,
|
||||
operationId: null,
|
||||
failure: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Command} from '../../protocol/commands';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, appendTransition} from './_helpers';
|
||||
import {beginCameraPublish} from './camera';
|
||||
import {beginMicrophonePublish} from './microphone';
|
||||
import {beginScreenPublish} from './screen';
|
||||
import {beginScreenAudioPublish} from './screenAudio';
|
||||
|
||||
function planDesiredLocalMedia(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'planDesiredLocalMedia snapshot must not be null');
|
||||
assert.ok(snapshot.capabilities != null, 'planDesiredLocalMedia snapshot.capabilities must not be null');
|
||||
let transition: VoiceEngineV2Transition = {snapshot, commands: []};
|
||||
transition = appendTransition(transition, beginMicrophonePublish(transition.snapshot));
|
||||
transition = appendTransition(transition, beginCameraPublish(transition.snapshot));
|
||||
transition = appendTransition(transition, beginScreenPublish(transition.snapshot));
|
||||
transition = appendTransition(transition, beginScreenAudioPublish(transition.snapshot));
|
||||
return transition;
|
||||
}
|
||||
|
||||
function planDesiredSessionConfiguration(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'planDesiredSessionConfiguration snapshot must not be null');
|
||||
assert.ok(snapshot.capabilities != null, 'planDesiredSessionConfiguration snapshot.capabilities must not be null');
|
||||
let next = snapshot;
|
||||
const commands: Array<VoiceEngineV2Command> = [];
|
||||
if (next.outputDevice.desiredDeviceId && next.capabilities.outputDevice) {
|
||||
const desiredDeviceId = next.outputDevice.desiredDeviceId;
|
||||
const allocated = allocateOperation(next);
|
||||
next = {
|
||||
...allocated.snapshot,
|
||||
outputDevice: {...allocated.snapshot.outputDevice, operationId: allocated.operationId, failure: null},
|
||||
};
|
||||
commands.push({
|
||||
type: 'outputDevice.set',
|
||||
operationId: allocated.operationId,
|
||||
options: {deviceId: desiredDeviceId},
|
||||
});
|
||||
}
|
||||
if (next.capabilities.participantVolume) {
|
||||
for (const participantIdentity of Object.keys(next.participantVolumes).sort()) {
|
||||
const allocated = allocateOperation(next);
|
||||
next = allocated.snapshot;
|
||||
commands.push({
|
||||
type: 'participantVolume.set',
|
||||
operationId: allocated.operationId,
|
||||
options: {
|
||||
participantIdentity,
|
||||
volume: next.participantVolumes[participantIdentity] ?? 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (next.capabilities.remoteTrackSubscription) {
|
||||
for (const key of Object.keys(next.remoteTrackSubscriptions).sort()) {
|
||||
const options = next.remoteTrackSubscriptions[key];
|
||||
if (!options) continue;
|
||||
const allocated = allocateOperation(next);
|
||||
next = allocated.snapshot;
|
||||
commands.push({
|
||||
type: 'remoteTrackSubscription.set',
|
||||
operationId: allocated.operationId,
|
||||
options,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {snapshot: next, commands};
|
||||
}
|
||||
|
||||
export function planDesiredState(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'planDesiredState snapshot must not be null');
|
||||
assert.ok(snapshot.capabilities != null, 'planDesiredState snapshot.capabilities must not be null');
|
||||
const localMedia = planDesiredLocalMedia(snapshot);
|
||||
return appendTransition(localMedia, planDesiredSessionConfiguration(localMedia.snapshot));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Error} from '../../protocol/types';
|
||||
import {implementationError} from './_helpers';
|
||||
|
||||
export function nativeZeroCopyRequiredError(
|
||||
resource: 'capture' | 'frame' | 'frameSink' | 'hardwareEncoder',
|
||||
): VoiceEngineV2Error {
|
||||
assert.equal(typeof resource, 'string', 'nativeZeroCopyRequiredError resource must be a string');
|
||||
assert.ok(resource.length > 0, 'nativeZeroCopyRequiredError resource must not be empty');
|
||||
const label =
|
||||
resource === 'capture'
|
||||
? 'Native Electron capture'
|
||||
: resource === 'frame'
|
||||
? 'Native Electron capture frames'
|
||||
: resource === 'frameSink'
|
||||
? 'Native Electron frame sinks'
|
||||
: 'Native hardware encoder usage';
|
||||
return implementationError(`${label} require zero-copy transport`, 'zeroCopyScreenTransport');
|
||||
}
|
||||
|
||||
export function unavailableZeroCopyTransportError(
|
||||
resource: 'capture' | 'frameSink' | 'hardwareEncoder',
|
||||
): VoiceEngineV2Error {
|
||||
assert.equal(typeof resource, 'string', 'unavailableZeroCopyTransportError resource must be a string');
|
||||
assert.ok(resource.length > 0, 'unavailableZeroCopyTransportError resource must not be empty');
|
||||
const label =
|
||||
resource === 'capture'
|
||||
? 'native Electron capture'
|
||||
: resource === 'frameSink'
|
||||
? 'native Electron frame sinks'
|
||||
: 'native hardware encoders';
|
||||
return {
|
||||
code: 'unsupportedCapability',
|
||||
capability: 'zeroCopyScreenTransport',
|
||||
message: `Voice engine v2 implementation does not support zero-copy transport for ${label}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {planVoiceEngineV2CameraEncodingChange} from '../../policies/cameraShare';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {
|
||||
VoiceEngineV2CameraEncodingOptions,
|
||||
VoiceEngineV2CameraOptions,
|
||||
VoiceEngineV2Error,
|
||||
VoiceEngineV2OperationId,
|
||||
} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, failUnpublish, invalidArgument, isConnected, unsupportedCapability} from './_helpers';
|
||||
import {applyMediaFailure, applyMediaSuccess, completeUnpublish} from './_media';
|
||||
|
||||
type VoiceEngineV2CameraEvent = Extract<VoiceEngineV2Event, {type: `camera.${string}`}>;
|
||||
|
||||
function sameCameraOptions(a: VoiceEngineV2CameraOptions | null, b: VoiceEngineV2CameraOptions | null): boolean {
|
||||
assert.ok(a !== undefined, 'sameCameraOptions a must not be undefined');
|
||||
assert.ok(b !== undefined, 'sameCameraOptions b must not be undefined');
|
||||
return (
|
||||
a?.deviceId === b?.deviceId && a?.width === b?.width && a?.height === b?.height && a?.frameRate === b?.frameRate
|
||||
);
|
||||
}
|
||||
|
||||
function sameCameraPublishCommandOptions(
|
||||
a: VoiceEngineV2CameraOptions | null,
|
||||
b: VoiceEngineV2CameraOptions | null,
|
||||
): boolean {
|
||||
assert.ok(a !== undefined, 'sameCameraPublishCommandOptions a must not be undefined');
|
||||
assert.ok(b !== undefined, 'sameCameraPublishCommandOptions b must not be undefined');
|
||||
if (!sameCameraOptions(a, b)) return false;
|
||||
return (a?.sendUpdate !== false) === (b?.sendUpdate !== false);
|
||||
}
|
||||
|
||||
function isCameraPublishInFlight(snapshot: VoiceEngineV2Snapshot): boolean {
|
||||
assert.ok(snapshot != null, 'isCameraPublishInFlight snapshot must not be null');
|
||||
assert.ok(snapshot.camera != null, 'isCameraPublishInFlight snapshot.camera must not be null');
|
||||
if (snapshot.camera.status !== 'publishing') return false;
|
||||
return snapshot.camera.operationId !== null;
|
||||
}
|
||||
|
||||
function isDuplicateInFlightCameraPublish(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
options: VoiceEngineV2CameraOptions,
|
||||
): boolean {
|
||||
assert.ok(snapshot != null, 'isDuplicateInFlightCameraPublish snapshot must not be null');
|
||||
assert.ok(options != null, 'isDuplicateInFlightCameraPublish options must not be null');
|
||||
if (!isCameraPublishInFlight(snapshot)) return false;
|
||||
assert.ok(snapshot.camera.desired !== null, 'in-flight camera publish must have desired options');
|
||||
return sameCameraPublishCommandOptions(snapshot.camera.desired, options);
|
||||
}
|
||||
|
||||
export function beginCameraPublish(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'beginCameraPublish snapshot must not be null');
|
||||
assert.ok(snapshot.camera != null, 'beginCameraPublish snapshot.camera must not be null');
|
||||
const desired = snapshot.camera.desired;
|
||||
if (!desired) return {snapshot, commands: []};
|
||||
if (!snapshot.capabilities.camera) {
|
||||
const error = unsupportedCapability('camera');
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
camera: {...snapshot.camera, status: 'failed', failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
if (!isConnected(snapshot)) return {snapshot, commands: []};
|
||||
if (snapshot.camera.status === 'published' && sameCameraOptions(snapshot.camera.published, desired)) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return {
|
||||
snapshot: {
|
||||
...allocated.snapshot,
|
||||
camera: {
|
||||
...allocated.snapshot.camera,
|
||||
status: 'publishing',
|
||||
operationId: allocated.operationId,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
commands: [{type: 'camera.publish', operationId: allocated.operationId, options: desired}],
|
||||
};
|
||||
}
|
||||
|
||||
function failCamera(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
error: VoiceEngineV2Error,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'failCamera snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'failCamera operationId must be an integer');
|
||||
assert.ok(error != null, 'failCamera error must not be null');
|
||||
const state = snapshot.camera;
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
camera: applyMediaFailure(state, operationId, error),
|
||||
lastFailure: state.operationId === operationId ? error : snapshot.lastFailure,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function beginCameraEncodingUpdate(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
options: VoiceEngineV2CameraEncodingOptions,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onCameraUpdateEncodingRequested snapshot must not be null');
|
||||
assert.ok(options != null, 'onCameraUpdateEncodingRequested options must not be null');
|
||||
const plan = planVoiceEngineV2CameraEncodingChange({
|
||||
published: snapshot.camera.published,
|
||||
desired: snapshot.camera.desired,
|
||||
update: options,
|
||||
});
|
||||
if (plan.action === 'reject' || !plan.desired) {
|
||||
const error =
|
||||
plan.error ?? invalidArgument('Cannot update camera encoding without a matching published camera', 'camera');
|
||||
return {
|
||||
snapshot: {...snapshot, camera: {...snapshot.camera, failure: error}, lastFailure: error},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
if (plan.action === 'noop') {
|
||||
return {snapshot: {...snapshot, camera: {...snapshot.camera, desired: plan.desired}}, commands: []};
|
||||
}
|
||||
if (!isConnected(snapshot)) {
|
||||
return {snapshot: {...snapshot, camera: {...snapshot.camera, desired: plan.desired}}, commands: []};
|
||||
}
|
||||
const allocated = allocateOperation({...snapshot, camera: {...snapshot.camera, desired: plan.desired}});
|
||||
const command =
|
||||
plan.action === 'republish'
|
||||
? ({type: 'camera.publish', operationId: allocated.operationId, options: plan.desired} as const)
|
||||
: ({type: 'camera.updateEncoding', operationId: allocated.operationId, options} as const);
|
||||
return {
|
||||
snapshot: {
|
||||
...allocated.snapshot,
|
||||
camera: {...allocated.snapshot.camera, status: 'publishing', operationId: allocated.operationId, failure: null},
|
||||
},
|
||||
commands: [command],
|
||||
};
|
||||
}
|
||||
|
||||
function beginCameraUnpublish(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
options: VoiceEngineV2CameraOptions | undefined,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'beginCameraUnpublish snapshot must not be null');
|
||||
assert.ok(options === undefined || typeof options === 'object', 'beginCameraUnpublish options must be an object');
|
||||
const base = {
|
||||
...snapshot,
|
||||
camera: {
|
||||
...snapshot.camera,
|
||||
desired: null,
|
||||
},
|
||||
};
|
||||
if (!isConnected(base) || (snapshot.camera.status === 'idle' && snapshot.camera.published == null)) {
|
||||
return {snapshot: base, commands: []};
|
||||
}
|
||||
const allocated = allocateOperation(base);
|
||||
return {
|
||||
snapshot: {
|
||||
...allocated.snapshot,
|
||||
camera: {
|
||||
...allocated.snapshot.camera,
|
||||
status: 'unpublishing',
|
||||
operationId: allocated.operationId,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
commands: [{type: 'camera.unpublish', operationId: allocated.operationId, options}],
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionCamera(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2CameraEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionCamera snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionCamera event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'camera event type must be a string');
|
||||
assert.ok(event.type.startsWith('camera.'), 'camera reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'camera.publishRequested':
|
||||
if (isDuplicateInFlightCameraPublish(snapshot, event.options)) return {snapshot, commands: []};
|
||||
return beginCameraPublish({...snapshot, camera: {...snapshot.camera, desired: event.options}});
|
||||
case 'camera.publishSucceeded':
|
||||
return {
|
||||
snapshot: {...snapshot, camera: applyMediaSuccess(snapshot.camera, event.operationId)},
|
||||
commands: [],
|
||||
};
|
||||
case 'camera.publishFailed':
|
||||
return failCamera(snapshot, event.operationId, event.error);
|
||||
case 'camera.updateEncodingRequested':
|
||||
return beginCameraEncodingUpdate(snapshot, event.options);
|
||||
case 'camera.updateEncodingSucceeded':
|
||||
return {
|
||||
snapshot: {...snapshot, camera: applyMediaSuccess(snapshot.camera, event.operationId)},
|
||||
commands: [],
|
||||
};
|
||||
case 'camera.updateEncodingFailed':
|
||||
return failCamera(snapshot, event.operationId, event.error);
|
||||
case 'camera.unpublishRequested':
|
||||
return beginCameraUnpublish(snapshot, event.options);
|
||||
case 'camera.unpublishSucceeded':
|
||||
return {
|
||||
snapshot: {...snapshot, camera: completeUnpublish(snapshot.camera, event.operationId)},
|
||||
commands: [],
|
||||
};
|
||||
case 'camera.unpublishFailed':
|
||||
return failUnpublish(snapshot, event.operationId, event.error, 'camera');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {
|
||||
VoiceEngineV2Error,
|
||||
VoiceEngineV2HardwareEncoderCapabilities,
|
||||
VoiceEngineV2OperationId,
|
||||
} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, markOperation, queueCommand, unsupportedCapability} from './_helpers';
|
||||
|
||||
type VoiceEngineV2CapabilitiesEvent = Extract<VoiceEngineV2Event, {type: `capabilities.${string}`}>;
|
||||
|
||||
export function transitionCapabilities(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2CapabilitiesEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionCapabilities snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionCapabilities event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'capabilities event type must be a string');
|
||||
assert.ok(event.type.startsWith('capabilities.'), 'capabilities reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'capabilities.changed':
|
||||
return {
|
||||
snapshot: {...snapshot, capabilities: event.capabilities},
|
||||
commands: [],
|
||||
};
|
||||
case 'capabilities.hardwareEncoderQueryRequested':
|
||||
return onHardwareEncoderQueryRequested(snapshot);
|
||||
case 'capabilities.hardwareEncoderChanged':
|
||||
return onHardwareEncoderChanged(snapshot, event.operationId, event.capabilities);
|
||||
case 'capabilities.hardwareEncoderQueryFailed':
|
||||
return onHardwareEncoderQueryFailed(snapshot, event.operationId, event.error);
|
||||
}
|
||||
}
|
||||
|
||||
function onHardwareEncoderQueryRequested(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onHardwareEncoderQueryRequested snapshot must not be null');
|
||||
assert.ok(snapshot.capabilities != null, 'onHardwareEncoderQueryRequested snapshot.capabilities must not be null');
|
||||
if (!snapshot.capabilities.hardwareEncoding) {
|
||||
const error = unsupportedCapability('hardwareEncoding');
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
hardwareEncoder: {...snapshot.hardwareEncoder, failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
hardwareEncoder: {
|
||||
...allocated.snapshot.hardwareEncoder,
|
||||
operationId: allocated.operationId,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
{type: 'capabilities.queryHardwareEncoder', operationId: allocated.operationId},
|
||||
);
|
||||
}
|
||||
|
||||
function onHardwareEncoderChanged(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId | null,
|
||||
capabilities: VoiceEngineV2HardwareEncoderCapabilities,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onHardwareEncoderChanged snapshot must not be null');
|
||||
assert.ok(capabilities != null, 'onHardwareEncoderChanged capabilities must not be null');
|
||||
if (operationId != null && snapshot.hardwareEncoder.operationId !== operationId) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
return {
|
||||
snapshot: {
|
||||
...(operationId != null ? markOperation(snapshot, operationId, 'succeeded') : snapshot),
|
||||
hardwareEncoder: {
|
||||
capabilities,
|
||||
operationId: operationId === snapshot.hardwareEncoder.operationId ? null : snapshot.hardwareEncoder.operationId,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onHardwareEncoderQueryFailed(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
error: VoiceEngineV2Error,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onHardwareEncoderQueryFailed snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onHardwareEncoderQueryFailed operationId must be an integer');
|
||||
assert.ok(error != null, 'onHardwareEncoderQueryFailed error must not be null');
|
||||
if (snapshot.hardwareEncoder.operationId !== operationId) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {
|
||||
...markOperation(snapshot, operationId, 'failed', error),
|
||||
hardwareEncoder: {...snapshot.hardwareEncoder, operationId: null, failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {getVoiceEngineV2CommandTypeResourceKey} from '../../protocol/commands';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, clearOperationForResource, markOperation, queueCommand} from './_helpers';
|
||||
|
||||
type VoiceEngineV2CommandEvent = Extract<VoiceEngineV2Event, {type: `command.${string}` | `operation.${string}`}>;
|
||||
|
||||
export function transitionCommand(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2CommandEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionCommand snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionCommand event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'command event type must be a string');
|
||||
assert.ok(
|
||||
event.type.startsWith('command.') || event.type.startsWith('operation.'),
|
||||
'command reducer received unrelated event',
|
||||
);
|
||||
switch (event.type) {
|
||||
case 'command.succeeded': {
|
||||
const resourceKey = getVoiceEngineV2CommandTypeResourceKey(event.commandType);
|
||||
const marked = markOperation(snapshot, event.operationId, 'succeeded');
|
||||
return {
|
||||
snapshot: resourceKey ? clearOperationForResource(marked, resourceKey, event.operationId) : marked,
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
case 'command.failed': {
|
||||
const resourceKey = getVoiceEngineV2CommandTypeResourceKey(event.commandType);
|
||||
const marked = markOperation(snapshot, event.operationId, 'failed', event.error);
|
||||
return {
|
||||
snapshot: {
|
||||
...(resourceKey ? clearOperationForResource(marked, resourceKey, event.operationId) : marked),
|
||||
lastFailure: event.error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
case 'command.staleCompletionRejected':
|
||||
return {snapshot: markOperation(snapshot, event.operationId, 'stale'), commands: []};
|
||||
case 'operation.cancelRequested': {
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(allocated.snapshot, {
|
||||
type: 'operation.cancel',
|
||||
operationId: allocated.operationId,
|
||||
targetOperationId: event.operationId,
|
||||
resourceKey: event.resourceKey,
|
||||
reason: event.reason,
|
||||
});
|
||||
}
|
||||
case 'operation.cancelled':
|
||||
return {
|
||||
snapshot: clearOperationForResource(
|
||||
markOperation(snapshot, event.targetOperationId, 'cancelled'),
|
||||
event.resourceKey,
|
||||
event.targetOperationId,
|
||||
),
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Command} from '../../protocol/commands';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {
|
||||
VoiceEngineV2ConnectOptions,
|
||||
VoiceEngineV2DisconnectReason,
|
||||
VoiceEngineV2Error,
|
||||
VoiceEngineV2LifecycleReason,
|
||||
VoiceEngineV2OperationId,
|
||||
} from '../../protocol/types';
|
||||
import {createVoiceEngineV2InitialSnapshot, type VoiceEngineV2Snapshot, type VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, markOperation, queueCommand, recordQueuedCommand, unsupportedCapability} from './_helpers';
|
||||
import {resetPublishedMedia} from './_media';
|
||||
|
||||
type VoiceEngineV2ConnectionEvent = Extract<VoiceEngineV2Event, {type: `connection.${string}`}>;
|
||||
|
||||
function sameConnectOptions(a: VoiceEngineV2ConnectOptions | null, b: VoiceEngineV2ConnectOptions | null): boolean {
|
||||
assert.ok(a !== undefined, 'sameConnectOptions a must not be undefined');
|
||||
assert.ok(b !== undefined, 'sameConnectOptions b must not be undefined');
|
||||
return a?.url === b?.url && a?.token === b?.token && a?.e2eeKey === b?.e2eeKey;
|
||||
}
|
||||
|
||||
export function resetAfterDisconnect(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'resetAfterDisconnect snapshot must not be null');
|
||||
assert.ok(snapshot.capabilities != null, 'resetAfterDisconnect snapshot.capabilities must not be null');
|
||||
const initial = createVoiceEngineV2InitialSnapshot(snapshot.capabilities);
|
||||
return {
|
||||
...initial,
|
||||
nextOperationId: snapshot.nextOperationId,
|
||||
connection: {
|
||||
...initial.connection,
|
||||
desired: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function staleCompletion(snapshot: VoiceEngineV2Snapshot, operationId: VoiceEngineV2OperationId | null): boolean {
|
||||
assert.ok(snapshot != null, 'staleCompletion snapshot must not be null');
|
||||
assert.ok(snapshot.connection != null, 'staleCompletion snapshot.connection must not be null');
|
||||
return operationId == null || operationId !== snapshot.connection.operationId;
|
||||
}
|
||||
|
||||
function hasGatewayVoiceStateToClear(snapshot: VoiceEngineV2Snapshot): boolean {
|
||||
assert.ok(snapshot != null, 'hasGatewayVoiceStateToClear snapshot must not be null');
|
||||
assert.ok(snapshot.gateway != null, 'hasGatewayVoiceStateToClear snapshot.gateway must not be null');
|
||||
return (
|
||||
snapshot.gateway.selfVoiceState?.channelId != null || snapshot.gateway.desiredVoiceStateWrite?.channelId != null
|
||||
);
|
||||
}
|
||||
|
||||
function gatewayVoiceStateGuildId(snapshot: VoiceEngineV2Snapshot): string | null {
|
||||
assert.ok(snapshot != null, 'gatewayVoiceStateGuildId snapshot must not be null');
|
||||
assert.ok(snapshot.gateway != null, 'gatewayVoiceStateGuildId snapshot.gateway must not be null');
|
||||
return snapshot.gateway.selfVoiceState?.guildId ?? snapshot.gateway.desiredVoiceStateWrite?.guildId ?? null;
|
||||
}
|
||||
|
||||
function beginConnectionConnect(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
options: VoiceEngineV2ConnectOptions,
|
||||
status: 'connecting' | 'reconnecting' = 'connecting',
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'beginConnectionConnect snapshot must not be null');
|
||||
assert.ok(options != null, 'beginConnectionConnect options must not be null');
|
||||
assert.equal(typeof options.url, 'string', 'beginConnectionConnect options.url must be a string');
|
||||
if (!snapshot.capabilities.connect) {
|
||||
const error = unsupportedCapability('connect');
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
connection: {...snapshot.connection, status: 'failed', failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
const allocated = allocateOperation(resetPublishedMedia(snapshot));
|
||||
const command: VoiceEngineV2Command = {type: 'connection.connect', operationId: allocated.operationId, options};
|
||||
return {
|
||||
snapshot: recordQueuedCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
connection: {
|
||||
...allocated.snapshot.connection,
|
||||
status,
|
||||
desired: options,
|
||||
operationId: allocated.operationId,
|
||||
disconnectReason: null,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
command,
|
||||
),
|
||||
commands: [command],
|
||||
};
|
||||
}
|
||||
|
||||
function beginConnectionDisconnect(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
reason: VoiceEngineV2DisconnectReason,
|
||||
desiredAfterDisconnect: VoiceEngineV2ConnectOptions | null,
|
||||
status: 'disconnecting' | 'reconnecting' = desiredAfterDisconnect ? 'reconnecting' : 'disconnecting',
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'beginConnectionDisconnect snapshot must not be null');
|
||||
assert.equal(typeof reason, 'string', 'beginConnectionDisconnect reason must be a string');
|
||||
const allocated = allocateOperation(resetPublishedMedia(snapshot));
|
||||
const command: VoiceEngineV2Command = {
|
||||
type: 'connection.disconnect',
|
||||
operationId: allocated.operationId,
|
||||
reason,
|
||||
};
|
||||
return {
|
||||
snapshot: recordQueuedCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
connection: {
|
||||
...allocated.snapshot.connection,
|
||||
status,
|
||||
desired: desiredAfterDisconnect,
|
||||
operationId: allocated.operationId,
|
||||
disconnectReason: reason,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
command,
|
||||
),
|
||||
commands: [command],
|
||||
};
|
||||
}
|
||||
|
||||
export function beginGatewayVoiceStateClear(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
guildId: string | null,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'beginGatewayVoiceStateClear snapshot must not be null');
|
||||
assert.ok(snapshot.gateway != null, 'beginGatewayVoiceStateClear snapshot.gateway must not be null');
|
||||
if (snapshot.gateway.operationId != null) return {snapshot, commands: []};
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
gateway: {
|
||||
...allocated.snapshot.gateway,
|
||||
desiredVoiceStateWrite: null,
|
||||
operationId: allocated.operationId,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
{type: 'gateway.voiceState.clear', operationId: allocated.operationId, guildId},
|
||||
);
|
||||
}
|
||||
|
||||
function beginLifecycleTeardownCommand(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
reason: VoiceEngineV2LifecycleReason,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'beginLifecycleTeardownCommand snapshot must not be null');
|
||||
assert.equal(typeof reason, 'string', 'beginLifecycleTeardownCommand reason must be a string');
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
lifecycle: {
|
||||
tearingDown: true,
|
||||
reason,
|
||||
operationId: allocated.operationId,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
{type: 'lifecycle.teardown', operationId: allocated.operationId, reason},
|
||||
);
|
||||
}
|
||||
|
||||
function clearConnectionAfterDisconnect(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'clearConnectionAfterDisconnect snapshot must not be null');
|
||||
assert.ok(snapshot.connection != null, 'clearConnectionAfterDisconnect snapshot.connection must not be null');
|
||||
return {
|
||||
...resetPublishedMedia(snapshot),
|
||||
connection: {
|
||||
...snapshot.connection,
|
||||
status: 'idle',
|
||||
active: null,
|
||||
desired: null,
|
||||
operationId: null,
|
||||
disconnectReason: null,
|
||||
failure: null,
|
||||
},
|
||||
liveKit: {
|
||||
...snapshot.liveKit,
|
||||
connectionState: 'disconnected',
|
||||
roomSid: null,
|
||||
roomName: null,
|
||||
serverRegion: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function planPendingConnectionTeardown(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'planPendingConnectionTeardown snapshot must not be null');
|
||||
assert.ok(snapshot.lifecycle != null, 'planPendingConnectionTeardown snapshot.lifecycle must not be null');
|
||||
if (snapshot.lifecycle.tearingDown) return planLifecycleTeardown(snapshot);
|
||||
if (
|
||||
(snapshot.connection.status === 'disconnecting' || snapshot.connection.status === 'reconnecting') &&
|
||||
snapshot.connection.operationId == null
|
||||
) {
|
||||
return planPendingDisconnect(snapshot);
|
||||
}
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
|
||||
function planLifecycleTeardown(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'planLifecycleTeardown snapshot must not be null');
|
||||
assert.ok(snapshot.lifecycle != null, 'planLifecycleTeardown snapshot.lifecycle must not be null');
|
||||
if (
|
||||
snapshot.lifecycle.operationId != null ||
|
||||
snapshot.gateway.operationId != null ||
|
||||
snapshot.connection.operationId != null
|
||||
) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
if (hasGatewayVoiceStateToClear(snapshot)) {
|
||||
return beginGatewayVoiceStateClear(snapshot, gatewayVoiceStateGuildId(snapshot));
|
||||
}
|
||||
if (
|
||||
snapshot.connection.status !== 'idle' ||
|
||||
snapshot.connection.active != null ||
|
||||
snapshot.connection.desired != null
|
||||
) {
|
||||
return beginConnectionDisconnect(
|
||||
{
|
||||
...snapshot,
|
||||
connection: {
|
||||
...snapshot.connection,
|
||||
desired: null,
|
||||
disconnectReason: 'shutdown',
|
||||
},
|
||||
},
|
||||
'shutdown',
|
||||
null,
|
||||
'disconnecting',
|
||||
);
|
||||
}
|
||||
return beginLifecycleTeardownCommand(snapshot, snapshot.lifecycle.reason ?? 'appShutdown');
|
||||
}
|
||||
|
||||
function planPendingDisconnect(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'planPendingDisconnect snapshot must not be null');
|
||||
assert.ok(snapshot.connection != null, 'planPendingDisconnect snapshot.connection must not be null');
|
||||
if (snapshot.connection.active == null && snapshot.connection.desired == null) {
|
||||
return {snapshot: resetAfterDisconnect(snapshot), commands: []};
|
||||
}
|
||||
return beginConnectionDisconnect(
|
||||
snapshot,
|
||||
snapshot.connection.disconnectReason ?? (snapshot.connection.desired ? 'replaced' : 'user'),
|
||||
snapshot.connection.desired,
|
||||
snapshot.connection.desired ? 'reconnecting' : 'disconnecting',
|
||||
);
|
||||
}
|
||||
|
||||
export function transitionConnection(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2ConnectionEvent,
|
||||
planAfterConnect: (snapshot: VoiceEngineV2Snapshot) => VoiceEngineV2Transition,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionConnection snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionConnection event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'connection event type must be a string');
|
||||
assert.ok(event.type.startsWith('connection.'), 'connection reducer received unrelated event');
|
||||
assert.equal(typeof planAfterConnect, 'function', 'transitionConnection planAfterConnect must be a function');
|
||||
switch (event.type) {
|
||||
case 'connection.connectRequested':
|
||||
return onConnectRequested(snapshot, event.options);
|
||||
case 'connection.connectSucceeded':
|
||||
return onConnectSucceeded(snapshot, event.operationId, planAfterConnect);
|
||||
case 'connection.connectFailed':
|
||||
return onConnectFailed(snapshot, event.operationId, event.error);
|
||||
case 'connection.disconnectRequested':
|
||||
return onDisconnectRequested(snapshot, event.reason);
|
||||
case 'connection.disconnectSucceeded':
|
||||
return onDisconnectSucceeded(snapshot, event.operationId);
|
||||
case 'connection.disconnectFailed':
|
||||
return onDisconnectFailed(snapshot, event.operationId, event.error);
|
||||
case 'connection.remoteDisconnected':
|
||||
return onRemoteDisconnected(snapshot, event.reason, event.error);
|
||||
case 'connection.reconnectRequested':
|
||||
return onReconnectRequested(snapshot);
|
||||
case 'connection.externallyEstablished':
|
||||
return onExternallyEstablished(snapshot, event.options, planAfterConnect);
|
||||
}
|
||||
}
|
||||
|
||||
function onExternallyEstablished(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
options: VoiceEngineV2ConnectOptions,
|
||||
planAfterConnect: (snapshot: VoiceEngineV2Snapshot) => VoiceEngineV2Transition,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onExternallyEstablished snapshot must not be null');
|
||||
assert.ok(options != null, 'onExternallyEstablished options must not be null');
|
||||
assert.equal(typeof options.url, 'string', 'onExternallyEstablished options.url must be a string');
|
||||
assert.equal(typeof options.token, 'string', 'onExternallyEstablished options.token must be a string');
|
||||
if (snapshot.connection.status === 'connected' && sameConnectOptions(snapshot.connection.active, options)) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
const connected: VoiceEngineV2Snapshot = {
|
||||
...snapshot,
|
||||
connection: {
|
||||
...snapshot.connection,
|
||||
status: 'connected',
|
||||
active: options,
|
||||
operationId: null,
|
||||
disconnectReason: null,
|
||||
failure: null,
|
||||
},
|
||||
};
|
||||
assert.equal(connected.connection.status, 'connected', 'onExternallyEstablished must mark snapshot connected');
|
||||
if (connected.lifecycle.tearingDown) return planPendingConnectionTeardown(connected);
|
||||
return planAfterConnect(connected);
|
||||
}
|
||||
|
||||
function onConnectRequested(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
options: VoiceEngineV2ConnectOptions,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onConnectRequested snapshot must not be null');
|
||||
assert.ok(options != null, 'onConnectRequested options must not be null');
|
||||
if (
|
||||
(snapshot.connection.status === 'connected' ||
|
||||
snapshot.connection.status === 'connecting' ||
|
||||
snapshot.connection.status === 'reconnecting') &&
|
||||
sameConnectOptions(snapshot.connection.desired ?? snapshot.connection.active, options)
|
||||
) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
if (snapshot.connection.status === 'connected' || snapshot.connection.active != null) {
|
||||
return beginConnectionDisconnect(snapshot, 'replaced', options, 'reconnecting');
|
||||
}
|
||||
if (snapshot.connection.status === 'disconnecting') {
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
connection: {
|
||||
...snapshot.connection,
|
||||
status: 'reconnecting',
|
||||
desired: options,
|
||||
disconnectReason: 'replaced',
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
return beginConnectionConnect(snapshot, options);
|
||||
}
|
||||
|
||||
function onConnectSucceeded(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
planAfterConnect: (snapshot: VoiceEngineV2Snapshot) => VoiceEngineV2Transition,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onConnectSucceeded snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onConnectSucceeded operationId must be an integer');
|
||||
if (staleCompletion(snapshot, operationId)) return {snapshot, commands: []};
|
||||
const connected: VoiceEngineV2Snapshot = {
|
||||
...markOperation(snapshot, operationId, 'succeeded'),
|
||||
connection: {
|
||||
...snapshot.connection,
|
||||
status: 'connected',
|
||||
active: snapshot.connection.desired,
|
||||
operationId: null,
|
||||
disconnectReason: null,
|
||||
failure: null,
|
||||
},
|
||||
};
|
||||
if (connected.lifecycle.tearingDown) return planPendingConnectionTeardown(connected);
|
||||
return planAfterConnect(connected);
|
||||
}
|
||||
|
||||
function onConnectFailed(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
error: VoiceEngineV2Error,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onConnectFailed snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onConnectFailed operationId must be an integer');
|
||||
assert.ok(error != null, 'onConnectFailed error must not be null');
|
||||
if (staleCompletion(snapshot, operationId)) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {
|
||||
...markOperation(snapshot, operationId, 'failed', error),
|
||||
connection: {
|
||||
...snapshot.connection,
|
||||
status: 'failed',
|
||||
operationId: null,
|
||||
failure: error,
|
||||
},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onDisconnectRequested(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
reason: VoiceEngineV2DisconnectReason,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onDisconnectRequested snapshot must not be null');
|
||||
assert.equal(typeof reason, 'string', 'onDisconnectRequested reason must be a string');
|
||||
const base = {
|
||||
...resetPublishedMedia(snapshot),
|
||||
connection: {
|
||||
...snapshot.connection,
|
||||
status: 'disconnecting' as const,
|
||||
desired: null,
|
||||
operationId: null,
|
||||
disconnectReason: reason,
|
||||
failure: null,
|
||||
},
|
||||
};
|
||||
if (hasGatewayVoiceStateToClear(base)) {
|
||||
return beginGatewayVoiceStateClear(base, gatewayVoiceStateGuildId(base));
|
||||
}
|
||||
if (
|
||||
snapshot.connection.status === 'idle' &&
|
||||
snapshot.connection.active == null &&
|
||||
snapshot.connection.operationId == null
|
||||
) {
|
||||
return {snapshot: resetAfterDisconnect(base), commands: []};
|
||||
}
|
||||
return beginConnectionDisconnect(base, reason, null, 'disconnecting');
|
||||
}
|
||||
|
||||
function onDisconnectSucceeded(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onDisconnectSucceeded snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onDisconnectSucceeded operationId must be an integer');
|
||||
if (staleCompletion(snapshot, operationId)) return {snapshot, commands: []};
|
||||
const completed = markOperation(snapshot, operationId, 'succeeded');
|
||||
if (completed.lifecycle.tearingDown) {
|
||||
return planPendingConnectionTeardown(clearConnectionAfterDisconnect(completed));
|
||||
}
|
||||
if (completed.connection.desired) {
|
||||
return beginConnectionConnect(
|
||||
{
|
||||
...completed,
|
||||
connection: {
|
||||
...completed.connection,
|
||||
active: null,
|
||||
operationId: null,
|
||||
disconnectReason: null,
|
||||
},
|
||||
},
|
||||
completed.connection.desired,
|
||||
'reconnecting',
|
||||
);
|
||||
}
|
||||
return {snapshot: resetAfterDisconnect(completed), commands: []};
|
||||
}
|
||||
|
||||
function onDisconnectFailed(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
error: VoiceEngineV2Error,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onDisconnectFailed snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onDisconnectFailed operationId must be an integer');
|
||||
assert.ok(error != null, 'onDisconnectFailed error must not be null');
|
||||
if (staleCompletion(snapshot, operationId)) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {
|
||||
...markOperation(snapshot, operationId, 'failed', error),
|
||||
connection: {
|
||||
...snapshot.connection,
|
||||
status: 'failed',
|
||||
operationId: null,
|
||||
failure: error,
|
||||
},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onRemoteDisconnected(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
reason: VoiceEngineV2DisconnectReason,
|
||||
error: VoiceEngineV2Error | undefined,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onRemoteDisconnected snapshot must not be null');
|
||||
assert.equal(typeof reason, 'string', 'onRemoteDisconnected reason must be a string');
|
||||
const disconnected = resetAfterDisconnect(snapshot);
|
||||
return {
|
||||
snapshot: {
|
||||
...disconnected,
|
||||
connection: {
|
||||
...disconnected.connection,
|
||||
disconnectReason: reason,
|
||||
failure: error ?? null,
|
||||
},
|
||||
lastFailure: error ?? snapshot.lastFailure,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onReconnectRequested(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onReconnectRequested snapshot must not be null');
|
||||
assert.ok(snapshot.connection != null, 'onReconnectRequested snapshot.connection must not be null');
|
||||
const options = snapshot.connection.desired ?? snapshot.connection.active;
|
||||
if (!options) return {snapshot, commands: []};
|
||||
if (snapshot.connection.status === 'connecting' || snapshot.connection.status === 'reconnecting') {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
if (snapshot.connection.status === 'connected' || snapshot.connection.active != null) {
|
||||
return beginConnectionDisconnect(snapshot, 'network', options, 'reconnecting');
|
||||
}
|
||||
return beginConnectionConnect(snapshot, options, 'reconnecting');
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {commandIfConnected} from './_helpers';
|
||||
|
||||
type VoiceEngineV2DataEvent = Extract<VoiceEngineV2Event, {type: `data.${string}`}>;
|
||||
|
||||
export function transitionData(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2DataEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionData snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionData event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'data event type must be a string');
|
||||
assert.ok(event.type.startsWith('data.'), 'data reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'data.publishRequested':
|
||||
return commandIfConnected(snapshot, 'dataChannel', {type: 'data.publish', options: event.options});
|
||||
case 'data.publishSucceeded':
|
||||
return {snapshot, commands: []};
|
||||
case 'data.publishFailed':
|
||||
return {snapshot: {...snapshot, lastFailure: event.error}, commands: []};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Command} from '../../protocol/commands';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2DeviceInventory, VoiceEngineV2Error, VoiceEngineV2OperationId} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, appendTransition, markOperation, queueCommand} from './_helpers';
|
||||
import {planMicrophoneDeviceChange} from './microphone';
|
||||
|
||||
type VoiceEngineV2DevicesEvent = Extract<VoiceEngineV2Event, {type: `devices.${string}`}>;
|
||||
|
||||
function beginDeviceSelection(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
commandType: 'devices.selectAudioInput' | 'devices.selectAudioOutput' | 'devices.selectCamera',
|
||||
deviceId: string | null,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'beginDeviceSelection snapshot must not be null');
|
||||
assert.equal(typeof commandType, 'string', 'beginDeviceSelection commandType must be a string');
|
||||
const allocated = allocateOperation(snapshot);
|
||||
const inventory = {
|
||||
...allocated.snapshot.devices.inventory,
|
||||
selectedAudioInputId:
|
||||
commandType === 'devices.selectAudioInput' ? deviceId : allocated.snapshot.devices.inventory.selectedAudioInputId,
|
||||
selectedAudioOutputId:
|
||||
commandType === 'devices.selectAudioOutput'
|
||||
? deviceId
|
||||
: allocated.snapshot.devices.inventory.selectedAudioOutputId,
|
||||
selectedCameraId:
|
||||
commandType === 'devices.selectCamera' ? deviceId : allocated.snapshot.devices.inventory.selectedCameraId,
|
||||
};
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
devices: {...allocated.snapshot.devices, inventory, operationId: allocated.operationId, failure: null},
|
||||
},
|
||||
{type: commandType, operationId: allocated.operationId, deviceId} as VoiceEngineV2Command,
|
||||
);
|
||||
}
|
||||
|
||||
export function transitionDevices(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2DevicesEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionDevices snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionDevices event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'devices event type must be a string');
|
||||
assert.ok(event.type.startsWith('devices.'), 'devices reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'devices.enumerateRequested':
|
||||
return onEnumerateRequested(snapshot);
|
||||
case 'devices.changed':
|
||||
return onChanged(snapshot, event.operationId, event.devices);
|
||||
case 'devices.enumerateFailed':
|
||||
return onEnumerateFailed(snapshot, event.operationId, event.error);
|
||||
case 'devices.selectAudioInputRequested': {
|
||||
const selection = beginDeviceSelection(snapshot, 'devices.selectAudioInput', event.deviceId);
|
||||
return appendTransition(selection, planMicrophoneDeviceChange(selection.snapshot, event.deviceId));
|
||||
}
|
||||
case 'devices.selectAudioOutputRequested':
|
||||
return beginDeviceSelection(snapshot, 'devices.selectAudioOutput', event.deviceId);
|
||||
case 'devices.selectCameraRequested':
|
||||
return beginDeviceSelection(snapshot, 'devices.selectCamera', event.deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
function onEnumerateRequested(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onEnumerateRequested snapshot must not be null');
|
||||
assert.ok(snapshot.devices != null, 'onEnumerateRequested snapshot.devices must not be null');
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
devices: {...allocated.snapshot.devices, operationId: allocated.operationId, failure: null},
|
||||
},
|
||||
{type: 'devices.enumerate', operationId: allocated.operationId},
|
||||
);
|
||||
}
|
||||
|
||||
function onChanged(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId | null,
|
||||
devices: VoiceEngineV2DeviceInventory,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onChanged snapshot must not be null');
|
||||
assert.ok(devices != null, 'onChanged devices must not be null');
|
||||
if (operationId != null && snapshot.devices.operationId !== operationId) return {snapshot, commands: []};
|
||||
const previousAudioInputId = snapshot.devices.inventory.selectedAudioInputId;
|
||||
const base = {
|
||||
...(operationId != null ? markOperation(snapshot, operationId, 'succeeded') : snapshot),
|
||||
devices: {
|
||||
inventory: devices,
|
||||
operationId: operationId === snapshot.devices.operationId ? null : snapshot.devices.operationId,
|
||||
failure: null,
|
||||
},
|
||||
};
|
||||
if (previousAudioInputId === devices.selectedAudioInputId) return {snapshot: base, commands: []};
|
||||
return planMicrophoneDeviceChange(base, devices.selectedAudioInputId);
|
||||
}
|
||||
|
||||
function onEnumerateFailed(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
error: VoiceEngineV2Error,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onEnumerateFailed snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onEnumerateFailed operationId must be an integer');
|
||||
assert.ok(error != null, 'onEnumerateFailed error must not be null');
|
||||
if (snapshot.devices.operationId !== operationId) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {
|
||||
...markOperation(snapshot, operationId, 'failed', error),
|
||||
devices: {...snapshot.devices, operationId: null, failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, markOperation, queueCommand} from './_helpers';
|
||||
|
||||
type VoiceEngineV2E2eeEvent = Extract<VoiceEngineV2Event, {type: `e2ee.${string}`}>;
|
||||
|
||||
export function transitionE2ee(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2E2eeEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionE2ee snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionE2ee event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'e2ee event type must be a string');
|
||||
assert.ok(event.type.startsWith('e2ee.'), 'e2ee reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'e2ee.setEnabledRequested': {
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
e2ee: {
|
||||
...allocated.snapshot.e2ee,
|
||||
status: event.enabled ? 'pendingKey' : 'disabled',
|
||||
keyId: event.keyId ?? allocated.snapshot.e2ee.keyId,
|
||||
failure: null,
|
||||
operationId: allocated.operationId,
|
||||
},
|
||||
},
|
||||
{type: 'e2ee.setEnabled', operationId: allocated.operationId, enabled: event.enabled, keyId: event.keyId},
|
||||
);
|
||||
}
|
||||
case 'e2ee.enabled':
|
||||
if (event.operationId != null && snapshot.e2ee.operationId !== event.operationId) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
return {
|
||||
snapshot: {
|
||||
...(event.operationId != null ? markOperation(snapshot, event.operationId, 'succeeded') : snapshot),
|
||||
e2ee: {status: 'enabled', keyId: event.keyId, failure: null, operationId: null},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
case 'e2ee.disabled':
|
||||
if (event.operationId != null && snapshot.e2ee.operationId !== event.operationId) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
return {
|
||||
snapshot: {
|
||||
...(event.operationId != null ? markOperation(snapshot, event.operationId, 'succeeded') : snapshot),
|
||||
e2ee: {status: 'disabled', keyId: null, failure: null, operationId: null},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
case 'e2ee.failed':
|
||||
if (event.operationId != null && snapshot.e2ee.operationId !== event.operationId) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
return {
|
||||
snapshot: {
|
||||
...(event.operationId != null ? markOperation(snapshot, event.operationId, 'failed', event.error) : snapshot),
|
||||
e2ee: {...snapshot.e2ee, status: 'failed', failure: event.error, operationId: null},
|
||||
lastFailure: event.error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {VoiceEngineV2GatewayDesiredVoiceState, VoiceEngineV2GatewayVoiceState} from '../../protocol/types';
|
||||
import {transitionVoiceEngineV2} from '../reducer';
|
||||
import type {VoiceEngineV2GatewayState, VoiceEngineV2Snapshot} from '../state';
|
||||
import {availableVoiceEngineV2Capabilities, createVoiceEngineV2InitialSnapshot} from '../state';
|
||||
import {
|
||||
deriveVoiceEngineV2DesiredGatewayVoiceState,
|
||||
gatewayVoiceStateWriteMatchesReported,
|
||||
reconcileGatewayVoiceState,
|
||||
shouldApplyGatewayVoiceStateEcho,
|
||||
} from './gateway';
|
||||
|
||||
function reported(overrides: Partial<VoiceEngineV2GatewayVoiceState> = {}): VoiceEngineV2GatewayVoiceState {
|
||||
return {
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
selfMute: true,
|
||||
selfDeaf: false,
|
||||
selfVideo: false,
|
||||
selfStream: false,
|
||||
suppress: false,
|
||||
requestToSpeakTimestamp: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function desired(
|
||||
overrides: Partial<VoiceEngineV2GatewayDesiredVoiceState> = {},
|
||||
): VoiceEngineV2GatewayDesiredVoiceState {
|
||||
return {
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
selfMute: false,
|
||||
selfDeaf: false,
|
||||
selfVideo: false,
|
||||
selfStream: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function connectedSnapshot(gatewayOverrides: Partial<VoiceEngineV2GatewayState> = {}): VoiceEngineV2Snapshot {
|
||||
const base = createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities());
|
||||
return {
|
||||
...base,
|
||||
connection: {...base.connection, status: 'connected'},
|
||||
gateway: {...base.gateway, selfVoiceState: reported(), ...gatewayOverrides},
|
||||
};
|
||||
}
|
||||
|
||||
describe('reconcileGatewayVoiceState', () => {
|
||||
it('emits a write when desired self-mute differs from reported (unmute drives convergence)', () => {
|
||||
const snapshot = connectedSnapshot({
|
||||
selfVoiceState: reported({selfMute: true}),
|
||||
desiredVoiceState: desired({selfMute: false}),
|
||||
});
|
||||
const result = reconcileGatewayVoiceState(snapshot);
|
||||
expect(result.commands).toHaveLength(1);
|
||||
expect(result.commands[0]).toMatchObject({type: 'gateway.voiceState.write', options: {selfMute: false}});
|
||||
expect(result.snapshot.gateway.desiredVoiceStateWrite).toMatchObject({selfMute: false});
|
||||
expect(result.snapshot.gateway.operationId).not.toBeNull();
|
||||
});
|
||||
|
||||
it('emits no write when there is no desired intent yet', () => {
|
||||
const snapshot = connectedSnapshot({selfVoiceState: reported({selfMute: true}), desiredVoiceState: null});
|
||||
expect(reconcileGatewayVoiceState(snapshot).commands).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits no write when desired already equals reported (converged)', () => {
|
||||
const snapshot = connectedSnapshot({
|
||||
selfVoiceState: reported({selfMute: false}),
|
||||
desiredVoiceState: desired({selfMute: false}),
|
||||
});
|
||||
expect(reconcileGatewayVoiceState(snapshot).commands).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits no write while a write is already in flight (operationId set)', () => {
|
||||
const snapshot = connectedSnapshot({
|
||||
selfVoiceState: reported({selfMute: true}),
|
||||
desiredVoiceState: desired({selfMute: false}),
|
||||
operationId: 7,
|
||||
});
|
||||
expect(reconcileGatewayVoiceState(snapshot).commands).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits no write when the same value is already pending an echo (no optimistic re-send, S2)', () => {
|
||||
const snapshot = connectedSnapshot({
|
||||
selfVoiceState: reported({selfMute: true}),
|
||||
desiredVoiceState: desired({selfMute: false}),
|
||||
desiredVoiceStateWrite: {
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
selfMute: false,
|
||||
selfDeaf: false,
|
||||
selfVideo: false,
|
||||
selfStream: false,
|
||||
},
|
||||
operationId: null,
|
||||
});
|
||||
expect(reconcileGatewayVoiceState(snapshot).commands).toEqual([]);
|
||||
});
|
||||
|
||||
it('re-emits when desired changed while a different value was pending (no lost update)', () => {
|
||||
const snapshot = connectedSnapshot({
|
||||
selfVoiceState: reported({selfMute: false}),
|
||||
desiredVoiceState: desired({selfMute: true}),
|
||||
desiredVoiceStateWrite: {
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
selfMute: false,
|
||||
selfDeaf: false,
|
||||
selfVideo: false,
|
||||
selfStream: false,
|
||||
},
|
||||
operationId: null,
|
||||
});
|
||||
const result = reconcileGatewayVoiceState(snapshot);
|
||||
expect(result.commands).toHaveLength(1);
|
||||
expect(result.commands[0]).toMatchObject({type: 'gateway.voiceState.write', options: {selfMute: true}});
|
||||
});
|
||||
|
||||
it('emits no write when disconnected, but the recorded intent re-asserts on connect', () => {
|
||||
const base = createVoiceEngineV2InitialSnapshot(availableVoiceEngineV2Capabilities());
|
||||
const disconnected: VoiceEngineV2Snapshot = {
|
||||
...base,
|
||||
connection: {...base.connection, status: 'idle'},
|
||||
gateway: {
|
||||
...base.gateway,
|
||||
selfVoiceState: reported({selfMute: true}),
|
||||
desiredVoiceState: desired({selfMute: false}),
|
||||
},
|
||||
};
|
||||
expect(reconcileGatewayVoiceState(disconnected).commands).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits a write to establish sync when the server state is not yet known', () => {
|
||||
const snapshot = connectedSnapshot({selfVoiceState: null, desiredVoiceState: desired({selfMute: false})});
|
||||
expect(deriveVoiceEngineV2DesiredGatewayVoiceState(snapshot)).toMatchObject({
|
||||
channelId: 'channel-1',
|
||||
selfMute: false,
|
||||
});
|
||||
const result = reconcileGatewayVoiceState(snapshot);
|
||||
expect(result.commands).toHaveLength(1);
|
||||
expect(result.commands[0]).toMatchObject({type: 'gateway.voiceState.write', options: {selfMute: false}});
|
||||
});
|
||||
|
||||
it('emits no write when the desired channel is null (not in a channel)', () => {
|
||||
const snapshot = connectedSnapshot({selfVoiceState: null, desiredVoiceState: desired({channelId: null})});
|
||||
expect(deriveVoiceEngineV2DesiredGatewayVoiceState(snapshot)).toBeNull();
|
||||
expect(reconcileGatewayVoiceState(snapshot).commands).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears the pending write when the server echoes the desired value (convergence)', () => {
|
||||
const snapshot = connectedSnapshot({
|
||||
selfVoiceState: reported({selfMute: true}),
|
||||
desiredVoiceState: desired({selfMute: false}),
|
||||
desiredVoiceStateWrite: {
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
selfMute: false,
|
||||
selfDeaf: false,
|
||||
selfVideo: false,
|
||||
selfStream: false,
|
||||
},
|
||||
});
|
||||
const echoed = transitionVoiceEngineV2(snapshot, {
|
||||
type: 'gateway.voiceStateUpdated',
|
||||
voiceState: reported({selfMute: false}),
|
||||
});
|
||||
expect(echoed.snapshot.gateway.selfVoiceState).toMatchObject({selfMute: false});
|
||||
expect(echoed.snapshot.gateway.desiredVoiceStateWrite).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the pending write when the echo does not yet match the desired value', () => {
|
||||
const snapshot = connectedSnapshot({
|
||||
selfVoiceState: reported({selfMute: true}),
|
||||
desiredVoiceState: desired({selfMute: false}),
|
||||
desiredVoiceStateWrite: {
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
selfMute: false,
|
||||
selfDeaf: false,
|
||||
selfVideo: false,
|
||||
selfStream: false,
|
||||
},
|
||||
});
|
||||
const echoed = transitionVoiceEngineV2(snapshot, {
|
||||
type: 'gateway.voiceStateUpdated',
|
||||
voiceState: reported({selfMute: true}),
|
||||
});
|
||||
expect(echoed.snapshot.gateway.desiredVoiceStateWrite).toMatchObject({selfMute: false});
|
||||
});
|
||||
|
||||
it('converges across the full unmute lifecycle: desired change, write, success, echo (L1)', () => {
|
||||
let snapshot = connectedSnapshot({selfVoiceState: reported({selfMute: true})});
|
||||
|
||||
const first = transitionVoiceEngineV2(snapshot, {
|
||||
type: 'gateway.desiredVoiceStateChanged',
|
||||
desired: desired({selfMute: false}),
|
||||
});
|
||||
expect(first.commands).toHaveLength(1);
|
||||
expect(first.commands[0]).toMatchObject({type: 'gateway.voiceState.write', options: {selfMute: false}});
|
||||
snapshot = first.snapshot;
|
||||
const operationId = snapshot.gateway.operationId;
|
||||
expect(operationId).not.toBeNull();
|
||||
|
||||
snapshot = transitionVoiceEngineV2(snapshot, {type: 'gateway.voiceStateReconcileRequested'}).snapshot;
|
||||
expect(snapshot.gateway.operationId).toBe(operationId);
|
||||
|
||||
snapshot = transitionVoiceEngineV2(snapshot, {
|
||||
type: 'gateway.voiceStateWriteSucceeded',
|
||||
operationId: operationId as number,
|
||||
}).snapshot;
|
||||
expect(snapshot.gateway.operationId).toBeNull();
|
||||
|
||||
snapshot = transitionVoiceEngineV2(snapshot, {
|
||||
type: 'gateway.voiceStateUpdated',
|
||||
voiceState: reported({selfMute: false}),
|
||||
}).snapshot;
|
||||
|
||||
expect(
|
||||
gatewayVoiceStateWriteMatchesReported(
|
||||
deriveVoiceEngineV2DesiredGatewayVoiceState(snapshot)!,
|
||||
snapshot.gateway.selfVoiceState!,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(transitionVoiceEngineV2(snapshot, {type: 'gateway.voiceStateReconcileRequested'}).commands).toEqual([]);
|
||||
});
|
||||
|
||||
it('retries after a failed write (clears pending so the next reconcile re-emits, L2)', () => {
|
||||
let snapshot = connectedSnapshot({
|
||||
selfVoiceState: reported({selfMute: true}),
|
||||
desiredVoiceState: desired({selfMute: false}),
|
||||
});
|
||||
const first = transitionVoiceEngineV2(snapshot, {type: 'gateway.voiceStateReconcileRequested'});
|
||||
snapshot = first.snapshot;
|
||||
const operationId = snapshot.gateway.operationId as number;
|
||||
snapshot = transitionVoiceEngineV2(snapshot, {
|
||||
type: 'gateway.voiceStateWriteFailed',
|
||||
operationId,
|
||||
error: {code: 'implementationError', message: 'boom'},
|
||||
}).snapshot;
|
||||
expect(snapshot.gateway.desiredVoiceStateWrite).toBeNull();
|
||||
expect(snapshot.gateway.operationId).toBeNull();
|
||||
const retry = transitionVoiceEngineV2(snapshot, {type: 'gateway.voiceStateReconcileRequested'});
|
||||
expect(retry.commands).toHaveLength(1);
|
||||
expect(retry.commands[0]).toMatchObject({type: 'gateway.voiceState.write', options: {selfMute: false}});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldApplyGatewayVoiceStateEcho', () => {
|
||||
it('applies the echo when there is no pending local write', () => {
|
||||
const snapshot = connectedSnapshot({desiredVoiceStateWrite: null});
|
||||
expect(shouldApplyGatewayVoiceStateEcho(snapshot, reported({selfMute: true}))).toBe(true);
|
||||
});
|
||||
|
||||
it('applies the echo when it matches the pending local write', () => {
|
||||
const snapshot = connectedSnapshot({
|
||||
desiredVoiceStateWrite: {
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
selfMute: false,
|
||||
selfDeaf: false,
|
||||
selfVideo: false,
|
||||
selfStream: false,
|
||||
},
|
||||
});
|
||||
expect(shouldApplyGatewayVoiceStateEcho(snapshot, reported({selfMute: false}))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a stale echo that differs from the pending local write', () => {
|
||||
const snapshot = connectedSnapshot({
|
||||
desiredVoiceStateWrite: {
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
selfMute: false,
|
||||
selfDeaf: false,
|
||||
selfVideo: false,
|
||||
selfStream: false,
|
||||
},
|
||||
});
|
||||
expect(shouldApplyGatewayVoiceStateEcho(snapshot, reported({selfMute: true}))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {
|
||||
VoiceEngineV2Error,
|
||||
VoiceEngineV2GatewayDesiredVoiceState,
|
||||
VoiceEngineV2GatewayVoiceState,
|
||||
VoiceEngineV2GatewayVoiceStateWrite,
|
||||
VoiceEngineV2OperationId,
|
||||
} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, isConnected, markOperation, queueCommand} from './_helpers';
|
||||
import {beginGatewayVoiceStateClear, planPendingConnectionTeardown} from './connection';
|
||||
|
||||
type VoiceEngineV2GatewayEvent = Extract<VoiceEngineV2Event, {type: `gateway.${string}` | 'livekit.roomStateChanged'}>;
|
||||
|
||||
export function transitionGateway(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2GatewayEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionGateway snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionGateway event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'gateway event type must be a string');
|
||||
assert.ok(
|
||||
event.type.startsWith('gateway.') || event.type === 'livekit.roomStateChanged',
|
||||
'gateway reducer received unrelated event',
|
||||
);
|
||||
switch (event.type) {
|
||||
case 'gateway.desiredVoiceStateChanged':
|
||||
return onDesiredVoiceStateChanged(snapshot, event.desired);
|
||||
case 'gateway.voiceStateReconcileRequested':
|
||||
return reconcileGatewayVoiceState(snapshot);
|
||||
case 'gateway.voiceStateWriteRequested':
|
||||
return onWriteRequested(snapshot, event.options);
|
||||
case 'gateway.voiceStateWriteSucceeded':
|
||||
return onWriteSucceeded(snapshot, event.operationId);
|
||||
case 'gateway.voiceStateWriteFailed':
|
||||
return onWriteFailed(snapshot, event.operationId, event.error);
|
||||
case 'gateway.voiceStateClearRequested':
|
||||
return beginGatewayVoiceStateClear(snapshot, event.guildId);
|
||||
case 'gateway.voiceStateClearSucceeded':
|
||||
return onClearSucceeded(snapshot, event.operationId);
|
||||
case 'gateway.voiceStateClearFailed':
|
||||
return onClearFailed(snapshot, event.operationId, event.error);
|
||||
case 'gateway.voiceStateUpdated':
|
||||
return onVoiceStateUpdated(snapshot, event.voiceState);
|
||||
case 'gateway.voiceServerUpdated':
|
||||
return {
|
||||
snapshot: {...snapshot, gateway: {...snapshot.gateway, voiceServer: event.voiceServer}},
|
||||
commands: [],
|
||||
};
|
||||
case 'livekit.roomStateChanged':
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
liveKit: {...snapshot.liveKit, ...event.room, failure: null},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function onWriteRequested(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
options: VoiceEngineV2GatewayVoiceStateWrite,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onWriteRequested snapshot must not be null');
|
||||
assert.ok(options != null, 'onWriteRequested options must not be null');
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
gateway: {
|
||||
...allocated.snapshot.gateway,
|
||||
desiredVoiceStateWrite: options,
|
||||
operationId: allocated.operationId,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'gateway.voiceState.write',
|
||||
operationId: allocated.operationId,
|
||||
options,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function onWriteSucceeded(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onWriteSucceeded snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onWriteSucceeded operationId must be an integer');
|
||||
if (snapshot.gateway.operationId !== operationId) return {snapshot, commands: []};
|
||||
return planPendingConnectionTeardown({
|
||||
...markOperation(snapshot, operationId, 'succeeded'),
|
||||
gateway: {...snapshot.gateway, operationId: null, failure: null},
|
||||
});
|
||||
}
|
||||
|
||||
function onWriteFailed(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
error: VoiceEngineV2Error,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onWriteFailed snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onWriteFailed operationId must be an integer');
|
||||
assert.ok(error != null, 'onWriteFailed error must not be null');
|
||||
if (snapshot.gateway.operationId !== operationId) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {
|
||||
...markOperation(snapshot, operationId, 'failed', error),
|
||||
gateway: {...snapshot.gateway, desiredVoiceStateWrite: null, operationId: null, failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onClearSucceeded(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onClearSucceeded snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onClearSucceeded operationId must be an integer');
|
||||
if (snapshot.gateway.operationId !== operationId) return {snapshot, commands: []};
|
||||
const cleared: VoiceEngineV2Snapshot = {
|
||||
...markOperation(snapshot, operationId, 'succeeded'),
|
||||
gateway: {
|
||||
...snapshot.gateway,
|
||||
desiredVoiceStateWrite: null,
|
||||
selfVoiceState: null,
|
||||
operationId: null,
|
||||
failure: null,
|
||||
},
|
||||
};
|
||||
return planPendingConnectionTeardown(cleared);
|
||||
}
|
||||
|
||||
function onClearFailed(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
error: VoiceEngineV2Error,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onClearFailed snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onClearFailed operationId must be an integer');
|
||||
assert.ok(error != null, 'onClearFailed error must not be null');
|
||||
if (snapshot.gateway.operationId !== operationId) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {
|
||||
...markOperation(snapshot, operationId, 'failed', error),
|
||||
gateway: {...snapshot.gateway, operationId: null, failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onVoiceStateUpdated(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
voiceState: VoiceEngineV2GatewayVoiceState | null,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onVoiceStateUpdated snapshot must not be null');
|
||||
assert.ok(snapshot.gateway != null, 'onVoiceStateUpdated snapshot.gateway must not be null');
|
||||
const pending = snapshot.gateway.desiredVoiceStateWrite;
|
||||
const converged = voiceState != null && pending != null && gatewayVoiceStateWriteMatchesReported(pending, voiceState);
|
||||
if (converged) {
|
||||
assert.ok(pending != null, 'onVoiceStateUpdated converged requires a pending write');
|
||||
}
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
gateway: {
|
||||
...snapshot.gateway,
|
||||
selfVoiceState: voiceState,
|
||||
desiredVoiceStateWrite: converged ? null : pending,
|
||||
},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onDesiredVoiceStateChanged(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
desired: VoiceEngineV2GatewayDesiredVoiceState,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onDesiredVoiceStateChanged snapshot must not be null');
|
||||
assert.ok(desired != null, 'onDesiredVoiceStateChanged desired must not be null');
|
||||
assert.equal(typeof desired.selfMute, 'boolean', 'onDesiredVoiceStateChanged selfMute must be a boolean');
|
||||
return reconcileGatewayVoiceState({
|
||||
...snapshot,
|
||||
gateway: {...snapshot.gateway, desiredVoiceState: desired},
|
||||
});
|
||||
}
|
||||
|
||||
export function deriveVoiceEngineV2DesiredGatewayVoiceState(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
): VoiceEngineV2GatewayVoiceStateWrite | null {
|
||||
assert.ok(snapshot != null, 'deriveVoiceEngineV2DesiredGatewayVoiceState snapshot must not be null');
|
||||
assert.ok(snapshot.gateway != null, 'deriveVoiceEngineV2DesiredGatewayVoiceState snapshot.gateway must not be null');
|
||||
const desired = snapshot.gateway.desiredVoiceState;
|
||||
if (desired == null) return null;
|
||||
if (desired.channelId == null) return null;
|
||||
return {
|
||||
guildId: desired.guildId,
|
||||
channelId: desired.channelId,
|
||||
selfMute: desired.selfMute,
|
||||
selfDeaf: desired.selfDeaf,
|
||||
selfVideo: desired.selfVideo,
|
||||
selfStream: desired.selfStream,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldApplyGatewayVoiceStateEcho(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
echo: VoiceEngineV2GatewayVoiceState,
|
||||
): boolean {
|
||||
assert.ok(snapshot != null, 'shouldApplyGatewayVoiceStateEcho snapshot must not be null');
|
||||
assert.ok(echo != null, 'shouldApplyGatewayVoiceStateEcho echo must not be null');
|
||||
const pending = snapshot.gateway.desiredVoiceStateWrite;
|
||||
if (pending == null) return true;
|
||||
return gatewayVoiceStateWriteMatchesReported(pending, echo);
|
||||
}
|
||||
|
||||
export function gatewayVoiceStateWriteMatchesReported(
|
||||
write: VoiceEngineV2GatewayVoiceStateWrite,
|
||||
reported: VoiceEngineV2GatewayVoiceState,
|
||||
): boolean {
|
||||
assert.ok(write != null, 'gatewayVoiceStateWriteMatchesReported write must not be null');
|
||||
assert.ok(reported != null, 'gatewayVoiceStateWriteMatchesReported reported must not be null');
|
||||
if (write.selfMute !== reported.selfMute) return false;
|
||||
if (write.selfDeaf !== reported.selfDeaf) return false;
|
||||
if ((write.selfVideo ?? false) !== reported.selfVideo) return false;
|
||||
if ((write.selfStream ?? false) !== reported.selfStream) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function gatewayVoiceStateWriteValuesEqual(
|
||||
a: VoiceEngineV2GatewayVoiceStateWrite,
|
||||
b: VoiceEngineV2GatewayVoiceStateWrite,
|
||||
): boolean {
|
||||
assert.ok(a != null, 'gatewayVoiceStateWriteValuesEqual a must not be null');
|
||||
assert.ok(b != null, 'gatewayVoiceStateWriteValuesEqual b must not be null');
|
||||
if (a.guildId !== b.guildId) return false;
|
||||
if (a.channelId !== b.channelId) return false;
|
||||
if (a.selfMute !== b.selfMute) return false;
|
||||
if (a.selfDeaf !== b.selfDeaf) return false;
|
||||
if ((a.selfVideo ?? false) !== (b.selfVideo ?? false)) return false;
|
||||
if ((a.selfStream ?? false) !== (b.selfStream ?? false)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function reconcileGatewayVoiceState(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'reconcileGatewayVoiceState snapshot must not be null');
|
||||
assert.ok(snapshot.gateway != null, 'reconcileGatewayVoiceState snapshot.gateway must not be null');
|
||||
if (!isConnected(snapshot)) return {snapshot, commands: []};
|
||||
const desired = deriveVoiceEngineV2DesiredGatewayVoiceState(snapshot);
|
||||
if (desired == null) return {snapshot, commands: []};
|
||||
const reported = snapshot.gateway.selfVoiceState;
|
||||
if (reported != null && gatewayVoiceStateWriteMatchesReported(desired, reported)) return {snapshot, commands: []};
|
||||
if (snapshot.gateway.operationId != null) return {snapshot, commands: []};
|
||||
const pending = snapshot.gateway.desiredVoiceStateWrite;
|
||||
if (pending != null && gatewayVoiceStateWriteValuesEqual(pending, desired)) return {snapshot, commands: []};
|
||||
return onWriteRequested(snapshot, desired);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, recordFailure} from './_helpers';
|
||||
|
||||
type VoiceEngineV2ImplementationEvent = Extract<VoiceEngineV2Event, {type: `implementation.${string}`}>;
|
||||
|
||||
export function transitionImplementation(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2ImplementationEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionImplementation snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionImplementation event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'implementation event type must be a string');
|
||||
assert.ok(event.type.startsWith('implementation.'), 'implementation reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'implementation.prewarmRequested': {
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return {
|
||||
snapshot: allocated.snapshot,
|
||||
commands: [{type: 'implementation.prewarm', operationId: allocated.operationId}],
|
||||
};
|
||||
}
|
||||
case 'implementation.prewarmSucceeded':
|
||||
return {snapshot, commands: []};
|
||||
case 'implementation.prewarmFailed':
|
||||
return recordFailure(snapshot, event.error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {
|
||||
VoiceEngineV2InboundVideoFrame,
|
||||
VoiceEngineV2InboundVideoFrameStats,
|
||||
VoiceEngineV2InboundVideoTrackSubscription,
|
||||
} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
|
||||
type VoiceEngineV2InboundVideoEvent = Extract<VoiceEngineV2Event, {type: `inboundVideo.${string}`}>;
|
||||
|
||||
export function removeInboundVideoTrack(snapshot: VoiceEngineV2Snapshot, trackSid: string): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'removeInboundVideoTrack snapshot must not be null');
|
||||
assert.equal(typeof trackSid, 'string', 'removeInboundVideoTrack trackSid must be a string');
|
||||
if (!snapshot.inboundVideo.tracks[trackSid]) return snapshot;
|
||||
const tracks = {...snapshot.inboundVideo.tracks};
|
||||
delete tracks[trackSid];
|
||||
return {
|
||||
...snapshot,
|
||||
inboundVideo: {
|
||||
...snapshot.inboundVideo,
|
||||
tracks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function upsertInboundVideoTrack(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
track: VoiceEngineV2InboundVideoTrackSubscription,
|
||||
): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'upsertInboundVideoTrack snapshot must not be null');
|
||||
assert.ok(track != null, 'upsertInboundVideoTrack track must not be null');
|
||||
assert.equal(typeof track.trackSid, 'string', 'track.trackSid must be a string');
|
||||
const existing = snapshot.inboundVideo.tracks[track.trackSid];
|
||||
return {
|
||||
...snapshot,
|
||||
inboundVideo: {
|
||||
...snapshot.inboundVideo,
|
||||
tracks: {
|
||||
...snapshot.inboundVideo.tracks,
|
||||
[track.trackSid]: {
|
||||
participantSid: track.participantSid,
|
||||
...(track.participantIdentity ? {participantIdentity: track.participantIdentity} : {}),
|
||||
trackSid: track.trackSid,
|
||||
source: track.source,
|
||||
width: track.width ?? existing?.width ?? 0,
|
||||
height: track.height ?? existing?.height ?? 0,
|
||||
frameCount: existing?.frameCount ?? 0,
|
||||
lastFrameTimestampUs: existing?.lastFrameTimestampUs ?? null,
|
||||
lastFrameByteLength: existing?.lastFrameByteLength ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyInboundVideoFrame(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
frame: VoiceEngineV2InboundVideoFrame,
|
||||
): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'applyInboundVideoFrame snapshot must not be null');
|
||||
assert.ok(frame != null, 'applyInboundVideoFrame frame must not be null');
|
||||
assert.equal(typeof frame.trackSid, 'string', 'frame.trackSid must be a string');
|
||||
const track = snapshot.inboundVideo.tracks[frame.trackSid];
|
||||
if (!track) {
|
||||
return {
|
||||
...snapshot,
|
||||
inboundVideo: {
|
||||
...snapshot.inboundVideo,
|
||||
droppedFrameCount: snapshot.inboundVideo.droppedFrameCount + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
inboundVideo: {
|
||||
...snapshot.inboundVideo,
|
||||
tracks: {
|
||||
...snapshot.inboundVideo.tracks,
|
||||
[frame.trackSid]: {
|
||||
...track,
|
||||
participantSid: frame.participantSid,
|
||||
...(frame.participantIdentity ? {participantIdentity: frame.participantIdentity} : {}),
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
frameCount: track.frameCount + 1,
|
||||
lastFrameTimestampUs: frame.timestampUs,
|
||||
lastFrameByteLength: frame.byteLength ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyInboundVideoFrameStats(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
stats: VoiceEngineV2InboundVideoFrameStats,
|
||||
): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'applyInboundVideoFrameStats snapshot must not be null');
|
||||
assert.ok(stats != null, 'applyInboundVideoFrameStats stats must not be null');
|
||||
assert.equal(typeof stats.trackSid, 'string', 'stats.trackSid must be a string');
|
||||
assert.ok(Number.isInteger(stats.frameCount), 'stats.frameCount must be an integer');
|
||||
assert.ok(stats.frameCount >= 0, 'stats.frameCount must be non-negative');
|
||||
const track = snapshot.inboundVideo.tracks[stats.trackSid];
|
||||
if (!track) {
|
||||
return {
|
||||
...snapshot,
|
||||
inboundVideo: {
|
||||
...snapshot.inboundVideo,
|
||||
droppedFrameCount: snapshot.inboundVideo.droppedFrameCount + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
inboundVideo: {
|
||||
...snapshot.inboundVideo,
|
||||
tracks: {
|
||||
...snapshot.inboundVideo.tracks,
|
||||
[stats.trackSid]: {
|
||||
...track,
|
||||
participantSid: stats.participantSid,
|
||||
...(stats.participantIdentity ? {participantIdentity: stats.participantIdentity} : {}),
|
||||
width: stats.width,
|
||||
height: stats.height,
|
||||
frameCount: stats.frameCount,
|
||||
lastFrameTimestampUs: stats.lastFrameTimestampUs,
|
||||
lastFrameByteLength: stats.lastFrameByteLength,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionInboundVideo(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2InboundVideoEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionInboundVideo snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionInboundVideo event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'inboundVideo event type must be a string');
|
||||
assert.ok(event.type.startsWith('inboundVideo.'), 'inboundVideo reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'inboundVideo.trackSubscribed':
|
||||
return {snapshot: upsertInboundVideoTrack(snapshot, event.track), commands: []};
|
||||
case 'inboundVideo.trackUnsubscribed':
|
||||
return {snapshot: removeInboundVideoTrack(snapshot, event.trackSid), commands: []};
|
||||
case 'inboundVideo.frameReceived':
|
||||
return {snapshot: applyInboundVideoFrame(snapshot, event.frame), commands: []};
|
||||
case 'inboundVideo.frameStats':
|
||||
return {snapshot: applyInboundVideoFrameStats(snapshot, event.stats), commands: []};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {markOperation} from './_helpers';
|
||||
import {resetPublishedMedia} from './_media';
|
||||
import {planPendingConnectionTeardown, resetAfterDisconnect} from './connection';
|
||||
|
||||
type VoiceEngineV2LifecycleEvent = Extract<VoiceEngineV2Event, {type: `lifecycle.${string}`}>;
|
||||
|
||||
export function transitionLifecycle(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2LifecycleEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionLifecycle snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionLifecycle event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'lifecycle event type must be a string');
|
||||
assert.ok(event.type.startsWith('lifecycle.'), 'lifecycle reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'lifecycle.teardownRequested':
|
||||
return planPendingConnectionTeardown({
|
||||
...resetPublishedMedia(snapshot),
|
||||
connection: {
|
||||
...snapshot.connection,
|
||||
desired: null,
|
||||
disconnectReason: 'shutdown',
|
||||
failure: null,
|
||||
},
|
||||
lifecycle: {
|
||||
tearingDown: true,
|
||||
reason: event.reason,
|
||||
operationId: null,
|
||||
failure: null,
|
||||
},
|
||||
});
|
||||
case 'lifecycle.teardownSucceeded':
|
||||
if (snapshot.lifecycle.operationId !== event.operationId) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {
|
||||
...markOperation(resetAfterDisconnect(snapshot), event.operationId, 'succeeded'),
|
||||
lifecycle: {tearingDown: false, reason: null, operationId: null, failure: null},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
case 'lifecycle.teardownFailed':
|
||||
if (snapshot.lifecycle.operationId !== event.operationId) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {
|
||||
...markOperation(snapshot, event.operationId, 'failed', event.error),
|
||||
lifecycle: {...snapshot.lifecycle, tearingDown: true, operationId: null, failure: event.error},
|
||||
lastFailure: event.error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
getVoiceEngineV2MicrophoneOperationFailureAction,
|
||||
type VoiceEngineV2MicrophoneOperationFailureAction,
|
||||
} from '../../policies/microphoneFailureAction';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {
|
||||
VoiceEngineV2AudioControls,
|
||||
VoiceEngineV2Error,
|
||||
VoiceEngineV2MicrophoneOptions,
|
||||
VoiceEngineV2OperationId,
|
||||
VoiceEngineV2PermissionResult,
|
||||
} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {
|
||||
allocateOperation,
|
||||
beginUnpublish,
|
||||
failUnpublish,
|
||||
isConnected,
|
||||
markOperation,
|
||||
unsupportedCapability,
|
||||
} from './_helpers';
|
||||
import {applyMediaFailure, completeUnpublish} from './_media';
|
||||
|
||||
type VoiceEngineV2MicrophoneEvent = Extract<
|
||||
VoiceEngineV2Event,
|
||||
{
|
||||
type: `microphone.${string}` | 'localAudio.muteRequested' | 'localAudio.deafenRequested' | 'audioControls.changed';
|
||||
}
|
||||
>;
|
||||
|
||||
function sameMicrophoneOptions(
|
||||
a: VoiceEngineV2MicrophoneOptions | null,
|
||||
b: VoiceEngineV2MicrophoneOptions | null,
|
||||
): boolean {
|
||||
assert.ok(a !== undefined, 'sameMicrophoneOptions a must not be undefined');
|
||||
assert.ok(b !== undefined, 'sameMicrophoneOptions b must not be undefined');
|
||||
return (
|
||||
a?.deviceId === b?.deviceId &&
|
||||
a?.echoCancellation === b?.echoCancellation &&
|
||||
a?.noiseSuppression === b?.noiseSuppression &&
|
||||
a?.autoGainControl === b?.autoGainControl &&
|
||||
a?.deepFilter === b?.deepFilter &&
|
||||
a?.deepFilterNoiseReductionLevel === b?.deepFilterNoiseReductionLevel &&
|
||||
a?.maxBitrateBps === b?.maxBitrateBps
|
||||
);
|
||||
}
|
||||
|
||||
function isMicrophonePublished(snapshot: VoiceEngineV2Snapshot): boolean {
|
||||
assert.ok(snapshot != null, 'isMicrophonePublished snapshot must not be null');
|
||||
assert.ok(snapshot.microphone != null, 'isMicrophonePublished snapshot.microphone must not be null');
|
||||
return snapshot.microphone.status === 'published' && snapshot.microphone.published != null;
|
||||
}
|
||||
|
||||
function computeEffectiveMicrophoneEnabled(audioControls: VoiceEngineV2AudioControls): boolean {
|
||||
assert.ok(audioControls != null, 'computeEffectiveMicrophoneEnabled audioControls must not be null');
|
||||
assert.equal(typeof audioControls.mode, 'string', 'audioControls.mode must be a string');
|
||||
if (audioControls.locallyMuted || audioControls.locallyDeafened || audioControls.mutedByPermission) return false;
|
||||
if (audioControls.mode === 'pushToTalk') return audioControls.pushToTalkActive;
|
||||
if (audioControls.mode === 'pushToMute') return !audioControls.pushToMuteActive;
|
||||
return true;
|
||||
}
|
||||
|
||||
function computeLocalSpeakingOverride(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
effectiveMicrophoneEnabled: boolean,
|
||||
): boolean | null {
|
||||
assert.ok(snapshot != null, 'computeLocalSpeakingOverride snapshot must not be null');
|
||||
assert.equal(typeof effectiveMicrophoneEnabled, 'boolean', 'effectiveMicrophoneEnabled must be a boolean');
|
||||
const hasPublication = isMicrophonePublished(snapshot);
|
||||
const effectivelyMuted =
|
||||
!hasPublication ||
|
||||
!effectiveMicrophoneEnabled ||
|
||||
snapshot.audioControls.locallyMuted ||
|
||||
snapshot.audioControls.locallyDeafened ||
|
||||
snapshot.audioControls.mutedByPermission;
|
||||
if (snapshot.audioControls.mode === 'pushToTalk') return !effectivelyMuted;
|
||||
if (snapshot.audioControls.mode === 'pushToMute' && snapshot.audioControls.pushToMuteActive) return false;
|
||||
if (effectivelyMuted) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function refreshMicrophoneDerivedState(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'refreshMicrophoneDerivedState snapshot must not be null');
|
||||
assert.ok(snapshot.audioControls != null, 'refreshMicrophoneDerivedState snapshot.audioControls must not be null');
|
||||
const enabled = computeEffectiveMicrophoneEnabled(snapshot.audioControls);
|
||||
return {
|
||||
...snapshot,
|
||||
microphone: {
|
||||
...snapshot.microphone,
|
||||
enabled,
|
||||
localSpeakingOverride: computeLocalSpeakingOverride(snapshot, enabled),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function planMicrophoneEnabled(snapshot: VoiceEngineV2Snapshot, forceCommand = false): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'planMicrophoneEnabled snapshot must not be null');
|
||||
assert.equal(typeof forceCommand, 'boolean', 'planMicrophoneEnabled forceCommand must be a boolean');
|
||||
const enabled = computeEffectiveMicrophoneEnabled(snapshot.audioControls);
|
||||
const previousEnabled = snapshot.microphone.enabled;
|
||||
const refreshed = refreshMicrophoneDerivedState(snapshot);
|
||||
if (!isConnected(refreshed) || !isMicrophonePublished(refreshed) || !refreshed.capabilities.microphone) {
|
||||
return {snapshot: refreshed, commands: []};
|
||||
}
|
||||
if (!forceCommand && previousEnabled === enabled) return {snapshot: refreshed, commands: []};
|
||||
if (forceCommand && enabled) return {snapshot: refreshed, commands: []};
|
||||
const allocated = allocateOperation(refreshed);
|
||||
return {
|
||||
snapshot: {
|
||||
...allocated.snapshot,
|
||||
microphone: {
|
||||
...allocated.snapshot.microphone,
|
||||
setEnabledOperationId: allocated.operationId,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
commands: [{type: 'microphone.setEnabled', operationId: allocated.operationId, enabled}],
|
||||
};
|
||||
}
|
||||
|
||||
function setLocalAudioMute(snapshot: VoiceEngineV2Snapshot, muted: boolean): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'setLocalAudioMute snapshot must not be null');
|
||||
assert.equal(typeof muted, 'boolean', 'setLocalAudioMute muted must be a boolean');
|
||||
const current = snapshot.audioControls;
|
||||
const controls: VoiceEngineV2AudioControls = {
|
||||
...current,
|
||||
preferredLocallyMuted: muted,
|
||||
hasUserSetMute: true,
|
||||
locallyMuted: current.mutedByPermission ? true : muted,
|
||||
locallyDeafened: current.locallyDeafened && muted ? current.locallyDeafened : false,
|
||||
hasUserSetDeaf: current.locallyDeafened && !muted ? true : current.hasUserSetDeaf,
|
||||
shouldUnmuteOnUndeafen: current.locallyDeafened && !muted ? false : current.shouldUnmuteOnUndeafen,
|
||||
};
|
||||
return planMicrophoneEnabled({...snapshot, audioControls: controls});
|
||||
}
|
||||
|
||||
function setLocalAudioDeafen(snapshot: VoiceEngineV2Snapshot, deafened: boolean): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'setLocalAudioDeafen snapshot must not be null');
|
||||
assert.equal(typeof deafened, 'boolean', 'setLocalAudioDeafen deafened must be a boolean');
|
||||
const current = snapshot.audioControls;
|
||||
const controls: VoiceEngineV2AudioControls = deafened
|
||||
? {
|
||||
...current,
|
||||
locallyMuted: true,
|
||||
locallyDeafened: true,
|
||||
hasUserSetDeaf: true,
|
||||
shouldUnmuteOnUndeafen: !current.locallyMuted && !current.mutedByPermission,
|
||||
}
|
||||
: {
|
||||
...current,
|
||||
locallyMuted: current.shouldUnmuteOnUndeafen && !current.mutedByPermission ? false : current.locallyMuted,
|
||||
preferredLocallyMuted:
|
||||
current.shouldUnmuteOnUndeafen && !current.mutedByPermission ? false : current.preferredLocallyMuted,
|
||||
locallyDeafened: false,
|
||||
hasUserSetDeaf: true,
|
||||
shouldUnmuteOnUndeafen: false,
|
||||
};
|
||||
return planMicrophoneEnabled({...snapshot, audioControls: controls});
|
||||
}
|
||||
|
||||
export function applyMicrophonePermissionResult(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
result: VoiceEngineV2PermissionResult,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'applyMicrophonePermissionResult snapshot must not be null');
|
||||
assert.ok(result != null, 'applyMicrophonePermissionResult result must not be null');
|
||||
if (result.name !== 'microphone') return {snapshot, commands: []};
|
||||
if (result.status === 'denied' || result.status === 'restricted') {
|
||||
return planMicrophoneEnabled({
|
||||
...snapshot,
|
||||
audioControls: {
|
||||
...snapshot.audioControls,
|
||||
locallyMuted: true,
|
||||
mutedByPermission: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (result.status !== 'granted') return {snapshot: refreshMicrophoneDerivedState(snapshot), commands: []};
|
||||
const locallyMuted = snapshot.audioControls.hasUserSetMute ? snapshot.audioControls.preferredLocallyMuted : false;
|
||||
return planMicrophoneEnabled({
|
||||
...snapshot,
|
||||
audioControls: {
|
||||
...snapshot.audioControls,
|
||||
locallyMuted,
|
||||
mutedByPermission: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function microphoneOptionsWithAudioInputDevice(
|
||||
options: VoiceEngineV2MicrophoneOptions,
|
||||
deviceId: string | null,
|
||||
): VoiceEngineV2MicrophoneOptions {
|
||||
assert.ok(options != null, 'microphoneOptionsWithAudioInputDevice options must not be null');
|
||||
assert.ok(deviceId === null || typeof deviceId === 'string', 'deviceId must be string or null');
|
||||
if (deviceId == null) {
|
||||
const {deviceId: _deviceId, ...rest} = options;
|
||||
return rest;
|
||||
}
|
||||
return {...options, deviceId};
|
||||
}
|
||||
|
||||
function nativeAudioDeviceModuleNotReadyError(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Error {
|
||||
assert.ok(snapshot != null, 'nativeAudioDeviceModuleNotReadyError snapshot must not be null');
|
||||
assert.ok(
|
||||
snapshot.nativeAudioDeviceModule.status === 'failed',
|
||||
'nativeAudioDeviceModuleNotReadyError requires failed ADM status',
|
||||
);
|
||||
return {
|
||||
code: 'deviceUnavailable',
|
||||
capability: 'microphone',
|
||||
message: snapshot.nativeAudioDeviceModule.detail ?? 'Native audio device module failed to become ready',
|
||||
};
|
||||
}
|
||||
|
||||
function planNativeAudioDeviceModuleGatedMicrophone(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition | null {
|
||||
assert.ok(snapshot != null, 'planNativeAudioDeviceModuleGatedMicrophone snapshot must not be null');
|
||||
assert.ok(snapshot.nativeAudioDeviceModule != null, 'snapshot.nativeAudioDeviceModule must not be null');
|
||||
switch (snapshot.nativeAudioDeviceModule.status) {
|
||||
case 'unsupported':
|
||||
case 'ready':
|
||||
return null;
|
||||
case 'unknown':
|
||||
case 'warming':
|
||||
return {
|
||||
snapshot: {...snapshot, microphone: {...snapshot.microphone, status: 'idle', failure: null}},
|
||||
commands: [],
|
||||
};
|
||||
case 'failed': {
|
||||
const error = nativeAudioDeviceModuleNotReadyError(snapshot);
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
microphone: {...snapshot.microphone, status: 'failed', published: null, operationId: null, failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function planMicrophoneDeviceChange(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
deviceId: string | null,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'planMicrophoneDeviceChange snapshot must not be null');
|
||||
assert.ok(deviceId === null || typeof deviceId === 'string', 'deviceId must be string or null');
|
||||
const current = snapshot.microphone.desired ?? snapshot.microphone.published;
|
||||
if (!current) return {snapshot, commands: []};
|
||||
const desired = microphoneOptionsWithAudioInputDevice(current, deviceId);
|
||||
if (sameMicrophoneOptions(snapshot.microphone.desired, desired)) return {snapshot, commands: []};
|
||||
return beginMicrophonePublish({
|
||||
...snapshot,
|
||||
microphone: {
|
||||
...snapshot.microphone,
|
||||
desired,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isMicrophoneFailureDuringReconnect(snapshot: VoiceEngineV2Snapshot): boolean {
|
||||
assert.ok(snapshot != null, 'isMicrophoneFailureDuringReconnect snapshot must not be null');
|
||||
assert.ok(snapshot.connection != null, 'isMicrophoneFailureDuringReconnect snapshot.connection must not be null');
|
||||
if (snapshot.connection.status === 'reconnecting') return true;
|
||||
return snapshot.liveKit.connectionState === 'reconnecting';
|
||||
}
|
||||
|
||||
function microphoneFailureAction(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
error: VoiceEngineV2Error,
|
||||
requestedEnabled: boolean,
|
||||
): VoiceEngineV2MicrophoneOperationFailureAction {
|
||||
assert.ok(error != null, 'microphoneFailureAction error must not be null');
|
||||
assert.equal(typeof requestedEnabled, 'boolean', 'requestedEnabled must be a boolean');
|
||||
return getVoiceEngineV2MicrophoneOperationFailureAction(
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
capability: typeof error.capability === 'string' ? error.capability : undefined,
|
||||
},
|
||||
},
|
||||
requestedEnabled,
|
||||
{reconnecting: isMicrophoneFailureDuringReconnect(snapshot)},
|
||||
);
|
||||
}
|
||||
|
||||
function applyMicrophoneFailureRecovery(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
error: VoiceEngineV2Error,
|
||||
requestedEnabled: boolean,
|
||||
): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'applyMicrophoneFailureRecovery snapshot must not be null');
|
||||
assert.ok(error != null, 'applyMicrophoneFailureRecovery error must not be null');
|
||||
const action = microphoneFailureAction(snapshot, error, requestedEnabled);
|
||||
if (action !== 'mute') return snapshot;
|
||||
return refreshMicrophoneDerivedState({
|
||||
...snapshot,
|
||||
audioControls: {
|
||||
...snapshot.audioControls,
|
||||
locallyMuted: true,
|
||||
preferredLocallyMuted: true,
|
||||
hasUserSetMute: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function beginMicrophonePublish(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'beginMicrophonePublish snapshot must not be null');
|
||||
assert.ok(snapshot.microphone != null, 'beginMicrophonePublish snapshot.microphone must not be null');
|
||||
const desired = snapshot.microphone.desired;
|
||||
if (!desired) return {snapshot, commands: []};
|
||||
if (!snapshot.capabilities.microphone) {
|
||||
const error = unsupportedCapability('microphone');
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
microphone: {...snapshot.microphone, status: 'failed', failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
if (!isConnected(snapshot)) return {snapshot, commands: []};
|
||||
const gated = planNativeAudioDeviceModuleGatedMicrophone(snapshot);
|
||||
if (gated) return gated;
|
||||
if (snapshot.microphone.status === 'publishing') return {snapshot, commands: []};
|
||||
if (snapshot.microphone.status === 'published' && sameMicrophoneOptions(snapshot.microphone.published, desired)) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return {
|
||||
snapshot: {
|
||||
...allocated.snapshot,
|
||||
microphone: {
|
||||
...allocated.snapshot.microphone,
|
||||
status: 'publishing',
|
||||
operationId: allocated.operationId,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
commands: [{type: 'microphone.publish', operationId: allocated.operationId, options: desired}],
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionMicrophone(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2MicrophoneEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionMicrophone snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionMicrophone event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'microphone event type must be a string');
|
||||
assert.ok(
|
||||
event.type.startsWith('microphone.') ||
|
||||
event.type === 'localAudio.muteRequested' ||
|
||||
event.type === 'localAudio.deafenRequested' ||
|
||||
event.type === 'audioControls.changed',
|
||||
'microphone reducer received unrelated event',
|
||||
);
|
||||
switch (event.type) {
|
||||
case 'microphone.publishRequested':
|
||||
return beginMicrophonePublish({
|
||||
...snapshot,
|
||||
microphone: {...snapshot.microphone, desired: event.options},
|
||||
});
|
||||
case 'microphone.publishSucceeded':
|
||||
return onPublishSucceeded(snapshot, event.operationId);
|
||||
case 'microphone.publishFailed':
|
||||
return onPublishFailed(snapshot, event.operationId, event.error);
|
||||
case 'microphone.unpublishRequested':
|
||||
return beginUnpublish(snapshot, 'microphone', 'microphone.unpublish');
|
||||
case 'microphone.unpublishSucceeded':
|
||||
return onUnpublishSucceeded(snapshot, event.operationId);
|
||||
case 'microphone.unpublishFailed':
|
||||
return failUnpublish(snapshot, event.operationId, event.error, 'microphone');
|
||||
case 'microphone.setEnabledRequested':
|
||||
return onSetEnabledRequested(snapshot, event.enabled);
|
||||
case 'microphone.setEnabledSucceeded':
|
||||
return onSetEnabledSucceeded(snapshot, event.operationId);
|
||||
case 'microphone.setEnabledFailed':
|
||||
return onSetEnabledFailed(snapshot, event.operationId, event.error);
|
||||
case 'localAudio.muteRequested':
|
||||
return setLocalAudioMute(snapshot, event.muted);
|
||||
case 'localAudio.deafenRequested':
|
||||
return setLocalAudioDeafen(snapshot, event.deafened);
|
||||
case 'audioControls.changed':
|
||||
return planMicrophoneEnabled({
|
||||
...snapshot,
|
||||
audioControls: {...snapshot.audioControls, ...event.controls},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onPublishSucceeded(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onPublishSucceeded snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onPublishSucceeded operationId must be an integer');
|
||||
if (snapshot.microphone.operationId !== operationId) return {snapshot, commands: []};
|
||||
return planMicrophoneEnabled(
|
||||
{
|
||||
...markOperation(snapshot, operationId, 'succeeded'),
|
||||
microphone: {
|
||||
...snapshot.microphone,
|
||||
status: snapshot.microphone.desired ? 'published' : 'idle',
|
||||
published: snapshot.microphone.desired,
|
||||
operationId: null,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function onPublishFailed(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
error: VoiceEngineV2Error,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onPublishFailed snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onPublishFailed operationId must be an integer');
|
||||
assert.ok(error != null, 'onPublishFailed error must not be null');
|
||||
if (snapshot.microphone.operationId !== operationId) return {snapshot, commands: []};
|
||||
const failed = {
|
||||
...markOperation(snapshot, operationId, 'failed', error),
|
||||
microphone: {
|
||||
...applyMediaFailure(snapshot.microphone, operationId, error),
|
||||
enabled: snapshot.microphone.enabled,
|
||||
localSpeakingOverride: snapshot.microphone.localSpeakingOverride,
|
||||
setEnabledOperationId: snapshot.microphone.setEnabledOperationId,
|
||||
},
|
||||
lastFailure: error,
|
||||
};
|
||||
return {
|
||||
snapshot: applyMicrophoneFailureRecovery(failed, error, snapshot.microphone.enabled),
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onUnpublishSucceeded(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onUnpublishSucceeded snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onUnpublishSucceeded operationId must be an integer');
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
microphone: {
|
||||
...completeUnpublish(snapshot.microphone, operationId),
|
||||
enabled: snapshot.microphone.enabled,
|
||||
localSpeakingOverride: false,
|
||||
setEnabledOperationId: snapshot.microphone.setEnabledOperationId,
|
||||
},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onSetEnabledRequested(snapshot: VoiceEngineV2Snapshot, enabled: boolean): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onSetEnabledRequested snapshot must not be null');
|
||||
assert.equal(typeof enabled, 'boolean', 'onSetEnabledRequested enabled must be a boolean');
|
||||
const base = {
|
||||
...snapshot,
|
||||
microphone: {
|
||||
...snapshot.microphone,
|
||||
enabled,
|
||||
},
|
||||
};
|
||||
if (!isConnected(base) || !base.capabilities.microphone) return {snapshot: base, commands: []};
|
||||
if (!isMicrophonePublished(base) && base.microphone.status !== 'publishing') return {snapshot: base, commands: []};
|
||||
const allocated = allocateOperation(base);
|
||||
return {
|
||||
snapshot: {
|
||||
...allocated.snapshot,
|
||||
microphone: {
|
||||
...allocated.snapshot.microphone,
|
||||
setEnabledOperationId: allocated.operationId,
|
||||
},
|
||||
},
|
||||
commands: [{type: 'microphone.setEnabled', operationId: allocated.operationId, enabled}],
|
||||
};
|
||||
}
|
||||
|
||||
function onSetEnabledSucceeded(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onSetEnabledSucceeded snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onSetEnabledSucceeded operationId must be an integer');
|
||||
if (snapshot.microphone.setEnabledOperationId !== operationId) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {
|
||||
...markOperation(snapshot, operationId, 'succeeded'),
|
||||
microphone: {...snapshot.microphone, setEnabledOperationId: null, failure: null},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onSetEnabledFailed(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
error: VoiceEngineV2Error,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onSetEnabledFailed snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onSetEnabledFailed operationId must be an integer');
|
||||
assert.ok(error != null, 'onSetEnabledFailed error must not be null');
|
||||
if (snapshot.microphone.setEnabledOperationId !== operationId) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: applyMicrophoneFailureRecovery(
|
||||
{
|
||||
...markOperation(snapshot, operationId, 'failed', error),
|
||||
microphone: {...snapshot.microphone, setEnabledOperationId: null, failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
error,
|
||||
snapshot.microphone.enabled,
|
||||
),
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {beginMicrophonePublish} from './microphone';
|
||||
|
||||
type VoiceEngineV2NativeAudioDeviceModuleEvent = Extract<
|
||||
VoiceEngineV2Event,
|
||||
{type: 'nativeAudioDeviceModule.statusChanged'}
|
||||
>;
|
||||
|
||||
export function transitionNativeAudioDeviceModule(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2NativeAudioDeviceModuleEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionNativeAudioDeviceModule snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionNativeAudioDeviceModule event must not be null');
|
||||
assert.equal(event.type, 'nativeAudioDeviceModule.statusChanged', 'unexpected ADM event type');
|
||||
assert.equal(typeof event.status, 'string', 'ADM status must be a string');
|
||||
const next: VoiceEngineV2Snapshot = {
|
||||
...snapshot,
|
||||
nativeAudioDeviceModule: {
|
||||
status: event.status,
|
||||
detail: event.detail ?? null,
|
||||
},
|
||||
};
|
||||
return beginMicrophonePublish(next);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, queueCommand} from './_helpers';
|
||||
|
||||
type VoiceEngineV2NativeAudioTapEvent = Extract<VoiceEngineV2Event, {type: `nativeAudioTap.${string}`}>;
|
||||
|
||||
export function transitionNativeAudioTap(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2NativeAudioTapEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionNativeAudioTap snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionNativeAudioTap event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'nativeAudioTap event type must be a string');
|
||||
assert.ok(event.type.startsWith('nativeAudioTap.'), 'nativeAudioTap reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'nativeAudioTap.startRequested': {
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
nativeAudioTap: {
|
||||
...allocated.snapshot.nativeAudioTap,
|
||||
taps: {...allocated.snapshot.nativeAudioTap.taps, [event.options.tapId]: event.options},
|
||||
operationIds: {
|
||||
...allocated.snapshot.nativeAudioTap.operationIds,
|
||||
[event.options.tapId]: allocated.operationId,
|
||||
},
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
{type: 'nativeAudioTap.start', operationId: allocated.operationId, options: event.options},
|
||||
);
|
||||
}
|
||||
case 'nativeAudioTap.stopRequested': {
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
nativeAudioTap: {
|
||||
...allocated.snapshot.nativeAudioTap,
|
||||
operationIds: {
|
||||
...allocated.snapshot.nativeAudioTap.operationIds,
|
||||
[event.tapId]: allocated.operationId,
|
||||
},
|
||||
},
|
||||
},
|
||||
{type: 'nativeAudioTap.stop', operationId: allocated.operationId, tapId: event.tapId},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Command} from '../../protocol/commands';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {
|
||||
VoiceEngineV2Error,
|
||||
VoiceEngineV2NativeCaptureOptions,
|
||||
VoiceEngineV2OperationId,
|
||||
} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, markOperation, queueCommand} from './_helpers';
|
||||
import {nativeZeroCopyRequiredError, unavailableZeroCopyTransportError} from './_zeroCopy';
|
||||
|
||||
type VoiceEngineV2NativeCaptureEvent = Extract<VoiceEngineV2Event, {type: `nativeCapture.${string}`}>;
|
||||
|
||||
function failWithZeroCopyRequired(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
resource: 'capture' | 'frame',
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'failWithZeroCopyRequired snapshot must not be null');
|
||||
assert.equal(typeof resource, 'string', 'failWithZeroCopyRequired resource must be a string');
|
||||
const error = nativeZeroCopyRequiredError(resource);
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
nativeCapture: {...snapshot.nativeCapture, failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function failWithUnavailableTransport(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'failWithUnavailableTransport snapshot must not be null');
|
||||
assert.ok(snapshot.nativeCapture != null, 'failWithUnavailableTransport snapshot.nativeCapture must not be null');
|
||||
const error = unavailableZeroCopyTransportError('capture');
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
nativeCapture: {...snapshot.nativeCapture, failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onStartOrUpdate(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
options: VoiceEngineV2NativeCaptureOptions,
|
||||
commandType: 'nativeCapture.start' | 'nativeCapture.update',
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onStartOrUpdate snapshot must not be null');
|
||||
assert.ok(options != null, 'onStartOrUpdate options must not be null');
|
||||
assert.equal(typeof options.captureId, 'string', 'options.captureId must be a string');
|
||||
if (options.zeroCopyRequired !== true) return failWithZeroCopyRequired(snapshot, 'capture');
|
||||
if (!snapshot.capabilities.zeroCopyScreenTransport) return failWithUnavailableTransport(snapshot);
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
nativeCapture: {
|
||||
...allocated.snapshot.nativeCapture,
|
||||
captures: {
|
||||
...allocated.snapshot.nativeCapture.captures,
|
||||
[options.captureId]: options,
|
||||
},
|
||||
operationIds: {
|
||||
...allocated.snapshot.nativeCapture.operationIds,
|
||||
[options.captureId]: allocated.operationId,
|
||||
},
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
{type: commandType, operationId: allocated.operationId, options} as VoiceEngineV2Command,
|
||||
);
|
||||
}
|
||||
|
||||
function onStopRequested(snapshot: VoiceEngineV2Snapshot, captureId: string): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onStopRequested snapshot must not be null');
|
||||
assert.equal(typeof captureId, 'string', 'onStopRequested captureId must be a string');
|
||||
assert.ok(captureId.length > 0, 'onStopRequested captureId must not be empty');
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
nativeCapture: {
|
||||
...allocated.snapshot.nativeCapture,
|
||||
operationIds: {
|
||||
...allocated.snapshot.nativeCapture.operationIds,
|
||||
[captureId]: allocated.operationId,
|
||||
},
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
{type: 'nativeCapture.stop', operationId: allocated.operationId, captureId},
|
||||
);
|
||||
}
|
||||
|
||||
function completeNativeCaptureOperation(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
captureId: string,
|
||||
operationId: VoiceEngineV2OperationId | null,
|
||||
error: VoiceEngineV2Error | null,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'completeNativeCaptureOperation snapshot must not be null');
|
||||
assert.equal(typeof captureId, 'string', 'completeNativeCaptureOperation captureId must be a string');
|
||||
if (operationId != null && snapshot.nativeCapture.operationIds[captureId] !== operationId) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
const operationIds = {...snapshot.nativeCapture.operationIds};
|
||||
if (operationId != null) delete operationIds[captureId];
|
||||
return {
|
||||
snapshot: {
|
||||
...(operationId != null ? markOperation(snapshot, operationId, error ? 'failed' : 'succeeded', error) : snapshot),
|
||||
nativeCapture: {
|
||||
...snapshot.nativeCapture,
|
||||
operationIds,
|
||||
failure: error,
|
||||
},
|
||||
lastFailure: error ?? snapshot.lastFailure,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function stopNativeCapture(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
captureId: string,
|
||||
operationId: VoiceEngineV2OperationId | null,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'stopNativeCapture snapshot must not be null');
|
||||
assert.equal(typeof captureId, 'string', 'stopNativeCapture captureId must be a string');
|
||||
if (operationId != null && snapshot.nativeCapture.operationIds[captureId] !== operationId) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
const captures = {...snapshot.nativeCapture.captures};
|
||||
const operationIds = {...snapshot.nativeCapture.operationIds};
|
||||
delete captures[captureId];
|
||||
delete operationIds[captureId];
|
||||
return {
|
||||
snapshot: {
|
||||
...(operationId != null ? markOperation(snapshot, operationId, 'succeeded') : snapshot),
|
||||
nativeCapture: {
|
||||
...snapshot.nativeCapture,
|
||||
captures,
|
||||
operationIds,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionNativeCapture(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2NativeCaptureEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionNativeCapture snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionNativeCapture event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'nativeCapture event type must be a string');
|
||||
assert.ok(event.type.startsWith('nativeCapture.'), 'nativeCapture reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'nativeCapture.startRequested':
|
||||
return onStartOrUpdate(snapshot, event.options, 'nativeCapture.start');
|
||||
case 'nativeCapture.updateRequested':
|
||||
return onStartOrUpdate(snapshot, event.options, 'nativeCapture.update');
|
||||
case 'nativeCapture.stopRequested':
|
||||
return onStopRequested(snapshot, event.captureId);
|
||||
case 'nativeCapture.started':
|
||||
return completeNativeCaptureOperation(snapshot, event.captureId, event.operationId, null);
|
||||
case 'nativeCapture.stopped':
|
||||
return stopNativeCapture(snapshot, event.captureId, event.operationId);
|
||||
case 'nativeCapture.failed':
|
||||
return completeNativeCaptureOperation(snapshot, event.captureId, event.operationId, event.error);
|
||||
case 'nativeCapture.frame':
|
||||
if (event.frame.zeroCopy !== true) return failWithZeroCopyRequired(snapshot, 'frame');
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2NativeFrameSinkOptions} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, queueCommand} from './_helpers';
|
||||
import {nativeZeroCopyRequiredError, unavailableZeroCopyTransportError} from './_zeroCopy';
|
||||
|
||||
type VoiceEngineV2NativeFrameSinkEvent = Extract<VoiceEngineV2Event, {type: `nativeFrameSink.${string}`}>;
|
||||
|
||||
export function transitionNativeFrameSink(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2NativeFrameSinkEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionNativeFrameSink snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionNativeFrameSink event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'nativeFrameSink event type must be a string');
|
||||
assert.ok(event.type.startsWith('nativeFrameSink.'), 'nativeFrameSink reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'nativeFrameSink.attachRequested':
|
||||
return onAttachRequested(snapshot, event.options);
|
||||
case 'nativeFrameSink.detachRequested':
|
||||
return onDetachRequested(snapshot, event.sinkId);
|
||||
}
|
||||
}
|
||||
|
||||
function onAttachRequested(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
options: VoiceEngineV2NativeFrameSinkOptions,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onAttachRequested snapshot must not be null');
|
||||
assert.ok(options != null, 'onAttachRequested options must not be null');
|
||||
assert.equal(typeof options.sinkId, 'string', 'options.sinkId must be a string');
|
||||
if (options.zeroCopyRequired !== true) {
|
||||
const error = nativeZeroCopyRequiredError('frameSink');
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
nativeFrameSink: {...snapshot.nativeFrameSink, failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
if (!snapshot.capabilities.zeroCopyScreenTransport) {
|
||||
const error = unavailableZeroCopyTransportError('frameSink');
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
nativeFrameSink: {...snapshot.nativeFrameSink, failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
nativeFrameSink: {
|
||||
...allocated.snapshot.nativeFrameSink,
|
||||
sinks: {...allocated.snapshot.nativeFrameSink.sinks, [options.sinkId]: options},
|
||||
operationIds: {
|
||||
...allocated.snapshot.nativeFrameSink.operationIds,
|
||||
[options.sinkId]: allocated.operationId,
|
||||
},
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
{type: 'nativeFrameSink.attach', operationId: allocated.operationId, options},
|
||||
);
|
||||
}
|
||||
|
||||
function onDetachRequested(snapshot: VoiceEngineV2Snapshot, sinkId: string): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onDetachRequested snapshot must not be null');
|
||||
assert.equal(typeof sinkId, 'string', 'onDetachRequested sinkId must be a string');
|
||||
assert.ok(sinkId.length > 0, 'onDetachRequested sinkId must not be empty');
|
||||
const allocated = allocateOperation(snapshot);
|
||||
const sinks = {...allocated.snapshot.nativeFrameSink.sinks};
|
||||
delete sinks[sinkId];
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
nativeFrameSink: {
|
||||
...allocated.snapshot.nativeFrameSink,
|
||||
sinks,
|
||||
operationIds: {
|
||||
...allocated.snapshot.nativeFrameSink.operationIds,
|
||||
[sinkId]: allocated.operationId,
|
||||
},
|
||||
},
|
||||
},
|
||||
{type: 'nativeFrameSink.detach', operationId: allocated.operationId, sinkId},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {commandIfConnected} from './_helpers';
|
||||
|
||||
type VoiceEngineV2OutputDeviceEvent = Extract<VoiceEngineV2Event, {type: `outputDevice.${string}`}>;
|
||||
|
||||
export function transitionOutputDevice(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2OutputDeviceEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionOutputDevice snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionOutputDevice event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'outputDevice event type must be a string');
|
||||
assert.ok(event.type.startsWith('outputDevice.'), 'outputDevice reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'outputDevice.setRequested': {
|
||||
const base = {
|
||||
...snapshot,
|
||||
outputDevice: {...snapshot.outputDevice, desiredDeviceId: event.options.deviceId},
|
||||
};
|
||||
return commandIfConnected(base, 'outputDevice', {
|
||||
type: 'outputDevice.set',
|
||||
options: event.options,
|
||||
});
|
||||
}
|
||||
case 'outputDevice.setSucceeded':
|
||||
if (snapshot.outputDevice.operationId !== event.operationId) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
outputDevice: {
|
||||
...snapshot.outputDevice,
|
||||
activeDeviceId: snapshot.outputDevice.desiredDeviceId,
|
||||
operationId: null,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
case 'outputDevice.setFailed':
|
||||
if (snapshot.outputDevice.operationId !== event.operationId) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
outputDevice: {...snapshot.outputDevice, operationId: null, failure: event.error},
|
||||
lastFailure: event.error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {commandIfConnected} from './_helpers';
|
||||
|
||||
type VoiceEngineV2ParticipantVolumeEvent = Extract<VoiceEngineV2Event, {type: `participantVolume.${string}`}>;
|
||||
|
||||
export function transitionParticipantVolume(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2ParticipantVolumeEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionParticipantVolume snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionParticipantVolume event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'participantVolume event type must be a string');
|
||||
assert.ok(event.type.startsWith('participantVolume.'), 'participantVolume reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'participantVolume.setRequested': {
|
||||
if (snapshot.participantVolumes[event.options.participantIdentity] === event.options.volume) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
const base = {
|
||||
...snapshot,
|
||||
participantVolumes: {
|
||||
...snapshot.participantVolumes,
|
||||
[event.options.participantIdentity]: event.options.volume,
|
||||
},
|
||||
};
|
||||
return commandIfConnected(base, 'participantVolume', {
|
||||
type: 'participantVolume.set',
|
||||
options: event.options,
|
||||
});
|
||||
}
|
||||
case 'participantVolume.setSucceeded':
|
||||
return {snapshot, commands: []};
|
||||
case 'participantVolume.setFailed':
|
||||
return {snapshot: {...snapshot, lastFailure: event.error}, commands: []};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {
|
||||
VoiceEngineV2Error,
|
||||
VoiceEngineV2OperationId,
|
||||
VoiceEngineV2PermissionName,
|
||||
VoiceEngineV2PermissionResult,
|
||||
} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, markOperation, queueCommand} from './_helpers';
|
||||
import {applyMicrophonePermissionResult} from './microphone';
|
||||
|
||||
type VoiceEngineV2PermissionsEvent = Extract<VoiceEngineV2Event, {type: `permissions.${string}`}>;
|
||||
|
||||
function beginPermissionOperation(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
commandType: 'permissions.check' | 'permissions.request',
|
||||
name: VoiceEngineV2PermissionName,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'beginPermissionOperation snapshot must not be null');
|
||||
assert.equal(typeof commandType, 'string', 'beginPermissionOperation commandType must be a string');
|
||||
assert.equal(typeof name, 'string', 'beginPermissionOperation name must be a string');
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(
|
||||
{
|
||||
...allocated.snapshot,
|
||||
permissions: {
|
||||
...allocated.snapshot.permissions,
|
||||
operationIds: {
|
||||
...allocated.snapshot.permissions.operationIds,
|
||||
[String(name)]: allocated.operationId,
|
||||
},
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
{type: commandType, operationId: allocated.operationId, name},
|
||||
);
|
||||
}
|
||||
|
||||
export function transitionPermissions(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2PermissionsEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionPermissions snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionPermissions event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'permissions event type must be a string');
|
||||
assert.ok(event.type.startsWith('permissions.'), 'permissions reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'permissions.checkRequested':
|
||||
return beginPermissionOperation(snapshot, 'permissions.check', event.name);
|
||||
case 'permissions.requestRequested':
|
||||
return beginPermissionOperation(snapshot, 'permissions.request', event.name);
|
||||
case 'permissions.result':
|
||||
return onResult(snapshot, event.operationId, event.result);
|
||||
case 'permissions.failed':
|
||||
return onFailed(snapshot, event.operationId, event.name, event.error);
|
||||
}
|
||||
}
|
||||
|
||||
function onResult(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId | null,
|
||||
result: VoiceEngineV2PermissionResult,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onResult snapshot must not be null');
|
||||
assert.ok(result != null, 'onResult result must not be null');
|
||||
assert.equal(typeof result.name, 'string', 'result.name must be a string');
|
||||
if (operationId != null && snapshot.permissions.operationIds[String(result.name)] !== operationId) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
const operationIds = {...snapshot.permissions.operationIds};
|
||||
if (operationId != null) delete operationIds[String(result.name)];
|
||||
return applyMicrophonePermissionResult(
|
||||
{
|
||||
...(operationId != null ? markOperation(snapshot, operationId, 'succeeded') : snapshot),
|
||||
permissions: {
|
||||
results: {
|
||||
...snapshot.permissions.results,
|
||||
[String(result.name)]: result,
|
||||
},
|
||||
operationIds,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
function onFailed(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
name: VoiceEngineV2PermissionName,
|
||||
error: VoiceEngineV2Error,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onFailed snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'onFailed operationId must be an integer');
|
||||
assert.ok(error != null, 'onFailed error must not be null');
|
||||
if (snapshot.permissions.operationIds[String(name)] !== operationId) return {snapshot, commands: []};
|
||||
const operationIds = {...snapshot.permissions.operationIds};
|
||||
delete operationIds[String(name)];
|
||||
return {
|
||||
snapshot: {
|
||||
...markOperation(snapshot, operationId, 'failed', error),
|
||||
permissions: {...snapshot.permissions, operationIds, failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {
|
||||
VoiceEngineV2RemoteTrackSubscriptionOptions,
|
||||
VoiceEngineV2Track,
|
||||
VoiceEngineV2WatchedStream,
|
||||
VoiceEngineV2WatchedStreamKey,
|
||||
} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {appendTransition, commandIfConnected} from './_helpers';
|
||||
|
||||
type VoiceEngineV2RemoteTrackSubscriptionEvent = Extract<
|
||||
VoiceEngineV2Event,
|
||||
{
|
||||
type: `remoteTrackSubscription.${string}` | `watchedStream.${string}` | `watchedStreams.${string}`;
|
||||
}
|
||||
>;
|
||||
|
||||
function sameRemoteTrackSubscriptionOptions(
|
||||
a: VoiceEngineV2RemoteTrackSubscriptionOptions,
|
||||
b: VoiceEngineV2RemoteTrackSubscriptionOptions,
|
||||
): boolean {
|
||||
assert.ok(a != null, 'sameRemoteTrackSubscriptionOptions a must not be null');
|
||||
assert.ok(b != null, 'sameRemoteTrackSubscriptionOptions b must not be null');
|
||||
return (
|
||||
a.participantIdentity === b.participantIdentity &&
|
||||
a.source === b.source &&
|
||||
a.subscribed === b.subscribed &&
|
||||
a.enabled === b.enabled &&
|
||||
a.quality === b.quality
|
||||
);
|
||||
}
|
||||
|
||||
function subscriptionKey(options: VoiceEngineV2RemoteTrackSubscriptionOptions): string {
|
||||
assert.ok(options != null, 'subscriptionKey options must not be null');
|
||||
assert.equal(typeof options.participantIdentity, 'string', 'options.participantIdentity must be a string');
|
||||
return `${options.participantIdentity}:${options.source}`;
|
||||
}
|
||||
|
||||
function watchedStreamKey(stream: VoiceEngineV2WatchedStreamKey): string {
|
||||
assert.ok(stream != null, 'watchedStreamKey stream must not be null');
|
||||
assert.equal(typeof stream.participantIdentity, 'string', 'stream.participantIdentity must be a string');
|
||||
return `${stream.participantIdentity}:${stream.source}`;
|
||||
}
|
||||
|
||||
function findMatchingPublishedTrack(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
stream: VoiceEngineV2WatchedStreamKey,
|
||||
): VoiceEngineV2Track | null {
|
||||
assert.ok(snapshot != null, 'findMatchingPublishedTrack snapshot must not be null');
|
||||
assert.ok(stream != null, 'findMatchingPublishedTrack stream must not be null');
|
||||
return (
|
||||
Object.values(snapshot.room.tracks).find(
|
||||
(track) => track.participantIdentity === stream.participantIdentity && track.source === stream.source,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function watchedStreamToSubscriptionOptions(
|
||||
stream: VoiceEngineV2WatchedStream,
|
||||
): VoiceEngineV2RemoteTrackSubscriptionOptions {
|
||||
assert.ok(stream != null, 'watchedStreamToSubscriptionOptions stream must not be null');
|
||||
assert.equal(typeof stream.participantIdentity, 'string', 'stream.participantIdentity must be a string');
|
||||
return {
|
||||
participantIdentity: stream.participantIdentity,
|
||||
source: stream.source,
|
||||
subscribed: stream.enabled,
|
||||
enabled: stream.enabled,
|
||||
...(stream.quality ? {quality: stream.quality} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function applyRemoteTrackSubscriptionRequest(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
options: VoiceEngineV2RemoteTrackSubscriptionOptions,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'applyRemoteTrackSubscriptionRequest snapshot must not be null');
|
||||
assert.ok(options != null, 'applyRemoteTrackSubscriptionRequest options must not be null');
|
||||
const current = snapshot.remoteTrackSubscriptions[subscriptionKey(options)];
|
||||
if (current && sameRemoteTrackSubscriptionOptions(current, options)) return {snapshot, commands: []};
|
||||
const base = {
|
||||
...snapshot,
|
||||
remoteTrackSubscriptions: {
|
||||
...snapshot.remoteTrackSubscriptions,
|
||||
[subscriptionKey(options)]: options,
|
||||
},
|
||||
};
|
||||
return commandIfConnected(base, 'remoteTrackSubscription', {
|
||||
type: 'remoteTrackSubscription.set',
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
function applyWatchedStream(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
stream: VoiceEngineV2WatchedStream,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'applyWatchedStream snapshot must not be null');
|
||||
assert.ok(stream != null, 'applyWatchedStream stream must not be null');
|
||||
const existingTrack = stream.trackSid ? null : findMatchingPublishedTrack(snapshot, stream);
|
||||
const desired: VoiceEngineV2WatchedStream = {
|
||||
...stream,
|
||||
trackSid: stream.trackSid ?? existingTrack?.trackSid ?? null,
|
||||
};
|
||||
const base = {
|
||||
...snapshot,
|
||||
watchedStreams: {
|
||||
...snapshot.watchedStreams,
|
||||
[watchedStreamKey(desired)]: desired,
|
||||
},
|
||||
};
|
||||
return applyRemoteTrackSubscriptionRequest(base, watchedStreamToSubscriptionOptions(desired));
|
||||
}
|
||||
|
||||
function applyUnwatchedStream(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
stream: VoiceEngineV2WatchedStreamKey,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'applyUnwatchedStream snapshot must not be null');
|
||||
assert.ok(stream != null, 'applyUnwatchedStream stream must not be null');
|
||||
const key = watchedStreamKey(stream);
|
||||
const previous = snapshot.watchedStreams[key];
|
||||
const watchedStreams = {...snapshot.watchedStreams};
|
||||
delete watchedStreams[key];
|
||||
const base = {...snapshot, watchedStreams};
|
||||
return applyRemoteTrackSubscriptionRequest(base, {
|
||||
participantIdentity: stream.participantIdentity,
|
||||
source: stream.source,
|
||||
subscribed: false,
|
||||
enabled: false,
|
||||
...(previous?.quality ? {quality: previous.quality} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function replaceWatchedStreams(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
streams: Array<VoiceEngineV2WatchedStream>,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'replaceWatchedStreams snapshot must not be null');
|
||||
assert.ok(Array.isArray(streams), 'replaceWatchedStreams streams must be an array');
|
||||
const desiredKeys = new Set(streams.map(watchedStreamKey));
|
||||
let transition: VoiceEngineV2Transition = {snapshot, commands: []};
|
||||
for (const key of Object.keys(snapshot.watchedStreams).sort()) {
|
||||
if (desiredKeys.has(key)) continue;
|
||||
const previous = snapshot.watchedStreams[key];
|
||||
if (!previous) continue;
|
||||
transition = appendTransition(
|
||||
transition,
|
||||
applyUnwatchedStream(transition.snapshot, {
|
||||
participantIdentity: previous.participantIdentity,
|
||||
source: previous.source,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const stream of [...streams].sort((a, b) => watchedStreamKey(a).localeCompare(watchedStreamKey(b)))) {
|
||||
transition = appendTransition(transition, applyWatchedStream(transition.snapshot, stream));
|
||||
}
|
||||
return transition;
|
||||
}
|
||||
|
||||
export function syncWatchedStreamTrack(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
track: VoiceEngineV2Track,
|
||||
): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'syncWatchedStreamTrack snapshot must not be null');
|
||||
assert.ok(track != null, 'syncWatchedStreamTrack track must not be null');
|
||||
const key = watchedStreamKey({participantIdentity: track.participantIdentity, source: track.source});
|
||||
const stream = snapshot.watchedStreams[key];
|
||||
if (!stream || stream.trackSid === track.trackSid) return snapshot;
|
||||
return {
|
||||
...snapshot,
|
||||
watchedStreams: {
|
||||
...snapshot.watchedStreams,
|
||||
[key]: {...stream, trackSid: track.trackSid},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function clearWatchedStreamTrack(snapshot: VoiceEngineV2Snapshot, trackSid: string): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'clearWatchedStreamTrack snapshot must not be null');
|
||||
assert.equal(typeof trackSid, 'string', 'clearWatchedStreamTrack trackSid must be a string');
|
||||
let changed = false;
|
||||
const watchedStreams: Record<string, VoiceEngineV2WatchedStream> = {};
|
||||
for (const key of Object.keys(snapshot.watchedStreams).sort()) {
|
||||
const stream = snapshot.watchedStreams[key];
|
||||
if (!stream) continue;
|
||||
if (stream.trackSid === trackSid) {
|
||||
changed = true;
|
||||
watchedStreams[key] = {...stream, trackSid: null};
|
||||
} else {
|
||||
watchedStreams[key] = stream;
|
||||
}
|
||||
}
|
||||
return changed ? {...snapshot, watchedStreams} : snapshot;
|
||||
}
|
||||
|
||||
export function transitionRemoteTrackSubscription(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2RemoteTrackSubscriptionEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionRemoteTrackSubscription snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionRemoteTrackSubscription event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'remoteTrackSubscription event type must be a string');
|
||||
assert.ok(
|
||||
event.type.startsWith('remoteTrackSubscription.') ||
|
||||
event.type.startsWith('watchedStream.') ||
|
||||
event.type.startsWith('watchedStreams.'),
|
||||
'remoteTrackSubscription reducer received unrelated event',
|
||||
);
|
||||
switch (event.type) {
|
||||
case 'remoteTrackSubscription.setRequested':
|
||||
return applyRemoteTrackSubscriptionRequest(snapshot, event.options);
|
||||
case 'remoteTrackSubscription.setSucceeded':
|
||||
return {snapshot, commands: []};
|
||||
case 'remoteTrackSubscription.setFailed':
|
||||
return {snapshot: {...snapshot, lastFailure: event.error}, commands: []};
|
||||
case 'watchedStream.watchRequested':
|
||||
return applyWatchedStream(snapshot, event.stream);
|
||||
case 'watchedStream.unwatchRequested':
|
||||
return applyUnwatchedStream(snapshot, event.stream);
|
||||
case 'watchedStreams.replaced':
|
||||
return replaceWatchedStreams(snapshot, event.streams);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2Participant, VoiceEngineV2Track} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {removeInboundVideoTrack} from './inboundVideo';
|
||||
import {clearWatchedStreamTrack, syncWatchedStreamTrack} from './remoteTrackSubscription';
|
||||
|
||||
type VoiceEngineV2RoomEvent = Extract<VoiceEngineV2Event, {type: `room.${string}`}>;
|
||||
|
||||
function participantMatches(participant: VoiceEngineV2Participant, identity?: string, sid?: string): boolean {
|
||||
assert.ok(participant != null, 'participantMatches participant must not be null');
|
||||
assert.equal(typeof participant.identity, 'string', 'participant.identity must be a string');
|
||||
return (
|
||||
(identity !== undefined && participant.identity === identity) || (sid !== undefined && participant.sid === sid)
|
||||
);
|
||||
}
|
||||
|
||||
function trackMatchesParticipant(track: VoiceEngineV2Track, identities: Set<string>, sid?: string): boolean {
|
||||
assert.ok(track != null, 'trackMatchesParticipant track must not be null');
|
||||
assert.ok(identities instanceof Set, 'trackMatchesParticipant identities must be a Set');
|
||||
return identities.has(track.participantIdentity) || (sid !== undefined && track.participantSid === sid);
|
||||
}
|
||||
|
||||
function removeParticipantRoomState(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
participantIdentity?: string,
|
||||
participantSid?: string,
|
||||
): VoiceEngineV2Snapshot {
|
||||
assert.ok(snapshot != null, 'removeParticipantRoomState snapshot must not be null');
|
||||
assert.ok(snapshot.room != null, 'removeParticipantRoomState snapshot.room must not be null');
|
||||
const identities = new Set<string>();
|
||||
if (participantIdentity) identities.add(participantIdentity);
|
||||
const participants = {...snapshot.room.participants};
|
||||
for (const identity of Object.keys(snapshot.room.participants).sort()) {
|
||||
const participant = snapshot.room.participants[identity];
|
||||
if (!participant) continue;
|
||||
if (!participantMatches(participant, participantIdentity, participantSid)) continue;
|
||||
identities.add(identity);
|
||||
delete participants[identity];
|
||||
}
|
||||
const tracks: Record<string, VoiceEngineV2Track> = {};
|
||||
const inboundTracks = {...snapshot.inboundVideo.tracks};
|
||||
for (const trackSid of Object.keys(snapshot.room.tracks).sort()) {
|
||||
const track = snapshot.room.tracks[trackSid];
|
||||
if (!track) continue;
|
||||
if (trackMatchesParticipant(track, identities, participantSid)) {
|
||||
delete inboundTracks[trackSid];
|
||||
continue;
|
||||
}
|
||||
tracks[trackSid] = track;
|
||||
}
|
||||
for (const trackSid of Object.keys(snapshot.inboundVideo.tracks).sort()) {
|
||||
const track = snapshot.inboundVideo.tracks[trackSid];
|
||||
if (!track) continue;
|
||||
if (
|
||||
(participantSid && track.participantSid === participantSid) ||
|
||||
(track.participantIdentity && identities.has(track.participantIdentity))
|
||||
) {
|
||||
delete inboundTracks[trackSid];
|
||||
}
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
room: {...snapshot.room, participants, tracks},
|
||||
inboundVideo: {...snapshot.inboundVideo, tracks: inboundTracks},
|
||||
};
|
||||
}
|
||||
|
||||
function onParticipantJoined(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
participant: VoiceEngineV2Participant,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onParticipantJoined snapshot must not be null');
|
||||
assert.ok(participant != null, 'onParticipantJoined participant must not be null');
|
||||
assert.equal(typeof participant.identity, 'string', 'participant.identity must be a string');
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
room: {
|
||||
...snapshot.room,
|
||||
participants: {
|
||||
...snapshot.room.participants,
|
||||
[participant.identity]: participant,
|
||||
},
|
||||
},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onTrackPublished(snapshot: VoiceEngineV2Snapshot, track: VoiceEngineV2Track): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onTrackPublished snapshot must not be null');
|
||||
assert.ok(track != null, 'onTrackPublished track must not be null');
|
||||
assert.equal(typeof track.trackSid, 'string', 'track.trackSid must be a string');
|
||||
return {
|
||||
snapshot: syncWatchedStreamTrack(
|
||||
{
|
||||
...snapshot,
|
||||
room: {
|
||||
...snapshot.room,
|
||||
tracks: {
|
||||
...snapshot.room.tracks,
|
||||
[track.trackSid]: track,
|
||||
},
|
||||
},
|
||||
},
|
||||
track,
|
||||
),
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onTrackUnpublished(snapshot: VoiceEngineV2Snapshot, trackSid: string): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onTrackUnpublished snapshot must not be null');
|
||||
assert.equal(typeof trackSid, 'string', 'onTrackUnpublished trackSid must be a string');
|
||||
const tracks = {...snapshot.room.tracks};
|
||||
delete tracks[trackSid];
|
||||
return {
|
||||
snapshot: removeInboundVideoTrack(
|
||||
clearWatchedStreamTrack({...snapshot, room: {...snapshot.room, tracks}}, trackSid),
|
||||
trackSid,
|
||||
),
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onTrackMuteChanged(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
trackSid: string,
|
||||
muted: boolean,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onTrackMuteChanged snapshot must not be null');
|
||||
assert.equal(typeof trackSid, 'string', 'onTrackMuteChanged trackSid must be a string');
|
||||
assert.equal(typeof muted, 'boolean', 'onTrackMuteChanged muted must be a boolean');
|
||||
const track = snapshot.room.tracks[trackSid];
|
||||
if (!track) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
room: {
|
||||
...snapshot.room,
|
||||
tracks: {
|
||||
...snapshot.room.tracks,
|
||||
[trackSid]: {...track, muted},
|
||||
},
|
||||
},
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionRoom(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2RoomEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionRoom snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionRoom event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'room event type must be a string');
|
||||
assert.ok(event.type.startsWith('room.'), 'room reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'room.participantJoined':
|
||||
return onParticipantJoined(snapshot, event.participant);
|
||||
case 'room.participantLeft':
|
||||
return {
|
||||
snapshot: removeParticipantRoomState(snapshot, event.participantIdentity, event.participantSid),
|
||||
commands: [],
|
||||
};
|
||||
case 'room.trackPublished':
|
||||
return onTrackPublished(snapshot, event.track);
|
||||
case 'room.trackUnpublished':
|
||||
return onTrackUnpublished(snapshot, event.trackSid);
|
||||
case 'room.trackMuted':
|
||||
return onTrackMuteChanged(snapshot, event.trackSid, true);
|
||||
case 'room.trackUnmuted':
|
||||
return onTrackMuteChanged(snapshot, event.trackSid, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {planVoiceEngineV2ScreenEncodingChange} from '../../policies/screenShare';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {
|
||||
VoiceEngineV2Error,
|
||||
VoiceEngineV2OperationId,
|
||||
VoiceEngineV2ScreenEncodingOptions,
|
||||
VoiceEngineV2ScreenOptions,
|
||||
} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {
|
||||
allocateOperation,
|
||||
beginUnpublish,
|
||||
failUnpublish,
|
||||
invalidArgument,
|
||||
isConnected,
|
||||
unsupportedCapability,
|
||||
} from './_helpers';
|
||||
import {applyMediaFailure, applyMediaSuccess, completeUnpublish} from './_media';
|
||||
import {nativeZeroCopyRequiredError, unavailableZeroCopyTransportError} from './_zeroCopy';
|
||||
|
||||
type VoiceEngineV2ScreenEvent = Extract<VoiceEngineV2Event, {type: `screen.${string}`}>;
|
||||
|
||||
function sameScreenOptions(a: VoiceEngineV2ScreenOptions | null, b: VoiceEngineV2ScreenOptions | null): boolean {
|
||||
assert.ok(a !== undefined, 'sameScreenOptions a must not be undefined');
|
||||
assert.ok(b !== undefined, 'sameScreenOptions b must not be undefined');
|
||||
return (
|
||||
a?.captureId === b?.captureId &&
|
||||
a?.width === b?.width &&
|
||||
a?.height === b?.height &&
|
||||
a?.codec === b?.codec &&
|
||||
a?.hardwareEncoding === b?.hardwareEncoding &&
|
||||
a?.zeroCopyRequired === b?.zeroCopyRequired &&
|
||||
a?.maxBitrateBps === b?.maxBitrateBps &&
|
||||
a?.maxFramerate === b?.maxFramerate &&
|
||||
a?.adaptiveSend === b?.adaptiveSend &&
|
||||
a?.minVideoFps === b?.minVideoFps &&
|
||||
a?.maxAudioBufferMs === b?.maxAudioBufferMs &&
|
||||
a?.pacing === b?.pacing
|
||||
);
|
||||
}
|
||||
|
||||
function validateScreenPublish(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
desired: VoiceEngineV2ScreenOptions,
|
||||
): VoiceEngineV2Error | null {
|
||||
assert.ok(snapshot != null, 'validateScreenPublish snapshot must not be null');
|
||||
assert.ok(desired != null, 'validateScreenPublish desired must not be null');
|
||||
if (desired.zeroCopyRequired === true && !snapshot.capabilities.zeroCopyScreenTransport) {
|
||||
return unavailableZeroCopyTransportError('capture');
|
||||
}
|
||||
if (desired.hardwareEncoding === true) {
|
||||
if (!snapshot.capabilities.hardwareEncoding) {
|
||||
return unsupportedCapability('hardwareEncoding');
|
||||
}
|
||||
if (desired.zeroCopyRequired !== true) {
|
||||
return nativeZeroCopyRequiredError('hardwareEncoder');
|
||||
}
|
||||
if (!snapshot.capabilities.zeroCopyScreenTransport) {
|
||||
return unavailableZeroCopyTransportError('hardwareEncoder');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function beginScreenPublish(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'beginScreenPublish snapshot must not be null');
|
||||
assert.ok(snapshot.screen != null, 'beginScreenPublish snapshot.screen must not be null');
|
||||
const desired = snapshot.screen.desired;
|
||||
if (!desired) return {snapshot, commands: []};
|
||||
if (!snapshot.capabilities.screen) {
|
||||
const error = unsupportedCapability('screen');
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
screen: {...snapshot.screen, status: 'failed', failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
const validationError = validateScreenPublish(snapshot, desired);
|
||||
if (validationError) {
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
screen: {...snapshot.screen, status: 'failed', failure: validationError},
|
||||
lastFailure: validationError,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
if (!isConnected(snapshot)) return {snapshot, commands: []};
|
||||
if (snapshot.screen.status === 'published' && sameScreenOptions(snapshot.screen.published, desired)) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return {
|
||||
snapshot: {
|
||||
...allocated.snapshot,
|
||||
screen: {
|
||||
...allocated.snapshot.screen,
|
||||
status: 'publishing',
|
||||
operationId: allocated.operationId,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
commands: [{type: 'screen.publish', operationId: allocated.operationId, options: desired}],
|
||||
};
|
||||
}
|
||||
|
||||
function failScreen(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
error: VoiceEngineV2Error,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'failScreen snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'failScreen operationId must be an integer');
|
||||
assert.ok(error != null, 'failScreen error must not be null');
|
||||
const state = snapshot.screen;
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
screen: applyMediaFailure(state, operationId, error),
|
||||
lastFailure: state.operationId === operationId ? error : snapshot.lastFailure,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function beginScreenEncodingUpdate(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
options: VoiceEngineV2ScreenEncodingOptions,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'onUpdateEncodingRequested snapshot must not be null');
|
||||
assert.ok(options != null, 'onUpdateEncodingRequested options must not be null');
|
||||
const plan = planVoiceEngineV2ScreenEncodingChange({
|
||||
published: snapshot.screen.published,
|
||||
desired: snapshot.screen.desired,
|
||||
update: options,
|
||||
});
|
||||
if (plan.action === 'reject' || !plan.desired) {
|
||||
const error =
|
||||
plan.error ?? invalidArgument('Cannot update screen encoding without a matching published screen', 'screen');
|
||||
return {
|
||||
snapshot: {...snapshot, screen: {...snapshot.screen, failure: error}, lastFailure: error},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
const validationError = validateScreenPublish(snapshot, plan.desired);
|
||||
if (validationError) {
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
screen: {...snapshot.screen, status: 'failed', desired: plan.desired, failure: validationError},
|
||||
lastFailure: validationError,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
if (plan.action === 'noop') {
|
||||
return {snapshot: {...snapshot, screen: {...snapshot.screen, desired: plan.desired}}, commands: []};
|
||||
}
|
||||
if (!isConnected(snapshot)) {
|
||||
return {snapshot: {...snapshot, screen: {...snapshot.screen, desired: plan.desired}}, commands: []};
|
||||
}
|
||||
const allocated = allocateOperation({...snapshot, screen: {...snapshot.screen, desired: plan.desired}});
|
||||
const command =
|
||||
plan.action === 'republish'
|
||||
? ({type: 'screen.publish', operationId: allocated.operationId, options: plan.desired} as const)
|
||||
: ({type: 'screen.updateEncoding', operationId: allocated.operationId, options} as const);
|
||||
return {
|
||||
snapshot: {
|
||||
...allocated.snapshot,
|
||||
screen: {...allocated.snapshot.screen, status: 'publishing', operationId: allocated.operationId},
|
||||
},
|
||||
commands: [command],
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionScreen(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2ScreenEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionScreen snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionScreen event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'screen event type must be a string');
|
||||
assert.ok(event.type.startsWith('screen.'), 'screen reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'screen.publishRequested':
|
||||
return beginScreenPublish({...snapshot, screen: {...snapshot.screen, desired: event.options}});
|
||||
case 'screen.publishSucceeded':
|
||||
return {
|
||||
snapshot: {...snapshot, screen: applyMediaSuccess(snapshot.screen, event.operationId)},
|
||||
commands: [],
|
||||
};
|
||||
case 'screen.publishFailed':
|
||||
return failScreen(snapshot, event.operationId, event.error);
|
||||
case 'screen.updateEncodingRequested':
|
||||
return beginScreenEncodingUpdate(snapshot, event.options);
|
||||
case 'screen.updateEncodingSucceeded':
|
||||
return {
|
||||
snapshot: {...snapshot, screen: applyMediaSuccess(snapshot.screen, event.operationId)},
|
||||
commands: [],
|
||||
};
|
||||
case 'screen.updateEncodingFailed':
|
||||
return failScreen(snapshot, event.operationId, event.error);
|
||||
case 'screen.unpublishRequested':
|
||||
return beginUnpublish(snapshot, 'screen', 'screen.unpublish');
|
||||
case 'screen.unpublishSucceeded':
|
||||
return {
|
||||
snapshot: {...snapshot, screen: completeUnpublish(snapshot.screen, event.operationId)},
|
||||
commands: [],
|
||||
};
|
||||
case 'screen.unpublishFailed':
|
||||
return failUnpublish(snapshot, event.operationId, event.error, 'screen');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2Error, VoiceEngineV2OperationId, VoiceEngineV2ScreenAudioOptions} from '../../protocol/types';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {
|
||||
allocateOperation,
|
||||
beginUnpublish,
|
||||
failUnpublish,
|
||||
invalidArgument,
|
||||
isConnected,
|
||||
unsupportedCapability,
|
||||
} from './_helpers';
|
||||
import {applyMediaFailure, applyMediaSuccess, completeUnpublish} from './_media';
|
||||
|
||||
type VoiceEngineV2ScreenAudioEvent = Extract<VoiceEngineV2Event, {type: `screenAudio.${string}`}>;
|
||||
|
||||
function sameScreenAudioOptions(
|
||||
a: VoiceEngineV2ScreenAudioOptions | null,
|
||||
b: VoiceEngineV2ScreenAudioOptions | null,
|
||||
): boolean {
|
||||
assert.ok(a !== undefined, 'sameScreenAudioOptions a must not be undefined');
|
||||
assert.ok(b !== undefined, 'sameScreenAudioOptions b must not be undefined');
|
||||
return (
|
||||
a?.sampleRate === b?.sampleRate &&
|
||||
a?.numChannels === b?.numChannels &&
|
||||
a?.route === b?.route &&
|
||||
a?.captureId === b?.captureId &&
|
||||
a?.tapId === b?.tapId
|
||||
);
|
||||
}
|
||||
|
||||
function validateScreenAudioPublish(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
desired: VoiceEngineV2ScreenAudioOptions,
|
||||
): VoiceEngineV2Error | null {
|
||||
assert.ok(snapshot != null, 'validateScreenAudioPublish snapshot must not be null');
|
||||
assert.ok(desired != null, 'validateScreenAudioPublish desired must not be null');
|
||||
if (desired.route !== 'native') return null;
|
||||
if (!snapshot.capabilities.nativeAudioTaps) {
|
||||
return unsupportedCapability('nativeAudioTaps');
|
||||
}
|
||||
if (!desired.captureId && !desired.tapId) {
|
||||
return invalidArgument('Native screen audio routing requires a capture id or tap id', 'screenAudio');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function beginScreenAudioPublish(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'beginScreenAudioPublish snapshot must not be null');
|
||||
assert.ok(snapshot.screenAudio != null, 'beginScreenAudioPublish snapshot.screenAudio must not be null');
|
||||
const desired = snapshot.screenAudio.desired;
|
||||
if (!desired) return {snapshot, commands: []};
|
||||
if (!snapshot.capabilities.screenAudio) {
|
||||
const error = unsupportedCapability('screenAudio');
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
screenAudio: {...snapshot.screenAudio, status: 'failed', failure: error},
|
||||
lastFailure: error,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
const validationError = validateScreenAudioPublish(snapshot, desired);
|
||||
if (validationError) {
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
screenAudio: {...snapshot.screenAudio, status: 'failed', failure: validationError},
|
||||
lastFailure: validationError,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
if (!isConnected(snapshot)) return {snapshot, commands: []};
|
||||
if (snapshot.screenAudio.status === 'published' && sameScreenAudioOptions(snapshot.screenAudio.published, desired)) {
|
||||
return {snapshot, commands: []};
|
||||
}
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return {
|
||||
snapshot: {
|
||||
...allocated.snapshot,
|
||||
screenAudio: {
|
||||
...allocated.snapshot.screenAudio,
|
||||
status: 'publishing',
|
||||
operationId: allocated.operationId,
|
||||
failure: null,
|
||||
},
|
||||
},
|
||||
commands: [{type: 'screenAudio.publish', operationId: allocated.operationId, options: desired}],
|
||||
};
|
||||
}
|
||||
|
||||
function failScreenAudio(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
operationId: VoiceEngineV2OperationId,
|
||||
error: VoiceEngineV2Error,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'failScreenAudio snapshot must not be null');
|
||||
assert.ok(Number.isInteger(operationId), 'failScreenAudio operationId must be an integer');
|
||||
assert.ok(error != null, 'failScreenAudio error must not be null');
|
||||
const state = snapshot.screenAudio;
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
screenAudio: applyMediaFailure(state, operationId, error),
|
||||
lastFailure: state.operationId === operationId ? error : snapshot.lastFailure,
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionScreenAudio(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2ScreenAudioEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionScreenAudio snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionScreenAudio event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'screenAudio event type must be a string');
|
||||
assert.ok(event.type.startsWith('screenAudio.'), 'screenAudio reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'screenAudio.publishRequested':
|
||||
return beginScreenAudioPublish({
|
||||
...snapshot,
|
||||
screenAudio: {...snapshot.screenAudio, desired: event.options},
|
||||
});
|
||||
case 'screenAudio.publishSucceeded':
|
||||
return {
|
||||
snapshot: {...snapshot, screenAudio: applyMediaSuccess(snapshot.screenAudio, event.operationId)},
|
||||
commands: [],
|
||||
};
|
||||
case 'screenAudio.publishFailed':
|
||||
return failScreenAudio(snapshot, event.operationId, event.error);
|
||||
case 'screenAudio.unpublishRequested':
|
||||
return beginUnpublish(snapshot, 'screenAudio', 'screenAudio.unpublish');
|
||||
case 'screenAudio.unpublishSucceeded':
|
||||
return {
|
||||
snapshot: {...snapshot, screenAudio: completeUnpublish(snapshot.screenAudio, event.operationId)},
|
||||
commands: [],
|
||||
};
|
||||
case 'screenAudio.unpublishFailed':
|
||||
return failUnpublish(snapshot, event.operationId, event.error, 'screenAudio');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2SourceLifecycleTransitionedEvent} from '../../protocol/events';
|
||||
import type {VoiceEngineV2DiagnosticEntry} from '../../protocol/types';
|
||||
import type {SourceFault, SourceLifecycleState} from '../../source_isolation/SourceLifecycleState';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, appendDiagnostic, queueCommand} from './_helpers';
|
||||
|
||||
const SOURCE_LIFECYCLE_DIAGNOSTICS_CODE = 'sourceFailed';
|
||||
|
||||
type VoiceEngineV2SourceLifecyclesEvent = VoiceEngineV2SourceLifecycleTransitionedEvent;
|
||||
|
||||
function assertNeverLifecycleKind(kind: never): never {
|
||||
assert.fail(`unhandled source lifecycle kind: ${JSON.stringify(kind)}`);
|
||||
}
|
||||
|
||||
function rebuildSourceLifecycleState(event: VoiceEngineV2SourceLifecycleTransitionedEvent): SourceLifecycleState {
|
||||
assert.ok(event != null, 'rebuildSourceLifecycleState event must not be null');
|
||||
assert.equal(typeof event.since, 'bigint', 'sourceLifecycle.transitioned.since must be a bigint');
|
||||
assert.ok(event.since >= 0n, 'sourceLifecycle.transitioned.since must be non-negative');
|
||||
assert.ok(Number.isInteger(event.attempts), 'sourceLifecycle.transitioned.attempts must be integer');
|
||||
assert.ok(event.attempts >= 0, 'sourceLifecycle.transitioned.attempts must be non-negative');
|
||||
if (event.kind === 'active') {
|
||||
return {kind: 'active', since: event.since};
|
||||
}
|
||||
if (event.kind === 'reconnecting') {
|
||||
assert.ok(event.fault !== null, 'reconnecting transition requires a fault');
|
||||
return {kind: 'reconnecting', since: event.since, attempts: event.attempts, lastFault: event.fault};
|
||||
}
|
||||
if (event.kind === 'failed') {
|
||||
assert.ok(event.fault !== null, 'failed transition requires a fault');
|
||||
return {kind: 'failed', since: event.since, finalFault: event.fault, totalAttempts: event.attempts};
|
||||
}
|
||||
return assertNeverLifecycleKind(event.kind);
|
||||
}
|
||||
|
||||
export function transitionSourceLifecycles(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2SourceLifecyclesEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionSourceLifecycles snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionSourceLifecycles event must not be null');
|
||||
assert.equal(typeof event.sourceId, 'string', 'sourceLifecycle.sourceId must be a string');
|
||||
assert.ok(event.sourceId.length > 0, 'sourceLifecycle.sourceId must not be empty');
|
||||
const nextState = rebuildSourceLifecycleState(event);
|
||||
const base: VoiceEngineV2Snapshot = {
|
||||
...snapshot,
|
||||
sourceLifecycles: {...snapshot.sourceLifecycles, [event.sourceId]: nextState},
|
||||
};
|
||||
if (event.kind !== 'failed') return {snapshot: base, commands: []};
|
||||
const fault: SourceFault | null = event.fault;
|
||||
assert.ok(fault !== null, 'failed transition requires a fault');
|
||||
const allocated = allocateOperation(base);
|
||||
const diagnosticEntry: VoiceEngineV2DiagnosticEntry = {
|
||||
id: `sourceLifecycle.failed.${event.sourceId}.${allocated.operationId}`,
|
||||
atMs: event.atMs,
|
||||
level: 'error',
|
||||
code: SOURCE_LIFECYCLE_DIAGNOSTICS_CODE,
|
||||
message: `source ${event.sourceId} failed with fault ${fault}`,
|
||||
detail: {sourceId: event.sourceId, fault, attempts: event.attempts},
|
||||
};
|
||||
return queueCommand(
|
||||
{...allocated.snapshot, diagnostics: appendDiagnostic(allocated.snapshot.diagnostics, diagnosticEntry)},
|
||||
{type: 'diagnostics.log', operationId: allocated.operationId, entry: diagnosticEntry},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {commandIfConnected} from './_helpers';
|
||||
|
||||
type VoiceEngineV2StatsEvent = Extract<
|
||||
VoiceEngineV2Event,
|
||||
{type: 'stats.collectRequested' | 'stats.collected' | 'stats.collectFailed'}
|
||||
>;
|
||||
|
||||
export function transitionStats(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2StatsEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionStats snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionStats event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'stats event type must be a string');
|
||||
assert.ok(event.type.startsWith('stats.'), 'stats reducer received unrelated event');
|
||||
switch (event.type) {
|
||||
case 'stats.collectRequested':
|
||||
return commandIfConnected(snapshot, 'stats', {type: 'stats.collect'});
|
||||
case 'stats.collected':
|
||||
if (snapshot.statsOperationId !== event.operationId) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {...snapshot, stats: event.stats, statsOperationId: null, statsFailure: null},
|
||||
commands: [],
|
||||
};
|
||||
case 'stats.collectFailed':
|
||||
if (snapshot.statsOperationId !== event.operationId) return {snapshot, commands: []};
|
||||
return {
|
||||
snapshot: {...snapshot, statsOperationId: null, statsFailure: event.error, lastFailure: event.error},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2Event} from '../../protocol/events';
|
||||
import type {VoiceEngineV2Snapshot, VoiceEngineV2Transition} from '../state';
|
||||
import {allocateOperation, appendDiagnostic, markOperation, queueCommand} from './_helpers';
|
||||
|
||||
type VoiceEngineV2UtilityPortsEvent = Extract<VoiceEngineV2Event, {type: `timer.${string}` | `diagnostics.${string}`}>;
|
||||
|
||||
export function transitionUtilityPorts(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
event: VoiceEngineV2UtilityPortsEvent,
|
||||
): VoiceEngineV2Transition {
|
||||
assert.ok(snapshot != null, 'transitionUtilityPorts snapshot must not be null');
|
||||
assert.ok(event != null, 'transitionUtilityPorts event must not be null');
|
||||
assert.equal(typeof event.type, 'string', 'utilityPorts event type must be a string');
|
||||
assert.ok(
|
||||
event.type.startsWith('timer.') || event.type.startsWith('diagnostics.'),
|
||||
'utilityPorts reducer received unrelated event',
|
||||
);
|
||||
switch (event.type) {
|
||||
case 'timer.scheduleRequested': {
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(allocated.snapshot, {
|
||||
type: 'timer.schedule',
|
||||
operationId: allocated.operationId,
|
||||
options: event.options,
|
||||
});
|
||||
}
|
||||
case 'timer.cancelRequested': {
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(allocated.snapshot, {
|
||||
type: 'timer.cancel',
|
||||
operationId: allocated.operationId,
|
||||
timerId: event.timerId,
|
||||
});
|
||||
}
|
||||
case 'timer.fired':
|
||||
return {snapshot, commands: []};
|
||||
case 'diagnostics.logRequested': {
|
||||
const allocated = allocateOperation(snapshot);
|
||||
return queueCommand(
|
||||
{...allocated.snapshot, diagnostics: appendDiagnostic(allocated.snapshot.diagnostics, event.entry)},
|
||||
{type: 'diagnostics.log', operationId: allocated.operationId, entry: event.entry},
|
||||
);
|
||||
}
|
||||
case 'diagnostics.logged':
|
||||
return {
|
||||
snapshot: {
|
||||
...(event.operationId != null ? markOperation(snapshot, event.operationId, 'succeeded') : snapshot),
|
||||
diagnostics: appendDiagnostic(snapshot.diagnostics, event.entry),
|
||||
},
|
||||
commands: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
hasVoiceEngineV2NativeNvencEncoder,
|
||||
hasVoiceEngineV2ZeroCopyNativeInput,
|
||||
summarizeVoiceEngineV2Stats,
|
||||
type VoiceEngineV2StatsNetworkSummary,
|
||||
type VoiceEngineV2StatsSummary,
|
||||
} from '../policies';
|
||||
import type {
|
||||
VoiceEngineV2AudioInputDevice,
|
||||
VoiceEngineV2AudioOutputDevice,
|
||||
VoiceEngineV2CameraDevice,
|
||||
VoiceEngineV2Capabilities,
|
||||
VoiceEngineV2ConnectionModel,
|
||||
VoiceEngineV2DeviceInventory,
|
||||
VoiceEngineV2DiagnosticEntry,
|
||||
VoiceEngineV2E2eeState,
|
||||
VoiceEngineV2Error,
|
||||
VoiceEngineV2HardwareEncoderCapabilities,
|
||||
VoiceEngineV2InboundVideoTrack,
|
||||
VoiceEngineV2LocalStreamSource,
|
||||
VoiceEngineV2MediaModel,
|
||||
VoiceEngineV2Model,
|
||||
VoiceEngineV2Participant,
|
||||
VoiceEngineV2PermissionResult,
|
||||
VoiceEngineV2Stats,
|
||||
VoiceEngineV2StreamNegotiationProjection,
|
||||
VoiceEngineV2Track,
|
||||
VoiceEngineV2WatchedStream,
|
||||
} from '../protocol/types';
|
||||
import type {SourceLifecycleState} from '../source_isolation/SourceLifecycleState';
|
||||
import type {VoiceEngineV2Snapshot} from './state';
|
||||
|
||||
export interface VoiceEngineV2ParticipantProjection {
|
||||
participants: Array<VoiceEngineV2Participant>;
|
||||
tracks: Array<VoiceEngineV2Track>;
|
||||
inboundVideoTracks: Array<VoiceEngineV2InboundVideoTrack>;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2DeviceProjection {
|
||||
devices: VoiceEngineV2DeviceInventory;
|
||||
permissions: Record<string, VoiceEngineV2PermissionResult>;
|
||||
selectedAudioInput: VoiceEngineV2AudioInputDevice | null;
|
||||
selectedAudioOutput: VoiceEngineV2AudioOutputDevice | null;
|
||||
selectedCamera: VoiceEngineV2CameraDevice | null;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2StatsProjection {
|
||||
stats: VoiceEngineV2Stats | null;
|
||||
summary: VoiceEngineV2StatsSummary | null;
|
||||
failure: VoiceEngineV2Error | null;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2StatsPresentationNetworkSummary extends VoiceEngineV2StatsNetworkSummary {
|
||||
droppedVideoFrameCallbacks?: number;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2StatsPresentationProjection extends VoiceEngineV2StatsSummary {
|
||||
network: VoiceEngineV2StatsPresentationNetworkSummary;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2CapabilitiesProjection {
|
||||
capabilities: VoiceEngineV2Capabilities;
|
||||
hardwareEncoderCapabilities: VoiceEngineV2HardwareEncoderCapabilities | null;
|
||||
hardwareEncoderFailure: VoiceEngineV2Error | null;
|
||||
hasZeroCopyNativeInput: boolean;
|
||||
hasNativeNvencH264: boolean;
|
||||
hasNativeNvencH265: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceEngineV2DiagnosticsProjection {
|
||||
entries: Array<VoiceEngineV2DiagnosticEntry>;
|
||||
lastFailure: VoiceEngineV2Error | null;
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2E2eeProjection(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2E2eeState {
|
||||
return {
|
||||
status: snapshot.e2ee.status,
|
||||
keyId: snapshot.e2ee.keyId,
|
||||
failure: snapshot.e2ee.failure,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2ConnectionProjection(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2ConnectionModel {
|
||||
const status = snapshot.connection.status;
|
||||
return {
|
||||
status,
|
||||
connected: status === 'connected',
|
||||
connecting: status === 'connecting',
|
||||
reconnecting: status === 'reconnecting',
|
||||
failed: status === 'failed',
|
||||
gateway: {
|
||||
selfVoiceState: snapshot.gateway.selfVoiceState,
|
||||
voiceServer: snapshot.gateway.voiceServer,
|
||||
},
|
||||
liveKit: {
|
||||
connectionState: snapshot.liveKit.connectionState,
|
||||
roomSid: snapshot.liveKit.roomSid,
|
||||
roomName: snapshot.liveKit.roomName,
|
||||
serverRegion: snapshot.liveKit.serverRegion,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2MediaProjection(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2MediaModel {
|
||||
return {
|
||||
microphone: snapshot.microphone.status,
|
||||
camera: snapshot.camera.status,
|
||||
screen: snapshot.screen.status,
|
||||
screenAudio: snapshot.screenAudio.status,
|
||||
audio: snapshot.audioControls,
|
||||
effectiveMicrophoneEnabled: snapshot.microphone.enabled,
|
||||
localSpeakingOverride: snapshot.microphone.localSpeakingOverride,
|
||||
e2ee: selectVoiceEngineV2E2eeProjection(snapshot),
|
||||
screenCaptureId: snapshot.screen.published?.captureId ?? snapshot.screen.desired?.captureId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2ParticipantProjection(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
): VoiceEngineV2ParticipantProjection {
|
||||
return {
|
||||
participants: Object.values(snapshot.room.participants),
|
||||
tracks: Object.values(snapshot.room.tracks),
|
||||
inboundVideoTracks: Object.values(snapshot.inboundVideo.tracks),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2WatchedStreams(snapshot: VoiceEngineV2Snapshot): Array<VoiceEngineV2WatchedStream> {
|
||||
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 {
|
||||
devices: inventory,
|
||||
permissions: snapshot.permissions.results,
|
||||
selectedAudioInput:
|
||||
inventory.audioInputs.find((device) => device.deviceId === inventory.selectedAudioInputId) ?? null,
|
||||
selectedAudioOutput:
|
||||
inventory.audioOutputs.find((device) => device.deviceId === inventory.selectedAudioOutputId) ?? null,
|
||||
selectedCamera: inventory.cameras.find((device) => device.deviceId === inventory.selectedCameraId) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2StatsSummary(stats: VoiceEngineV2Stats | null): VoiceEngineV2StatsSummary | null {
|
||||
return stats ? summarizeVoiceEngineV2Stats(stats) : null;
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2StatsPresentationProjection(
|
||||
stats: VoiceEngineV2Stats | null,
|
||||
): VoiceEngineV2StatsPresentationProjection | null {
|
||||
const summary = selectVoiceEngineV2StatsSummary(stats);
|
||||
if (!stats || !summary) return null;
|
||||
const droppedVideoFrameCallbacks = stats.droppedVideoFrameCallbacks ?? stats.droppedNativeVideoFrames;
|
||||
if (droppedVideoFrameCallbacks == null) return summary;
|
||||
return {
|
||||
...summary,
|
||||
network: {
|
||||
...summary.network,
|
||||
droppedVideoFrameCallbacks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2StatsProjection(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2StatsProjection {
|
||||
return {
|
||||
stats: snapshot.stats,
|
||||
summary: selectVoiceEngineV2StatsSummary(snapshot.stats),
|
||||
failure: snapshot.statsFailure,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2CapabilitiesProjection(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
): VoiceEngineV2CapabilitiesProjection {
|
||||
const hardwareEncoderCapabilities = snapshot.hardwareEncoder.capabilities;
|
||||
return {
|
||||
capabilities: snapshot.capabilities,
|
||||
hardwareEncoderCapabilities,
|
||||
hardwareEncoderFailure: snapshot.hardwareEncoder.failure,
|
||||
hasZeroCopyNativeInput: hasVoiceEngineV2ZeroCopyNativeInput(hardwareEncoderCapabilities),
|
||||
hasNativeNvencH264: hasVoiceEngineV2NativeNvencEncoder(hardwareEncoderCapabilities, 'h264'),
|
||||
hasNativeNvencH265: hasVoiceEngineV2NativeNvencEncoder(hardwareEncoderCapabilities, 'h265'),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2DiagnosticsProjection(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
): VoiceEngineV2DiagnosticsProjection {
|
||||
return {
|
||||
entries: snapshot.diagnostics,
|
||||
lastFailure: snapshot.lastFailure,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2SourceLifecycle(
|
||||
snapshot: VoiceEngineV2Snapshot,
|
||||
sourceId: string,
|
||||
): SourceLifecycleState | null {
|
||||
if (typeof sourceId !== 'string') throw new TypeError('sourceId must be a string');
|
||||
if (sourceId.length === 0) throw new RangeError('sourceId must not be empty');
|
||||
return snapshot.sourceLifecycles[sourceId] ?? null;
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2FailedSourceIds(snapshot: VoiceEngineV2Snapshot): ReadonlyArray<string> {
|
||||
if (snapshot === null || typeof snapshot !== 'object') throw new TypeError('snapshot must be an object');
|
||||
const lifecycles = snapshot.sourceLifecycles;
|
||||
if (lifecycles === null || typeof lifecycles !== 'object') throw new TypeError('sourceLifecycles must be a record');
|
||||
const result: Array<string> = [];
|
||||
for (const sourceId of Object.keys(lifecycles).sort()) {
|
||||
const state = lifecycles[sourceId];
|
||||
if (state && state.kind === 'failed') result.push(sourceId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function selectVoiceEngineV2Model(snapshot: VoiceEngineV2Snapshot): VoiceEngineV2Model {
|
||||
const status = snapshot.connection.status;
|
||||
const participants = selectVoiceEngineV2ParticipantProjection(snapshot);
|
||||
const devices = selectVoiceEngineV2DeviceProjection(snapshot);
|
||||
return {
|
||||
connection: selectVoiceEngineV2ConnectionProjection(snapshot),
|
||||
media: selectVoiceEngineV2MediaProjection(snapshot),
|
||||
canPublishMedia: status === 'connected',
|
||||
hasActiveLocalMedia:
|
||||
snapshot.microphone.status === 'published' ||
|
||||
snapshot.camera.status === 'published' ||
|
||||
snapshot.screen.status === 'published' ||
|
||||
snapshot.screenAudio.status === 'published',
|
||||
participants: participants.participants,
|
||||
tracks: participants.tracks,
|
||||
watchedStreams: selectVoiceEngineV2WatchedStreams(snapshot),
|
||||
inboundVideoTracks: participants.inboundVideoTracks,
|
||||
devices: devices.devices,
|
||||
permissions: devices.permissions,
|
||||
stats: snapshot.stats,
|
||||
diagnostics: snapshot.diagnostics,
|
||||
tearingDown: snapshot.lifecycle.tearingDown,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user