Files
PTT-Live/server/bridge/LiveKitClient.js
T
benoit a5879a2ea9 fix: corrections audit - connexion audio bridge, optimisations
- Implémente connexion AudioBridge → LiveKitClient pour flux audio bidirectionnel
  * Envoi audio carte son vers clients (sendAudioData)
  * Réception audio clients vers carte son (via event audioData)
- Supprime LiveKitServerBridge.js (code mort jamais utilisé)
- Retire console.log DEBUG de LiveKitClient.js
- Remplace device IDs hardcodés par null dans config.yaml (auto-détection)
- Optimise allocations buffers audio avec pool réutilisable
  * Pool de Float32Array et Buffer PCM (max 50 buffers)
  * Réduit pression GC pour 30+ clients simultanés

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-26 19:22:02 +02:00

351 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* LiveKitClient.js
* Client LiveKit pour le bridge audio serveur (Node.js)
*
* Utilise @livekit/rtc-node pour :
* - Connexion à la room en tant que participant "bridge"
* - Publication de tracks audio (PCM depuis carte son)
* - Souscription aux tracks des autres participants (clients PWA)
* - Gestion audio bas niveau (AudioSource/AudioStream)
* - Reconnexion automatique
*/
import { Room, RoomEvent, AudioSource, AudioFrame, LocalAudioTrack, TrackSource } from '@livekit/rtc-node';
import { EventEmitter } from 'events';
export class LiveKitClient extends EventEmitter {
constructor(options = {}) {
super();
this.options = {
url: options.url || 'ws://localhost:7880',
roomName: options.roomName || 'main',
participantName: options.participantName || 'AudioBridge',
token: options.token || null,
autoSubscribe: options.autoSubscribe !== false,
sampleRate: options.sampleRate || 48000,
channels: options.channels || 1, // Mono par défaut pour PTT
...options
};
this.room = null;
this.audioSource = null;
this.localAudioTrack = null;
this.isConnected = false;
this.reconnecting = false;
// Map des participants distants et leurs tracks
this.remoteParticipants = new Map();
}
/**
* Connexion à la room LiveKit
* @returns {Promise<void>}
*/
async connect() {
if (this.isConnected) {
console.warn('Déjà connecté à LiveKit');
return;
}
if (!this.options.token) {
throw new Error('Token LiveKit requis pour la connexion');
}
try {
// Création room
this.room = new Room();
// Configuration des event listeners
this._setupEventListeners();
// Connexion
await this.room.connect(this.options.url, this.options.token);
this.isConnected = true;
console.log(`✓ Connecté à LiveKit room "${this.options.roomName}" en tant que "${this.options.participantName}"`);
this.emit('connected', {
roomName: this.options.roomName,
participantName: this.options.participantName
});
// Création de l'AudioSource pour pouvoir publier de l'audio
await this._createAudioSource();
} catch (error) {
console.error('Erreur connexion LiveKit:', error);
this.emit('error', error);
throw error;
}
}
/**
* Crée une AudioSource pour la publication audio
* @private
*/
async _createAudioSource() {
try {
// Conversion explicite en int32 pour l'API LiveKit
const sampleRate = parseInt(this.options.sampleRate, 10);
const channels = parseInt(this.options.channels, 10);
// Création de l'AudioSource
this.audioSource = new AudioSource(sampleRate, channels);
// Création du LocalAudioTrack depuis l'AudioSource
const localTrack = LocalAudioTrack.createAudioTrack('bridge-audio', this.audioSource);
// Publication du track
const options = {
source: TrackSource.SOURCE_MICROPHONE // Simule un microphone pour les clients
};
this.localAudioTrack = await this.room.localParticipant.publishTrack(
localTrack,
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
* @private
*/
_setupEventListeners() {
if (!this.room) return;
// Connexion
this.room.on(RoomEvent.Connected, () => {
console.log('✓ Room connectée');
this.isConnected = true;
});
// Déconnexion
this.room.on(RoomEvent.Disconnected, (reason) => {
console.log('⚠ Room déconnectée:', reason);
this.isConnected = false;
this.emit('disconnected', { reason: reason || 'unknown' });
});
// Participants
this.room.on(RoomEvent.ParticipantConnected, (participant) => {
console.log(` Participant connecté: ${participant.identity}`);
this.emit('participantConnected', participant);
});
this.room.on(RoomEvent.ParticipantDisconnected, (participant) => {
console.log(` Participant déconnecté: ${participant.identity}`);
this.remoteParticipants.delete(participant.sid);
this.emit('participantDisconnected', participant);
});
// Tracks
this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === 'audio') {
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, {
participant,
track,
publication,
stream
});
// Lecture des frames audio
this._startAudioReceive(participant.sid, stream);
this.emit('audioTrackSubscribed', { track, participant });
}
});
this.room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
if (track.kind === 'audio') {
console.log(`🔇 Track audio désouscrit de ${participant.identity}`);
this.remoteParticipants.delete(participant.sid);
this.emit('audioTrackUnsubscribed', { track, participant });
}
});
}
/**
* Démarre la réception audio d'un participant
* @private
*/
async _startAudioReceive(participantSid, stream) {
try {
// 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 {
// Création d'un AudioFrame (conversion en int32 explicite)
const samplesPerChannel = Math.floor(pcmData.length / 2 / this.options.channels);
const frame = new AudioFrame(
pcmData,
parseInt(this.options.sampleRate, 10),
parseInt(this.options.channels, 10),
samplesPerChannel
);
// Envoi via AudioSource
await this.audioSource.captureFrame(frame);
} catch (error) {
console.error('Erreur envoi audio:', error);
}
}
/**
* Récupère tous les tracks audio distants actifs
* @returns {Array<Object>}
*/
getRemoteAudioTracks() {
return Array.from(this.remoteParticipants.values()).map(({ participant, track, publication }) => ({
participantId: participant.sid,
participantName: participant.identity,
track,
publication,
isMuted: publication.isMuted,
isSubscribed: publication.isSubscribed
}));
}
/**
* Récupère un participant distant par son SID
* @param {string} sid
* @returns {Object|null}
*/
getRemoteParticipant(sid) {
return this.remoteParticipants.get(sid) || null;
}
/**
* Obtient les statistiques de connexion
* @returns {Object}
*/
async getStats() {
if (!this.room || !this.isConnected) {
return null;
}
const participants = this.room.remoteParticipants;
const localParticipant = this.room.localParticipant;
return {
connected: this.isConnected,
reconnecting: this.reconnecting,
roomName: this.options.roomName,
participantName: this.options.participantName,
localParticipant: {
sid: localParticipant?.sid,
identity: localParticipant?.identity,
tracksPublished: localParticipant?.trackPublications?.size || 0
},
remoteParticipants: {
count: participants.size,
list: Array.from(participants.values()).map(p => ({
sid: p.sid,
identity: p.identity,
audioTracks: Array.from(p.audioTrackPublications?.values() || []).length
}))
}
};
}
/**
* Déconnexion de la room
*/
async disconnect() {
if (this.room) {
// Unpublish track
if (this.localAudioTrack) {
await this.room.localParticipant.unpublishTrack(this.localAudioTrack.sid);
this.localAudioTrack = null;
}
// Déconnexion
await this.room.disconnect();
this.room = null;
this.audioSource = null;
this.isConnected = false;
this.remoteParticipants.clear();
console.log('✓ Déconnecté de LiveKit');
this.emit('disconnected', { reason: 'manual' });
}
}
/**
* Détruit le client et libère les ressources
*/
async destroy() {
await this.disconnect();
this.removeAllListeners();
console.log('✓ LiveKitClient détruit');
}
/**
* Vérifie si le client est connecté
* @returns {boolean}
*/
get connected() {
return this.isConnected && this.room !== null;
}
/**
* Récupère la room LiveKit (accès direct si nécessaire)
* @returns {Room|null}
*/
getRoom() {
return this.room;
}
}
export default LiveKitClient;