- {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) {