Compare commits

6 Commits
Author SHA1 Message Date
benoit 675348794c docs: mettre à jour README (v0.3.0, structure bridge, licence MIT) 2026-07-07 21:57:56 +02:00
benoit 9e1b025c8f docs: ajouter licence MIT 2026-07-07 21:54:47 +02:00
benoit a351116f58 Merge pull request 'Routage complexe' (#4) from Routage-complexe into desktop-app
Reviewed-on: #4
2026-07-03 17:29:07 +02:00
benoit bb31b142c9 Merge pull request 'Routage complexe' (#3) from Routage-complexe into desktop-app
Reviewed-on: #3
2026-07-03 17:27:53 +02:00
benoit 0cbad12e49 refactor: simplifier UI server audio users — entrée vide = écoute seule
Supprime la checkbox "Publier audio" et la colonne Mode.
Le comportement est déduit de l'entrée : aucune entrée = écoute seule.
Option "Aucune entrée" ajoutée au select canal d'entrée (comme pour sortie).
publish dérivé de inputChannel !== null dans AudioBridgeManager.
2026-07-03 17:21:48 +02:00
benoit b0f7d294d8 feat: mode écoute seule pour les server audio users (Master par groupe)
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
2026-07-03 17:13:58 +02:00
8 changed files with 115 additions and 34 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Benoit S
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+17 -14
View File
@@ -114,8 +114,6 @@ Communiquez via smartphone (PWA) en WiFi, le serveur fait le pont avec l'install
--- ---
---
## 🐛 Dépannage : "Connexion impossible" ## 🐛 Dépannage : "Connexion impossible"
**Cause** : Clés LiveKit non configurées ou invalides. **Cause** : Clés LiveKit non configurées ou invalides.
@@ -132,7 +130,9 @@ Voir le guide complet : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
## 📚 Documentation ## 📚 Documentation
- **[README-PORTABLE.md](README-PORTABLE.md)** - 🆕 **Guide déploiement portable** (zéro config) - **[DESKTOP-APP.md](DESKTOP-APP.md)** - Application desktop Electron (dashboard + config)
- **[README-PORTABLE.md](README-PORTABLE.md)** - Guide déploiement portable (zéro config)
- **[SSL-SETUP.md](SSL-SETUP.md)** - Configuration certificats HTTPS locaux
- **[NETWORK_SETUP.md](NETWORK_SETUP.md)** - Configuration réseau multi-appareils - **[NETWORK_SETUP.md](NETWORK_SETUP.md)** - Configuration réseau multi-appareils
- **[docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)** - Configuration LiveKit (Cloud + Local) - **[docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)** - Configuration LiveKit (Cloud + Local)
- **[CLAUDE.md](CLAUDE.md)** - Documentation développement complète - **[CLAUDE.md](CLAUDE.md)** - Documentation développement complète
@@ -144,10 +144,11 @@ Voir le guide complet : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
- ✅ **Phase 1** : MVP fonctionnel (WebRTC + PTT) - ✅ **Phase 1** : MVP fonctionnel (WebRTC + PTT)
- ✅ **Phase 2** : Fonctionnalités avancées (groupes, routing, admin) - ✅ **Phase 2** : Fonctionnalités avancées (groupes, routing, admin)
- 🆕 **Portable** : Installation zéro-config macOS/Linux - **Portable** : Installation zéro-config macOS/Linux
- ✅ **Desktop** : Application Electron avec dashboard complet
- ⏳ **Phase 3** : Intégrations audio pro (Dante, AES67) - ⏳ **Phase 3** : Intégrations audio pro (Dante, AES67)
**Version actuelle** : 0.2.0 (Portable - production-ready) **Version actuelle** : 0.3.0 (Desktop App)
--- ---
@@ -299,15 +300,17 @@ project/
│ ├── index.js # Point d'entrée unique │ ├── index.js # Point d'entrée unique
│ ├── livekit-server # Binaire (téléchargé à l'install) │ ├── livekit-server # Binaire (téléchargé à l'install)
│ ├── bridge/ │ ├── bridge/
│ │ ├── audio.js # Détection + abstraction │ │ ├── AudioBridge.js # Classe principale
│ │ ├── backends/ │ │ ├── AudioBridgeManager.js # Gestion cycle de vie
│ │ │ ├── jack.js │ │ ├── LiveKitClient.js # Connexion SFU
│ │ │ ├── pipewire.js │ │ ├── OpusCodec.js # Transcodage PCM ↔ Opus
│ │ │ ├── coreaudio.js │ │ ├── JitterBuffer.js # Buffer 40ms
│ │ │ └── wasapi.js │ │ ├── ServerAudioUser.js # Utilisateur audio serveur
│ │ ── livekit.js │ │ ── backends/
│ │ ├── opus.js │ │ ├── CoreAudioBackend.js
│ │ └── jitter.js │ │ ├── JACKBackend.js
│ │ ├── PipeWireBackend.js
│ │ └── WASAPIBackend.js
│ ├── api/ # Admin REST │ ├── api/ # Admin REST
│ └── config/ │ └── config/
│ └── config.yaml │ └── config.yaml
+14 -2
View File
@@ -454,7 +454,13 @@ app.whenReady().then(async () => {
if (users.find(u => u.name === name)) { if (users.find(u => u.name === name)) {
return { success: false, error: `Un utilisateur "${name}" existe déjà` }; 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 parsedInput = input_channel !== null && input_channel !== undefined ? parseInt(input_channel) : null;
const user = {
name,
group,
input_channel: parsedInput,
output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null
};
config.server_audio_users = [...users, user]; config.server_audio_users = [...users, user];
writeConfig(config); writeConfig(config);
return { success: true, user }; return { success: true, user };
@@ -469,7 +475,13 @@ app.whenReady().then(async () => {
const users = config.server_audio_users || []; const users = config.server_audio_users || [];
const idx = users.findIndex(u => u.name === name); const idx = users.findIndex(u => u.name === name);
if (idx === -1) return { success: false, error: `Utilisateur "${name}" introuvable` }; 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 parsedInput = input_channel !== null && input_channel !== undefined ? parseInt(input_channel) : null;
config.server_audio_users[idx] = {
name,
group,
input_channel: parsedInput,
output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null
};
writeConfig(config); writeConfig(config);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
+21 -12
View File
@@ -803,7 +803,7 @@ document.addEventListener('DOMContentLoaded', () => {
const action = btn.dataset.sauAction; const action = btn.dataset.sauAction;
const name = btn.dataset.sauName; const name = btn.dataset.sauName;
if (action === 'edit') { 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);
} else if (action === 'delete') { } else if (action === 'delete') {
await deleteServerAudioUser(name); await deleteServerAudioUser(name);
} }
@@ -874,9 +874,7 @@ function buildChannelOptions(dir) {
label: names[i] ? `Ch ${i}: ${names[i]}` : `Ch ${i}` label: names[i] ? `Ch ${i}: ${names[i]}` : `Ch ${i}`
})); }));
if (dir === 'output') { opts.unshift({ value: '', label: dir === 'input' ? 'Aucune entrée' : 'Aucune sortie' });
opts.unshift({ value: '', label: 'Aucune sortie' });
}
return opts; return opts;
} }
@@ -890,8 +888,8 @@ async function addServerAudioUser() {
const outOpts = buildChannelOptions('output'); const outOpts = buildChannelOptions('output');
const inputField = inOpts const inputField = inOpts
? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: '0' } ? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: '' }
: { name: 'input_channel', label: 'Canal entrée (index)', type: 'number', default: 0, min: 0, max: 63 }; : { name: 'input_channel', label: 'Canal entrée (index, vide = aucune)', type: 'number', default: '', min: 0, max: 63 };
const outputField = outOpts const outputField = outOpts
? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: '' } ? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: '' }
: { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: '', min: 0, max: 63 }; : { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: '', min: 0, max: 63 };
@@ -912,7 +910,7 @@ async function addServerAudioUser() {
const res = await window.electronAPI.serverAudioUsers.create({ const res = await window.electronAPI.serverAudioUsers.create({
name: result.name.trim(), name: result.name.trim(),
group: result.group, group: result.group,
input_channel: parseInt(result.input_channel), input_channel: result.input_channel !== '' ? parseInt(result.input_channel) : null,
output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null
}); });
@@ -931,10 +929,11 @@ async function editServerAudioUser(name, group, input_channel, output_channel) {
const inOpts = buildChannelOptions('input'); const inOpts = buildChannelOptions('input');
const outOpts = buildChannelOptions('output'); const outOpts = buildChannelOptions('output');
const inputDefault = input_channel !== null && input_channel !== undefined && input_channel !== 'null' ? String(input_channel) : '';
const inputField = inOpts const inputField = inOpts
? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: String(input_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: input_channel, min: 0, max: 63 }; : { name: 'input_channel', label: 'Canal entrée (index, vide = aucune)', type: 'number', default: inputDefault, min: 0, max: 63 };
const outputDefault = output_channel !== null && output_channel !== undefined ? String(output_channel) : ''; const outputDefault = output_channel !== null && output_channel !== undefined && output_channel !== 'null' ? String(output_channel) : '';
const outputField = outOpts const outputField = outOpts
? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: outputDefault } ? { 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 }; : { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: outputDefault, min: 0, max: 63 };
@@ -954,7 +953,7 @@ async function editServerAudioUser(name, group, input_channel, output_channel) {
const res = await window.electronAPI.serverAudioUsers.update({ const res = await window.electronAPI.serverAudioUsers.update({
name, name,
group: result.group, group: result.group,
input_channel: parseInt(result.input_channel), input_channel: result.input_channel !== '' ? parseInt(result.input_channel) : null,
output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null
}); });
@@ -1118,6 +1117,15 @@ function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClas
<select id="modal-field-${field.name}" class="form-control">${optionsHtml}</select> <select id="modal-field-${field.name}" class="form-control">${optionsHtml}</select>
</div>`; </div>`;
} }
if (field.type === 'checkbox') {
return `
<div class="form-group form-group-check">
<label class="check-label">
<input type="checkbox" id="modal-field-${field.name}" ${field.default !== false ? 'checked' : ''}>
${escapeHtml(field.label)}
</label>
</div>`;
}
return ` return `
<div class="form-group"> <div class="form-group">
<label>${escapeHtml(field.label)}</label> <label>${escapeHtml(field.label)}</label>
@@ -1155,7 +1163,8 @@ function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClas
const result = {}; const result = {};
fields.forEach(f => { fields.forEach(f => {
const input = document.getElementById(`modal-field-${f.name}`); 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); cleanup(); resolve(result);
} }
+23
View File
@@ -1044,6 +1044,29 @@ body {
font-family: inherit; font-family: inherit;
} }
.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 bar */
.routing-actions { .routing-actions {
+5 -1
View File
@@ -309,6 +309,7 @@ export class AudioBridge extends EventEmitter {
groupId: userConfig.groupId, groupId: userConfig.groupId,
inputChannel: userConfig.inputChannel, inputChannel: userConfig.inputChannel,
outputChannel: userConfig.outputChannel, outputChannel: userConfig.outputChannel,
publish: userConfig.publish !== false,
liveKitUrl: this.options.liveKitUrl, liveKitUrl: this.options.liveKitUrl,
token: userConfig.token, token: userConfig.token,
sampleRate: this.options.sampleRate, sampleRate: this.options.sampleRate,
@@ -338,7 +339,10 @@ export class AudioBridge extends EventEmitter {
await user.start(); await user.start();
this.serverAudioUsers.set(userConfig.name, user); 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`); console.log(`${this.serverAudioUsers.size} server audio user(s) initialisés`);
+7 -2
View File
@@ -66,10 +66,14 @@ class AudioBridgeManager extends EventEmitter {
} }
); );
const rawInputChannel = user.input_channel ?? user.inputChannel ?? null;
const inputChannel = rawInputChannel !== null && rawInputChannel !== undefined ? rawInputChannel : null;
const publish = inputChannel !== null;
token.addGrant({ token.addGrant({
room: groupId, room: groupId,
roomJoin: true, roomJoin: true,
canPublish: true, canPublish: publish,
canSubscribe: true, canSubscribe: true,
canPublishData: true canPublishData: true
}); });
@@ -81,8 +85,9 @@ class AudioBridgeManager extends EventEmitter {
serverAudioUsers.push({ serverAudioUsers.push({
name: user.name, name: user.name,
groupId, groupId,
inputChannel: user.input_channel ?? user.inputChannel ?? 0, inputChannel,
outputChannel: outputChannel !== null && outputChannel !== undefined ? outputChannel : null, outputChannel: outputChannel !== null && outputChannel !== undefined ? outputChannel : null,
publish,
token: jwt token: jwt
}); });
+7 -3
View File
@@ -17,10 +17,13 @@ class ServerAudioUser extends EventEmitter {
super(); super();
this.name = options.name; 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) this.outputChannel = (options.outputChannel !== null && options.outputChannel !== undefined)
? parseInt(options.outputChannel, 10) ? parseInt(options.outputChannel, 10)
: null; : null;
this.publish = options.publish !== false; // false = écoute seule
this.groupId = options.groupId; this.groupId = options.groupId;
this.frameSize = options.frameSize || 960; this.frameSize = options.frameSize || 960;
this.sampleRate = options.sampleRate || 48000; this.sampleRate = options.sampleRate || 48000;
@@ -45,7 +48,8 @@ class ServerAudioUser extends EventEmitter {
_setupClientEvents() { _setupClientEvents() {
this.client.on('connected', () => { 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'); this.emit('connected');
}); });
@@ -78,7 +82,7 @@ class ServerAudioUser extends EventEmitter {
* @param {Float32Array} float32Data - Données PCM normalisées [-1.0, 1.0] * @param {Float32Array} float32Data - Données PCM normalisées [-1.0, 1.0]
*/ */
sendAudio(float32Data) { sendAudio(float32Data) {
if (!this.client.isConnected) return; if (!this.publish || !this.client.isConnected) return;
const pcmBuffer = this._float32ToBuffer(float32Data); const pcmBuffer = this._float32ToBuffer(float32Data);
this.client.sendAudioData(pcmBuffer); this.client.sendAudioData(pcmBuffer);