diff --git a/.changeset/user-data-frame-metadata.md b/.changeset/user-data-frame-metadata.md new file mode 100644 index 0000000000..c53182fd35 --- /dev/null +++ b/.changeset/user-data-frame-metadata.md @@ -0,0 +1,5 @@ +--- +"livekit-client": patch +--- + +Add support for user_data frame metadata trailer type \ No newline at end of file diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index 9e7b783929..3471049a1e 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -347,6 +347,13 @@ const appActions = { `\nReceive: ${fmt(receiveTime)}` + `\nLatency: ${latencyDisplay}`; } + if (meta.userData && meta.userData.byteLength > 0) { + const hex = Array.from(meta.userData.subarray(0, 6)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(' '); + const ellipsis = meta.userData.byteLength > 6 ? ' ...' : ''; + text += `${text ? '\n' : ''}user data: ${meta.userData.byteLength} bytes [${hex}${ellipsis}]`; + } overlayElm.textContent = text; } }); diff --git a/src/e2ee/E2eeManager.ts b/src/e2ee/E2eeManager.ts index 9b07e7139f..994f6a9dcd 100644 --- a/src/e2ee/E2eeManager.ts +++ b/src/e2ee/E2eeManager.ts @@ -1,6 +1,7 @@ import { Encryption_Type, TrackInfo } from '@livekit/protocol'; import { EventEmitter } from 'events'; import type TypedEventEmitter from 'typed-emitter'; +import type { FrameMetadata } from '../frameMetadata/types'; import { hasFrameMetadataPublishOptions } from '../frameMetadata/utils'; import log, { LogLevel, workerLogger } from '../logger'; import type RTCEngine from '../room/RTCEngine'; @@ -246,7 +247,7 @@ export class E2EEManager trackId: string, rtpTimestamp: number, ssrc: number, - metadata: { userTimestamp: bigint; frameId: number }, + metadata: FrameMetadata, ) { if (!this.room) { return; diff --git a/src/frameMetadata/frameMetadata.test.ts b/src/frameMetadata/frameMetadata.test.ts index 6a172f3e44..2fe0d946de 100644 --- a/src/frameMetadata/frameMetadata.test.ts +++ b/src/frameMetadata/frameMetadata.test.ts @@ -1,11 +1,57 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + PACKET_TRAILER_ENVELOPE_SIZE, + PACKET_TRAILER_FRAME_ID_TAG, + PACKET_TRAILER_MAGIC, + PACKET_TRAILER_TIMESTAMP_TAG, + PACKET_TRAILER_USER_DATA_TAG, appendPacketTrailer, appendPacketTrailerToEncodedFrame, extractPacketTrailer, processPacketTrailer, } from './frameMetadata'; +/** + * Builds a packet trailer (XORed TLVs + envelope) for the given fields. The + * SDK only writes timestamp/frameId, so this mirrors the native sender's TLV + * encoding to exercise user_data extraction. + */ +function buildTrailer( + payload: Uint8Array, + fields: { userTimestamp?: bigint; frameId?: number; userData?: Uint8Array }, +): Uint8Array { + const tlvs: number[] = []; + + if (fields.userTimestamp !== undefined) { + tlvs.push(PACKET_TRAILER_TIMESTAMP_TAG ^ 0xff, 8 ^ 0xff); + for (let i = 7; i >= 0; i -= 1) { + tlvs.push(Number((fields.userTimestamp >> BigInt(i * 8)) & BigInt(0xff)) ^ 0xff); + } + } + + if (fields.frameId !== undefined) { + tlvs.push(PACKET_TRAILER_FRAME_ID_TAG ^ 0xff, 4 ^ 0xff); + for (let i = 3; i >= 0; i -= 1) { + tlvs.push(((fields.frameId >> (i * 8)) & 0xff) ^ 0xff); + } + } + + if (fields.userData !== undefined) { + tlvs.push(PACKET_TRAILER_USER_DATA_TAG ^ 0xff, fields.userData.length ^ 0xff); + for (const byte of fields.userData) { + tlvs.push(byte ^ 0xff); + } + } + + const trailerLength = tlvs.length + PACKET_TRAILER_ENVELOPE_SIZE; + const result = new Uint8Array(payload.length + trailerLength); + result.set(payload, 0); + result.set(tlvs, payload.length); + result[payload.length + tlvs.length] = trailerLength ^ 0xff; + result.set(PACKET_TRAILER_MAGIC, payload.length + tlvs.length + 1); + return result; +} + describe('packetTrailer', () => { afterEach(() => { vi.useRealTimers(); @@ -47,6 +93,43 @@ describe('packetTrailer', () => { }); }); + it('extracts user_data from a trailer carrying only user_data', () => { + const payload = Uint8Array.from([1, 2, 3, 4]); + const userData = Uint8Array.from([0x00, 0x01, 0xfe, 0xff, 0x42]); + const trailer = buildTrailer(payload, { userData }); + const extracted = extractPacketTrailer(trailer); + + expect(Array.from(extracted.data)).toEqual(Array.from(payload)); + expect(extracted.metadata?.userTimestamp).toBe(0n); + expect(extracted.metadata?.frameId).toBe(0); + expect(extracted.metadata?.userData).toBeInstanceOf(Uint8Array); + expect(Array.from(extracted.metadata!.userData!)).toEqual(Array.from(userData)); + }); + + it('extracts user_data alongside timestamp and frameId', () => { + const payload = Uint8Array.from([9, 8, 7, 6, 5]); + const userData = Uint8Array.from([10, 20, 30, 40]); + const trailer = buildTrailer(payload, { + userTimestamp: 1_744_249_600_123_456n, + frameId: 42, + userData, + }); + const extracted = extractPacketTrailer(trailer); + + expect(Array.from(extracted.data)).toEqual(Array.from(payload)); + expect(extracted.metadata?.userTimestamp).toBe(1_744_249_600_123_456n); + expect(extracted.metadata?.frameId).toBe(42); + expect(Array.from(extracted.metadata!.userData!)).toEqual(Array.from(userData)); + }); + + it('leaves userData undefined when no user_data tag is present', () => { + const payload = Uint8Array.from([1, 2, 3, 4]); + const trailer = appendPacketTrailer(payload, 1_744_249_600_123_456n, 42); + const extracted = extractPacketTrailer(trailer); + + expect(extracted.metadata?.userData).toBeUndefined(); + }); + it('returns data unchanged when both timestamp and frameId are 0', () => { const payload = Uint8Array.from([1, 2, 3, 4]); const result = appendPacketTrailer(payload, 0n, 0); diff --git a/src/frameMetadata/frameMetadata.ts b/src/frameMetadata/frameMetadata.ts index 7b8afa4d84..1fe7adca69 100644 --- a/src/frameMetadata/frameMetadata.ts +++ b/src/frameMetadata/frameMetadata.ts @@ -10,6 +10,7 @@ export const PACKET_TRAILER_MAGIC = Uint8Array.from([ export const PACKET_TRAILER_TIMESTAMP_TAG = 0x01; export const PACKET_TRAILER_FRAME_ID_TAG = 0x02; +export const PACKET_TRAILER_USER_DATA_TAG = 0x03; export const PACKET_TRAILER_ENVELOPE_SIZE = 5; const TIMESTAMP_TLV_SIZE = 10; @@ -127,6 +128,13 @@ export function extractPacketTrailer(data: ArrayBuffer | Uint8Array): ExtractPac } else if (tag === PACKET_TRAILER_FRAME_ID_TAG && length === 4) { metadata.frameId = readUint32Xor(bytes, offset, length); foundAny = true; + } else if (tag === PACKET_TRAILER_USER_DATA_TAG) { + const userData = new Uint8Array(length); + for (let index = 0; index < length; index += 1) { + userData[index] = bytes[offset + index] ^ 0xff; + } + metadata.userData = userData; + foundAny = true; } offset += length; diff --git a/src/frameMetadata/types.ts b/src/frameMetadata/types.ts index a96ec245f2..0c31843dee 100644 --- a/src/frameMetadata/types.ts +++ b/src/frameMetadata/types.ts @@ -3,6 +3,7 @@ import type { FrameMetadataPayload } from './frameMetadata'; export interface FrameMetadata { userTimestamp: bigint; frameId: number; + userData?: Uint8Array; } /** @deprecated Use {@link FrameMetadata} instead. */