fix: migration vers @livekit/rtc-node pour bridge audio serveur

- Remplacement livekit-client (navigateur) par @livekit/rtc-node (serveur Node.js)
- Support natif AudioSource/AudioFrame pour gestion PCM bas niveau
- Réception audio via AudioStream asynchrone (for await)
- Publication track audio via AudioSource.captureFrame()
- Permet au serveur d'agir comme participant LiveKit complet
- Suppression dépendance livekit-client inutile côté serveur
This commit is contained in:
2026-05-26 14:26:32 +02:00
parent cd76b66529
commit be05755677
4 changed files with 128 additions and 102 deletions
+1 -1
View File
@@ -81,7 +81,7 @@ define(['./workbox-290dd570'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "index.html", "url": "index.html",
"revision": "0.guj84039cv8" "revision": "0.n59u4ot43hg"
}], {}); }], {});
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
File diff suppressed because one or more lines are too long
+125 -99
View File
@@ -1,23 +1,16 @@
/** /**
* LiveKitClient.js * LiveKitClient.js
* Client LiveKit pour le bridge audio serveur * Client LiveKit pour le bridge audio serveur (Node.js)
* *
* Gère : * Utilise @livekit/rtc-node pour :
* - Connexion à la room en tant que participant "bridge" * - Connexion à la room en tant que participant "bridge"
* - Publication de track audio (Opus depuis carte son) * - Publication de tracks audio (PCM depuis carte son)
* - Souscription aux tracks des autres participants (clients PWA) * - Souscription aux tracks des autres participants (clients PWA)
* - Gestion audio bas niveau (AudioSource/AudioStream)
* - Reconnexion automatique * - Reconnexion automatique
*/ */
import { import { Room, RoomEvent, AudioSource, AudioFrame } from '@livekit/rtc-node';
Room,
RoomEvent,
RemoteTrack,
RemoteParticipant,
LocalAudioTrack,
TrackPublishOptions,
AudioPresets
} from 'livekit-client';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
export class LiveKitClient extends EventEmitter { export class LiveKitClient extends EventEmitter {
@@ -30,11 +23,13 @@ export class LiveKitClient extends EventEmitter {
participantName: options.participantName || 'AudioBridge', participantName: options.participantName || 'AudioBridge',
token: options.token || null, token: options.token || null,
autoSubscribe: options.autoSubscribe !== false, autoSubscribe: options.autoSubscribe !== false,
audioBitrate: options.audioBitrate || 96000, // 96kbps par défaut sampleRate: options.sampleRate || 48000,
channels: options.channels || 1, // Mono par défaut pour PTT
...options ...options
}; };
this.room = null; this.room = null;
this.audioSource = null;
this.localAudioTrack = null; this.localAudioTrack = null;
this.isConnected = false; this.isConnected = false;
this.reconnecting = false; this.reconnecting = false;
@@ -58,13 +53,8 @@ export class LiveKitClient extends EventEmitter {
} }
try { try {
this.room = new Room({ // Création room
adaptiveStream: true, this.room = new Room();
dynacast: true,
reconnectionPolicy: {
nextRetryDelayInMs: (retryCount) => Math.min(1000 * Math.pow(2, retryCount), 10000)
}
});
// Configuration des event listeners // Configuration des event listeners
this._setupEventListeners(); this._setupEventListeners();
@@ -79,6 +69,10 @@ export class LiveKitClient extends EventEmitter {
roomName: this.options.roomName, roomName: this.options.roomName,
participantName: this.options.participantName participantName: this.options.participantName
}); });
// Création de l'AudioSource pour pouvoir publier de l'audio
await this._createAudioSource();
} catch (error) { } catch (error) {
console.error('Erreur connexion LiveKit:', error); console.error('Erreur connexion LiveKit:', error);
this.emit('error', error); this.emit('error', error);
@@ -86,6 +80,36 @@ export class LiveKitClient extends EventEmitter {
} }
} }
/**
* Crée une AudioSource pour la publication audio
* @private
*/
async _createAudioSource() {
try {
this.audioSource = new AudioSource(
this.options.sampleRate,
this.options.channels
);
// Publication du track audio
const options = {
source: 'microphone' // Simule un microphone pour les clients
};
this.localAudioTrack = await this.room.localParticipant.publishTrack(
this.audioSource,
options
);
console.log('✓ AudioSource créée et track publié');
this.emit('trackPublished', this.localAudioTrack);
} catch (error) {
console.error('Erreur création AudioSource:', error);
throw error;
}
}
/** /**
* Configuration des event listeners de la room * Configuration des event listeners de la room
* @private * @private
@@ -93,28 +117,17 @@ export class LiveKitClient extends EventEmitter {
_setupEventListeners() { _setupEventListeners() {
if (!this.room) return; if (!this.room) return;
// Connexion/déconnexion // Connexion
this.room.on(RoomEvent.Connected, () => { this.room.on(RoomEvent.Connected, () => {
console.log('✓ Room connectée'); console.log('✓ Room connectée');
this.isConnected = true; this.isConnected = true;
}); });
this.room.on(RoomEvent.Disconnected, (reason) => { // Déconnexion
console.log('⚠ Room déconnectée:', reason); this.room.on(RoomEvent.Disconnected, () => {
console.log('⚠ Room déconnectée');
this.isConnected = false; this.isConnected = false;
this.emit('disconnected', { reason }); this.emit('disconnected');
});
this.room.on(RoomEvent.Reconnecting, () => {
console.log('🔄 Reconnexion en cours...');
this.reconnecting = true;
this.emit('reconnecting');
});
this.room.on(RoomEvent.Reconnected, () => {
console.log('✓ Reconnecté');
this.reconnecting = false;
this.emit('reconnected');
}); });
// Participants // Participants
@@ -133,11 +146,23 @@ export class LiveKitClient extends EventEmitter {
this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => { this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === 'audio') { if (track.kind === 'audio') {
console.log(`🎵 Track audio souscrit de ${participant.identity}`); console.log(`🎵 Track audio souscrit de ${participant.identity}`);
// Création d'un AudioStream pour recevoir les données PCM
const stream = new track.AudioStream(
this.options.sampleRate,
this.options.channels
);
this.remoteParticipants.set(participant.sid, { this.remoteParticipants.set(participant.sid, {
participant, participant,
track, track,
publication publication,
stream
}); });
// Lecture des frames audio
this._startAudioReceive(participant.sid, stream);
this.emit('audioTrackSubscribed', { track, participant }); this.emit('audioTrackSubscribed', { track, participant });
} }
}); });
@@ -149,77 +174,72 @@ export class LiveKitClient extends EventEmitter {
this.emit('audioTrackUnsubscribed', { track, participant }); this.emit('audioTrackUnsubscribed', { track, participant });
} }
}); });
// Données audio
this.room.on(RoomEvent.AudioPlaybackStatusChanged, () => {
this.emit('audioPlaybackChanged');
});
// Erreurs
this.room.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
this.emit('qualityChanged', { quality, participant });
});
} }
/** /**
* Publie un track audio local depuis le bridge * Démarre la réception audio d'un participant
* Note: Pour un bridge serveur, on utilise plutôt publishData pour envoyer Opus directement * @private
* @param {MediaStreamTrack} mediaStreamTrack - Track audio du microphone
* @returns {Promise<void>}
*/ */
async publishAudioTrack(mediaStreamTrack) { async _startAudioReceive(participantSid, stream) {
if (!this.isConnected) { try {
throw new Error('Pas connecté à LiveKit'); // Lecture continue des frames audio
for await (const frame of stream) {
// frame est un AudioFrame avec :
// - data: Buffer PCM int16
// - sampleRate: number
// - numChannels: number
// - samplesPerChannel: number
const participant = this.remoteParticipants.get(participantSid);
if (!participant) break;
// Émettre les données audio vers AudioBridge
this.emit('audioData', {
participantSid,
participantName: participant.participant.identity,
pcmData: frame.data,
sampleRate: frame.sampleRate,
channels: frame.numChannels,
samplesPerChannel: frame.samplesPerChannel
});
}
} catch (error) {
console.error(`Erreur réception audio ${participantSid}:`, error);
}
}
/**
* Envoie des données audio PCM vers les clients
* @param {Buffer} pcmData - Données PCM int16 (mono ou multi-canal)
*/
async sendAudioData(pcmData) {
if (!this.audioSource) {
console.warn('AudioSource non initialisée');
return;
} }
try { try {
// Options de publication // Création d'un AudioFrame
const options = { const samplesPerChannel = pcmData.length / 2 / this.options.channels;
name: 'bridge-audio',
source: 'microphone',
audioBitrate: this.options.audioBitrate
};
this.localAudioTrack = await this.room.localParticipant.publishTrack( const frame = new AudioFrame(
mediaStreamTrack, pcmData,
options this.options.sampleRate,
this.options.channels,
samplesPerChannel
); );
console.log('✓ Track audio local publié'); // Envoi via AudioSource
this.emit('trackPublished', this.localAudioTrack); await this.audioSource.captureFrame(frame);
} catch (error) { } catch (error) {
console.error('Erreur publication track:', error); console.error('Erreur envoi audio:', error);
this.emit('error', error);
throw error;
} }
} }
/**
* Unpublish le track audio local
*/
async unpublishAudioTrack() {
if (this.localAudioTrack) {
await this.room.localParticipant.unpublishTrack(this.localAudioTrack);
this.localAudioTrack = null;
console.log('✓ Track audio local dépublié');
}
}
/**
* Envoie des données audio Opus directement (pour bridge serveur)
* Alternative à publishAudioTrack pour contrôle bas niveau
* @param {Buffer} opusData - Données Opus encodées
*/
sendAudioData(opusData) {
// Note: LiveKit ne supporte pas directement l'envoi de données Opus brutes
// Cette méthode serait implémentée avec un track custom ou DataChannel
// Pour l'instant, on utilise publishAudioTrack avec un MediaStreamTrack
console.warn('sendAudioData: Non implémenté, utiliser publishAudioTrack');
}
/** /**
* Récupère tous les tracks audio distants actifs * Récupère tous les tracks audio distants actifs
* @returns {Array<Object>} Liste des tracks avec métadonnées * @returns {Array<Object>}
*/ */
getRemoteAudioTracks() { getRemoteAudioTracks() {
return Array.from(this.remoteParticipants.values()).map(({ participant, track, publication }) => ({ return Array.from(this.remoteParticipants.values()).map(({ participant, track, publication }) => ({
@@ -234,7 +254,7 @@ export class LiveKitClient extends EventEmitter {
/** /**
* Récupère un participant distant par son SID * Récupère un participant distant par son SID
* @param {string} sid - SID du participant * @param {string} sid
* @returns {Object|null} * @returns {Object|null}
*/ */
getRemoteParticipant(sid) { getRemoteParticipant(sid) {
@@ -261,15 +281,14 @@ export class LiveKitClient extends EventEmitter {
localParticipant: { localParticipant: {
sid: localParticipant?.sid, sid: localParticipant?.sid,
identity: localParticipant?.identity, identity: localParticipant?.identity,
tracksPublished: localParticipant?.trackPublications.size || 0 tracksPublished: localParticipant?.trackPublications?.size || 0
}, },
remoteParticipants: { remoteParticipants: {
count: participants.size, count: participants.size,
list: Array.from(participants.values()).map(p => ({ list: Array.from(participants.values()).map(p => ({
sid: p.sid, sid: p.sid,
identity: p.identity, identity: p.identity,
audioTracks: Array.from(p.audioTrackPublications.values()).length, audioTracks: Array.from(p.audioTrackPublications?.values() || []).length
connectionQuality: p.connectionQuality
})) }))
} }
}; };
@@ -280,13 +299,20 @@ export class LiveKitClient extends EventEmitter {
*/ */
async disconnect() { async disconnect() {
if (this.room) { if (this.room) {
await this.unpublishAudioTrack(); // Unpublish track
this.room.disconnect(); if (this.localAudioTrack) {
await this.room.localParticipant.unpublishTrack(this.localAudioTrack.sid);
this.localAudioTrack = null;
}
// Déconnexion
await this.room.disconnect();
this.room = null; this.room = null;
this.audioSource = null;
this.isConnected = false; this.isConnected = false;
this.remoteParticipants.clear(); this.remoteParticipants.clear();
console.log('✓ Déconnecté de LiveKit'); console.log('✓ Déconnecté de LiveKit');
this.emit('disconnected', { reason: 'manual' }); this.emit('disconnected');
} }
} }
+1 -1
View File
@@ -19,9 +19,9 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@livekit/rtc-node": "^0.13.28",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^4.19.2", "express": "^4.19.2",
"livekit-client": "^2.19.0",
"livekit-server-sdk": "^2.6.0", "livekit-server-sdk": "^2.6.0",
"opusscript": "^0.1.1", "opusscript": "^0.1.1",
"ws": "^8.17.0", "ws": "^8.17.0",