From 42badb1fdfe68356b929ce9c6b63a24006d4017c Mon Sep 17 00:00:00 2001 From: Benoit Date: Mon, 25 May 2026 21:03:40 +0200 Subject: [PATCH] =?UTF-8?q?refactor:=20remplacement=20syst=C3=A8me=20de=20?= =?UTF-8?q?canaux=20statiques=20par=20canaux=20virtuels=20depuis=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.jsx | 16 ++++-- client/src/components/UserList.css | 55 ++++++++++++++++++ client/src/components/UserList.jsx | 52 +++++++++++++++-- client/src/hooks/useLiveKit.js | 92 +++++++++++++++++++++++++++--- client/src/index.css | 2 + server/api/admin.js | 45 ++++----------- server/config/config.yaml | 42 ++++++++------ server/index.js | 27 +++++++-- 8 files changed, 252 insertions(+), 79 deletions(-) diff --git a/client/src/App.jsx b/client/src/App.jsx index 2e64284..49386c3 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -23,7 +23,8 @@ function App() { disconnect, switchGroup, startTalking, - stopTalking + stopTalking, + toggleParticipantMute } = useLiveKit(); // Charger configuration au démarrage @@ -91,8 +92,8 @@ function App() { console.log('🔗 Connexion LiveKit:', livekitUrl); - // Se connecter à LiveKit - await connect(livekitUrl, data.token); + // Se connecter à LiveKit avec les canaux virtuels + await connect(livekitUrl, data.token, data.virtualChannels || []); } catch (err) { console.error('Erreur connexion:', err); @@ -136,8 +137,8 @@ function App() { livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`; } - // Changer de room LiveKit - await switchGroup(livekitUrl, data.token); + // Changer de room LiveKit avec les canaux virtuels du nouveau groupe + await switchGroup(livekitUrl, data.token, data.virtualChannels || []); // Mettre à jour l'état setGroupId(newGroupId); @@ -238,7 +239,10 @@ function App() { /> {/* Liste des participants */} - + {/* Bouton PTT principal avec VU-mètre intégré */} -

Aucun autre participant

+

Aucun participant ou canal

); } + // Séparer canaux virtuels et utilisateurs + const virtualChannels = participants.filter(p => p.isVirtual); + const users = participants.filter(p => !p.isVirtual); + return (
- {participants.length} participant{participants.length > 1 ? 's' : ''} + {virtualChannels.length > 0 && `${virtualChannels.length} canal${virtualChannels.length > 1 ? 'aux' : ''}`} + {virtualChannels.length > 0 && users.length > 0 && ' • '} + {users.length > 0 && `${users.length} utilisateur${users.length > 1 ? 's' : ''}`}
- {participants.map((participant) => ( + {/* Canaux virtuels en premier */} + {virtualChannels.map((participant) => ( +
+
+ + + +
+ +
+ {participant.name} + Canal audio +
+ + +
+ ))} + + {/* Utilisateurs WebRTC */} + {users.map((participant) => (
{ + const connect = useCallback(async (url, token, virtualChannels = []) => { try { + // Stocker les canaux virtuels + virtualChannelsRef.current = virtualChannels; + // Créer room const room = new Room({ adaptiveStream: true, @@ -154,7 +159,7 @@ export default function useLiveKit() { /** * Changer de groupe (reconnexion à une nouvelle room) */ - const switchGroup = useCallback(async (url, token) => { + const switchGroup = useCallback(async (url, token, virtualChannels = []) => { console.log('🔄 Changement de groupe...'); // Déconnexion propre @@ -167,8 +172,11 @@ export default function useLiveKit() { setIsConnected(false); setParticipants([]); + // Reset canaux muted + mutedChannelsRef.current.clear(); + // Reconnexion avec nouveau token - await connect(url, token); + await connect(url, token, virtualChannels); }, [connect]); /** @@ -252,15 +260,30 @@ export default function useLiveKit() { }, []); /** - * Mise à jour liste participants + * Mise à jour liste participants (inclut canaux virtuels) */ - const updateParticipants = () => { + const updateParticipants = useCallback(() => { if (!roomRef.current) return; const room = roomRef.current; const participantsList = []; - // Participants distants + // Canaux virtuels (affichés en premier) + virtualChannelsRef.current.forEach((channel) => { + participantsList.push({ + identity: channel.id, + name: channel.name, + isLocal: false, + isVirtual: true, + isSpeaking: false, // TODO: détection audio depuis bridge + hasAudio: true, + isMuted: mutedChannelsRef.current.has(channel.id), + audioInput: channel.audioInput, + audioOutput: channel.audioOutput + }); + }); + + // Participants distants (utilisateurs WebRTC) room.remoteParticipants.forEach((participant) => { const audioTracks = participant.audioTracks ? Array.from(participant.audioTracks.values()) : []; const audioPublication = audioTracks[0]; @@ -270,13 +293,63 @@ export default function useLiveKit() { identity: participant.identity, name: participant.name || participant.identity, isLocal: false, + isVirtual: false, isSpeaking, - hasAudio: audioPublication?.isSubscribed || false + hasAudio: audioPublication?.isSubscribed || false, + isMuted: false }); }); setParticipants(participantsList); - }; + }, []); + + /** + * Toggle mute/unmute d'un participant (canal virtuel ou utilisateur) + */ + const toggleParticipantMute = useCallback((participantId, isVirtual) => { + if (isVirtual) { + // Canal virtuel : toggle dans l'état local + const isMuted = mutedChannelsRef.current.has(participantId); + + if (isMuted) { + mutedChannelsRef.current.delete(participantId); + console.log('🔊 Canal virtuel unmuted:', participantId); + } else { + mutedChannelsRef.current.add(participantId); + console.log('🔇 Canal virtuel muted:', participantId); + } + + // TODO Phase 3: Envoyer commande au bridge audio via DataChannel + // pour vraiment muter/unmuter le canal physique + + // Mettre à jour l'affichage + updateParticipants(); + } else { + // Utilisateur WebRTC : muter localement la lecture audio + if (!roomRef.current) return; + + const participant = roomRef.current.remoteParticipants.get(participantId); + if (!participant) return; + + const audioTracks = Array.from(participant.audioTracks.values()); + const audioPublication = audioTracks[0]; + + if (audioPublication && audioPublication.audioTrack) { + const track = audioPublication.audioTrack; + const newMutedState = !track.isMuted; + + if (newMutedState) { + track.mute(); + console.log('🔇 Participant muted:', participantId); + } else { + track.unmute(); + console.log('🔊 Participant unmuted:', participantId); + } + + updateParticipants(); + } + } + }, [updateParticipants]); /** * Setup analyseur audio pour VU-mètre (micro local) @@ -412,6 +485,7 @@ export default function useLiveKit() { disconnect, switchGroup, startTalking, - stopTalking + stopTalking, + toggleParticipantMute }; } diff --git a/client/src/index.css b/client/src/index.css index 6b2b335..d3f9645 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -13,6 +13,8 @@ --color-success: #10b981; --color-warning: #f59e0b; --color-danger: #ef4444; + --color-accent: #8b5cf6; + --color-error: #ef4444; /* PTT States */ --color-ptt-idle: #374151; diff --git a/server/api/admin.js b/server/api/admin.js index af80793..eff8113 100644 --- a/server/api/admin.js +++ b/server/api/admin.js @@ -52,16 +52,12 @@ function loadConfig() { const configFile = readFileSync(configPath, 'utf8'); const config = YAML.parse(configFile); - // Générer les IDs pour les groupes et canaux + // Générer les IDs pour les groupes config.groups = config.groups.map(group => { const groupId = slugify(group.name); return { ...group, - id: groupId, - channels: group.channels ? group.channels.map(channel => ({ - ...channel, - id: channel.id || `${groupId}-${slugify(channel.name)}` - })) : [] + id: groupId }; }); @@ -78,13 +74,7 @@ function saveConfig(config) { ...config, groups: config.groups.map(group => { const { id, ...groupWithoutId } = group; - return { - ...groupWithoutId, - channels: group.channels ? group.channels.map(channel => { - const { id: channelId, ...channelWithoutId } = channel; - return channelWithoutId; - }) : [] - }; + return groupWithoutId; }) }; @@ -194,11 +184,11 @@ router.get('/groups', (req, res) => { */ router.post('/groups', (req, res) => { try { - const { name, audioBitrate, channels } = req.body; + const { name, audioBitrate } = req.body; - if (!name || !channels || !Array.isArray(channels)) { + if (!name) { return res.status(400).json({ - error: 'Missing required fields: name, channels' + error: 'Missing required field: name' }); } @@ -214,17 +204,10 @@ router.post('/groups', (req, res) => { }); } - // Générer les IDs pour les canaux - const channelsWithIds = channels.map(channel => ({ - ...channel, - id: channel.id || `${id}-${slugify(channel.name)}` - })); - - // Créer le nouveau groupe + // Créer le nouveau groupe (sans channels) const newGroup = { name, - audioBitrate: audioBitrate || config.audio.defaultBitrate, - channels: channelsWithIds + ...(audioBitrate && { audioBitrate }) }; config.groups.push(newGroup); @@ -246,13 +229,13 @@ router.post('/groups', (req, res) => { /** * PUT /admin/groups/:id * Modifie un groupe existant - * Body: { name?, audioBitrate?, channels? } + * Body: { name?, audioBitrate? } * Note: l'ID est un slug généré, on cherche le groupe par nom dans le YAML */ router.put('/groups/:id', (req, res) => { try { const { id } = req.params; - const { name, audioBitrate, channels } = req.body; + const { name, audioBitrate } = req.body; const config = loadConfig(); @@ -268,14 +251,6 @@ router.put('/groups/:id', (req, res) => { // Mettre à jour les champs fournis if (name !== undefined) config.groups[groupIndex].name = name; if (audioBitrate !== undefined) config.groups[groupIndex].audioBitrate = audioBitrate; - if (channels !== undefined) { - // Pas besoin de générer les IDs ici, ils seront générés au chargement - config.groups[groupIndex].channels = channels.map(channel => ({ - name: channel.name, - audioInput: channel.audioInput, - audioOutput: channel.audioOutput - })); - } saveConfig(config); diff --git a/server/config/config.yaml b/server/config/config.yaml index a87fd16..d9eccc8 100644 --- a/server/config/config.yaml +++ b/server/config/config.yaml @@ -7,30 +7,36 @@ audio: inputDeviceId: 0 outputDeviceId: 2 sampleRate: 48000 + routing: + inputToGroup: + "1": + - technique + "2": + - technique + "4": + - technique + "5": + - technique + groupToOutput: {} + gains: {} + channelNames: + inputs: + "0": "Micro Régisseur" + "1": "Talkback FOH" + "2": "Retour Console" + "3": "Liaison Scène" + "4": "Monitor Mix" + "5": "Spare 1" + outputs: + "0": "Sortie Principale" + "1": "Retour Scène" + "2": "Talkback Console" groups: - name: Production audioBitrate: 96 - channels: - - name: Principal - audioInput: 0 - audioOutput: 0 - - name: Backup - audioInput: 1 - audioOutput: 1 - name: Technique - channels: - - name: Général - audioInput: 2 - audioOutput: 2 - name: Sonorisation audioBitrate: 128 - channels: - - name: Principal - audioInput: 3 - audioOutput: 3 - - name: Retours - audioInput: 4 - audioOutput: 4 server: host: 0.0.0.0 port: 3000 diff --git a/server/index.js b/server/index.js index 7e6d4f1..07cc474 100644 --- a/server/index.js +++ b/server/index.js @@ -196,11 +196,7 @@ app.get('/config', (req, res) => { const clientConfig = { groups: config.groups.map(g => ({ id: g.id, - name: g.name, - channels: g.channels.map(c => ({ - id: c.id, - name: c.name - })) + name: g.name })), audio: { sampleRate: config.audio.sampleRate, @@ -281,11 +277,30 @@ app.post('/token', async (req, res) => { // Enregistrer l'utilisateur dans le système admin registerUser(participantIdentity, username, groupId, roomName); + // Générer les canaux virtuels depuis le routing (inputs uniquement) + const virtualChannels = []; + const inputToGroup = config.audio?.routing?.inputToGroup || {}; + const channelNames = config.audio?.channelNames?.inputs || {}; + + // Trouver tous les canaux physiques routés vers ce groupe + for (const [inputChannel, groups] of Object.entries(inputToGroup)) { + if (groups.includes(groupId)) { + const channelName = channelNames[inputChannel] || `Canal ${inputChannel}`; + virtualChannels.push({ + id: `input-${inputChannel}`, + name: channelName, + isVirtual: true, + audioInput: parseInt(inputChannel, 10) + }); + } + } + res.json({ token, url: LIVEKIT_URL, roomName, - participantIdentity + participantIdentity, + virtualChannels }); } catch (error) {