Compare commits

5 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 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"
**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
- **[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
- **[docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)** - Configuration LiveKit (Cloud + Local)
- **[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 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)
**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
│ ├── livekit-server # Binaire (téléchargé à l'install)
│ ├── bridge/
│ │ ├── audio.js # Détection + abstraction
│ │ ├── backends/
│ │ │ ├── jack.js
│ │ │ ├── pipewire.js
│ │ │ ├── coreaudio.js
│ │ │ └── wasapi.js
│ │ ── livekit.js
│ │ ├── opus.js
│ │ └── jitter.js
│ │ ├── AudioBridge.js # Classe principale
│ │ ├── AudioBridgeManager.js # Gestion cycle de vie
│ │ ├── LiveKitClient.js # Connexion SFU
│ │ ├── OpusCodec.js # Transcodage PCM ↔ Opus
│ │ ├── JitterBuffer.js # Buffer 40ms
│ │ ├── ServerAudioUser.js # Utilisateur audio serveur
│ │ ── backends/
│ │ ├── CoreAudioBackend.js
│ │ ├── JACKBackend.js
│ │ ├── PipeWireBackend.js
│ │ └── WASAPIBackend.js
│ ├── api/ # Admin REST
│ └── config/
│ └── config.yaml
+14 -2
View File
@@ -454,7 +454,13 @@ app.whenReady().then(async () => {
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 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];
writeConfig(config);
return { success: true, user };
@@ -469,7 +475,13 @@ app.whenReady().then(async () => {
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 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);
return { success: true };
} catch (error) {
+21 -12
View File
@@ -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);
} else if (action === 'delete') {
await deleteServerAudioUser(name);
}
@@ -874,9 +874,7 @@ function buildChannelOptions(dir) {
label: names[i] ? `Ch ${i}: ${names[i]}` : `Ch ${i}`
}));
if (dir === 'output') {
opts.unshift({ value: '', label: 'Aucune sortie' });
}
opts.unshift({ value: '', label: dir === 'input' ? 'Aucune entrée' : 'Aucune sortie' });
return opts;
}
@@ -890,8 +888,8 @@ async function addServerAudioUser() {
const outOpts = buildChannelOptions('output');
const inputField = inOpts
? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: '0' }
: { name: 'input_channel', label: 'Canal entrée (index)', type: 'number', default: 0, min: 0, max: 63 };
? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: '' }
: { name: 'input_channel', label: 'Canal entrée (index, vide = aucune)', type: 'number', default: '', min: 0, max: 63 };
const outputField = outOpts
? { 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 };
@@ -912,7 +910,7 @@ async function addServerAudioUser() {
const res = await window.electronAPI.serverAudioUsers.create({
name: result.name.trim(),
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
});
@@ -931,10 +929,11 @@ async function editServerAudioUser(name, group, input_channel, output_channel) {
const inOpts = buildChannelOptions('input');
const outOpts = buildChannelOptions('output');
const inputDefault = input_channel !== null && input_channel !== undefined && input_channel !== 'null' ? String(input_channel) : '';
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, vide = aucune)', 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 };
@@ -954,7 +953,7 @@ async function editServerAudioUser(name, group, input_channel, output_channel) {
const res = await window.electronAPI.serverAudioUsers.update({
name,
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
});
@@ -1118,6 +1117,15 @@ function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClas
<select id="modal-field-${field.name}" class="form-control">${optionsHtml}</select>
</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 `
<div class="form-group">
<label>${escapeHtml(field.label)}</label>
@@ -1155,7 +1163,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);
}
+23
View File
@@ -1044,6 +1044,29 @@ body {
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 {
+5 -1
View File
@@ -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`);
+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({
room: groupId,
roomJoin: true,
canPublish: true,
canPublish: publish,
canSubscribe: true,
canPublishData: true
});
@@ -81,8 +85,9 @@ class AudioBridgeManager extends EventEmitter {
serverAudioUsers.push({
name: user.name,
groupId,
inputChannel: user.input_channel ?? user.inputChannel ?? 0,
inputChannel,
outputChannel: outputChannel !== null && outputChannel !== undefined ? outputChannel : null,
publish,
token: jwt
});
+7 -3
View File
@@ -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);