refactor: remplacement système de canaux statiques par canaux virtuels depuis routing

This commit is contained in:
2026-05-25 21:03:40 +02:00
parent 7037517ca2
commit 42badb1fdf
8 changed files with 252 additions and 79 deletions
+10 -6
View File
@@ -23,7 +23,8 @@ function App() {
disconnect, disconnect,
switchGroup, switchGroup,
startTalking, startTalking,
stopTalking stopTalking,
toggleParticipantMute
} = useLiveKit(); } = useLiveKit();
// Charger configuration au démarrage // Charger configuration au démarrage
@@ -91,8 +92,8 @@ function App() {
console.log('🔗 Connexion LiveKit:', livekitUrl); console.log('🔗 Connexion LiveKit:', livekitUrl);
// Se connecter à LiveKit // Se connecter à LiveKit avec les canaux virtuels
await connect(livekitUrl, data.token); await connect(livekitUrl, data.token, data.virtualChannels || []);
} catch (err) { } catch (err) {
console.error('Erreur connexion:', err); console.error('Erreur connexion:', err);
@@ -136,8 +137,8 @@ function App() {
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`; livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`;
} }
// Changer de room LiveKit // Changer de room LiveKit avec les canaux virtuels du nouveau groupe
await switchGroup(livekitUrl, data.token); await switchGroup(livekitUrl, data.token, data.virtualChannels || []);
// Mettre à jour l'état // Mettre à jour l'état
setGroupId(newGroupId); setGroupId(newGroupId);
@@ -238,7 +239,10 @@ function App() {
/> />
{/* Liste des participants */} {/* Liste des participants */}
<UserList participants={participants} /> <UserList
participants={participants}
onToggleMute={toggleParticipantMute}
/>
{/* Bouton PTT principal avec VU-mètre intégré */} {/* Bouton PTT principal avec VU-mètre intégré */}
<PTTButton <PTTButton
+55
View File
@@ -84,6 +84,20 @@
background: rgba(16, 185, 129, 0.1); background: rgba(16, 185, 129, 0.1);
} }
/* Canal virtuel */
.user-item.virtual-channel {
border-left: 3px solid var(--color-accent);
}
.user-item.virtual-channel.muted {
opacity: 0.5;
border-left-color: var(--color-text-secondary);
}
.user-avatar.channel {
background: var(--color-accent);
}
/* Avatar */ /* Avatar */
.user-avatar { .user-avatar {
width: 40px; width: 40px;
@@ -190,3 +204,44 @@
max-height: 120px; max-height: 120px;
} }
} }
/* Bouton mute/unmute */
.mute-button {
width: 40px;
height: 40px;
border: none;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
color: var(--color-text-primary);
}
.mute-button:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.mute-button:active {
transform: scale(0.95);
}
.mute-button svg {
width: 20px;
height: 20px;
}
.user-item.muted .mute-button {
background: rgba(239, 68, 68, 0.2);
color: var(--color-error);
}
.channel-label {
font-size: 0.75rem;
color: var(--color-accent);
font-weight: 500;
}
+47 -5
View File
@@ -1,27 +1,69 @@
import './UserList.css'; import './UserList.css';
/** /**
* Liste des participants connectés * Liste des participants connectés (utilisateurs + canaux virtuels)
*/ */
export default function UserList({ participants }) { export default function UserList({ participants, onToggleMute }) {
if (participants.length === 0) { if (participants.length === 0) {
return ( return (
<div className="user-list empty"> <div className="user-list empty">
<p className="empty-message">Aucun autre participant</p> <p className="empty-message">Aucun participant ou canal</p>
</div> </div>
); );
} }
// Séparer canaux virtuels et utilisateurs
const virtualChannels = participants.filter(p => p.isVirtual);
const users = participants.filter(p => !p.isVirtual);
return ( return (
<div className="user-list"> <div className="user-list">
<div className="user-list-header"> <div className="user-list-header">
<span className="user-count"> <span className="user-count">
{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' : ''}`}
</span> </span>
</div> </div>
<div className="user-list-items"> <div className="user-list-items">
{participants.map((participant) => ( {/* Canaux virtuels en premier */}
{virtualChannels.map((participant) => (
<div
key={participant.identity}
className={`user-item virtual-channel ${participant.isSpeaking ? 'speaking' : ''} ${participant.isMuted ? 'muted' : ''}`}
>
<div className="user-avatar channel">
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21c2.31 0 4.2-1.75 4.45-4H15V6h4V3h-7z"/>
</svg>
</div>
<div className="user-info">
<span className="user-name">{participant.name}</span>
<span className="user-status channel-label">Canal audio</span>
</div>
<button
className="mute-button"
onClick={() => onToggleMute(participant.identity, participant.isVirtual)}
title={participant.isMuted ? 'Activer' : 'Désactiver'}
>
{participant.isMuted ? (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
) : (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
)}
</button>
</div>
))}
{/* Utilisateurs WebRTC */}
{users.map((participant) => (
<div <div
key={participant.identity} key={participant.identity}
className={`user-item ${participant.isSpeaking ? 'speaking' : ''}`} className={`user-item ${participant.isSpeaking ? 'speaking' : ''}`}
+83 -9
View File
@@ -16,6 +16,8 @@ export default function useLiveKit() {
const analyserRef = useRef(null); const analyserRef = useRef(null);
const animationFrameRef = useRef(null); const animationFrameRef = useRef(null);
const isAudioUnlockedRef = useRef(false); const isAudioUnlockedRef = useRef(false);
const virtualChannelsRef = useRef([]);
const mutedChannelsRef = useRef(new Set()); // IDs des canaux muted
// Analyseur audio pour pistes distantes (audio entrant) // Analyseur audio pour pistes distantes (audio entrant)
const remoteAudioContextRef = useRef(null); const remoteAudioContextRef = useRef(null);
@@ -25,8 +27,11 @@ export default function useLiveKit() {
/** /**
* Connexion à la room LiveKit * Connexion à la room LiveKit
*/ */
const connect = useCallback(async (url, token) => { const connect = useCallback(async (url, token, virtualChannels = []) => {
try { try {
// Stocker les canaux virtuels
virtualChannelsRef.current = virtualChannels;
// Créer room // Créer room
const room = new Room({ const room = new Room({
adaptiveStream: true, adaptiveStream: true,
@@ -154,7 +159,7 @@ export default function useLiveKit() {
/** /**
* Changer de groupe (reconnexion à une nouvelle room) * 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...'); console.log('🔄 Changement de groupe...');
// Déconnexion propre // Déconnexion propre
@@ -167,8 +172,11 @@ export default function useLiveKit() {
setIsConnected(false); setIsConnected(false);
setParticipants([]); setParticipants([]);
// Reset canaux muted
mutedChannelsRef.current.clear();
// Reconnexion avec nouveau token // Reconnexion avec nouveau token
await connect(url, token); await connect(url, token, virtualChannels);
}, [connect]); }, [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; if (!roomRef.current) return;
const room = roomRef.current; const room = roomRef.current;
const participantsList = []; 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) => { room.remoteParticipants.forEach((participant) => {
const audioTracks = participant.audioTracks ? Array.from(participant.audioTracks.values()) : []; const audioTracks = participant.audioTracks ? Array.from(participant.audioTracks.values()) : [];
const audioPublication = audioTracks[0]; const audioPublication = audioTracks[0];
@@ -270,13 +293,63 @@ export default function useLiveKit() {
identity: participant.identity, identity: participant.identity,
name: participant.name || participant.identity, name: participant.name || participant.identity,
isLocal: false, isLocal: false,
isVirtual: false,
isSpeaking, isSpeaking,
hasAudio: audioPublication?.isSubscribed || false hasAudio: audioPublication?.isSubscribed || false,
isMuted: false
}); });
}); });
setParticipants(participantsList); 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) * Setup analyseur audio pour VU-mètre (micro local)
@@ -412,6 +485,7 @@ export default function useLiveKit() {
disconnect, disconnect,
switchGroup, switchGroup,
startTalking, startTalking,
stopTalking stopTalking,
toggleParticipantMute
}; };
} }
+2
View File
@@ -13,6 +13,8 @@
--color-success: #10b981; --color-success: #10b981;
--color-warning: #f59e0b; --color-warning: #f59e0b;
--color-danger: #ef4444; --color-danger: #ef4444;
--color-accent: #8b5cf6;
--color-error: #ef4444;
/* PTT States */ /* PTT States */
--color-ptt-idle: #374151; --color-ptt-idle: #374151;
+10 -35
View File
@@ -52,16 +52,12 @@ function loadConfig() {
const configFile = readFileSync(configPath, 'utf8'); const configFile = readFileSync(configPath, 'utf8');
const config = YAML.parse(configFile); 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 => { config.groups = config.groups.map(group => {
const groupId = slugify(group.name); const groupId = slugify(group.name);
return { return {
...group, ...group,
id: groupId, id: groupId
channels: group.channels ? group.channels.map(channel => ({
...channel,
id: channel.id || `${groupId}-${slugify(channel.name)}`
})) : []
}; };
}); });
@@ -78,13 +74,7 @@ function saveConfig(config) {
...config, ...config,
groups: config.groups.map(group => { groups: config.groups.map(group => {
const { id, ...groupWithoutId } = group; const { id, ...groupWithoutId } = group;
return { return groupWithoutId;
...groupWithoutId,
channels: group.channels ? group.channels.map(channel => {
const { id: channelId, ...channelWithoutId } = channel;
return channelWithoutId;
}) : []
};
}) })
}; };
@@ -194,11 +184,11 @@ router.get('/groups', (req, res) => {
*/ */
router.post('/groups', (req, res) => { router.post('/groups', (req, res) => {
try { 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({ 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 // Créer le nouveau groupe (sans channels)
const channelsWithIds = channels.map(channel => ({
...channel,
id: channel.id || `${id}-${slugify(channel.name)}`
}));
// Créer le nouveau groupe
const newGroup = { const newGroup = {
name, name,
audioBitrate: audioBitrate || config.audio.defaultBitrate, ...(audioBitrate && { audioBitrate })
channels: channelsWithIds
}; };
config.groups.push(newGroup); config.groups.push(newGroup);
@@ -246,13 +229,13 @@ router.post('/groups', (req, res) => {
/** /**
* PUT /admin/groups/:id * PUT /admin/groups/:id
* Modifie un groupe existant * 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 * Note: l'ID est un slug généré, on cherche le groupe par nom dans le YAML
*/ */
router.put('/groups/:id', (req, res) => { router.put('/groups/:id', (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { name, audioBitrate, channels } = req.body; const { name, audioBitrate } = req.body;
const config = loadConfig(); const config = loadConfig();
@@ -268,14 +251,6 @@ router.put('/groups/:id', (req, res) => {
// Mettre à jour les champs fournis // Mettre à jour les champs fournis
if (name !== undefined) config.groups[groupIndex].name = name; if (name !== undefined) config.groups[groupIndex].name = name;
if (audioBitrate !== undefined) config.groups[groupIndex].audioBitrate = audioBitrate; 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); saveConfig(config);
+24 -18
View File
@@ -7,30 +7,36 @@ audio:
inputDeviceId: 0 inputDeviceId: 0
outputDeviceId: 2 outputDeviceId: 2
sampleRate: 48000 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: groups:
- name: Production - name: Production
audioBitrate: 96 audioBitrate: 96
channels:
- name: Principal
audioInput: 0
audioOutput: 0
- name: Backup
audioInput: 1
audioOutput: 1
- name: Technique - name: Technique
channels:
- name: Général
audioInput: 2
audioOutput: 2
- name: Sonorisation - name: Sonorisation
audioBitrate: 128 audioBitrate: 128
channels:
- name: Principal
audioInput: 3
audioOutput: 3
- name: Retours
audioInput: 4
audioOutput: 4
server: server:
host: 0.0.0.0 host: 0.0.0.0
port: 3000 port: 3000
+21 -6
View File
@@ -196,11 +196,7 @@ app.get('/config', (req, res) => {
const clientConfig = { const clientConfig = {
groups: config.groups.map(g => ({ groups: config.groups.map(g => ({
id: g.id, id: g.id,
name: g.name, name: g.name
channels: g.channels.map(c => ({
id: c.id,
name: c.name
}))
})), })),
audio: { audio: {
sampleRate: config.audio.sampleRate, sampleRate: config.audio.sampleRate,
@@ -281,11 +277,30 @@ app.post('/token', async (req, res) => {
// Enregistrer l'utilisateur dans le système admin // Enregistrer l'utilisateur dans le système admin
registerUser(participantIdentity, username, groupId, roomName); 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({ res.json({
token, token,
url: LIVEKIT_URL, url: LIVEKIT_URL,
roomName, roomName,
participantIdentity participantIdentity,
virtualChannels
}); });
} catch (error) { } catch (error) {