From 4d4bf31e24bda39dfc380eccde0f2083b63c0c1e Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 6 Mar 2025 12:52:05 +0000 Subject: [PATCH 01/34] Add LocalAudioRecorder helper --- examples/local-recording/demo.ts | 481 ++++++++++++++++++++ examples/local-recording/index.html | 159 +++++++ examples/local-recording/styles.css | 50 ++ examples/local-recording/tsconfig copy.json | 20 + examples/local-recording/tsconfig.json | 20 + package.json | 1 + src/index.ts | 2 + src/room/track/record.ts | 94 ++++ 8 files changed, 827 insertions(+) create mode 100644 examples/local-recording/demo.ts create mode 100644 examples/local-recording/index.html create mode 100644 examples/local-recording/styles.css create mode 100644 examples/local-recording/tsconfig copy.json create mode 100644 examples/local-recording/tsconfig.json create mode 100644 src/room/track/record.ts diff --git a/examples/local-recording/demo.ts b/examples/local-recording/demo.ts new file mode 100644 index 0000000000..1759e2c748 --- /dev/null +++ b/examples/local-recording/demo.ts @@ -0,0 +1,481 @@ +//@ts-ignore +import type { LocalAudioTrack, RoomOptions } from '../../src/index'; +import { + ConnectionState, + DisconnectReason, + LocalAudioRecorder, + LogLevel, + MediaDeviceFailure, + Participant, + ParticipantEvent, + RemoteParticipant, + Room, + RoomEvent, + Track, + createLocalAudioTrack, + setLogLevel, +} from '../../src/index'; + +setLogLevel(LogLevel.debug); + +const $ = (id: string) => document.getElementById(id) as T; + +const state = { + defaultDevices: new Map([['audioinput', 'default']]), + microphoneTrack: undefined as LocalAudioTrack | undefined, + recorder: undefined as LocalAudioRecorder | undefined, + chunks: [] as Blob[], +}; +let currentRoom: Room | undefined; + +let startTime: number; + +const searchParams = new URLSearchParams(window.location.search); +const storedUrl = searchParams.get('url') ?? 'ws://localhost:7880'; +const storedToken = searchParams.get('token') ?? ''; +($('url')).value = storedUrl; +($('token')).value = storedToken; + +function updateSearchParams(url: string, token: string) { + const params = new URLSearchParams({ url, token }); + window.history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`); +} + +// handles actions from the HTML +const appActions = { + connectWithFormInput: async () => { + const url = ($('url')).value; + const token = ($('token')).value; + + if (url && token) { + updateSearchParams(url, token); + try { + await connect(url, token); + } catch (e) { + appendLog('error connecting', e); + } + } else { + appendLog('url and token are required'); + } + }, + + createMicrophoneTrack: async () => { + try { + appendLog('Creating microphone track...'); + const track = await createLocalAudioTrack(); + track.source = Track.Source.Microphone; + state.microphoneTrack = track; + appendLog('Microphone track created successfully'); + updateButtonsForPublishState(); + } catch (e) { + appendLog('Error creating microphone track:', e); + } + }, + + publishMicrophoneTrack: async () => { + if (state.microphoneTrack && currentRoom) { + try { + appendLog('Publishing microphone track...'); + await currentRoom.localParticipant.publishTrack(state.microphoneTrack); + appendLog('Microphone track published successfully'); + updateButtonsForPublishState(); + } catch (e) { + appendLog('Error publishing microphone track:', e); + } + } else { + appendLog('Cannot publish: No microphone track created or not connected to room'); + } + }, + + unpublishMicrophoneTrack: async () => { + if (state.microphoneTrack && currentRoom) { + try { + appendLog('Unpublishing microphone track...'); + await currentRoom.localParticipant.unpublishTrack(state.microphoneTrack); + appendLog('Microphone track unpublished successfully'); + updateButtonsForPublishState(); + } catch (e) { + appendLog('Error unpublishing microphone track:', e); + } + } else { + appendLog('Cannot unpublish: No microphone track created or not connected to room'); + } + }, + + toggleAudioMute: async () => { + if (state.microphoneTrack) { + try { + if (state.microphoneTrack.isMuted) { + appendLog('Unmuting microphone track...'); + await state.microphoneTrack.unmute(); + appendLog('Microphone track unmuted'); + } else { + appendLog('Muting microphone track...'); + await state.microphoneTrack.mute(); + appendLog('Microphone track muted'); + } + updateButtonsForPublishState(); + } catch (e) { + appendLog('Error toggling mute state:', e); + } + } else { + appendLog('Cannot toggle mute: No microphone track created'); + } + }, + + startLocalRecording: async () => { + if (state.microphoneTrack) { + try { + appendLog('Starting local recording...'); + state.recorder = new LocalAudioRecorder(state.microphoneTrack); + appendLog('Local recording started'); + updateButtonsForPublishState(); + } catch (e) { + appendLog('Error starting local recording:', e); + return; + } + const stream = state.recorder.start(); + + for await (const chunk of stream) { + console.log('handle local audio chunk', chunk); + state.chunks.push(chunk); + } + + const blob = new Blob(state.chunks, { type: 'audio/ogg; codecs=opus' }); + const url = URL.createObjectURL(blob); + state.chunks = []; + const a = document.createElement('a'); + a.href = url; + a.download = 'recording.ogg'; + a.click(); + } else { + appendLog('Cannot start recording: No microphone track created'); + } + }, + + stopLocalRecording: async () => { + if (state.recorder) { + try { + appendLog('Stopping local recording...'); + await state.recorder.stop(); + state.recorder = undefined; + updateButtonsForPublishState(); + } catch (e) { + appendLog('Error stopping local recording:', e); + } + } else { + appendLog('Cannot stop recording: No recording in progress'); + } + }, + + handleDeviceSelected: (e: Event) => { + const deviceId = (e.target).value; + const elementId = (e.target).id; + const kind = elementMapping[elementId as keyof typeof elementMapping]; + if (!kind) { + return; + } + + state.defaultDevices.set(kind, deviceId); + + if (currentRoom) { + switch (kind) { + case 'audioinput': + currentRoom.switchActiveDevice(kind, deviceId); + break; + case 'audiooutput': + currentRoom.switchActiveDevice(kind, deviceId); + break; + default: + break; + } + } + }, + + disconnectRoom: () => { + if (currentRoom) { + currentRoom.disconnect(); + } + }, +}; + +declare global { + interface Window { + currentRoom: any; + appActions: typeof appActions; + } +} + +window.appActions = appActions; + +// --------------------------- event handlers ------------------------------- // + +async function participantConnected(participant: Participant) { + appendLog('participant', participant.identity, 'connected', participant.metadata); + participant + .on(ParticipantEvent.TrackMuted, () => { + appendLog('track was muted', participant.identity); + }) + .on(ParticipantEvent.TrackUnmuted, () => { + appendLog('track was unmuted', participant.identity); + }); +} + +function participantDisconnected(participant: RemoteParticipant) { + appendLog('participant', participant.sid, 'disconnected'); +} + +function handleRoomDisconnect(reason?: DisconnectReason) { + if (!currentRoom) return; + appendLog('disconnected from room', { reason }); + + // Stop any active recording + if (state.recorder) { + appendLog('Stopping recording due to room disconnect'); + try { + state.recorder.stop(); + appendLog('Recording stopped due to disconnect'); + } catch (error) { + appendLog('Error stopping recorder on disconnect:', error); + } + state.recorder = undefined; + } + + setButtonsForState(false); + + const container = $('participants-area'); + if (container) { + container.innerHTML = ''; + } + + currentRoom = undefined; + window.currentRoom = undefined; +} + +// -------------------------- rendering helpers ----------------------------- // + +function appendLog(...args: any[]) { + const logger = $('log')!; + for (let i = 0; i < arguments.length; i += 1) { + if (typeof args[i] === 'object') { + logger.innerHTML += `${ + JSON && JSON.stringify ? JSON.stringify(args[i], undefined, 2) : args[i] + } `; + } else { + logger.innerHTML += `${args[i]} `; + } + } + logger.innerHTML += '\n'; + (() => { + logger.scrollTop = logger.scrollHeight; + })(); +} + +// --------------------------- connection handling -------------------------- // + +async function connect(url: string, token: string) { + if (currentRoom) { + appendLog('disconnecting existing room'); + currentRoom.disconnect(); + } + + try { + appendLog('connecting to', url); + const roomOpts: RoomOptions = { + stopLocalTrackOnUnpublish: false, + }; + + startTime = Date.now(); + const room = new Room(roomOpts); + + room + .on(RoomEvent.ParticipantConnected, participantConnected) + .on(RoomEvent.ParticipantDisconnected, participantDisconnected) + .on(RoomEvent.Disconnected, handleRoomDisconnect) + .on(RoomEvent.LocalTrackPublished, () => { + appendLog('Local track published'); + updateButtonsForPublishState(); + }) + .on(RoomEvent.LocalTrackUnpublished, () => { + appendLog('Local track unpublished'); + updateButtonsForPublishState(); + }) + .on(RoomEvent.MediaDevicesError, (e: Error) => { + const failure = MediaDeviceFailure.getFailure(e); + appendLog('media device failure', failure); + }) + .on(RoomEvent.ConnectionStateChanged, (connectionState: ConnectionState) => { + appendLog('connection state changed', connectionState); + }) + .on(RoomEvent.MediaDevicesChanged, handleDevicesChanged); + + await room.connect(url, token); + + currentRoom = room; + window.currentRoom = room; + + appendLog('connected to room', room.name); + appendLog('connection time', Date.now() - startTime); + + // Set button states based on connection + setButtonsForState(true); + + // Acquire device list + await acquireDeviceList(); + + // Update publish state buttons + updateButtonsForPublishState(); + } catch (error) { + appendLog('error connecting to room', error); + } +} + +// ---------------------------- device management --------------------------- // + +const elementMapping = { + 'audio-input': 'audioinput', + 'audio-output': 'audiooutput', +} as const; + +async function handleDevicesChanged() { + Promise.all( + Object.keys(elementMapping).map(async (id) => { + const kind = elementMapping[id as keyof typeof elementMapping]; + if (!kind) { + return; + } + const devices = await Room.getLocalDevices(kind); + const element = $(id); + populateSelect(element, devices, state.defaultDevices.get(kind)); + }), + ); +} + +function populateSelect( + element: HTMLSelectElement, + devices: MediaDeviceInfo[], + selectedDeviceId?: string, +) { + // clear all elements + element.innerHTML = ''; + + for (const device of devices) { + const option = document.createElement('option'); + option.text = device.label; + option.value = device.deviceId; + if (device.deviceId === selectedDeviceId) { + option.selected = true; + } + element.appendChild(option); + } +} + +function updateButtonsForPublishState() { + if (!currentRoom) { + return; + } + const lp = currentRoom.localParticipant; + + // audio + setButtonState( + 'toggle-audio-button', + `${lp.isMicrophoneEnabled ? 'Disable' : 'Enable'} Audio`, + lp.isMicrophoneEnabled, + ); + + // Update microphone track buttons based on state + const hasMicTrack = !!state.microphoneTrack; + const isPublished = + hasMicTrack && + currentRoom.localParticipant + .getTrackPublications() + .some((pub) => pub.track === state.microphoneTrack); + + // Create mic track button + setButtonDisabled('create-mic-track-button', hasMicTrack); + + // Publish/unpublish buttons + setButtonDisabled('publish-mic-track-button', !hasMicTrack || isPublished); + setButtonDisabled('unpublish-mic-track-button', !hasMicTrack || !isPublished); + + // Mute toggle button + setButtonDisabled('toggle-audio-mute-button', !hasMicTrack); + if (hasMicTrack) { + setButtonState( + 'toggle-audio-mute-button', + state.microphoneTrack!.isMuted ? 'Unmute' : 'Mute', + !state.microphoneTrack!.isMuted, + ); + } + + // Recording buttons + const isRecording = !!state.recorder; + setButtonDisabled('start-recording-button', !hasMicTrack || isRecording); + setButtonDisabled('stop-recording-button', !isRecording); +} + +async function acquireDeviceList() { + handleDevicesChanged(); +} + +// -------------------------- button handling ------------------------------ // + +function setButtonState( + buttonId: string, + buttonText: string, + isActive: boolean, + isDisabled: boolean | undefined = undefined, +) { + const el = $(buttonId); + if (!el) return; + if (isDisabled !== undefined) { + el.disabled = isDisabled; + } + el.innerHTML = buttonText; + if (isActive) { + el.classList.add('active'); + } else { + el.classList.remove('active'); + } +} + +function setButtonDisabled(buttonId: string, isDisabled: boolean) { + const el = $(buttonId); + if (el) { + el.disabled = isDisabled; + } +} + +function setButtonsForState(connected: boolean) { + const connectedButtons = [ + 'toggle-audio-button', + 'disconnect-room-button', + 'create-mic-track-button', + ]; + + // Buttons that require both connection and a microphone track + const trackDependentButtons = [ + 'publish-mic-track-button', + 'unpublish-mic-track-button', + 'toggle-audio-mute-button', + 'start-recording-button', + ]; + + connectedButtons.forEach((id) => { + setButtonDisabled(id, !connected); + }); + + // These buttons will be further controlled by updateButtonsForPublishState + // based on microphone track state + trackDependentButtons.forEach((id) => { + setButtonDisabled(id, !connected || !state.microphoneTrack); + }); + + // Connect button disabled when connected + setButtonDisabled('connect-button', connected); + + // If we disconnect, also update stop-recording button + if (!connected) { + setButtonDisabled('stop-recording-button', true); + } +} diff --git a/examples/local-recording/index.html b/examples/local-recording/index.html new file mode 100644 index 0000000000..6df29a0488 --- /dev/null +++ b/examples/local-recording/index.html @@ -0,0 +1,159 @@ + + + + Livekit Simple Demo + + + + + + + +
+
+
+

Livekit Simple Demo

+
+
+
+ LiveKit URL +
+
+ +
+
+ Token +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ + + + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+ +
+ +
+
+ + + diff --git a/examples/local-recording/styles.css b/examples/local-recording/styles.css new file mode 100644 index 0000000000..6171534854 --- /dev/null +++ b/examples/local-recording/styles.css @@ -0,0 +1,50 @@ +#connect-area { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: min-content min-content; + grid-auto-flow: column; + grid-gap: 10px; + margin-bottom: 15px; +} + +#actions-area { + display: grid; + grid-template-columns: fit-content(100px) auto; + grid-gap: 1.25rem; + margin-bottom: 15px; +} + +#mic-track-actions { + display: flex; + justify-content: flex-start; + margin-bottom: 15px; +} + +#recording-actions { + display: flex; + justify-content: flex-start; + margin-bottom: 15px; +} + +#inputs-area { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-gap: 1.25rem; + margin-bottom: 10px; +} + +#participants-area { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; +} + +#log-area { + margin-top: 1.25rem; + margin-bottom: 1rem; +} + +#log { + width: 100%; + height: 200px; +} diff --git a/examples/local-recording/tsconfig copy.json b/examples/local-recording/tsconfig copy.json new file mode 100644 index 0000000000..4d3333f991 --- /dev/null +++ b/examples/local-recording/tsconfig copy.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, + "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true /* Enable all strict type-checking options. */, + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + "skipLibCheck": true /* Skip type checking of declaration files. */, + "noUnusedLocals": true, + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, + "moduleResolution": "node", + "resolveJsonModule": true + }, + "include": ["../../src/**/*", "demo.ts"], + "exclude": ["**/*.test.ts", "build/**/*"] +} diff --git a/examples/local-recording/tsconfig.json b/examples/local-recording/tsconfig.json new file mode 100644 index 0000000000..4d3333f991 --- /dev/null +++ b/examples/local-recording/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, + "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, + "outDir": "build", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true /* Enable all strict type-checking options. */, + "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, + "skipLibCheck": true /* Skip type checking of declaration files. */, + "noUnusedLocals": true, + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, + "moduleResolution": "node", + "resolveJsonModule": true + }, + "include": ["../../src/**/*", "demo.ts"], + "exclude": ["**/*.test.ts", "build/**/*"] +} diff --git a/package.json b/package.json index e9fe6b41a2..bb86ff6eb0 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "build-docs": "typedoc && mkdir -p docs/assets/github && cp .github/*.png docs/assets/github/ && find docs -name '*.html' -type f -exec sed -i.bak 's|=\"/.github/|=\"assets/github/|g' {} + && find docs -name '*.bak' -delete", "proto": "protoc --es_out src/proto --es_opt target=ts -I./protocol ./protocol/livekit_rtc.proto ./protocol/livekit_models.proto", "examples:demo": "vite examples/demo -c vite.config.mjs", + "examples:record": "vite examples/local-recording -c vite.config.mjs", "dev": "pnpm examples:demo", "lint": "eslint src", "test": "vitest run src", diff --git a/src/index.ts b/src/index.ts index 23469d3abb..aec0003fd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -129,3 +129,5 @@ export type { ReconnectContext, ReconnectPolicy, }; + +export { LocalAudioRecorder } from './room/track/record'; diff --git a/src/room/track/record.ts b/src/room/track/record.ts new file mode 100644 index 0000000000..c20ba1298b --- /dev/null +++ b/src/room/track/record.ts @@ -0,0 +1,94 @@ +import { sleep } from '../utils'; +import type LocalAudioTrack from './LocalAudioTrack'; +import type LocalTrack from './LocalTrack'; + +// import type LocalVideoTrack from './LocalVideoTrack'; + +class LocalTrackRecorder { + private mediaRecorder: MediaRecorder; + + private chunks: Blob[] = []; + + private isStopped = false; + + private reader: ReadableStream; + + constructor(track: T) { + const mediaStream = new MediaStream([track.mediaStreamTrack]); + this.mediaRecorder = new MediaRecorder(mediaStream); + + this.reader = new ReadableStream({ + start: async (controller) => { + this.mediaRecorder.addEventListener('dataavailable', (event) => { + if (event.data.size > 0) { + this.chunks.push(event.data); + } + }); + + this.mediaRecorder.addEventListener('stop', () => { + this.isStopped = true; + }); + + this.mediaRecorder.addEventListener('error', (event) => { + controller.error(event); + }); + + while (!this.isStopped) { + const nextChunk = this.chunks.shift(); + if (nextChunk) { + controller.enqueue(nextChunk); + } else { + await sleep(100); + } + } + + controller.close(); + }, + }); + } + + /** + * Start recording and return an iterator for the recorded chunks + * @param timeslice Optional time slice in ms for how often to generate data events + * @returns An iterator that yields recorded Blob chunks + */ + start(timeslice: number = 100): AsyncIterableIterator { + this.mediaRecorder.start(timeslice); + return this.createIterator(); + } + + stop() { + if (!this.isStopped && this.mediaRecorder.state !== 'inactive') { + this.mediaRecorder.stop(); + } + } + + // Private iterator implementation + private createIterator(): AsyncIterableIterator { + const reader = this.reader.getReader(); + + return { + next: async (): Promise> => { + const { done, value } = await reader.read(); + if (done) { + return { done: true, value: undefined as any }; + } else { + return { done: false, value }; + } + }, + + async return(): Promise> { + reader.releaseLock(); + return { done: true, value: undefined }; + }, + + [Symbol.asyncIterator](): AsyncIterableIterator { + return this; + }, + }; + } +} + +export class LocalAudioRecorder extends LocalTrackRecorder {} + +// export class LocalVideoRecorder extends LocalTrackRecorder {} From 1782d3efc55701f993659f48343698e554471249 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 6 Mar 2025 15:40:13 +0000 Subject: [PATCH 02/34] cleanup --- examples/local-recording/demo.ts | 6 +++--- examples/local-recording/tsconfig copy.json | 20 -------------------- 2 files changed, 3 insertions(+), 23 deletions(-) delete mode 100644 examples/local-recording/tsconfig copy.json diff --git a/examples/local-recording/demo.ts b/examples/local-recording/demo.ts index 1759e2c748..c180806129 100644 --- a/examples/local-recording/demo.ts +++ b/examples/local-recording/demo.ts @@ -258,14 +258,14 @@ function appendLog(...args: any[]) { const logger = $('log')!; for (let i = 0; i < arguments.length; i += 1) { if (typeof args[i] === 'object') { - logger.innerHTML += `${ + logger.innerText += `${ JSON && JSON.stringify ? JSON.stringify(args[i], undefined, 2) : args[i] } `; } else { - logger.innerHTML += `${args[i]} `; + logger.innerText += `${args[i]} `; } } - logger.innerHTML += '\n'; + logger.innerText += '\n'; (() => { logger.scrollTop = logger.scrollHeight; })(); diff --git a/examples/local-recording/tsconfig copy.json b/examples/local-recording/tsconfig copy.json deleted file mode 100644 index 4d3333f991..0000000000 --- a/examples/local-recording/tsconfig copy.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, - "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, - "outDir": "build", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "strict": true /* Enable all strict type-checking options. */, - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, - "skipLibCheck": true /* Skip type checking of declaration files. */, - "noUnusedLocals": true, - "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, - "moduleResolution": "node", - "resolveJsonModule": true - }, - "include": ["../../src/**/*", "demo.ts"], - "exclude": ["**/*.test.ts", "build/**/*"] -} From 2ea6895aff0a23664d0b5fa12b80d0f4ed34d03e Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 6 Mar 2025 16:06:40 +0000 Subject: [PATCH 03/34] generalize recorder --- examples/local-recording/demo.ts | 4 ++-- src/index.ts | 2 +- src/room/track/record.ts | 9 +-------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/examples/local-recording/demo.ts b/examples/local-recording/demo.ts index c180806129..e1f6c7d22f 100644 --- a/examples/local-recording/demo.ts +++ b/examples/local-recording/demo.ts @@ -3,7 +3,7 @@ import type { LocalAudioTrack, RoomOptions } from '../../src/index'; import { ConnectionState, DisconnectReason, - LocalAudioRecorder, + LocalTrackRecorder, LogLevel, MediaDeviceFailure, Participant, @@ -127,7 +127,7 @@ const appActions = { if (state.microphoneTrack) { try { appendLog('Starting local recording...'); - state.recorder = new LocalAudioRecorder(state.microphoneTrack); + state.recorder = new LocalTrackRecorder(state.microphoneTrack); appendLog('Local recording started'); updateButtonsForPublishState(); } catch (e) { diff --git a/src/index.ts b/src/index.ts index aec0003fd4..270316784d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -130,4 +130,4 @@ export type { ReconnectPolicy, }; -export { LocalAudioRecorder } from './room/track/record'; +export { LocalTrackRecorder } from './room/track/record'; diff --git a/src/room/track/record.ts b/src/room/track/record.ts index c20ba1298b..abc8c465ee 100644 --- a/src/room/track/record.ts +++ b/src/room/track/record.ts @@ -1,10 +1,7 @@ import { sleep } from '../utils'; -import type LocalAudioTrack from './LocalAudioTrack'; import type LocalTrack from './LocalTrack'; -// import type LocalVideoTrack from './LocalVideoTrack'; - -class LocalTrackRecorder { +export class LocalTrackRecorder { private mediaRecorder: MediaRecorder; private chunks: Blob[] = []; @@ -88,7 +85,3 @@ class LocalTrackRecorder { }; } } - -export class LocalAudioRecorder extends LocalTrackRecorder {} - -// export class LocalVideoRecorder extends LocalTrackRecorder {} From 4aee23c17ed0599d430c7b13c99a823be537f53f Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 6 Mar 2025 16:27:37 +0000 Subject: [PATCH 04/34] uint8 --- examples/local-recording/demo.ts | 4 ++-- src/room/track/record.ts | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/examples/local-recording/demo.ts b/examples/local-recording/demo.ts index e1f6c7d22f..2945ebe874 100644 --- a/examples/local-recording/demo.ts +++ b/examples/local-recording/demo.ts @@ -23,8 +23,8 @@ const $ = (id: string) => document.getElementById(id) as const state = { defaultDevices: new Map([['audioinput', 'default']]), microphoneTrack: undefined as LocalAudioTrack | undefined, - recorder: undefined as LocalAudioRecorder | undefined, - chunks: [] as Blob[], + recorder: undefined as LocalTrackRecorder | undefined, + chunks: [] as Uint8Array[], }; let currentRoom: Room | undefined; diff --git a/src/room/track/record.ts b/src/room/track/record.ts index abc8c465ee..dc662ddb39 100644 --- a/src/room/track/record.ts +++ b/src/room/track/record.ts @@ -4,11 +4,11 @@ import type LocalTrack from './LocalTrack'; export class LocalTrackRecorder { private mediaRecorder: MediaRecorder; - private chunks: Blob[] = []; + private chunks: Uint8Array[] = []; private isStopped = false; - private reader: ReadableStream; + private reader: ReadableStream; constructor(track: T) { const mediaStream = new MediaStream([track.mediaStreamTrack]); @@ -16,9 +16,9 @@ export class LocalTrackRecorder { this.reader = new ReadableStream({ start: async (controller) => { - this.mediaRecorder.addEventListener('dataavailable', (event) => { + this.mediaRecorder.addEventListener('dataavailable', async (event) => { if (event.data.size > 0) { - this.chunks.push(event.data); + this.chunks.push(await event.data.bytes()); } }); @@ -47,9 +47,9 @@ export class LocalTrackRecorder { /** * Start recording and return an iterator for the recorded chunks * @param timeslice Optional time slice in ms for how often to generate data events - * @returns An iterator that yields recorded Blob chunks + * @returns An iterator that yields recorded Uint8Array chunks */ - start(timeslice: number = 100): AsyncIterableIterator { + start(timeslice: number = 100): AsyncIterableIterator { this.mediaRecorder.start(timeslice); return this.createIterator(); } @@ -61,11 +61,11 @@ export class LocalTrackRecorder { } // Private iterator implementation - private createIterator(): AsyncIterableIterator { + private createIterator(): AsyncIterableIterator { const reader = this.reader.getReader(); return { - next: async (): Promise> => { + next: async (): Promise> => { const { done, value } = await reader.read(); if (done) { return { done: true, value: undefined as any }; @@ -74,12 +74,12 @@ export class LocalTrackRecorder { } }, - async return(): Promise> { + async return(): Promise> { reader.releaseLock(); - return { done: true, value: undefined }; + return { done: true, value: undefined as any }; }, - [Symbol.asyncIterator](): AsyncIterableIterator { + [Symbol.asyncIterator](): AsyncIterableIterator { return this; }, }; From 929296ffb873356be9a9745a1f9910dbbb71f595 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 7 Mar 2025 13:23:14 +0000 Subject: [PATCH 05/34] expose state --- src/room/track/record.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/room/track/record.ts b/src/room/track/record.ts index dc662ddb39..7a8932bd10 100644 --- a/src/room/track/record.ts +++ b/src/room/track/record.ts @@ -60,6 +60,10 @@ export class LocalTrackRecorder { } } + get state() { + return this.mediaRecorder.state; + } + // Private iterator implementation private createIterator(): AsyncIterableIterator { const reader = this.reader.getReader(); From cd9b7319361ee3b9176cb524b4b3da02860e7c79 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 8 Apr 2025 16:57:55 +0200 Subject: [PATCH 06/34] use timeslice in constructor as argument --- src/room/track/record.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/room/track/record.ts b/src/room/track/record.ts index 7a8932bd10..e0f68ba67d 100644 --- a/src/room/track/record.ts +++ b/src/room/track/record.ts @@ -10,7 +10,10 @@ export class LocalTrackRecorder { private reader: ReadableStream; - constructor(track: T) { + private timeslice: number; + + constructor(track: T, options: { timeslice?: number } = {}) { + this.timeslice = options.timeslice ?? 100; const mediaStream = new MediaStream([track.mediaStreamTrack]); this.mediaRecorder = new MediaRecorder(mediaStream); @@ -35,7 +38,7 @@ export class LocalTrackRecorder { if (nextChunk) { controller.enqueue(nextChunk); } else { - await sleep(100); + await sleep(this.timeslice * 0.5); } } @@ -49,8 +52,8 @@ export class LocalTrackRecorder { * @param timeslice Optional time slice in ms for how often to generate data events * @returns An iterator that yields recorded Uint8Array chunks */ - start(timeslice: number = 100): AsyncIterableIterator { - this.mediaRecorder.start(timeslice); + start(): AsyncIterableIterator { + this.mediaRecorder.start(this.timeslice); return this.createIterator(); } From 9dc3aab4bb5fc1121ebd694582bb6e0951a12dbf Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 8 Apr 2025 16:58:49 +0200 Subject: [PATCH 07/34] Create brave-beds-burn.md --- .changeset/brave-beds-burn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/brave-beds-burn.md diff --git a/.changeset/brave-beds-burn.md b/.changeset/brave-beds-burn.md new file mode 100644 index 0000000000..fe763b8185 --- /dev/null +++ b/.changeset/brave-beds-burn.md @@ -0,0 +1,5 @@ +--- +"livekit-client": patch +--- + +Add LocalTrackRecorder helper From edd4bb8e406f4dcca474df20f54a3f433ba79448 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 11 Apr 2025 16:30:38 +0200 Subject: [PATCH 08/34] change to super light wrapper --- examples/local-recording/demo.ts | 27 +++++----- src/room/track/record.ts | 93 ++------------------------------ 2 files changed, 16 insertions(+), 104 deletions(-) diff --git a/examples/local-recording/demo.ts b/examples/local-recording/demo.ts index 2945ebe874..236fb6fbed 100644 --- a/examples/local-recording/demo.ts +++ b/examples/local-recording/demo.ts @@ -134,20 +134,19 @@ const appActions = { appendLog('Error starting local recording:', e); return; } - const stream = state.recorder.start(); - - for await (const chunk of stream) { - console.log('handle local audio chunk', chunk); - state.chunks.push(chunk); - } - - const blob = new Blob(state.chunks, { type: 'audio/ogg; codecs=opus' }); - const url = URL.createObjectURL(blob); - state.chunks = []; - const a = document.createElement('a'); - a.href = url; - a.download = 'recording.ogg'; - a.click(); + state.recorder.addEventListener('dataavailable', async (event) => { + state.chunks.push(await event.data.bytes()); + }); + state.recorder.addEventListener('stop', () => { + const blob = new Blob(state.chunks, { type: 'audio/ogg; codecs=opus' }); + const url = URL.createObjectURL(blob); + state.chunks = []; + const a = document.createElement('a'); + a.href = url; + a.download = 'recording.ogg'; + a.click(); + }); + state.recorder.start(); } else { appendLog('Cannot start recording: No microphone track created'); } diff --git a/src/room/track/record.ts b/src/room/track/record.ts index e0f68ba67d..918e933712 100644 --- a/src/room/track/record.ts +++ b/src/room/track/record.ts @@ -1,94 +1,7 @@ -import { sleep } from '../utils'; import type LocalTrack from './LocalTrack'; -export class LocalTrackRecorder { - private mediaRecorder: MediaRecorder; - - private chunks: Uint8Array[] = []; - - private isStopped = false; - - private reader: ReadableStream; - - private timeslice: number; - - constructor(track: T, options: { timeslice?: number } = {}) { - this.timeslice = options.timeslice ?? 100; - const mediaStream = new MediaStream([track.mediaStreamTrack]); - this.mediaRecorder = new MediaRecorder(mediaStream); - - this.reader = new ReadableStream({ - start: async (controller) => { - this.mediaRecorder.addEventListener('dataavailable', async (event) => { - if (event.data.size > 0) { - this.chunks.push(await event.data.bytes()); - } - }); - - this.mediaRecorder.addEventListener('stop', () => { - this.isStopped = true; - }); - - this.mediaRecorder.addEventListener('error', (event) => { - controller.error(event); - }); - - while (!this.isStopped) { - const nextChunk = this.chunks.shift(); - if (nextChunk) { - controller.enqueue(nextChunk); - } else { - await sleep(this.timeslice * 0.5); - } - } - - controller.close(); - }, - }); - } - - /** - * Start recording and return an iterator for the recorded chunks - * @param timeslice Optional time slice in ms for how often to generate data events - * @returns An iterator that yields recorded Uint8Array chunks - */ - start(): AsyncIterableIterator { - this.mediaRecorder.start(this.timeslice); - return this.createIterator(); - } - - stop() { - if (!this.isStopped && this.mediaRecorder.state !== 'inactive') { - this.mediaRecorder.stop(); - } - } - - get state() { - return this.mediaRecorder.state; - } - - // Private iterator implementation - private createIterator(): AsyncIterableIterator { - const reader = this.reader.getReader(); - - return { - next: async (): Promise> => { - const { done, value } = await reader.read(); - if (done) { - return { done: true, value: undefined as any }; - } else { - return { done: false, value }; - } - }, - - async return(): Promise> { - reader.releaseLock(); - return { done: true, value: undefined as any }; - }, - - [Symbol.asyncIterator](): AsyncIterableIterator { - return this; - }, - }; +export class LocalTrackRecorder extends MediaRecorder { + constructor(track: T, options?: MediaRecorderOptions) { + super(new MediaStream([track.mediaStreamTrack]), options); } } From eb4afb69896926e8caf6574f480095c9ea27e840 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 29 Apr 2025 18:11:09 +0200 Subject: [PATCH 09/34] preconnectbuffer methods --- src/room/events.ts | 5 +++++ src/room/track/LocalTrack.ts | 36 ++++++++++++++++++++++++++++++++++++ src/room/track/Track.ts | 1 + 3 files changed, 42 insertions(+) diff --git a/src/room/events.ts b/src/room/events.ts index 7dcf0004da..ba5abfb1a2 100644 --- a/src/room/events.ts +++ b/src/room/events.ts @@ -643,4 +643,9 @@ export enum TrackEvent { * @experimental */ TimeSyncUpdate = 'timeSyncUpdate', + + /** + * @internal + */ + PreConnectBufferFlushed = 'preConnectBufferFlushed', } diff --git a/src/room/track/LocalTrack.ts b/src/room/track/LocalTrack.ts index 6a97e5e558..8dd0ef45bb 100644 --- a/src/room/track/LocalTrack.ts +++ b/src/room/track/LocalTrack.ts @@ -9,6 +9,7 @@ import { compareVersions, isMobile, sleep, unwrapConstraint } from '../utils'; import { Track, attachToElement, detachTrack } from './Track'; import type { VideoCodec } from './options'; import type { TrackProcessor } from './processor/types'; +import { LocalTrackRecorder } from './record'; import type { ReplaceTrackOptions } from './types'; const defaultDimensionsTimeout = 1000; @@ -55,6 +56,10 @@ export default abstract class LocalTrack< protected manuallyStopped: boolean = false; + protected preConnectBuffer: Uint8Array[] = []; + + protected localTrackRecorder: LocalTrackRecorder | undefined; + private restartLock: Mutex; /** @@ -585,5 +590,36 @@ export default abstract class LocalTrack< this.emit(TrackEvent.TrackProcessorUpdate); } + startPreConnectBuffer() { + if (!this.localTrackRecorder) { + this.localTrackRecorder = new LocalTrackRecorder(this); + } else { + this.log.warn('preconnect buffer already started'); + return; + } + this.localTrackRecorder.addEventListener('dataavailable', async (event) => { + this.preConnectBuffer.push(await event.data.bytes()); + }); + this.localTrackRecorder.start(); + } + + /** @internal */ + flushPreConnectBuffer() { + if (this.localTrackRecorder) { + this.localTrackRecorder.stop(); + this.emit(TrackEvent.PreConnectBufferFlushed, this.preConnectBuffer); + } + this.localTrackRecorder = undefined; + this.preConnectBuffer = []; + } + + cancelPreConnectBuffer() { + if (this.localTrackRecorder) { + this.localTrackRecorder.stop(); + this.localTrackRecorder = undefined; + } + this.preConnectBuffer = []; + } + protected abstract monitorSender(): void; } diff --git a/src/room/track/Track.ts b/src/room/track/Track.ts index 718badbc30..66613917f7 100644 --- a/src/room/track/Track.ts +++ b/src/room/track/Track.ts @@ -528,4 +528,5 @@ export type TrackEventCallbacks = { trackProcessorUpdate: (processor?: TrackProcessor) => void; audioTrackFeatureUpdate: (track: any, feature: AudioTrackFeature, enabled: boolean) => void; timeSyncUpdate: (update: { timestamp: number; rtpTimestamp: number }) => void; + preConnectBufferFlushed: (buffer: Uint8Array[]) => void; }; From 7a60314062009546c06bb5f3708e003866122dfb Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 5 May 2025 10:29:57 +0200 Subject: [PATCH 10/34] wip parallel publish --- examples/demo/demo.ts | 1 + src/room/participant/LocalParticipant.ts | 45 ++++++++++++++++++++++++ src/room/track/LocalTrack.ts | 7 +++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index 9a3043fc51..6351342119 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -265,6 +265,7 @@ const appActions = { timestamp: info.timestamp, message: await reader.readAll(), }, + room.getParticipantByIdentity(participant?.identity), ); diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 81984716fd..542bb1b1f1 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -1,6 +1,7 @@ import { Mutex } from '@livekit/mutex'; import { AddTrackRequest, + AudioTrackFeature, BackupCodecPolicy, ChatMessage as ChatMessageModel, Codec, @@ -970,6 +971,49 @@ export default class LocalParticipant extends Participant { track.on(TrackEvent.UpstreamResumed, this.onTrackUpstreamResumed); track.on(TrackEvent.AudioTrackFeatureUpdate, this.onTrackFeatureUpdate); + const audioFeatures: AudioTrackFeature[] = []; + if (isLocalAudioTrack(track) && track.hasPreConnectBuffer) { + audioFeatures.push(AudioTrackFeature.TF_PRECONNECT_BUFFER); + const buffer = track.flushPreConnectBuffer(); + if (buffer.length > 0) { + this.log.debug('sending preconnect buffer', { + ...this.logContext, + ...getLogContextFromTrack(track), + }); + const bufferStreamPromise = new Promise(async (resolve, reject) => { + try { + const writer = await this.streamBytes({ + name: 'preconnect-buffer', + mimeType: 'audio/opus', + topic: 'lk.preconnect-buffer', + totalSize: buffer.reduce((acc, curr) => acc + curr.byteLength, 0), + }); + for (const chunk of buffer) { + await writer.write(chunk); + } + await writer.close(); + resolve(); + } catch (e) { + reject(e); + } + }); + bufferStreamPromise + .then(() => { + this.log.debug('preconnect buffer sent', { + ...this.logContext, + ...getLogContextFromTrack(track), + }); + }) + .catch((e) => { + this.log.error('error sending preconnect buffer', { + ...this.logContext, + ...getLogContextFromTrack(track), + error: e, + }); + }); + } + } + // create track publication from track const req = new AddTrackRequest({ // get local track id for use during publishing @@ -984,6 +1028,7 @@ export default class LocalParticipant extends Participant { disableRed: this.isE2EEEnabled || !(opts.red ?? true), stream: opts?.stream, backupCodecPolicy: opts?.backupCodecPolicy as BackupCodecPolicy, + audioFeatures, }); // compute encodings and layers for video diff --git a/src/room/track/LocalTrack.ts b/src/room/track/LocalTrack.ts index 8dd0ef45bb..c03465fee5 100644 --- a/src/room/track/LocalTrack.ts +++ b/src/room/track/LocalTrack.ts @@ -36,6 +36,10 @@ export default abstract class LocalTrack< return this._constraints; } + get hasPreConnectBuffer() { + return this.preConnectBuffer.length > 0; + } + protected _constraints: MediaTrackConstraints; protected reacquireTrack: boolean; @@ -607,10 +611,11 @@ export default abstract class LocalTrack< flushPreConnectBuffer() { if (this.localTrackRecorder) { this.localTrackRecorder.stop(); - this.emit(TrackEvent.PreConnectBufferFlushed, this.preConnectBuffer); } + const buffer = this.preConnectBuffer; this.localTrackRecorder = undefined; this.preConnectBuffer = []; + return buffer; } cancelPreConnectBuffer() { From d87a46e2d79848ae31fabbb3bf91cd27b0b616e1 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 9 May 2025 09:19:04 +0200 Subject: [PATCH 11/34] options as features --- package.json | 2 +- pnpm-lock.yaml | 18 +++++++++--------- src/room/participant/LocalParticipant.ts | 22 +++++++++++++++++++++- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 1be6d04a7e..b78a166ed6 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ }, "dependencies": { "@livekit/mutex": "1.1.1", - "@livekit/protocol": "1.36.1", + "@livekit/protocol": "1.38.0", "events": "^3.3.0", "loglevel": "^1.9.2", "sdp-transform": "^2.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 198f131bb1..8bcd83f21f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: 1.1.1 version: 1.1.1 '@livekit/protocol': - specifier: 1.36.1 - version: 1.36.1 + specifier: 1.38.0 + version: 1.38.0 events: specifier: ^3.3.0 version: 3.3.0 @@ -975,8 +975,8 @@ packages: '@livekit/mutex@1.1.1': resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} - '@livekit/protocol@1.36.1': - resolution: {integrity: sha512-nN3QnITAQ5yXk7UKfotH7CRWIlEozNWeKVyFJ0/+dtSzvWP/ib+10l1DDnRYi3A1yICJOGAKFgJ5d6kmi1HCUA==} + '@livekit/protocol@1.38.0': + resolution: {integrity: sha512-XX6ulvsE1XCN18LVf3ydHN7Ri1Z1M1P5dQdjnm5nVDsSqUL12Vbo/4RKcRlCEXAg2qB62mKjcaVLXVwkfXggkg==} '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -3197,8 +3197,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.9.0-dev.20250407: - resolution: {integrity: sha512-JW8/Our6MR+QYS3M134UaLWtEYdVXWzwlbg6rj3fmF9TppADEdaSNiJK90M2wmfSuu5j8Nefk93oSrZF03JkGw==} + typescript@5.9.0-dev.20250508: + resolution: {integrity: sha512-vTtyza+uNzjJO/NgvQxsZkopsalnGdtipQo/lz2rdJ4i+wOdWQuOZxGgaUa2Fi8vj/4Xp6chlfpisgOI3mxvOQ==} engines: {node: '>=14.17'} hasBin: true @@ -4476,7 +4476,7 @@ snapshots: '@livekit/mutex@1.1.1': {} - '@livekit/protocol@1.36.1': + '@livekit/protocol@1.38.0': dependencies: '@bufbuild/protobuf': 1.10.0 @@ -5318,7 +5318,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 5.9.0-dev.20250407 + typescript: 5.9.0-dev.20250508 dunder-proto@1.0.1: dependencies: @@ -6899,7 +6899,7 @@ snapshots: typescript@5.8.3: {} - typescript@5.9.0-dev.20250407: {} + typescript@5.9.0-dev.20250508: {} uc.micro@2.1.0: {} diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 542bb1b1f1..2d0c4a888e 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -972,6 +972,26 @@ export default class LocalParticipant extends Participant { track.on(TrackEvent.AudioTrackFeatureUpdate, this.onTrackFeatureUpdate); const audioFeatures: AudioTrackFeature[] = []; + const disableDtx = !(opts.dtx ?? true); + + const settings = track.getSourceTrackSettings(); + + if (settings.autoGainControl) { + audioFeatures.push(AudioTrackFeature.TF_AUTO_GAIN_CONTROL); + } + if (settings.echoCancellation) { + audioFeatures.push(AudioTrackFeature.TF_ECHO_CANCELLATION); + } + if (settings.noiseSuppression) { + audioFeatures.push(AudioTrackFeature.TF_NOISE_SUPPRESSION); + } + if (settings.channelCount && settings.channelCount > 1) { + audioFeatures.push(AudioTrackFeature.TF_STEREO); + } + if (disableDtx) { + audioFeatures.push(AudioTrackFeature.TF_NO_DTX); + } + if (isLocalAudioTrack(track) && track.hasPreConnectBuffer) { audioFeatures.push(AudioTrackFeature.TF_PRECONNECT_BUFFER); const buffer = track.flushPreConnectBuffer(); @@ -1022,7 +1042,7 @@ export default class LocalParticipant extends Participant { type: Track.kindToProto(track.kind), muted: track.isMuted, source: Track.sourceToProto(track.source), - disableDtx: !(opts.dtx ?? true), + disableDtx, encryption: this.encryptionType, stereo: isStereo, disableRed: this.isE2EEEnabled || !(opts.red ?? true), From bddf3947877bb93fd2ab6cd6e4da993cb32f32d6 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 14 May 2025 15:00:28 +0200 Subject: [PATCH 12/34] Add ParticipantActive event to signal data message readiness --- examples/demo/demo.ts | 7 +++++-- src/room/Room.ts | 4 ++++ src/room/events.ts | 12 ++++++++++++ src/room/participant/Participant.ts | 10 +++++++++- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index 9a3043fc51..f14923b2dd 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -168,6 +168,7 @@ const appActions = { appendLog(`prewarmed connection in ${prewarmTime}ms`); room + .on(RoomEvent.ParticipantConnected, participantConnected) .on(RoomEvent.ParticipantDisconnected, participantDisconnected) .on(RoomEvent.ChatMessage, handleChatMessage) @@ -179,6 +180,10 @@ const appActions = { await room.engine.getConnectedServerAddress(), ); }) + .on(RoomEvent.ParticipantActive, async (participant) => { + appendLog(`participant ${participant.identity} is active and ready to receive messages`); + await sendGreetingTo(participant); + }) .on(RoomEvent.ActiveDeviceChanged, handleActiveDeviceChanged) .on(RoomEvent.LocalTrackPublished, (pub) => { const track = pub.track; @@ -652,8 +657,6 @@ async function participantConnected(participant: Participant) { .on(ParticipantEvent.ConnectionQualityChanged, () => { renderParticipant(participant); }); - - await sendGreetingTo(participant); } function participantDisconnected(participant: RemoteParticipant) { diff --git a/src/room/Room.ts b/src/room/Room.ts index b69543e04c..309495626a 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -2240,6 +2240,9 @@ class Room extends (EventEmitter as new () => TypedEmitter) status, participant, ); + }) + .on(ParticipantEvent.Active, () => { + this.emitWhenConnected(RoomEvent.ParticipantActive, participant); }); // update info at the end after callbacks have been set up @@ -2714,4 +2717,5 @@ export type RoomEventCallbacks = { chatMessage: (message: ChatMessage, participant?: RemoteParticipant | LocalParticipant) => void; localTrackSubscribed: (publication: LocalTrackPublication, participant: LocalParticipant) => void; metricsReceived: (metrics: MetricsBatch, participant?: Participant) => void; + participantActive: (participant: Participant) => void; }; diff --git a/src/room/events.ts b/src/room/events.ts index 84a339eee5..b0a93a072b 100644 --- a/src/room/events.ts +++ b/src/room/events.ts @@ -203,6 +203,13 @@ export enum RoomEvent { */ ParticipantAttributesChanged = 'participantAttributesChanged', + /** + * Emitted when the participant's state changes to active and is ready to send/receive data messages + * + * args: (participant: [[Participant]]) + */ + ParticipantActive = 'participantActive', + /** * Room metadata is a simple way for app-specific state to be pushed to * all users. @@ -540,6 +547,11 @@ export enum ParticipantEvent { /** only emitted on local participant */ ChatMessage = 'chatMessage', + + /** + * Emitted when the participant's state changes to active and is ready to send/receive data messages + */ + Active = 'active', } /** @internal */ diff --git a/src/room/participant/Participant.ts b/src/room/participant/Participant.ts index b0f7794305..860ad184dc 100644 --- a/src/room/participant/Participant.ts +++ b/src/room/participant/Participant.ts @@ -1,6 +1,7 @@ import { DataPacket_Kind, ParticipantInfo, + ParticipantInfo_State, ParticipantInfo_Kind as ParticipantKind, ParticipantPermission, ConnectionQuality as ProtoQuality, @@ -110,6 +111,10 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter return this.permissions?.agent || this.kind === ParticipantKind.AGENT; } + get isActive() { + return this.participantInfo?.state === ParticipantInfo_State.ACTIVE; + } + get kind() { return this._kind; } @@ -224,12 +229,14 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter this._setName(info.name); this._setMetadata(info.metadata); this._setAttributes(info.attributes); + if (info.state === ParticipantInfo_State.ACTIVE) { + this.emit(ParticipantEvent.Active); + } if (info.permission) { this.setPermissions(info.permission); } // set this last so setMetadata can detect changes this.participantInfo = info; - this.log.trace('update participant info', { ...this.logContext, info }); return true; } @@ -387,4 +394,5 @@ export type ParticipantEventCallbacks = { attributesChanged: (changedAttributes: Record) => void; localTrackSubscribed: (trackPublication: LocalTrackPublication) => void; chatMessage: (msg: ChatMessage) => void; + active: () => void; }; From c9be283e58e1098ea151ffd071c32ff538c38c65 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 14 May 2025 15:02:12 +0200 Subject: [PATCH 13/34] whitespace --- examples/demo/demo.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/demo/demo.ts b/examples/demo/demo.ts index f14923b2dd..e4c211bfda 100644 --- a/examples/demo/demo.ts +++ b/examples/demo/demo.ts @@ -168,7 +168,6 @@ const appActions = { appendLog(`prewarmed connection in ${prewarmTime}ms`); room - .on(RoomEvent.ParticipantConnected, participantConnected) .on(RoomEvent.ParticipantDisconnected, participantDisconnected) .on(RoomEvent.ChatMessage, handleChatMessage) From 428a3b814ccbcb02f44f2fb230e27fe108ca5ebf Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 14 May 2025 15:02:39 +0200 Subject: [PATCH 14/34] Create nice-moons-clap.md --- .changeset/nice-moons-clap.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nice-moons-clap.md diff --git a/.changeset/nice-moons-clap.md b/.changeset/nice-moons-clap.md new file mode 100644 index 0000000000..4c566c2b6b --- /dev/null +++ b/.changeset/nice-moons-clap.md @@ -0,0 +1,5 @@ +--- +"livekit-client": patch +--- + +Add ParticipantActive event to signal data message readiness From df8ef6513b60fe71c2d279b4c0d29046577df6aa Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 14 May 2025 15:14:02 +0200 Subject: [PATCH 15/34] address comments --- src/room/events.ts | 4 ++-- src/room/participant/Participant.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/room/events.ts b/src/room/events.ts index b0a93a072b..b9bee902f9 100644 --- a/src/room/events.ts +++ b/src/room/events.ts @@ -204,7 +204,7 @@ export enum RoomEvent { ParticipantAttributesChanged = 'participantAttributesChanged', /** - * Emitted when the participant's state changes to active and is ready to send/receive data messages + * Emitted when the participant's state changes to ACTIVE and is ready to send/receive data messages * * args: (participant: [[Participant]]) */ @@ -549,7 +549,7 @@ export enum ParticipantEvent { ChatMessage = 'chatMessage', /** - * Emitted when the participant's state changes to active and is ready to send/receive data messages + * Emitted when the participant's state changes to ACTIVE and is ready to send/receive data messages */ Active = 'active', } diff --git a/src/room/participant/Participant.ts b/src/room/participant/Participant.ts index 860ad184dc..85b6eb438a 100644 --- a/src/room/participant/Participant.ts +++ b/src/room/participant/Participant.ts @@ -229,7 +229,10 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter this._setName(info.name); this._setMetadata(info.metadata); this._setAttributes(info.attributes); - if (info.state === ParticipantInfo_State.ACTIVE) { + if ( + info.state === ParticipantInfo_State.ACTIVE && + this.participantInfo?.state !== ParticipantInfo_State.ACTIVE + ) { this.emit(ParticipantEvent.Active); } if (info.permission) { From daad392af8ab22db4524bb0878ee09735c2e4e67 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 14 May 2025 15:19:05 +0200 Subject: [PATCH 16/34] add waitUntil helper --- src/room/participant/Participant.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/room/participant/Participant.ts b/src/room/participant/Participant.ts index 85b6eb438a..6c590a9f38 100644 --- a/src/room/participant/Participant.ts +++ b/src/room/participant/Participant.ts @@ -178,6 +178,21 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter } } + /** + * Waits until the participant is active and ready to send/receive data messages + * @returns a promise that resolves when the participant is active + */ + waitUntilActive(): Promise { + if (this.isActive) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + this.once(ParticipantEvent.Active, () => { + resolve(true); + }); + }); + } + get connectionQuality(): ConnectionQuality { return this._connectionQuality; } From d33b6e1141afe92b3eee41880abef7510aa6ff83 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 14 May 2025 15:20:53 +0200 Subject: [PATCH 17/34] comment --- src/room/participant/Participant.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/participant/Participant.ts b/src/room/participant/Participant.ts index 6c590a9f38..500665dac7 100644 --- a/src/room/participant/Participant.ts +++ b/src/room/participant/Participant.ts @@ -179,7 +179,7 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter } /** - * Waits until the participant is active and ready to send/receive data messages + * Waits until the participant is active and ready to receive data messages * @returns a promise that resolves when the participant is active */ waitUntilActive(): Promise { From 2ba10c30af0af83c14857f0be364a77840bf3719 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 14 May 2025 15:31:04 +0200 Subject: [PATCH 18/34] reject when disconnected --- src/room/Room.ts | 1 + src/room/participant/Participant.ts | 28 +++++++++++++++++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index 309495626a..2d039df3b1 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -1644,6 +1644,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) participant.unpublishTrack(publication.trackSid, true); }); this.emit(RoomEvent.ParticipantDisconnected, participant); + participant.setDisconnected(); this.localParticipant?.handleParticipantDisconnected(participant.identity); } diff --git a/src/room/participant/Participant.ts b/src/room/participant/Participant.ts index 500665dac7..60fe8bce0f 100644 --- a/src/room/participant/Participant.ts +++ b/src/room/participant/Participant.ts @@ -19,7 +19,7 @@ import { Track } from '../track/Track'; import type { TrackPublication } from '../track/TrackPublication'; import { diffAttributes } from '../track/utils'; import type { ChatMessage, LoggerOptions, TranscriptionSegment } from '../types'; -import { isAudioTrack } from '../utils'; +import { Future, isAudioTrack } from '../utils'; export enum ConnectionQuality { Excellent = 'excellent', @@ -94,6 +94,8 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter protected loggerOptions?: LoggerOptions; + protected activeFuture?: Future; + protected get logContext() { return { ...this.loggerOptions?.loggerContextCb?.(), @@ -182,15 +184,17 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter * Waits until the participant is active and ready to receive data messages * @returns a promise that resolves when the participant is active */ - waitUntilActive(): Promise { + waitUntilActive(): Promise { if (this.isActive) { - return Promise.resolve(true); + return Promise.resolve(); } - return new Promise((resolve) => { - this.once(ParticipantEvent.Active, () => { - resolve(true); - }); + + const future = new Future(); + + this.once(ParticipantEvent.Active, () => { + future.resolve?.(); }); + return future.promise; } get connectionQuality(): ConnectionQuality { @@ -335,6 +339,16 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter } } + /** + * @internal + */ + setDisconnected() { + if (this.activeFuture) { + this.activeFuture.reject?.(new Error('Participant disconnected')); + this.activeFuture = undefined; + } + } + /** * @internal */ From d0e6dc993b09663f278d0f9030cec96103eae252 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 14 May 2025 15:42:07 +0200 Subject: [PATCH 19/34] fix state handling --- src/room/participant/Participant.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/room/participant/Participant.ts b/src/room/participant/Participant.ts index 60fe8bce0f..1d641c549e 100644 --- a/src/room/participant/Participant.ts +++ b/src/room/participant/Participant.ts @@ -94,7 +94,7 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter protected loggerOptions?: LoggerOptions; - protected activeFuture?: Future; + protected activeFuture?: Future; protected get logContext() { return { @@ -189,12 +189,17 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter return Promise.resolve(); } - const future = new Future(); + if (this.activeFuture) { + return this.activeFuture.promise; + } + + this.activeFuture = new Future(); this.once(ParticipantEvent.Active, () => { - future.resolve?.(); + this.activeFuture?.resolve?.(); + this.activeFuture = undefined; }); - return future.promise; + return this.activeFuture.promise; } get connectionQuality(): ConnectionQuality { From 2ab789e623c777e90f524d6a31f91176ffc8d8de Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 14 May 2025 18:04:22 +0200 Subject: [PATCH 20/34] wip with bytestream --- src/room/participant/LocalParticipant.ts | 17 ++++++++--- src/room/track/LocalTrack.ts | 27 +++++------------ src/room/track/record.ts | 38 ++++++++++++++++++++++++ tsconfig.json | 9 +++++- 4 files changed, 67 insertions(+), 24 deletions(-) diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 6256489d02..bf614af2c3 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -994,8 +994,18 @@ export default class LocalParticipant extends Participant { if (isLocalAudioTrack(track) && track.hasPreConnectBuffer) { audioFeatures.push(AudioTrackFeature.TF_PRECONNECT_BUFFER); - const buffer = track.flushPreConnectBuffer(); - if (buffer.length > 0) { + const stream = track.getPreConnectBuffer(); + this.on(ParticipantEvent.LocalTrackSubscribed, (publication) => { + if (publication.track!.mediaStreamTrack.id === track.mediaStreamTrack.id) { + this.log.debug('sending preconnect buffer', { + ...this.logContext, + ...getLogContextFromTrack(track), + }); + track.stopPreConnectBuffer(); + } + }); + + if (stream) { this.log.debug('sending preconnect buffer', { ...this.logContext, ...getLogContextFromTrack(track), @@ -1006,9 +1016,8 @@ export default class LocalParticipant extends Participant { name: 'preconnect-buffer', mimeType: 'audio/opus', topic: 'lk.preconnect-buffer', - totalSize: buffer.reduce((acc, curr) => acc + curr.byteLength, 0), }); - for (const chunk of buffer) { + for await (const chunk of stream) { await writer.write(chunk); } await writer.close(); diff --git a/src/room/track/LocalTrack.ts b/src/room/track/LocalTrack.ts index c03465fee5..482874e356 100644 --- a/src/room/track/LocalTrack.ts +++ b/src/room/track/LocalTrack.ts @@ -37,7 +37,7 @@ export default abstract class LocalTrack< } get hasPreConnectBuffer() { - return this.preConnectBuffer.length > 0; + return !!this.localTrackRecorder; } protected _constraints: MediaTrackConstraints; @@ -60,8 +60,6 @@ export default abstract class LocalTrack< protected manuallyStopped: boolean = false; - protected preConnectBuffer: Uint8Array[] = []; - protected localTrackRecorder: LocalTrackRecorder | undefined; private restartLock: Mutex; @@ -601,29 +599,20 @@ export default abstract class LocalTrack< this.log.warn('preconnect buffer already started'); return; } - this.localTrackRecorder.addEventListener('dataavailable', async (event) => { - this.preConnectBuffer.push(await event.data.bytes()); - }); - this.localTrackRecorder.start(); - } - /** @internal */ - flushPreConnectBuffer() { - if (this.localTrackRecorder) { - this.localTrackRecorder.stop(); - } - const buffer = this.preConnectBuffer; - this.localTrackRecorder = undefined; - this.preConnectBuffer = []; - return buffer; + this.localTrackRecorder.start(100); } - cancelPreConnectBuffer() { + stopPreConnectBuffer() { if (this.localTrackRecorder) { this.localTrackRecorder.stop(); this.localTrackRecorder = undefined; } - this.preConnectBuffer = []; + } + + /** @internal */ + getPreConnectBuffer() { + return this.localTrackRecorder?.byteStream; } protected abstract monitorSender(): void; diff --git a/src/room/track/record.ts b/src/room/track/record.ts index 918e933712..2ffc2de505 100644 --- a/src/room/track/record.ts +++ b/src/room/track/record.ts @@ -1,7 +1,45 @@ import type LocalTrack from './LocalTrack'; export class LocalTrackRecorder extends MediaRecorder { + byteStream: ReadableStream; + constructor(track: T, options?: MediaRecorderOptions) { super(new MediaStream([track.mediaStreamTrack]), options); + + let dataListener: (event: BlobEvent) => void; + + let streamController: ReadableStreamDefaultController | undefined; + + const onStop = () => { + this.removeEventListener('dataavailable', dataListener); + this.removeEventListener('stop', onStop); + this.removeEventListener('error', onError); + streamController?.close(); + streamController = undefined; + }; + + const onError = (event: Event) => { + streamController?.error(event); + this.removeEventListener('dataavailable', dataListener); + this.removeEventListener('stop', onStop); + this.removeEventListener('error', onError); + streamController = undefined; + }; + + this.byteStream = new ReadableStream({ + start: (controller) => { + streamController = controller; + dataListener = async (event) => { + controller.enqueue(await event.data.bytes()); + }; + this.addEventListener('dataavailable', dataListener); + }, + cancel: () => { + onStop(); + }, + }); + + this.addEventListener('stop', onStop); + this.addEventListener('error', onError); } } diff --git a/tsconfig.json b/tsconfig.json index 3e8739afec..672494bd35 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,14 @@ "types": ["sdp-transform", "ua-parser-js", "events"], "target": "ES2015" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, "module": "ES2020" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, - "lib": ["DOM", "DOM.Iterable", "ES2017", "ES2018.Promise", "ES2021.WeakRef"], + "lib": [ + "DOM", + "DOM.Iterable", + "ES2017", + "ES2018.Promise", + "ES2021.WeakRef", + "DOM.AsyncIterable" + ], "rootDir": "./", "outDir": "dist", "declaration": true, From d1edadca75d390349df9ade19521aa7ec70d45a8 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 15 May 2025 15:34:36 +0200 Subject: [PATCH 21/34] preconnect integration working --- src/room/Room.ts | 5 +- src/room/defaults.ts | 1 + src/room/participant/LocalParticipant.ts | 162 ++++++++++++++++------- src/room/track/LocalTrack.ts | 19 ++- src/room/track/options.ts | 9 ++ src/room/track/record.ts | 10 +- 6 files changed, 151 insertions(+), 55 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index 2d039df3b1..ac23ffddfe 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -65,7 +65,7 @@ import { ConnectionError, ConnectionErrorReason, UnsupportedServer } from './err import { EngineEvent, ParticipantEvent, RoomEvent, TrackEvent } from './events'; import LocalParticipant from './participant/LocalParticipant'; import type Participant from './participant/Participant'; -import type { ConnectionQuality } from './participant/Participant'; +import { type ConnectionQuality, ParticipantKind } from './participant/Participant'; import RemoteParticipant from './participant/RemoteParticipant'; import { MAX_PAYLOAD_BYTES, RpcError, type RpcInvocationData, byteLength } from './rpc'; import CriticalTimers from './timers'; @@ -2244,6 +2244,9 @@ class Room extends (EventEmitter as new () => TypedEmitter) }) .on(ParticipantEvent.Active, () => { this.emitWhenConnected(RoomEvent.ParticipantActive, participant); + if (participant.kind === ParticipantKind.AGENT) { + this.localParticipant.setActiveAgent(participant); + } }); // update info at the end after callbacks have been set up diff --git a/src/room/defaults.ts b/src/room/defaults.ts index a0ed5b0719..1da7f16de3 100644 --- a/src/room/defaults.ts +++ b/src/room/defaults.ts @@ -19,6 +19,7 @@ export const publishDefaults: TrackPublishDefaults = { stopMicTrackOnMute: false, videoCodec: defaultVideoCodec, backupCodec: true, + preConnectBuffer: false, } as const; export const audioDefaults: AudioCaptureOptions = { diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index bf614af2c3..a8cb2762aa 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -103,6 +103,7 @@ import { import Participant from './Participant'; import type { ParticipantTrackPermission } from './ParticipantTrackPermission'; import { trackPermissionToProto } from './ParticipantTrackPermission'; +import type RemoteParticipant from './RemoteParticipant'; import { computeTrackBackupEncodings, computeVideoEncodings, @@ -146,6 +147,10 @@ export default class LocalParticipant extends Participant { private reconnectFuture?: Future; + private activeAgentFuture?: Future; + + private firstActiveAgent?: RemoteParticipant; + private rpcHandlers: Map Promise>; private pendingSignalRequests: Map< @@ -270,6 +275,10 @@ export default class LocalParticipant extends Participant { this.reconnectFuture?.reject?.('Got disconnected during reconnection attempt'); this.reconnectFuture = undefined; } + + this.activeAgentFuture?.reject?.('Got disconnected without active agent present'); + this.activeAgentFuture = undefined; + this.firstActiveAgent = undefined; }; private handleSignalRequestResponse = (response: RequestResponse) => { @@ -523,6 +532,20 @@ export default class LocalParticipant extends Participant { this.pendingPublishing.delete(source); throw e; } + + for (const localTrack of localTracks) { + if ( + source === Track.Source.Microphone && + isAudioTrack(localTrack) && + publishOptions?.preConnectBuffer + ) { + this.log.info('starting preconnect buffer for microphone', { + ...this.logContext, + }); + localTrack.startPreConnectBuffer(); + } + } + try { const publishPromises: Array> = []; for (const localTrack of localTracks) { @@ -991,56 +1014,8 @@ export default class LocalParticipant extends Participant { if (disableDtx) { audioFeatures.push(AudioTrackFeature.TF_NO_DTX); } - if (isLocalAudioTrack(track) && track.hasPreConnectBuffer) { audioFeatures.push(AudioTrackFeature.TF_PRECONNECT_BUFFER); - const stream = track.getPreConnectBuffer(); - this.on(ParticipantEvent.LocalTrackSubscribed, (publication) => { - if (publication.track!.mediaStreamTrack.id === track.mediaStreamTrack.id) { - this.log.debug('sending preconnect buffer', { - ...this.logContext, - ...getLogContextFromTrack(track), - }); - track.stopPreConnectBuffer(); - } - }); - - if (stream) { - this.log.debug('sending preconnect buffer', { - ...this.logContext, - ...getLogContextFromTrack(track), - }); - const bufferStreamPromise = new Promise(async (resolve, reject) => { - try { - const writer = await this.streamBytes({ - name: 'preconnect-buffer', - mimeType: 'audio/opus', - topic: 'lk.preconnect-buffer', - }); - for await (const chunk of stream) { - await writer.write(chunk); - } - await writer.close(); - resolve(); - } catch (e) { - reject(e); - } - }); - bufferStreamPromise - .then(() => { - this.log.debug('preconnect buffer sent', { - ...this.logContext, - ...getLogContextFromTrack(track), - }); - }) - .catch((e) => { - this.log.error('error sending preconnect buffer', { - ...this.logContext, - ...getLogContextFromTrack(track), - error: e, - }); - }); - } } // create track publication from track @@ -1274,6 +1249,72 @@ export default class LocalParticipant extends Participant { this.addTrackPublication(publication); // send event for publication this.emit(ParticipantEvent.LocalTrackPublished, publication); + + if ( + isLocalAudioTrack(track) && + ti.audioFeatures.includes(AudioTrackFeature.TF_PRECONNECT_BUFFER) + ) { + const stream = track.getPreConnectBuffer(); + // TODO: we're registering the listener after negotiation, so there might be a race + this.on(ParticipantEvent.LocalTrackSubscribed, (pub) => { + if (pub.trackSid === ti.sid) { + this.log.debug('finished recording preconnect buffer', { + ...this.logContext, + ...getLogContextFromTrack(track), + }); + track.stopPreConnectBuffer(); + } + }); + + if (stream) { + const bufferStreamPromise = new Promise(async (resolve, reject) => { + try { + this.log.debug('waiting for agent', { + ...this.logContext, + ...getLogContextFromTrack(track), + }); + const agent = await this.waitUntilActiveAgentPresent(); + this.log.debug('sending preconnect buffer', { + ...this.logContext, + ...getLogContextFromTrack(track), + }); + const trackSettings = track.mediaStreamTrack.getSettings(); + const writer = await this.streamBytes({ + name: 'preconnect-buffer', + mimeType: 'audio/opus', + topic: 'lk.agent.pre-connect-audio-buffer', + destinationIdentities: [agent.identity], + attributes: { + trackId: publication.trackSid, + sampleRate: String(trackSettings.sampleRate ?? '48000'), + channels: String(trackSettings.channelCount ?? '1'), + }, + }); + for await (const chunk of stream) { + await writer.write(chunk); + } + await writer.close(); + resolve(); + } catch (e) { + reject(e); + } + }); + bufferStreamPromise + .then(() => { + this.log.debug('preconnect buffer sent successfully', { + ...this.logContext, + ...getLogContextFromTrack(track), + }); + }) + .catch((e) => { + this.log.error('error sending preconnect buffer', { + ...this.logContext, + ...getLogContextFromTrack(track), + error: e, + }); + }); + } + } return publication; } @@ -1854,6 +1895,7 @@ export default class LocalParticipant extends Participant { streamId, topic: info.topic, timestamp: numberToBigInt(Date.now()), + attributes: info.attributes, contentHeader: { case: 'byteHeader', value: new DataStream_ByteHeader({ @@ -2164,6 +2206,30 @@ export default class LocalParticipant extends Participant { ); }; + /** @internal */ + setActiveAgent(agent: RemoteParticipant | undefined) { + this.firstActiveAgent = agent; + if (agent && !this.firstActiveAgent) { + this.firstActiveAgent = agent; + } + if (agent) { + this.activeAgentFuture?.resolve?.(agent); + } else { + this.activeAgentFuture?.reject?.('Agent disconnected'); + } + this.activeAgentFuture = undefined; + } + + private waitUntilActiveAgentPresent() { + if (this.firstActiveAgent) { + return Promise.resolve(this.firstActiveAgent); + } + if (!this.activeAgentFuture) { + this.activeAgentFuture = new Future(); + } + return this.activeAgentFuture.promise; + } + /** @internal */ private onTrackUnmuted = (track: LocalTrack) => { this.onTrackMuted(track, track.isUpstreamPaused); diff --git a/src/room/track/LocalTrack.ts b/src/room/track/LocalTrack.ts index 482874e356..9bba385746 100644 --- a/src/room/track/LocalTrack.ts +++ b/src/room/track/LocalTrack.ts @@ -12,13 +12,16 @@ import type { TrackProcessor } from './processor/types'; import { LocalTrackRecorder } from './record'; import type { ReplaceTrackOptions } from './types'; -const defaultDimensionsTimeout = 1000; +const DEFAULT_DIMENSIONS_TIMEOUT = 1000; +const PRE_CONNECT_BUFFER_TIMEOUT = 5000; export default abstract class LocalTrack< TrackKind extends Track.Kind = Track.Kind, > extends Track { protected _sender?: RTCRtpSender; + private autoStopPreConnectBuffer: ReturnType | undefined; + /** @internal */ get sender(): RTCRtpSender | undefined { return this._sender; @@ -210,7 +213,7 @@ export default abstract class LocalTrack< } } - async waitForDimensions(timeout = defaultDimensionsTimeout): Promise { + async waitForDimensions(timeout = DEFAULT_DIMENSIONS_TIMEOUT): Promise { if (this.kind === Track.Kind.Audio) { throw new Error('cannot get dimensions for audio tracks'); } @@ -592,7 +595,7 @@ export default abstract class LocalTrack< this.emit(TrackEvent.TrackProcessorUpdate); } - startPreConnectBuffer() { + startPreConnectBuffer(timeslice: number = 100) { if (!this.localTrackRecorder) { this.localTrackRecorder = new LocalTrackRecorder(this); } else { @@ -600,10 +603,18 @@ export default abstract class LocalTrack< return; } - this.localTrackRecorder.start(100); + this.localTrackRecorder.start(timeslice); + this.autoStopPreConnectBuffer = setTimeout(() => { + this.log.warn( + 'preconnect buffer timed out, stopping recording automatically', + this.logContext, + ); + this.stopPreConnectBuffer(); + }, PRE_CONNECT_BUFFER_TIMEOUT); } stopPreConnectBuffer() { + clearTimeout(this.autoStopPreConnectBuffer); if (this.localTrackRecorder) { this.localTrackRecorder.stop(); this.localTrackRecorder = undefined; diff --git a/src/room/track/options.ts b/src/room/track/options.ts index b9acd736fa..6da12f42a4 100644 --- a/src/room/track/options.ts +++ b/src/room/track/options.ts @@ -119,6 +119,15 @@ export interface TrackPublishDefaults { * defaults to false */ stopMicTrackOnMute?: boolean; + + /** + * Enables preconnect buffer for a user's microphone track. + * This is useful for reducing perceived latency when the user starts to speak before the connection is established. + * Only works for agent use cases. + * + * Defaults to false. + */ + preConnectBuffer?: boolean; } /** diff --git a/src/room/track/record.ts b/src/room/track/record.ts index 2ffc2de505..dfdade5ff6 100644 --- a/src/room/track/record.ts +++ b/src/room/track/record.ts @@ -10,6 +10,8 @@ export class LocalTrackRecorder extends MediaRecorder { let streamController: ReadableStreamDefaultController | undefined; + const isClosed = () => streamController === undefined; + const onStop = () => { this.removeEventListener('dataavailable', dataListener); this.removeEventListener('stop', onStop); @@ -29,8 +31,12 @@ export class LocalTrackRecorder extends MediaRecorder { this.byteStream = new ReadableStream({ start: (controller) => { streamController = controller; - dataListener = async (event) => { - controller.enqueue(await event.data.bytes()); + dataListener = async (event: BlobEvent) => { + const arrayBuffer = await event.data.arrayBuffer(); + if (isClosed()) { + return; + } + controller.enqueue(new Uint8Array(arrayBuffer)); }; this.addEventListener('dataavailable', dataListener); }, From beb99d8842f425a8f6655756458b6b5c67c4197a Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 15 May 2025 15:37:09 +0200 Subject: [PATCH 22/34] reuse settings --- src/room/participant/LocalParticipant.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index a8cb2762aa..aa59ea65b9 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -1278,7 +1278,6 @@ export default class LocalParticipant extends Participant { ...this.logContext, ...getLogContextFromTrack(track), }); - const trackSettings = track.mediaStreamTrack.getSettings(); const writer = await this.streamBytes({ name: 'preconnect-buffer', mimeType: 'audio/opus', @@ -1286,8 +1285,8 @@ export default class LocalParticipant extends Participant { destinationIdentities: [agent.identity], attributes: { trackId: publication.trackSid, - sampleRate: String(trackSettings.sampleRate ?? '48000'), - channels: String(trackSettings.channelCount ?? '1'), + sampleRate: String(settings.sampleRate ?? '48000'), + channels: String(settings.channelCount ?? '1'), }, }); for await (const chunk of stream) { From c7d9f540682c44fb105e32aa15278156e65826c6 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 15 May 2025 16:10:06 +0200 Subject: [PATCH 23/34] align timeout with swift --- src/room/participant/LocalParticipant.ts | 4 ++++ src/room/track/LocalTrack.ts | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index aa59ea65b9..d839995c3a 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -1258,6 +1258,10 @@ export default class LocalParticipant extends Participant { // TODO: we're registering the listener after negotiation, so there might be a race this.on(ParticipantEvent.LocalTrackSubscribed, (pub) => { if (pub.trackSid === ti.sid) { + if (!track.hasPreConnectBuffer) { + this.log.warn('subscribe event came to late, buffer already closed', this.logContext); + return; + } this.log.debug('finished recording preconnect buffer', { ...this.logContext, ...getLogContextFromTrack(track), diff --git a/src/room/track/LocalTrack.ts b/src/room/track/LocalTrack.ts index 9bba385746..2f5ab4ca09 100644 --- a/src/room/track/LocalTrack.ts +++ b/src/room/track/LocalTrack.ts @@ -13,7 +13,7 @@ import { LocalTrackRecorder } from './record'; import type { ReplaceTrackOptions } from './types'; const DEFAULT_DIMENSIONS_TIMEOUT = 1000; -const PRE_CONNECT_BUFFER_TIMEOUT = 5000; +const PRE_CONNECT_BUFFER_TIMEOUT = 10_000; export default abstract class LocalTrack< TrackKind extends Track.Kind = Track.Kind, @@ -595,6 +595,7 @@ export default abstract class LocalTrack< this.emit(TrackEvent.TrackProcessorUpdate); } + /** @internal */ startPreConnectBuffer(timeslice: number = 100) { if (!this.localTrackRecorder) { this.localTrackRecorder = new LocalTrackRecorder(this); @@ -613,6 +614,7 @@ export default abstract class LocalTrack< }, PRE_CONNECT_BUFFER_TIMEOUT); } + /** @internal */ stopPreConnectBuffer() { clearTimeout(this.autoStopPreConnectBuffer); if (this.localTrackRecorder) { From 668cc92572ae0ac3fc79dc2fd0e570f485f6d9fc Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 15 May 2025 16:27:55 +0200 Subject: [PATCH 24/34] add agent timeout --- src/room/participant/LocalParticipant.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index d839995c3a..42739443d5 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -1277,7 +1277,11 @@ export default class LocalParticipant extends Participant { ...this.logContext, ...getLogContextFromTrack(track), }); + const agentActiveTimeout = setTimeout(() => { + reject(new Error('agent not active within 10 seconds')); + }, 10_000); const agent = await this.waitUntilActiveAgentPresent(); + clearTimeout(agentActiveTimeout); this.log.debug('sending preconnect buffer', { ...this.logContext, ...getLogContextFromTrack(track), From 76d1638e865dbedb04840670ab267d680c84870f Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 15 May 2025 17:37:31 +0200 Subject: [PATCH 25/34] remove outdated example --- examples/local-recording/demo.ts | 480 ------------------------- examples/local-recording/index.html | 159 -------- examples/local-recording/styles.css | 50 --- examples/local-recording/tsconfig.json | 20 -- 4 files changed, 709 deletions(-) delete mode 100644 examples/local-recording/demo.ts delete mode 100644 examples/local-recording/index.html delete mode 100644 examples/local-recording/styles.css delete mode 100644 examples/local-recording/tsconfig.json diff --git a/examples/local-recording/demo.ts b/examples/local-recording/demo.ts deleted file mode 100644 index 236fb6fbed..0000000000 --- a/examples/local-recording/demo.ts +++ /dev/null @@ -1,480 +0,0 @@ -//@ts-ignore -import type { LocalAudioTrack, RoomOptions } from '../../src/index'; -import { - ConnectionState, - DisconnectReason, - LocalTrackRecorder, - LogLevel, - MediaDeviceFailure, - Participant, - ParticipantEvent, - RemoteParticipant, - Room, - RoomEvent, - Track, - createLocalAudioTrack, - setLogLevel, -} from '../../src/index'; - -setLogLevel(LogLevel.debug); - -const $ = (id: string) => document.getElementById(id) as T; - -const state = { - defaultDevices: new Map([['audioinput', 'default']]), - microphoneTrack: undefined as LocalAudioTrack | undefined, - recorder: undefined as LocalTrackRecorder | undefined, - chunks: [] as Uint8Array[], -}; -let currentRoom: Room | undefined; - -let startTime: number; - -const searchParams = new URLSearchParams(window.location.search); -const storedUrl = searchParams.get('url') ?? 'ws://localhost:7880'; -const storedToken = searchParams.get('token') ?? ''; -($('url')).value = storedUrl; -($('token')).value = storedToken; - -function updateSearchParams(url: string, token: string) { - const params = new URLSearchParams({ url, token }); - window.history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`); -} - -// handles actions from the HTML -const appActions = { - connectWithFormInput: async () => { - const url = ($('url')).value; - const token = ($('token')).value; - - if (url && token) { - updateSearchParams(url, token); - try { - await connect(url, token); - } catch (e) { - appendLog('error connecting', e); - } - } else { - appendLog('url and token are required'); - } - }, - - createMicrophoneTrack: async () => { - try { - appendLog('Creating microphone track...'); - const track = await createLocalAudioTrack(); - track.source = Track.Source.Microphone; - state.microphoneTrack = track; - appendLog('Microphone track created successfully'); - updateButtonsForPublishState(); - } catch (e) { - appendLog('Error creating microphone track:', e); - } - }, - - publishMicrophoneTrack: async () => { - if (state.microphoneTrack && currentRoom) { - try { - appendLog('Publishing microphone track...'); - await currentRoom.localParticipant.publishTrack(state.microphoneTrack); - appendLog('Microphone track published successfully'); - updateButtonsForPublishState(); - } catch (e) { - appendLog('Error publishing microphone track:', e); - } - } else { - appendLog('Cannot publish: No microphone track created or not connected to room'); - } - }, - - unpublishMicrophoneTrack: async () => { - if (state.microphoneTrack && currentRoom) { - try { - appendLog('Unpublishing microphone track...'); - await currentRoom.localParticipant.unpublishTrack(state.microphoneTrack); - appendLog('Microphone track unpublished successfully'); - updateButtonsForPublishState(); - } catch (e) { - appendLog('Error unpublishing microphone track:', e); - } - } else { - appendLog('Cannot unpublish: No microphone track created or not connected to room'); - } - }, - - toggleAudioMute: async () => { - if (state.microphoneTrack) { - try { - if (state.microphoneTrack.isMuted) { - appendLog('Unmuting microphone track...'); - await state.microphoneTrack.unmute(); - appendLog('Microphone track unmuted'); - } else { - appendLog('Muting microphone track...'); - await state.microphoneTrack.mute(); - appendLog('Microphone track muted'); - } - updateButtonsForPublishState(); - } catch (e) { - appendLog('Error toggling mute state:', e); - } - } else { - appendLog('Cannot toggle mute: No microphone track created'); - } - }, - - startLocalRecording: async () => { - if (state.microphoneTrack) { - try { - appendLog('Starting local recording...'); - state.recorder = new LocalTrackRecorder(state.microphoneTrack); - appendLog('Local recording started'); - updateButtonsForPublishState(); - } catch (e) { - appendLog('Error starting local recording:', e); - return; - } - state.recorder.addEventListener('dataavailable', async (event) => { - state.chunks.push(await event.data.bytes()); - }); - state.recorder.addEventListener('stop', () => { - const blob = new Blob(state.chunks, { type: 'audio/ogg; codecs=opus' }); - const url = URL.createObjectURL(blob); - state.chunks = []; - const a = document.createElement('a'); - a.href = url; - a.download = 'recording.ogg'; - a.click(); - }); - state.recorder.start(); - } else { - appendLog('Cannot start recording: No microphone track created'); - } - }, - - stopLocalRecording: async () => { - if (state.recorder) { - try { - appendLog('Stopping local recording...'); - await state.recorder.stop(); - state.recorder = undefined; - updateButtonsForPublishState(); - } catch (e) { - appendLog('Error stopping local recording:', e); - } - } else { - appendLog('Cannot stop recording: No recording in progress'); - } - }, - - handleDeviceSelected: (e: Event) => { - const deviceId = (e.target).value; - const elementId = (e.target).id; - const kind = elementMapping[elementId as keyof typeof elementMapping]; - if (!kind) { - return; - } - - state.defaultDevices.set(kind, deviceId); - - if (currentRoom) { - switch (kind) { - case 'audioinput': - currentRoom.switchActiveDevice(kind, deviceId); - break; - case 'audiooutput': - currentRoom.switchActiveDevice(kind, deviceId); - break; - default: - break; - } - } - }, - - disconnectRoom: () => { - if (currentRoom) { - currentRoom.disconnect(); - } - }, -}; - -declare global { - interface Window { - currentRoom: any; - appActions: typeof appActions; - } -} - -window.appActions = appActions; - -// --------------------------- event handlers ------------------------------- // - -async function participantConnected(participant: Participant) { - appendLog('participant', participant.identity, 'connected', participant.metadata); - participant - .on(ParticipantEvent.TrackMuted, () => { - appendLog('track was muted', participant.identity); - }) - .on(ParticipantEvent.TrackUnmuted, () => { - appendLog('track was unmuted', participant.identity); - }); -} - -function participantDisconnected(participant: RemoteParticipant) { - appendLog('participant', participant.sid, 'disconnected'); -} - -function handleRoomDisconnect(reason?: DisconnectReason) { - if (!currentRoom) return; - appendLog('disconnected from room', { reason }); - - // Stop any active recording - if (state.recorder) { - appendLog('Stopping recording due to room disconnect'); - try { - state.recorder.stop(); - appendLog('Recording stopped due to disconnect'); - } catch (error) { - appendLog('Error stopping recorder on disconnect:', error); - } - state.recorder = undefined; - } - - setButtonsForState(false); - - const container = $('participants-area'); - if (container) { - container.innerHTML = ''; - } - - currentRoom = undefined; - window.currentRoom = undefined; -} - -// -------------------------- rendering helpers ----------------------------- // - -function appendLog(...args: any[]) { - const logger = $('log')!; - for (let i = 0; i < arguments.length; i += 1) { - if (typeof args[i] === 'object') { - logger.innerText += `${ - JSON && JSON.stringify ? JSON.stringify(args[i], undefined, 2) : args[i] - } `; - } else { - logger.innerText += `${args[i]} `; - } - } - logger.innerText += '\n'; - (() => { - logger.scrollTop = logger.scrollHeight; - })(); -} - -// --------------------------- connection handling -------------------------- // - -async function connect(url: string, token: string) { - if (currentRoom) { - appendLog('disconnecting existing room'); - currentRoom.disconnect(); - } - - try { - appendLog('connecting to', url); - const roomOpts: RoomOptions = { - stopLocalTrackOnUnpublish: false, - }; - - startTime = Date.now(); - const room = new Room(roomOpts); - - room - .on(RoomEvent.ParticipantConnected, participantConnected) - .on(RoomEvent.ParticipantDisconnected, participantDisconnected) - .on(RoomEvent.Disconnected, handleRoomDisconnect) - .on(RoomEvent.LocalTrackPublished, () => { - appendLog('Local track published'); - updateButtonsForPublishState(); - }) - .on(RoomEvent.LocalTrackUnpublished, () => { - appendLog('Local track unpublished'); - updateButtonsForPublishState(); - }) - .on(RoomEvent.MediaDevicesError, (e: Error) => { - const failure = MediaDeviceFailure.getFailure(e); - appendLog('media device failure', failure); - }) - .on(RoomEvent.ConnectionStateChanged, (connectionState: ConnectionState) => { - appendLog('connection state changed', connectionState); - }) - .on(RoomEvent.MediaDevicesChanged, handleDevicesChanged); - - await room.connect(url, token); - - currentRoom = room; - window.currentRoom = room; - - appendLog('connected to room', room.name); - appendLog('connection time', Date.now() - startTime); - - // Set button states based on connection - setButtonsForState(true); - - // Acquire device list - await acquireDeviceList(); - - // Update publish state buttons - updateButtonsForPublishState(); - } catch (error) { - appendLog('error connecting to room', error); - } -} - -// ---------------------------- device management --------------------------- // - -const elementMapping = { - 'audio-input': 'audioinput', - 'audio-output': 'audiooutput', -} as const; - -async function handleDevicesChanged() { - Promise.all( - Object.keys(elementMapping).map(async (id) => { - const kind = elementMapping[id as keyof typeof elementMapping]; - if (!kind) { - return; - } - const devices = await Room.getLocalDevices(kind); - const element = $(id); - populateSelect(element, devices, state.defaultDevices.get(kind)); - }), - ); -} - -function populateSelect( - element: HTMLSelectElement, - devices: MediaDeviceInfo[], - selectedDeviceId?: string, -) { - // clear all elements - element.innerHTML = ''; - - for (const device of devices) { - const option = document.createElement('option'); - option.text = device.label; - option.value = device.deviceId; - if (device.deviceId === selectedDeviceId) { - option.selected = true; - } - element.appendChild(option); - } -} - -function updateButtonsForPublishState() { - if (!currentRoom) { - return; - } - const lp = currentRoom.localParticipant; - - // audio - setButtonState( - 'toggle-audio-button', - `${lp.isMicrophoneEnabled ? 'Disable' : 'Enable'} Audio`, - lp.isMicrophoneEnabled, - ); - - // Update microphone track buttons based on state - const hasMicTrack = !!state.microphoneTrack; - const isPublished = - hasMicTrack && - currentRoom.localParticipant - .getTrackPublications() - .some((pub) => pub.track === state.microphoneTrack); - - // Create mic track button - setButtonDisabled('create-mic-track-button', hasMicTrack); - - // Publish/unpublish buttons - setButtonDisabled('publish-mic-track-button', !hasMicTrack || isPublished); - setButtonDisabled('unpublish-mic-track-button', !hasMicTrack || !isPublished); - - // Mute toggle button - setButtonDisabled('toggle-audio-mute-button', !hasMicTrack); - if (hasMicTrack) { - setButtonState( - 'toggle-audio-mute-button', - state.microphoneTrack!.isMuted ? 'Unmute' : 'Mute', - !state.microphoneTrack!.isMuted, - ); - } - - // Recording buttons - const isRecording = !!state.recorder; - setButtonDisabled('start-recording-button', !hasMicTrack || isRecording); - setButtonDisabled('stop-recording-button', !isRecording); -} - -async function acquireDeviceList() { - handleDevicesChanged(); -} - -// -------------------------- button handling ------------------------------ // - -function setButtonState( - buttonId: string, - buttonText: string, - isActive: boolean, - isDisabled: boolean | undefined = undefined, -) { - const el = $(buttonId); - if (!el) return; - if (isDisabled !== undefined) { - el.disabled = isDisabled; - } - el.innerHTML = buttonText; - if (isActive) { - el.classList.add('active'); - } else { - el.classList.remove('active'); - } -} - -function setButtonDisabled(buttonId: string, isDisabled: boolean) { - const el = $(buttonId); - if (el) { - el.disabled = isDisabled; - } -} - -function setButtonsForState(connected: boolean) { - const connectedButtons = [ - 'toggle-audio-button', - 'disconnect-room-button', - 'create-mic-track-button', - ]; - - // Buttons that require both connection and a microphone track - const trackDependentButtons = [ - 'publish-mic-track-button', - 'unpublish-mic-track-button', - 'toggle-audio-mute-button', - 'start-recording-button', - ]; - - connectedButtons.forEach((id) => { - setButtonDisabled(id, !connected); - }); - - // These buttons will be further controlled by updateButtonsForPublishState - // based on microphone track state - trackDependentButtons.forEach((id) => { - setButtonDisabled(id, !connected || !state.microphoneTrack); - }); - - // Connect button disabled when connected - setButtonDisabled('connect-button', connected); - - // If we disconnect, also update stop-recording button - if (!connected) { - setButtonDisabled('stop-recording-button', true); - } -} diff --git a/examples/local-recording/index.html b/examples/local-recording/index.html deleted file mode 100644 index 6df29a0488..0000000000 --- a/examples/local-recording/index.html +++ /dev/null @@ -1,159 +0,0 @@ - - - - Livekit Simple Demo - - - - - - - -
-
-
-

Livekit Simple Demo

-
-
-
- LiveKit URL -
-
- -
-
- Token -
-
- -
-
- - -
-
- -
-
- -
-
- - -
-
- - - - -
-
- - -
-
- - -
-
- -
-
- -
-
- -
-
-
-
- -
- -
- -
-
- - - diff --git a/examples/local-recording/styles.css b/examples/local-recording/styles.css deleted file mode 100644 index 6171534854..0000000000 --- a/examples/local-recording/styles.css +++ /dev/null @@ -1,50 +0,0 @@ -#connect-area { - display: grid; - grid-template-columns: 1fr 1fr; - grid-template-rows: min-content min-content; - grid-auto-flow: column; - grid-gap: 10px; - margin-bottom: 15px; -} - -#actions-area { - display: grid; - grid-template-columns: fit-content(100px) auto; - grid-gap: 1.25rem; - margin-bottom: 15px; -} - -#mic-track-actions { - display: flex; - justify-content: flex-start; - margin-bottom: 15px; -} - -#recording-actions { - display: flex; - justify-content: flex-start; - margin-bottom: 15px; -} - -#inputs-area { - display: grid; - grid-template-columns: repeat(2, 1fr); - grid-gap: 1.25rem; - margin-bottom: 10px; -} - -#participants-area { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 20px; -} - -#log-area { - margin-top: 1.25rem; - margin-bottom: 1rem; -} - -#log { - width: 100%; - height: 200px; -} diff --git a/examples/local-recording/tsconfig.json b/examples/local-recording/tsconfig.json deleted file mode 100644 index 4d3333f991..0000000000 --- a/examples/local-recording/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, - "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, - "outDir": "build", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "strict": true /* Enable all strict type-checking options. */, - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, - "skipLibCheck": true /* Skip type checking of declaration files. */, - "noUnusedLocals": true, - "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, - "moduleResolution": "node", - "resolveJsonModule": true - }, - "include": ["../../src/**/*", "demo.ts"], - "exclude": ["**/*.test.ts", "build/**/*"] -} From cec07cb8921771fc53542aba947b8c64de857c8f Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 15 May 2025 17:46:49 +0200 Subject: [PATCH 26/34] fix build with tsconfig --- src/e2ee/worker/tsconfig.json | 10 +++++++++- tsconfig.json | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/e2ee/worker/tsconfig.json b/src/e2ee/worker/tsconfig.json index f153669776..5e10a3dfc2 100644 --- a/src/e2ee/worker/tsconfig.json +++ b/src/e2ee/worker/tsconfig.json @@ -1,6 +1,14 @@ { "extends": "../../../tsconfig.json", "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2017", "ES2018.Promise", "WebWorker", "ES2021.WeakRef"] + "lib": [ + "DOM", + "DOM.Iterable", + "ES2017", + "ES2018.Promise", + "WebWorker", + "ES2021.WeakRef", + "DOM.AsyncIterable" + ] } } diff --git a/tsconfig.json b/tsconfig.json index 672494bd35..7f14bcd039 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,10 +6,10 @@ "lib": [ "DOM", "DOM.Iterable", + "DOM.AsyncIterable", "ES2017", "ES2018.Promise", - "ES2021.WeakRef", - "DOM.AsyncIterable" + "ES2021.WeakRef" ], "rootDir": "./", "outDir": "dist", From 230217874cf6e44f294966cc17f4e2934b048a71 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 15 May 2025 18:07:09 +0200 Subject: [PATCH 27/34] add TODO --- src/room/participant/LocalParticipant.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 42739443d5..6e4958fb9c 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -889,6 +889,7 @@ export default class LocalParticipant extends Participant { ), ); }, 15_000); + // TODO: all events need to be moved into `setupEngine` to ensure they are not lost when engine is recreated this.engine.once(EngineEvent.SignalConnected, onSignalConnected); this.engine.on(EngineEvent.Closing, () => { this.engine.off(EngineEvent.SignalConnected, onSignalConnected); From 937201020f25c0bd5c9f16bd6bba55255b214080 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 16 May 2025 12:09:09 +0200 Subject: [PATCH 28/34] better deferred handling --- src/room/RTCEngine.ts | 7 +-- src/room/participant/LocalParticipant.ts | 57 +++++++++++++++++------- src/room/track/LocalTrack.ts | 4 +- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index e8f4a57710..6a923cebde 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -249,10 +249,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } this.clientConfiguration = joinResponse.clientConfiguration; - // emit signal connected event after a short delay to allow for join response to be processed on room - setTimeout(() => { - this.emit(EngineEvent.SignalConnected); - }, 10); + this.emit(EngineEvent.SignalConnected, joinResponse); return joinResponse; } catch (e) { if (e instanceof ConnectionError) { @@ -1510,7 +1507,7 @@ export type EngineEventCallbacks = { remoteMute: (trackSid: string, muted: boolean) => void; offline: () => void; signalRequestResponse: (response: RequestResponse) => void; - signalConnected: () => void; + signalConnected: (joinResp: JoinResponse) => void; }; function supportOptionalDatachannel(protocol: number | undefined): boolean { diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 6e4958fb9c..5d6154604c 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -14,8 +14,10 @@ import { DataStream_TextHeader, DataStream_Trailer, Encryption_Type, + JoinResponse, ParticipantInfo, ParticipantPermission, + ReconnectResponse, RequestResponse, RequestResponse_Reason, RpcAck, @@ -147,6 +149,8 @@ export default class LocalParticipant extends Participant { private reconnectFuture?: Future; + private signalConnectedFuture?: Future; + private activeAgentFuture?: Future; private firstActiveAgent?: RemoteParticipant; @@ -246,6 +250,7 @@ export default class LocalParticipant extends Participant { this.engine .on(EngineEvent.Connected, this.handleReconnected) + .on(EngineEvent.SignalConnected, this.handleSignalConnected) .on(EngineEvent.SignalRestarted, this.handleReconnected) .on(EngineEvent.SignalResumed, this.handleReconnected) .on(EngineEvent.Restarting, this.handleReconnecting) @@ -275,12 +280,27 @@ export default class LocalParticipant extends Participant { this.reconnectFuture?.reject?.('Got disconnected during reconnection attempt'); this.reconnectFuture = undefined; } + if (this.signalConnectedFuture) { + this.signalConnectedFuture.reject?.('Got disconnected without signal connected'); + this.signalConnectedFuture = undefined; + } this.activeAgentFuture?.reject?.('Got disconnected without active agent present'); this.activeAgentFuture = undefined; this.firstActiveAgent = undefined; }; + private handleSignalConnected = (joinResponse: JoinResponse) => { + if (joinResponse.participant) { + this.updateInfo(joinResponse.participant); + } + if (!this.signalConnectedFuture) { + this.signalConnectedFuture = new Future(); + } + + this.signalConnectedFuture.resolve?.(); + }; + private handleSignalRequestResponse = (response: RequestResponse) => { const { requestId, reason, message } = response; const targetRequest = this.pendingSignalRequests.get(requestId); @@ -872,16 +892,8 @@ export default class LocalParticipant extends Participant { ...this.logContext, track: getLogContextFromTrack(track), }); - const onSignalConnected = async () => { - try { - const publication = await this.publish(track, opts, isStereo); - resolve(publication); - } catch (e) { - reject(e); - } - }; - setTimeout(() => { - this.engine.off(EngineEvent.SignalConnected, onSignalConnected); + + const timeout = setTimeout(() => { reject( new PublishTrackError( 'publishing rejected as engine not connected within timeout', @@ -890,11 +902,10 @@ export default class LocalParticipant extends Participant { ); }, 15_000); // TODO: all events need to be moved into `setupEngine` to ensure they are not lost when engine is recreated - this.engine.once(EngineEvent.SignalConnected, onSignalConnected); - this.engine.on(EngineEvent.Closing, () => { - this.engine.off(EngineEvent.SignalConnected, onSignalConnected); - reject(new PublishTrackError('publishing rejected as engine closed', 499)); - }); + await this.waitUntilEngineConnected(); + clearTimeout(timeout); + const publication = await this.publish(track, opts, isStereo); + resolve(publication); } else { try { const publication = await this.publish(track, opts, isStereo); @@ -918,6 +929,13 @@ export default class LocalParticipant extends Participant { } } + private waitUntilEngineConnected() { + if (!this.signalConnectedFuture) { + this.signalConnectedFuture = new Future(); + } + return this.signalConnectedFuture.promise; + } + private hasPermissionsToPublish(track: LocalTrack): boolean { if (!this.permissions) { this.log.warn('no permissions present for publishing track', { @@ -1298,9 +1316,18 @@ export default class LocalParticipant extends Participant { channels: String(settings.channelCount ?? '1'), }, }); + // additionally create a audio element to play the buffer from object url + const chunks: Uint8Array[] = []; for await (const chunk of stream) { await writer.write(chunk); + chunks.push(chunk); } + const audio = new Audio(); + audio.controls = true; + audio.src = URL.createObjectURL(new Blob(chunks)); + console.log('chunks', chunks); + document.body.appendChild(audio); + await writer.close(); resolve(); } catch (e) { diff --git a/src/room/track/LocalTrack.ts b/src/room/track/LocalTrack.ts index 2f5ab4ca09..b7467c5ab4 100644 --- a/src/room/track/LocalTrack.ts +++ b/src/room/track/LocalTrack.ts @@ -598,7 +598,9 @@ export default abstract class LocalTrack< /** @internal */ startPreConnectBuffer(timeslice: number = 100) { if (!this.localTrackRecorder) { - this.localTrackRecorder = new LocalTrackRecorder(this); + this.localTrackRecorder = new LocalTrackRecorder(this, { + mimeType: 'audio/webm;codecs=opus', + }); } else { this.log.warn('preconnect buffer already started'); return; From b971aa9ee7a2bc7b67140d2f75a8271839d8b28c Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 16 May 2025 12:11:45 +0200 Subject: [PATCH 29/34] Delete .changeset/nice-moons-clap.md --- .changeset/nice-moons-clap.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/nice-moons-clap.md diff --git a/.changeset/nice-moons-clap.md b/.changeset/nice-moons-clap.md deleted file mode 100644 index 4c566c2b6b..0000000000 --- a/.changeset/nice-moons-clap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"livekit-client": patch ---- - -Add ParticipantActive event to signal data message readiness From 79e524e2e4d82d534bb4a38d23b62ec13c793d07 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 16 May 2025 12:12:48 +0200 Subject: [PATCH 30/34] remove TODO --- src/room/participant/LocalParticipant.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 99358ccff3..2aae2e9787 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -901,7 +901,6 @@ export default class LocalParticipant extends Participant { ), ); }, 15_000); - // TODO: all events need to be moved into `setupEngine` to ensure they are not lost when engine is recreated await this.waitUntilEngineConnected(); clearTimeout(timeout); const publication = await this.publish(track, opts, isStereo); From ef85213d6d2467e86a46492d311fd5f02c8a933b Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 16 May 2025 12:13:47 +0200 Subject: [PATCH 31/34] cleanup --- src/room/participant/LocalParticipant.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 2aae2e9787..9e81b907a4 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -1315,18 +1315,9 @@ export default class LocalParticipant extends Participant { channels: String(settings.channelCount ?? '1'), }, }); - // additionally create a audio element to play the buffer from object url - const chunks: Uint8Array[] = []; for await (const chunk of stream) { await writer.write(chunk); - chunks.push(chunk); } - const audio = new Audio(); - audio.controls = true; - audio.src = URL.createObjectURL(new Blob(chunks)); - console.log('chunks', chunks); - document.body.appendChild(audio); - await writer.close(); resolve(); } catch (e) { From 772bc2614912a497986ca8f6deb9b69c49cddb87 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 16 May 2025 12:15:45 +0200 Subject: [PATCH 32/34] lint --- src/room/participant/LocalParticipant.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index 9e81b907a4..18b5bf59ed 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -17,7 +17,6 @@ import { JoinResponse, ParticipantInfo, ParticipantPermission, - ReconnectResponse, RequestResponse, RequestResponse_Reason, RpcAck, From 02c828980b21f9d314e147c5115257adf33dda72 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 19 May 2025 14:20:44 +0200 Subject: [PATCH 33/34] Add MediaRecorder mock for tests --- package.json | 3 +- pnpm-lock.yaml | 300 ++++++++----------------------------------- test/setupMocks.ts | 48 +++++++ tsconfig.eslint.json | 2 +- tsconfig.json | 2 +- vite.config.mjs | 1 + 6 files changed, 106 insertions(+), 250 deletions(-) create mode 100644 test/setupMocks.ts diff --git a/package.json b/package.json index 543494dd67..c4d8ed4230 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,8 @@ "eslint-plugin-ecmascript-compat": "^3.2.1", "eslint-plugin-import": "2.31.0", "gh-pages": "6.3.0", - "happy-dom": "^15.10.2", + "happy-dom": "^17.2.0", + "jsdom": "^26.1.0", "prettier": "^3.4.2", "rollup": "4.39.0", "rollup-plugin-delete": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bcd83f21f..7bbfeea87d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,8 +112,11 @@ importers: specifier: 6.3.0 version: 6.3.0 happy-dom: - specifier: ^15.10.2 - version: 15.11.7 + specifier: ^17.2.0 + version: 17.4.7 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 prettier: specifier: ^3.4.2 version: 3.5.3 @@ -143,7 +146,7 @@ importers: version: 5.4.17(@types/node@22.7.4)(terser@5.24.0) vitest: specifier: ^1.6.0 - version: 1.6.1(@types/node@22.7.4)(happy-dom@15.11.7)(jsdom@24.1.3)(terser@5.24.0) + version: 1.6.1(@types/node@22.7.4)(happy-dom@17.4.7)(jsdom@26.1.0)(terser@5.24.0) packages: @@ -1489,9 +1492,6 @@ packages: async@3.2.5: resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -1562,10 +1562,6 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - call-bind@1.0.7: resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} engines: {node: '>= 0.4'} @@ -1614,10 +1610,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - commander@13.0.0: resolution: {integrity: sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==} engines: {node: '>=18'} @@ -1726,10 +1718,6 @@ packages: resolution: {integrity: sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==} engines: {node: '>=10'} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -1758,10 +1746,6 @@ packages: resolution: {integrity: sha512-vo835pntK7kzYStk7xUHDifiYJvXxVhUapt85uk2AI94gUUAQX9HNRtrcMHNSc3YHJUEHGbYIGsM99uIbgAtxw==} hasBin: true - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - electron-to-chromium@1.5.4: resolution: {integrity: sha512-orzA81VqLyIGUEA77YkVA1D+N+nNfl2isJVjjmOyrlxuooZ19ynb+dOlaDTqd/idKRS9lDCSBmtzM+kyCsMnkA==} @@ -1791,10 +1775,6 @@ packages: resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} engines: {node: '>= 0.4'} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} @@ -1806,18 +1786,10 @@ packages: resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} engines: {node: '>= 0.4'} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - es-set-tostringtag@2.0.3: resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} engines: {node: '>= 0.4'} - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - es-shim-unscopables@1.0.2: resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} @@ -2053,10 +2025,6 @@ packages: for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} - engines: {node: '>= 6'} - fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -2102,14 +2070,6 @@ packages: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} @@ -2157,18 +2117,14 @@ packages: gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - happy-dom@15.11.7: - resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==} + happy-dom@17.4.7: + resolution: {integrity: sha512-NZypxadhCiV5NT4A+Y86aQVVKQ05KDmueja3sz008uJfDRwz028wd0aTiJPwo4RQlvlz0fznkEEBBCHVNWc08g==} engines: {node: '>=18.0.0'} has-bigints@1.0.2: @@ -2189,10 +2145,6 @@ packages: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - has-tostringtag@1.0.2: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} @@ -2394,11 +2346,11 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true - jsdom@24.1.3: - resolution: {integrity: sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==} + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} peerDependencies: - canvas: ^2.11.2 + canvas: ^3.0.0 peerDependenciesMeta: canvas: optional: true @@ -2516,10 +2468,6 @@ packages: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -2786,9 +2734,6 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - psl@1.15.0: - resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} - punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -2797,9 +2742,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - querystringify@2.2.0: - resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2849,9 +2791,6 @@ packages: resolution: {integrity: sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==} hasBin: true - requires-port@1.0.0: - resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2890,9 +2829,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rrweb-cssom@0.7.1: - resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} - rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -3106,6 +3042,13 @@ packages: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -3114,9 +3057,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - tough-cookie@4.1.4: - resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} - engines: {node: '>=6'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -3197,8 +3140,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.9.0-dev.20250508: - resolution: {integrity: sha512-vTtyza+uNzjJO/NgvQxsZkopsalnGdtipQo/lz2rdJ4i+wOdWQuOZxGgaUa2Fi8vj/4Xp6chlfpisgOI3mxvOQ==} + typescript@5.9.0-dev.20250519: + resolution: {integrity: sha512-cD9QL1mE9TWmUaC8hHGQUXS0oRsZI45Sovemi6S8ATHu2yKIxUtrlpXkMPQFD5g92QT7SSbNWuR8k6iMIHC/6A==} engines: {node: '>=14.17'} hasBin: true @@ -3237,10 +3180,6 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} - universalify@0.2.0: - resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} - engines: {node: '>= 4.0.0'} - universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -3260,9 +3199,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - url-parse@1.5.10: - resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} - vite-node@1.6.1: resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -3450,7 +3386,6 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) '@csstools/css-tokenizer': 3.0.3 lru-cache: 10.4.3 - optional: true '@babel/code-frame@7.26.2': dependencies: @@ -4301,14 +4236,12 @@ snapshots: human-id: 4.1.1 prettier: 2.8.8 - '@csstools/color-helpers@5.0.2': - optional: true + '@csstools/color-helpers@5.0.2': {} '@csstools/css-calc@2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': dependencies: '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) '@csstools/css-tokenizer': 3.0.3 - optional: true '@csstools/css-color-parser@3.0.8(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': dependencies: @@ -4316,15 +4249,12 @@ snapshots: '@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) '@csstools/css-tokenizer': 3.0.3 - optional: true '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': dependencies: '@csstools/css-tokenizer': 3.0.3 - optional: true - '@csstools/css-tokenizer@3.0.3': - optional: true + '@csstools/css-tokenizer@3.0.3': {} '@esbuild/aix-ppc64@0.21.5': optional: true @@ -4940,8 +4870,7 @@ snapshots: acorn@8.14.1: {} - agent-base@7.1.3: - optional: true + agent-base@7.1.3: {} aggregate-error@3.1.0: dependencies: @@ -5034,9 +4963,6 @@ snapshots: async@3.2.5: {} - asynckit@0.4.0: - optional: true - available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.0.0 @@ -5117,12 +5043,6 @@ snapshots: cac@6.7.14: {} - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - optional: true - call-bind@1.0.7: dependencies: es-define-property: 1.0.0 @@ -5180,11 +5100,6 @@ snapshots: color-name@1.1.4: {} - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - optional: true - commander@13.0.0: {} commander@2.20.3: {} @@ -5219,13 +5134,11 @@ snapshots: dependencies: '@asamuzakjp/css-color': 3.1.1 rrweb-cssom: 0.8.0 - optional: true data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - optional: true data-view-buffer@1.0.1: dependencies: @@ -5259,8 +5172,7 @@ snapshots: dependencies: ms: 2.1.3 - decimal.js@10.5.0: - optional: true + decimal.js@10.5.0: {} deep-eql@4.1.4: dependencies: @@ -5293,9 +5205,6 @@ snapshots: rimraf: 3.0.2 slash: 3.0.0 - delayed-stream@1.0.0: - optional: true - detect-indent@6.1.0: {} diff-sequences@29.6.3: {} @@ -5318,14 +5227,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 5.9.0-dev.20250508 - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - optional: true + typescript: 5.9.0-dev.20250519 electron-to-chromium@1.5.4: {} @@ -5398,9 +5300,6 @@ snapshots: dependencies: get-intrinsic: 1.2.4 - es-define-property@1.0.1: - optional: true - es-errors@1.3.0: {} es-module-lexer@1.3.1: {} @@ -5409,25 +5308,12 @@ snapshots: dependencies: es-errors: 1.3.0 - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - optional: true - es-set-tostringtag@2.0.3: dependencies: get-intrinsic: 1.2.4 has-tostringtag: 1.0.2 hasown: 2.0.2 - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - optional: true - es-shim-unscopables@1.0.2: dependencies: hasown: 2.0.2 @@ -5736,14 +5622,6 @@ snapshots: dependencies: is-callable: 1.2.7 - form-data@4.0.2: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - mime-types: 2.1.35 - optional: true - fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -5796,26 +5674,6 @@ snapshots: has-symbols: 1.0.3 hasown: 2.0.2 - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - optional: true - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - optional: true - get-stream@8.0.1: {} get-symbol-description@1.0.2: @@ -5876,16 +5734,12 @@ snapshots: dependencies: get-intrinsic: 1.2.4 - gopd@1.2.0: - optional: true - graceful-fs@4.2.11: {} graphemer@1.4.0: {} - happy-dom@15.11.7: + happy-dom@17.4.7: dependencies: - entities: 4.5.0 webidl-conversions: 7.0.0 whatwg-mimetype: 3.0.0 @@ -5901,9 +5755,6 @@ snapshots: has-symbols@1.0.3: {} - has-symbols@1.1.0: - optional: true - has-tostringtag@1.0.2: dependencies: has-symbols: 1.0.3 @@ -5915,7 +5766,6 @@ snapshots: html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 - optional: true http-proxy-agent@7.0.2: dependencies: @@ -5923,7 +5773,6 @@ snapshots: debug: 4.4.0 transitivePeerDependencies: - supports-color - optional: true https-proxy-agent@7.0.6: dependencies: @@ -5931,7 +5780,6 @@ snapshots: debug: 4.4.0 transitivePeerDependencies: - supports-color - optional: true human-id@4.1.1: {} @@ -5944,7 +5792,6 @@ snapshots: iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 - optional: true ignore@5.3.1: {} @@ -6026,8 +5873,7 @@ snapshots: is-path-inside@3.0.3: {} - is-potential-custom-element-name@1.0.1: - optional: true + is-potential-custom-element-name@1.0.1: {} is-reference@1.2.1: dependencies: @@ -6091,22 +5937,21 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@24.1.3: + jsdom@26.1.0: dependencies: cssstyle: 4.3.0 data-urls: 5.0.0 decimal.js: 10.5.0 - form-data: 4.0.2 html-encoding-sniffer: 4.0.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.20 parse5: 7.2.1 - rrweb-cssom: 0.7.1 + rrweb-cssom: 0.8.0 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 4.1.4 + tough-cookie: 5.1.2 w3c-xmlserializer: 5.0.0 webidl-conversions: 7.0.0 whatwg-encoding: 3.1.1 @@ -6118,7 +5963,6 @@ snapshots: - bufferutil - supports-color - utf-8-validate - optional: true jsesc@3.0.2: {} @@ -6192,8 +6036,7 @@ snapshots: dependencies: get-func-name: 2.0.2 - lru-cache@10.4.3: - optional: true + lru-cache@10.4.3: {} lru-cache@5.1.1: dependencies: @@ -6226,9 +6069,6 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 - math-intrinsics@1.1.0: - optional: true - mdurl@2.0.0: {} merge-stream@2.0.0: {} @@ -6295,8 +6135,7 @@ snapshots: dependencies: path-key: 4.0.0 - nwsapi@2.2.20: - optional: true + nwsapi@2.2.20: {} object-inspect@1.13.1: {} @@ -6396,7 +6235,6 @@ snapshots: parse5@7.2.1: dependencies: entities: 4.5.0 - optional: true path-exists@4.0.0: {} @@ -6458,18 +6296,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 - psl@1.15.0: - dependencies: - punycode: 2.3.1 - optional: true - punycode.js@2.3.1: {} punycode@2.3.1: {} - querystringify@2.2.0: - optional: true - queue-microtask@1.2.3: {} randombytes@2.1.0: @@ -6527,9 +6357,6 @@ snapshots: dependencies: jsesc: 3.0.2 - requires-port@1.0.0: - optional: true - resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -6587,11 +6414,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.39.0 fsevents: 2.3.3 - rrweb-cssom@0.7.1: - optional: true - - rrweb-cssom@0.8.0: - optional: true + rrweb-cssom@0.8.0: {} run-parallel@1.2.0: dependencies: @@ -6622,7 +6445,6 @@ snapshots: saxes@6.0.0: dependencies: xmlchars: 2.2.0 - optional: true schema-utils@3.3.0: dependencies: @@ -6765,8 +6587,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - symbol-tree@3.2.4: - optional: true + symbol-tree@3.2.4: {} tapable@2.2.1: {} @@ -6796,6 +6617,12 @@ snapshots: tinyspy@2.2.1: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -6804,20 +6631,15 @@ snapshots: dependencies: is-number: 7.0.0 - tough-cookie@4.1.4: + tough-cookie@5.1.2: dependencies: - psl: 1.15.0 - punycode: 2.3.1 - universalify: 0.2.0 - url-parse: 1.5.10 - optional: true + tldts: 6.1.86 tr46@0.0.3: {} tr46@5.1.0: dependencies: punycode: 2.3.1 - optional: true trim-repeated@1.0.0: dependencies: @@ -6899,7 +6721,7 @@ snapshots: typescript@5.8.3: {} - typescript@5.9.0-dev.20250508: {} + typescript@5.9.0-dev.20250519: {} uc.micro@2.1.0: {} @@ -6930,9 +6752,6 @@ snapshots: universalify@0.1.2: {} - universalify@0.2.0: - optional: true - universalify@2.0.1: {} update-browserslist-db@1.1.0(browserslist@4.23.3): @@ -6957,12 +6776,6 @@ snapshots: dependencies: punycode: 2.3.1 - url-parse@1.5.10: - dependencies: - querystringify: 2.2.0 - requires-port: 1.0.0 - optional: true - vite-node@1.6.1(@types/node@22.7.4)(terser@5.24.0): dependencies: cac: 6.7.14 @@ -6991,7 +6804,7 @@ snapshots: fsevents: 2.3.3 terser: 5.24.0 - vitest@1.6.1(@types/node@22.7.4)(happy-dom@15.11.7)(jsdom@24.1.3)(terser@5.24.0): + vitest@1.6.1(@types/node@22.7.4)(happy-dom@17.4.7)(jsdom@26.1.0)(terser@5.24.0): dependencies: '@vitest/expect': 1.6.1 '@vitest/runner': 1.6.1 @@ -7015,8 +6828,8 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.7.4 - happy-dom: 15.11.7 - jsdom: 24.1.3 + happy-dom: 17.4.7 + jsdom: 26.1.0 transitivePeerDependencies: - less - lightningcss @@ -7030,7 +6843,6 @@ snapshots: w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 - optional: true watchpack@2.4.0: dependencies: @@ -7081,18 +6893,15 @@ snapshots: whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 - optional: true whatwg-mimetype@3.0.0: {} - whatwg-mimetype@4.0.0: - optional: true + whatwg-mimetype@4.0.0: {} whatwg-url@14.2.0: dependencies: tr46: 5.1.0 webidl-conversions: 7.0.0 - optional: true whatwg-url@5.0.0: dependencies: @@ -7126,14 +6935,11 @@ snapshots: wrappy@1.0.2: {} - ws@8.18.1: - optional: true + ws@8.18.1: {} - xml-name-validator@5.0.0: - optional: true + xml-name-validator@5.0.0: {} - xmlchars@2.2.0: - optional: true + xmlchars@2.2.0: {} yallist@3.1.1: {} diff --git a/test/setupMocks.ts b/test/setupMocks.ts new file mode 100644 index 0000000000..1b1c15fa56 --- /dev/null +++ b/test/setupMocks.ts @@ -0,0 +1,48 @@ +import { vi } from 'vitest'; + +class MockMediaRecorder { + static isTypeSupported(type: string) { + return true; + } + + stream: MediaStream; + mimeType: string; + state: 'inactive' | 'recording' | 'paused' = 'inactive'; + ondataavailable: ((event: BlobEvent) => void) | null = null; + onstop: (() => void) | null = null; + + constructor(stream: MediaStream, options: MediaRecorderOptions = {}) { + this.stream = stream; + this.mimeType = options.mimeType || 'video/webm'; + } + + start() { + this.state = 'recording'; + setTimeout(() => { + const blob = new Blob(['mock data'], { type: this.mimeType }); + const event = { data: blob } as BlobEvent; + this.ondataavailable?.(event); + }, 10); + } + + stop() { + this.state = 'inactive'; + this.onstop?.(); + } + + pause() { + this.state = 'paused'; + } + + resume() { + this.state = 'recording'; + } + + requestData() { + const blob = new Blob(['mock data'], { type: this.mimeType }); + const event = { data: blob } as BlobEvent; + this.ondataavailable?.(event); + } +} + +vi.stubGlobal('MediaRecorder', MockMediaRecorder); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index c55ea0e2c3..f74d341dd5 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -10,5 +10,5 @@ "rollup.config.worker.js", "vite.config.mjs" ], - "exclude": ["dist/**", "examples/**/dist"] + "exclude": ["dist/**", "examples/**/dist", "test/**"] } diff --git a/tsconfig.json b/tsconfig.json index 7f14bcd039..d3cc374010 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,7 @@ "verbatimModuleSyntax": true, "ignoreDeprecations": "5.0" }, - "exclude": ["dist", "**/*.test.ts"], + "exclude": ["dist", "**/*.test.ts", "test/**"], "include": ["src/**/*.ts"], "typedocOptions": { "entryPoints": ["src/index.ts"], diff --git a/vite.config.mjs b/vite.config.mjs index a5fd71986a..c321653641 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -41,5 +41,6 @@ export default defineConfig({ }, test: { environment: 'happy-dom', + setupFiles: './test/setupMocks.ts', }, }); From f20f7cfffe26a43215eedc7b6c92068b55b539cb Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 19 May 2025 14:25:06 +0200 Subject: [PATCH 34/34] remove unneeded script --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index ac125f87bb..65750b752d 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,6 @@ "build-docs": "typedoc && mkdir -p docs/assets/github && cp .github/*.png docs/assets/github/ && find docs -name '*.html' -type f -exec sed -i.bak 's|=\"/.github/|=\"assets/github/|g' {} + && find docs -name '*.bak' -delete", "proto": "protoc --es_out src/proto --es_opt target=ts -I./protocol ./protocol/livekit_rtc.proto ./protocol/livekit_models.proto", "examples:demo": "vite examples/demo -c vite.config.mjs", - "examples:record": "vite examples/local-recording -c vite.config.mjs", "dev": "pnpm examples:demo", "lint": "eslint src", "test": "vitest run src",