From b0f7d294d802ec52f18b5f9e359f5e93a0d60ba0 Mon Sep 17 00:00:00 2001 From: Benoit Date: Fri, 3 Jul 2026 17:13:58 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20mode=20=C3=A9coute=20seule=20pour=20les?= =?UTF-8?q?=20server=20audio=20users=20(Master=20par=20groupe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un participant serveur peut être configuré sans publier de micro — il reçoit le mix du groupe et le sort sur un canal physique. - ServerAudioUser: flag publish (défaut true), sendAudio no-op si false - AudioBridgeManager: canPublish LiveKit selon flag, input_channel null si écoute - AudioBridge: passe publish à ServerAudioUser, log adapté - Electron UI: checkbox "Écoute seule" dans add/edit, badges 🎤/👂 dans table - main.js IPC: persist publish + input_channel null en écoute --- electron/main.js | 22 ++++++++++--- electron/ui/app.js | 50 +++++++++++++++++++++-------- electron/ui/styles.css | 41 +++++++++++++++++++++++ server/bridge/AudioBridge.js | 6 +++- server/bridge/AudioBridgeManager.js | 8 +++-- server/bridge/ServerAudioUser.js | 10 ++++-- 6 files changed, 114 insertions(+), 23 deletions(-) diff --git a/electron/main.js b/electron/main.js index 4fade6c..a21a34b 100644 --- a/electron/main.js +++ b/electron/main.js @@ -447,14 +447,21 @@ app.whenReady().then(async () => { } }); - ipcMain.handle('server-audio-users:create', (event, { name, group, input_channel, output_channel }) => { + ipcMain.handle('server-audio-users:create', (event, { name, group, input_channel, output_channel, publish }) => { try { const config = readConfig(); const users = config.server_audio_users || []; if (users.find(u => u.name === name)) { return { success: false, error: `Un utilisateur "${name}" existe déjà` }; } - const user = { name, group, input_channel: parseInt(input_channel), output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null }; + const isPublish = publish !== false; + const user = { + name, + group, + publish: isPublish, + input_channel: isPublish && input_channel !== null && input_channel !== undefined ? parseInt(input_channel) : null, + output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null + }; config.server_audio_users = [...users, user]; writeConfig(config); return { success: true, user }; @@ -463,13 +470,20 @@ app.whenReady().then(async () => { } }); - ipcMain.handle('server-audio-users:update', (event, { name, group, input_channel, output_channel }) => { + ipcMain.handle('server-audio-users:update', (event, { name, group, input_channel, output_channel, publish }) => { try { const config = readConfig(); const users = config.server_audio_users || []; const idx = users.findIndex(u => u.name === name); if (idx === -1) return { success: false, error: `Utilisateur "${name}" introuvable` }; - config.server_audio_users[idx] = { name, group, input_channel: parseInt(input_channel), output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null }; + const isPublish = publish !== false; + config.server_audio_users[idx] = { + name, + group, + publish: isPublish, + input_channel: isPublish && input_channel !== null && input_channel !== undefined ? parseInt(input_channel) : null, + output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null + }; writeConfig(config); return { success: true }; } catch (error) { diff --git a/electron/ui/app.js b/electron/ui/app.js index 49c96a5..f4c0393 100644 --- a/electron/ui/app.js +++ b/electron/ui/app.js @@ -803,7 +803,7 @@ document.addEventListener('DOMContentLoaded', () => { const action = btn.dataset.sauAction; const name = btn.dataset.sauName; if (action === 'edit') { - await editServerAudioUser(name, btn.dataset.sauGroup, parseInt(btn.dataset.sauInput), parseInt(btn.dataset.sauOutput)); + await editServerAudioUser(name, btn.dataset.sauGroup, btn.dataset.sauInput, btn.dataset.sauOutput, btn.dataset.sauPublish !== 'false'); } else if (action === 'delete') { await deleteServerAudioUser(name); } @@ -834,14 +834,19 @@ function renderServerAudioUsers() { container.innerHTML = ` - + - ${users.map(u => ` + ${users.map(u => { + const isListenOnly = u.publish === false; + const modeLabel = isListenOnly ? '👂 Écoute' : '🎤 Actif'; + const inputLabel = isListenOnly ? '' : `${chLabel(u.input_channel, 'input')}`; + return ` - + + - `).join('')} + `; + }).join('')}
NomGroupeEntréeSortie
NomGroupeModeEntréeSortie
${escapeHtml(u.name)} ${escapeHtml(u.group)}${chLabel(u.input_channel, 'input')}${modeLabel}${inputLabel} ${chLabel(u.output_channel, 'output')} + data-sau-output="${u.output_channel}" + data-sau-publish="${u.publish !== false}">Éditer
`; } @@ -901,6 +908,7 @@ async function addServerAudioUser() { fields: [ { name: 'name', label: 'Nom (identifiant unique, ex: foh)' }, { name: 'group', label: 'Groupe', type: 'select', options: groupOptions, default: defaultGroup }, + { name: 'publish', label: 'Publier audio vers le groupe (décocher = écoute seule)', type: 'checkbox', default: true }, inputField, outputField ], @@ -909,10 +917,12 @@ async function addServerAudioUser() { if (!result || !result.name.trim()) return; + const publish = result.publish !== false && result.publish !== 'false'; const res = await window.electronAPI.serverAudioUsers.create({ name: result.name.trim(), group: result.group, - input_channel: parseInt(result.input_channel), + publish, + input_channel: publish ? (result.input_channel !== '' ? parseInt(result.input_channel) : null) : null, output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null }); @@ -924,17 +934,18 @@ async function addServerAudioUser() { } } -async function editServerAudioUser(name, group, input_channel, output_channel) { +async function editServerAudioUser(name, group, input_channel, output_channel, publish = true) { const groupsData = await window.electronAPI.groups.list(); const groupOptions = (groupsData.groups || []).map(g => ({ value: slugify(g.name), label: g.name })); const inOpts = buildChannelOptions('input'); const outOpts = buildChannelOptions('output'); + const inputDefault = input_channel !== null && input_channel !== undefined && input_channel !== 'null' ? String(input_channel) : '0'; const inputField = inOpts - ? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: String(input_channel) } - : { name: 'input_channel', label: 'Canal entrée (index)', type: 'number', default: input_channel, min: 0, max: 63 }; - const outputDefault = output_channel !== null && output_channel !== undefined ? String(output_channel) : ''; + ? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: inputDefault } + : { name: 'input_channel', label: 'Canal entrée (index)', type: 'number', default: inputDefault, min: 0, max: 63 }; + const outputDefault = output_channel !== null && output_channel !== undefined && output_channel !== 'null' ? String(output_channel) : ''; const outputField = outOpts ? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: outputDefault } : { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: outputDefault, min: 0, max: 63 }; @@ -943,6 +954,7 @@ async function editServerAudioUser(name, group, input_channel, output_channel) { title: `Modifier "${name}"`, fields: [ { name: 'group', label: 'Groupe', type: 'select', options: groupOptions, default: group }, + { name: 'publish', label: 'Publier audio vers le groupe (décocher = écoute seule)', type: 'checkbox', default: publish }, inputField, outputField ], @@ -951,10 +963,12 @@ async function editServerAudioUser(name, group, input_channel, output_channel) { if (!result) return; + const newPublish = result.publish !== false && result.publish !== 'false'; const res = await window.electronAPI.serverAudioUsers.update({ name, group: result.group, - input_channel: parseInt(result.input_channel), + publish: newPublish, + input_channel: newPublish ? (result.input_channel !== '' ? parseInt(result.input_channel) : null) : null, output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null }); @@ -1118,6 +1132,15 @@ function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClas `; } + if (field.type === 'checkbox') { + return ` +
+ +
`; + } return `
@@ -1155,7 +1178,8 @@ function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClas const result = {}; fields.forEach(f => { const input = document.getElementById(`modal-field-${f.name}`); - result[f.name] = input ? input.value : ''; + if (!input) { result[f.name] = ''; return; } + result[f.name] = f.type === 'checkbox' ? input.checked : input.value; }); cleanup(); resolve(result); } diff --git a/electron/ui/styles.css b/electron/ui/styles.css index 066949a..9d297fd 100644 --- a/electron/ui/styles.css +++ b/electron/ui/styles.css @@ -1044,6 +1044,47 @@ body { font-family: inherit; } +.ch-badge-active { + background: rgba(76, 175, 80, 0.12); + border-color: rgba(76, 175, 80, 0.3); + color: #4caf50; + font-family: inherit; +} + +.ch-badge-listen { + background: rgba(255, 152, 0, 0.12); + border-color: rgba(255, 152, 0, 0.3); + color: #ff9800; + font-family: inherit; +} + +.ch-badge-muted { + color: var(--text-secondary); + opacity: 0.5; +} + +.form-group-check { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.check-label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + font-size: 0.9rem; + color: var(--text-primary); +} + +.check-label input[type="checkbox"] { + width: 1rem; + height: 1rem; + cursor: pointer; + accent-color: var(--accent-primary); +} + /* Routing actions bar */ .routing-actions { diff --git a/server/bridge/AudioBridge.js b/server/bridge/AudioBridge.js index a32eb50..d2d9579 100644 --- a/server/bridge/AudioBridge.js +++ b/server/bridge/AudioBridge.js @@ -309,6 +309,7 @@ export class AudioBridge extends EventEmitter { groupId: userConfig.groupId, inputChannel: userConfig.inputChannel, outputChannel: userConfig.outputChannel, + publish: userConfig.publish !== false, liveKitUrl: this.options.liveKitUrl, token: userConfig.token, sampleRate: this.options.sampleRate, @@ -338,7 +339,10 @@ export class AudioBridge extends EventEmitter { await user.start(); this.serverAudioUsers.set(userConfig.name, user); - console.log(`✓ Server audio user "${userConfig.name}" démarré (entrée canal ${userConfig.inputChannel} → sortie canal ${userConfig.outputChannel}, room: ${userConfig.groupId})`); + const modeStr = userConfig.publish !== false + ? `canal ${userConfig.inputChannel} → sortie canal ${userConfig.outputChannel ?? 'aucune'}` + : `écoute seule → sortie canal ${userConfig.outputChannel ?? 'aucune'}`; + console.log(`✓ Server audio user "${userConfig.name}" démarré (${modeStr}, room: ${userConfig.groupId})`); } console.log(`✓ ${this.serverAudioUsers.size} server audio user(s) initialisés`); diff --git a/server/bridge/AudioBridgeManager.js b/server/bridge/AudioBridgeManager.js index e79e0af..0fc82bf 100644 --- a/server/bridge/AudioBridgeManager.js +++ b/server/bridge/AudioBridgeManager.js @@ -66,10 +66,12 @@ class AudioBridgeManager extends EventEmitter { } ); + const publish = user.publish !== false; + token.addGrant({ room: groupId, roomJoin: true, - canPublish: true, + canPublish: publish, canSubscribe: true, canPublishData: true }); @@ -77,12 +79,14 @@ class AudioBridgeManager extends EventEmitter { const jwt = await token.toJwt(); const outputChannel = user.output_channel ?? user.outputChannel; + const rawInputChannel = user.input_channel ?? user.inputChannel; serverAudioUsers.push({ name: user.name, groupId, - inputChannel: user.input_channel ?? user.inputChannel ?? 0, + inputChannel: rawInputChannel !== null && rawInputChannel !== undefined ? rawInputChannel : null, outputChannel: outputChannel !== null && outputChannel !== undefined ? outputChannel : null, + publish, token: jwt }); diff --git a/server/bridge/ServerAudioUser.js b/server/bridge/ServerAudioUser.js index 231b223..630def4 100644 --- a/server/bridge/ServerAudioUser.js +++ b/server/bridge/ServerAudioUser.js @@ -17,10 +17,13 @@ class ServerAudioUser extends EventEmitter { super(); this.name = options.name; - this.inputChannel = parseInt(options.inputChannel, 10); + this.inputChannel = (options.inputChannel !== null && options.inputChannel !== undefined) + ? parseInt(options.inputChannel, 10) + : null; this.outputChannel = (options.outputChannel !== null && options.outputChannel !== undefined) ? parseInt(options.outputChannel, 10) : null; + this.publish = options.publish !== false; // false = écoute seule this.groupId = options.groupId; this.frameSize = options.frameSize || 960; this.sampleRate = options.sampleRate || 48000; @@ -45,7 +48,8 @@ class ServerAudioUser extends EventEmitter { _setupClientEvents() { this.client.on('connected', () => { - console.log(`[ServerAudioUser:${this.name}] Connecté à room "${this.groupId}" (in:${this.inputChannel} → out:${this.outputChannel ?? 'aucune'})`); + const mode = this.publish ? `in:${this.inputChannel} → out:${this.outputChannel ?? 'aucune'}` : `écoute → out:${this.outputChannel ?? 'aucune'}`; + console.log(`[ServerAudioUser:${this.name}] Connecté à room "${this.groupId}" (${mode})`); this.emit('connected'); }); @@ -78,7 +82,7 @@ class ServerAudioUser extends EventEmitter { * @param {Float32Array} float32Data - Données PCM normalisées [-1.0, 1.0] */ sendAudio(float32Data) { - if (!this.client.isConnected) return; + if (!this.publish || !this.client.isConnected) return; const pcmBuffer = this._float32ToBuffer(float32Data); this.client.sendAudioData(pcmBuffer);