Compare commits
19 Commits
9654c7f421
...
1c89546b61
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c89546b61 | |||
| f873dc25f6 | |||
| e89b20295e | |||
| 2338562b4f | |||
| 6a9ee05114 | |||
| 2acd652df0 | |||
| 61b3bedcae | |||
| cc4f5ca35a | |||
| be05755677 | |||
| cd76b66529 | |||
| 7e5c8744cd | |||
| 37aa447ecd | |||
| 6c35121866 | |||
| fb9d0fd101 | |||
| e460376d9a | |||
| 37ed66a043 | |||
| b766789a2a | |||
| b5874b5c3b | |||
| 37205f0409 |
@@ -1,7 +1,7 @@
|
|||||||
# TODO.md - Plan de développement PTT Live
|
# TODO.md - Plan de développement PTT Live
|
||||||
|
|
||||||
**Dernière mise à jour** : 2026-05-25
|
**Dernière mise à jour** : 2026-05-26
|
||||||
**Phase actuelle** : PHASE 2 - Fonctionnalités professionnelles (Phase 2.5 TERMINÉE - Configuration audio visuelle complète)
|
**Phase actuelle** : PHASE 3 - Intégrations audio pro (Phase 3.1 EN COURS - Support Linux)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -215,28 +215,28 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
|
|||||||
## PHASE 3 — Intégrations audio pro
|
## PHASE 3 — Intégrations audio pro
|
||||||
|
|
||||||
### 3.1 Support Linux
|
### 3.1 Support Linux
|
||||||
- [ ] Backend JACK (server/bridge/backends/JACKBackend.js)
|
- [x] Backend JACK (server/bridge/backends/JACKBackend.js)
|
||||||
- [ ] Backend PipeWire (server/bridge/backends/PipeWireBackend.js)
|
- [x] Backend PipeWire (server/bridge/backends/PipeWireBackend.js)
|
||||||
- [ ] Script install/linux.sh
|
- [x] Script install/linux.sh
|
||||||
- [ ] Tests Ubuntu 22.04 LTS + Arch Linux
|
- [ ] Tests Ubuntu 22.04 LTS + Arch Linux
|
||||||
|
|
||||||
### 3.2 Dante
|
### 3.2 Dante
|
||||||
- [ ] Documentation setup DVS macOS
|
- [x] Documentation setup DVS macOS
|
||||||
- [ ] Routing JACK ↔ DVS
|
- [x] Guide configuration réseau Dante
|
||||||
|
- [ ] Routing JACK ↔ DVS (tests pratiques)
|
||||||
- [ ] Tests multi-canaux (8+)
|
- [ ] Tests multi-canaux (8+)
|
||||||
- [ ] Guide configuration réseau Dante
|
|
||||||
|
|
||||||
### 3.3 AES67
|
### 3.3 AES67
|
||||||
- [ ] Backend RTP multicast (Linux)
|
- [x] Documentation setup AES67 + PTP sync
|
||||||
- [ ] PTP sync
|
- [ ] Backend RTP multicast (Linux) - optionnel, driver Merging RAVENNA suffit
|
||||||
- [ ] Tests interop Dante (mode AES67)
|
- [ ] Tests interop Dante (mode AES67)
|
||||||
|
|
||||||
### 3.4 Production
|
### 3.4 Production
|
||||||
- [ ] Script install Windows (install/windows.ps1)
|
- [ ] Script install Windows (install/windows.ps1) - optionnel, focus Linux/macOS
|
||||||
- [ ] Tests charge : 30+ clients simultanés
|
- [ ] Tests charge : 30+ clients simultanés - à réaliser en situation réelle
|
||||||
- [ ] Optimisation réseau (QoS, DSCP)
|
- [x] Documentation déploiement complet (DEPLOYMENT.md)
|
||||||
- [ ] Documentation déploiement complet
|
- [x] Guide troubleshooting (TROUBLESHOOTING.md)
|
||||||
- [ ] Guide troubleshooting
|
- [x] Optimisation réseau (QoS, DSCP) - documenté dans DEPLOYMENT.md
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -255,10 +255,17 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
|
|||||||
5. ✅ Préférences utilisateur pour mode PTT par défaut (2.2)
|
5. ✅ Préférences utilisateur pour mode PTT par défaut (2.2)
|
||||||
6. ✅ Web Push notifications pour appels privés (2.4)
|
6. ✅ Web Push notifications pour appels privés (2.4)
|
||||||
|
|
||||||
### Phase 3 - Préparation
|
### Phase 3 - COMPLETEE (documentation et backends)
|
||||||
- Support Linux (JACK/PipeWire backends)
|
1. ✅ Backend JACK pour Linux professionnel (3.1)
|
||||||
- Intégration Dante/AES67
|
2. ✅ Backend PipeWire pour Linux moderne (3.1)
|
||||||
- Tests charge 30+ clients
|
3. ✅ Détection automatique backend dans AudioBridge (3.1)
|
||||||
|
4. ✅ Script installation Linux multi-distros (3.1)
|
||||||
|
5. ✅ Documentation complete Dante + routing JACK (3.2)
|
||||||
|
6. ✅ Documentation complete AES67 + PTP sync (3.3)
|
||||||
|
7. ✅ Guide deploiement production 30+ clients (3.4)
|
||||||
|
8. ✅ Guide troubleshooting complet (3.4)
|
||||||
|
9. ⏳ Tests pratiques sur Ubuntu 22.04 LTS (3.1) - a realiser
|
||||||
|
10. ⏳ Tests charge 30+ clients reel (3.4) - a realiser en evenement
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ define(['./workbox-290dd570'], (function (workbox) { 'use strict';
|
|||||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||||
}, {
|
}, {
|
||||||
"url": "index.html",
|
"url": "index.html",
|
||||||
"revision": "0.guj84039cv8"
|
"revision": "0.oebo7b1mt4g"
|
||||||
}], {});
|
}], {});
|
||||||
workbox.cleanupOutdatedCaches();
|
workbox.cleanupOutdatedCaches();
|
||||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+20
-7
@@ -19,6 +19,7 @@ function Admin() {
|
|||||||
const [selectedInputDevice, setSelectedInputDevice] = useState(null);
|
const [selectedInputDevice, setSelectedInputDevice] = useState(null);
|
||||||
const [selectedOutputDevice, setSelectedOutputDevice] = useState(null);
|
const [selectedOutputDevice, setSelectedOutputDevice] = useState(null);
|
||||||
const [selectedSampleRate, setSelectedSampleRate] = useState(48000);
|
const [selectedSampleRate, setSelectedSampleRate] = useState(48000);
|
||||||
|
const [isEditingAudio, setIsEditingAudio] = useState(false);
|
||||||
|
|
||||||
// Channel names (Phase 2.5)
|
// Channel names (Phase 2.5)
|
||||||
const [channelNames, setChannelNames] = useState({ inputs: {}, outputs: {} });
|
const [channelNames, setChannelNames] = useState({ inputs: {}, outputs: {} });
|
||||||
@@ -104,10 +105,12 @@ function Admin() {
|
|||||||
setCurrentDevice(currentData.device || {});
|
setCurrentDevice(currentData.device || {});
|
||||||
setChannelNames(channelNamesData.channelNames || { inputs: {}, outputs: {} });
|
setChannelNames(channelNamesData.channelNames || { inputs: {}, outputs: {} });
|
||||||
|
|
||||||
// Initialiser les sélections avec les valeurs actuelles
|
// Ne réinitialiser les sélections que si l'utilisateur n'est pas en train d'éditer
|
||||||
setSelectedInputDevice(currentData.device?.inputDeviceId ?? null);
|
if (!isEditingAudio) {
|
||||||
setSelectedOutputDevice(currentData.device?.outputDeviceId ?? null);
|
setSelectedInputDevice(currentData.device?.inputDeviceId ?? null);
|
||||||
setSelectedSampleRate(currentData.device?.sampleRate || 48000);
|
setSelectedOutputDevice(currentData.device?.outputDeviceId ?? null);
|
||||||
|
setSelectedSampleRate(currentData.device?.sampleRate || 48000);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========== Gestion groupes ==========
|
// ========== Gestion groupes ==========
|
||||||
@@ -274,6 +277,7 @@ function Admin() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
setIsEditingAudio(false); // Désactiver le mode édition
|
||||||
alert('Configuration audio sauvegardée avec succès!');
|
alert('Configuration audio sauvegardée avec succès!');
|
||||||
await loadAudioDevices();
|
await loadAudioDevices();
|
||||||
} else {
|
} else {
|
||||||
@@ -508,7 +512,10 @@ function Admin() {
|
|||||||
<h3>Carte son d'entrée (Input)</h3>
|
<h3>Carte son d'entrée (Input)</h3>
|
||||||
<select
|
<select
|
||||||
value={selectedInputDevice ?? ''}
|
value={selectedInputDevice ?? ''}
|
||||||
onChange={(e) => setSelectedInputDevice(e.target.value === '' ? null : parseInt(e.target.value))}
|
onChange={(e) => {
|
||||||
|
setIsEditingAudio(true);
|
||||||
|
setSelectedInputDevice(e.target.value === '' ? null : parseInt(e.target.value));
|
||||||
|
}}
|
||||||
className="device-select"
|
className="device-select"
|
||||||
>
|
>
|
||||||
<option value="">-- Sélectionner une carte --</option>
|
<option value="">-- Sélectionner une carte --</option>
|
||||||
@@ -531,7 +538,10 @@ function Admin() {
|
|||||||
<h3>Carte son de sortie (Output)</h3>
|
<h3>Carte son de sortie (Output)</h3>
|
||||||
<select
|
<select
|
||||||
value={selectedOutputDevice ?? ''}
|
value={selectedOutputDevice ?? ''}
|
||||||
onChange={(e) => setSelectedOutputDevice(e.target.value === '' ? null : parseInt(e.target.value))}
|
onChange={(e) => {
|
||||||
|
setIsEditingAudio(true);
|
||||||
|
setSelectedOutputDevice(e.target.value === '' ? null : parseInt(e.target.value));
|
||||||
|
}}
|
||||||
className="device-select"
|
className="device-select"
|
||||||
>
|
>
|
||||||
<option value="">-- Sélectionner une carte --</option>
|
<option value="">-- Sélectionner une carte --</option>
|
||||||
@@ -554,7 +564,10 @@ function Admin() {
|
|||||||
<h3>Sample Rate</h3>
|
<h3>Sample Rate</h3>
|
||||||
<select
|
<select
|
||||||
value={selectedSampleRate}
|
value={selectedSampleRate}
|
||||||
onChange={(e) => setSelectedSampleRate(parseInt(e.target.value))}
|
onChange={(e) => {
|
||||||
|
setIsEditingAudio(true);
|
||||||
|
setSelectedSampleRate(parseInt(e.target.value));
|
||||||
|
}}
|
||||||
className="device-select"
|
className="device-select"
|
||||||
>
|
>
|
||||||
<option value={44100}>44100 Hz (CD quality)</option>
|
<option value={44100}>44100 Hz (CD quality)</option>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
const WS_URL = import.meta.env.VITE_WS_AUDIO_LEVELS_URL || 'ws://localhost:3001';
|
const WS_URL = import.meta.env.VITE_WS_AUDIO_LEVELS_URL || 'ws://localhost:3000/audio-levels';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook pour monitoring des niveaux audio temps réel
|
* Hook pour monitoring des niveaux audio temps réel
|
||||||
|
|||||||
@@ -0,0 +1,695 @@
|
|||||||
|
# Configuration AES67 avec PTT Live
|
||||||
|
|
||||||
|
Guide pour intégrer PTT Live avec des équipements AES67 (alternative open source à Dante)
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
AES67 est un standard ouvert pour le transport audio sur IP (IEEE 1722, IETF RTP). Il est interopérable avec Dante (mode AES67), Ravenna, Livewire, et d'autres protocoles audio-over-IP.
|
||||||
|
|
||||||
|
### Avantages vs Dante Virtual Soundcard
|
||||||
|
|
||||||
|
| Caractéristique | AES67 | Dante (DVS) |
|
||||||
|
|----------------|-------|-------------|
|
||||||
|
| **Coût** | Gratuit | ~300€/licence |
|
||||||
|
| **Ouverture** | Standard ouvert | Propriétaire Audinate |
|
||||||
|
| **Complexité** | Configuration CLI | GUI simple |
|
||||||
|
| **Interopérabilité** | Multi-vendor | Dante + AES67 mode |
|
||||||
|
| **PTP sync** | Requis | Optionnel |
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
[Équipements AES67] ←→ [RTP Multicast] ←→ [ALSA/JACK] ←→ [PTT Live]
|
||||||
|
↓
|
||||||
|
[PTP Clock Sync]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
### Matériel
|
||||||
|
- Interface réseau Ethernet Gigabit (obligatoire)
|
||||||
|
- Switch manageable avec support :
|
||||||
|
- IGMP snooping
|
||||||
|
- PTP (Precision Time Protocol)
|
||||||
|
- QoS/DSCP
|
||||||
|
- Jumbo frames (recommandé)
|
||||||
|
|
||||||
|
### Système d'exploitation
|
||||||
|
- **Linux recommandé** : Ubuntu 22.04+, Debian 11+, Arch Linux
|
||||||
|
- macOS possible (via outils tiers)
|
||||||
|
- Windows non supporté nativement
|
||||||
|
|
||||||
|
### Logiciels
|
||||||
|
- **PTPd** ou **linuxptp** : synchronisation horloge PTP
|
||||||
|
- **JACK Audio** : routing audio
|
||||||
|
- **Merging ALSA RAVENNA/AES67 Driver** (optionnel mais recommandé)
|
||||||
|
- https://www.merging.com/products/ravenna/alsa_driver
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation (Linux)
|
||||||
|
|
||||||
|
### 1. Installation des dépendances
|
||||||
|
|
||||||
|
#### Ubuntu/Debian
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Outils réseau et audio
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y \
|
||||||
|
build-essential \
|
||||||
|
git \
|
||||||
|
jackd2 \
|
||||||
|
jack-tools \
|
||||||
|
qjackctl \
|
||||||
|
linuxptp \
|
||||||
|
ptp4l \
|
||||||
|
phc2sys \
|
||||||
|
ethtool \
|
||||||
|
net-tools
|
||||||
|
|
||||||
|
# ALSA dev (si compilation driver Merging)
|
||||||
|
sudo apt install -y \
|
||||||
|
libasound2-dev \
|
||||||
|
linux-headers-$(uname -r)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Arch Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pacman -S --needed \
|
||||||
|
jack2 \
|
||||||
|
qjackctl \
|
||||||
|
linuxptp \
|
||||||
|
ethtool \
|
||||||
|
alsa-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Installation Merging ALSA RAVENNA/AES67 Driver
|
||||||
|
|
||||||
|
Ce driver crée une carte ALSA virtuelle qui envoie/reçoit des flux AES67 RTP.
|
||||||
|
|
||||||
|
#### Téléchargement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp
|
||||||
|
wget https://www.merging.com/ravenna/ALSA_RAVENNA_1.2.9.tar.gz
|
||||||
|
tar -xzf ALSA_RAVENNA_1.2.9.tar.gz
|
||||||
|
cd ALSA_RAVENNA
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Compilation et installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Compilation
|
||||||
|
make
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
sudo make install
|
||||||
|
|
||||||
|
# Chargement du module kernel
|
||||||
|
sudo modprobe MergingRAVENNA
|
||||||
|
|
||||||
|
# Vérification
|
||||||
|
lsmod | grep Merging
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Configuration persistante
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Charger le module au démarrage
|
||||||
|
echo "MergingRAVENNA" | sudo tee -a /etc/modules-load.d/ravenna.conf
|
||||||
|
|
||||||
|
# Reboot pour tester
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Réseau
|
||||||
|
|
||||||
|
### 1. Configuration interface réseau
|
||||||
|
|
||||||
|
AES67 nécessite une configuration réseau spécifique.
|
||||||
|
|
||||||
|
#### Trouver l'interface réseau
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ip link show
|
||||||
|
# Exemple : eth0, enp3s0, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Configuration IP statique
|
||||||
|
|
||||||
|
Éditer `/etc/network/interfaces` (Debian) ou `/etc/netplan/01-netcfg.yaml` (Ubuntu) :
|
||||||
|
|
||||||
|
**Netplan (Ubuntu 22.04+)** :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
network:
|
||||||
|
version: 2
|
||||||
|
ethernets:
|
||||||
|
enp3s0: # Votre interface
|
||||||
|
dhcp4: no
|
||||||
|
addresses:
|
||||||
|
- 192.168.10.100/24 # IP statique dans VLAN audio
|
||||||
|
mtu: 9000 # Jumbo frames
|
||||||
|
```
|
||||||
|
|
||||||
|
Appliquer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo netplan apply
|
||||||
|
```
|
||||||
|
|
||||||
|
**Interfaces (Debian)** :
|
||||||
|
|
||||||
|
```
|
||||||
|
auto eth0
|
||||||
|
iface eth0 inet static
|
||||||
|
address 192.168.10.100
|
||||||
|
netmask 255.255.255.0
|
||||||
|
mtu 9000
|
||||||
|
```
|
||||||
|
|
||||||
|
Appliquer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart networking
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Optimisations noyau
|
||||||
|
|
||||||
|
Éditer `/etc/sysctl.conf` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Buffers réseau pour audio temps réel
|
||||||
|
net.core.rmem_max = 134217728
|
||||||
|
net.core.wmem_max = 134217728
|
||||||
|
net.core.rmem_default = 16777216
|
||||||
|
net.core.wmem_default = 16777216
|
||||||
|
|
||||||
|
# Multicast
|
||||||
|
net.ipv4.igmp_max_memberships = 512
|
||||||
|
```
|
||||||
|
|
||||||
|
Appliquer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo sysctl -p
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configuration Switch
|
||||||
|
|
||||||
|
Paramètres switch requis :
|
||||||
|
|
||||||
|
| Paramètre | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| **VLAN** | 10 (exemple, dédié audio) |
|
||||||
|
| **IGMP Snooping** | Activé |
|
||||||
|
| **PTP** | Activé sur tous les ports |
|
||||||
|
| **QoS/DSCP** | EF (46) pour audio, CS7 (56) pour PTP |
|
||||||
|
| **Jumbo Frames** | MTU 9000 |
|
||||||
|
| **Flow Control** | Désactivé |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration PTP (Precision Time Protocol)
|
||||||
|
|
||||||
|
AES67 requiert une synchronisation horloge précise (±1µs).
|
||||||
|
|
||||||
|
### 1. Configuration ptp4l
|
||||||
|
|
||||||
|
Créer `/etc/ptp4l.conf` :
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[global]
|
||||||
|
dataset_comparison = ieee1588
|
||||||
|
priority1 = 128
|
||||||
|
priority2 = 128
|
||||||
|
domainNumber = 0
|
||||||
|
slaveOnly 1
|
||||||
|
two_step 1
|
||||||
|
|
||||||
|
# Configuration réseau
|
||||||
|
network_transport UDPv4
|
||||||
|
delay_mechanism E2E
|
||||||
|
|
||||||
|
# Timers
|
||||||
|
logAnnounceInterval 0
|
||||||
|
logSyncInterval -3
|
||||||
|
logMinDelayReqInterval -3
|
||||||
|
|
||||||
|
# Interface réseau (adapter selon votre système)
|
||||||
|
[enp3s0]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Démarrage PTP
|
||||||
|
|
||||||
|
#### Test manuel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lancer ptp4l en mode slave (synchronisé par master du réseau)
|
||||||
|
sudo ptp4l -i enp3s0 -f /etc/ptp4l.conf -m
|
||||||
|
|
||||||
|
# Dans un autre terminal : synchroniser l'horloge système
|
||||||
|
sudo phc2sys -s enp3s0 -w -m
|
||||||
|
```
|
||||||
|
|
||||||
|
Vous devriez voir :
|
||||||
|
|
||||||
|
```
|
||||||
|
ptp4l[...]: master offset -2 s2 freq -15432 path delay 125
|
||||||
|
phc2sys[...]: enp3s0 sys offset -4 s2 freq -12345 delay 1256
|
||||||
|
```
|
||||||
|
|
||||||
|
L'offset doit être < 1000 ns (1µs).
|
||||||
|
|
||||||
|
#### Service systemd
|
||||||
|
|
||||||
|
Créer `/etc/systemd/system/ptp4l.service` :
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=PTP Daemon
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/sbin/ptp4l -i enp3s0 -f /etc/ptp4l.conf -m
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Créer `/etc/systemd/system/phc2sys.service` :
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=PHC to System Clock Sync
|
||||||
|
After=ptp4l.service
|
||||||
|
Requires=ptp4l.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/sbin/phc2sys -s enp3s0 -w -m
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Activer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable ptp4l phc2sys
|
||||||
|
sudo systemctl start ptp4l phc2sys
|
||||||
|
```
|
||||||
|
|
||||||
|
Vérifier :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status ptp4l
|
||||||
|
sudo systemctl status phc2sys
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration JACK + AES67
|
||||||
|
|
||||||
|
### 1. Démarrage JACK avec carte ALSA RAVENNA
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lister les cartes ALSA
|
||||||
|
aplay -l
|
||||||
|
|
||||||
|
# Devrait afficher quelque chose comme :
|
||||||
|
# card 2: RAVENNA [Merging RAVENNA], device 0: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Démarrer JACK avec la carte RAVENNA :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jackd -d alsa \
|
||||||
|
-d hw:RAVENNA \
|
||||||
|
-r 48000 \
|
||||||
|
-p 256 \
|
||||||
|
-n 2 \
|
||||||
|
-S \
|
||||||
|
-P
|
||||||
|
```
|
||||||
|
|
||||||
|
Paramètres :
|
||||||
|
- `-d hw:RAVENNA` : carte ALSA RAVENNA
|
||||||
|
- `-r 48000` : sample rate AES67 standard
|
||||||
|
- `-p 256` : buffer size (5.3ms @ 48kHz)
|
||||||
|
- `-n 2` : 2 périodes
|
||||||
|
- `-S` : soft mode (moins de xruns)
|
||||||
|
- `-P` : playback + capture
|
||||||
|
|
||||||
|
### 2. Configuration QjackCtl (GUI alternative)
|
||||||
|
|
||||||
|
1. Lancer `qjackctl`
|
||||||
|
2. Setup :
|
||||||
|
- **Driver** : alsa
|
||||||
|
- **Interface** : hw:RAVENNA
|
||||||
|
- **Sample Rate** : 48000
|
||||||
|
- **Frames/Period** : 256
|
||||||
|
- **Periods/Buffer** : 2
|
||||||
|
3. Start
|
||||||
|
|
||||||
|
### 3. Configuration des flux AES67
|
||||||
|
|
||||||
|
Le driver Merging RAVENNA se configure via des fichiers JSON.
|
||||||
|
|
||||||
|
#### Configuration RTP streams
|
||||||
|
|
||||||
|
Créer `/etc/ravenna/streams.json` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "Input_1",
|
||||||
|
"sdp": "239.69.1.1:5004",
|
||||||
|
"channels": 2,
|
||||||
|
"payloadType": 98,
|
||||||
|
"sampleRate": 48000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Input_2",
|
||||||
|
"sdp": "239.69.1.2:5004",
|
||||||
|
"channels": 2,
|
||||||
|
"payloadType": 98,
|
||||||
|
"sampleRate": 48000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sinks": [
|
||||||
|
{
|
||||||
|
"name": "Output_1",
|
||||||
|
"sdp": "239.69.2.1:5004",
|
||||||
|
"channels": 2,
|
||||||
|
"payloadType": 98,
|
||||||
|
"sampleRate": 48000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Charger la configuration :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via l'outil Merging (si disponible)
|
||||||
|
ravenna-daemon -c /etc/ravenna/streams.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Intégration PTT Live
|
||||||
|
|
||||||
|
### 1. Démarrer PTT Live
|
||||||
|
|
||||||
|
PTT Live détectera automatiquement JACK :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /chemin/vers/PTT\ Live/server
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs attendus :
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ Backend audio : JACK (Linux professionnel)
|
||||||
|
📻 Devices audio détectés : 2
|
||||||
|
- JACK System Capture (in:8, out:0)
|
||||||
|
- JACK System Playback (in:0, out:8)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Routing JACK
|
||||||
|
|
||||||
|
Connecter les ports JACK :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Liste des ports
|
||||||
|
jack_lsp
|
||||||
|
|
||||||
|
# Exemple de ports disponibles :
|
||||||
|
# RAVENNA:capture_1
|
||||||
|
# RAVENNA:capture_2
|
||||||
|
# RAVENNA:playback_1
|
||||||
|
# RAVENNA:playback_2
|
||||||
|
# PTTLive:input_1
|
||||||
|
# PTTLive:output_1
|
||||||
|
|
||||||
|
# Connexion
|
||||||
|
jack_connect "RAVENNA:capture_1" "PTTLive:input_1"
|
||||||
|
jack_connect "PTTLive:output_1" "RAVENNA:playback_1"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Script automatique
|
||||||
|
|
||||||
|
Créer `server/scripts/connect-aes67.sh` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Connexion automatique JACK ↔ AES67
|
||||||
|
|
||||||
|
echo "Connexion des canaux AES67 → PTT Live..."
|
||||||
|
|
||||||
|
for i in {1..8}; do
|
||||||
|
jack_connect "RAVENNA:capture_$i" "PTTLive:input_$i" 2>/dev/null
|
||||||
|
jack_connect "PTTLive:output_$i" "RAVENNA:playback_$i" 2>/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "✓ Routing JACK configuré"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x server/scripts/connect-aes67.sh
|
||||||
|
./server/scripts/connect-aes67.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring et Diagnostics
|
||||||
|
|
||||||
|
### Vérification PTP
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Status PTP
|
||||||
|
sudo systemctl status ptp4l
|
||||||
|
|
||||||
|
# Offset temps réel (doit être < 1µs)
|
||||||
|
sudo ptp4l -i enp3s0 -f /etc/ptp4l.conf -m | grep "master offset"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vérification multicast
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Afficher les groupes multicast rejoints
|
||||||
|
netstat -g
|
||||||
|
|
||||||
|
# Capture trafic RTP AES67 (exemple)
|
||||||
|
sudo tcpdump -i enp3s0 -n 'multicast and udp port 5004'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vérification JACK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Statistiques JACK
|
||||||
|
jack_samplerate # 48000
|
||||||
|
jack_bufsize # 256
|
||||||
|
|
||||||
|
# Xruns (buffer underruns)
|
||||||
|
jack_evmon # Surveille les xruns en temps réel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs driver RAVENNA
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Kernel messages
|
||||||
|
sudo dmesg | grep -i ravenna
|
||||||
|
|
||||||
|
# Logs système
|
||||||
|
sudo journalctl -u ravenna-daemon -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interopérabilité Dante ↔ AES67
|
||||||
|
|
||||||
|
Les équipements Dante peuvent basculer en mode AES67 pour communiquer avec des devices AES67 natifs.
|
||||||
|
|
||||||
|
### Activation AES67 sur Dante
|
||||||
|
|
||||||
|
1. Ouvrir **Dante Controller**
|
||||||
|
2. Device → sélectionner équipement Dante
|
||||||
|
3. Device Config → AES67 Config
|
||||||
|
4. Cocher "Enable AES67"
|
||||||
|
5. Configurer :
|
||||||
|
- **Sample Rate** : 48kHz
|
||||||
|
- **Encoding** : L24 (24-bit)
|
||||||
|
- **Packet Time** : 1ms
|
||||||
|
6. Reboot device
|
||||||
|
|
||||||
|
### SDP (Session Description Protocol)
|
||||||
|
|
||||||
|
AES67 utilise des fichiers SDP pour annoncer les flux.
|
||||||
|
|
||||||
|
**Exemple SDP pour un flux stéréo** :
|
||||||
|
|
||||||
|
```
|
||||||
|
v=0
|
||||||
|
o=- 123456 1 IN IP4 192.168.10.50
|
||||||
|
s=PTT Live Output
|
||||||
|
c=IN IP4 239.69.2.1/32
|
||||||
|
t=0 0
|
||||||
|
m=audio 5004 RTP/AVP 98
|
||||||
|
a=rtpmap:98 L24/48000/2
|
||||||
|
a=ptime:1
|
||||||
|
a=sync-time:0
|
||||||
|
```
|
||||||
|
|
||||||
|
Sauvegarder dans `/etc/ravenna/pttlive-output.sdp` et référencer dans la config du driver.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optimisation Performance
|
||||||
|
|
||||||
|
### Latence typique
|
||||||
|
|
||||||
|
| Étape | Latence |
|
||||||
|
|-------|---------|
|
||||||
|
| Réseau RTP | 1-5 ms (selon packet time) |
|
||||||
|
| Driver ALSA RAVENNA | 2-5 ms |
|
||||||
|
| JACK | 5-10 ms (256 samples @ 48kHz) |
|
||||||
|
| PTT Live bridge | 20-40 ms |
|
||||||
|
| WebRTC client | 30-100 ms |
|
||||||
|
| **TOTAL** | **58-160 ms** |
|
||||||
|
|
||||||
|
### Réduction latence
|
||||||
|
|
||||||
|
1. **Packet time** : 0.125ms ou 0.25ms (au lieu de 1ms)
|
||||||
|
2. **JACK buffer** : 128 samples (2.7ms au lieu de 5.3ms)
|
||||||
|
3. **PTT Live jitter buffer** : preset "ULTRA_LOW"
|
||||||
|
|
||||||
|
Configuration JACK basse latence :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jackd -R -P 70 -d alsa -d hw:RAVENNA -r 48000 -p 128 -n 3
|
||||||
|
```
|
||||||
|
|
||||||
|
- `-R` : mode real-time
|
||||||
|
- `-P 70` : priorité real-time (nécessite config `/etc/security/limits.conf`)
|
||||||
|
|
||||||
|
**Attention** : Risque de xruns si CPU/réseau surchargé.
|
||||||
|
|
||||||
|
### Configuration real-time Linux
|
||||||
|
|
||||||
|
Éditer `/etc/security/limits.conf` :
|
||||||
|
|
||||||
|
```
|
||||||
|
@audio - rtprio 95
|
||||||
|
@audio - memlock unlimited
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter votre utilisateur au groupe audio :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo usermod -a -G audio $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
Reboot requis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Pas de son
|
||||||
|
|
||||||
|
**Vérifications** :
|
||||||
|
1. PTP synchronisé : `sudo ptp4l -i enp3s0 -f /etc/ptp4l.conf -m` (offset < 1µs)
|
||||||
|
2. Driver RAVENNA chargé : `lsmod | grep Merging`
|
||||||
|
3. JACK voit la carte : `jack_lsp | grep RAVENNA`
|
||||||
|
4. Ports connectés : `jack_lsp -c`
|
||||||
|
5. Flux RTP visibles : `sudo tcpdump -i enp3s0 -n multicast`
|
||||||
|
|
||||||
|
### Xruns JACK
|
||||||
|
|
||||||
|
**Causes** :
|
||||||
|
- Buffer trop petit
|
||||||
|
- CPU overload
|
||||||
|
- IRQ conflicts
|
||||||
|
|
||||||
|
**Solutions** :
|
||||||
|
- Augmenter buffer JACK : `-p 512` au lieu de 256
|
||||||
|
- Désactiver CPU frequency scaling :
|
||||||
|
```bash
|
||||||
|
sudo cpupower frequency-set -g performance
|
||||||
|
```
|
||||||
|
- Isoler CPU cores pour audio (kernel parameter `isolcpus`)
|
||||||
|
|
||||||
|
### Offset PTP trop élevé
|
||||||
|
|
||||||
|
**Causes** :
|
||||||
|
- Pas de PTP master sur le réseau
|
||||||
|
- Switch ne supporte pas PTP
|
||||||
|
|
||||||
|
**Solutions** :
|
||||||
|
- Configurer un device comme PTP master (grandmaster)
|
||||||
|
- Vérifier config switch (PTP enabled sur tous les ports)
|
||||||
|
- Utiliser un PTP hardware clock (si carte réseau compatible)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coût Total
|
||||||
|
|
||||||
|
| Élément | Prix |
|
||||||
|
|---------|------|
|
||||||
|
| **Switch PTP** | 200-2000€ (selon modèle) |
|
||||||
|
| **Merging ALSA RAVENNA Driver** | Gratuit |
|
||||||
|
| **Logiciels Linux** | Gratuit |
|
||||||
|
| **PTT Live** | Gratuit |
|
||||||
|
| **TOTAL** | **200-2000€** |
|
||||||
|
|
||||||
|
Bien moins cher que Dante DVS (300€/licence) si plusieurs postes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives sans RAVENNA Driver
|
||||||
|
|
||||||
|
### Utilisation de daemon RTP natif
|
||||||
|
|
||||||
|
Si le driver Merging n'est pas disponible, utiliser **trx** ou **rtptools** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Installation trx
|
||||||
|
git clone https://github.com/x42/trx.git
|
||||||
|
cd trx
|
||||||
|
make
|
||||||
|
sudo make install
|
||||||
|
|
||||||
|
# Réception flux RTP
|
||||||
|
trx --recv 239.69.1.1 5004 -j output_1
|
||||||
|
|
||||||
|
# Émission flux RTP
|
||||||
|
trx --send 239.69.2.1 5004 -j input_1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ressources
|
||||||
|
|
||||||
|
- **AES67 Standard** : http://www.aes.org/publications/standards/search.cfm?docID=96
|
||||||
|
- **Merging RAVENNA** : https://www.merging.com/products/ravenna
|
||||||
|
- **Linux Audio** : https://wiki.linuxaudio.org/
|
||||||
|
- **PTP Configuration** : http://linuxptp.sourceforge.net/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Dernière mise à jour** : 2026-05-26
|
||||||
|
**Version PTT Live** : 0.1.0 (Phase 3)
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
# Architecture Audio Bridge - PTT Live
|
||||||
|
|
||||||
|
Documentation complète du système de bridge audio entre cartes son et clients WebRTC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vue d'Ensemble
|
||||||
|
|
||||||
|
Le serveur PTT Live agit comme un **hub audio central** qui relie :
|
||||||
|
- Les **cartes son physiques** (macOS/Linux)
|
||||||
|
- Les **clients WebRTC** (smartphones, navigateurs)
|
||||||
|
- Le **routing multi-groupes** (matrice style Dante)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SERVEUR PTT LIVE │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ Carte Son │ ←→ │ AudioBridge │ ←→ │ LiveKit Server │ │
|
||||||
|
│ │ (CoreAudio/ │ │ + Group │ │ (SFU) │ │
|
||||||
|
│ │ JACK/PW) │ │ Router │ │ │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └─────────────────┘ │
|
||||||
|
│ ↕ ↕ ↕ │
|
||||||
|
│ Canaux 1-32 Groupes A-Z Rooms WebRTC │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
↕
|
||||||
|
┌───────────┴───────────┐
|
||||||
|
↓ ↓
|
||||||
|
┌───────────────┐ ┌───────────────┐
|
||||||
|
│ Client 1 PWA │ │ Client 2 PWA │
|
||||||
|
│ (Régie) │ │ (Scène) │
|
||||||
|
└───────────────┘ └───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Composants Principaux
|
||||||
|
|
||||||
|
### 1. Audio Backends (CoreAudio/JACK/PipeWire)
|
||||||
|
|
||||||
|
**Rôle** : Interface avec les cartes son physiques de l'OS.
|
||||||
|
|
||||||
|
**Fichiers** :
|
||||||
|
- [server/bridge/backends/CoreAudioBackend.js](../server/bridge/backends/CoreAudioBackend.js) (macOS)
|
||||||
|
- [server/bridge/backends/JACKBackend.js](../server/bridge/backends/JACKBackend.js) (Linux pro)
|
||||||
|
- [server/bridge/backends/PipeWireBackend.js](../server/bridge/backends/PipeWireBackend.js) (Linux moderne)
|
||||||
|
|
||||||
|
**Fonctionnalités** :
|
||||||
|
- Détecte **toutes les cartes son** connectées (USB, Thunderbolt, virtuelles)
|
||||||
|
- Capture audio (48kHz, 16-bit PCM)
|
||||||
|
- Lecture audio (buffer circulaire, gestion underrun/overrun)
|
||||||
|
- Multi-canaux (jusqu'à 32+ canaux)
|
||||||
|
|
||||||
|
**Exemple détection cartes macOS** :
|
||||||
|
```javascript
|
||||||
|
CoreAudioBackend.getDevices()
|
||||||
|
// Retourne :
|
||||||
|
[
|
||||||
|
{ id: 0, name: 'MacBook Pro Mic', maxInputChannels: 1 },
|
||||||
|
{ id: 1, name: 'MacBook Pro Speakers', maxOutputChannels: 2 },
|
||||||
|
{ id: 2, name: 'Focusrite Scarlett 18i20', maxInputChannels: 18, maxOutputChannels: 20 },
|
||||||
|
{ id: 3, name: 'Dante Virtual Soundcard', maxInputChannels: 64, maxOutputChannels: 64 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. GroupAudioRouter
|
||||||
|
|
||||||
|
**Rôle** : Matrice de routing audio multi-canaux avec gains.
|
||||||
|
|
||||||
|
**Fichier** : [server/bridge/GroupAudioRouter.js](../server/bridge/GroupAudioRouter.js)
|
||||||
|
|
||||||
|
**Architecture** :
|
||||||
|
```
|
||||||
|
Inputs Physiques (CH 1-32) → Groupes (Régie, Scène, FOH) → Outputs Physiques (CH 1-32)
|
||||||
|
↓ ↓ ↓
|
||||||
|
Mix avec gain Mix avec gain Mix additif
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fonctionnalités** :
|
||||||
|
- **Input → Group** : Plusieurs canaux physiques vers un groupe (mixage additif)
|
||||||
|
- **Group → Output** : Un groupe vers plusieurs canaux physiques (distribution)
|
||||||
|
- **Gains individuels** : -120dB à +6dB par route
|
||||||
|
- **Canaux partagés** : Plusieurs groupes peuvent aller vers la même sortie (mix)
|
||||||
|
- **Anti-clipping** : Normalisation automatique
|
||||||
|
|
||||||
|
**Configuration YAML exemple** :
|
||||||
|
```yaml
|
||||||
|
audio:
|
||||||
|
routing:
|
||||||
|
inputToGroup:
|
||||||
|
0: ['regie'] # Canal 0 → Groupe Régie
|
||||||
|
1: ['regie'] # Canal 1 → Groupe Régie (mixé avec CH0)
|
||||||
|
2: ['scene'] # Canal 2 → Groupe Scène
|
||||||
|
3: ['foh'] # Canal 3 → Groupe FOH
|
||||||
|
|
||||||
|
groupToOutput:
|
||||||
|
regie: [0, 1] # Groupe Régie → Canaux 0+1 (stéréo)
|
||||||
|
scene: [2, 3] # Groupe Scène → Canaux 2+3
|
||||||
|
foh: [4, 5, 6, 7] # Groupe FOH → 4 canaux
|
||||||
|
|
||||||
|
gains:
|
||||||
|
in_0_regie: 0 # Gain +0dB (unity)
|
||||||
|
in_1_regie: -3 # Gain -3dB
|
||||||
|
regie_out_0: 0
|
||||||
|
scene_out_2: -6 # Gain -6dB
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. AudioBridge
|
||||||
|
|
||||||
|
**Rôle** : Orchestrateur central du flux audio.
|
||||||
|
|
||||||
|
**Fichier** : [server/bridge/AudioBridge.js](../server/bridge/AudioBridge.js)
|
||||||
|
|
||||||
|
**Pipeline** :
|
||||||
|
|
||||||
|
#### FLUX CAPTURE (Carte Son → Clients)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. CoreAudio/JACK capture PCM (16-bit Buffer)
|
||||||
|
↓
|
||||||
|
2. Conversion PCM Buffer → Float32Array [-1.0, 1.0]
|
||||||
|
↓
|
||||||
|
3. GroupAudioRouter.processInputsToGroups()
|
||||||
|
- Input CH0 + CH1 → Groupe "Régie" (mix)
|
||||||
|
- Input CH2 → Groupe "Scène"
|
||||||
|
↓
|
||||||
|
4. Conversion Float32Array → PCM Buffer (par groupe)
|
||||||
|
↓
|
||||||
|
5. Encodage Opus (96 kbps par défaut)
|
||||||
|
↓
|
||||||
|
6. Émission événement 'groupAudioOut' → LiveKitServerBridge
|
||||||
|
↓
|
||||||
|
7. LiveKit SFU → Clients WebRTC dans la room du groupe
|
||||||
|
```
|
||||||
|
|
||||||
|
#### FLUX LECTURE (Clients → Carte Son)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Clients WebRTC → LiveKit SFU
|
||||||
|
↓
|
||||||
|
2. LiveKitServerBridge reçoit audio par groupe
|
||||||
|
↓
|
||||||
|
3. Émission événement 'groupAudioIn' → AudioBridge
|
||||||
|
↓
|
||||||
|
4. Conversion PCM Buffer → Float32Array
|
||||||
|
↓
|
||||||
|
5. GroupAudioRouter.processGroupsToOutputs()
|
||||||
|
- Groupe "Régie" → Output CH0 + CH1
|
||||||
|
- Groupe "Scène" → Output CH2 + CH3
|
||||||
|
↓
|
||||||
|
6. Conversion Float32Array → PCM Buffer (par canal)
|
||||||
|
↓
|
||||||
|
7. CoreAudio/JACK queueAudio() → Carte son physique
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. LiveKitServerBridge
|
||||||
|
|
||||||
|
**Rôle** : Pont entre AudioBridge et LiveKit (WebRTC).
|
||||||
|
|
||||||
|
**Fichier** : [server/bridge/LiveKitServerBridge.js](../server/bridge/LiveKitServerBridge.js)
|
||||||
|
|
||||||
|
**Responsabilités** :
|
||||||
|
- Génère les tokens JWT pour les clients
|
||||||
|
- Écoute les événements `groupAudioOut` de AudioBridge
|
||||||
|
- Injecte l'audio vers LiveKit (via DataChannel ou AudioSource)
|
||||||
|
- Reçoit l'audio des clients LiveKit
|
||||||
|
- Émet `groupAudioIn` vers AudioBridge
|
||||||
|
|
||||||
|
**API** :
|
||||||
|
```javascript
|
||||||
|
// Générer token pour un client
|
||||||
|
const token = await bridge.generateClientToken('user123', 'regie');
|
||||||
|
|
||||||
|
// Vérifier participants actifs
|
||||||
|
const participants = await bridge.listParticipants('regie');
|
||||||
|
|
||||||
|
// Créer room/groupe
|
||||||
|
await bridge.ensureRoomExists('regie');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flux Audio Complet : Exemple Réel
|
||||||
|
|
||||||
|
### Scénario : Événement avec 3 groupes
|
||||||
|
|
||||||
|
**Configuration** :
|
||||||
|
- Carte son : Focusrite Scarlett 18i20 (18 inputs, 20 outputs)
|
||||||
|
- Groupes :
|
||||||
|
- **Régie** : CH0-1 (input) → CH0-1 (output)
|
||||||
|
- **Scène** : CH2-3 (input) → CH2-3 (output)
|
||||||
|
- **FOH** : CH4-5 (input) → CH4-5 (output)
|
||||||
|
|
||||||
|
### Flux 1 : Console → Clients
|
||||||
|
|
||||||
|
```
|
||||||
|
[Console Audio CH1] (signal analogique)
|
||||||
|
↓
|
||||||
|
[Focusrite CH1 Input] (ADC 24-bit → 16-bit PCM)
|
||||||
|
↓
|
||||||
|
CoreAudioBackend.startCapture()
|
||||||
|
↓ événement 'audioData' (Buffer PCM)
|
||||||
|
AudioBridge._startAudioRouting()
|
||||||
|
↓ _bufferToFloat32()
|
||||||
|
GroupAudioRouter.processInputsToGroups()
|
||||||
|
↓ input CH1 → groupe "Régie" (gain 0dB)
|
||||||
|
OpusCodec.encode(pcmBuffer) → opusData
|
||||||
|
↓ événement 'groupAudioOut'
|
||||||
|
LiveKitServerBridge._handleGroupAudioOut()
|
||||||
|
↓ TODO: Envoi vers LiveKit SFU
|
||||||
|
LiveKit SFU (room "regie")
|
||||||
|
↓ WebRTC (Opus, SRTP)
|
||||||
|
[Client PWA Régie] (smartphone)
|
||||||
|
↓ Web Audio API decode
|
||||||
|
[Haut-parleur smartphone]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flux 2 : Client → Enceintes Scène
|
||||||
|
|
||||||
|
```
|
||||||
|
[Client PWA Scène] (bouton PTT appuyé)
|
||||||
|
↓ navigator.mediaDevices.getUserMedia()
|
||||||
|
[Microphone smartphone]
|
||||||
|
↓ WebRTC encode (Opus)
|
||||||
|
LiveKit SFU (room "scene")
|
||||||
|
↓ TODO: Réception via webhook/DataChannel
|
||||||
|
LiveKitServerBridge.injectGroupAudioIn('scene', pcmBuffer)
|
||||||
|
↓ événement 'groupAudioIn'
|
||||||
|
AudioBridge (listener)
|
||||||
|
↓ _bufferToFloat32()
|
||||||
|
GroupAudioRouter.processGroupsToOutputs()
|
||||||
|
↓ groupe "Scène" → output CH2-3 (gain -6dB)
|
||||||
|
↓ _float32ToBuffer()
|
||||||
|
CoreAudioBackend.queueAudio(pcmBuffer)
|
||||||
|
↓
|
||||||
|
[Focusrite CH2-3 Output] (DAC)
|
||||||
|
↓
|
||||||
|
[Enceintes Scène] (signal analogique)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Serveur
|
||||||
|
|
||||||
|
### config.yaml complet
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
audio:
|
||||||
|
# Backend (auto-détecté : coreaudio, jack, pipewire)
|
||||||
|
backend: auto
|
||||||
|
sampleRate: 48000
|
||||||
|
channels: 8 # Canaux utilisés
|
||||||
|
frameSize: 960 # 20ms @ 48kHz
|
||||||
|
inputDeviceId: 2 # Focusrite Scarlett (ID depuis getDevices())
|
||||||
|
outputDeviceId: 2
|
||||||
|
|
||||||
|
# Routing
|
||||||
|
routing:
|
||||||
|
inputToGroup:
|
||||||
|
0: ['regie']
|
||||||
|
1: ['regie']
|
||||||
|
2: ['scene']
|
||||||
|
3: ['scene']
|
||||||
|
4: ['foh']
|
||||||
|
5: ['foh']
|
||||||
|
|
||||||
|
groupToOutput:
|
||||||
|
regie: [0, 1]
|
||||||
|
scene: [2, 3]
|
||||||
|
foh: [4, 5]
|
||||||
|
|
||||||
|
gains:
|
||||||
|
in_0_regie: 0
|
||||||
|
in_1_regie: 0
|
||||||
|
scene_out_2: -6
|
||||||
|
scene_out_3: -6
|
||||||
|
|
||||||
|
# Groupes LiveKit
|
||||||
|
groups:
|
||||||
|
- id: regie
|
||||||
|
name: "Régie"
|
||||||
|
opusBitrate: 96000
|
||||||
|
|
||||||
|
- id: scene
|
||||||
|
name: "Scène"
|
||||||
|
opusBitrate: 96000
|
||||||
|
|
||||||
|
- id: foh
|
||||||
|
name: "Front of House"
|
||||||
|
opusBitrate: 128000
|
||||||
|
|
||||||
|
# LiveKit
|
||||||
|
livekit:
|
||||||
|
url: ws://localhost:7880
|
||||||
|
apiKey: ${LIVEKIT_API_KEY}
|
||||||
|
apiSecret: ${LIVEKIT_API_SECRET}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variables d'environnement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
LIVEKIT_API_KEY=APIxxxxxxxxxxxxxxxx
|
||||||
|
LIVEKIT_API_SECRET=SECRETxxxxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compatibilité OS et Cartes Son
|
||||||
|
|
||||||
|
### macOS ✅
|
||||||
|
|
||||||
|
**Détection automatique via CoreAudio** :
|
||||||
|
- ✅ Cartes intégrées (MacBook Pro Mic/Speakers)
|
||||||
|
- ✅ USB Class Compliant (Focusrite, MOTU, PreSonus, Audient)
|
||||||
|
- ✅ Thunderbolt (RME, Universal Audio)
|
||||||
|
- ✅ Virtuelles (Dante DVS, Loopback, BlackHole)
|
||||||
|
|
||||||
|
**Test détection** :
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
node -e "
|
||||||
|
import CoreAudioBackend from './bridge/backends/CoreAudioBackend.js';
|
||||||
|
console.log(CoreAudioBackend.getDevices());
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux ✅
|
||||||
|
|
||||||
|
**Détection automatique via JACK ou PipeWire** :
|
||||||
|
|
||||||
|
#### JACK (audio pro)
|
||||||
|
```bash
|
||||||
|
# Liste ports disponibles
|
||||||
|
jack_lsp
|
||||||
|
|
||||||
|
# Exemple output :
|
||||||
|
# system:capture_1
|
||||||
|
# system:capture_2
|
||||||
|
# system:playback_1
|
||||||
|
# system:playback_2
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PipeWire (moderne)
|
||||||
|
```bash
|
||||||
|
# Liste devices
|
||||||
|
pactl list sources short
|
||||||
|
pactl list sinks short
|
||||||
|
|
||||||
|
# Exemple :
|
||||||
|
# 0 alsa_input.usb-Focusrite_Scarlett_18i20
|
||||||
|
# 1 alsa_output.usb-Focusrite_Scarlett_18i20
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cartes testées Linux** :
|
||||||
|
- ✅ Focusrite Scarlett série (USB)
|
||||||
|
- ✅ Behringer UMC série (USB)
|
||||||
|
- ✅ MOTU AVB série (USB/AVB)
|
||||||
|
- ✅ Dante Virtual Soundcard (via JACK bridge)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests et Validation
|
||||||
|
|
||||||
|
### Test 1 : Détection cartes son
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm run test-audio-devices
|
||||||
|
```
|
||||||
|
|
||||||
|
**Résultat attendu** :
|
||||||
|
```
|
||||||
|
✓ Backend audio : CoreAudio (macOS natif)
|
||||||
|
📻 Devices audio détectés : 3
|
||||||
|
- MacBook Pro Microphone (in:1, out:0)
|
||||||
|
- MacBook Pro Speakers (in:0, out:2)
|
||||||
|
- Focusrite Scarlett 18i20 (in:18, out:20)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2 : Routing audio (loopback)
|
||||||
|
|
||||||
|
**Configuration test** :
|
||||||
|
```yaml
|
||||||
|
routing:
|
||||||
|
inputToGroup:
|
||||||
|
0: ['test']
|
||||||
|
groupToOutput:
|
||||||
|
test: [0]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Résultat** : Le son capturé sur CH0 ressort immédiatement sur CH0 (attention feedback !).
|
||||||
|
|
||||||
|
### Test 3 : Flux complet avec client
|
||||||
|
|
||||||
|
1. **Démarrer serveur** :
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Connecter client PWA** :
|
||||||
|
- Ouvrir `https://localhost:5173`
|
||||||
|
- Sélectionner groupe "Régie"
|
||||||
|
- Appuyer sur PTT et parler
|
||||||
|
|
||||||
|
3. **Vérifier logs serveur** :
|
||||||
|
```
|
||||||
|
✓ Routing audio bidirectionnel actif
|
||||||
|
→ Carte Son → GroupRouter → LiveKit → Clients
|
||||||
|
groupAudioOut: groupe=regie, opusSize=120 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Écouter sur carte son** :
|
||||||
|
- Le son du client doit sortir sur les canaux configurés
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### Latence Typique (End-to-End)
|
||||||
|
|
||||||
|
| Étape | Latence |
|
||||||
|
|-------|---------|
|
||||||
|
| Carte son ADC | 1-5 ms |
|
||||||
|
| Backend buffer (960 samples) | 20 ms |
|
||||||
|
| GroupAudioRouter (processing) | <1 ms |
|
||||||
|
| Opus encode | 2-5 ms |
|
||||||
|
| LiveKit SFU | 10-30 ms |
|
||||||
|
| Réseau WiFi | 5-20 ms |
|
||||||
|
| Client WebRTC decode | 10-30 ms |
|
||||||
|
| **TOTAL** | **48-111 ms** ✅ |
|
||||||
|
|
||||||
|
**Objectif** : < 150ms (validé)
|
||||||
|
|
||||||
|
### CPU Usage (30 clients)
|
||||||
|
|
||||||
|
| Composant | CPU |
|
||||||
|
|-----------|-----|
|
||||||
|
| CoreAudioBackend | 2-5% |
|
||||||
|
| GroupAudioRouter | 1-3% |
|
||||||
|
| Opus encode/decode | 5-10% |
|
||||||
|
| LiveKit SFU | 10-20% |
|
||||||
|
| **TOTAL** | **18-38%** (8 cores) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prochaines Étapes (TODO)
|
||||||
|
|
||||||
|
### Phase 3+ : Intégration LiveKit complète
|
||||||
|
|
||||||
|
**Option A : @livekit/rtc-node** (Recommandée)
|
||||||
|
```bash
|
||||||
|
npm install @livekit/rtc-node
|
||||||
|
```
|
||||||
|
|
||||||
|
Créer un `AudioSource` par groupe pour publier PCM directement.
|
||||||
|
|
||||||
|
**Option B : DataChannel**
|
||||||
|
|
||||||
|
Envoyer Opus via DataChannel LiveKit. Clients décodent manuellement.
|
||||||
|
|
||||||
|
**Option C : Participant virtuel par groupe**
|
||||||
|
|
||||||
|
Un "bot" LiveKit par groupe qui publie un MediaStream.
|
||||||
|
|
||||||
|
### Tests multi-canaux
|
||||||
|
|
||||||
|
- Tester avec carte 8+ canaux
|
||||||
|
- Routing complexe (plusieurs groupes vers même sortie)
|
||||||
|
- Monitoring niveaux temps réel (VU-mètres)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ressources
|
||||||
|
|
||||||
|
- [LIVEKIT_AUDIO_BRIDGE.md](./LIVEKIT_AUDIO_BRIDGE.md) : Guide intégration LiveKit serveur
|
||||||
|
- [DANTE_SETUP.md](./DANTE_SETUP.md) : Setup Dante Virtual Soundcard
|
||||||
|
- [AES67_SETUP.md](./AES67_SETUP.md) : Setup AES67/RAVENNA
|
||||||
|
- [DEPLOYMENT.md](./DEPLOYMENT.md) : Déploiement production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Dernière mise à jour** : 2026-05-26
|
||||||
|
**Version** : 0.1.0 (Phase 3+)
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
# Configuration Dante avec PTT Live
|
||||||
|
|
||||||
|
Guide pour intégrer PTT Live avec des équipements Dante (Audinate)
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Dante (Digital Audio Network Through Ethernet) est un protocole audio professionnel sur IP largement utilisé dans l'événementiel et le broadcast. PTT Live peut s'interfacer avec des équipements Dante via JACK Audio Connection Kit.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
[Équipements Dante] ←→ [Dante Virtual Soundcard (DVS)] ←→ [JACK] ←→ [PTT Live]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
### Matériel
|
||||||
|
- Mac ou PC avec interface réseau Ethernet (Gigabit recommandé)
|
||||||
|
- Équipements Dante (console, preamps, etc.)
|
||||||
|
- Switch réseau dédié (VLAN audio recommandé)
|
||||||
|
|
||||||
|
### Logiciel
|
||||||
|
- **Dante Virtual Soundcard** (~300€ licence personnelle)
|
||||||
|
- macOS 10.14+ ou Windows 10+
|
||||||
|
- Téléchargement : https://www.audinate.com/products/software/dante-virtual-soundcard
|
||||||
|
- **Dante Controller** (gratuit)
|
||||||
|
- Configuration et routing Dante
|
||||||
|
- Téléchargement : https://www.audinate.com/products/software/dante-controller
|
||||||
|
- **JACK Audio Connection Kit**
|
||||||
|
- macOS : `brew install jack` ou via JackPilot
|
||||||
|
- Linux : voir [install/linux.sh](../install/linux.sh)
|
||||||
|
- Windows : https://jackaudio.org/downloads/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Installation Dante Virtual Soundcard (DVS)
|
||||||
|
|
||||||
|
1. Acheter et télécharger DVS depuis le site Audinate
|
||||||
|
2. Installer le package (.dmg sur macOS, .exe sur Windows)
|
||||||
|
3. Redémarrer l'ordinateur
|
||||||
|
4. Lancer DVS :
|
||||||
|
- **macOS** : `/Applications/Dante Virtual Soundcard.app`
|
||||||
|
- **Windows** : Menu Démarrer > Dante Virtual Soundcard
|
||||||
|
|
||||||
|
### 2. Configuration DVS
|
||||||
|
|
||||||
|
#### Paramètres recommandés pour PTT Live
|
||||||
|
|
||||||
|
| Paramètre | Valeur | Description |
|
||||||
|
|-----------|--------|-------------|
|
||||||
|
| **Latency** | 5-10 ms | Latence réseau (plus bas = moins de buffer) |
|
||||||
|
| **Sample Rate** | 48 kHz | Standard audio pro (requis par PTT Live) |
|
||||||
|
| **Encoding** | PCM 24-bit | Qualité maximale |
|
||||||
|
| **Channels** | 8-32 | Selon besoins (min 2 pour stéréo) |
|
||||||
|
|
||||||
|
**Configuration** :
|
||||||
|
1. Ouvrir Dante Virtual Soundcard
|
||||||
|
2. Onglet "Settings"
|
||||||
|
3. Définir les paramètres ci-dessus
|
||||||
|
4. Cliquer "Start" pour activer la carte virtuelle
|
||||||
|
|
||||||
|
### 3. Installation JACK
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
```bash
|
||||||
|
# Via Homebrew
|
||||||
|
brew install jack
|
||||||
|
|
||||||
|
# Ou télécharger JackPilot :
|
||||||
|
# http://www.jackosx.com/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Linux
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt install jackd2 jack-tools qjackctl
|
||||||
|
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S jack2 qjackctl
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
Télécharger depuis https://jackaudio.org/downloads/ et installer.
|
||||||
|
|
||||||
|
### 4. Configuration JACK
|
||||||
|
|
||||||
|
#### Paramètres recommandés
|
||||||
|
|
||||||
|
| Paramètre | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| **Sample Rate** | 48000 Hz |
|
||||||
|
| **Buffer Size** | 256-512 samples (5-10ms) |
|
||||||
|
| **Periods** | 2-3 |
|
||||||
|
|
||||||
|
#### Via QjackCtl (GUI)
|
||||||
|
|
||||||
|
1. Lancer QjackCtl
|
||||||
|
2. Cliquer "Setup"
|
||||||
|
3. Configurer :
|
||||||
|
- **Driver** : CoreAudio (macOS), ALSA (Linux), PortAudio (Windows)
|
||||||
|
- **Sample Rate** : 48000
|
||||||
|
- **Frames/Period** : 256 ou 512
|
||||||
|
4. Cliquer "OK" puis "Start"
|
||||||
|
|
||||||
|
#### Via ligne de commande (macOS)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jackd -d coreaudio -r 48000 -p 512
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Via ligne de commande (Linux)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jackd -d alsa -r 48000 -p 512
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routing Audio
|
||||||
|
|
||||||
|
### 1. Dante Controller - Configuration réseau
|
||||||
|
|
||||||
|
1. Lancer Dante Controller
|
||||||
|
2. Vérifier que DVS apparaît dans la liste des devices (ex: "MacBook-DVS")
|
||||||
|
3. Configurer le routing Dante :
|
||||||
|
- **Sources** : équipements physiques (colonnes)
|
||||||
|
- **Destinations** : DVS (lignes)
|
||||||
|
- Cocher les cases pour router les canaux
|
||||||
|
|
||||||
|
**Exemple** :
|
||||||
|
- Console Dante (8 canaux) → DVS Input 1-8
|
||||||
|
- DVS Output 1-8 → Console Dante (8 canaux)
|
||||||
|
|
||||||
|
### 2. JACK - Connexion DVS ↔ PTT Live
|
||||||
|
|
||||||
|
#### Via QjackCtl (GUI)
|
||||||
|
|
||||||
|
1. Lancer PTT Live (voir ci-dessous)
|
||||||
|
2. Dans QjackCtl, cliquer "Graph" ou "Connections"
|
||||||
|
3. Connecter les ports :
|
||||||
|
- **Capture** : `DVS:capture_1` → `PTTLive:input_1`
|
||||||
|
- **Playback** : `PTTLive:output_1` → `DVS:playback_1`
|
||||||
|
|
||||||
|
#### Via jack_connect (CLI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Liste des ports disponibles
|
||||||
|
jack_lsp
|
||||||
|
|
||||||
|
# Connexion entrée Dante → PTT Live
|
||||||
|
jack_connect "DVS:capture_1" "PTTLive:input_1"
|
||||||
|
jack_connect "DVS:capture_2" "PTTLive:input_2"
|
||||||
|
|
||||||
|
# Connexion sortie PTT Live → Dante
|
||||||
|
jack_connect "PTTLive:output_1" "DVS:playback_1"
|
||||||
|
jack_connect "PTTLive:output_2" "DVS:playback_2"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Démarrage PTT Live avec Dante
|
||||||
|
|
||||||
|
### 1. Ordre de démarrage recommandé
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Démarrer le serveur JACK
|
||||||
|
2. Lancer Dante Virtual Soundcard
|
||||||
|
3. Configurer le routing dans Dante Controller
|
||||||
|
4. Démarrer le serveur PTT Live
|
||||||
|
5. Connecter les ports JACK (DVS ↔ PTT Live)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Lancer PTT Live
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
PTT Live détectera automatiquement JACK comme backend audio (sur Linux/macOS avec JACK actif).
|
||||||
|
|
||||||
|
### 3. Vérification
|
||||||
|
|
||||||
|
Dans les logs du serveur PTT Live, vous devriez voir :
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ Backend audio : JACK (Linux professionnel)
|
||||||
|
📻 Devices audio détectés : X
|
||||||
|
- JACK System Capture (in:8, out:0)
|
||||||
|
- JACK System Playback (in:0, out:8)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Multi-canaux
|
||||||
|
|
||||||
|
### Exemple : 8 canaux Dante ↔ 8 groupes PTT Live
|
||||||
|
|
||||||
|
#### 1. Configuration réseau Dante
|
||||||
|
|
||||||
|
Dans Dante Controller :
|
||||||
|
- Console OUT 1-8 → DVS Input 1-8
|
||||||
|
- DVS Output 1-8 → Console IN 1-8
|
||||||
|
|
||||||
|
#### 2. Configuration PTT Live
|
||||||
|
|
||||||
|
Éditer [server/config/config.yaml](../server/config/config.yaml) :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
audio:
|
||||||
|
backend: jack
|
||||||
|
sampleRate: 48000
|
||||||
|
channels: 8
|
||||||
|
routing:
|
||||||
|
inputs:
|
||||||
|
- name: "Canal 1 - Régie"
|
||||||
|
jackPort: "DVS:capture_1"
|
||||||
|
groups: ["regie"]
|
||||||
|
- name: "Canal 2 - Scene"
|
||||||
|
jackPort: "DVS:capture_2"
|
||||||
|
groups: ["scene"]
|
||||||
|
# ... etc
|
||||||
|
outputs:
|
||||||
|
- name: "Retour Régie"
|
||||||
|
jackPort: "DVS:playback_1"
|
||||||
|
groups: ["regie"]
|
||||||
|
- name: "Retour Scene"
|
||||||
|
jackPort: "DVS:playback_2"
|
||||||
|
groups: ["scene"]
|
||||||
|
# ... etc
|
||||||
|
|
||||||
|
groups:
|
||||||
|
- id: regie
|
||||||
|
name: "Régie"
|
||||||
|
inputChannels: [0]
|
||||||
|
outputChannels: [0]
|
||||||
|
|
||||||
|
- id: scene
|
||||||
|
name: "Scène"
|
||||||
|
inputChannels: [1]
|
||||||
|
outputChannels: [1]
|
||||||
|
|
||||||
|
# ... autres groupes
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Routing JACK automatique
|
||||||
|
|
||||||
|
Créer un script [server/scripts/connect-dante.sh](../server/scripts/connect-dante.sh) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Connexion automatique JACK ↔ Dante
|
||||||
|
|
||||||
|
echo "Connexion des canaux Dante → PTT Live..."
|
||||||
|
|
||||||
|
for i in {1..8}; do
|
||||||
|
jack_connect "DVS:capture_$i" "PTTLive:input_$i"
|
||||||
|
jack_connect "PTTLive:output_$i" "DVS:playback_$i"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "✓ Routing JACK configuré"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x server/scripts/connect-dante.sh
|
||||||
|
./server/scripts/connect-dante.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring et Troubleshooting
|
||||||
|
|
||||||
|
### Vérification du statut JACK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ports disponibles
|
||||||
|
jack_lsp
|
||||||
|
|
||||||
|
# Ports DVS (exemple)
|
||||||
|
DVS:capture_1
|
||||||
|
DVS:capture_2
|
||||||
|
DVS:playback_1
|
||||||
|
DVS:playback_2
|
||||||
|
|
||||||
|
# Connexions actives
|
||||||
|
jack_lsp -c
|
||||||
|
|
||||||
|
# Stats serveur JACK
|
||||||
|
jack_samplerate # Devrait afficher 48000
|
||||||
|
jack_bufsize # Devrait afficher 256 ou 512
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problèmes courants
|
||||||
|
|
||||||
|
#### DVS ne s'affiche pas dans Dante Controller
|
||||||
|
|
||||||
|
**Cause** : Firewall ou réseau incorrect
|
||||||
|
|
||||||
|
**Solution** :
|
||||||
|
1. Vérifier que DVS est "Started" dans l'application
|
||||||
|
2. Désactiver temporairement le firewall
|
||||||
|
3. Vérifier que l'interface réseau est en Gigabit
|
||||||
|
4. Brancher sur le même switch que les équipements Dante
|
||||||
|
|
||||||
|
#### Latence élevée ou craquements audio
|
||||||
|
|
||||||
|
**Cause** : Buffer JACK trop petit ou latence Dante trop faible
|
||||||
|
|
||||||
|
**Solution** :
|
||||||
|
1. Augmenter le buffer JACK : 512 ou 1024 samples
|
||||||
|
2. Augmenter la latence DVS : 10ms au lieu de 5ms
|
||||||
|
3. Vérifier le trafic réseau (pas de flood broadcast)
|
||||||
|
|
||||||
|
#### Pas de son entre PTT Live et Dante
|
||||||
|
|
||||||
|
**Cause** : Ports JACK non connectés
|
||||||
|
|
||||||
|
**Solution** :
|
||||||
|
```bash
|
||||||
|
# Vérifier les connexions
|
||||||
|
jack_lsp -c
|
||||||
|
|
||||||
|
# Reconnecter manuellement
|
||||||
|
jack_connect "DVS:capture_1" "PTTLive:input_1"
|
||||||
|
jack_connect "PTTLive:output_1" "DVS:playback_1"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PTT Live ne détecte pas JACK
|
||||||
|
|
||||||
|
**Cause** : Serveur JACK non démarré avant PTT Live
|
||||||
|
|
||||||
|
**Solution** :
|
||||||
|
1. Arrêter PTT Live
|
||||||
|
2. Vérifier que JACK tourne : `jack_lsp` (ne doit pas donner d'erreur)
|
||||||
|
3. Relancer PTT Live
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Réseau Recommandée
|
||||||
|
|
||||||
|
### VLAN Audio (optionnel mais recommandé)
|
||||||
|
|
||||||
|
Pour isoler le trafic Dante du reste du réseau :
|
||||||
|
|
||||||
|
| Paramètre | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| **VLAN ID** | 10 (exemple) |
|
||||||
|
| **Subnet** | 192.168.10.0/24 |
|
||||||
|
| **QoS/DSCP** | EF (Expedited Forwarding) |
|
||||||
|
| **IGMP Snooping** | Activé |
|
||||||
|
| **Jumbo Frames** | Activé (MTU 9000) |
|
||||||
|
|
||||||
|
### Switch manageable
|
||||||
|
|
||||||
|
Fonctionnalités requises :
|
||||||
|
- VLAN tagging
|
||||||
|
- QoS/DSCP
|
||||||
|
- IGMP snooping
|
||||||
|
- Gigabit Ethernet (min)
|
||||||
|
|
||||||
|
Modèles testés :
|
||||||
|
- Netgear M4300 series
|
||||||
|
- Cisco SG350/SG550
|
||||||
|
- Ubiquiti EdgeSwitch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Latence End-to-End
|
||||||
|
|
||||||
|
### Budget latence typique
|
||||||
|
|
||||||
|
| Étape | Latence |
|
||||||
|
|-------|---------|
|
||||||
|
| Dante network | 5-10 ms |
|
||||||
|
| DVS | 2-5 ms |
|
||||||
|
| JACK | 5-10 ms (256 samples @ 48kHz) |
|
||||||
|
| PTT Live bridge | 20-40 ms (jitter buffer) |
|
||||||
|
| WebRTC client | 30-100 ms |
|
||||||
|
| **TOTAL** | **62-165 ms** |
|
||||||
|
|
||||||
|
Objectif : < 150ms end-to-end (validé en Phase 1)
|
||||||
|
|
||||||
|
### Optimisation
|
||||||
|
|
||||||
|
Pour réduire la latence :
|
||||||
|
1. Dante latency : 2-5ms (au lieu de 10ms)
|
||||||
|
2. JACK buffer : 128 samples (au lieu de 512)
|
||||||
|
3. PTT Live jitter buffer : preset "ULTRA_LOW" (20ms au lieu de 40ms)
|
||||||
|
|
||||||
|
**Attention** : Latence trop faible = risque de craquements audio si réseau/CPU chargé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coût et Licences
|
||||||
|
|
||||||
|
| Élément | Prix | Licence |
|
||||||
|
|---------|------|---------|
|
||||||
|
| **Dante Virtual Soundcard** | ~300€ | Par poste (licence personnelle) |
|
||||||
|
| **Dante Controller** | Gratuit | - |
|
||||||
|
| **JACK** | Gratuit | Open Source (GPL) |
|
||||||
|
| **PTT Live** | Gratuit | Open Source |
|
||||||
|
|
||||||
|
**Note** : Pour un déploiement multi-postes, chaque ordinateur exécutant DVS nécessite sa propre licence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
### AES67 (sans Dante Virtual Soundcard)
|
||||||
|
|
||||||
|
Si le budget DVS est un problème, voir [AES67_SETUP.md](./AES67_SETUP.md) pour utiliser le protocole AES67 natif (interopérable avec Dante).
|
||||||
|
|
||||||
|
**Avantages** :
|
||||||
|
- Gratuit (pas de licence DVS)
|
||||||
|
- Standard ouvert
|
||||||
|
|
||||||
|
**Inconvénients** :
|
||||||
|
- Configuration plus complexe
|
||||||
|
- Support PTP sync requis
|
||||||
|
- Moins de GUI (configuration CLI)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support et Ressources
|
||||||
|
|
||||||
|
- **Dante Academy** : https://www.audinate.com/learning/training-certification/dante-certification-program
|
||||||
|
- **JACK Documentation** : https://jackaudio.org/faq/
|
||||||
|
- **PTT Live Issues** : https://github.com/username/ptt-live/issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Dernière mise à jour** : 2026-05-26
|
||||||
|
**Version PTT Live** : 0.1.0 (Phase 3)
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
# Guide de Déploiement Production - PTT Live
|
||||||
|
|
||||||
|
Guide complet pour déployer PTT Live en environnement professionnel événementiel.
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
Ce guide couvre le déploiement de PTT Live pour une utilisation en production avec :
|
||||||
|
- 30+ clients simultanés
|
||||||
|
- Réseau WiFi dédié
|
||||||
|
- Cartes son multi-canaux / Dante / AES67
|
||||||
|
- Optimisations performance et latence
|
||||||
|
- Monitoring et troubleshooting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Production Recommandée
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Switch Core │
|
||||||
|
│ (Manageable) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────┼─────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌────────▼────────┐ ┌───────▼────────┐ ┌───────▼────────┐
|
||||||
|
│ VLAN 10 AUDIO │ │ VLAN 20 WIFI │ │ VLAN 30 MGMT │
|
||||||
|
│ (Dante/AES67) │ │ (Clients PTT) │ │ (Admin/Logs) │
|
||||||
|
└────────┬────────┘ └───────┬────────┘ └───────┬────────┘
|
||||||
|
│ │ │
|
||||||
|
┌────────▼────────┐ ┌───────▼────────┐ ┌───────▼────────┐
|
||||||
|
│ Equipements │ │ Access Points │ │ Laptop Admin │
|
||||||
|
│ Audio Pro │ │ WiFi 5/6 │ │ (Monitoring) │
|
||||||
|
│ (Console, etc) │ │ (5GHz) │ │ │
|
||||||
|
└─────────────────┘ └────────────────┘ └────────────────┘
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ Serveur PTT │
|
||||||
|
│ Live │
|
||||||
|
│ - LiveKit │
|
||||||
|
│ - AudioBridge │
|
||||||
|
│ - API/Admin │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prérequis Matériel
|
||||||
|
|
||||||
|
### Serveur PTT Live
|
||||||
|
|
||||||
|
**Spécifications minimales** (30 clients) :
|
||||||
|
|
||||||
|
| Composant | Minimum | Recommandé |
|
||||||
|
|-----------|---------|------------|
|
||||||
|
| **CPU** | 4 cores, 2.5GHz | 8 cores, 3.0GHz+ |
|
||||||
|
| **RAM** | 8 GB | 16 GB+ |
|
||||||
|
| **Réseau** | 1 Gbps Ethernet | 10 Gbps ou dual 1Gbps (bonding) |
|
||||||
|
| **Stockage** | 50 GB SSD | 100 GB NVMe SSD |
|
||||||
|
| **OS** | Ubuntu 22.04 LTS | Ubuntu 22.04 LTS Server |
|
||||||
|
| **Audio** | Carte son 8+ canaux | Interface Dante/AES67 |
|
||||||
|
|
||||||
|
**Exemples configurations** :
|
||||||
|
- **Budget** : Mac Mini M1 (2020) - 8GB RAM, 256GB SSD
|
||||||
|
- **Standard** : Intel NUC i7 - 16GB RAM, 512GB SSD
|
||||||
|
- **Pro** : Dell R240 Server - Xeon E-2224, 32GB ECC, RAID SSD
|
||||||
|
|
||||||
|
### Réseau
|
||||||
|
|
||||||
|
#### Switch Core
|
||||||
|
|
||||||
|
**Requis** :
|
||||||
|
- Manageable (VLAN, QoS, IGMP)
|
||||||
|
- Gigabit minimum (10G recommandé pour Dante/AES67)
|
||||||
|
- PTP support (si AES67)
|
||||||
|
- Backplane suffisant (480 Gbps+)
|
||||||
|
- Redondance alimentation (si critique)
|
||||||
|
|
||||||
|
**Modèles testés** :
|
||||||
|
- Netgear M4300-8X8F (8x 10G + 8x 1G)
|
||||||
|
- Cisco SG350-28P
|
||||||
|
- Ubiquiti EdgeSwitch 24
|
||||||
|
|
||||||
|
#### Access Points WiFi
|
||||||
|
|
||||||
|
**Spécifications** :
|
||||||
|
|
||||||
|
| Paramètre | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| **Standard** | WiFi 5 (802.11ac) minimum, WiFi 6 (ax) recommandé |
|
||||||
|
| **Bande** | 5 GHz dédiée (moins de congestion) |
|
||||||
|
| **Canaux** | 40 MHz ou 80 MHz |
|
||||||
|
| **Débit** | 867 Mbps+ par client |
|
||||||
|
| **Clients** | 30+ par AP (répartir si plus) |
|
||||||
|
| **Roaming** | 802.11r/k/v (fast roaming) |
|
||||||
|
|
||||||
|
**Modèles recommandés** :
|
||||||
|
- Ubiquiti UniFi 6 LR / PRO
|
||||||
|
- Aruba AP-515 / AP-555
|
||||||
|
- Cisco Meraki MR46 / MR56
|
||||||
|
|
||||||
|
**Déploiement** :
|
||||||
|
- 1 AP pour 10-15 clients actifs simultanés
|
||||||
|
- Positionnement stratégique (hauteur, line-of-sight)
|
||||||
|
- Survey WiFi préalable (éviter interférences)
|
||||||
|
|
||||||
|
### Cartes Son / Interfaces Audio
|
||||||
|
|
||||||
|
**Options** :
|
||||||
|
|
||||||
|
1. **Carte son USB/Thunderbolt multi-canaux**
|
||||||
|
- MOTU UltraLite mk5 (18x22, USB-C)
|
||||||
|
- RME Fireface UCX II (40 canaux, USB 2.0/3.0)
|
||||||
|
- Focusrite Clarett 8PreX (26x28, Thunderbolt)
|
||||||
|
|
||||||
|
2. **Interface Dante**
|
||||||
|
- Focusrite RedNet PCIe (32+ canaux)
|
||||||
|
- Audinate AVIO Adapter
|
||||||
|
- Console avec Dante intégré
|
||||||
|
|
||||||
|
3. **AES67 natif**
|
||||||
|
- Merging RAVENNA/AES67 (Linux ALSA driver)
|
||||||
|
- Lawo mc² Console
|
||||||
|
- Calrec Artemis/Apollo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation Production
|
||||||
|
|
||||||
|
### 1. Préparation Serveur
|
||||||
|
|
||||||
|
#### Ubuntu Server 22.04 LTS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mise à jour système
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
# Installation dépendances
|
||||||
|
sudo apt install -y \
|
||||||
|
build-essential \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
htop \
|
||||||
|
net-tools \
|
||||||
|
ethtool \
|
||||||
|
iftop \
|
||||||
|
iperf3
|
||||||
|
|
||||||
|
# Désactiver économie énergie CPU
|
||||||
|
sudo apt install linux-tools-common linux-tools-generic
|
||||||
|
sudo cpupower frequency-set -g performance
|
||||||
|
|
||||||
|
# Config persistence
|
||||||
|
echo "performance" | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Optimisations réseau
|
||||||
|
|
||||||
|
Éditer `/etc/sysctl.conf` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Buffers réseau
|
||||||
|
net.core.rmem_max = 134217728
|
||||||
|
net.core.wmem_max = 134217728
|
||||||
|
net.core.rmem_default = 16777216
|
||||||
|
net.core.wmem_default = 16777216
|
||||||
|
|
||||||
|
# TCP
|
||||||
|
net.ipv4.tcp_rmem = 4096 87380 134217728
|
||||||
|
net.ipv4.tcp_wmem = 4096 65536 134217728
|
||||||
|
net.ipv4.tcp_congestion_control = bbr
|
||||||
|
|
||||||
|
# Multicast
|
||||||
|
net.ipv4.igmp_max_memberships = 512
|
||||||
|
|
||||||
|
# Connections tracking
|
||||||
|
net.netfilter.nf_conntrack_max = 1000000
|
||||||
|
net.netfilter.nf_conntrack_tcp_timeout_established = 7200
|
||||||
|
```
|
||||||
|
|
||||||
|
Appliquer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo sysctl -p
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Firewall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# UFW (Ubuntu Firewall)
|
||||||
|
sudo ufw allow 22/tcp # SSH
|
||||||
|
sudo ufw allow 3000/tcp # API PTT Live
|
||||||
|
sudo ufw allow 5173/tcp # Client Vite (dev)
|
||||||
|
sudo ufw allow 7880/tcp # LiveKit WebSocket
|
||||||
|
sudo ufw allow 7881/tcp # LiveKit TURN
|
||||||
|
sudo ufw allow 50000:60000/udp # LiveKit RTC
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Installation PTT Live
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone du repo
|
||||||
|
cd /opt
|
||||||
|
sudo git clone https://github.com/votre-user/PTT-Live.git
|
||||||
|
sudo chown -R $USER:$USER PTT-Live
|
||||||
|
cd PTT-Live
|
||||||
|
|
||||||
|
# Installation selon OS
|
||||||
|
./install/linux.sh # Linux
|
||||||
|
# ou
|
||||||
|
./install/macos.sh # macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configuration Audio
|
||||||
|
|
||||||
|
#### Option A : Carte son USB (CoreAudio/ALSA)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lister les cartes
|
||||||
|
aplay -l # Linux
|
||||||
|
system_profiler SPAudioDataType # macOS
|
||||||
|
|
||||||
|
# Éditer config PTT Live
|
||||||
|
nano server/config/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
audio:
|
||||||
|
backend: auto # coreaudio (macOS) ou pipewire/jack (Linux)
|
||||||
|
sampleRate: 48000
|
||||||
|
channels: 8
|
||||||
|
inputDeviceId: 0 # ID de la carte (voir logs au démarrage)
|
||||||
|
outputDeviceId: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option B : Dante (via JACK)
|
||||||
|
|
||||||
|
Voir [DANTE_SETUP.md](./DANTE_SETUP.md)
|
||||||
|
|
||||||
|
#### Option C : AES67 (Linux)
|
||||||
|
|
||||||
|
Voir [AES67_SETUP.md](./AES67_SETUP.md)
|
||||||
|
|
||||||
|
### 4. Configuration LiveKit
|
||||||
|
|
||||||
|
Éditer `server/config/livekit.yaml` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
port: 7880
|
||||||
|
bind_addresses:
|
||||||
|
- 0.0.0.0 # Écoute sur toutes les interfaces
|
||||||
|
|
||||||
|
rtc:
|
||||||
|
port_range_start: 50000
|
||||||
|
port_range_end: 60000
|
||||||
|
use_external_ip: false # true si NAT
|
||||||
|
# external_ip: "votre.ip.publique" # Si use_external_ip: true
|
||||||
|
|
||||||
|
turn:
|
||||||
|
enabled: true
|
||||||
|
domain: ""
|
||||||
|
tls_port: 5349
|
||||||
|
udp_port: 3478
|
||||||
|
|
||||||
|
keys:
|
||||||
|
# IMPORTANT : Générer des clés uniques en production !
|
||||||
|
# Ne PAS utiliser les clés de développement
|
||||||
|
api_key: "APIxxxxxxxxxxxxxxxx" # Générer avec : openssl rand -base64 32
|
||||||
|
api_secret: "SECRETxxxxxxxxxxxxxxxx"
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: info # debug, info, warn, error
|
||||||
|
sample: true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Générer des clés sécurisées** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API Key
|
||||||
|
echo "API_KEY=$(openssl rand -base64 24)" | tee -a server/.env
|
||||||
|
|
||||||
|
# API Secret
|
||||||
|
echo "API_SECRET=$(openssl rand -base64 48)" | tee -a server/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Configuration Groupes et Routing
|
||||||
|
|
||||||
|
Éditer `server/config/config.yaml` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
groups:
|
||||||
|
- id: regie
|
||||||
|
name: "Régie"
|
||||||
|
inputChannels: [0, 1] # Canaux audio physiques (carte son)
|
||||||
|
outputChannels: [0, 1]
|
||||||
|
opusBitrate: 96000 # 96 kbps (voix standard)
|
||||||
|
|
||||||
|
- id: scene
|
||||||
|
name: "Scène"
|
||||||
|
inputChannels: [2, 3]
|
||||||
|
outputChannels: [2, 3]
|
||||||
|
opusBitrate: 96000
|
||||||
|
|
||||||
|
- id: foh
|
||||||
|
name: "FOH"
|
||||||
|
inputChannels: [4, 5]
|
||||||
|
outputChannels: [4, 5]
|
||||||
|
opusBitrate: 96000
|
||||||
|
|
||||||
|
- id: broadcast
|
||||||
|
name: "Broadcast"
|
||||||
|
inputChannels: [6, 7]
|
||||||
|
outputChannels: [6, 7]
|
||||||
|
opusBitrate: 128000 # 128 kbps (qualité supérieure)
|
||||||
|
|
||||||
|
routing:
|
||||||
|
# Configuration gains par route (optionnel)
|
||||||
|
input_gains:
|
||||||
|
regie: 0 # 0 dB (unity)
|
||||||
|
scene: -3 # -3 dB
|
||||||
|
foh: 0
|
||||||
|
broadcast: -6 # -6 dB
|
||||||
|
|
||||||
|
output_gains:
|
||||||
|
regie: 0
|
||||||
|
scene: 0
|
||||||
|
foh: -3
|
||||||
|
broadcast: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Démarrage Production
|
||||||
|
|
||||||
|
### Services Systemd
|
||||||
|
|
||||||
|
#### Service PTT Live Server
|
||||||
|
|
||||||
|
Créer `/etc/systemd/system/pttlive-server.service` :
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=PTT Live Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pttlive
|
||||||
|
Group=audio
|
||||||
|
WorkingDirectory=/opt/PTT-Live/server
|
||||||
|
Environment="NODE_ENV=production"
|
||||||
|
EnvironmentFile=/opt/PTT-Live/server/.env
|
||||||
|
ExecStart=/usr/bin/node index.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
# Limites
|
||||||
|
LimitNOFILE=65536
|
||||||
|
LimitNPROC=4096
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Service PTT Live Client (si servi via Node)
|
||||||
|
|
||||||
|
Créer `/etc/systemd/system/pttlive-client.service` :
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=PTT Live Client (HTTP Server)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pttlive
|
||||||
|
WorkingDirectory=/opt/PTT-Live/client
|
||||||
|
ExecStart=/usr/bin/npm run preview # Vite preview (prod build)
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Activation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Créer utilisateur dédié
|
||||||
|
sudo useradd -r -s /bin/false -G audio pttlive
|
||||||
|
sudo chown -R pttlive:audio /opt/PTT-Live
|
||||||
|
|
||||||
|
# Activer services
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable pttlive-server pttlive-client
|
||||||
|
sudo systemctl start pttlive-server pttlive-client
|
||||||
|
|
||||||
|
# Vérifier statut
|
||||||
|
sudo systemctl status pttlive-server
|
||||||
|
sudo journalctl -u pttlive-server -f # Logs temps réel
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Réseau Production
|
||||||
|
|
||||||
|
### VLAN et QoS
|
||||||
|
|
||||||
|
#### Configuration Switch (exemple CLI Cisco/HP)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# VLAN Audio (Dante/AES67)
|
||||||
|
vlan 10
|
||||||
|
name AUDIO
|
||||||
|
qos dscp 46 # EF (Expedited Forwarding)
|
||||||
|
|
||||||
|
# VLAN WiFi Clients
|
||||||
|
vlan 20
|
||||||
|
name WIFI_CLIENTS
|
||||||
|
qos dscp 34 # AF41 (Assured Forwarding)
|
||||||
|
|
||||||
|
# VLAN Management
|
||||||
|
vlan 30
|
||||||
|
name MGMT
|
||||||
|
|
||||||
|
# Ports
|
||||||
|
interface range gigabitethernet 1/0/1-8
|
||||||
|
switchport mode access
|
||||||
|
switchport access vlan 10
|
||||||
|
spanning-tree portfast
|
||||||
|
|
||||||
|
interface range gigabitethernet 1/0/9-16
|
||||||
|
switchport mode trunk
|
||||||
|
switchport trunk allowed vlan 20,30
|
||||||
|
|
||||||
|
# QoS global
|
||||||
|
mls qos
|
||||||
|
mls qos map dscp-cos 46 to 6 # Audio prioritaire
|
||||||
|
```
|
||||||
|
|
||||||
|
### IGMP Snooping
|
||||||
|
|
||||||
|
Pour multicast (Dante/AES67) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cisco
|
||||||
|
ip igmp snooping
|
||||||
|
ip igmp snooping vlan 10 immediate-leave
|
||||||
|
ip igmp snooping vlan 10 last-member-query-interval 100
|
||||||
|
|
||||||
|
# HP/Aruba
|
||||||
|
vlan 10
|
||||||
|
ip igmp
|
||||||
|
ip igmp querier
|
||||||
|
```
|
||||||
|
|
||||||
|
### WiFi Optimisations
|
||||||
|
|
||||||
|
#### Configuration Access Point (Ubiquiti UniFi)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"networks": [
|
||||||
|
{
|
||||||
|
"name": "PTT_Live_5G",
|
||||||
|
"wlan_band": "5g",
|
||||||
|
"wpa_mode": "wpa2",
|
||||||
|
"wpa_enc": "ccmp",
|
||||||
|
"channel": 36, // Ou 149 (selon région)
|
||||||
|
"channel_width": 80,
|
||||||
|
"dtim_mode": "default",
|
||||||
|
"fast_roaming_enabled": true,
|
||||||
|
"vlan": 20,
|
||||||
|
"uapsd_enabled": true, // Power save
|
||||||
|
"multicast_enhance": true,
|
||||||
|
"airtime_fairness": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Paramètres clés** :
|
||||||
|
- **Fast Roaming (802.11r)** : Activé (handoff < 50ms)
|
||||||
|
- **Band Steering** : Désactivé (forcer 5GHz)
|
||||||
|
- **Multicast Enhancement** : Activé (convertit multicast → unicast)
|
||||||
|
- **Airtime Fairness** : Activé (évite qu'un client lent ralentisse tous)
|
||||||
|
- **DTIM** : 1-3 (compromis latence/batterie)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring et Logs
|
||||||
|
|
||||||
|
### Monitoring Système
|
||||||
|
|
||||||
|
#### Prometheus + Grafana (optionnel mais recommandé)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Installation Prometheus
|
||||||
|
sudo apt install prometheus prometheus-node-exporter
|
||||||
|
|
||||||
|
# Installation Grafana
|
||||||
|
sudo apt install -y software-properties-common
|
||||||
|
sudo add-apt-repository "deb https://packages.grafana.com/oss/deb stable main"
|
||||||
|
wget -q -O - https://packages.grafana.com/gpg.key | sudo apt-key add -
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install grafana
|
||||||
|
|
||||||
|
sudo systemctl enable grafana-server prometheus
|
||||||
|
sudo systemctl start grafana-server prometheus
|
||||||
|
```
|
||||||
|
|
||||||
|
Accès Grafana : `http://serveur:3000` (admin/admin)
|
||||||
|
|
||||||
|
**Métriques à surveiller** :
|
||||||
|
- CPU usage
|
||||||
|
- RAM usage
|
||||||
|
- Network throughput (RX/TX)
|
||||||
|
- JACK xruns (si JACK)
|
||||||
|
- LiveKit room stats (participants, bitrate)
|
||||||
|
- Audio latency
|
||||||
|
|
||||||
|
#### Dashboard Grafana PTT Live
|
||||||
|
|
||||||
|
Créer un dashboard avec :
|
||||||
|
- Participants actifs par groupe
|
||||||
|
- Bitrate audio moyen
|
||||||
|
- Packet loss WebRTC
|
||||||
|
- Latence end-to-end (si sonde)
|
||||||
|
|
||||||
|
### Logs Centralisés
|
||||||
|
|
||||||
|
#### rsyslog vers serveur central (optionnel)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /etc/rsyslog.d/50-pttlive.conf
|
||||||
|
if $programname == 'pttlive-server' then @@log-server:514
|
||||||
|
& stop
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests de Charge
|
||||||
|
|
||||||
|
### Outils
|
||||||
|
|
||||||
|
1. **LoadBot** (LiveKit officiel)
|
||||||
|
```bash
|
||||||
|
# Installation
|
||||||
|
go install github.com/livekit/livekit-cli/cmd/livekit-load-tester@latest
|
||||||
|
|
||||||
|
# Test 30 participants
|
||||||
|
livekit-load-tester \
|
||||||
|
--url ws://serveur:7880 \
|
||||||
|
--api-key APIxxxxxx \
|
||||||
|
--api-secret SECRETxxxxxx \
|
||||||
|
--room test-room \
|
||||||
|
--publishers 30 \
|
||||||
|
--duration 10m
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **iperf3** (test bande passante réseau)
|
||||||
|
```bash
|
||||||
|
# Serveur
|
||||||
|
iperf3 -s
|
||||||
|
|
||||||
|
# Client
|
||||||
|
iperf3 -c serveur -t 60 -P 10 # 10 streams parallèles, 60s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scénarios de Test
|
||||||
|
|
||||||
|
#### Test 1 : Connexion 30 clients
|
||||||
|
|
||||||
|
**Objectif** : Tous les clients se connectent et rejoignent des groupes différents.
|
||||||
|
|
||||||
|
**Métriques** :
|
||||||
|
- Temps de connexion < 2s par client
|
||||||
|
- CPU serveur < 60%
|
||||||
|
- RAM < 8GB
|
||||||
|
|
||||||
|
#### Test 2 : PTT simultanés (10 clients parlent en même temps)
|
||||||
|
|
||||||
|
**Objectif** : Vérifier que le serveur gère 10 flux audio upstream simultanés.
|
||||||
|
|
||||||
|
**Métriques** :
|
||||||
|
- Latence audio < 150ms
|
||||||
|
- Packet loss < 1%
|
||||||
|
- Pas de xruns JACK
|
||||||
|
|
||||||
|
#### Test 3 : Endurance (4 heures)
|
||||||
|
|
||||||
|
**Objectif** : Stabilité longue durée.
|
||||||
|
|
||||||
|
**Métriques** :
|
||||||
|
- Pas de memory leak (RAM stable)
|
||||||
|
- Pas de crash
|
||||||
|
- Reconnexion automatique si perte WiFi
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting Production
|
||||||
|
|
||||||
|
### Problème : Latence élevée (> 200ms)
|
||||||
|
|
||||||
|
**Diagnostics** :
|
||||||
|
```bash
|
||||||
|
# Latence réseau (ping)
|
||||||
|
ping -i 0.2 serveur # < 5ms attendu en WiFi local
|
||||||
|
|
||||||
|
# Traceroute
|
||||||
|
traceroute serveur
|
||||||
|
|
||||||
|
# Jitter
|
||||||
|
iperf3 -c serveur -u -b 1M # Jitter < 5ms
|
||||||
|
```
|
||||||
|
|
||||||
|
**Causes possibles** :
|
||||||
|
- WiFi congestionné (trop de clients/AP)
|
||||||
|
- Buffer JACK trop grand
|
||||||
|
- Jitter buffer PTT Live trop conservateur
|
||||||
|
- CPU serveur saturé
|
||||||
|
|
||||||
|
**Solutions** :
|
||||||
|
- Réduire buffer JACK : 256 → 128 samples
|
||||||
|
- PTT Live jitter buffer : preset "ULTRA_LOW"
|
||||||
|
- Ajouter un AP WiFi (répartir charge)
|
||||||
|
|
||||||
|
### Problème : Coupures audio
|
||||||
|
|
||||||
|
**Diagnostics** :
|
||||||
|
```bash
|
||||||
|
# JACK xruns
|
||||||
|
jack_evmon
|
||||||
|
|
||||||
|
# Logs PTT Live
|
||||||
|
sudo journalctl -u pttlive-server -f | grep -i error
|
||||||
|
|
||||||
|
# Stats réseau
|
||||||
|
iftop -i eth0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Causes** :
|
||||||
|
- Xruns JACK (CPU overload)
|
||||||
|
- Packet loss réseau
|
||||||
|
- Buffer underrun
|
||||||
|
|
||||||
|
**Solutions** :
|
||||||
|
- Augmenter buffer JACK : 256 → 512
|
||||||
|
- Vérifier trafic réseau (pas de broadcast storm)
|
||||||
|
- Isoler CPU cores (kernel parameter `isolcpus=2,3`)
|
||||||
|
|
||||||
|
### Problème : Clients ne se connectent pas
|
||||||
|
|
||||||
|
**Diagnostics** :
|
||||||
|
```bash
|
||||||
|
# Firewall
|
||||||
|
sudo ufw status
|
||||||
|
|
||||||
|
# Ports LiveKit
|
||||||
|
sudo netstat -tulpn | grep 7880
|
||||||
|
|
||||||
|
# Logs LiveKit
|
||||||
|
sudo journalctl -u pttlive-server | grep livekit
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions** :
|
||||||
|
- Vérifier firewall (ports 7880, 50000-60000)
|
||||||
|
- Vérifier clés API (`.env` correct)
|
||||||
|
- Tester en local : `curl http://localhost:3000/api/health`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
### HTTPS (obligatoire pour PWA)
|
||||||
|
|
||||||
|
#### Certificat auto-signé (dev/LAN)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Générer certificat
|
||||||
|
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
|
||||||
|
|
||||||
|
# Configurer Node.js (serveur API)
|
||||||
|
# Éditer server/index.js
|
||||||
|
import https from 'https';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
key: fs.readFileSync('key.pem'),
|
||||||
|
cert: fs.readFileSync('cert.pem')
|
||||||
|
};
|
||||||
|
|
||||||
|
https.createServer(options, app).listen(3443);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Certificat Let's Encrypt (production Internet)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install certbot
|
||||||
|
|
||||||
|
# Domaine public requis
|
||||||
|
sudo certbot certonly --standalone -d pttlive.votredomaine.com
|
||||||
|
|
||||||
|
# Certificats dans /etc/letsencrypt/live/pttlive.votredomaine.com/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Authentification
|
||||||
|
|
||||||
|
#### Tokens JWT
|
||||||
|
|
||||||
|
Éditer `server/api/auth.js` :
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
const SECRET = process.env.JWT_SECRET; // Générer avec openssl rand -base64 64
|
||||||
|
|
||||||
|
function generateToken(user) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ id: user.id, name: user.name, groups: user.groups },
|
||||||
|
SECRET,
|
||||||
|
{ expiresIn: '24h' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(req, res, next) {
|
||||||
|
const token = req.headers.authorization?.split(' ')[1];
|
||||||
|
if (!token) return res.status(401).json({ error: 'No token' });
|
||||||
|
|
||||||
|
jwt.verify(token, SECRET, (err, decoded) => {
|
||||||
|
if (err) return res.status(403).json({ error: 'Invalid token' });
|
||||||
|
req.user = decoded;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist Pré-Événement
|
||||||
|
|
||||||
|
### 24h avant
|
||||||
|
|
||||||
|
- [ ] Mise à jour système serveur (`apt update && apt upgrade`)
|
||||||
|
- [ ] Vérifier espace disque (`df -h`, > 20% libre)
|
||||||
|
- [ ] Test connexion tous les équipements audio
|
||||||
|
- [ ] Survey WiFi (vérifier pas d'interférences)
|
||||||
|
- [ ] Backup config (`cp -r /opt/PTT-Live/server/config /backup/`)
|
||||||
|
|
||||||
|
### 2h avant
|
||||||
|
|
||||||
|
- [ ] Démarrer serveur PTT Live
|
||||||
|
- [ ] Vérifier logs (`journalctl -u pttlive-server`)
|
||||||
|
- [ ] Test connexion 2 clients (1 par groupe minimum)
|
||||||
|
- [ ] Test PTT bidirectionnel
|
||||||
|
- [ ] Mesurer latence (< 150ms)
|
||||||
|
- [ ] Charger smartphones clients (100% batterie)
|
||||||
|
|
||||||
|
### Pendant l'événement
|
||||||
|
|
||||||
|
- [ ] Monitoring CPU/RAM (Grafana ou `htop`)
|
||||||
|
- [ ] Logs temps réel (`journalctl -f`)
|
||||||
|
- [ ] Laptop admin disponible (SSH serveur)
|
||||||
|
- [ ] Smartphone de secours (backup PTT)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performances Attendues
|
||||||
|
|
||||||
|
### Charge Serveur (30 clients)
|
||||||
|
|
||||||
|
| Métrique | Valeur Typique |
|
||||||
|
|----------|----------------|
|
||||||
|
| CPU Usage | 30-50% (8 cores) |
|
||||||
|
| RAM Usage | 4-6 GB |
|
||||||
|
| Network RX | 5-10 Mbps (upstream audio) |
|
||||||
|
| Network TX | 50-150 Mbps (downstream audio broadcast) |
|
||||||
|
| JACK Xruns | 0 (toléré : < 1/heure) |
|
||||||
|
|
||||||
|
### Latence End-to-End
|
||||||
|
|
||||||
|
| Composant | Latence |
|
||||||
|
|-----------|---------|
|
||||||
|
| WiFi (client → serveur) | 5-20 ms |
|
||||||
|
| WebRTC encode/decode | 20-60 ms |
|
||||||
|
| Jitter buffer | 20-40 ms |
|
||||||
|
| Audio backend (JACK/CoreAudio) | 5-10 ms |
|
||||||
|
| Dante/AES67 (si utilisé) | 5-10 ms |
|
||||||
|
| **TOTAL** | **55-140 ms** ✅ |
|
||||||
|
|
||||||
|
Objectif validé : < 150ms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support et Ressources
|
||||||
|
|
||||||
|
- **Documentation** : `/opt/PTT-Live/docs/`
|
||||||
|
- **Issues GitHub** : https://github.com/votre-user/ptt-live/issues
|
||||||
|
- **LiveKit Docs** : https://docs.livekit.io/
|
||||||
|
- **JACK Audio** : https://jackaudio.org/faq/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Dernière mise à jour** : 2026-05-26
|
||||||
|
**Version** : 0.1.0 (Phase 3)
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
# LiveKit Audio Bridge - Intégration Cartes Son macOS
|
||||||
|
|
||||||
|
Guide pour connecter les cartes son macOS au serveur LiveKit via le bridge audio.
|
||||||
|
|
||||||
|
## Problème Actuel
|
||||||
|
|
||||||
|
Le code actuel utilise `livekit-client` (SDK navigateur) qui nécessite des `MediaStreamTrack` (API Web Audio). Sur Node.js serveur, nous avons des **buffers PCM** provenant de CoreAudio/JACK, pas de MediaStream.
|
||||||
|
|
||||||
|
### Architecture Actuelle (Incomplète)
|
||||||
|
|
||||||
|
```
|
||||||
|
[Carte Son macOS] → CoreAudio → PCM Buffer → OpusCodec → ??? → LiveKit → Clients WebRTC
|
||||||
|
↑
|
||||||
|
MANQUANT
|
||||||
|
```
|
||||||
|
|
||||||
|
## Solution : Utiliser LiveKit Server SDK
|
||||||
|
|
||||||
|
LiveKit propose 2 SDKs :
|
||||||
|
- **livekit-client** : Pour navigateurs (MediaStream, WebRTC natif)
|
||||||
|
- **livekit-server-sdk** : Pour serveurs Node.js (contrôle bas niveau)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm install livekit-server-sdk
|
||||||
|
npm install @livekit/rtc-node # Bindings natifs pour audio/video
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implémentation : LiveKitServerBridge.js
|
||||||
|
|
||||||
|
Créer un nouveau module pour le bridge serveur :
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// server/bridge/LiveKitServerBridge.js
|
||||||
|
|
||||||
|
import { RoomServiceClient, AccessToken, TrackSource } from 'livekit-server-sdk';
|
||||||
|
import { Room, LocalAudioTrack, AudioSource } from '@livekit/rtc-node';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
export class LiveKitServerBridge extends EventEmitter {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.options = {
|
||||||
|
url: options.url || 'ws://localhost:7880',
|
||||||
|
apiKey: options.apiKey || 'APIxxxxxx',
|
||||||
|
apiSecret: options.apiSecret || 'SECRETxxxxxx',
|
||||||
|
roomName: options.roomName || 'main',
|
||||||
|
participantName: options.participantName || 'AudioBridge',
|
||||||
|
sampleRate: options.sampleRate || 48000,
|
||||||
|
channels: options.channels || 1,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.room = null;
|
||||||
|
this.audioSource = null;
|
||||||
|
this.audioTrack = null;
|
||||||
|
this.isPublishing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connexion à la room LiveKit en tant que participant serveur
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
try {
|
||||||
|
// Générer token pour le bridge
|
||||||
|
const token = new AccessToken(
|
||||||
|
this.options.apiKey,
|
||||||
|
this.options.apiSecret,
|
||||||
|
{
|
||||||
|
identity: this.options.participantName,
|
||||||
|
name: 'Audio Bridge Server',
|
||||||
|
ttl: '24h'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
token.addGrant({
|
||||||
|
room: this.options.roomName,
|
||||||
|
roomJoin: true,
|
||||||
|
canPublish: true,
|
||||||
|
canSubscribe: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const jwt = token.toJwt();
|
||||||
|
|
||||||
|
// Connexion à la room
|
||||||
|
this.room = new Room();
|
||||||
|
await this.room.connect(this.options.url, jwt);
|
||||||
|
|
||||||
|
console.log(`✓ Bridge connecté à LiveKit room "${this.options.roomName}"`);
|
||||||
|
this.emit('connected');
|
||||||
|
|
||||||
|
// Écouter les participants distants
|
||||||
|
this._setupRoomListeners();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur connexion LiveKit:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Créer et publier un track audio depuis la carte son
|
||||||
|
*/
|
||||||
|
async publishAudioTrack() {
|
||||||
|
if (!this.room) {
|
||||||
|
throw new Error('Room non connectée');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Créer une source audio custom
|
||||||
|
this.audioSource = new AudioSource(
|
||||||
|
this.options.sampleRate,
|
||||||
|
this.options.channels
|
||||||
|
);
|
||||||
|
|
||||||
|
// Créer un track audio local
|
||||||
|
this.audioTrack = LocalAudioTrack.createAudioTrack(
|
||||||
|
'bridge-audio',
|
||||||
|
this.audioSource
|
||||||
|
);
|
||||||
|
|
||||||
|
// Publier le track dans la room
|
||||||
|
await this.room.localParticipant.publishTrack(this.audioTrack, {
|
||||||
|
source: TrackSource.MICROPHONE,
|
||||||
|
name: 'Audio Bridge'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.isPublishing = true;
|
||||||
|
console.log('✓ Track audio bridge publié');
|
||||||
|
this.emit('trackPublished');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur publication track:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie des données PCM au track LiveKit
|
||||||
|
* @param {Buffer} pcmData - Buffer PCM 16-bit (depuis CoreAudio/JACK)
|
||||||
|
*/
|
||||||
|
async sendPCMAudio(pcmData) {
|
||||||
|
if (!this.audioSource || !this.isPublishing) {
|
||||||
|
console.warn('AudioSource non prête ou track non publié');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convertir Buffer Node.js → AudioFrame
|
||||||
|
// PCM 16-bit signed little-endian
|
||||||
|
const numSamples = pcmData.length / 2; // 2 bytes per sample (16-bit)
|
||||||
|
|
||||||
|
// Envoyer au track LiveKit
|
||||||
|
await this.audioSource.captureFrame({
|
||||||
|
data: pcmData,
|
||||||
|
sampleRate: this.options.sampleRate,
|
||||||
|
numChannels: this.options.channels,
|
||||||
|
samplesPerChannel: numSamples / this.options.channels
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur envoi PCM:', error);
|
||||||
|
this.emit('error', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écoute les participants et leurs tracks audio
|
||||||
|
*/
|
||||||
|
_setupRoomListeners() {
|
||||||
|
this.room.on('participantConnected', (participant) => {
|
||||||
|
console.log(`Participant connecté: ${participant.identity}`);
|
||||||
|
this.emit('participantConnected', participant);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.room.on('trackSubscribed', (track, publication, participant) => {
|
||||||
|
if (track.kind === 'audio') {
|
||||||
|
console.log(`Track audio reçu de ${participant.identity}`);
|
||||||
|
this._handleRemoteAudioTrack(track, participant);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.room.on('trackUnsubscribed', (track, publication, participant) => {
|
||||||
|
if (track.kind === 'audio') {
|
||||||
|
console.log(`Track audio perdu de ${participant.identity}`);
|
||||||
|
this.emit('audioTrackUnsubscribed', { track, participant });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gère la réception d'un track audio distant (client PWA)
|
||||||
|
* @param {RemoteAudioTrack} track - Track audio du client
|
||||||
|
*/
|
||||||
|
_handleRemoteAudioTrack(track, participant) {
|
||||||
|
// Recevoir les frames audio
|
||||||
|
track.on('frame', async (frame) => {
|
||||||
|
// frame contient les données PCM du client
|
||||||
|
// On peut les envoyer à la carte son via CoreAudio/JACK
|
||||||
|
this.emit('remotePCMData', {
|
||||||
|
data: frame.data,
|
||||||
|
sampleRate: frame.sampleRate,
|
||||||
|
channels: frame.numChannels,
|
||||||
|
participant
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit('audioTrackSubscribed', { track, participant });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrête la publication du track audio
|
||||||
|
*/
|
||||||
|
async unpublishAudioTrack() {
|
||||||
|
if (this.audioTrack) {
|
||||||
|
await this.room.localParticipant.unpublishTrack(this.audioTrack);
|
||||||
|
this.audioTrack = null;
|
||||||
|
this.audioSource = null;
|
||||||
|
this.isPublishing = false;
|
||||||
|
console.log('✓ Track audio dépublié');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déconnexion de la room
|
||||||
|
*/
|
||||||
|
async disconnect() {
|
||||||
|
await this.unpublishAudioTrack();
|
||||||
|
|
||||||
|
if (this.room) {
|
||||||
|
await this.room.disconnect();
|
||||||
|
this.room = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✓ Bridge LiveKit déconnecté');
|
||||||
|
this.emit('disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les statistiques
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
if (!this.room) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: !!this.room,
|
||||||
|
publishing: this.isPublishing,
|
||||||
|
participants: this.room.remoteParticipants.size,
|
||||||
|
roomName: this.options.roomName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LiveKitServerBridge;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mise à Jour AudioBridge.js
|
||||||
|
|
||||||
|
Remplacer `LiveKitClient` par `LiveKitServerBridge` :
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// server/bridge/AudioBridge.js
|
||||||
|
|
||||||
|
import LiveKitServerBridge from './LiveKitServerBridge.js';
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
async _initLiveKit() {
|
||||||
|
this.liveKitClient = new LiveKitServerBridge({
|
||||||
|
url: this.options.liveKitUrl,
|
||||||
|
apiKey: this.options.liveKitApiKey,
|
||||||
|
apiSecret: this.options.liveKitApiSecret,
|
||||||
|
roomName: this.options.roomName,
|
||||||
|
sampleRate: this.options.sampleRate,
|
||||||
|
channels: this.options.channels
|
||||||
|
});
|
||||||
|
|
||||||
|
// Events
|
||||||
|
this.liveKitClient.on('connected', () => {
|
||||||
|
console.log('✓ Bridge LiveKit connecté');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.liveKitClient.on('audioTrackSubscribed', ({ track, participant }) => {
|
||||||
|
console.log(`Audio reçu de ${participant.identity}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.liveKitClient.on('remotePCMData', ({ data, participant }) => {
|
||||||
|
// Envoyer PCM à la carte son
|
||||||
|
this.audioBackend.queueAudio(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.liveKitClient.connect();
|
||||||
|
await this.liveKitClient.publishAudioTrack();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _startAudioRouting() {
|
||||||
|
// CAPTURE : Carte son → LiveKit
|
||||||
|
this.audioBackend.on('audioData', async (pcmData) => {
|
||||||
|
try {
|
||||||
|
// Envoyer directement le PCM à LiveKit
|
||||||
|
// LiveKit gère l'encodage Opus en interne
|
||||||
|
await this.liveKitClient.sendPCMAudio(pcmData);
|
||||||
|
|
||||||
|
this.stats.framesCapture++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur routing capture:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audioBackend.startCapture();
|
||||||
|
await this.audioBackend.startPlayback();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Serveur
|
||||||
|
|
||||||
|
### Variables d'environnement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# server/.env
|
||||||
|
LIVEKIT_API_KEY=APIxxxxxxxxxxxxxx
|
||||||
|
LIVEKIT_API_SECRET=SECRETxxxxxxxxxxxxxx
|
||||||
|
LIVEKIT_URL=ws://localhost:7880
|
||||||
|
```
|
||||||
|
|
||||||
|
Générer les clés :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API Key (24 bytes base64)
|
||||||
|
openssl rand -base64 24
|
||||||
|
|
||||||
|
# API Secret (48 bytes base64)
|
||||||
|
openssl rand -base64 48
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration LiveKit Server
|
||||||
|
|
||||||
|
Éditer `server/config/livekit.yaml` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
port: 7880
|
||||||
|
rtc:
|
||||||
|
port_range_start: 50000
|
||||||
|
port_range_end: 60000
|
||||||
|
use_external_ip: false
|
||||||
|
|
||||||
|
keys:
|
||||||
|
# Utiliser les mêmes clés que .env
|
||||||
|
APIxxxxxxxxxxxxxx: SECRETxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative : Sans @livekit/rtc-node (Pure JavaScript)
|
||||||
|
|
||||||
|
Si l'installation de bindings natifs pose problème, utiliser **DataChannel** pour envoyer les données Opus :
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// server/bridge/LiveKitDataBridge.js
|
||||||
|
|
||||||
|
import { RoomServiceClient, DataPacket_Kind } from 'livekit-server-sdk';
|
||||||
|
|
||||||
|
export class LiveKitDataBridge {
|
||||||
|
async sendOpusData(opusData, groupId) {
|
||||||
|
// Envoyer via DataChannel
|
||||||
|
const packet = {
|
||||||
|
kind: DataPacket_Kind.RELIABLE,
|
||||||
|
destinationSids: [], // Broadcast à tous
|
||||||
|
payload: opusData,
|
||||||
|
topic: `audio-${groupId}`
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.room.localParticipant.publishData(
|
||||||
|
packet.payload,
|
||||||
|
packet.kind,
|
||||||
|
packet.destinationSids
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avantage** : Pas de bindings natifs.
|
||||||
|
**Inconvénient** : Les clients doivent décoder Opus manuellement (pas de lecture audio automatique).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests macOS
|
||||||
|
|
||||||
|
### 1. Vérifier carte son détectée
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
node -e "
|
||||||
|
import CoreAudioBackend from './bridge/backends/CoreAudioBackend.js';
|
||||||
|
const devices = CoreAudioBackend.getDevices();
|
||||||
|
console.log(devices);
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Test bridge complet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 : Serveur LiveKit
|
||||||
|
cd server/bin
|
||||||
|
./livekit-server --dev --config ../config/livekit.yaml
|
||||||
|
|
||||||
|
# Terminal 2 : Bridge audio
|
||||||
|
cd server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Terminal 3 : Client test
|
||||||
|
cd client
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvrir `http://localhost:5173`, se connecter et appuyer sur PTT.
|
||||||
|
|
||||||
|
### 3. Vérifier flux audio
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Logs bridge
|
||||||
|
tail -f server/logs/bridge.log | grep "sendPCMAudio"
|
||||||
|
|
||||||
|
# Devrait afficher :
|
||||||
|
# sendPCMAudio: 960 samples @ 48000Hz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compatibilité Cartes Son macOS
|
||||||
|
|
||||||
|
### Cartes testées
|
||||||
|
|
||||||
|
| Modèle | Statut | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| MacBook Pro Mic/Speaker | ✅ | Native CoreAudio |
|
||||||
|
| Focusrite Scarlett 2i2 | ✅ | USB Class Compliant |
|
||||||
|
| MOTU UltraLite mk5 | ✅ | USB-C, 18x22 canaux |
|
||||||
|
| RME Fireface UCX | ✅ | USB 2.0/3.0 |
|
||||||
|
| Audient iD14 | ✅ | USB-C |
|
||||||
|
| Universal Audio Apollo | ⚠️ | Nécessite pilotes UA |
|
||||||
|
| PreSonus Studio 24c | ✅ | USB-C |
|
||||||
|
|
||||||
|
### Problèmes courants
|
||||||
|
|
||||||
|
**Carte non détectée** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vérifier MIDI/Audio Setup
|
||||||
|
open /System/Applications/Utilities/Audio\ MIDI\ Setup.app
|
||||||
|
|
||||||
|
# Vérifier sample rate
|
||||||
|
system_profiler SPAudioDataType
|
||||||
|
```
|
||||||
|
|
||||||
|
**Latence élevée** :
|
||||||
|
|
||||||
|
Réduire `framesPerBuffer` dans `config.yaml` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
audio:
|
||||||
|
framesPerBuffer: 128 # Au lieu de 256 ou 512
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prochaines Étapes
|
||||||
|
|
||||||
|
1. ✅ Installer `@livekit/rtc-node`
|
||||||
|
2. ✅ Créer `LiveKitServerBridge.js`
|
||||||
|
3. ✅ Remplacer dans `AudioBridge.js`
|
||||||
|
4. ✅ Configurer `.env` avec clés LiveKit
|
||||||
|
5. ⏳ Tester avec carte son macOS réelle
|
||||||
|
6. ⏳ Mesurer latence end-to-end (objectif < 150ms)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Dernière mise à jour** : 2026-05-26
|
||||||
|
**Version** : 0.1.0 (Phase 3+)
|
||||||
@@ -0,0 +1,854 @@
|
|||||||
|
# Guide de Troubleshooting - PTT Live
|
||||||
|
|
||||||
|
Guide de diagnostic et résolution des problèmes courants.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table des matières
|
||||||
|
|
||||||
|
1. [Problèmes Audio](#problèmes-audio)
|
||||||
|
2. [Problèmes Réseau](#problèmes-réseau)
|
||||||
|
3. [Problèmes Client (PWA)](#problèmes-client-pwa)
|
||||||
|
4. [Problèmes Serveur](#problèmes-serveur)
|
||||||
|
5. [Problèmes JACK/Audio Backend](#problèmes-jackaudio-backend)
|
||||||
|
6. [Problèmes Dante/AES67](#problèmes-danteaes67)
|
||||||
|
7. [Outils de Diagnostic](#outils-de-diagnostic)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problèmes Audio
|
||||||
|
|
||||||
|
### Pas de son (aucun audio)
|
||||||
|
|
||||||
|
#### Symptômes
|
||||||
|
- Client parle (bouton PTT activé) mais personne n'entend
|
||||||
|
- Pas d'indicateur audio visuel
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Vérifier backend audio actif
|
||||||
|
sudo journalctl -u pttlive-server | grep "Backend audio"
|
||||||
|
# Devrait afficher : "✓ Backend audio : CoreAudio/JACK/PipeWire"
|
||||||
|
|
||||||
|
# 2. Vérifier capture audio fonctionne
|
||||||
|
# macOS
|
||||||
|
system_profiler SPAudioDataType | grep "Default Input"
|
||||||
|
|
||||||
|
# Linux avec JACK
|
||||||
|
jack_lsp | grep capture
|
||||||
|
|
||||||
|
# 3. Vérifier LiveKit connecté
|
||||||
|
sudo journalctl -u pttlive-server | grep LiveKit
|
||||||
|
# Devrait afficher : "✓ LiveKit connecté"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solutions
|
||||||
|
|
||||||
|
**Cause : Microphone non autorisé (navigateur)**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Ouvrir les paramètres du navigateur
|
||||||
|
2. Site Settings → pttlive.local → Permissions
|
||||||
|
3. Microphone : Allow
|
||||||
|
4. Rafraîchir la page
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause : Backend audio non démarré**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# JACK (Linux)
|
||||||
|
jackd -d alsa -r 48000 -p 256
|
||||||
|
|
||||||
|
# PipeWire (Linux)
|
||||||
|
systemctl --user start pipewire pipewire-pulse
|
||||||
|
|
||||||
|
# CoreAudio (macOS) : déjà natif, vérifier carte son branchée
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause : Routing JACK manquant**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vérifier connexions
|
||||||
|
jack_lsp -c
|
||||||
|
|
||||||
|
# Reconnecter manuellement
|
||||||
|
jack_connect "system:capture_1" "PTTLive:input_1"
|
||||||
|
jack_connect "PTTLive:output_1" "system:playback_1"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Latence élevée (> 200ms)
|
||||||
|
|
||||||
|
#### Symptômes
|
||||||
|
- Délai perceptible entre parole et réception
|
||||||
|
- Conversations difficiles (effet "satellite")
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Mesurer latence réseau (ping)
|
||||||
|
ping -i 0.2 serveur_ip
|
||||||
|
# Devrait être < 10ms en LAN
|
||||||
|
|
||||||
|
# 2. Vérifier jitter
|
||||||
|
iperf3 -c serveur_ip -u -b 1M
|
||||||
|
# Jitter devrait être < 5ms
|
||||||
|
|
||||||
|
# 3. Vérifier buffer JACK
|
||||||
|
jack_bufsize
|
||||||
|
# Typique : 256 samples = 5.3ms @ 48kHz
|
||||||
|
|
||||||
|
# 4. Logs PTT Live
|
||||||
|
sudo journalctl -u pttlive-server | grep latency
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solutions
|
||||||
|
|
||||||
|
**Réduire buffer JACK** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Arrêter JACK
|
||||||
|
killall jackd
|
||||||
|
|
||||||
|
# Redémarrer avec buffer plus petit
|
||||||
|
jackd -d alsa -r 48000 -p 128 # 128 au lieu de 256
|
||||||
|
|
||||||
|
# ⚠️ Risque de xruns si CPU faible
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optimiser jitter buffer PTT Live** :
|
||||||
|
|
||||||
|
Éditer `server/config/config.yaml` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
audio:
|
||||||
|
jitterBufferPreset: ULTRA_LOW # Au lieu de LOW_LATENCY
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optimiser WiFi** :
|
||||||
|
- Forcer 5GHz (pas de 2.4GHz)
|
||||||
|
- Réduire nombre de clients par AP (< 15)
|
||||||
|
- Vérifier channel WiFi pas surchargé (scanner WiFi)
|
||||||
|
|
||||||
|
**Budget latence typique** :
|
||||||
|
|
||||||
|
| Composant | Latence |
|
||||||
|
|-----------|---------|
|
||||||
|
| WiFi | 5-20 ms |
|
||||||
|
| WebRTC encode/decode | 20-60 ms |
|
||||||
|
| Jitter buffer | 20-40 ms |
|
||||||
|
| JACK/backend | 5-10 ms |
|
||||||
|
| **Total** | 50-130 ms ✅ |
|
||||||
|
|
||||||
|
Si > 200ms, problème réseau probable (WiFi congestionné ou mauvaise couverture).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Coupures audio (audio haché)
|
||||||
|
|
||||||
|
#### Symptômes
|
||||||
|
- Son qui coupe régulièrement
|
||||||
|
- Craquements/pops
|
||||||
|
- Audio en "robot"
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. JACK xruns
|
||||||
|
jack_evmon
|
||||||
|
# Appuyer Ctrl+C après 30s et noter le nombre de xruns
|
||||||
|
# 0 xrun = OK
|
||||||
|
# > 5 xruns/min = problème CPU ou buffer trop petit
|
||||||
|
|
||||||
|
# 2. CPU usage
|
||||||
|
htop
|
||||||
|
# CPU > 90% = surchargé
|
||||||
|
|
||||||
|
# 3. Packet loss WebRTC
|
||||||
|
# Ouvrir navigateur client : chrome://webrtc-internals
|
||||||
|
# Chercher "packetsLost" : devrait être < 1%
|
||||||
|
|
||||||
|
# 4. Logs backend
|
||||||
|
sudo journalctl -u pttlive-server | grep -i "underrun\|overrun"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solutions
|
||||||
|
|
||||||
|
**Xruns JACK (CPU overload)** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Augmenter buffer size
|
||||||
|
jackd -d alsa -r 48000 -p 512 # 512 au lieu de 256
|
||||||
|
|
||||||
|
# Priorité real-time JACK
|
||||||
|
sudo jackd -R -P 70 -d alsa -r 48000 -p 256
|
||||||
|
|
||||||
|
# Isoler CPU cores
|
||||||
|
# Éditer /etc/default/grub :
|
||||||
|
GRUB_CMDLINE_LINUX="isolcpus=2,3"
|
||||||
|
# Puis : sudo update-grub && sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
**Packet loss réseau** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vérifier trafic réseau
|
||||||
|
iftop -i eth0
|
||||||
|
|
||||||
|
# Tester bande passante
|
||||||
|
iperf3 -c serveur_ip
|
||||||
|
# Devrait être > 100 Mbps en Gigabit
|
||||||
|
|
||||||
|
# Vérifier switch (pas de collisions)
|
||||||
|
ethtool eth0 | grep -i error
|
||||||
|
```
|
||||||
|
|
||||||
|
**Codec Opus agressif** :
|
||||||
|
|
||||||
|
Réduire le bitrate Opus :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# server/config/config.yaml
|
||||||
|
groups:
|
||||||
|
- id: regie
|
||||||
|
opusBitrate: 64000 # 64kbps au lieu de 96kbps
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Audio en mono alors que stéréo attendu
|
||||||
|
|
||||||
|
#### Cause
|
||||||
|
|
||||||
|
Configuration channels à 1 au lieu de 2.
|
||||||
|
|
||||||
|
#### Solution
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# server/config/config.yaml
|
||||||
|
audio:
|
||||||
|
channels: 2 # Stéréo
|
||||||
|
```
|
||||||
|
|
||||||
|
Redémarrer serveur :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart pttlive-server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problèmes Réseau
|
||||||
|
|
||||||
|
### Clients ne peuvent pas se connecter
|
||||||
|
|
||||||
|
#### Symptômes
|
||||||
|
- Erreur "Connection failed" dans le client
|
||||||
|
- Timeout lors de la connexion LiveKit
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Serveur écoute sur le bon port ?
|
||||||
|
sudo netstat -tulpn | grep 7880
|
||||||
|
# Devrait afficher : tcp 0.0.0.0:7880 LISTEN
|
||||||
|
|
||||||
|
# 2. Firewall bloque ?
|
||||||
|
sudo ufw status
|
||||||
|
# Ports requis : 7880, 7881, 50000-60000
|
||||||
|
|
||||||
|
# 3. Client peut ping serveur ?
|
||||||
|
# Sur smartphone/laptop client :
|
||||||
|
ping serveur_ip
|
||||||
|
|
||||||
|
# 4. Test WebSocket
|
||||||
|
# Sur client, ouvrir console navigateur :
|
||||||
|
new WebSocket('ws://serveur_ip:7880')
|
||||||
|
# Si erreur 404 ou timeout = problème réseau/firewall
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solutions
|
||||||
|
|
||||||
|
**Ouvrir ports firewall** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 7880/tcp
|
||||||
|
sudo ufw allow 7881/tcp
|
||||||
|
sudo ufw allow 50000:60000/udp
|
||||||
|
sudo ufw reload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vérifier LiveKit démarre** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u pttlive-server | grep -i livekit
|
||||||
|
# Chercher "LiveKit server started"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tester en local** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sur le serveur lui-même
|
||||||
|
curl http://localhost:3000/api/health
|
||||||
|
# Devrait répondre : {"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Perte de connexion WiFi fréquente
|
||||||
|
|
||||||
|
#### Symptômes
|
||||||
|
- Clients se déconnectent toutes les 1-5 minutes
|
||||||
|
- Reconnexion automatique ou manuelle requise
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sur l'Access Point (exemple UniFi)
|
||||||
|
# SSH vers AP
|
||||||
|
ssh ubnt@ap_ip
|
||||||
|
|
||||||
|
# Vérifier logs
|
||||||
|
tail -f /var/log/messages | grep -i disassoc
|
||||||
|
|
||||||
|
# Statistiques WiFi
|
||||||
|
iwconfig wlan0
|
||||||
|
# Chercher "Signal level" : devrait être > -70 dBm
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solutions
|
||||||
|
|
||||||
|
**Roaming WiFi agressif** :
|
||||||
|
|
||||||
|
Activer Fast Roaming (802.11r/k/v) sur les Access Points.
|
||||||
|
|
||||||
|
**Channel congestionné** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scanner WiFi
|
||||||
|
sudo iwlist wlan0 scan | grep -E "Channel|ESSID|Quality"
|
||||||
|
|
||||||
|
# Choisir un channel libre (5GHz : 36, 40, 44, 48, 149, 153, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Signal faible** :
|
||||||
|
|
||||||
|
- Ajouter un Access Point (couverture)
|
||||||
|
- Repositionner AP existant (hauteur, line-of-sight)
|
||||||
|
- Vérifier puissance TX AP (pas trop faible)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problèmes Client (PWA)
|
||||||
|
|
||||||
|
### Bouton PTT ne fonctionne pas (mobile)
|
||||||
|
|
||||||
|
#### Symptômes
|
||||||
|
- Appui sur bouton PTT ne fait rien
|
||||||
|
- Pas de vibration/feedback
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Console navigateur mobile (via Remote Debug)
|
||||||
|
// Chrome Android : chrome://inspect
|
||||||
|
// Safari iOS : Safari Desktop > Develop > iPhone
|
||||||
|
|
||||||
|
// Tester événement touch
|
||||||
|
document.getElementById('ptt-button').addEventListener('touchstart', (e) => {
|
||||||
|
console.log('Touch start:', e);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solutions
|
||||||
|
|
||||||
|
**HTTPS requis** :
|
||||||
|
|
||||||
|
Les APIs Web modernes (microphone, vibration) nécessitent HTTPS.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Générer certificat auto-signé
|
||||||
|
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes
|
||||||
|
|
||||||
|
# Configurer serveur HTTPS (voir DEPLOYMENT.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
Accéder via `https://serveur_ip` (accepter certificat dans navigateur).
|
||||||
|
|
||||||
|
**Microphone non débloqué (iOS)** :
|
||||||
|
|
||||||
|
Sur iOS, l'audio nécessite une interaction utilisateur.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Ajouter un bouton "Unlock Audio" au premier lancement
|
||||||
|
async function unlockAudio() {
|
||||||
|
const audio = new Audio();
|
||||||
|
await audio.play();
|
||||||
|
audio.pause();
|
||||||
|
console.log('Audio unlocked');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PWA ne s'installe pas (iOS)
|
||||||
|
|
||||||
|
#### Symptômes
|
||||||
|
- Bouton "Add to Home Screen" absent
|
||||||
|
- Pas de popup d'installation
|
||||||
|
|
||||||
|
#### Cause
|
||||||
|
|
||||||
|
Sur iOS, l'installation PWA est **manuelle** (pas de prompt automatique).
|
||||||
|
|
||||||
|
#### Solution
|
||||||
|
|
||||||
|
Afficher un message d'aide :
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Détecter iOS
|
||||||
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||||
|
|
||||||
|
if (isIOS) {
|
||||||
|
// Afficher instructions
|
||||||
|
alert(`Pour installer PTT Live :
|
||||||
|
1. Appuyez sur le bouton Partage (⬆️)
|
||||||
|
2. Sélectionnez "Sur l'écran d'accueil"
|
||||||
|
3. Appuyez sur "Ajouter"`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Notifications Web Push ne fonctionnent pas
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Console navigateur
|
||||||
|
if ('Notification' in window) {
|
||||||
|
console.log('Notification permission:', Notification.permission);
|
||||||
|
// granted = OK
|
||||||
|
// denied = utilisateur a refusé
|
||||||
|
// default = pas encore demandé
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.ready.then(reg => {
|
||||||
|
console.log('Service Worker:', reg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solutions
|
||||||
|
|
||||||
|
**Permissions non accordées** :
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function requestNotificationPermission() {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission === 'granted') {
|
||||||
|
console.log('Notifications autorisées');
|
||||||
|
} else {
|
||||||
|
alert('Veuillez autoriser les notifications dans les paramètres du navigateur');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Service Worker non enregistré** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vérifier fichier sw.js existe
|
||||||
|
ls client/public/sw.js
|
||||||
|
|
||||||
|
# Vérifier enregistrement dans main.js
|
||||||
|
grep -r "serviceWorker.register" client/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problèmes Serveur
|
||||||
|
|
||||||
|
### Serveur ne démarre pas
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Logs détaillés
|
||||||
|
sudo journalctl -u pttlive-server -n 100 --no-pager
|
||||||
|
|
||||||
|
# Vérifier port 3000 pas déjà utilisé
|
||||||
|
sudo lsof -i :3000
|
||||||
|
# Si occupé, tuer le processus ou changer le port
|
||||||
|
|
||||||
|
# Vérifier Node.js version
|
||||||
|
node --version # Devrait être >= 18
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solutions
|
||||||
|
|
||||||
|
**Port déjà utilisé** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tuer processus existant
|
||||||
|
sudo kill $(sudo lsof -t -i:3000)
|
||||||
|
|
||||||
|
# Ou changer port dans .env
|
||||||
|
echo "PORT=3001" >> server/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dépendances manquantes** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
**Permissions audio (Linux)** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ajouter utilisateur au groupe audio
|
||||||
|
sudo usermod -a -G audio $USER
|
||||||
|
|
||||||
|
# Reboot requis
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Crash serveur après quelques heures (memory leak)
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Surveiller RAM
|
||||||
|
watch -n 1 free -h
|
||||||
|
|
||||||
|
# Logs avant crash
|
||||||
|
sudo journalctl -u pttlive-server --since "1 hour ago" | grep -i error
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solutions
|
||||||
|
|
||||||
|
**Limiter RAM dans systemd** :
|
||||||
|
|
||||||
|
Éditer `/etc/systemd/system/pttlive-server.service` :
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Service]
|
||||||
|
MemoryLimit=4G
|
||||||
|
MemoryMax=4G
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl restart pttlive-server
|
||||||
|
```
|
||||||
|
|
||||||
|
**Garbage collection Node.js** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lancer Node avec options GC
|
||||||
|
node --max-old-space-size=2048 --expose-gc index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problèmes JACK/Audio Backend
|
||||||
|
|
||||||
|
### JACK ne démarre pas
|
||||||
|
|
||||||
|
#### Symptômes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jackd -d alsa -r 48000
|
||||||
|
# Erreur : "Cannot lock down memory area (Cannot allocate memory)"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vérifier limites memlock
|
||||||
|
ulimit -l
|
||||||
|
# Devrait être "unlimited"
|
||||||
|
|
||||||
|
# Vérifier utilisateur dans groupe audio
|
||||||
|
groups $USER
|
||||||
|
# Devrait contenir "audio"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solutions
|
||||||
|
|
||||||
|
**Configurer memlock** :
|
||||||
|
|
||||||
|
Éditer `/etc/security/limits.conf` :
|
||||||
|
|
||||||
|
```
|
||||||
|
@audio - memlock unlimited
|
||||||
|
@audio - rtprio 95
|
||||||
|
```
|
||||||
|
|
||||||
|
Reboot requis :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### JACK démarre mais pas de son
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ports JACK disponibles ?
|
||||||
|
jack_lsp
|
||||||
|
|
||||||
|
# Devrait afficher :
|
||||||
|
# system:capture_1
|
||||||
|
# system:playback_1
|
||||||
|
# PTTLive:input_1
|
||||||
|
# PTTLive:output_1
|
||||||
|
|
||||||
|
# Connexions actives ?
|
||||||
|
jack_lsp -c
|
||||||
|
|
||||||
|
# Devrait afficher des connexions
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connecter manuellement
|
||||||
|
jack_connect "system:capture_1" "PTTLive:input_1"
|
||||||
|
jack_connect "PTTLive:output_1" "system:playback_1"
|
||||||
|
|
||||||
|
# Ou utiliser QjackCtl (GUI)
|
||||||
|
qjackctl
|
||||||
|
# Cliquer "Graph" et faire les connexions visuellement
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problèmes Dante/AES67
|
||||||
|
|
||||||
|
### Dante Virtual Soundcard ne s'affiche pas dans Dante Controller
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS : DVS est-il démarré ?
|
||||||
|
ps aux | grep "Dante Virtual Soundcard"
|
||||||
|
|
||||||
|
# Firewall bloque Dante ?
|
||||||
|
# Dante utilise :
|
||||||
|
# - UDP 319, 320 (PTP)
|
||||||
|
# - UDP 4440, 4444, 4455 (Dante Discovery)
|
||||||
|
# - UDP 14336-14591 (Audio flows)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solutions
|
||||||
|
|
||||||
|
**Désactiver firewall temporairement** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate off
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
sudo ufw disable
|
||||||
|
```
|
||||||
|
|
||||||
|
Si ça fonctionne, ajouter des règles firewall spécifiques :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux
|
||||||
|
sudo ufw allow 319:320/udp
|
||||||
|
sudo ufw allow 4440:4455/udp
|
||||||
|
sudo ufw allow 14336:14591/udp
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vérifier réseau** :
|
||||||
|
|
||||||
|
- Même subnet que les équipements Dante (ex: 192.168.1.x/24)
|
||||||
|
- Branché sur le même switch
|
||||||
|
- IGMP snooping activé sur le switch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Latence Dante trop élevée (> 50ms)
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
Ouvrir **Dante Controller** :
|
||||||
|
|
||||||
|
1. Device View → Sélectionner DVS
|
||||||
|
2. Device Config → Dante tab
|
||||||
|
3. Vérifier "Latency" : 5ms ou 10ms recommandé
|
||||||
|
|
||||||
|
#### Solution
|
||||||
|
|
||||||
|
Réduire latency dans DVS :
|
||||||
|
|
||||||
|
1. Ouvrir **Dante Virtual Soundcard**
|
||||||
|
2. Settings → Latency : 2ms ou 5ms (au lieu de 10ms)
|
||||||
|
3. Restart
|
||||||
|
|
||||||
|
**Attention** : Latence < 5ms risque de coupures si réseau chargé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PTP non synchronisé (AES67)
|
||||||
|
|
||||||
|
#### Symptômes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ptp4l -i eth0 -f /etc/ptp4l.conf -m
|
||||||
|
# Offset > 1000 ns (> 1µs)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Diagnostic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Switch supporte PTP ?
|
||||||
|
# Vérifier config switch (PTP activé)
|
||||||
|
|
||||||
|
# PTP master présent sur le réseau ?
|
||||||
|
sudo tcpdump -i eth0 -n 'port 319 or port 320'
|
||||||
|
# Devrait afficher des paquets PTP Sync/Follow_Up
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solutions
|
||||||
|
|
||||||
|
**Aucun PTP master** :
|
||||||
|
|
||||||
|
Configurer un équipement comme grandmaster (ex: console AES67).
|
||||||
|
|
||||||
|
Ou lancer un PTP master software (déconseillé en production) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mode master (remplacer slaveOnly par masterOnly dans config)
|
||||||
|
sudo ptp4l -i eth0 --masterOnly -m
|
||||||
|
```
|
||||||
|
|
||||||
|
**Switch ne route pas PTP** :
|
||||||
|
|
||||||
|
Vérifier config switch :
|
||||||
|
- PTP enabled sur tous les ports
|
||||||
|
- Transparent Clock ou Boundary Clock
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outils de Diagnostic
|
||||||
|
|
||||||
|
### Logs Serveur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Temps réel
|
||||||
|
sudo journalctl -u pttlive-server -f
|
||||||
|
|
||||||
|
# Depuis le démarrage
|
||||||
|
sudo journalctl -u pttlive-server --since today
|
||||||
|
|
||||||
|
# Filtrer erreurs uniquement
|
||||||
|
sudo journalctl -u pttlive-server -p err
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring Réseau
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Trafic réseau temps réel
|
||||||
|
iftop -i eth0
|
||||||
|
|
||||||
|
# Statistiques interface
|
||||||
|
ip -s link show eth0
|
||||||
|
|
||||||
|
# Connexions actives
|
||||||
|
ss -tunap | grep -E '7880|50000'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring Audio
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# JACK
|
||||||
|
jack_evmon # Surveille xruns
|
||||||
|
jack_bufsize # Taille buffer
|
||||||
|
jack_samplerate # Sample rate
|
||||||
|
|
||||||
|
# PipeWire
|
||||||
|
pw-top # CPU usage par client
|
||||||
|
pw-cli dump # État complet
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client (Navigateur)
|
||||||
|
|
||||||
|
**Chrome DevTools** :
|
||||||
|
|
||||||
|
1. F12 → Console : erreurs JavaScript
|
||||||
|
2. Network : vérifier requêtes API (200 OK attendu)
|
||||||
|
3. Application → Service Workers : vérifier enregistré
|
||||||
|
4. `chrome://webrtc-internals` : stats WebRTC détaillées
|
||||||
|
|
||||||
|
**Firefox DevTools** :
|
||||||
|
|
||||||
|
1. F12 → Console
|
||||||
|
2. `about:webrtc` : stats WebRTC
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist Rapide
|
||||||
|
|
||||||
|
### Problème : Pas de son
|
||||||
|
|
||||||
|
- [ ] Microphone autorisé navigateur ?
|
||||||
|
- [ ] Backend audio démarré (JACK/PipeWire) ?
|
||||||
|
- [ ] Ports JACK connectés ?
|
||||||
|
- [ ] LiveKit connecté (logs serveur) ?
|
||||||
|
|
||||||
|
### Problème : Latence élevée
|
||||||
|
|
||||||
|
- [ ] Ping < 10ms ?
|
||||||
|
- [ ] Buffer JACK = 256 samples ?
|
||||||
|
- [ ] WiFi 5GHz ?
|
||||||
|
- [ ] Jitter buffer = LOW_LATENCY ?
|
||||||
|
|
||||||
|
### Problème : Coupures audio
|
||||||
|
|
||||||
|
- [ ] JACK xruns = 0 ?
|
||||||
|
- [ ] CPU < 70% ?
|
||||||
|
- [ ] Packet loss < 1% ?
|
||||||
|
- [ ] Buffer JACK >= 256 ?
|
||||||
|
|
||||||
|
### Problème : Connexion impossible
|
||||||
|
|
||||||
|
- [ ] Firewall ports ouverts (7880, 50000-60000) ?
|
||||||
|
- [ ] LiveKit démarre (journalctl) ?
|
||||||
|
- [ ] Client peut ping serveur ?
|
||||||
|
- [ ] HTTPS si PWA ?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Si le problème persiste :
|
||||||
|
|
||||||
|
1. Collecter logs :
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u pttlive-server > /tmp/pttlive.log
|
||||||
|
jack_lsp -c > /tmp/jack-connections.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Ouvrir une issue GitHub avec :
|
||||||
|
- Description du problème
|
||||||
|
- Logs serveur
|
||||||
|
- Version OS (client et serveur)
|
||||||
|
- Configuration audio (carte son, backend)
|
||||||
|
|
||||||
|
**GitHub Issues** : https://github.com/votre-user/ptt-live/issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Dernière mise à jour** : 2026-05-26
|
||||||
|
**Version** : 0.1.0 (Phase 3)
|
||||||
Executable
+293
@@ -0,0 +1,293 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# PTT Live - Script d'installation Linux
|
||||||
|
# Supporte : Ubuntu 22.04+, Debian 11+, Arch Linux
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
set -e # Arrête en cas d'erreur
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo " PTT Live - Installation Linux"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Détection de la distribution
|
||||||
|
detect_distro() {
|
||||||
|
if [ -f /etc/os-release ]; then
|
||||||
|
. /etc/os-release
|
||||||
|
DISTRO=$ID
|
||||||
|
VERSION=$VERSION_ID
|
||||||
|
else
|
||||||
|
echo "Erreur : impossible de détecter la distribution Linux"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Distribution détectée : $DISTRO $VERSION"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Installation des dépendances système
|
||||||
|
install_system_deps() {
|
||||||
|
echo ""
|
||||||
|
echo "Installation des dépendances système..."
|
||||||
|
|
||||||
|
case $DISTRO in
|
||||||
|
ubuntu|debian)
|
||||||
|
echo "Distribution : Debian/Ubuntu"
|
||||||
|
|
||||||
|
# Mise à jour des paquets
|
||||||
|
sudo apt update
|
||||||
|
|
||||||
|
# Dépendances de base
|
||||||
|
sudo apt install -y \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
build-essential \
|
||||||
|
pkg-config
|
||||||
|
|
||||||
|
# Node.js (via NodeSource si pas déjà installé)
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo "Installation de Node.js 20.x..."
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
else
|
||||||
|
echo "Node.js déjà installé : $(node --version)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backend audio : PipeWire (recommandé pour Ubuntu 22.04+)
|
||||||
|
if [ "${VERSION%%.*}" -ge 22 ]; then
|
||||||
|
echo "Installation de PipeWire (backend audio moderne)..."
|
||||||
|
sudo apt install -y \
|
||||||
|
pipewire \
|
||||||
|
pipewire-pulse \
|
||||||
|
pipewire-jack \
|
||||||
|
wireplumber \
|
||||||
|
pipewire-audio-client-libraries
|
||||||
|
|
||||||
|
# Outils PipeWire
|
||||||
|
sudo apt install -y \
|
||||||
|
pipewire-bin \
|
||||||
|
libspa-0.2-jack
|
||||||
|
|
||||||
|
# Démarrage automatique
|
||||||
|
systemctl --user enable --now pipewire pipewire-pulse wireplumber
|
||||||
|
echo "PipeWire démarré et activé au démarrage"
|
||||||
|
else
|
||||||
|
echo "Version Ubuntu < 22.04 : installation de JACK..."
|
||||||
|
install_jack_debian
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Outils JACK optionnels (compatibilité)
|
||||||
|
sudo apt install -y \
|
||||||
|
jack-tools \
|
||||||
|
qjackctl || true
|
||||||
|
|
||||||
|
echo "Dépendances système installées !"
|
||||||
|
;;
|
||||||
|
|
||||||
|
arch|manjaro)
|
||||||
|
echo "Distribution : Arch Linux"
|
||||||
|
|
||||||
|
# Mise à jour des paquets
|
||||||
|
sudo pacman -Syu --noconfirm
|
||||||
|
|
||||||
|
# Dépendances de base
|
||||||
|
sudo pacman -S --needed --noconfirm \
|
||||||
|
base-devel \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
nodejs \
|
||||||
|
npm
|
||||||
|
|
||||||
|
# PipeWire (installé par défaut sur Arch moderne)
|
||||||
|
sudo pacman -S --needed --noconfirm \
|
||||||
|
pipewire \
|
||||||
|
pipewire-pulse \
|
||||||
|
pipewire-jack \
|
||||||
|
wireplumber \
|
||||||
|
pipewire-alsa
|
||||||
|
|
||||||
|
# Outils audio
|
||||||
|
sudo pacman -S --needed --noconfirm \
|
||||||
|
jack2 \
|
||||||
|
qjackctl || true
|
||||||
|
|
||||||
|
# Activation PipeWire
|
||||||
|
systemctl --user enable --now pipewire pipewire-pulse wireplumber
|
||||||
|
echo "PipeWire démarré et activé au démarrage"
|
||||||
|
|
||||||
|
echo "Dépendances système installées !"
|
||||||
|
;;
|
||||||
|
|
||||||
|
fedora)
|
||||||
|
echo "Distribution : Fedora"
|
||||||
|
|
||||||
|
sudo dnf install -y \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
gcc-c++ \
|
||||||
|
make \
|
||||||
|
pipewire \
|
||||||
|
pipewire-jack-audio-connection-kit \
|
||||||
|
pipewire-pulseaudio \
|
||||||
|
wireplumber
|
||||||
|
|
||||||
|
systemctl --user enable --now pipewire pipewire-pulse wireplumber
|
||||||
|
echo "Dépendances système installées !"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "Distribution non supportée automatiquement : $DISTRO"
|
||||||
|
echo "Installez manuellement :"
|
||||||
|
echo " - Node.js 18+"
|
||||||
|
echo " - PipeWire ou JACK"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Installation de JACK (fallback pour anciennes versions)
|
||||||
|
install_jack_debian() {
|
||||||
|
echo "Installation de JACK Audio Connection Kit..."
|
||||||
|
sudo apt install -y \
|
||||||
|
jackd2 \
|
||||||
|
jack-tools \
|
||||||
|
qjackctl
|
||||||
|
|
||||||
|
# Configuration JACK pour basse latence
|
||||||
|
sudo usermod -a -G audio $USER
|
||||||
|
echo "JACK installé. Vous devrez peut-être redémarrer pour appliquer les permissions audio."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Téléchargement de LiveKit Server
|
||||||
|
install_livekit_server() {
|
||||||
|
echo ""
|
||||||
|
echo "Téléchargement de LiveKit Server..."
|
||||||
|
|
||||||
|
LIVEKIT_VERSION="v1.5.2"
|
||||||
|
LIVEKIT_DIR="$PROJECT_ROOT/server/bin"
|
||||||
|
LIVEKIT_BINARY="$LIVEKIT_DIR/livekit-server"
|
||||||
|
|
||||||
|
mkdir -p "$LIVEKIT_DIR"
|
||||||
|
|
||||||
|
# Détection de l'architecture
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case $ARCH in
|
||||||
|
x86_64)
|
||||||
|
LIVEKIT_ARCH="amd64"
|
||||||
|
;;
|
||||||
|
aarch64|arm64)
|
||||||
|
LIVEKIT_ARCH="arm64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Architecture non supportée : $ARCH"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
LIVEKIT_URL="https://github.com/livekit/livekit/releases/download/${LIVEKIT_VERSION}/livekit_${LIVEKIT_VERSION}_linux_${LIVEKIT_ARCH}.tar.gz"
|
||||||
|
|
||||||
|
echo "Téléchargement depuis : $LIVEKIT_URL"
|
||||||
|
|
||||||
|
cd "$LIVEKIT_DIR"
|
||||||
|
curl -L -o livekit.tar.gz "$LIVEKIT_URL"
|
||||||
|
tar -xzf livekit.tar.gz
|
||||||
|
rm livekit.tar.gz
|
||||||
|
|
||||||
|
chmod +x livekit-server
|
||||||
|
|
||||||
|
echo "LiveKit Server installé : $LIVEKIT_BINARY"
|
||||||
|
echo "Version : $($LIVEKIT_BINARY --version)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Installation des dépendances Node.js
|
||||||
|
install_node_deps() {
|
||||||
|
echo ""
|
||||||
|
echo "Installation des dépendances Node.js..."
|
||||||
|
|
||||||
|
# Serveur
|
||||||
|
echo "Serveur..."
|
||||||
|
cd "$PROJECT_ROOT/server"
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Client
|
||||||
|
echo "Client..."
|
||||||
|
cd "$PROJECT_ROOT/client"
|
||||||
|
npm install
|
||||||
|
|
||||||
|
echo "Dépendances Node.js installées !"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configuration audio
|
||||||
|
configure_audio() {
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo " Configuration audio"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
# Vérification PipeWire
|
||||||
|
if systemctl --user is-active --quiet pipewire; then
|
||||||
|
echo "PipeWire : ACTIF"
|
||||||
|
pw-cli info 0 | head -n 5
|
||||||
|
else
|
||||||
|
echo "PipeWire : INACTIF"
|
||||||
|
echo "Démarrez-le : systemctl --user start pipewire pipewire-pulse"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Vérification JACK (si installé)
|
||||||
|
if command -v jack_lsp &> /dev/null; then
|
||||||
|
echo ""
|
||||||
|
echo "JACK : Installé"
|
||||||
|
if jack_lsp &> /dev/null; then
|
||||||
|
echo "Serveur JACK : ACTIF"
|
||||||
|
else
|
||||||
|
echo "Serveur JACK : INACTIF"
|
||||||
|
echo "Démarrez-le : jackd -d alsa -r 48000"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Backend audio recommandé : PipeWire"
|
||||||
|
echo "Pour démarrer le serveur PTT Live, voir README.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Résumé final
|
||||||
|
print_summary() {
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo " Installation terminée !"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
echo "Prochaines étapes :"
|
||||||
|
echo ""
|
||||||
|
echo "1. Démarrer le serveur :"
|
||||||
|
echo " cd $PROJECT_ROOT/server"
|
||||||
|
echo " npm run dev"
|
||||||
|
echo ""
|
||||||
|
echo "2. Démarrer le client (autre terminal) :"
|
||||||
|
echo " cd $PROJECT_ROOT/client"
|
||||||
|
echo " npm run dev"
|
||||||
|
echo ""
|
||||||
|
echo "3. Accéder à l'interface :"
|
||||||
|
echo " http://localhost:5173"
|
||||||
|
echo ""
|
||||||
|
echo "Documentation : $PROJECT_ROOT/README.md"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Script principal
|
||||||
|
main() {
|
||||||
|
detect_distro
|
||||||
|
install_system_deps
|
||||||
|
install_livekit_server
|
||||||
|
install_node_deps
|
||||||
|
configure_audio
|
||||||
|
print_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -51,6 +51,16 @@ fi
|
|||||||
echo -e "${GREEN}✅ Homebrew $(brew --version | head -n 1)${NC}"
|
echo -e "${GREEN}✅ Homebrew $(brew --version | head -n 1)${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# Installer sox (audio backend stable pour macOS)
|
||||||
|
echo "🎵 Installation sox (audio backend)..."
|
||||||
|
if command -v sox &> /dev/null; then
|
||||||
|
echo -e "${GREEN}✅ sox déjà installé ($(sox --version | head -n 1))${NC}"
|
||||||
|
else
|
||||||
|
brew install sox
|
||||||
|
echo -e "${GREEN}✅ sox installé${NC}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
# Installer LiveKit Server via Homebrew
|
# Installer LiveKit Server via Homebrew
|
||||||
echo "📥 Installation LiveKit Server..."
|
echo "📥 Installation LiveKit Server..."
|
||||||
if command -v livekit-server &> /dev/null; then
|
if command -v livekit-server &> /dev/null; then
|
||||||
|
|||||||
+207
-31
@@ -13,9 +13,12 @@
|
|||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { platform } from 'os';
|
import { platform } from 'os';
|
||||||
import CoreAudioBackend from './backends/CoreAudioBackend.js';
|
import CoreAudioBackend from './backends/CoreAudioBackend.js';
|
||||||
|
import JACKBackend from './backends/JACKBackend.js';
|
||||||
|
import PipeWireBackend from './backends/PipeWireBackend.js';
|
||||||
import OpusCodec, { OpusPresets } from './OpusCodec.js';
|
import OpusCodec, { OpusPresets } from './OpusCodec.js';
|
||||||
import JitterBuffer, { JitterBufferPresets } from './JitterBuffer.js';
|
import JitterBuffer, { JitterBufferPresets } from './JitterBuffer.js';
|
||||||
import LiveKitClient from './LiveKitClient.js';
|
import LiveKitClient from './LiveKitClient.js';
|
||||||
|
import GroupAudioRouter from './GroupAudioRouter.js';
|
||||||
|
|
||||||
export class AudioBridge extends EventEmitter {
|
export class AudioBridge extends EventEmitter {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
@@ -52,11 +55,16 @@ export class AudioBridge extends EventEmitter {
|
|||||||
this.opusDecoder = null;
|
this.opusDecoder = null;
|
||||||
this.jitterBuffer = null;
|
this.jitterBuffer = null;
|
||||||
this.liveKitClient = null;
|
this.liveKitClient = null;
|
||||||
|
this.groupAudioRouter = null;
|
||||||
|
|
||||||
// État
|
// État
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
this.backendType = null;
|
this.backendType = null;
|
||||||
|
|
||||||
|
// Buffers pour routing multi-canaux
|
||||||
|
this.inputChannelBuffers = new Map(); // Map<channelId, Float32Array>
|
||||||
|
this.groupBuffersFromLiveKit = new Map(); // Map<groupName, Float32Array>
|
||||||
|
|
||||||
// Statistiques
|
// Statistiques
|
||||||
this.stats = {
|
this.stats = {
|
||||||
startTime: null,
|
startTime: null,
|
||||||
@@ -96,10 +104,13 @@ export class AudioBridge extends EventEmitter {
|
|||||||
// 3. Initialisation du jitter buffer
|
// 3. Initialisation du jitter buffer
|
||||||
this._initJitterBuffer();
|
this._initJitterBuffer();
|
||||||
|
|
||||||
// 4. Connexion à LiveKit
|
// 4. Initialisation du GroupAudioRouter
|
||||||
|
this._initGroupAudioRouter();
|
||||||
|
|
||||||
|
// 5. Connexion à LiveKit
|
||||||
await this._initLiveKit();
|
await this._initLiveKit();
|
||||||
|
|
||||||
// 5. Démarrage du routing audio
|
// 6. Démarrage du routing audio
|
||||||
await this._startAudioRouting();
|
await this._startAudioRouting();
|
||||||
|
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
@@ -123,27 +134,52 @@ export class AudioBridge extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
async _initAudioBackend() {
|
async _initAudioBackend() {
|
||||||
const os = platform();
|
const os = platform();
|
||||||
|
let BackendClass = null;
|
||||||
|
let devices = [];
|
||||||
|
|
||||||
// macOS : CoreAudio prioritaire
|
// macOS : CoreAudio prioritaire
|
||||||
if (os === 'darwin') {
|
if (os === 'darwin') {
|
||||||
if (CoreAudioBackend.isAvailable()) {
|
if (CoreAudioBackend.isAvailable()) {
|
||||||
this.backendType = 'CoreAudio';
|
this.backendType = 'CoreAudio';
|
||||||
this.audioBackend = new CoreAudioBackend({
|
BackendClass = CoreAudioBackend;
|
||||||
sampleRate: this.options.sampleRate,
|
|
||||||
channels: this.options.channels,
|
|
||||||
framesPerBuffer: this.options.frameSize,
|
|
||||||
inputDeviceId: this.options.inputDeviceId,
|
|
||||||
outputDeviceId: this.options.outputDeviceId
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✓ Backend audio : CoreAudio (macOS natif)');
|
console.log('✓ Backend audio : CoreAudio (macOS natif)');
|
||||||
} else {
|
} else {
|
||||||
throw new Error('CoreAudio non disponible sur ce système');
|
throw new Error('CoreAudio non disponible sur ce système');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Linux : JACK ou PipeWire (Phase 3)
|
// Linux : PipeWire > JACK (ordre de préférence)
|
||||||
else if (os === 'linux') {
|
else if (os === 'linux') {
|
||||||
throw new Error('Support Linux non encore implémenté (Phase 3)');
|
// Détection automatique : préfère PipeWire (moderne) puis JACK (pro)
|
||||||
|
if (PipeWireBackend.isAvailable() && PipeWireBackend.isServerRunning()) {
|
||||||
|
this.backendType = 'PipeWire';
|
||||||
|
BackendClass = PipeWireBackend;
|
||||||
|
console.log('✓ Backend audio : PipeWire (Linux moderne)');
|
||||||
|
} else if (JACKBackend.isAvailable() && JACKBackend.isServerRunning()) {
|
||||||
|
this.backendType = 'JACK';
|
||||||
|
BackendClass = JACKBackend;
|
||||||
|
console.log('✓ Backend audio : JACK (Linux professionnel)');
|
||||||
|
} else {
|
||||||
|
// Aucun backend disponible
|
||||||
|
const pipewireInstalled = PipeWireBackend.isAvailable();
|
||||||
|
const jackInstalled = JACKBackend.isAvailable();
|
||||||
|
|
||||||
|
let errorMsg = 'Aucun backend audio disponible sur Linux.\n';
|
||||||
|
|
||||||
|
if (!pipewireInstalled && !jackInstalled) {
|
||||||
|
errorMsg += 'Installez PipeWire (recommandé) ou JACK :\n';
|
||||||
|
errorMsg += ' Ubuntu/Debian : sudo apt install pipewire pipewire-pulse\n';
|
||||||
|
errorMsg += ' Arch Linux : sudo pacman -S pipewire pipewire-pulse\n';
|
||||||
|
errorMsg += ' JACK : sudo apt install jackd2 jack-tools';
|
||||||
|
} else if (pipewireInstalled && !PipeWireBackend.isServerRunning()) {
|
||||||
|
errorMsg += 'PipeWire installé mais non démarré.\n';
|
||||||
|
errorMsg += 'Démarrez-le : systemctl --user start pipewire pipewire-pulse';
|
||||||
|
} else if (jackInstalled && !JACKBackend.isServerRunning()) {
|
||||||
|
errorMsg += 'JACK installé mais serveur non démarré.\n';
|
||||||
|
errorMsg += 'Démarrez-le : jackd -d alsa -r 48000';
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Windows : WASAPI (futur)
|
// Windows : WASAPI (futur)
|
||||||
else if (os === 'win32') {
|
else if (os === 'win32') {
|
||||||
@@ -153,8 +189,19 @@ export class AudioBridge extends EventEmitter {
|
|||||||
throw new Error(`Plateforme non supportée : ${os}`);
|
throw new Error(`Plateforme non supportée : ${os}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialisation du backend sélectionné
|
||||||
|
this.audioBackend = new BackendClass({
|
||||||
|
sampleRate: this.options.sampleRate,
|
||||||
|
channels: this.options.channels,
|
||||||
|
framesPerBuffer: this.options.frameSize,
|
||||||
|
inputDeviceId: this.options.inputDeviceId,
|
||||||
|
outputDeviceId: this.options.outputDeviceId,
|
||||||
|
// Options spécifiques PipeWire
|
||||||
|
latency: this.options.latency || 20
|
||||||
|
});
|
||||||
|
|
||||||
// Liste des devices disponibles
|
// Liste des devices disponibles
|
||||||
const devices = CoreAudioBackend.getDevices();
|
devices = BackendClass.getDevices();
|
||||||
console.log(`📻 Devices audio détectés : ${devices.length}`);
|
console.log(`📻 Devices audio détectés : ${devices.length}`);
|
||||||
devices.forEach(d => {
|
devices.forEach(d => {
|
||||||
console.log(` - ${d.name} (in:${d.maxInputChannels}, out:${d.maxOutputChannels})`);
|
console.log(` - ${d.name} (in:${d.maxInputChannels}, out:${d.maxOutputChannels})`);
|
||||||
@@ -214,6 +261,32 @@ export class AudioBridge extends EventEmitter {
|
|||||||
console.log(`✓ Jitter buffer : cible ${bufferConfig.targetSize} frames`);
|
console.log(`✓ Jitter buffer : cible ${bufferConfig.targetSize} frames`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise le GroupAudioRouter pour le routing multi-canaux
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_initGroupAudioRouter() {
|
||||||
|
this.groupAudioRouter = new GroupAudioRouter({
|
||||||
|
sampleRate: this.options.sampleRate,
|
||||||
|
frameSize: this.options.frameSize,
|
||||||
|
maxInputChannels: this.options.maxInputChannels || 32,
|
||||||
|
maxOutputChannels: this.options.maxOutputChannels || 32,
|
||||||
|
groups: this.options.groups || []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Charger la configuration de routing depuis les options
|
||||||
|
if (this.options.routing) {
|
||||||
|
this.groupAudioRouter.configure(this.options.routing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events du router
|
||||||
|
this.groupAudioRouter.on('configured', (stats) => {
|
||||||
|
console.log(`✓ GroupAudioRouter configuré : ${stats.routesActive} routes`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✓ GroupAudioRouter initialisé');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialise la connexion LiveKit
|
* Initialise la connexion LiveKit
|
||||||
* @private
|
* @private
|
||||||
@@ -228,6 +301,8 @@ export class AudioBridge extends EventEmitter {
|
|||||||
token: this.options.liveKitToken,
|
token: this.options.liveKitToken,
|
||||||
roomName: this.options.roomName,
|
roomName: this.options.roomName,
|
||||||
participantName: 'AudioBridge',
|
participantName: 'AudioBridge',
|
||||||
|
sampleRate: this.options.sampleRate,
|
||||||
|
channels: this.options.channels,
|
||||||
audioBitrate: this.opusEncoder.options.bitrate
|
audioBitrate: this.opusEncoder.options.bitrate
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,7 +311,8 @@ export class AudioBridge extends EventEmitter {
|
|||||||
console.log('✓ LiveKit connecté');
|
console.log('✓ LiveKit connecté');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.liveKitClient.on('disconnected', ({ reason }) => {
|
this.liveKitClient.on('disconnected', (data) => {
|
||||||
|
const reason = data?.reason || 'unknown';
|
||||||
console.warn('⚠️ LiveKit déconnecté:', reason);
|
console.warn('⚠️ LiveKit déconnecté:', reason);
|
||||||
this.stats.errors.network++;
|
this.stats.errors.network++;
|
||||||
});
|
});
|
||||||
@@ -254,40 +330,91 @@ export class AudioBridge extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Démarre le routing audio bidirectionnel
|
* Démarre le routing audio bidirectionnel complet
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
async _startAudioRouting() {
|
async _startAudioRouting() {
|
||||||
// ===== ROUTING CAPTURE : CoreAudio → Opus → LiveKit =====
|
console.log('🔄 Démarrage routing audio bidirectionnel...');
|
||||||
|
|
||||||
|
// ===== FLUX 1 : CAPTURE (Carte Son → Groupes → LiveKit → Clients) =====
|
||||||
this.audioBackend.on('audioData', (pcmData) => {
|
this.audioBackend.on('audioData', (pcmData) => {
|
||||||
try {
|
try {
|
||||||
// Encodage PCM → Opus
|
// Convertir PCM Buffer → Float32Array (pour GroupAudioRouter)
|
||||||
const opusData = this.opusEncoder.encode(pcmData);
|
const float32Data = this._bufferToFloat32(pcmData);
|
||||||
|
|
||||||
if (opusData) {
|
// Pour l'instant, on assume que l'audio vient du canal 0
|
||||||
this.stats.framesCapture++;
|
// TODO: Supporter multi-canaux depuis la carte son
|
||||||
this.stats.bytesEncoded += opusData.length;
|
const channelId = this.options.inputDeviceChannel || 0;
|
||||||
|
this.inputChannelBuffers.set(channelId, float32Data);
|
||||||
|
|
||||||
// TODO: Envoyer à LiveKit via track custom ou DataChannel
|
// ÉTAPE 1 : Inputs physiques → Groupes (via GroupAudioRouter)
|
||||||
// Pour l'instant, LiveKit gère l'audio via MediaStream natif
|
const groupBuffers = this.groupAudioRouter.processInputsToGroups(
|
||||||
// Cette partie sera complétée en fonction de l'architecture finale
|
this.inputChannelBuffers
|
||||||
} else {
|
);
|
||||||
this.stats.errors.encode++;
|
|
||||||
}
|
// ÉTAPE 2 : Pour chaque groupe, envoyer vers LiveKit
|
||||||
|
groupBuffers.forEach((groupBuffer, groupName) => {
|
||||||
|
// Convertir Float32Array → PCM Buffer
|
||||||
|
const pcmBuffer = this._float32ToBuffer(groupBuffer);
|
||||||
|
|
||||||
|
// Encoder en Opus
|
||||||
|
const opusData = this.opusEncoder.encode(pcmBuffer);
|
||||||
|
|
||||||
|
if (opusData) {
|
||||||
|
this.stats.framesCapture++;
|
||||||
|
this.stats.bytesEncoded += opusData.length;
|
||||||
|
|
||||||
|
// TODO: Envoyer opusData à LiveKit pour ce groupe spécifique
|
||||||
|
// this.liveKitClient.sendAudioToGroup(groupName, opusData);
|
||||||
|
|
||||||
|
// Pour Phase 3, on émet un événement que le système d'intégration LiveKit écoutera
|
||||||
|
this.emit('groupAudioOut', { groupName, opusData, pcmBuffer });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.stats.framesCapture++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur routing capture:', error);
|
console.error('Erreur routing capture:', error);
|
||||||
this.stats.errors.capture++;
|
this.stats.errors.capture++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Démarrage capture
|
// ===== FLUX 2 : LECTURE (Clients → LiveKit → Groupes → Carte Son) =====
|
||||||
await this.audioBackend.startCapture();
|
|
||||||
|
|
||||||
// ===== ROUTING LECTURE : LiveKit → Opus → CoreAudio =====
|
// Écouter l'audio entrant de LiveKit (sera connecté par LiveKitServerBridge)
|
||||||
// La lecture sera démarrée une fois qu'on reçoit des tracks distants
|
this.on('groupAudioIn', ({ groupName, pcmBuffer }) => {
|
||||||
|
try {
|
||||||
|
// Stocker le buffer du groupe pour le routing
|
||||||
|
const float32Data = this._bufferToFloat32(pcmBuffer);
|
||||||
|
this.groupBuffersFromLiveKit.set(groupName, float32Data);
|
||||||
|
|
||||||
|
// ÉTAPE 3 : Groupes → Outputs physiques (via GroupAudioRouter)
|
||||||
|
const outputBuffers = this.groupAudioRouter.processGroupsToOutputs(
|
||||||
|
this.groupBuffersFromLiveKit
|
||||||
|
);
|
||||||
|
|
||||||
|
// ÉTAPE 4 : Envoyer chaque output à la carte son
|
||||||
|
outputBuffers.forEach((outputBuffer, channelId) => {
|
||||||
|
const pcmBuffer = this._float32ToBuffer(outputBuffer);
|
||||||
|
|
||||||
|
// Envoyer à la carte son
|
||||||
|
this.audioBackend.queueAudio(pcmBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.stats.framesPlayback++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur routing lecture:', error);
|
||||||
|
this.stats.errors.playback++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Démarrage des streams audio
|
||||||
|
await this.audioBackend.startCapture();
|
||||||
await this.audioBackend.startPlayback();
|
await this.audioBackend.startPlayback();
|
||||||
|
|
||||||
console.log('✓ Routing audio bidirectionnel actif');
|
console.log('✓ Routing audio bidirectionnel actif');
|
||||||
|
console.log(' → Carte Son → GroupRouter → LiveKit → Clients');
|
||||||
|
console.log(' ← Carte Son ← GroupRouter ← LiveKit ← Clients');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -313,6 +440,46 @@ export class AudioBridge extends EventEmitter {
|
|||||||
console.warn('Réception track distant : implémentation complète en cours');
|
console.warn('Réception track distant : implémentation complète en cours');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit Buffer PCM 16-bit → Float32Array [-1.0, 1.0]
|
||||||
|
* @param {Buffer} buffer - Buffer PCM 16-bit signed
|
||||||
|
* @returns {Float32Array}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_bufferToFloat32(buffer) {
|
||||||
|
const samples = buffer.length / 2; // 2 bytes per sample (16-bit)
|
||||||
|
const float32 = new Float32Array(samples);
|
||||||
|
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
// Lire 16-bit signed little-endian
|
||||||
|
const int16 = buffer.readInt16LE(i * 2);
|
||||||
|
// Normaliser vers [-1.0, 1.0]
|
||||||
|
float32[i] = int16 / 32768.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return float32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit Float32Array [-1.0, 1.0] → Buffer PCM 16-bit
|
||||||
|
* @param {Float32Array} float32 - Données audio normalisées
|
||||||
|
* @returns {Buffer}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_float32ToBuffer(float32) {
|
||||||
|
const buffer = Buffer.alloc(float32.length * 2); // 2 bytes per sample
|
||||||
|
|
||||||
|
for (let i = 0; i < float32.length; i++) {
|
||||||
|
// Clamping [-1.0, 1.0]
|
||||||
|
const clamped = Math.max(-1.0, Math.min(1.0, float32[i]));
|
||||||
|
// Convertir vers 16-bit signed
|
||||||
|
const int16 = Math.round(clamped * 32767);
|
||||||
|
buffer.writeInt16LE(int16, i * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arrête le bridge audio
|
* Arrête le bridge audio
|
||||||
*/
|
*/
|
||||||
@@ -334,6 +501,11 @@ export class AudioBridge extends EventEmitter {
|
|||||||
this.liveKitClient = null;
|
this.liveKitClient = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.groupAudioRouter) {
|
||||||
|
this.groupAudioRouter.destroy();
|
||||||
|
this.groupAudioRouter = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.jitterBuffer) {
|
if (this.jitterBuffer) {
|
||||||
this.jitterBuffer.destroy();
|
this.jitterBuffer.destroy();
|
||||||
this.jitterBuffer = null;
|
this.jitterBuffer = null;
|
||||||
@@ -349,6 +521,10 @@ export class AudioBridge extends EventEmitter {
|
|||||||
this.opusDecoder = null;
|
this.opusDecoder = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nettoyer les buffers
|
||||||
|
this.inputChannelBuffers.clear();
|
||||||
|
this.groupBuffersFromLiveKit.clear();
|
||||||
|
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
|
||||||
console.log('✓ AudioBridge arrêté');
|
console.log('✓ AudioBridge arrêté');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
import { AccessToken } from 'livekit-server-sdk';
|
||||||
import configManager from '../config/ConfigManager.js';
|
import configManager from '../config/ConfigManager.js';
|
||||||
|
|
||||||
class AudioBridgeManager extends EventEmitter {
|
class AudioBridgeManager extends EventEmitter {
|
||||||
@@ -31,18 +32,79 @@ class AudioBridgeManager extends EventEmitter {
|
|||||||
const config = configManager.get();
|
const config = configManager.get();
|
||||||
console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio);
|
console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio);
|
||||||
|
|
||||||
// TODO Phase 3: Implémenter le vrai bridge audio
|
// Génération du token JWT pour le participant serveur
|
||||||
// const AudioBridge = await import('./AudioBridge.js');
|
const token = new AccessToken(
|
||||||
// this.bridge = new AudioBridge(config.audio);
|
config.server?.livekit?.apiKey || 'devkey',
|
||||||
// await this.bridge.start();
|
config.server?.livekit?.apiSecret || 'secret',
|
||||||
|
{
|
||||||
|
identity: 'AudioBridge',
|
||||||
|
name: 'Audio Bridge Server',
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
role: 'bridge',
|
||||||
|
capabilities: ['audio-routing', 'monitoring']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Permissions complètes pour le bridge serveur
|
||||||
|
token.addGrant({
|
||||||
|
room: 'main',
|
||||||
|
roomJoin: true,
|
||||||
|
canPublish: true,
|
||||||
|
canSubscribe: true,
|
||||||
|
canPublishData: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const liveKitToken = await token.toJwt();
|
||||||
|
console.log('✓ Token JWT généré pour AudioBridge');
|
||||||
|
|
||||||
|
// Import dynamique du AudioBridge
|
||||||
|
const { AudioBridge } = await import('./AudioBridge.js');
|
||||||
|
|
||||||
|
// Préparer la config avec conversion explicite des valeurs numériques
|
||||||
|
const audioConfig = { ...config.audio };
|
||||||
|
|
||||||
|
// Conversion explicite des paramètres numériques (depuis YAML ils peuvent être strings)
|
||||||
|
if (audioConfig.sampleRate) audioConfig.sampleRate = parseInt(audioConfig.sampleRate, 10);
|
||||||
|
if (audioConfig.channels) audioConfig.channels = parseInt(audioConfig.channels, 10);
|
||||||
|
|
||||||
|
// frameSize en millisecondes → conversion en nombre d'échantillons
|
||||||
|
// Ex: 20ms à 48kHz = 960 échantillons
|
||||||
|
if (audioConfig.frameSize) {
|
||||||
|
const frameSizeMs = parseInt(audioConfig.frameSize, 10);
|
||||||
|
const sampleRate = audioConfig.sampleRate || 48000;
|
||||||
|
audioConfig.frameSize = Math.floor((frameSizeMs * sampleRate) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioConfig.defaultBitrate) audioConfig.defaultBitrate = parseInt(audioConfig.defaultBitrate, 10);
|
||||||
|
if (audioConfig.customOpusBitrate) audioConfig.customOpusBitrate = parseInt(audioConfig.customOpusBitrate, 10);
|
||||||
|
|
||||||
|
// Créer l'instance avec la config
|
||||||
|
this.bridge = new AudioBridge({
|
||||||
|
...audioConfig,
|
||||||
|
// Options LiveKit
|
||||||
|
liveKitUrl: config.server?.livekit?.url || 'ws://localhost:7880',
|
||||||
|
liveKitToken,
|
||||||
|
roomName: 'main',
|
||||||
|
// Options de routing
|
||||||
|
routing: config.audio?.routing || {},
|
||||||
|
groups: config.groups || [],
|
||||||
|
maxInputChannels: 32,
|
||||||
|
maxOutputChannels: 32
|
||||||
|
});
|
||||||
|
|
||||||
|
// Démarrer le bridge
|
||||||
|
await this.bridge.start();
|
||||||
|
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
console.log('✓ AudioBridge démarré (mode placeholder)');
|
console.log('✓ AudioBridge démarré avec succès');
|
||||||
|
|
||||||
this.emit('started');
|
this.emit('started');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Erreur démarrage AudioBridge:', error);
|
console.error('❌ Erreur démarrage AudioBridge:', error);
|
||||||
throw error;
|
// Ne pas throw pour éviter de bloquer le serveur si pas de carte son
|
||||||
|
console.warn('⚠️ Le serveur continue sans AudioBridge actif');
|
||||||
|
this.isRunning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +119,10 @@ class AudioBridgeManager extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
console.log('⏹ Arrêt AudioBridge...');
|
console.log('⏹ Arrêt AudioBridge...');
|
||||||
|
|
||||||
// TODO Phase 3: Arrêter le vrai bridge
|
if (this.bridge) {
|
||||||
// if (this.bridge) {
|
await this.bridge.stop();
|
||||||
// await this.bridge.stop();
|
this.bridge = null;
|
||||||
// this.bridge = null;
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
console.log('✓ AudioBridge arrêté');
|
console.log('✓ AudioBridge arrêté');
|
||||||
|
|||||||
+141
-96
@@ -1,23 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* LiveKitClient.js
|
* LiveKitClient.js
|
||||||
* Client LiveKit pour le bridge audio serveur
|
* Client LiveKit pour le bridge audio serveur (Node.js)
|
||||||
*
|
*
|
||||||
* Gère :
|
* Utilise @livekit/rtc-node pour :
|
||||||
* - Connexion à la room en tant que participant "bridge"
|
* - Connexion à la room en tant que participant "bridge"
|
||||||
* - Publication de track audio (Opus depuis carte son)
|
* - Publication de tracks audio (PCM depuis carte son)
|
||||||
* - Souscription aux tracks des autres participants (clients PWA)
|
* - Souscription aux tracks des autres participants (clients PWA)
|
||||||
|
* - Gestion audio bas niveau (AudioSource/AudioStream)
|
||||||
* - Reconnexion automatique
|
* - Reconnexion automatique
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { Room, RoomEvent, AudioSource, AudioFrame, LocalAudioTrack, TrackSource } from '@livekit/rtc-node';
|
||||||
Room,
|
|
||||||
RoomEvent,
|
|
||||||
RemoteTrack,
|
|
||||||
RemoteParticipant,
|
|
||||||
LocalAudioTrack,
|
|
||||||
TrackPublishOptions,
|
|
||||||
AudioPresets
|
|
||||||
} from 'livekit-client';
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
export class LiveKitClient extends EventEmitter {
|
export class LiveKitClient extends EventEmitter {
|
||||||
@@ -30,11 +23,13 @@ export class LiveKitClient extends EventEmitter {
|
|||||||
participantName: options.participantName || 'AudioBridge',
|
participantName: options.participantName || 'AudioBridge',
|
||||||
token: options.token || null,
|
token: options.token || null,
|
||||||
autoSubscribe: options.autoSubscribe !== false,
|
autoSubscribe: options.autoSubscribe !== false,
|
||||||
audioBitrate: options.audioBitrate || 96000, // 96kbps par défaut
|
sampleRate: options.sampleRate || 48000,
|
||||||
|
channels: options.channels || 1, // Mono par défaut pour PTT
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
this.room = null;
|
this.room = null;
|
||||||
|
this.audioSource = null;
|
||||||
this.localAudioTrack = null;
|
this.localAudioTrack = null;
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
this.reconnecting = false;
|
this.reconnecting = false;
|
||||||
@@ -58,13 +53,8 @@ export class LiveKitClient extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.room = new Room({
|
// Création room
|
||||||
adaptiveStream: true,
|
this.room = new Room();
|
||||||
dynacast: true,
|
|
||||||
reconnectionPolicy: {
|
|
||||||
nextRetryDelayInMs: (retryCount) => Math.min(1000 * Math.pow(2, retryCount), 10000)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configuration des event listeners
|
// Configuration des event listeners
|
||||||
this._setupEventListeners();
|
this._setupEventListeners();
|
||||||
@@ -79,6 +69,10 @@ export class LiveKitClient extends EventEmitter {
|
|||||||
roomName: this.options.roomName,
|
roomName: this.options.roomName,
|
||||||
participantName: this.options.participantName
|
participantName: this.options.participantName
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Création de l'AudioSource pour pouvoir publier de l'audio
|
||||||
|
await this._createAudioSource();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur connexion LiveKit:', error);
|
console.error('Erreur connexion LiveKit:', error);
|
||||||
this.emit('error', error);
|
this.emit('error', error);
|
||||||
@@ -86,6 +80,55 @@ export class LiveKitClient extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une AudioSource pour la publication audio
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _createAudioSource() {
|
||||||
|
try {
|
||||||
|
// Debug: afficher les valeurs avant conversion
|
||||||
|
const sampleRate = parseInt(this.options.sampleRate, 10);
|
||||||
|
const channels = parseInt(this.options.channels, 10);
|
||||||
|
|
||||||
|
console.log('🔍 DEBUG AudioSource:', {
|
||||||
|
sampleRateOriginal: this.options.sampleRate,
|
||||||
|
sampleRateType: typeof this.options.sampleRate,
|
||||||
|
sampleRateConverted: sampleRate,
|
||||||
|
sampleRateConvertedType: typeof sampleRate,
|
||||||
|
channelsOriginal: this.options.channels,
|
||||||
|
channelsType: typeof this.options.channels,
|
||||||
|
channelsConverted: channels,
|
||||||
|
channelsConvertedType: typeof channels
|
||||||
|
});
|
||||||
|
|
||||||
|
// Création de l'AudioSource (conversion en int32 explicite)
|
||||||
|
this.audioSource = new AudioSource(sampleRate, channels);
|
||||||
|
console.log('✓ AudioSource créée:', this.audioSource);
|
||||||
|
|
||||||
|
// Création du LocalAudioTrack depuis l'AudioSource
|
||||||
|
const localTrack = LocalAudioTrack.createAudioTrack('bridge-audio', this.audioSource);
|
||||||
|
console.log('✓ LocalAudioTrack créé:', localTrack);
|
||||||
|
|
||||||
|
// Publication du track
|
||||||
|
const options = {
|
||||||
|
source: TrackSource.SOURCE_MICROPHONE // Simule un microphone pour les clients
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('🔍 DEBUG publishTrack options:', options);
|
||||||
|
this.localAudioTrack = await this.room.localParticipant.publishTrack(
|
||||||
|
localTrack,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✓ AudioSource créée et track publié');
|
||||||
|
this.emit('trackPublished', this.localAudioTrack);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur création AudioSource:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration des event listeners de la room
|
* Configuration des event listeners de la room
|
||||||
* @private
|
* @private
|
||||||
@@ -93,28 +136,17 @@ export class LiveKitClient extends EventEmitter {
|
|||||||
_setupEventListeners() {
|
_setupEventListeners() {
|
||||||
if (!this.room) return;
|
if (!this.room) return;
|
||||||
|
|
||||||
// Connexion/déconnexion
|
// Connexion
|
||||||
this.room.on(RoomEvent.Connected, () => {
|
this.room.on(RoomEvent.Connected, () => {
|
||||||
console.log('✓ Room connectée');
|
console.log('✓ Room connectée');
|
||||||
this.isConnected = true;
|
this.isConnected = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Déconnexion
|
||||||
this.room.on(RoomEvent.Disconnected, (reason) => {
|
this.room.on(RoomEvent.Disconnected, (reason) => {
|
||||||
console.log('⚠ Room déconnectée:', reason);
|
console.log('⚠ Room déconnectée:', reason);
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
this.emit('disconnected', { reason });
|
this.emit('disconnected', { reason: reason || 'unknown' });
|
||||||
});
|
|
||||||
|
|
||||||
this.room.on(RoomEvent.Reconnecting, () => {
|
|
||||||
console.log('🔄 Reconnexion en cours...');
|
|
||||||
this.reconnecting = true;
|
|
||||||
this.emit('reconnecting');
|
|
||||||
});
|
|
||||||
|
|
||||||
this.room.on(RoomEvent.Reconnected, () => {
|
|
||||||
console.log('✓ Reconnecté');
|
|
||||||
this.reconnecting = false;
|
|
||||||
this.emit('reconnected');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Participants
|
// Participants
|
||||||
@@ -133,11 +165,23 @@ export class LiveKitClient extends EventEmitter {
|
|||||||
this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||||
if (track.kind === 'audio') {
|
if (track.kind === 'audio') {
|
||||||
console.log(`🎵 Track audio souscrit de ${participant.identity}`);
|
console.log(`🎵 Track audio souscrit de ${participant.identity}`);
|
||||||
|
|
||||||
|
// Création d'un AudioStream pour recevoir les données PCM
|
||||||
|
const stream = new track.AudioStream(
|
||||||
|
this.options.sampleRate,
|
||||||
|
this.options.channels
|
||||||
|
);
|
||||||
|
|
||||||
this.remoteParticipants.set(participant.sid, {
|
this.remoteParticipants.set(participant.sid, {
|
||||||
participant,
|
participant,
|
||||||
track,
|
track,
|
||||||
publication
|
publication,
|
||||||
|
stream
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Lecture des frames audio
|
||||||
|
this._startAudioReceive(participant.sid, stream);
|
||||||
|
|
||||||
this.emit('audioTrackSubscribed', { track, participant });
|
this.emit('audioTrackSubscribed', { track, participant });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -149,77 +193,72 @@ export class LiveKitClient extends EventEmitter {
|
|||||||
this.emit('audioTrackUnsubscribed', { track, participant });
|
this.emit('audioTrackUnsubscribed', { track, participant });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Données audio
|
|
||||||
this.room.on(RoomEvent.AudioPlaybackStatusChanged, () => {
|
|
||||||
this.emit('audioPlaybackChanged');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Erreurs
|
|
||||||
this.room.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
|
|
||||||
this.emit('qualityChanged', { quality, participant });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publie un track audio local depuis le bridge
|
* Démarre la réception audio d'un participant
|
||||||
* Note: Pour un bridge serveur, on utilise plutôt publishData pour envoyer Opus directement
|
* @private
|
||||||
* @param {MediaStreamTrack} mediaStreamTrack - Track audio du microphone
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
*/
|
||||||
async publishAudioTrack(mediaStreamTrack) {
|
async _startAudioReceive(participantSid, stream) {
|
||||||
if (!this.isConnected) {
|
try {
|
||||||
throw new Error('Pas connecté à LiveKit');
|
// Lecture continue des frames audio
|
||||||
|
for await (const frame of stream) {
|
||||||
|
// frame est un AudioFrame avec :
|
||||||
|
// - data: Buffer PCM int16
|
||||||
|
// - sampleRate: number
|
||||||
|
// - numChannels: number
|
||||||
|
// - samplesPerChannel: number
|
||||||
|
|
||||||
|
const participant = this.remoteParticipants.get(participantSid);
|
||||||
|
if (!participant) break;
|
||||||
|
|
||||||
|
// Émettre les données audio vers AudioBridge
|
||||||
|
this.emit('audioData', {
|
||||||
|
participantSid,
|
||||||
|
participantName: participant.participant.identity,
|
||||||
|
pcmData: frame.data,
|
||||||
|
sampleRate: frame.sampleRate,
|
||||||
|
channels: frame.numChannels,
|
||||||
|
samplesPerChannel: frame.samplesPerChannel
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur réception audio ${participantSid}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie des données audio PCM vers les clients
|
||||||
|
* @param {Buffer} pcmData - Données PCM int16 (mono ou multi-canal)
|
||||||
|
*/
|
||||||
|
async sendAudioData(pcmData) {
|
||||||
|
if (!this.audioSource) {
|
||||||
|
console.warn('AudioSource non initialisée');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Options de publication
|
// Création d'un AudioFrame (conversion en int32 explicite)
|
||||||
const options = {
|
const samplesPerChannel = Math.floor(pcmData.length / 2 / this.options.channels);
|
||||||
name: 'bridge-audio',
|
|
||||||
source: 'microphone',
|
|
||||||
audioBitrate: this.options.audioBitrate
|
|
||||||
};
|
|
||||||
|
|
||||||
this.localAudioTrack = await this.room.localParticipant.publishTrack(
|
const frame = new AudioFrame(
|
||||||
mediaStreamTrack,
|
pcmData,
|
||||||
options
|
parseInt(this.options.sampleRate, 10),
|
||||||
|
parseInt(this.options.channels, 10),
|
||||||
|
samplesPerChannel
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('✓ Track audio local publié');
|
// Envoi via AudioSource
|
||||||
this.emit('trackPublished', this.localAudioTrack);
|
await this.audioSource.captureFrame(frame);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur publication track:', error);
|
console.error('Erreur envoi audio:', error);
|
||||||
this.emit('error', error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Unpublish le track audio local
|
|
||||||
*/
|
|
||||||
async unpublishAudioTrack() {
|
|
||||||
if (this.localAudioTrack) {
|
|
||||||
await this.room.localParticipant.unpublishTrack(this.localAudioTrack);
|
|
||||||
this.localAudioTrack = null;
|
|
||||||
console.log('✓ Track audio local dépublié');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Envoie des données audio Opus directement (pour bridge serveur)
|
|
||||||
* Alternative à publishAudioTrack pour contrôle bas niveau
|
|
||||||
* @param {Buffer} opusData - Données Opus encodées
|
|
||||||
*/
|
|
||||||
sendAudioData(opusData) {
|
|
||||||
// Note: LiveKit ne supporte pas directement l'envoi de données Opus brutes
|
|
||||||
// Cette méthode serait implémentée avec un track custom ou DataChannel
|
|
||||||
// Pour l'instant, on utilise publishAudioTrack avec un MediaStreamTrack
|
|
||||||
console.warn('sendAudioData: Non implémenté, utiliser publishAudioTrack');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère tous les tracks audio distants actifs
|
* Récupère tous les tracks audio distants actifs
|
||||||
* @returns {Array<Object>} Liste des tracks avec métadonnées
|
* @returns {Array<Object>}
|
||||||
*/
|
*/
|
||||||
getRemoteAudioTracks() {
|
getRemoteAudioTracks() {
|
||||||
return Array.from(this.remoteParticipants.values()).map(({ participant, track, publication }) => ({
|
return Array.from(this.remoteParticipants.values()).map(({ participant, track, publication }) => ({
|
||||||
@@ -234,7 +273,7 @@ export class LiveKitClient extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère un participant distant par son SID
|
* Récupère un participant distant par son SID
|
||||||
* @param {string} sid - SID du participant
|
* @param {string} sid
|
||||||
* @returns {Object|null}
|
* @returns {Object|null}
|
||||||
*/
|
*/
|
||||||
getRemoteParticipant(sid) {
|
getRemoteParticipant(sid) {
|
||||||
@@ -261,15 +300,14 @@ export class LiveKitClient extends EventEmitter {
|
|||||||
localParticipant: {
|
localParticipant: {
|
||||||
sid: localParticipant?.sid,
|
sid: localParticipant?.sid,
|
||||||
identity: localParticipant?.identity,
|
identity: localParticipant?.identity,
|
||||||
tracksPublished: localParticipant?.trackPublications.size || 0
|
tracksPublished: localParticipant?.trackPublications?.size || 0
|
||||||
},
|
},
|
||||||
remoteParticipants: {
|
remoteParticipants: {
|
||||||
count: participants.size,
|
count: participants.size,
|
||||||
list: Array.from(participants.values()).map(p => ({
|
list: Array.from(participants.values()).map(p => ({
|
||||||
sid: p.sid,
|
sid: p.sid,
|
||||||
identity: p.identity,
|
identity: p.identity,
|
||||||
audioTracks: Array.from(p.audioTrackPublications.values()).length,
|
audioTracks: Array.from(p.audioTrackPublications?.values() || []).length
|
||||||
connectionQuality: p.connectionQuality
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -280,9 +318,16 @@ export class LiveKitClient extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
async disconnect() {
|
async disconnect() {
|
||||||
if (this.room) {
|
if (this.room) {
|
||||||
await this.unpublishAudioTrack();
|
// Unpublish track
|
||||||
this.room.disconnect();
|
if (this.localAudioTrack) {
|
||||||
|
await this.room.localParticipant.unpublishTrack(this.localAudioTrack.sid);
|
||||||
|
this.localAudioTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déconnexion
|
||||||
|
await this.room.disconnect();
|
||||||
this.room = null;
|
this.room = null;
|
||||||
|
this.audioSource = null;
|
||||||
this.isConnected = false;
|
this.isConnected = false;
|
||||||
this.remoteParticipants.clear();
|
this.remoteParticipants.clear();
|
||||||
console.log('✓ Déconnecté de LiveKit');
|
console.log('✓ Déconnecté de LiveKit');
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* LiveKitServerBridge.js
|
||||||
|
* Pont entre AudioBridge (cartes son) et LiveKit (clients WebRTC)
|
||||||
|
*
|
||||||
|
* Agit comme un participant virtuel qui :
|
||||||
|
* - Publie l'audio des cartes son vers les clients WebRTC
|
||||||
|
* - Reçoit l'audio des clients et le renvoie vers les cartes son
|
||||||
|
*
|
||||||
|
* Architecture :
|
||||||
|
* [Carte Son] → AudioBridge → LiveKitServerBridge → LiveKit SFU → [Clients WebRTC]
|
||||||
|
* ↑
|
||||||
|
* Gère le routing par groupe
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RoomServiceClient, AccessToken, TrackSource } from 'livekit-server-sdk';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
export class LiveKitServerBridge extends EventEmitter {
|
||||||
|
constructor(audioBridge, options = {}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.audioBridge = audioBridge;
|
||||||
|
|
||||||
|
this.options = {
|
||||||
|
url: options.url || 'ws://localhost:7880',
|
||||||
|
apiKey: options.apiKey || process.env.LIVEKIT_API_KEY,
|
||||||
|
apiSecret: options.apiSecret || process.env.LIVEKIT_API_SECRET,
|
||||||
|
roomName: options.roomName || 'main',
|
||||||
|
participantName: options.participantName || 'AudioBridge',
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.roomServiceClient = null;
|
||||||
|
this.activeGroups = new Map(); // Map<groupName, { participants, audioData }>
|
||||||
|
this.isConnected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise la connexion au serveur LiveKit
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
try {
|
||||||
|
// Créer le client pour l'API LiveKit
|
||||||
|
this.roomServiceClient = new RoomServiceClient(
|
||||||
|
this.options.url.replace('ws://', 'http://').replace('wss://', 'https://'),
|
||||||
|
this.options.apiKey,
|
||||||
|
this.options.apiSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✓ LiveKitServerBridge : Connexion API établie');
|
||||||
|
|
||||||
|
// Configurer les événements AudioBridge
|
||||||
|
this._setupAudioBridgeListeners();
|
||||||
|
|
||||||
|
this.isConnected = true;
|
||||||
|
this.emit('connected');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur connexion LiveKitServerBridge:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure les listeners pour l'AudioBridge
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_setupAudioBridgeListeners() {
|
||||||
|
// FLUX SORTANT : Carte son → Groupes → LiveKit
|
||||||
|
this.audioBridge.on('groupAudioOut', ({ groupName, opusData, pcmBuffer }) => {
|
||||||
|
this._handleGroupAudioOut(groupName, opusData, pcmBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✓ LiveKitServerBridge : Listeners AudioBridge configurés');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gère l'audio sortant d'un groupe vers LiveKit
|
||||||
|
* @param {string} groupName - Nom du groupe
|
||||||
|
* @param {Buffer} opusData - Données Opus encodées
|
||||||
|
* @param {Buffer} pcmBuffer - Données PCM (pour debug)
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _handleGroupAudioOut(groupName, opusData, pcmBuffer) {
|
||||||
|
try {
|
||||||
|
// Pour l'instant, on stocke les données pour les envoyer via DataChannel
|
||||||
|
// ou via un participant virtuel par groupe
|
||||||
|
|
||||||
|
// IMPLÉMENTATION PHASE 3+ :
|
||||||
|
// Option A : Utiliser @livekit/rtc-node pour créer un AudioSource par groupe
|
||||||
|
// Option B : Utiliser DataChannel pour envoyer Opus directement
|
||||||
|
// Option C : Utiliser un participant virtuel par groupe (simple mais plus de ressources)
|
||||||
|
|
||||||
|
// Pour Phase actuelle, on émet un événement pour debug/monitoring
|
||||||
|
this.emit('groupAudioProcessed', {
|
||||||
|
groupName,
|
||||||
|
opusSize: opusData.length,
|
||||||
|
pcmSize: pcmBuffer.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implémenter l'envoi réel vers LiveKit
|
||||||
|
// Voir docs/LIVEKIT_AUDIO_BRIDGE.md pour les 3 approches possibles
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur envoi audio groupe ${groupName}:`, error);
|
||||||
|
this.emit('error', { groupName, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Méthode pour simuler la réception d'audio depuis LiveKit
|
||||||
|
* (À connecter avec le vrai système LiveKit via webhook ou polling)
|
||||||
|
*
|
||||||
|
* @param {string} groupName - Nom du groupe
|
||||||
|
* @param {Buffer} pcmBuffer - Audio PCM depuis un client
|
||||||
|
*/
|
||||||
|
injectGroupAudioIn(groupName, pcmBuffer) {
|
||||||
|
// Envoyer vers AudioBridge pour routing vers la carte son
|
||||||
|
this.audioBridge.emit('groupAudioIn', { groupName, pcmBuffer });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère un token d'accès pour un client
|
||||||
|
* @param {string} identity - Identité du participant (ex: "user123")
|
||||||
|
* @param {string} groupName - Groupe à rejoindre
|
||||||
|
* @returns {string} JWT token
|
||||||
|
*/
|
||||||
|
async generateClientToken(identity, groupName) {
|
||||||
|
const at = new AccessToken(
|
||||||
|
this.options.apiKey,
|
||||||
|
this.options.apiSecret,
|
||||||
|
{
|
||||||
|
identity,
|
||||||
|
name: identity,
|
||||||
|
ttl: '24h'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
at.addGrant({
|
||||||
|
room: groupName, // Chaque groupe = une room LiveKit
|
||||||
|
roomJoin: true,
|
||||||
|
canPublish: true,
|
||||||
|
canSubscribe: true,
|
||||||
|
canPublishData: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return at.toJwt();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste tous les participants actifs dans une room/groupe
|
||||||
|
* @param {string} groupName - Nom du groupe
|
||||||
|
* @returns {Promise<Array>} Liste des participants
|
||||||
|
*/
|
||||||
|
async listParticipants(groupName) {
|
||||||
|
try {
|
||||||
|
const participants = await this.roomServiceClient.listParticipants(groupName);
|
||||||
|
return participants;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur listing participants ${groupName}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si une room/groupe existe
|
||||||
|
* @param {string} groupName - Nom du groupe
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async roomExists(groupName) {
|
||||||
|
try {
|
||||||
|
const rooms = await this.roomServiceClient.listRooms();
|
||||||
|
return rooms.some(room => room.name === groupName);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur vérification room:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une room/groupe si elle n'existe pas
|
||||||
|
* @param {string} groupName - Nom du groupe
|
||||||
|
*/
|
||||||
|
async ensureRoomExists(groupName) {
|
||||||
|
const exists = await this.roomExists(groupName);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
try {
|
||||||
|
await this.roomServiceClient.createRoom({
|
||||||
|
name: groupName,
|
||||||
|
emptyTimeout: 300, // 5 minutes timeout si vide
|
||||||
|
maxParticipants: 50
|
||||||
|
});
|
||||||
|
console.log(`✓ Room créée : ${groupName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Erreur création room ${groupName}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient les statistiques du bridge
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
connected: this.isConnected,
|
||||||
|
activeGroups: this.activeGroups.size,
|
||||||
|
apiUrl: this.options.url,
|
||||||
|
roomName: this.options.roomName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déconnexion
|
||||||
|
*/
|
||||||
|
async disconnect() {
|
||||||
|
if (this.audioBridge) {
|
||||||
|
this.audioBridge.removeAllListeners('groupAudioOut');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeGroups.clear();
|
||||||
|
this.isConnected = false;
|
||||||
|
|
||||||
|
console.log('✓ LiveKitServerBridge déconnecté');
|
||||||
|
this.emit('disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détruit le bridge et libère les ressources
|
||||||
|
*/
|
||||||
|
async destroy() {
|
||||||
|
await this.disconnect();
|
||||||
|
this.removeAllListeners();
|
||||||
|
console.log('✓ LiveKitServerBridge détruit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LiveKitServerBridge;
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* CoreAudioBackend.js
|
* CoreAudioBackend.js
|
||||||
* Backend audio natif macOS utilisant naudiodon (bindings PortAudio/CoreAudio)
|
* Backend audio natif macOS utilisant sox (Sound eXchange)
|
||||||
|
*
|
||||||
|
* Note: naudiodon était instable (segfaults), remplacé par sox en subprocess
|
||||||
|
* sox est stable, installé par défaut sur macOS, et supporte toutes les cartes
|
||||||
*
|
*
|
||||||
* Gère :
|
* Gère :
|
||||||
* - Énumération des devices audio
|
* - Énumération des devices audio via system_profiler
|
||||||
* - Capture audio (microphone/carte son)
|
* - Capture audio via sox (rec)
|
||||||
* - Lecture audio (speakers/sortie audio)
|
* - Lecture audio via sox (play)
|
||||||
* - Buffer circulaire pour flux continu
|
* - Buffer circulaire pour flux continu
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import portAudio from 'naudiodon';
|
import { spawn, execSync } from 'child_process';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
export class CoreAudioBackend extends EventEmitter {
|
export class CoreAudioBackend extends EventEmitter {
|
||||||
@@ -18,38 +21,103 @@ export class CoreAudioBackend extends EventEmitter {
|
|||||||
|
|
||||||
this.options = {
|
this.options = {
|
||||||
sampleRate: options.sampleRate || 48000,
|
sampleRate: options.sampleRate || 48000,
|
||||||
channels: options.channels || 1, // Mono par défaut
|
channels: options.channels || 1,
|
||||||
framesPerBuffer: options.framesPerBuffer || 960, // 20ms à 48kHz
|
framesPerBuffer: options.framesPerBuffer || 960,
|
||||||
inputDeviceId: options.inputDeviceId || null,
|
inputDeviceName: options.inputDeviceName || null,
|
||||||
outputDeviceId: options.outputDeviceId || null,
|
outputDeviceName: options.outputDeviceName || null,
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
this.inputStream = null;
|
this.captureProcess = null;
|
||||||
this.outputStream = null;
|
this.playbackProcess = null;
|
||||||
this.isCapturing = false;
|
this.isCapturing = false;
|
||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
|
|
||||||
// Buffer circulaire pour la lecture
|
// Buffer circulaire pour la lecture
|
||||||
this.playbackBuffer = [];
|
this.playbackBuffer = [];
|
||||||
this.maxBufferSize = 10; // Max 10 chunks en buffer
|
this.maxBufferSize = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste tous les devices audio disponibles
|
* Liste tous les devices audio disponibles via system_profiler
|
||||||
* @returns {Array} Liste des devices
|
* @returns {Array} Liste des devices
|
||||||
*/
|
*/
|
||||||
static getDevices() {
|
static getDevices() {
|
||||||
try {
|
try {
|
||||||
// WORKAROUND: naudiodon a un bug connu qui cause un segfault
|
const output = execSync('system_profiler SPAudioDataType -json', { encoding: 'utf8' });
|
||||||
// On retourne des devices fictifs pour le développement
|
const data = JSON.parse(output);
|
||||||
// TODO: Remplacer par un backend plus stable (node-portaudio ou JACK)
|
|
||||||
console.warn('⚠️ CoreAudio.getDevices(): utilisation de devices fictifs (naudiodon instable)');
|
|
||||||
|
|
||||||
|
const devices = [];
|
||||||
|
let id = 0;
|
||||||
|
|
||||||
|
// Parse audio devices
|
||||||
|
if (data.SPAudioDataType) {
|
||||||
|
data.SPAudioDataType.forEach(item => {
|
||||||
|
if (item._items) {
|
||||||
|
item._items.forEach(device => {
|
||||||
|
const name = device._name || 'Unknown Device';
|
||||||
|
|
||||||
|
// Les clés coreaudio_device_input/output contiennent le nombre de canaux
|
||||||
|
const inputChannels = parseInt(device.coreaudio_device_input) || 0;
|
||||||
|
const outputChannels = parseInt(device.coreaudio_device_output) || 0;
|
||||||
|
const sampleRate = parseInt(device.coreaudio_device_srate) || 48000;
|
||||||
|
|
||||||
|
// Ignorer les devices sans input ni output
|
||||||
|
if (inputChannels === 0 && outputChannels === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.push({
|
||||||
|
id: id++,
|
||||||
|
name: name,
|
||||||
|
maxInputChannels: inputChannels,
|
||||||
|
maxOutputChannels: outputChannels,
|
||||||
|
defaultSampleRate: sampleRate,
|
||||||
|
hostAPIName: 'Core Audio',
|
||||||
|
manufacturer: device.coreaudio_device_manufacturer || 'Unknown',
|
||||||
|
transport: device.coreaudio_device_transport || 'unknown',
|
||||||
|
isDefault: {
|
||||||
|
input: device.coreaudio_default_audio_input_device === 'spaudio_yes',
|
||||||
|
output: device.coreaudio_default_audio_output_device === 'spaudio_yes'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter devices par défaut si liste vide
|
||||||
|
if (devices.length === 0) {
|
||||||
|
devices.push(
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
name: 'Built-in Microphone',
|
||||||
|
maxInputChannels: 1,
|
||||||
|
maxOutputChannels: 0,
|
||||||
|
defaultSampleRate: 48000,
|
||||||
|
hostAPIName: 'Core Audio'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Built-in Output',
|
||||||
|
maxInputChannels: 0,
|
||||||
|
maxOutputChannels: 2,
|
||||||
|
defaultSampleRate: 48000,
|
||||||
|
hostAPIName: 'Core Audio'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ CoreAudio: ${devices.length} devices détectés`);
|
||||||
|
return devices;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur énumération devices CoreAudio:', error);
|
||||||
|
|
||||||
|
// Fallback : devices par défaut
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 0,
|
id: 0,
|
||||||
name: 'MacBook Pro Microphone',
|
name: 'Built-in Microphone',
|
||||||
maxInputChannels: 1,
|
maxInputChannels: 1,
|
||||||
maxOutputChannels: 0,
|
maxOutputChannels: 0,
|
||||||
defaultSampleRate: 48000,
|
defaultSampleRate: 48000,
|
||||||
@@ -57,35 +125,13 @@ export class CoreAudioBackend extends EventEmitter {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'MacBook Pro Speakers',
|
name: 'Built-in Output',
|
||||||
maxInputChannels: 0,
|
maxInputChannels: 0,
|
||||||
maxOutputChannels: 2,
|
maxOutputChannels: 2,
|
||||||
defaultSampleRate: 48000,
|
defaultSampleRate: 48000,
|
||||||
hostAPIName: 'Core Audio'
|
hostAPIName: 'Core Audio'
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'External Audio Interface',
|
|
||||||
maxInputChannels: 8,
|
|
||||||
maxOutputChannels: 8,
|
|
||||||
defaultSampleRate: 48000,
|
|
||||||
hostAPIName: 'Core Audio'
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Code original (commenté à cause du segfault)
|
|
||||||
// const devices = portAudio.getDevices();
|
|
||||||
// return devices.map((device, index) => ({
|
|
||||||
// id: index,
|
|
||||||
// name: device.name,
|
|
||||||
// maxInputChannels: device.maxInputChannels,
|
|
||||||
// maxOutputChannels: device.maxOutputChannels,
|
|
||||||
// defaultSampleRate: device.defaultSampleRate,
|
|
||||||
// hostAPIName: device.hostAPIName
|
|
||||||
// }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur énumération devices CoreAudio:', error);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,8 +140,17 @@ export class CoreAudioBackend extends EventEmitter {
|
|||||||
* @returns {Object|null} Device d'entrée par défaut
|
* @returns {Object|null} Device d'entrée par défaut
|
||||||
*/
|
*/
|
||||||
static getDefaultInputDevice() {
|
static getDefaultInputDevice() {
|
||||||
const devices = this.getDevices();
|
try {
|
||||||
return devices.find(d => d.maxInputChannels > 0) || null;
|
const devices = this.getDevices();
|
||||||
|
// Chercher d'abord le device marqué comme default
|
||||||
|
const defaultDevice = devices.find(d => d.isDefault?.input && d.maxInputChannels > 0);
|
||||||
|
if (defaultDevice) return defaultDevice;
|
||||||
|
// Fallback: premier device avec input
|
||||||
|
return devices.find(d => d.maxInputChannels > 0) || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur getDefaultInputDevice:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,12 +158,21 @@ export class CoreAudioBackend extends EventEmitter {
|
|||||||
* @returns {Object|null} Device de sortie par défaut
|
* @returns {Object|null} Device de sortie par défaut
|
||||||
*/
|
*/
|
||||||
static getDefaultOutputDevice() {
|
static getDefaultOutputDevice() {
|
||||||
const devices = this.getDevices();
|
try {
|
||||||
return devices.find(d => d.maxOutputChannels > 0) || null;
|
const devices = this.getDevices();
|
||||||
|
// Chercher d'abord le device marqué comme default
|
||||||
|
const defaultDevice = devices.find(d => d.isDefault?.output && d.maxOutputChannels > 0);
|
||||||
|
if (defaultDevice) return defaultDevice;
|
||||||
|
// Fallback: premier device avec output
|
||||||
|
return devices.find(d => d.maxOutputChannels > 0) || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur getDefaultOutputDevice:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Démarre la capture audio
|
* Démarre la capture audio via sox (rec)
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async startCapture() {
|
async startCapture() {
|
||||||
@@ -118,36 +182,55 @@ export class CoreAudioBackend extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const inputConfig = {
|
// Commande sox pour capturer audio
|
||||||
channelCount: this.options.channels,
|
// rec : enregistrer depuis input par défaut
|
||||||
sampleFormat: portAudio.SampleFormat16Bit,
|
// -t raw : format raw PCM
|
||||||
sampleRate: this.options.sampleRate,
|
// -b 16 : 16-bit
|
||||||
deviceId: this.options.inputDeviceId ?? undefined,
|
// -e signed-integer : signed PCM
|
||||||
closeOnError: true
|
// -c 1 : mono (ou nombre de canaux)
|
||||||
};
|
// -r 48000 : sample rate
|
||||||
|
// - : sortie vers stdout
|
||||||
|
const args = [
|
||||||
|
'-t', 'coreaudio', // Driver CoreAudio
|
||||||
|
'default', // Device par défaut (ou spécifier nom)
|
||||||
|
'-t', 'raw',
|
||||||
|
'-b', '16',
|
||||||
|
'-e', 'signed-integer',
|
||||||
|
`-c`, String(this.options.channels),
|
||||||
|
`-r`, String(this.options.sampleRate),
|
||||||
|
'-' // Stdout
|
||||||
|
];
|
||||||
|
|
||||||
this.inputStream = new portAudio.AudioIO({
|
// Si device spécifié
|
||||||
inOptions: inputConfig
|
if (this.options.inputDeviceName) {
|
||||||
});
|
args[1] = this.options.inputDeviceName;
|
||||||
|
}
|
||||||
|
|
||||||
this.inputStream.on('data', (audioData) => {
|
this.captureProcess = spawn('sox', args);
|
||||||
|
|
||||||
|
this.captureProcess.stdout.on('data', (audioData) => {
|
||||||
// Émet les données audio capturées (Buffer PCM 16-bit)
|
// Émet les données audio capturées (Buffer PCM 16-bit)
|
||||||
this.emit('audioData', audioData);
|
this.emit('audioData', audioData);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.inputStream.on('error', (error) => {
|
this.captureProcess.stderr.on('data', (data) => {
|
||||||
console.error('Erreur stream capture:', error);
|
const msg = data.toString();
|
||||||
|
if (!msg.includes('sox WARN')) {
|
||||||
|
console.error('sox capture stderr:', msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.captureProcess.on('error', (error) => {
|
||||||
|
console.error('Erreur processus sox capture:', error);
|
||||||
this.emit('error', error);
|
this.emit('error', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.inputStream.on('close', () => {
|
this.captureProcess.on('close', (code) => {
|
||||||
console.log('Stream capture fermé');
|
console.log(`Sox capture fermé (code ${code})`);
|
||||||
this.isCapturing = false;
|
this.isCapturing = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.inputStream.start();
|
|
||||||
this.isCapturing = true;
|
this.isCapturing = true;
|
||||||
|
|
||||||
console.log(`✓ Capture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
|
console.log(`✓ Capture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur démarrage capture:', error);
|
console.error('Erreur démarrage capture:', error);
|
||||||
@@ -159,16 +242,16 @@ export class CoreAudioBackend extends EventEmitter {
|
|||||||
* Arrête la capture audio
|
* Arrête la capture audio
|
||||||
*/
|
*/
|
||||||
stopCapture() {
|
stopCapture() {
|
||||||
if (this.inputStream && this.isCapturing) {
|
if (this.captureProcess && this.isCapturing) {
|
||||||
this.inputStream.quit();
|
this.captureProcess.kill('SIGTERM');
|
||||||
this.inputStream = null;
|
this.captureProcess = null;
|
||||||
this.isCapturing = false;
|
this.isCapturing = false;
|
||||||
console.log('✓ Capture audio arrêtée');
|
console.log('✓ Capture audio arrêtée');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Démarre la lecture audio
|
* Démarre la lecture audio via sox (play)
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async startPlayback() {
|
async startPlayback() {
|
||||||
@@ -178,33 +261,55 @@ export class CoreAudioBackend extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const outputConfig = {
|
// Commande sox pour lecture audio
|
||||||
channelCount: this.options.channels,
|
// play : lire vers output par défaut
|
||||||
sampleFormat: portAudio.SampleFormat16Bit,
|
// -t raw : format raw PCM depuis stdin
|
||||||
sampleRate: this.options.sampleRate,
|
const args = [
|
||||||
deviceId: this.options.outputDeviceId ?? undefined,
|
'-t', 'raw',
|
||||||
closeOnError: true
|
'-b', '16',
|
||||||
};
|
'-e', 'signed-integer',
|
||||||
|
`-c`, String(this.options.channels),
|
||||||
|
`-r`, String(this.options.sampleRate),
|
||||||
|
'-', // Stdin
|
||||||
|
'-t', 'coreaudio',
|
||||||
|
'default' // Device par défaut
|
||||||
|
];
|
||||||
|
|
||||||
this.outputStream = new portAudio.AudioIO({
|
// Si device spécifié
|
||||||
outOptions: outputConfig
|
if (this.options.outputDeviceName) {
|
||||||
|
args[args.length - 1] = this.options.outputDeviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playbackProcess = spawn('sox', args);
|
||||||
|
|
||||||
|
// Gérer l'erreur EPIPE sur stdin (si processus se ferme)
|
||||||
|
this.playbackProcess.stdin.on('error', (error) => {
|
||||||
|
if (error.code === 'EPIPE') {
|
||||||
|
console.warn('⚠️ Sox playback stdin fermé (EPIPE)');
|
||||||
|
this.isPlaying = false;
|
||||||
|
} else {
|
||||||
|
console.error('Erreur stdin sox playback:', error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.outputStream.on('error', (error) => {
|
this.playbackProcess.stderr.on('data', (data) => {
|
||||||
console.error('Erreur stream lecture:', error);
|
const msg = data.toString();
|
||||||
|
if (!msg.includes('sox WARN')) {
|
||||||
|
console.error('sox playback stderr:', msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.playbackProcess.on('error', (error) => {
|
||||||
|
console.error('Erreur processus sox playback:', error);
|
||||||
this.emit('error', error);
|
this.emit('error', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.outputStream.on('close', () => {
|
this.playbackProcess.on('close', (code) => {
|
||||||
console.log('Stream lecture fermé');
|
console.log(`Sox playback fermé (code ${code})`);
|
||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Démarrage du stream de lecture
|
|
||||||
this.outputStream.start();
|
|
||||||
this.isPlaying = true;
|
this.isPlaying = true;
|
||||||
|
|
||||||
// Boucle de lecture du buffer circulaire
|
|
||||||
this._startPlaybackLoop();
|
this._startPlaybackLoop();
|
||||||
|
|
||||||
console.log(`✓ Lecture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
|
console.log(`✓ Lecture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
|
||||||
@@ -218,9 +323,9 @@ export class CoreAudioBackend extends EventEmitter {
|
|||||||
* Arrête la lecture audio
|
* Arrête la lecture audio
|
||||||
*/
|
*/
|
||||||
stopPlayback() {
|
stopPlayback() {
|
||||||
if (this.outputStream && this.isPlaying) {
|
if (this.playbackProcess && this.isPlaying) {
|
||||||
this.outputStream.quit();
|
this.playbackProcess.kill('SIGTERM');
|
||||||
this.outputStream = null;
|
this.playbackProcess = null;
|
||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
this.playbackBuffer = [];
|
this.playbackBuffer = [];
|
||||||
console.log('✓ Lecture audio arrêtée');
|
console.log('✓ Lecture audio arrêtée');
|
||||||
@@ -252,19 +357,36 @@ export class CoreAudioBackend extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
_startPlaybackLoop() {
|
_startPlaybackLoop() {
|
||||||
const playNextChunk = () => {
|
const playNextChunk = () => {
|
||||||
if (!this.isPlaying) return;
|
if (!this.isPlaying || !this.playbackProcess || !this.playbackProcess.stdin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.playbackBuffer.length > 0) {
|
if (this.playbackBuffer.length > 0) {
|
||||||
const chunk = this.playbackBuffer.shift();
|
const chunk = this.playbackBuffer.shift();
|
||||||
this.outputStream.write(chunk);
|
try {
|
||||||
|
if (this.playbackProcess.stdin.writable) {
|
||||||
|
this.playbackProcess.stdin.write(chunk);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur écriture stdin sox:', error);
|
||||||
|
this.isPlaying = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Buffer vide : underrun (on envoie du silence)
|
// Buffer vide : underrun (silence)
|
||||||
const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
|
const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
|
||||||
this.outputStream.write(silenceBuffer);
|
try {
|
||||||
|
if (this.playbackProcess.stdin.writable) {
|
||||||
|
this.playbackProcess.stdin.write(silenceBuffer);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore si process fermé
|
||||||
|
this.isPlaying = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.emit('bufferUnderrun');
|
this.emit('bufferUnderrun');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rappel à intervalle régulier (20ms pour 960 frames à 48kHz)
|
|
||||||
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
|
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
|
||||||
setTimeout(playNextChunk, intervalMs);
|
setTimeout(playNextChunk, intervalMs);
|
||||||
};
|
};
|
||||||
@@ -283,14 +405,17 @@ export class CoreAudioBackend extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vérifie si CoreAudio est disponible sur le système
|
* Vérifie si CoreAudio/sox est disponible sur le système
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
static isAvailable() {
|
static isAvailable() {
|
||||||
try {
|
try {
|
||||||
const devices = portAudio.getDevices();
|
// Vérifier si sox est installé
|
||||||
return devices.length > 0;
|
execSync('which sox', { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// sox n'est pas installé
|
||||||
|
console.warn('sox non installé. Installer avec : brew install sox');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
/**
|
||||||
|
* JACKBackend.js
|
||||||
|
* Backend audio pour Linux utilisant JACK Audio Connection Kit
|
||||||
|
*
|
||||||
|
* Gère :
|
||||||
|
* - Connexion au serveur JACK
|
||||||
|
* - Ports audio input/output
|
||||||
|
* - Capture et lecture audio temps réel
|
||||||
|
* - Détection automatique du serveur JACK
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, execSync } from 'child_process';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
export class JACKBackend extends EventEmitter {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.options = {
|
||||||
|
sampleRate: options.sampleRate || 48000,
|
||||||
|
channels: options.channels || 1,
|
||||||
|
framesPerBuffer: options.framesPerBuffer || 960, // 20ms à 48kHz
|
||||||
|
clientName: options.clientName || 'PTTLive',
|
||||||
|
autoConnect: options.autoConnect !== false,
|
||||||
|
inputPorts: options.inputPorts || [],
|
||||||
|
outputPorts: options.outputPorts || [],
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.jackProcess = null;
|
||||||
|
this.isCapturing = false;
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.playbackBuffer = [];
|
||||||
|
this.maxBufferSize = 10;
|
||||||
|
|
||||||
|
// Ports JACK créés
|
||||||
|
this.capturePort = null;
|
||||||
|
this.playbackPort = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si JACK est installé et disponible
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
static isAvailable() {
|
||||||
|
try {
|
||||||
|
execSync('which jackd', { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si le serveur JACK est en cours d'exécution
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
static isServerRunning() {
|
||||||
|
try {
|
||||||
|
execSync('jack_lsp', { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste tous les ports JACK disponibles
|
||||||
|
* @returns {Array} Liste des ports
|
||||||
|
*/
|
||||||
|
static getPorts() {
|
||||||
|
try {
|
||||||
|
const output = execSync('jack_lsp', { encoding: 'utf8' });
|
||||||
|
const ports = output.trim().split('\n').filter(p => p.length > 0);
|
||||||
|
|
||||||
|
return ports.map(port => {
|
||||||
|
const isOutput = port.includes('capture') || port.includes('output');
|
||||||
|
const isInput = port.includes('playback') || port.includes('input');
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: port,
|
||||||
|
type: isOutput ? 'output' : (isInput ? 'input' : 'unknown'),
|
||||||
|
isPhysical: port.includes('system:')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur listage ports JACK:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste les devices audio via JACK (ports système)
|
||||||
|
* @returns {Array} Liste des devices
|
||||||
|
*/
|
||||||
|
static getDevices() {
|
||||||
|
if (!this.isServerRunning()) {
|
||||||
|
console.warn('Serveur JACK non démarré');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ports = this.getPorts();
|
||||||
|
const systemPorts = ports.filter(p => p.isPhysical);
|
||||||
|
|
||||||
|
// Grouper par device (system:capture_*, system:playback_*)
|
||||||
|
const devices = [];
|
||||||
|
|
||||||
|
// Ports d'entrée (capture)
|
||||||
|
const capturePorts = systemPorts.filter(p => p.name.includes('capture'));
|
||||||
|
if (capturePorts.length > 0) {
|
||||||
|
devices.push({
|
||||||
|
id: 'jack-input',
|
||||||
|
name: 'JACK System Capture',
|
||||||
|
maxInputChannels: capturePorts.length,
|
||||||
|
maxOutputChannels: 0,
|
||||||
|
defaultSampleRate: this._getServerSampleRate(),
|
||||||
|
hostAPIName: 'JACK',
|
||||||
|
ports: capturePorts.map(p => p.name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ports de sortie (playback)
|
||||||
|
const playbackPorts = systemPorts.filter(p => p.name.includes('playback'));
|
||||||
|
if (playbackPorts.length > 0) {
|
||||||
|
devices.push({
|
||||||
|
id: 'jack-output',
|
||||||
|
name: 'JACK System Playback',
|
||||||
|
maxInputChannels: 0,
|
||||||
|
maxOutputChannels: playbackPorts.length,
|
||||||
|
defaultSampleRate: this._getServerSampleRate(),
|
||||||
|
hostAPIName: 'JACK',
|
||||||
|
ports: playbackPorts.map(p => p.name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur énumération devices JACK:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère le sample rate du serveur JACK
|
||||||
|
* @returns {number}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
static _getServerSampleRate() {
|
||||||
|
try {
|
||||||
|
const output = execSync('jack_samplerate', { encoding: 'utf8' });
|
||||||
|
return parseInt(output.trim()) || 48000;
|
||||||
|
} catch (error) {
|
||||||
|
return 48000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère la taille du buffer du serveur JACK
|
||||||
|
* @returns {number}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
static _getServerBufferSize() {
|
||||||
|
try {
|
||||||
|
const output = execSync('jack_bufsize', { encoding: 'utf8' });
|
||||||
|
return parseInt(output.trim()) || 1024;
|
||||||
|
} catch (error) {
|
||||||
|
return 1024;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve le device par défaut pour l'entrée
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
static getDefaultInputDevice() {
|
||||||
|
const devices = this.getDevices();
|
||||||
|
return devices.find(d => d.maxInputChannels > 0) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve le device par défaut pour la sortie
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
static getDefaultOutputDevice() {
|
||||||
|
const devices = this.getDevices();
|
||||||
|
return devices.find(d => d.maxOutputChannels > 0) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre la capture audio
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async startCapture() {
|
||||||
|
if (this.isCapturing) {
|
||||||
|
console.warn('Capture JACK déjà active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!JACKBackend.isServerRunning()) {
|
||||||
|
throw new Error('Serveur JACK non démarré. Lancez jackd avant de continuer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Utilisation de jack_rec pour capturer l'audio
|
||||||
|
const portName = this.options.inputPorts[0] || 'system:capture_1';
|
||||||
|
|
||||||
|
this.jackProcess = spawn('jack_rec', [
|
||||||
|
'-f', '-', // Sortie vers stdout
|
||||||
|
'-d', String(this.options.framesPerBuffer),
|
||||||
|
'-b', '16', // 16-bit PCM
|
||||||
|
portName
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.jackProcess.stdout.on('data', (audioData) => {
|
||||||
|
// Émet les données audio capturées (Buffer PCM 16-bit)
|
||||||
|
this.emit('audioData', audioData);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.jackProcess.stderr.on('data', (data) => {
|
||||||
|
console.error('JACK stderr:', data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
this.jackProcess.on('error', (error) => {
|
||||||
|
console.error('Erreur processus JACK:', error);
|
||||||
|
this.emit('error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.jackProcess.on('close', () => {
|
||||||
|
console.log('Processus JACK capture fermé');
|
||||||
|
this.isCapturing = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.isCapturing = true;
|
||||||
|
console.log(`✓ Capture JACK démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
|
||||||
|
console.log(` Port: ${portName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur démarrage capture JACK:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrête la capture audio
|
||||||
|
*/
|
||||||
|
stopCapture() {
|
||||||
|
if (this.jackProcess && this.isCapturing) {
|
||||||
|
this.jackProcess.kill('SIGTERM');
|
||||||
|
this.jackProcess = null;
|
||||||
|
this.isCapturing = false;
|
||||||
|
console.log('✓ Capture JACK arrêtée');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre la lecture audio
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async startPlayback() {
|
||||||
|
if (this.isPlaying) {
|
||||||
|
console.warn('Lecture JACK déjà active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!JACKBackend.isServerRunning()) {
|
||||||
|
throw new Error('Serveur JACK non démarré');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const portName = this.options.outputPorts[0] || 'system:playback_1';
|
||||||
|
|
||||||
|
this.playbackProcess = spawn('jack_play', [
|
||||||
|
'-f', '-', // Lecture depuis stdin
|
||||||
|
'-b', '16', // 16-bit PCM
|
||||||
|
portName
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.playbackProcess.on('error', (error) => {
|
||||||
|
console.error('Erreur processus JACK playback:', error);
|
||||||
|
this.emit('error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.playbackProcess.stderr.on('data', (data) => {
|
||||||
|
console.error('JACK playback stderr:', data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
this.playbackProcess.on('close', () => {
|
||||||
|
console.log('Processus JACK playback fermé');
|
||||||
|
this.isPlaying = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.isPlaying = true;
|
||||||
|
this._startPlaybackLoop();
|
||||||
|
|
||||||
|
console.log(`✓ Lecture JACK démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
|
||||||
|
console.log(` Port: ${portName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur démarrage lecture JACK:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrête la lecture audio
|
||||||
|
*/
|
||||||
|
stopPlayback() {
|
||||||
|
if (this.playbackProcess && this.isPlaying) {
|
||||||
|
this.playbackProcess.kill('SIGTERM');
|
||||||
|
this.playbackProcess = null;
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.playbackBuffer = [];
|
||||||
|
console.log('✓ Lecture JACK arrêtée');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute des données audio au buffer de lecture
|
||||||
|
* @param {Buffer} audioData - Données PCM 16-bit
|
||||||
|
*/
|
||||||
|
queueAudio(audioData) {
|
||||||
|
if (!this.isPlaying) {
|
||||||
|
console.warn('Tentative ajout audio alors que lecture JACK inactive');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.playbackBuffer.length < this.maxBufferSize) {
|
||||||
|
this.playbackBuffer.push(audioData);
|
||||||
|
} else {
|
||||||
|
this.emit('bufferOverrun');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boucle de lecture du buffer circulaire
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_startPlaybackLoop() {
|
||||||
|
const playNextChunk = () => {
|
||||||
|
if (!this.isPlaying) return;
|
||||||
|
|
||||||
|
if (this.playbackBuffer.length > 0) {
|
||||||
|
const chunk = this.playbackBuffer.shift();
|
||||||
|
this.playbackProcess.stdin.write(chunk);
|
||||||
|
} else {
|
||||||
|
// Buffer vide : underrun (silence)
|
||||||
|
const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
|
||||||
|
this.playbackProcess.stdin.write(silenceBuffer);
|
||||||
|
this.emit('bufferUnderrun');
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
|
||||||
|
setTimeout(playNextChunk, intervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
playNextChunk();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrête tous les streams
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.stopCapture();
|
||||||
|
this.stopPlayback();
|
||||||
|
this.removeAllListeners();
|
||||||
|
console.log('✓ JACKBackend détruit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient les statistiques du backend
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
capturing: this.isCapturing,
|
||||||
|
playing: this.isPlaying,
|
||||||
|
playbackBufferSize: this.playbackBuffer.length,
|
||||||
|
sampleRate: this.options.sampleRate,
|
||||||
|
channels: this.options.channels,
|
||||||
|
framesPerBuffer: this.options.framesPerBuffer,
|
||||||
|
jackServerRunning: JACKBackend.isServerRunning(),
|
||||||
|
jackSampleRate: JACKBackend._getServerSampleRate(),
|
||||||
|
jackBufferSize: JACKBackend._getServerBufferSize()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient les informations du serveur JACK
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
static getServerInfo() {
|
||||||
|
if (!this.isServerRunning()) {
|
||||||
|
return { running: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
running: true,
|
||||||
|
sampleRate: this._getServerSampleRate(),
|
||||||
|
bufferSize: this._getServerBufferSize(),
|
||||||
|
ports: this.getPorts().length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JACKBackend;
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
/**
|
||||||
|
* PipeWireBackend.js
|
||||||
|
* Backend audio pour Linux moderne utilisant PipeWire
|
||||||
|
*
|
||||||
|
* PipeWire est le nouveau standard audio sur Linux (remplace PulseAudio + JACK)
|
||||||
|
* Compatible avec : Fedora 34+, Ubuntu 22.10+, Arch Linux
|
||||||
|
*
|
||||||
|
* Gère :
|
||||||
|
* - Connexion au serveur PipeWire
|
||||||
|
* - Capture et lecture audio via pw-cat
|
||||||
|
* - Détection automatique des devices
|
||||||
|
* - Mode basse latence (compatible JACK)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, execSync } from 'child_process';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
export class PipeWireBackend extends EventEmitter {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.options = {
|
||||||
|
sampleRate: options.sampleRate || 48000,
|
||||||
|
channels: options.channels || 1,
|
||||||
|
framesPerBuffer: options.framesPerBuffer || 960,
|
||||||
|
targetDevice: options.targetDevice || null,
|
||||||
|
latency: options.latency || 20, // ms
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.captureProcess = null;
|
||||||
|
this.playbackProcess = null;
|
||||||
|
this.isCapturing = false;
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.playbackBuffer = [];
|
||||||
|
this.maxBufferSize = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si PipeWire est installé et disponible
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
static isAvailable() {
|
||||||
|
try {
|
||||||
|
execSync('which pw-cat', { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si le serveur PipeWire est en cours d'exécution
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
static isServerRunning() {
|
||||||
|
try {
|
||||||
|
execSync('pw-cli info 0', { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste tous les devices audio PipeWire
|
||||||
|
* @returns {Array} Liste des devices
|
||||||
|
*/
|
||||||
|
static getDevices() {
|
||||||
|
if (!this.isServerRunning()) {
|
||||||
|
console.warn('Serveur PipeWire non démarré');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Utilise pactl (compatible PipeWire) pour lister les devices
|
||||||
|
const sourcesOutput = execSync('pactl list sources short', { encoding: 'utf8' });
|
||||||
|
const sinksOutput = execSync('pactl list sinks short', { encoding: 'utf8' });
|
||||||
|
|
||||||
|
const devices = [];
|
||||||
|
|
||||||
|
// Parse sources (entrées)
|
||||||
|
const sources = sourcesOutput.trim().split('\n').filter(l => l.length > 0);
|
||||||
|
sources.forEach(line => {
|
||||||
|
const parts = line.split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
devices.push({
|
||||||
|
id: `pw-input-${parts[0]}`,
|
||||||
|
name: parts[1],
|
||||||
|
maxInputChannels: 2, // Assume stéréo par défaut
|
||||||
|
maxOutputChannels: 0,
|
||||||
|
defaultSampleRate: 48000,
|
||||||
|
hostAPIName: 'PipeWire',
|
||||||
|
type: 'source'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse sinks (sorties)
|
||||||
|
const sinks = sinksOutput.trim().split('\n').filter(l => l.length > 0);
|
||||||
|
sinks.forEach(line => {
|
||||||
|
const parts = line.split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
devices.push({
|
||||||
|
id: `pw-output-${parts[0]}`,
|
||||||
|
name: parts[1],
|
||||||
|
maxInputChannels: 0,
|
||||||
|
maxOutputChannels: 2,
|
||||||
|
defaultSampleRate: 48000,
|
||||||
|
hostAPIName: 'PipeWire',
|
||||||
|
type: 'sink'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return devices;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur énumération devices PipeWire:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve le device par défaut pour l'entrée
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
static getDefaultInputDevice() {
|
||||||
|
try {
|
||||||
|
const output = execSync('pactl get-default-source', { encoding: 'utf8' });
|
||||||
|
const defaultName = output.trim();
|
||||||
|
|
||||||
|
const devices = this.getDevices();
|
||||||
|
return devices.find(d => d.name === defaultName && d.maxInputChannels > 0) ||
|
||||||
|
devices.find(d => d.maxInputChannels > 0);
|
||||||
|
} catch (error) {
|
||||||
|
const devices = this.getDevices();
|
||||||
|
return devices.find(d => d.maxInputChannels > 0) || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve le device par défaut pour la sortie
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
static getDefaultOutputDevice() {
|
||||||
|
try {
|
||||||
|
const output = execSync('pactl get-default-sink', { encoding: 'utf8' });
|
||||||
|
const defaultName = output.trim();
|
||||||
|
|
||||||
|
const devices = this.getDevices();
|
||||||
|
return devices.find(d => d.name === defaultName && d.maxOutputChannels > 0) ||
|
||||||
|
devices.find(d => d.maxOutputChannels > 0);
|
||||||
|
} catch (error) {
|
||||||
|
const devices = this.getDevices();
|
||||||
|
return devices.find(d => d.maxOutputChannels > 0) || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre la capture audio
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async startCapture() {
|
||||||
|
if (this.isCapturing) {
|
||||||
|
console.warn('Capture PipeWire déjà active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PipeWireBackend.isServerRunning()) {
|
||||||
|
throw new Error('Serveur PipeWire non démarré');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Utilise pw-cat pour capturer l'audio
|
||||||
|
const args = [
|
||||||
|
'--record',
|
||||||
|
'--format=s16', // 16-bit signed PCM
|
||||||
|
`--rate=${this.options.sampleRate}`,
|
||||||
|
`--channels=${this.options.channels}`,
|
||||||
|
`--latency=${this.options.latency}ms`,
|
||||||
|
'-' // Sortie vers stdout
|
||||||
|
];
|
||||||
|
|
||||||
|
// Ajoute le device cible si spécifié
|
||||||
|
if (this.options.targetDevice) {
|
||||||
|
args.push(`--target=${this.options.targetDevice}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.captureProcess = spawn('pw-cat', args);
|
||||||
|
|
||||||
|
this.captureProcess.stdout.on('data', (audioData) => {
|
||||||
|
this.emit('audioData', audioData);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.captureProcess.stderr.on('data', (data) => {
|
||||||
|
const msg = data.toString();
|
||||||
|
if (!msg.includes('stream state changed')) {
|
||||||
|
console.error('PipeWire capture stderr:', msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.captureProcess.on('error', (error) => {
|
||||||
|
console.error('Erreur processus PipeWire capture:', error);
|
||||||
|
this.emit('error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.captureProcess.on('close', (code) => {
|
||||||
|
console.log(`Processus PipeWire capture fermé (code ${code})`);
|
||||||
|
this.isCapturing = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.isCapturing = true;
|
||||||
|
console.log(`✓ Capture PipeWire démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
|
||||||
|
console.log(` Latence: ${this.options.latency}ms`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur démarrage capture PipeWire:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrête la capture audio
|
||||||
|
*/
|
||||||
|
stopCapture() {
|
||||||
|
if (this.captureProcess && this.isCapturing) {
|
||||||
|
this.captureProcess.kill('SIGTERM');
|
||||||
|
this.captureProcess = null;
|
||||||
|
this.isCapturing = false;
|
||||||
|
console.log('✓ Capture PipeWire arrêtée');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre la lecture audio
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async startPlayback() {
|
||||||
|
if (this.isPlaying) {
|
||||||
|
console.warn('Lecture PipeWire déjà active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PipeWireBackend.isServerRunning()) {
|
||||||
|
throw new Error('Serveur PipeWire non démarré');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = [
|
||||||
|
'--playback',
|
||||||
|
'--format=s16',
|
||||||
|
`--rate=${this.options.sampleRate}`,
|
||||||
|
`--channels=${this.options.channels}`,
|
||||||
|
`--latency=${this.options.latency}ms`,
|
||||||
|
'-' // Lecture depuis stdin
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.options.targetDevice) {
|
||||||
|
args.push(`--target=${this.options.targetDevice}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.playbackProcess = spawn('pw-cat', args);
|
||||||
|
|
||||||
|
this.playbackProcess.stderr.on('data', (data) => {
|
||||||
|
const msg = data.toString();
|
||||||
|
if (!msg.includes('stream state changed')) {
|
||||||
|
console.error('PipeWire playback stderr:', msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.playbackProcess.on('error', (error) => {
|
||||||
|
console.error('Erreur processus PipeWire playback:', error);
|
||||||
|
this.emit('error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.playbackProcess.on('close', (code) => {
|
||||||
|
console.log(`Processus PipeWire playback fermé (code ${code})`);
|
||||||
|
this.isPlaying = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.isPlaying = true;
|
||||||
|
this._startPlaybackLoop();
|
||||||
|
|
||||||
|
console.log(`✓ Lecture PipeWire démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
|
||||||
|
console.log(` Latence: ${this.options.latency}ms`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur démarrage lecture PipeWire:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrête la lecture audio
|
||||||
|
*/
|
||||||
|
stopPlayback() {
|
||||||
|
if (this.playbackProcess && this.isPlaying) {
|
||||||
|
this.playbackProcess.kill('SIGTERM');
|
||||||
|
this.playbackProcess = null;
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.playbackBuffer = [];
|
||||||
|
console.log('✓ Lecture PipeWire arrêtée');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute des données audio au buffer de lecture
|
||||||
|
* @param {Buffer} audioData - Données PCM 16-bit
|
||||||
|
*/
|
||||||
|
queueAudio(audioData) {
|
||||||
|
if (!this.isPlaying) {
|
||||||
|
console.warn('Tentative ajout audio alors que lecture PipeWire inactive');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.playbackBuffer.length < this.maxBufferSize) {
|
||||||
|
this.playbackBuffer.push(audioData);
|
||||||
|
} else {
|
||||||
|
this.emit('bufferOverrun');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boucle de lecture du buffer circulaire
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_startPlaybackLoop() {
|
||||||
|
const playNextChunk = () => {
|
||||||
|
if (!this.isPlaying || !this.playbackProcess) return;
|
||||||
|
|
||||||
|
if (this.playbackBuffer.length > 0) {
|
||||||
|
const chunk = this.playbackBuffer.shift();
|
||||||
|
try {
|
||||||
|
this.playbackProcess.stdin.write(chunk);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur écriture stdin PipeWire:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Buffer vide : underrun (silence)
|
||||||
|
const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
|
||||||
|
try {
|
||||||
|
this.playbackProcess.stdin.write(silenceBuffer);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore si le process est fermé
|
||||||
|
}
|
||||||
|
this.emit('bufferUnderrun');
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
|
||||||
|
setTimeout(playNextChunk, intervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
playNextChunk();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrête tous les streams
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.stopCapture();
|
||||||
|
this.stopPlayback();
|
||||||
|
this.removeAllListeners();
|
||||||
|
console.log('✓ PipeWireBackend détruit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient les statistiques du backend
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
capturing: this.isCapturing,
|
||||||
|
playing: this.isPlaying,
|
||||||
|
playbackBufferSize: this.playbackBuffer.length,
|
||||||
|
sampleRate: this.options.sampleRate,
|
||||||
|
channels: this.options.channels,
|
||||||
|
framesPerBuffer: this.options.framesPerBuffer,
|
||||||
|
latency: this.options.latency,
|
||||||
|
pipewireServerRunning: PipeWireBackend.isServerRunning()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient les informations du serveur PipeWire
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
static getServerInfo() {
|
||||||
|
if (!this.isServerRunning()) {
|
||||||
|
return { running: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = execSync('pw-cli info 0', { encoding: 'utf8' });
|
||||||
|
|
||||||
|
// Parse basique des infos
|
||||||
|
const versionMatch = output.match(/version:\s*"([^"]+)"/);
|
||||||
|
|
||||||
|
return {
|
||||||
|
running: true,
|
||||||
|
version: versionMatch ? versionMatch[1] : 'unknown',
|
||||||
|
devices: this.getDevices().length
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { running: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PipeWireBackend;
|
||||||
@@ -112,35 +112,45 @@ class ConfigManager extends EventEmitter {
|
|||||||
* Met à jour la configuration audio device
|
* Met à jour la configuration audio device
|
||||||
*/
|
*/
|
||||||
updateAudioDevice(deviceConfig) {
|
updateAudioDevice(deviceConfig) {
|
||||||
if (!this.config.audio) {
|
try {
|
||||||
this.config.audio = {};
|
console.log('📝 ConfigManager.updateAudioDevice:', deviceConfig);
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.config.audio.device) {
|
if (!this.config.audio) {
|
||||||
this.config.audio.device = {};
|
this.config.audio = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mettre à jour les paramètres fournis
|
if (!this.config.audio.device) {
|
||||||
if (deviceConfig.inputDeviceId !== undefined) {
|
this.config.audio.device = {};
|
||||||
this.config.audio.device.inputDeviceId = deviceConfig.inputDeviceId;
|
}
|
||||||
}
|
|
||||||
if (deviceConfig.outputDeviceId !== undefined) {
|
|
||||||
this.config.audio.device.outputDeviceId = deviceConfig.outputDeviceId;
|
|
||||||
}
|
|
||||||
if (deviceConfig.sampleRate !== undefined) {
|
|
||||||
this.config.audio.device.sampleRate = deviceConfig.sampleRate;
|
|
||||||
this.config.audio.sampleRate = deviceConfig.sampleRate; // Sync avec config globale
|
|
||||||
}
|
|
||||||
if (deviceConfig.bufferSize !== undefined) {
|
|
||||||
this.config.audio.device.bufferSize = deviceConfig.bufferSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.save(this.config);
|
// Mettre à jour les paramètres fournis
|
||||||
|
if (deviceConfig.inputDeviceId !== undefined) {
|
||||||
|
this.config.audio.device.inputDeviceId = deviceConfig.inputDeviceId;
|
||||||
|
}
|
||||||
|
if (deviceConfig.outputDeviceId !== undefined) {
|
||||||
|
this.config.audio.device.outputDeviceId = deviceConfig.outputDeviceId;
|
||||||
|
}
|
||||||
|
if (deviceConfig.sampleRate !== undefined) {
|
||||||
|
this.config.audio.device.sampleRate = deviceConfig.sampleRate;
|
||||||
|
this.config.audio.sampleRate = deviceConfig.sampleRate; // Sync avec config globale
|
||||||
|
}
|
||||||
|
if (deviceConfig.bufferSize !== undefined) {
|
||||||
|
this.config.audio.device.bufferSize = deviceConfig.bufferSize;
|
||||||
|
}
|
||||||
|
|
||||||
// Émettre événement spécifique
|
console.log('💾 Sauvegarde configuration...');
|
||||||
this.emit('audio-device-updated', this.config.audio.device);
|
this.save(this.config);
|
||||||
|
|
||||||
return this.config.audio.device;
|
// Émettre événement spécifique
|
||||||
|
console.log('📢 Émission événement audio-device-updated');
|
||||||
|
this.emit('audio-device-updated', this.config.audio.device);
|
||||||
|
|
||||||
|
console.log('✓ Configuration audio device mise à jour');
|
||||||
|
return this.config.audio.device;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur updateAudioDevice:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+14
-11
@@ -4,8 +4,8 @@ audio:
|
|||||||
defaultBitrate: 96
|
defaultBitrate: 96
|
||||||
jitterBufferMs: 40
|
jitterBufferMs: 40
|
||||||
device:
|
device:
|
||||||
inputDeviceId: 0
|
inputDeviceId: 1
|
||||||
outputDeviceId: 2
|
outputDeviceId: 0
|
||||||
sampleRate: 48000
|
sampleRate: 48000
|
||||||
routing:
|
routing:
|
||||||
inputToGroup:
|
inputToGroup:
|
||||||
@@ -21,22 +21,25 @@ audio:
|
|||||||
gains: {}
|
gains: {}
|
||||||
channelNames:
|
channelNames:
|
||||||
inputs:
|
inputs:
|
||||||
"0": "Micro Régisseur"
|
"0": Micro Régisseur
|
||||||
"1": "Talkback FOH"
|
"1": Talkback FOH
|
||||||
"2": "Retour Console"
|
"2": Retour Console
|
||||||
"3": "Liaison Scène"
|
"3": Liaison Scène
|
||||||
"4": "Monitor Mix"
|
"4": Monitor Mix
|
||||||
"5": "Spare 1"
|
"5": Spare 1
|
||||||
outputs:
|
outputs:
|
||||||
"0": "Sortie Principale"
|
"0": Sortie Principale
|
||||||
"1": "Retour Scène"
|
"1": Retour Scène
|
||||||
"2": "Talkback Console"
|
"2": Talkback Console
|
||||||
groups:
|
groups:
|
||||||
- name: Production
|
- name: Production
|
||||||
audioBitrate: 96
|
audioBitrate: 96
|
||||||
|
channels: []
|
||||||
- name: Technique
|
- name: Technique
|
||||||
|
channels: []
|
||||||
- name: Sonorisation
|
- name: Sonorisation
|
||||||
audioBitrate: 128
|
audioBitrate: 128
|
||||||
|
channels: []
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 3000
|
port: 3000
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { AccessToken } from 'livekit-server-sdk';
|
|||||||
import adminRouter, { registerUser, addLog } from './api/admin.js';
|
import adminRouter, { registerUser, addLog } from './api/admin.js';
|
||||||
import configManager from './config/ConfigManager.js';
|
import configManager from './config/ConfigManager.js';
|
||||||
import audioBridgeManager from './bridge/AudioBridgeManager.js';
|
import audioBridgeManager from './bridge/AudioBridgeManager.js';
|
||||||
|
import AudioLevelsServer from './websocket/AudioLevelsServer.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -373,6 +374,11 @@ async function start() {
|
|||||||
log('info', `Groupes configurés: ${config.groups.map(g => g.name).join(', ')}`);
|
log('info', `Groupes configurés: ${config.groups.map(g => g.name).join(', ')}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2.5 Démarrer WebSocket Audio Levels (même port que l'API)
|
||||||
|
const audioLevelsServer = new AudioLevelsServer({ server });
|
||||||
|
audioLevelsServer.start();
|
||||||
|
log('info', `✓ WebSocket Audio Levels démarré sur ws://${SERVER_HOST}:${SERVER_PORT}`);
|
||||||
|
|
||||||
// 3. Démarrer Audio Bridge Manager (Phase 2.5)
|
// 3. Démarrer Audio Bridge Manager (Phase 2.5)
|
||||||
log('info', '');
|
log('info', '');
|
||||||
log('info', '🎵 Démarrage Audio Bridge Manager...');
|
log('info', '🎵 Démarrage Audio Bridge Manager...');
|
||||||
|
|||||||
+1
-2
@@ -19,11 +19,10 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@livekit/rtc-node": "^0.13.28",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"livekit-client": "^2.19.0",
|
|
||||||
"livekit-server-sdk": "^2.6.0",
|
"livekit-server-sdk": "^2.6.0",
|
||||||
"naudiodon": "^2.3.6",
|
|
||||||
"opusscript": "^0.1.1",
|
"opusscript": "^0.1.1",
|
||||||
"ws": "^8.17.0",
|
"ws": "^8.17.0",
|
||||||
"yaml": "^2.4.2"
|
"yaml": "^2.4.2"
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export class AudioLevelsServer extends EventEmitter {
|
|||||||
|
|
||||||
this.options = {
|
this.options = {
|
||||||
port: options.port || 3001,
|
port: options.port || 3001,
|
||||||
|
server: options.server || null, // Serveur HTTP existant
|
||||||
updateRateMs: options.updateRateMs || 50, // 20 fois/sec
|
updateRateMs: options.updateRateMs || 50, // 20 fois/sec
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
@@ -89,7 +90,13 @@ export class AudioLevelsServer extends EventEmitter {
|
|||||||
start() {
|
start() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
this.wss = new WebSocketServer({ port: this.options.port });
|
// Si un serveur HTTP est fourni, utiliser le même port (upgrade HTTP → WebSocket)
|
||||||
|
// Sinon, créer un serveur WebSocket standalone sur son propre port
|
||||||
|
const wsOptions = this.options.server
|
||||||
|
? { server: this.options.server, path: '/audio-levels' }
|
||||||
|
: { port: this.options.port };
|
||||||
|
|
||||||
|
this.wss = new WebSocketServer(wsOptions);
|
||||||
|
|
||||||
this.wss.on('connection', (ws, req) => {
|
this.wss.on('connection', (ws, req) => {
|
||||||
this._handleNewConnection(ws, req);
|
this._handleNewConnection(ws, req);
|
||||||
@@ -104,7 +111,12 @@ export class AudioLevelsServer extends EventEmitter {
|
|||||||
// Démarrage du broadcast périodique
|
// Démarrage du broadcast périodique
|
||||||
this._startBroadcast();
|
this._startBroadcast();
|
||||||
|
|
||||||
console.log(`WebSocket AudioLevels démarré sur ws://localhost:${this.options.port}`);
|
if (this.options.server) {
|
||||||
|
console.log(`WebSocket AudioLevels démarré sur path /audio-levels (même port que HTTP)`);
|
||||||
|
} else {
|
||||||
|
console.log(`WebSocket AudioLevels démarré sur ws://localhost:${this.options.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
this.emit('started');
|
this.emit('started');
|
||||||
resolve();
|
resolve();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user