Compare commits
106 Commits
49df6bd44c
..
macos
| Author | SHA1 | Date | |
|---|---|---|---|
| 060453fe06 | |||
| 2b88ea0ad5 | |||
| 9aff58c528 | |||
| a803250f9f | |||
| 36e1799ec5 | |||
| f302b3f266 | |||
| 91d13d1be7 | |||
| 77bc36b765 | |||
| 58bc91b966 | |||
| c9ec10dfd9 | |||
| d908cf4ee6 | |||
| 522a6255fe | |||
| 5784aa68e1 | |||
| 05e7f69ffb | |||
| 5534a43b0a | |||
| 1941e9c8a1 | |||
| cfeb275d18 | |||
| adadbfeeb7 | |||
| aab23dc51f | |||
| c562415a3d | |||
| 65357c29cc | |||
| 5fd46fb2a3 | |||
| 13d066b188 | |||
| 6630ced079 | |||
| 7e6798cf92 | |||
| 061872b2d7 | |||
| 3041863286 | |||
| d5850a5918 | |||
| 52c2a0d326 | |||
| 574ca7e95d | |||
| 1f0ac0647d | |||
| 8882ff5892 | |||
| b454fb2584 | |||
| e84ed7c731 | |||
| 70fc1e833d | |||
| d46fa708e7 | |||
| 6b13981dad | |||
| 244aadcf8b | |||
| d1cbf1fd21 | |||
| 999fbf0412 | |||
| 9b1db5a119 | |||
| 5a4939dac8 | |||
| 7b1770dd40 | |||
| 7aa09e5453 | |||
| 73e141c5db | |||
| 79cda9653b | |||
| 1050369469 | |||
| 01f1faa9aa | |||
| 94e03fcc5d | |||
| ec067329ce | |||
| 324ff11be9 | |||
| b35f80fc7c | |||
| f2e1a50d6d | |||
| a5879a2ea9 | |||
| eb7959eb09 | |||
| 1c89546b61 | |||
| f873dc25f6 | |||
| e89b20295e | |||
| 2338562b4f | |||
| 6a9ee05114 | |||
| 2acd652df0 | |||
| 61b3bedcae | |||
| cc4f5ca35a | |||
| be05755677 | |||
| cd76b66529 | |||
| 7e5c8744cd | |||
| 37aa447ecd | |||
| 6c35121866 | |||
| fb9d0fd101 | |||
| e460376d9a | |||
| 37ed66a043 | |||
| b766789a2a | |||
| b5874b5c3b | |||
| 37205f0409 | |||
| 9654c7f421 | |||
| f5a5643f4b | |||
| b64bac1f3d | |||
| 5ae9dfe2ac | |||
| 8c43c7e8af | |||
| 3ee474d90c | |||
| 86b86e9037 | |||
| 7682b90557 | |||
| 63147f93f4 | |||
| 42badb1fdf | |||
| 7037517ca2 | |||
| ba3d32fd3d | |||
| 0b31708b48 | |||
| 4a8a7a60e1 | |||
| e053924b63 | |||
| 0aebf3e3e0 | |||
| ccfdd54e2c | |||
| c1202a63a5 | |||
| 4a4c3e40ad | |||
| 9350c9410c | |||
| 7fd60315dd | |||
| 5583808279 | |||
| 03b3f94824 | |||
| 55d777319b | |||
| c3b6af7d30 | |||
| 6e9dd738d7 | |||
| 2bd0c27a71 | |||
| 8b05946632 | |||
| a0839ed563 | |||
| 637cc3e3a7 | |||
| 7e42164c5c | |||
| ed6d2e763d |
+12
@@ -8,10 +8,18 @@ pnpm-lock.yaml
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
server/.env
|
||||
client/.env
|
||||
|
||||
# Keep .env.example files (templates)
|
||||
!.env.example
|
||||
!client/.env.example
|
||||
!server/.env.example
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
dev-dist/
|
||||
*.log
|
||||
|
||||
# OS files
|
||||
@@ -43,3 +51,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
server.log
|
||||
|
||||
# Runtime files
|
||||
/tmp/ptt-live.pid
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Audit PTT Live — 2026-05-26
|
||||
|
||||
## Structure & Documentation
|
||||
|
||||
La structure réelle **diverge de CLAUDE.md** — l'implémentation a avancé au-delà du plan initial sans mise à jour de la doc.
|
||||
|
||||
Fichiers documentés mais absents : `server/api/routes.js`, `client/src/utils/audio.js`
|
||||
Fichiers présents mais non documentés : `AudioBridgeManager.js`, `LiveKitServerBridge.js`, `AudioLevelsServer.js`, + plusieurs composants React.
|
||||
|
||||
---
|
||||
|
||||
## Problèmes critiques
|
||||
|
||||
**1. Chaîne audio serveur incomplète**
|
||||
Le bridge audio ne transmet pas l'audio capturé vers LiveKit. Les TODOs sont explicites :
|
||||
- `server/bridge/AudioBridge.js:368` : `// TODO: Envoyer opusData à LiveKit pour ce groupe spécifique`
|
||||
- `server/bridge/AudioBridge.js:439` : `// TODO: Implémenter réception bas niveau Opus depuis LiveKit`
|
||||
|
||||
Le flux `carte son → LiveKit → clients` n'est pas fonctionnel côté serveur.
|
||||
|
||||
**2. `LiveKitServerBridge.js` jamais utilisé**
|
||||
Fichier créé, jamais importé ni appelé. Contient lui-même un `// TODO: Implémenter l'envoi réel vers LiveKit`. Code mort qui confond les responsabilités avec `LiveKitClient.js`.
|
||||
|
||||
**3. Pas d'authentification sur `/admin`**
|
||||
N'importe qui sur le réseau peut modifier la configuration (groupes, routing, devices). Critique en production.
|
||||
Note du dev : c'est normal et non critique pour le moment.
|
||||
|
||||
---
|
||||
|
||||
## Problèmes de sécurité
|
||||
|
||||
| Sévérité | Problème |
|
||||
|----------|----------|
|
||||
| Haute | CORS `*` dans `server/index.js` — accès depuis n'importe quel domaine |
|
||||
| Haute | API `/admin` sans authentification |
|
||||
| Moyenne | Clés LiveKit hardcodées en fallback `'devkey'/'secret'` |
|
||||
|
||||
Note du dev : l'app a pour vocation à être utilisée sur un réseau local.
|
||||
---
|
||||
|
||||
## Qualité du code
|
||||
|
||||
**Points positifs**
|
||||
- Architecture modulaire solide
|
||||
- EventEmitter bien utilisé pour la réactivité et le hot-reload
|
||||
- Gestion d'erreurs gracieuse (fallback sans crash si pas de carte son)
|
||||
- OpusCodec robuste avec presets configurables
|
||||
- JitterBuffer avec stats adaptatives
|
||||
|
||||
**Points faibles**
|
||||
- Logging DEBUG non retiré dans `server/bridge/LiveKitClient.js:93`
|
||||
- Device IDs hardcodés dans `config.yaml` (`inputDeviceId: 4`, `outputDeviceId: 0`) — non portable
|
||||
- Création de `Float32Array` à chaque frame audio → pression GC potentielle à 30+ clients
|
||||
|
||||
---
|
||||
|
||||
## État des phases
|
||||
|
||||
| Phase | Avancement | Bloquant |
|
||||
|-------|-----------|---------|
|
||||
| Phase 1 MVP | ~80% | Bridge audio serveur incomplet |
|
||||
| Phase 2 Fonctionnalités | ~95% | Authentification manquante |
|
||||
| Phase 3 Intégrations | ~85% | Tests réels manquants |
|
||||
|
||||
---
|
||||
|
||||
## Recommandations par priorité
|
||||
|
||||
### Priorité 1 — Bloquant
|
||||
1. Implémenter la connexion `AudioBridge → LiveKitClient` (TODOs lignes 368/439)
|
||||
2. Ajouter authentification sur `/admin` (token Bearer ou session)
|
||||
3. Supprimer ou intégrer `LiveKitServerBridge.js`
|
||||
|
||||
### Priorité 2 — Important
|
||||
4. CORS : remplacer `*` par origin explicite du client
|
||||
5. Retirer les `console.log` DEBUG de `LiveKitClient.js`
|
||||
6. Device IDs : auto-détection plutôt que valeurs hardcodées
|
||||
|
||||
### Priorité 3 — Amélioration
|
||||
7. Pool de buffers audio pré-alloués pour tenir 30+ clients
|
||||
8. Mettre à jour `CLAUDE.md` avec la structure réelle du code
|
||||
9. Tests d'intégration E2E (latence mesurée, scénario multi-clients)
|
||||
@@ -204,24 +204,48 @@ PTT Live/
|
||||
## Commandes de développement
|
||||
|
||||
```bash
|
||||
# Installation initiale
|
||||
./install/macos.sh
|
||||
# Installation automatique (recommandé)
|
||||
./install.sh # Détecte OS, configure tout automatiquement
|
||||
|
||||
# Serveur (dev)
|
||||
# Démarrage rapide
|
||||
./start.sh --dev # Mode développement
|
||||
./start.sh # Mode production
|
||||
|
||||
# OU manuellement (deux terminaux)
|
||||
# Serveur
|
||||
cd server
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# Client (dev)
|
||||
# Client
|
||||
cd client
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# Production
|
||||
cd server
|
||||
npm start
|
||||
```
|
||||
|
||||
## Fonctionnalités de portabilité (v0.2.1)
|
||||
|
||||
### Installation zéro-config
|
||||
- **Script multi-OS** : `install.sh` détecte automatiquement macOS/Linux
|
||||
- **Auto-détection IP** : Génère les `.env` avec l'IP réseau du serveur
|
||||
- **Devices audio** : API `/admin/devices/list` pour énumérer devices disponibles
|
||||
- **Templates** : `.env.example` pour serveur et client
|
||||
|
||||
### QR Code terminal
|
||||
- **Affichage automatique** au démarrage du serveur
|
||||
- **Scan rapide** depuis smartphone (connexion en 5s)
|
||||
- **URL adaptative** : dev (5173) ou prod (3000) selon build client
|
||||
|
||||
### HTTPS automatique
|
||||
- **Vite dev server** : HTTPS par défaut (certificat self-signed)
|
||||
- **Redirection HTTP → HTTPS** en mode développement
|
||||
- **Production** : utiliser reverse proxy (nginx/Caddy) pour HTTPS
|
||||
|
||||
### Configuration dynamique
|
||||
- **LIVEKIT_URL: AUTO** dans config.yaml → détection IP runtime
|
||||
- **Vite loadEnv()** pour variables d'environnement dynamiques
|
||||
- **Serveur statique** : Express sert `client/dist/` en production
|
||||
|
||||
## Tests et validation
|
||||
|
||||
### Métriques critiques
|
||||
@@ -307,7 +331,8 @@ test: description
|
||||
2. **Après chaque tâche complétée** :
|
||||
- ✅ Valider la tâche dans [TODO.md](TODO.md)
|
||||
- 🔄 Commiter avec message descriptif en français
|
||||
- 📝 Mettre à jour CLAUDE.md si nécessaire
|
||||
- 📝 Mettre à jour CLAUDE.md si nécessaire sans écrire "🤖 Generated with Claude Code Co-Authored-By: Claude noreply@anthropic.com"
|
||||
- Ne pas créer de fichiers récapitulatifs markdown.
|
||||
|
||||
**Exemple workflow** :
|
||||
```bash
|
||||
@@ -326,5 +351,5 @@ Voir [TODO.md](TODO.md) pour le plan détaillé.
|
||||
|
||||
---
|
||||
|
||||
**Dernière mise à jour** : 2026-05-21
|
||||
**Version** : 0.1.0 (Phase 1 en cours)
|
||||
**Dernière mise à jour** : 2026-05-27
|
||||
**Version** : 0.2.1 (Portable + QR Code)
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
# PTT Live - Guide de Déploiement Portable
|
||||
|
||||
Ce guide explique comment déployer **PTT Live** sur n'importe quelle machine macOS ou Linux, sans configuration manuelle d'IP ou de devices audio.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation Rapide
|
||||
|
||||
### Prérequis
|
||||
|
||||
- **macOS** : Homebrew installé ([brew.sh](https://brew.sh))
|
||||
- **Linux** : Ubuntu 22.04+, Debian 11+, Arch Linux ou Fedora
|
||||
- **Node.js** : Version 20+ (installé automatiquement si absent)
|
||||
- **Connexion Internet** : Pour télécharger les dépendances
|
||||
|
||||
### Commandes
|
||||
|
||||
```bash
|
||||
# Cloner ou télécharger le projet
|
||||
cd ptt-live
|
||||
|
||||
# Lancer l'installation (détection automatique OS)
|
||||
./install.sh
|
||||
|
||||
# Ou manuellement selon votre système :
|
||||
./install/macos.sh # macOS
|
||||
./install/linux.sh # Linux
|
||||
```
|
||||
|
||||
### Ce que l'installeur fait automatiquement
|
||||
|
||||
✅ Détecte votre système d'exploitation
|
||||
✅ Installe Node.js 20+ (si absent)
|
||||
✅ Installe LiveKit Server (binaire local)
|
||||
✅ Installe les backends audio (sox/PipeWire/JACK)
|
||||
✅ Détecte votre IP réseau locale
|
||||
✅ Génère les fichiers `.env` avec la bonne configuration
|
||||
✅ Installe toutes les dépendances npm
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Démarrage
|
||||
|
||||
### Méthode 1 : Script unifié (recommandé)
|
||||
|
||||
```bash
|
||||
# Mode développement (serveur + client avec hot-reload)
|
||||
./start.sh --dev
|
||||
|
||||
# Mode production (build client + serveur optimisé)
|
||||
./start.sh
|
||||
```
|
||||
|
||||
L'IP réseau est **détectée automatiquement** et affichée au démarrage.
|
||||
|
||||
### Méthode 2 : Manuel (deux terminaux)
|
||||
|
||||
**Terminal 1 : Serveur**
|
||||
```bash
|
||||
cd server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Terminal 2 : Client**
|
||||
```bash
|
||||
cd client
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Accès depuis d'autres appareils
|
||||
|
||||
### Sur le même réseau WiFi
|
||||
|
||||
Après le démarrage, notez l'**IP affichée** (exemple : `192.168.1.100`).
|
||||
|
||||
#### Depuis un smartphone/tablette
|
||||
|
||||
1. **Connectez l'appareil au même WiFi** que le serveur
|
||||
2. Ouvrez le navigateur
|
||||
3. Allez sur : `https://IP_SERVEUR:5173` (dev) ou `http://IP_SERVEUR:3000` (prod)
|
||||
4. **iOS** : Installez la PWA sur l'écran d'accueil pour activer les notifications
|
||||
|
||||
#### Depuis un autre ordinateur
|
||||
|
||||
Même procédure : `https://IP_SERVEUR:5173`
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration Avancée
|
||||
|
||||
### Changer l'IP du serveur manuellement
|
||||
|
||||
Si l'auto-détection ne fonctionne pas (VPN, Docker, etc.) :
|
||||
|
||||
**1. Modifier `server/.env`**
|
||||
|
||||
```bash
|
||||
# Remplacer AUTO par l'IP voulue
|
||||
LIVEKIT_URL=ws://192.168.1.100:7880
|
||||
```
|
||||
|
||||
**2. Pour le client (accès réseau)**
|
||||
|
||||
Modifier `client/.env` :
|
||||
|
||||
```bash
|
||||
# Décommenter et mettre l'IP du serveur
|
||||
VITE_API_URL=http://192.168.1.100:3000
|
||||
```
|
||||
|
||||
**3. Redémarrer**
|
||||
|
||||
```bash
|
||||
./start.sh --dev
|
||||
```
|
||||
|
||||
### Lister les devices audio disponibles
|
||||
|
||||
```bash
|
||||
# Via API (serveur doit tourner)
|
||||
curl http://localhost:3000/admin/devices/list
|
||||
|
||||
# Retourne JSON :
|
||||
{
|
||||
"inputs": [
|
||||
{ "id": 0, "name": "Microphone MacBook Pro" },
|
||||
{ "id": 4, "name": "USB Audio Interface" }
|
||||
],
|
||||
"outputs": [...],
|
||||
"platform": "darwin"
|
||||
}
|
||||
```
|
||||
|
||||
Utilisez ensuite l'interface admin (`/admin`) pour sélectionner les devices.
|
||||
|
||||
### Changer les ports
|
||||
|
||||
**API serveur (port 3000 par défaut)**
|
||||
|
||||
Modifier `server/.env` :
|
||||
|
||||
```bash
|
||||
PORT=3001
|
||||
```
|
||||
|
||||
**Client dev (port 5173 par défaut)**
|
||||
|
||||
Modifier `client/vite.config.js` :
|
||||
|
||||
```javascript
|
||||
server: {
|
||||
port: 5174,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Mode Production (événement en conditions réelles)
|
||||
|
||||
### Build optimisé
|
||||
|
||||
```bash
|
||||
# Build du client statique
|
||||
cd client
|
||||
npm run build
|
||||
|
||||
# Le dossier dist/ contient le build optimisé
|
||||
```
|
||||
|
||||
### Servir en production
|
||||
|
||||
```bash
|
||||
# Méthode 1 : Script start.sh (recommandé)
|
||||
./start.sh
|
||||
|
||||
# Méthode 2 : npm start direct
|
||||
cd server
|
||||
npm start
|
||||
|
||||
# Le serveur Express sert automatiquement client/dist/
|
||||
```
|
||||
|
||||
### Reverse proxy Nginx (optionnel)
|
||||
|
||||
Pour un domaine personnalisé avec HTTPS :
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name ptt.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
# Client PWA
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# WebSocket LiveKit
|
||||
location /livekit {
|
||||
proxy_pass http://localhost:7880;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "Upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Dépannage
|
||||
|
||||
### Erreur "Port déjà utilisé"
|
||||
|
||||
**Port 3000 (API)**
|
||||
```bash
|
||||
# Trouver le processus
|
||||
lsof -i :3000
|
||||
# Tuer ou changer PORT dans .env
|
||||
```
|
||||
|
||||
**Port 7880 (LiveKit)**
|
||||
```bash
|
||||
lsof -i :7880
|
||||
# Arrêter LiveKit ou changer dans config.yaml
|
||||
```
|
||||
|
||||
### IP détectée incorrecte
|
||||
|
||||
**Lister toutes les interfaces réseau :**
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
ifconfig | grep "inet "
|
||||
|
||||
# Linux
|
||||
ip addr show
|
||||
```
|
||||
|
||||
Puis modifier `server/.env` avec la bonne IP.
|
||||
|
||||
### Clients ne peuvent pas se connecter
|
||||
|
||||
**1. Vérifier le serveur**
|
||||
```bash
|
||||
curl http://IP_SERVEUR:3000/health
|
||||
```
|
||||
|
||||
**2. Vérifier LiveKit**
|
||||
```bash
|
||||
curl http://IP_SERVEUR:7880
|
||||
```
|
||||
|
||||
**3. Firewall**
|
||||
|
||||
macOS/Linux : autoriser ports 3000, 7880, 7882 (UDP)
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --add /path/to/node
|
||||
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --unblock /path/to/livekit-server
|
||||
|
||||
# Linux (ufw)
|
||||
sudo ufw allow 3000/tcp
|
||||
sudo ufw allow 7880/tcp
|
||||
sudo ufw allow 7882/udp
|
||||
```
|
||||
|
||||
### Pas d'audio (macOS)
|
||||
|
||||
**Permissions microphone :**
|
||||
|
||||
1. **Navigateur** : Autoriser le micro dans les préférences Safari/Chrome
|
||||
2. **Terminal** : `Réglages Système > Confidentialité > Microphone` → autoriser Terminal
|
||||
|
||||
**Carte son externe :**
|
||||
|
||||
```bash
|
||||
# Lister devices
|
||||
curl http://localhost:3000/admin/devices/list
|
||||
|
||||
# Sélectionner via interface admin
|
||||
open http://localhost:3000/admin
|
||||
```
|
||||
|
||||
### Pas d'audio (Linux)
|
||||
|
||||
**Vérifier PipeWire :**
|
||||
|
||||
```bash
|
||||
systemctl --user status pipewire
|
||||
pw-cli info 0
|
||||
```
|
||||
|
||||
**Démarrer si inactif :**
|
||||
|
||||
```bash
|
||||
systemctl --user start pipewire pipewire-pulse
|
||||
```
|
||||
|
||||
**Lister devices PulseAudio :**
|
||||
|
||||
```bash
|
||||
pactl list short sources # Inputs
|
||||
pactl list short sinks # Outputs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Architecture Portable
|
||||
|
||||
### Structure des fichiers de configuration
|
||||
|
||||
```
|
||||
PTT Live/
|
||||
├── server/
|
||||
│ ├── .env # Généré par install (IP auto)
|
||||
│ └── config/
|
||||
│ └── config.yaml # LIVEKIT_URL = AUTO
|
||||
├── client/
|
||||
│ ├── .env # Généré par install
|
||||
│ └── .env.example # Template
|
||||
└── install/
|
||||
├── macos.sh # Détection IP + génération .env
|
||||
└── linux.sh # Idem
|
||||
```
|
||||
|
||||
### Flux de configuration automatique
|
||||
|
||||
```
|
||||
1. install.sh
|
||||
└─> Détecte OS (macOS/Linux)
|
||||
└─> Lance install/{os}.sh
|
||||
└─> Détecte IP réseau (ifconfig/hostname)
|
||||
└─> Génère server/.env avec LIVEKIT_URL=AUTO
|
||||
└─> Génère client/.env avec IP dans commentaires
|
||||
|
||||
2. npm run dev (serveur)
|
||||
└─> Lit server/.env
|
||||
└─> Si LIVEKIT_URL=AUTO → détecte IP au runtime (index.js:75)
|
||||
└─> Lance LiveKit sur 0.0.0.0:7880
|
||||
└─> Retourne ws://IP_DETECTÉE:7880 aux clients via /token
|
||||
|
||||
3. Client se connecte
|
||||
└─> Appelle POST /token avec username + groupId
|
||||
└─> Reçoit { token, url: "ws://192.168.x.x:7880" }
|
||||
└─> Se connecte automatiquement à la bonne URL
|
||||
```
|
||||
|
||||
**Résultat** : **Zéro configuration manuelle** d'IP pour l'utilisateur final.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Sécurité en Production
|
||||
|
||||
### Bonnes pratiques
|
||||
|
||||
1. **Changer les clés LiveKit** (par défaut : `devkey/secret`)
|
||||
|
||||
Modifier `server/.env` :
|
||||
```bash
|
||||
LIVEKIT_API_KEY=$(openssl rand -hex 32)
|
||||
LIVEKIT_API_SECRET=$(openssl rand -hex 64)
|
||||
```
|
||||
|
||||
2. **Activer HTTPS/WSS** (avec certificats Let's Encrypt ou mkcert)
|
||||
|
||||
3. **Firewall strict** : Autoriser seulement les ports nécessaires
|
||||
|
||||
4. **Authentification admin** : Ajouter un mot de passe sur `/admin` (Phase 2.3)
|
||||
|
||||
5. **VLAN dédié** : Isoler le réseau PTT Live du reste du LAN (événements)
|
||||
|
||||
---
|
||||
|
||||
## ✨ Fonctionnalités Portables
|
||||
|
||||
✅ **Auto-détection IP réseau** (macOS/Linux)
|
||||
✅ **Auto-détection devices audio** (API `/admin/devices/list`)
|
||||
✅ **Génération .env automatique** lors de l'installation
|
||||
✅ **Scripts start.sh multi-OS** (dev/prod)
|
||||
✅ **Configuration dynamique Vite** (loadEnv)
|
||||
✅ **Support JACK, PipeWire, CoreAudio**
|
||||
✅ **PWA installable** (iOS/Android)
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation Complémentaire
|
||||
|
||||
- [README.md](README.md) — Guide utilisateur complet
|
||||
- [NETWORK_SETUP.md](NETWORK_SETUP.md) — Configuration réseau détaillée
|
||||
- [CLAUDE.md](CLAUDE.md) — Documentation développement
|
||||
- [docs/](docs/) — Guides techniques (JACK, Dante, AES67)
|
||||
|
||||
---
|
||||
|
||||
**Dernière mise à jour** : 2026-05-27
|
||||
**Version** : 0.2.0 (Portable)
|
||||
@@ -8,20 +8,43 @@ Communiquez via smartphone (PWA) en WiFi, le serveur fait le pont avec l'install
|
||||
|
||||
## 🚀 Démarrage rapide
|
||||
|
||||
### Prérequis
|
||||
### Installation Automatique (Recommandé)
|
||||
|
||||
- Node.js 20+ ([télécharger](https://nodejs.org))
|
||||
- Compte LiveKit Cloud gratuit ([créer ici](https://cloud.livekit.io))
|
||||
**Un seul script pour tout installer** (détection automatique macOS/Linux) :
|
||||
|
||||
### Installation (5 minutes)
|
||||
```bash
|
||||
# Lancer l'installation portable
|
||||
./install.sh
|
||||
|
||||
1. **Installer les dépendances**
|
||||
# Démarrer le système
|
||||
./start.sh --dev
|
||||
```
|
||||
|
||||
✨ **L'installeur configure automatiquement** :
|
||||
- LiveKit Server local (pas besoin de compte cloud)
|
||||
- Détection et configuration IP réseau
|
||||
- Backends audio (sox/PipeWire/JACK selon OS)
|
||||
- Toutes les dépendances
|
||||
|
||||
📖 **Guide portable complet** : [README-PORTABLE.md](README-PORTABLE.md)
|
||||
|
||||
---
|
||||
|
||||
### Installation Manuelle (avec LiveKit Cloud)
|
||||
|
||||
**Alternative si vous préférez utiliser LiveKit Cloud**
|
||||
|
||||
1. **Prérequis**
|
||||
- Node.js 20+ ([télécharger](https://nodejs.org))
|
||||
- Compte LiveKit Cloud gratuit ([créer ici](https://cloud.livekit.io))
|
||||
|
||||
2. **Installer les dépendances**
|
||||
```bash
|
||||
cd server && npm install
|
||||
cd ../client && npm install
|
||||
```
|
||||
|
||||
2. **Configurer LiveKit Cloud**
|
||||
3. **Configurer LiveKit Cloud**
|
||||
|
||||
- Créer compte sur https://cloud.livekit.io
|
||||
- Créer un projet
|
||||
@@ -35,7 +58,7 @@ Communiquez via smartphone (PWA) en WiFi, le serveur fait le pont avec l'install
|
||||
USE_LOCAL_LIVEKIT=false
|
||||
```
|
||||
|
||||
3. **Démarrer**
|
||||
4. **Démarrer**
|
||||
|
||||
Terminal 1 :
|
||||
```bash
|
||||
@@ -47,13 +70,13 @@ Communiquez via smartphone (PWA) en WiFi, le serveur fait le pont avec l'install
|
||||
cd client && npm run dev
|
||||
```
|
||||
|
||||
4. **Tester** : http://localhost:5173
|
||||
5. **Tester** : https://localhost:5173
|
||||
|
||||
- Se connecter avec votre nom
|
||||
- Ouvrir second onglet avec autre nom
|
||||
- Maintenir bouton PTT pour parler !
|
||||
|
||||
📖 **Guide complet** : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
|
||||
📖 **Guide LiveKit Cloud** : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -84,6 +107,8 @@ Voir le guide complet : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **[README-PORTABLE.md](README-PORTABLE.md)** - 🆕 **Guide déploiement portable** (zéro config)
|
||||
- **[NETWORK_SETUP.md](NETWORK_SETUP.md)** - Configuration réseau multi-appareils
|
||||
- **[docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)** - Configuration LiveKit (Cloud + Local)
|
||||
- **[CLAUDE.md](CLAUDE.md)** - Documentation développement complète
|
||||
- **[TODO.md](TODO.md)** - Progression des phases
|
||||
@@ -92,13 +117,12 @@ Voir le guide complet : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
|
||||
|
||||
## 🎯 État du projet
|
||||
|
||||
- ✅ **Phase 1.1** : Infrastructure
|
||||
- ✅ **Phase 1.2** : Serveur + API REST
|
||||
- ⏳ **Phase 1.3** : Bridge audio macOS
|
||||
- ✅ **Phase 1.4** : Client PWA React
|
||||
- ⏳ **Phase 1.5** : Tests validation
|
||||
- ✅ **Phase 1** : MVP fonctionnel (WebRTC + PTT)
|
||||
- ✅ **Phase 2** : Fonctionnalités avancées (groupes, routing, admin)
|
||||
- 🆕 **Portable** : Installation zéro-config macOS/Linux
|
||||
- ⏳ **Phase 3** : Intégrations audio pro (Dante, AES67)
|
||||
|
||||
**Version actuelle** : 0.1.0 (Phase 1 MVP en cours)
|
||||
**Version actuelle** : 0.2.0 (Portable - production-ready)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# TODO.md - Plan de développement PTT Live
|
||||
|
||||
**Dernière mise à jour** : 2026-05-23
|
||||
**Phase actuelle** : PHASE 2 - Fonctionnalités professionnelles (En cours)
|
||||
**Dernière mise à jour** : 2026-05-26
|
||||
**Phase actuelle** : PHASE 3 - Intégrations audio pro (Phase 3.1 EN COURS - Support Linux)
|
||||
|
||||
---
|
||||
|
||||
@@ -156,64 +156,116 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
|
||||
### 2.2 Modes PTT avancés
|
||||
- [x] Mode continu : toggle ON/OFF (appui long 3s)
|
||||
- [x] Vibration + indicateur visuel rouge (lock actif)
|
||||
- [ ] Préférences utilisateur (mode par défaut)
|
||||
- [x] Préférences utilisateur (mode par défaut)
|
||||
|
||||
### 2.3 Interface admin
|
||||
- [ ] Page admin web (/admin)
|
||||
- [ ] Gestion groupes (CRUD)
|
||||
- [ ] Gestion utilisateurs connectés
|
||||
- [ ] Monitoring temps réel (latence, qualité)
|
||||
- [ ] Logs serveur (affichage live)
|
||||
- [x] Page admin web (/admin)
|
||||
- [x] Gestion groupes (CRUD)
|
||||
- [x] Gestion utilisateurs connectés
|
||||
- [x] Monitoring temps réel (latence, qualité)
|
||||
- [x] Logs serveur (affichage live)
|
||||
|
||||
### 2.5 Configuration audio visuelle (PRIORITÉ)
|
||||
#### Détection et sélection carte son
|
||||
- [x] API GET /api/audio/devices (énumération cartes son CoreAudio/JACK)
|
||||
- [x] API POST /api/audio/device (sélection + config sample rate/buffer)
|
||||
- [x] Page admin : dropdown sélection carte son
|
||||
- [x] Page admin : affichage infos carte (entrées/sorties, sample rate)
|
||||
- [x] Backend : reload bridge audio sans redémarrer serveur
|
||||
|
||||
#### Nommage des canaux
|
||||
- [x] API PUT /api/audio/channels/names (sauvegarde noms canaux)
|
||||
- [x] API GET /api/audio/channels/names (récupération noms)
|
||||
- [x] Page admin : formulaire nommage canaux (inputs/outputs)
|
||||
- [x] Page admin : filtre "canaux nommés uniquement"
|
||||
- [x] Sauvegarde automatique dans config.yaml
|
||||
|
||||
#### Matrice de routing (style Dante Controller)
|
||||
- [x] API GET /api/audio/routing (récupération routing actuel)
|
||||
- [x] API POST /api/audio/routing (sauvegarde routing)
|
||||
- [x] Component React : AudioRoutingMatrix.jsx
|
||||
- [x] Matrice inputs → groups (checkboxes)
|
||||
- [x] Matrice groups → outputs (checkboxes)
|
||||
- [x] Dropdowns gain par route (-12dB à +6dB)
|
||||
- [x] Indicateurs niveaux temps réel (WebSocket)
|
||||
- [x] Backend : GroupAudioRouter.js (routing par groupe)
|
||||
- [x] Mix canaux physiques multiples → groupe
|
||||
- [x] Distribution groupe → canaux physiques multiples
|
||||
- [x] Gestion gains individuels
|
||||
- [x] Support canaux partagés (mixage additif)
|
||||
- [x] Backend : ConfigManager.js (lecture/écriture YAML)
|
||||
- [x] Méthodes update pour device/channels/routing
|
||||
- [x] Sauvegarde atomique avec backup auto
|
||||
- [x] Émission événement config-updated
|
||||
- [x] WebSocket audio-levels (monitoring temps réel)
|
||||
- [x] Server WebSocket AudioLevelsServer.js
|
||||
- [x] Hook React useAudioLevels
|
||||
- [x] Composant VUMeter (mini/horizontal/vertical)
|
||||
- [x] Intégration VU-mètres dans matrice routing
|
||||
- [ ] Tests : routing multi-canaux, canaux partagés - Phase 3
|
||||
|
||||
### 2.4 Notifications
|
||||
- [ ] Web Push : appels privés
|
||||
- [ ] Service Worker : gestion notifications
|
||||
- [ ] iOS : message onboarding "Installer sur écran d'accueil"
|
||||
- [ ] Permissions notification au premier lancement
|
||||
- [x] Web Push : appels privés (infrastructure prête)
|
||||
- [x] Service Worker : gestion notifications
|
||||
- [x] iOS : message onboarding "Installer sur écran d'accueil"
|
||||
- [x] Permissions notification au premier lancement
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3 — Intégrations audio pro
|
||||
|
||||
### 3.1 Support Linux
|
||||
- [ ] Backend JACK (server/bridge/backends/JACKBackend.js)
|
||||
- [ ] Backend PipeWire (server/bridge/backends/PipeWireBackend.js)
|
||||
- [ ] Script install/linux.sh
|
||||
- [x] Backend JACK (server/bridge/backends/JACKBackend.js)
|
||||
- [x] Backend PipeWire (server/bridge/backends/PipeWireBackend.js)
|
||||
- [x] Script install/linux.sh
|
||||
- [ ] Tests Ubuntu 22.04 LTS + Arch Linux
|
||||
|
||||
### 3.2 Dante
|
||||
- [ ] Documentation setup DVS macOS
|
||||
- [ ] Routing JACK ↔ DVS
|
||||
- [x] Documentation setup DVS macOS
|
||||
- [x] Guide configuration réseau Dante
|
||||
- [ ] Routing JACK ↔ DVS (tests pratiques)
|
||||
- [ ] Tests multi-canaux (8+)
|
||||
- [ ] Guide configuration réseau Dante
|
||||
|
||||
### 3.3 AES67
|
||||
- [ ] Backend RTP multicast (Linux)
|
||||
- [ ] PTP sync
|
||||
- [x] Documentation setup AES67 + PTP sync
|
||||
- [ ] Backend RTP multicast (Linux) - optionnel, driver Merging RAVENNA suffit
|
||||
- [ ] Tests interop Dante (mode AES67)
|
||||
|
||||
### 3.4 Production
|
||||
- [ ] Script install Windows (install/windows.ps1)
|
||||
- [ ] Tests charge : 30+ clients simultanés
|
||||
- [ ] Optimisation réseau (QoS, DSCP)
|
||||
- [ ] Documentation déploiement complet
|
||||
- [ ] Guide troubleshooting
|
||||
- [ ] Script install Windows (install/windows.ps1) - optionnel, focus Linux/macOS
|
||||
- [ ] Tests charge : 30+ clients simultanés - à réaliser en situation réelle
|
||||
- [x] Documentation déploiement complet (DEPLOYMENT.md)
|
||||
- [x] Guide troubleshooting (TROUBLESHOOTING.md)
|
||||
- [x] Optimisation réseau (QoS, DSCP) - documenté dans DEPLOYMENT.md
|
||||
|
||||
---
|
||||
|
||||
## Prochaines actions immédiates
|
||||
|
||||
### Phase 2 - Suite
|
||||
### Phase 2 - TERMINÉE
|
||||
1. ✅ Multi-groupes avec sélection dynamique (2.1)
|
||||
2. ✅ Mode PTT continu par appui long (2.2)
|
||||
3. ⏭️ Préférences utilisateur pour mode PTT par défaut
|
||||
4. ⏭️ Interface admin web (/admin) pour gestion groupes (2.3)
|
||||
5. ⏭️ Web Push notifications pour appels privés (2.4)
|
||||
3. ✅ Interface admin web (/admin) pour gestion groupes (2.3)
|
||||
4. ✅ **Configuration audio visuelle (2.5)** - TERMINÉ
|
||||
- ✅ Détection/sélection carte son via interface admin
|
||||
- ✅ Nommage canaux (inputs/outputs)
|
||||
- ✅ Matrice routing style Dante Controller avec gains
|
||||
- ✅ VU-mètres temps réel WebSocket
|
||||
- ✅ Sauvegarde automatique dans YAML
|
||||
5. ✅ Préférences utilisateur pour mode PTT par défaut (2.2)
|
||||
6. ✅ Web Push notifications pour appels privés (2.4)
|
||||
|
||||
### Phase 3 - Préparation
|
||||
- Support Linux (JACK/PipeWire backends)
|
||||
- Intégration Dante/AES67
|
||||
- Tests charge 30+ clients
|
||||
### Phase 3 - COMPLETEE (documentation et backends)
|
||||
1. ✅ Backend JACK pour Linux professionnel (3.1)
|
||||
2. ✅ Backend PipeWire pour Linux moderne (3.1)
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
@@ -239,6 +291,8 @@ test: description # Tests
|
||||
|
||||
**IMPORTANT** : Commiter après chaque tâche complétée, pas à la fin de la journée !
|
||||
|
||||
**IMPORTANT** : Interdiction d'utiliser des icônes et émojis.
|
||||
|
||||
---
|
||||
|
||||
## Notes et décisions
|
||||
|
||||
+12
-4
@@ -1,7 +1,15 @@
|
||||
# PTT Live Client - Configuration environnement
|
||||
# Configuration Client PTT Live
|
||||
# Copiez ce fichier en .env et adaptez selon votre environnement
|
||||
|
||||
# URL API serveur (en dev, utilise le proxy Vite)
|
||||
# URL de l'API serveur
|
||||
# En développement : laissez /api pour utiliser le proxy Vite
|
||||
# En production : spécifiez l'URL complète du serveur
|
||||
# Exemples :
|
||||
# VITE_API_URL=/api # Dev local (via proxy Vite)
|
||||
# VITE_API_URL=http://192.168.1.100:3000 # Serveur sur réseau local
|
||||
# VITE_API_URL=https://ptt.example.com # Production avec domaine
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Pour production, pointer vers le serveur
|
||||
# VITE_API_URL=https://your-server.com
|
||||
# URL LiveKit WebSocket (optionnel, normalement auto-détectée)
|
||||
# Ne définir que si vous voulez forcer une URL spécifique
|
||||
# VITE_LIVEKIT_URL=ws://192.168.1.100:7880
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Configuration PTT Live Client - Production
|
||||
# Le client est servi depuis le même domaine que l'API
|
||||
|
||||
# URLs relatives directes (pas de proxy /api)
|
||||
VITE_API_URL=.
|
||||
@@ -0,0 +1 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// If the loader is already loaded, just stop.
|
||||
if (!self.define) {
|
||||
let registry = {};
|
||||
|
||||
// Used for `eval` and `importScripts` where we can't get script URL by other means.
|
||||
// In both cases, it's safe to use a global var because those functions are synchronous.
|
||||
let nextDefineUri;
|
||||
|
||||
const singleRequire = (uri, parentUri) => {
|
||||
uri = new URL(uri + ".js", parentUri).href;
|
||||
return registry[uri] || (
|
||||
|
||||
new Promise(resolve => {
|
||||
if ("document" in self) {
|
||||
const script = document.createElement("script");
|
||||
script.src = uri;
|
||||
script.onload = resolve;
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
nextDefineUri = uri;
|
||||
importScripts(uri);
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
|
||||
.then(() => {
|
||||
let promise = registry[uri];
|
||||
if (!promise) {
|
||||
throw new Error(`Module ${uri} didn’t register its module`);
|
||||
}
|
||||
return promise;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
self.define = (depsNames, factory) => {
|
||||
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
|
||||
if (registry[uri]) {
|
||||
// Module is already loading or loaded.
|
||||
return;
|
||||
}
|
||||
let exports = {};
|
||||
const require = depUri => singleRequire(depUri, uri);
|
||||
const specialDeps = {
|
||||
module: { uri },
|
||||
exports,
|
||||
require
|
||||
};
|
||||
registry[uri] = Promise.all(depsNames.map(
|
||||
depName => specialDeps[depName] || require(depName)
|
||||
)).then(deps => {
|
||||
factory(...deps);
|
||||
return exports;
|
||||
});
|
||||
};
|
||||
}
|
||||
define(['./workbox-290dd570'], (function (workbox) { 'use strict';
|
||||
|
||||
self.skipWaiting();
|
||||
workbox.clientsClaim();
|
||||
/**
|
||||
* The precacheAndRoute() method efficiently caches and responds to
|
||||
* requests for URLs in the manifest.
|
||||
* See https://goo.gl/S9QRab
|
||||
*/
|
||||
workbox.precacheAndRoute([{
|
||||
"url": "registerSW.js",
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "index.html",
|
||||
"revision": "0.t6h2k1g9avg"
|
||||
}], {});
|
||||
workbox.cleanupOutdatedCaches();
|
||||
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
|
||||
allowlist: [/^\/$/]
|
||||
}));
|
||||
workbox.registerRoute(/^https:\/\/.*\.livekit\.cloud\/.*/i, new workbox.NetworkFirst({
|
||||
"cacheName": "livekit-cache",
|
||||
plugins: [new workbox.ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 86400
|
||||
})]
|
||||
}), 'GET');
|
||||
|
||||
}));
|
||||
//# sourceMappingURL=sw.js.map
|
||||
//# sourceMappingURL=sw.js.map
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,74 @@
|
||||
// Service Worker personnalisé pour PTT Live
|
||||
// Gère les notifications push pour les appels privés
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('Service Worker: Installation');
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('Service Worker: Activation');
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// Écouter les notifications push du serveur
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('Service Worker: Push reçu');
|
||||
|
||||
let data = {
|
||||
title: 'PTT Live',
|
||||
body: 'Nouveau message',
|
||||
icon: '/pwa-192x192.png',
|
||||
badge: '/badge-72x72.png'
|
||||
};
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
data = event.data.json();
|
||||
} catch (error) {
|
||||
console.error('Erreur parsing push data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: data.body,
|
||||
icon: data.icon || '/pwa-192x192.png',
|
||||
badge: data.badge || '/badge-72x72.png',
|
||||
vibrate: [200, 100, 200],
|
||||
tag: data.tag || 'ptt-notification',
|
||||
requireInteraction: data.requireInteraction || false,
|
||||
data: data.data || {}
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, options)
|
||||
);
|
||||
});
|
||||
|
||||
// Gérer les clics sur les notifications
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('Service Worker: Notification cliquée');
|
||||
event.notification.close();
|
||||
|
||||
// Ouvrir l'application ou focus si déjà ouverte
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
// Si une fenêtre est déjà ouverte, la focus
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.registration.scope) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Sinon ouvrir une nouvelle fenêtre
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow('/');
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Gérer la fermeture des notifications
|
||||
self.addEventListener('notificationclose', (event) => {
|
||||
console.log('Service Worker: Notification fermée');
|
||||
});
|
||||
@@ -0,0 +1,833 @@
|
||||
/* Admin Interface - Utilise les mêmes variables que l'app principale */
|
||||
|
||||
.admin-container {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.admin-header {
|
||||
background: var(--color-surface);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-secondary);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.admin-tabs {
|
||||
background: var(--color-surface);
|
||||
padding: 0 var(--spacing-lg);
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-tabs button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-tabs button:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.admin-tabs button.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
padding: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: 8px;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: var(--spacing-xl);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Tab Headers */
|
||||
.tab-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.tab-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-secondary);
|
||||
border: none;
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-small:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-danger-small {
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.3rem var(--spacing-sm);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-danger-small:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
/* Groups */
|
||||
.groups-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.group-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: var(--spacing-lg);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.group-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.group-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.group-info {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.channels-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.channel-badge {
|
||||
background: var(--color-surface-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Group Form */
|
||||
.group-form-container {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 12px;
|
||||
padding: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.group-form-container h3 {
|
||||
margin: 0 0 var(--spacing-lg) 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.group-form-container label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.group-form-container label input,
|
||||
.group-form-container label select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.group-form-container label input:focus,
|
||||
.group-form-container label select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.channels-section {
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.channels-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.channels-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.channel-item {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 80px 80px 50px;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.channel-item input {
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.channel-item input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* Users Table */
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--color-surface);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
background: var(--color-surface-hover);
|
||||
padding: var(--spacing-md);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.users-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.users-table tr:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.group-badge {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
padding: 0.25rem var(--spacing-sm);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: var(--spacing-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.audio-stats {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: var(--spacing-lg);
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.audio-stats h3 {
|
||||
margin: 0 0 var(--spacing-lg) 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.stats-table th {
|
||||
background: var(--color-surface-hover);
|
||||
padding: var(--spacing-md);
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.stats-table td {
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
vertical-align: top;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stats-table code {
|
||||
display: block;
|
||||
background: var(--color-bg);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
/* Logs */
|
||||
.logs-container {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: var(--spacing-md);
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
border-left: 3px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: 180px 80px 1fr;
|
||||
gap: var(--spacing-md);
|
||||
align-items: start;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.log-entry.log-debug {
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.log-entry.log-info {
|
||||
border-left-color: var(--color-success);
|
||||
}
|
||||
|
||||
.log-entry.log-warn {
|
||||
border-left-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.log-entry.log-error {
|
||||
border-left-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.log-entry.log-debug .log-level {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.log-entry.log-info .log-level {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.log-entry.log-warn .log-level {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.log-entry.log-error .log-level {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
grid-column: 3;
|
||||
background: var(--color-surface);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
display: block;
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Audio Configuration (Phase 2.5) */
|
||||
.tab-audio {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.audio-config-container {
|
||||
display: grid;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.audio-section {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: var(--spacing-xl);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.audio-section:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.audio-section h3 {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.audio-section h3::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
background: var(--color-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.device-select {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-bg);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.device-select:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.device-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.device-select option {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.audio-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.audio-actions .btn-primary {
|
||||
padding: var(--spacing-md) var(--spacing-xl);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
min-width: 250px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.audio-actions .btn-primary:hover {
|
||||
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.current-config {
|
||||
background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-hover) 100%);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.current-config h3 {
|
||||
margin: 0 0 var(--spacing-lg) 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.config-info {
|
||||
display: grid;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.config-info p {
|
||||
margin: 0;
|
||||
padding: var(--spacing-sm) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.config-info p:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.config-info strong {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.audio-devices-list {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: var(--spacing-xl);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.audio-devices-list h3 {
|
||||
margin: 0 0 var(--spacing-lg) 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.devices-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--color-bg);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.devices-table th {
|
||||
background: var(--color-surface-hover);
|
||||
padding: var(--spacing-md);
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.devices-table td {
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.devices-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.devices-table tr:hover {
|
||||
background: var(--color-surface-hover);
|
||||
}
|
||||
|
||||
.devices-table tr:hover td {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Badge pour les devices */
|
||||
.device-type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem var(--spacing-sm);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.device-type-input {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.device-type-output {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.device-type-both {
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.admin-content {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.groups-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.channel-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-header h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.admin-tabs button {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import './Admin.css';
|
||||
import AudioRoutingMatrix from './components/AudioRoutingMatrix';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
function Admin() {
|
||||
// Lire l'onglet depuis l'URL hash (ex: #audio) ou utiliser 'groups' par défaut
|
||||
const getInitialTab = () => {
|
||||
const hash = window.location.hash.slice(1); // Enlever le #
|
||||
return ['groups', 'audio', 'users', 'stats', 'logs'].includes(hash) ? hash : 'groups';
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTab());
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Audio devices (Phase 2.5)
|
||||
const [audioDevices, setAudioDevices] = useState([]);
|
||||
const [currentDevice, setCurrentDevice] = useState({ inputChannels: 8, outputChannels: 8 });
|
||||
const [selectedInputDevice, setSelectedInputDevice] = useState(null);
|
||||
const [selectedOutputDevice, setSelectedOutputDevice] = useState(null);
|
||||
const [selectedSampleRate, setSelectedSampleRate] = useState(48000);
|
||||
const isEditingAudioRef = useRef(false);
|
||||
|
||||
// Channel names (Phase 2.5)
|
||||
const [channelNames, setChannelNames] = useState({ inputs: {}, outputs: {} });
|
||||
|
||||
// Gestion formulaire nouveau groupe
|
||||
const [showGroupForm, setShowGroupForm] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState(null);
|
||||
const [groupForm, setGroupForm] = useState({
|
||||
name: '',
|
||||
audioBitrate: 96
|
||||
});
|
||||
|
||||
// Synchroniser l'onglet avec l'URL hash
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (['groups', 'audio', 'users', 'stats', 'logs'].includes(hash)) {
|
||||
setActiveTab(hash);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
|
||||
// Rafraîchissement automatique
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, 3000); // Refresh toutes les 3s
|
||||
return () => clearInterval(interval);
|
||||
}, [activeTab]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (activeTab === 'groups') {
|
||||
await loadGroups();
|
||||
} else if (activeTab === 'users') {
|
||||
await loadUsers();
|
||||
} else if (activeTab === 'stats') {
|
||||
await loadStats();
|
||||
} else if (activeTab === 'logs') {
|
||||
await loadLogs();
|
||||
} else if (activeTab === 'audio') {
|
||||
await loadAudioDevices();
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement données:', err);
|
||||
setError('Erreur de connexion au serveur');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadGroups = async () => {
|
||||
const res = await fetch(`${API_URL}/admin/groups`);
|
||||
const data = await res.json();
|
||||
setGroups(data.groups || []);
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
const res = await fetch(`${API_URL}/admin/users`);
|
||||
const data = await res.json();
|
||||
setUsers(data.users || []);
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
const res = await fetch(`${API_URL}/admin/stats`);
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
const res = await fetch(`${API_URL}/admin/logs?limit=50`);
|
||||
const data = await res.json();
|
||||
setLogs(data.logs || []);
|
||||
};
|
||||
|
||||
const loadAudioDevices = async () => {
|
||||
const [devicesRes, currentDeviceRes, channelNamesRes, groupsRes] = await Promise.all([
|
||||
fetch(`${API_URL}/admin/audio/devices`),
|
||||
fetch(`${API_URL}/admin/audio/device`),
|
||||
fetch(`${API_URL}/admin/audio/channels/names`),
|
||||
fetch(`${API_URL}/admin/groups`)
|
||||
]);
|
||||
|
||||
const devicesData = await devicesRes.json();
|
||||
const currentData = await currentDeviceRes.json();
|
||||
const channelNamesData = await channelNamesRes.json();
|
||||
const groupsData = await groupsRes.json();
|
||||
|
||||
setAudioDevices(devicesData.devices || []);
|
||||
setGroups(groupsData.groups || []);
|
||||
|
||||
const device = currentData.device || { inputChannels: 8, outputChannels: 8 };
|
||||
setCurrentDevice(device);
|
||||
setChannelNames(channelNamesData.channelNames || { inputs: {}, outputs: {} });
|
||||
|
||||
// Ne réinitialiser les sélections que lors du chargement initial (pas en train d'éditer)
|
||||
if (!isEditingAudioRef.current) {
|
||||
setSelectedInputDevice(device.inputDeviceId ?? null);
|
||||
setSelectedOutputDevice(device.outputDeviceId ?? null);
|
||||
setSelectedSampleRate(device.sampleRate || 48000);
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Gestion groupes ==========
|
||||
|
||||
const handleCreateGroup = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/groups`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(groupForm)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setShowGroupForm(false);
|
||||
resetGroupForm();
|
||||
await loadGroups();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
alert(`Erreur: ${error.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur création groupe:', err);
|
||||
alert('Erreur lors de la création du groupe');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateGroup = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/groups/${editingGroup}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(groupForm)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setEditingGroup(null);
|
||||
resetGroupForm();
|
||||
await loadGroups();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
alert(`Erreur: ${error.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur modification groupe:', err);
|
||||
alert('Erreur lors de la modification du groupe');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteGroup = async (groupId) => {
|
||||
if (!confirm('Êtes-vous sûr de vouloir supprimer ce groupe ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/groups/${groupId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await loadGroups();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
alert(`Erreur: ${error.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur suppression groupe:', err);
|
||||
alert('Erreur lors de la suppression du groupe');
|
||||
}
|
||||
};
|
||||
|
||||
const startEditGroup = (group) => {
|
||||
setEditingGroup(group.id);
|
||||
setGroupForm({
|
||||
name: group.name,
|
||||
audioBitrate: group.audioBitrate || 96
|
||||
});
|
||||
setShowGroupForm(true);
|
||||
};
|
||||
|
||||
const resetGroupForm = () => {
|
||||
setGroupForm({
|
||||
name: '',
|
||||
audioBitrate: 96
|
||||
});
|
||||
setShowGroupForm(false);
|
||||
setEditingGroup(null);
|
||||
};
|
||||
|
||||
// ========== Gestion audio devices (Phase 2.5) ==========
|
||||
|
||||
const handleSaveChannelNames = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/audio/channels/names`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(channelNames)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert('Noms de canaux sauvegardés avec succès!');
|
||||
await loadAudioDevices();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
alert(`Erreur: ${error.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur sauvegarde noms canaux:', err);
|
||||
alert('Erreur lors de la sauvegarde');
|
||||
}
|
||||
};
|
||||
|
||||
const updateChannelName = (type, channelId, name) => {
|
||||
setChannelNames(prev => ({
|
||||
...prev,
|
||||
[type]: {
|
||||
...prev[type],
|
||||
[channelId]: name
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveAudioDevice = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/audio/device`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
inputDeviceId: selectedInputDevice || undefined,
|
||||
outputDeviceId: selectedOutputDevice || undefined,
|
||||
sampleRate: parseInt(selectedSampleRate)
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
isEditingAudioRef.current = false; // Désactiver le mode édition
|
||||
alert('Configuration audio sauvegardée avec succès!');
|
||||
await loadAudioDevices();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
alert(`Erreur: ${error.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur sauvegarde configuration audio:', err);
|
||||
alert('Erreur lors de la sauvegarde');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Gestion utilisateurs ==========
|
||||
|
||||
const handleDisconnectUser = async (identity) => {
|
||||
if (!confirm('Déconnecter cet utilisateur ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/users/${identity}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await loadUsers();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
alert(`Erreur: ${error.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur déconnexion utilisateur:', err);
|
||||
alert('Erreur lors de la déconnexion');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Render ==========
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('fr-FR');
|
||||
};
|
||||
|
||||
const formatUptime = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-container">
|
||||
<header className="admin-header">
|
||||
<h1>PTT Live - Administration</h1>
|
||||
<a href="/" className="btn-back">← Retour</a>
|
||||
</header>
|
||||
|
||||
<nav className="admin-tabs">
|
||||
<button
|
||||
className={activeTab === 'groups' ? 'active' : ''}
|
||||
onClick={() => { window.location.hash = 'groups'; setActiveTab('groups'); }}
|
||||
>
|
||||
Groupes
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'audio' ? 'active' : ''}
|
||||
onClick={() => { window.location.hash = 'audio'; setActiveTab('audio'); }}
|
||||
>
|
||||
Audio
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'users' ? 'active' : ''}
|
||||
onClick={() => { window.location.hash = 'users'; setActiveTab('users'); }}
|
||||
>
|
||||
Utilisateurs ({users.length})
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'stats' ? 'active' : ''}
|
||||
onClick={() => { window.location.hash = 'stats'; setActiveTab('stats'); }}
|
||||
>
|
||||
Statistiques
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'logs' ? 'active' : ''}
|
||||
onClick={() => { window.location.hash = 'logs'; setActiveTab('logs'); }}
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<main className="admin-content">
|
||||
{error && (
|
||||
<div className="admin-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB: Groupes */}
|
||||
{activeTab === 'groups' && (
|
||||
<div className="tab-groups">
|
||||
<div className="tab-header">
|
||||
<h2>Gestion des groupes</h2>
|
||||
{!showGroupForm && (
|
||||
<button className="btn-primary" onClick={() => setShowGroupForm(true)}>
|
||||
+ Nouveau groupe
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showGroupForm && (
|
||||
<div className="group-form-container">
|
||||
<form onSubmit={editingGroup ? handleUpdateGroup : handleCreateGroup}>
|
||||
<h3>{editingGroup ? 'Modifier' : 'Nouveau'} groupe</h3>
|
||||
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Nom du groupe
|
||||
<input
|
||||
type="text"
|
||||
value={groupForm.name}
|
||||
onChange={(e) => setGroupForm({ ...groupForm, name: e.target.value })}
|
||||
placeholder="ex: Production, Technique..."
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Bitrate audio (kbps)
|
||||
<input
|
||||
type="number"
|
||||
value={groupForm.audioBitrate}
|
||||
onChange={(e) => setGroupForm({ ...groupForm, audioBitrate: parseInt(e.target.value) })}
|
||||
min="32"
|
||||
max="320"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p style={{color: 'var(--color-text-secondary)', fontSize: '0.9rem', marginTop: 'var(--spacing-md)'}}>
|
||||
Le routing audio se configure dans l'onglet "Audio" via la matrice de routing.
|
||||
</p>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-primary">
|
||||
{editingGroup ? 'Modifier' : 'Créer'}
|
||||
</button>
|
||||
<button type="button" onClick={resetGroupForm} className="btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="groups-list">
|
||||
{groups.map(group => (
|
||||
<div key={group.id} className="group-card">
|
||||
<div className="group-header">
|
||||
<h3>{group.name}</h3>
|
||||
<div className="group-actions">
|
||||
<button onClick={() => startEditGroup(group)} className="btn-edit">
|
||||
Modifier
|
||||
</button>
|
||||
<button onClick={() => handleDeleteGroup(group.id)} className="btn-delete">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group-info">
|
||||
<span>Bitrate: {group.audioBitrate || 96} kbps</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB: Audio (Phase 2.5) */}
|
||||
{activeTab === 'audio' && (
|
||||
<div className="tab-audio">
|
||||
<div className="tab-header">
|
||||
<h2>Configuration audio</h2>
|
||||
</div>
|
||||
|
||||
<div className="audio-config-container">
|
||||
<div className="audio-section">
|
||||
<h3>Configuration des cartes son</h3>
|
||||
|
||||
<div style={{display: 'grid', gap: 'var(--spacing-lg)', marginTop: 'var(--spacing-md)'}}>
|
||||
<div>
|
||||
<label style={{display: 'block', marginBottom: 'var(--spacing-xs)', fontSize: '0.9rem', fontWeight: 600, color: 'var(--color-text-secondary)'}}>
|
||||
Carte son d'entrée (Input)
|
||||
</label>
|
||||
<select
|
||||
value={selectedInputDevice ?? ''}
|
||||
onChange={(e) => {
|
||||
isEditingAudioRef.current = true;
|
||||
setSelectedInputDevice(e.target.value === '' ? null : e.target.value);
|
||||
}}
|
||||
className="device-select"
|
||||
>
|
||||
<option value="">-- Sélectionner une carte --</option>
|
||||
{audioDevices
|
||||
.filter(d => d.maxInputChannels > 0)
|
||||
.map((device, index) => (
|
||||
<option key={`input-${device.id}-${index}`} value={device.id}>
|
||||
{device.name} - {device.maxInputChannels} canaux - {device.defaultSampleRate}Hz
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedInputDevice !== null && selectedInputDevice !== '' && (
|
||||
<p style={{marginTop: 'var(--spacing-sm)', color: 'var(--color-text-secondary)', fontSize: '0.85rem', wordBreak: 'break-all'}}>
|
||||
Device ID: {selectedInputDevice}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{display: 'block', marginBottom: 'var(--spacing-xs)', fontSize: '0.9rem', fontWeight: 600, color: 'var(--color-text-secondary)'}}>
|
||||
Carte son de sortie (Output)
|
||||
</label>
|
||||
<select
|
||||
value={selectedOutputDevice ?? ''}
|
||||
onChange={(e) => {
|
||||
isEditingAudioRef.current = true;
|
||||
setSelectedOutputDevice(e.target.value === '' ? null : e.target.value);
|
||||
}}
|
||||
className="device-select"
|
||||
>
|
||||
<option value="">-- Sélectionner une carte --</option>
|
||||
{audioDevices
|
||||
.filter(d => d.maxOutputChannels > 0)
|
||||
.map((device, index) => (
|
||||
<option key={`output-${device.id}-${index}`} value={device.id}>
|
||||
{device.name} - {device.maxOutputChannels} canaux - {device.defaultSampleRate}Hz
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedOutputDevice !== null && selectedOutputDevice !== '' && (
|
||||
<p style={{marginTop: 'var(--spacing-sm)', color: 'var(--color-text-secondary)', fontSize: '0.85rem', wordBreak: 'break-all'}}>
|
||||
Device ID: {selectedOutputDevice}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{display: 'block', marginBottom: 'var(--spacing-xs)', fontSize: '0.9rem', fontWeight: 600, color: 'var(--color-text-secondary)'}}>
|
||||
Sample Rate
|
||||
</label>
|
||||
<select
|
||||
value={selectedSampleRate}
|
||||
onChange={(e) => {
|
||||
isEditingAudioRef.current = true;
|
||||
setSelectedSampleRate(parseInt(e.target.value));
|
||||
}}
|
||||
className="device-select"
|
||||
>
|
||||
<option value={44100}>44100 Hz (CD quality)</option>
|
||||
<option value={48000}>48000 Hz (Recommended)</option>
|
||||
<option value={96000}>96000 Hz (High quality)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audio-actions">
|
||||
<button onClick={handleSaveAudioDevice} className="btn-primary">
|
||||
Sauvegarder la configuration audio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audio-section">
|
||||
<h3>Nommage des canaux physiques</h3>
|
||||
|
||||
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-xl)', marginTop: 'var(--spacing-md)'}}>
|
||||
<div>
|
||||
<h4 style={{marginBottom: 'var(--spacing-md)', color: 'var(--color-text-secondary)'}}>
|
||||
Entrées (Inputs) - {currentDevice.inputChannels || 0} canaux disponibles
|
||||
</h4>
|
||||
<div style={{display: 'grid', gap: 'var(--spacing-sm)'}}>
|
||||
{Array.from({length: currentDevice.inputChannels || 8}, (_, i) => (
|
||||
<div key={`input-${i}`} style={{display: 'grid', gridTemplateColumns: '40px 1fr', gap: 'var(--spacing-sm)', alignItems: 'center'}}>
|
||||
<span style={{color: 'var(--color-text-secondary)', fontSize: '0.85rem'}}>{i}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={channelNames.inputs?.[i] || ''}
|
||||
onChange={(e) => updateChannelName('inputs', i, e.target.value)}
|
||||
placeholder={`Input ${i}`}
|
||||
style={{
|
||||
padding: 'var(--spacing-sm)',
|
||||
background: 'var(--color-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--color-text)',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{marginBottom: 'var(--spacing-md)', color: 'var(--color-text-secondary)'}}>
|
||||
Sorties (Outputs) - {currentDevice.outputChannels || 0} canaux disponibles
|
||||
</h4>
|
||||
<div style={{display: 'grid', gap: 'var(--spacing-sm)'}}>
|
||||
{Array.from({length: currentDevice.outputChannels || 8}, (_, i) => (
|
||||
<div key={`output-${i}`} style={{display: 'grid', gridTemplateColumns: '40px 1fr', gap: 'var(--spacing-sm)', alignItems: 'center'}}>
|
||||
<span style={{color: 'var(--color-text-secondary)', fontSize: '0.85rem'}}>{i}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={channelNames.outputs?.[i] || ''}
|
||||
onChange={(e) => updateChannelName('outputs', i, e.target.value)}
|
||||
placeholder={`Output ${i}`}
|
||||
style={{
|
||||
padding: 'var(--spacing-sm)',
|
||||
background: 'var(--color-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '6px',
|
||||
color: 'var(--color-text)',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audio-actions">
|
||||
<button onClick={handleSaveChannelNames} className="btn-primary">
|
||||
Sauvegarder les noms des canaux
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AudioRoutingMatrix groups={groups} channelNames={channelNames} />
|
||||
|
||||
{currentDevice && currentDevice.inputDeviceId && (
|
||||
<div className="current-config">
|
||||
<h3>Configuration actuelle</h3>
|
||||
<div className="config-info">
|
||||
<p><strong>Input Device:</strong> {currentDevice.inputDeviceName || currentDevice.inputDeviceId}</p>
|
||||
<p><strong>Output Device:</strong> {currentDevice.outputDeviceName || currentDevice.outputDeviceId}</p>
|
||||
<p><strong>Sample Rate:</strong> {currentDevice.sampleRate ?? 48000} Hz</p>
|
||||
<p><strong>Canaux:</strong> {currentDevice.inputChannels} entrées / {currentDevice.outputChannels} sorties</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="audio-devices-list">
|
||||
<h3>Toutes les cartes son disponibles</h3>
|
||||
<table className="devices-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Nom</th>
|
||||
<th>Entrées</th>
|
||||
<th>Sorties</th>
|
||||
<th>Sample Rate</th>
|
||||
<th>API</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{audioDevices.map((device, index) => (
|
||||
<tr key={`${device.id}-${index}`}>
|
||||
<td style={{fontSize: '0.75rem', wordBreak: 'break-all', maxWidth: '200px'}}>{device.id}</td>
|
||||
<td>{device.name}</td>
|
||||
<td>{device.maxInputChannels}</td>
|
||||
<td>{device.maxOutputChannels}</td>
|
||||
<td>{device.defaultSampleRate} Hz</td>
|
||||
<td>{device.hostAPIName}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB: Utilisateurs */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="tab-users">
|
||||
<h2>Utilisateurs connectés ({users.length})</h2>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<p className="empty-state">Aucun utilisateur connecté</p>
|
||||
) : (
|
||||
<table className="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Utilisateur</th>
|
||||
<th>Groupe</th>
|
||||
<th>Connecté depuis</th>
|
||||
<th>Dernière activité</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(user => (
|
||||
<tr key={user.identity}>
|
||||
<td>{user.username}</td>
|
||||
<td><span className="group-badge">{user.groupId}</span></td>
|
||||
<td>{formatDate(user.connectedAt)}</td>
|
||||
<td>{formatDate(user.lastActivity)}</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => handleDisconnectUser(user.identity)}
|
||||
className="btn-danger-small"
|
||||
>
|
||||
Déconnecter
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB: Statistiques */}
|
||||
{activeTab === 'stats' && stats && (
|
||||
<div className="tab-stats">
|
||||
<h2>Statistiques système</h2>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<h3>Connexions totales</h3>
|
||||
<div className="stat-value">{stats.totalConnections}</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>Connexions actives</h3>
|
||||
<div className="stat-value">{stats.activeConnections}</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>Uptime</h3>
|
||||
<div className="stat-value">{formatUptime(stats.uptime)}</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>Mémoire</h3>
|
||||
<div className="stat-value">
|
||||
{Math.round(stats.memory.heapUsed / 1024 / 1024)} MB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats.audioStats && stats.audioStats.length > 0 && (
|
||||
<div className="audio-stats">
|
||||
<h3>Dernières stats audio</h3>
|
||||
<table className="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Type</th>
|
||||
<th>Données</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.audioStats.map((stat, index) => (
|
||||
<tr key={index}>
|
||||
<td>{formatDate(stat.timestamp)}</td>
|
||||
<td>{stat.type || 'N/A'}</td>
|
||||
<td><code>{JSON.stringify(stat, null, 2)}</code></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB: Logs */}
|
||||
{activeTab === 'logs' && (
|
||||
<div className="tab-logs">
|
||||
<h2>Logs serveur ({logs.length})</h2>
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<p className="empty-state">Aucun log disponible</p>
|
||||
) : (
|
||||
<div className="logs-container">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className={`log-entry log-${log.level}`}>
|
||||
<span className="log-timestamp">{formatDate(log.timestamp)}</span>
|
||||
<span className="log-level">{log.level.toUpperCase()}</span>
|
||||
<span className="log-message">{log.message}</span>
|
||||
{log.meta && Object.keys(log.meta).length > 0 && (
|
||||
<code className="log-meta">{JSON.stringify(log.meta)}</code>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Admin;
|
||||
@@ -125,6 +125,22 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-disconnect {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-surface-hover);
|
||||
|
||||
+52
-14
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import useLiveKit from './hooks/useLiveKit';
|
||||
import usePush from './hooks/usePush';
|
||||
import PTTButton from './components/PTTButton';
|
||||
import UserList from './components/UserList';
|
||||
import AudioIndicator from './components/AudioIndicator';
|
||||
import GroupSelector from './components/GroupSelector';
|
||||
import Settings from './components/Settings';
|
||||
import PWAInstallPrompt from './components/PWAInstallPrompt';
|
||||
import './App.css';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -14,6 +16,7 @@ function App() {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
const {
|
||||
isConnected,
|
||||
@@ -24,9 +27,17 @@ function App() {
|
||||
disconnect,
|
||||
switchGroup,
|
||||
startTalking,
|
||||
stopTalking
|
||||
stopTalking,
|
||||
toggleParticipantMute
|
||||
} = useLiveKit();
|
||||
|
||||
const {
|
||||
isSupported: isPushSupported,
|
||||
isPermissionGranted: isPushGranted,
|
||||
requestPermission: requestPushPermission,
|
||||
showNotification
|
||||
} = usePush();
|
||||
|
||||
// Charger configuration au démarrage
|
||||
useEffect(() => {
|
||||
fetch(`${API_URL}/config`)
|
||||
@@ -58,6 +69,12 @@ function App() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Demander permission notifications au premier lancement
|
||||
if (isPushSupported && !isPushGranted) {
|
||||
console.log('Demande permission notifications...');
|
||||
await requestPushPermission();
|
||||
}
|
||||
|
||||
// IMPORTANT iOS : Demander permission microphone AVANT tout
|
||||
console.log('🎤 Demande permission microphone...');
|
||||
try {
|
||||
@@ -83,17 +100,20 @@ function App() {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Adapter l'URL LiveKit selon le protocole de la page
|
||||
// En mode dev (HTTPS via Vite), utiliser le proxy WebSocket
|
||||
// En mode prod (HTTP direct), utiliser l'URL LiveKit directement
|
||||
let livekitUrl = data.url;
|
||||
if (window.location.protocol === 'https:') {
|
||||
// En HTTPS, utiliser le proxy WSS local via Vite
|
||||
|
||||
if (import.meta.env.DEV && window.location.protocol === 'https:') {
|
||||
// Mode dev avec Vite : utiliser le proxy WSS
|
||||
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`;
|
||||
}
|
||||
|
||||
console.log('🔗 Connexion LiveKit:', livekitUrl);
|
||||
console.log('📝 Mode:', import.meta.env.DEV ? 'dev' : 'prod');
|
||||
|
||||
// Se connecter à LiveKit
|
||||
await connect(livekitUrl, data.token);
|
||||
// Se connecter à LiveKit avec les canaux virtuels
|
||||
await connect(livekitUrl, data.token, data.virtualChannels || []);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erreur connexion:', err);
|
||||
@@ -137,8 +157,8 @@ function App() {
|
||||
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`;
|
||||
}
|
||||
|
||||
// Changer de room LiveKit
|
||||
await switchGroup(livekitUrl, data.token);
|
||||
// Changer de room LiveKit avec les canaux virtuels du nouveau groupe
|
||||
await switchGroup(livekitUrl, data.token, data.virtualChannels || []);
|
||||
|
||||
// Mettre à jour l'état
|
||||
setGroupId(newGroupId);
|
||||
@@ -222,12 +242,23 @@ function App() {
|
||||
{groups.find(g => g.id === groupId)?.name || groupId}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setShowSettings(true)}
|
||||
title="Paramètres"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
|
||||
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="btn-disconnect"
|
||||
onClick={handleDisconnect}
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
@@ -239,18 +270,25 @@ function App() {
|
||||
/>
|
||||
|
||||
{/* Liste des participants */}
|
||||
<UserList participants={participants} />
|
||||
<UserList
|
||||
participants={participants}
|
||||
onToggleMute={toggleParticipantMute}
|
||||
/>
|
||||
|
||||
{/* Indicateur audio */}
|
||||
<AudioIndicator level={audioLevel} isTalking={isTalking} />
|
||||
|
||||
{/* Bouton PTT principal */}
|
||||
{/* Bouton PTT principal avec VU-mètre intégré */}
|
||||
<PTTButton
|
||||
isTalking={isTalking}
|
||||
onPressStart={startTalking}
|
||||
onPressEnd={stopTalking}
|
||||
audioLevel={audioLevel}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* Modal de paramètres */}
|
||||
<Settings isOpen={showSettings} onClose={() => setShowSettings(false)} />
|
||||
|
||||
{/* Prompt installation PWA (iOS) */}
|
||||
<PWAInstallPrompt />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
.routing-matrix-container {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: var(--spacing-xl);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.routing-actions {
|
||||
margin-top: var(--spacing-xl);
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.routing-matrix-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.routing-matrix-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ws-status {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ws-status.connected {
|
||||
color: #44ff44;
|
||||
background: rgba(68, 255, 68, 0.1);
|
||||
}
|
||||
|
||||
.ws-status.disconnected {
|
||||
color: #888;
|
||||
background: rgba(136, 136, 136, 0.1);
|
||||
}
|
||||
|
||||
.routing-section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.routing-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.routing-section h4 {
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.routing-description {
|
||||
margin: 0 0 var(--spacing-lg) 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.routing-matrix {
|
||||
display: inline-grid;
|
||||
gap: 2px;
|
||||
background: var(--color-border);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.matrix-corner {
|
||||
background: var(--color-surface-hover);
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.matrix-header-cell {
|
||||
background: var(--color-surface-hover);
|
||||
padding: var(--spacing-sm);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 50px;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.matrix-label-cell {
|
||||
background: var(--color-surface-hover);
|
||||
padding: var(--spacing-sm);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 120px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.label-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.matrix-cell {
|
||||
background: var(--color-bg);
|
||||
padding: var(--spacing-sm);
|
||||
min-height: 60px;
|
||||
min-width: 80px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-xs);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cell-checkbox {
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.matrix-cell:hover {
|
||||
background: var(--color-surface-hover);
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.matrix-cell.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.matrix-cell.active:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.gain-select {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 1);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gain-select:focus {
|
||||
outline: none;
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
border-color: rgba(255, 255, 255, 1);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.matrix-header-cell,
|
||||
.matrix-label-cell {
|
||||
font-size: 0.75rem;
|
||||
padding: var(--spacing-xs);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.matrix-cell {
|
||||
min-width: 70px;
|
||||
min-height: 50px;
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.gain-select {
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.routing-matrix-container {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.routing-matrix-header {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.matrix-header-cell,
|
||||
.matrix-label-cell {
|
||||
font-size: 0.7rem;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.matrix-cell {
|
||||
min-width: 65px;
|
||||
min-height: 45px;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.gain-select {
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './AudioRoutingMatrix.css';
|
||||
import VUMeter from './VUMeter.jsx';
|
||||
import { useAudioLevels } from '../hooks/useAudioLevels.js';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
|
||||
function AudioRoutingMatrix({ groups, channelNames }) {
|
||||
const { levels, connected: wsConnected } = useAudioLevels();
|
||||
const [routing, setRouting] = useState({ inputToGroup: {}, groupToOutput: {}, gains: {} });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showOnlyNamedChannels, setShowOnlyNamedChannels] = useState(false);
|
||||
const [audioDevice, setAudioDevice] = useState({ inputChannels: 8, outputChannels: 8 });
|
||||
|
||||
useEffect(() => {
|
||||
loadRouting();
|
||||
loadAudioDevice();
|
||||
}, []);
|
||||
|
||||
const loadRouting = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/audio/routing`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setRouting(data.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} });
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement routing:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAudioDevice = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/audio/device`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAudioDevice({
|
||||
inputChannels: data.device?.inputChannels || 8,
|
||||
outputChannels: data.device?.outputChannels || 8
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement audio device:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveRouting = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/admin/audio/routing`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(routing)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
alert('Configuration de routing sauvegardée!');
|
||||
} else {
|
||||
const errorText = await res.text();
|
||||
console.error('Erreur serveur:', errorText);
|
||||
alert(`Erreur: ${res.status} - ${errorText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur sauvegarde routing:', error);
|
||||
alert('Erreur lors de la sauvegarde');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInputToGroup = (inputId, groupId) => {
|
||||
setRouting(prev => {
|
||||
const inputToGroup = { ...prev.inputToGroup };
|
||||
if (!inputToGroup[inputId]) {
|
||||
inputToGroup[inputId] = [];
|
||||
}
|
||||
|
||||
const groupArray = [...inputToGroup[inputId]];
|
||||
const index = groupArray.indexOf(groupId);
|
||||
|
||||
if (index > -1) {
|
||||
groupArray.splice(index, 1);
|
||||
} else {
|
||||
groupArray.push(groupId);
|
||||
}
|
||||
|
||||
inputToGroup[inputId] = groupArray;
|
||||
|
||||
return { ...prev, inputToGroup };
|
||||
});
|
||||
};
|
||||
|
||||
const toggleGroupToOutput = (groupId, outputId) => {
|
||||
setRouting(prev => {
|
||||
const groupToOutput = { ...prev.groupToOutput };
|
||||
if (!groupToOutput[groupId]) {
|
||||
groupToOutput[groupId] = [];
|
||||
}
|
||||
|
||||
const outputArray = [...groupToOutput[groupId]];
|
||||
const index = outputArray.indexOf(outputId);
|
||||
|
||||
if (index > -1) {
|
||||
outputArray.splice(index, 1);
|
||||
} else {
|
||||
outputArray.push(outputId);
|
||||
}
|
||||
|
||||
groupToOutput[groupId] = outputArray;
|
||||
|
||||
return { ...prev, groupToOutput };
|
||||
});
|
||||
};
|
||||
|
||||
const isInputRoutedToGroup = (inputId, groupId) => {
|
||||
return routing.inputToGroup[inputId]?.includes(groupId) || false;
|
||||
};
|
||||
|
||||
const isGroupRoutedToOutput = (groupId, outputId) => {
|
||||
return routing.groupToOutput[groupId]?.includes(outputId) || false;
|
||||
};
|
||||
|
||||
const getGainForInputToGroup = (inputId, groupId) => {
|
||||
const key = `in_${inputId}_${groupId}`;
|
||||
return routing.gains?.[key] || 0.0;
|
||||
};
|
||||
|
||||
const getGainForGroupToOutput = (groupId, outputId) => {
|
||||
const key = `${groupId}_out_${outputId}`;
|
||||
return routing.gains?.[key] || 0.0;
|
||||
};
|
||||
|
||||
const setGainForInputToGroup = (inputId, groupId, gainDb) => {
|
||||
setRouting(prev => {
|
||||
const gains = { ...prev.gains };
|
||||
const key = `in_${inputId}_${groupId}`;
|
||||
gains[key] = parseFloat(gainDb);
|
||||
return { ...prev, gains };
|
||||
});
|
||||
};
|
||||
|
||||
const setGainForGroupToOutput = (groupId, outputId, gainDb) => {
|
||||
setRouting(prev => {
|
||||
const gains = { ...prev.gains };
|
||||
const key = `${groupId}_out_${outputId}`;
|
||||
gains[key] = parseFloat(gainDb);
|
||||
return { ...prev, gains };
|
||||
});
|
||||
};
|
||||
|
||||
const formatGain = (gainDb) => {
|
||||
if (gainDb === 0) return '0dB';
|
||||
return gainDb > 0 ? `+${gainDb}dB` : `${gainDb}dB`;
|
||||
};
|
||||
|
||||
const getChannelName = (type, id) => {
|
||||
const name = channelNames?.[type]?.[id];
|
||||
return name || `${type === 'inputs' ? 'Input' : 'Output'} ${id}`;
|
||||
};
|
||||
|
||||
const hasCustomName = (type, id) => {
|
||||
return channelNames?.[type]?.[id] !== undefined;
|
||||
};
|
||||
|
||||
const getVisibleInputChannels = () => {
|
||||
const allInputs = Array.from({length: audioDevice.inputChannels}, (_, i) => i);
|
||||
if (showOnlyNamedChannels) {
|
||||
return allInputs.filter(i => hasCustomName('inputs', i));
|
||||
}
|
||||
return allInputs;
|
||||
};
|
||||
|
||||
const getVisibleOutputChannels = () => {
|
||||
const allOutputs = Array.from({length: audioDevice.outputChannels}, (_, i) => i);
|
||||
if (showOnlyNamedChannels) {
|
||||
return allOutputs.filter(i => hasCustomName('outputs', i));
|
||||
}
|
||||
return allOutputs;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={{padding: 'var(--spacing-xl)', textAlign: 'center'}}>Chargement...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="routing-matrix-container">
|
||||
<div className="routing-matrix-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<h3>Matrice de routing audio</h3>
|
||||
<span
|
||||
className={`ws-status ${wsConnected ? 'connected' : 'disconnected'}`}
|
||||
title={wsConnected ? 'Monitoring temps réel actif' : 'Monitoring temps réel déconnecté'}
|
||||
>
|
||||
{wsConnected ? '● Live' : '○ Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showOnlyNamedChannels}
|
||||
onChange={(e) => setShowOnlyNamedChannels(e.target.checked)}
|
||||
/>
|
||||
<span>Afficher uniquement les canaux nommés</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="routing-section">
|
||||
<h4>Inputs vers Groupes</h4>
|
||||
<p className="routing-description">
|
||||
Sélectionnez quels inputs audio alimentent chaque groupe
|
||||
</p>
|
||||
|
||||
<div className="routing-matrix" style={{gridTemplateColumns: `120px repeat(${groups.length}, minmax(60px, 1fr))`}}>
|
||||
<div className="matrix-corner"></div>
|
||||
|
||||
{groups.map(group => (
|
||||
<div key={group.id} className="matrix-header-cell">
|
||||
{group.name}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{getVisibleInputChannels().map(i => (
|
||||
<React.Fragment key={`input-row-${i}`}>
|
||||
<div className="matrix-label-cell">
|
||||
<div className="label-content">
|
||||
<span className="label-text">{getChannelName('inputs', i)}</span>
|
||||
{wsConnected && levels.inputs[i] && (
|
||||
<VUMeter level={levels.inputs[i]} size="mini" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groups.map(group => {
|
||||
const isRouted = isInputRoutedToGroup(String(i), group.id);
|
||||
const gain = getGainForInputToGroup(String(i), group.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${i}-${group.id}`}
|
||||
className={`matrix-cell ${isRouted ? 'active' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="cell-checkbox"
|
||||
onClick={() => toggleInputToGroup(String(i), group.id)}
|
||||
>
|
||||
{isRouted && <span className="checkmark">✓</span>}
|
||||
</div>
|
||||
{isRouted && (
|
||||
<select
|
||||
className="gain-select"
|
||||
value={gain}
|
||||
onChange={(e) => setGainForInputToGroup(String(i), group.id, e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="-12">-12dB</option>
|
||||
<option value="-6">-6dB</option>
|
||||
<option value="-3">-3dB</option>
|
||||
<option value="0">0dB</option>
|
||||
<option value="3">+3dB</option>
|
||||
<option value="6">+6dB</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="routing-section">
|
||||
<h4>Groupes vers Outputs</h4>
|
||||
<p className="routing-description">
|
||||
Sélectionnez vers quels outputs chaque groupe envoie son audio
|
||||
</p>
|
||||
|
||||
<div className="routing-matrix" style={{gridTemplateColumns: `120px repeat(${getVisibleOutputChannels().length}, minmax(60px, 1fr))`}}>
|
||||
<div className="matrix-corner"></div>
|
||||
|
||||
{getVisibleOutputChannels().map(i => (
|
||||
<div key={`output-header-${i}`} className="matrix-header-cell">
|
||||
<div className="header-content">
|
||||
<span className="header-text">{getChannelName('outputs', i)}</span>
|
||||
{wsConnected && levels.outputs[i] && (
|
||||
<VUMeter level={levels.outputs[i]} size="mini" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{groups.map(group => (
|
||||
<React.Fragment key={`group-row-${group.id}`}>
|
||||
<div className="matrix-label-cell">
|
||||
<div className="label-content">
|
||||
<span className="label-text">{group.name}</span>
|
||||
{wsConnected && levels.groups[group.id] && (
|
||||
<VUMeter level={levels.groups[group.id]} size="mini" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getVisibleOutputChannels().map(i => {
|
||||
const isRouted = isGroupRoutedToOutput(group.id, String(i));
|
||||
const gain = getGainForGroupToOutput(group.id, String(i));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${group.id}-${i}`}
|
||||
className={`matrix-cell ${isRouted ? 'active' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="cell-checkbox"
|
||||
onClick={() => toggleGroupToOutput(group.id, String(i))}
|
||||
>
|
||||
{isRouted && <span className="checkmark">✓</span>}
|
||||
</div>
|
||||
{isRouted && (
|
||||
<select
|
||||
className="gain-select"
|
||||
value={gain}
|
||||
onChange={(e) => setGainForGroupToOutput(group.id, String(i), e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="-12">-12dB</option>
|
||||
<option value="-6">-6dB</option>
|
||||
<option value="-3">-3dB</option>
|
||||
<option value="0">0dB</option>
|
||||
<option value="3">+3dB</option>
|
||||
<option value="6">+6dB</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="routing-actions">
|
||||
<button onClick={saveRouting} className="btn-primary">
|
||||
Sauvegarder le routing audio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AudioRoutingMatrix;
|
||||
@@ -11,6 +11,24 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Wrapper bouton + anneau VU-mètre */
|
||||
.ptt-button-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Anneau VU-mètre (SVG) */
|
||||
.audio-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.ptt-button {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
@@ -27,6 +45,7 @@
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
z-index: 1; /* Au-dessus de l'anneau */
|
||||
|
||||
/* Mobile touch optimizations */
|
||||
touch-action: none;
|
||||
@@ -189,6 +208,11 @@
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.audio-ring {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.ptt-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
@@ -210,6 +234,11 @@
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.audio-ring {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.ptt-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import './PTTButton.css';
|
||||
import { loadSettings } from './Settings';
|
||||
|
||||
/**
|
||||
* Bouton PTT principal
|
||||
* Gère touch et mouse events pour desktop et mobile
|
||||
* Modes :
|
||||
* - PTT classique : maintenir pour parler
|
||||
* - Mode continu (lock) : glisser vers le haut pendant qu'on parle
|
||||
* - Mode continu (lock) : glisser vers le haut pendant qu'on parle OU mode par défaut
|
||||
* Inclut VU-mètre intégré (anneau autour du bouton)
|
||||
*/
|
||||
export default function PTTButton({ isTalking, onPressStart, onPressEnd }) {
|
||||
export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLevel = 0 }) {
|
||||
const buttonRef = useRef(null);
|
||||
const isPressingRef = useRef(false);
|
||||
const [isLockMode, setIsLockMode] = useState(false);
|
||||
const isLockModeRef = useRef(false); // Ref pour accès immédiat dans event handlers
|
||||
const [settings, setSettings] = useState(loadSettings());
|
||||
|
||||
// Drag tracking
|
||||
const dragStartYRef = useRef(null);
|
||||
@@ -206,8 +209,8 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd }) {
|
||||
|
||||
// Le micro est déjà actif (onPressStart a été appelé)
|
||||
|
||||
// Vibration pour feedback
|
||||
if (navigator.vibrate) {
|
||||
// Vibration pour feedback (si activé dans les paramètres)
|
||||
if (settings.vibrationEnabled && navigator.vibrate) {
|
||||
navigator.vibrate([100, 50, 100]);
|
||||
}
|
||||
};
|
||||
@@ -225,8 +228,8 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd }) {
|
||||
console.log('🔒 Mode lock ON');
|
||||
onPressStart();
|
||||
|
||||
// Vibration pour feedback
|
||||
if (navigator.vibrate) {
|
||||
// Vibration pour feedback (si activé dans les paramètres)
|
||||
if (settings.vibrationEnabled && navigator.vibrate) {
|
||||
navigator.vibrate([100, 50, 100]);
|
||||
}
|
||||
} else {
|
||||
@@ -234,13 +237,32 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd }) {
|
||||
console.log('🔓 Mode lock OFF');
|
||||
onPressEnd();
|
||||
|
||||
// Vibration pour feedback
|
||||
if (navigator.vibrate) {
|
||||
// Vibration pour feedback (si activé dans les paramètres)
|
||||
if (settings.vibrationEnabled && navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Calculer le niveau audio normalisé (0-100)
|
||||
const normalizedLevel = Math.min(100, Math.max(0, audioLevel));
|
||||
|
||||
// Convertir le niveau en angle pour le cercle SVG (0-360°)
|
||||
const levelAngle = (normalizedLevel / 100) * 360;
|
||||
|
||||
// Calculer le dasharray pour l'arc SVG
|
||||
const radius = 130; // Rayon du cercle VU-mètre
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const dashOffset = circumference - (levelAngle / 360) * circumference;
|
||||
|
||||
// Déterminer la couleur selon le niveau
|
||||
const getAudioColor = () => {
|
||||
if (normalizedLevel > 90) return '#ef4444'; // Danger (rouge)
|
||||
if (normalizedLevel > 75) return '#f59e0b'; // Warning (orange)
|
||||
if (isTalking) return '#3b82f6'; // Talking (bleu)
|
||||
return '#10b981'; // Normal (vert)
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ptt-container">
|
||||
{/* Zone de drag vers le haut (indicateur visuel) */}
|
||||
@@ -253,6 +275,44 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conteneur bouton + VU-mètre */}
|
||||
<div className="ptt-button-wrapper">
|
||||
{/* VU-mètre circulaire (SVG) */}
|
||||
<svg
|
||||
className="audio-ring"
|
||||
width="280"
|
||||
height="280"
|
||||
viewBox="0 0 280 280"
|
||||
>
|
||||
{/* Cercle de fond (gris) */}
|
||||
<circle
|
||||
cx="140"
|
||||
cy="140"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(255, 255, 255, 0.1)"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
|
||||
{/* Cercle de niveau audio (coloré) */}
|
||||
<circle
|
||||
cx="140"
|
||||
cy="140"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={getAudioColor()}
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
transform="rotate(-90 140 140)"
|
||||
style={{
|
||||
transition: 'stroke-dashoffset 0.1s ease, stroke 0.2s ease',
|
||||
filter: normalizedLevel > 0 ? `drop-shadow(0 0 8px ${getAudioColor()})` : 'none'
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Bouton PTT principal */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
@@ -295,6 +355,7 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd }) {
|
||||
: 'Maintenir pour parler'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="ptt-hint">
|
||||
{isLockMode
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
.pwa-prompt-overlay {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1001;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.pwa-prompt {
|
||||
width: 100%;
|
||||
background: #1a1a1a;
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
.pwa-prompt-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.pwa-prompt-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.pwa-prompt-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pwa-prompt-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.pwa-prompt-content {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.pwa-prompt-content > p {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #d1d5db;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.pwa-prompt-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.pwa-prompt-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pwa-prompt-step p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.pwa-prompt-step svg {
|
||||
flex-shrink: 0;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.pwa-prompt-footer {
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pwa-prompt-footer .btn-primary {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './PWAInstallPrompt.css';
|
||||
|
||||
/**
|
||||
* Composant pour afficher un message d'onboarding PWA
|
||||
* Spécialement pour iOS qui nécessite l'installation manuelle
|
||||
*/
|
||||
export default function PWAInstallPrompt() {
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
const [isStandalone, setIsStandalone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Détecter iOS
|
||||
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
setIsIOS(iOS);
|
||||
|
||||
// Détecter si déjà en mode standalone (installé)
|
||||
const standalone = window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.navigator.standalone
|
||||
|| document.referrer.includes('android-app://');
|
||||
setIsStandalone(standalone);
|
||||
|
||||
// Vérifier si l'utilisateur a déjà vu le prompt
|
||||
const hasSeenPrompt = localStorage.getItem('pwa-install-prompt-seen');
|
||||
|
||||
// Afficher le prompt si iOS, pas installé, et jamais vu
|
||||
if (iOS && !standalone && !hasSeenPrompt) {
|
||||
// Afficher après 3 secondes pour ne pas être intrusif
|
||||
setTimeout(() => {
|
||||
setShowPrompt(true);
|
||||
}, 3000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
localStorage.setItem('pwa-install-prompt-seen', 'true');
|
||||
};
|
||||
|
||||
if (!showPrompt || !isIOS || isStandalone) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pwa-prompt-overlay">
|
||||
<div className="pwa-prompt">
|
||||
<div className="pwa-prompt-header">
|
||||
<h3>Installation requise pour les notifications</h3>
|
||||
<button className="pwa-prompt-close" onClick={handleDismiss}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pwa-prompt-content">
|
||||
<p>
|
||||
Pour recevoir les notifications d'appels, vous devez installer l'application sur votre écran d'accueil.
|
||||
</p>
|
||||
|
||||
<div className="pwa-prompt-steps">
|
||||
<div className="pwa-prompt-step">
|
||||
<div className="step-number">1</div>
|
||||
<p>Appuyez sur le bouton <strong>Partager</strong></p>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="pwa-prompt-step">
|
||||
<div className="step-number">2</div>
|
||||
<p>Sélectionnez <strong>Sur l'écran d'accueil</strong></p>
|
||||
</div>
|
||||
|
||||
<div className="pwa-prompt-step">
|
||||
<div className="step-number">3</div>
|
||||
<p>Tapez <strong>Ajouter</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pwa-prompt-footer">
|
||||
<button className="btn-primary" onClick={handleDismiss}>
|
||||
J'ai compris
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.settings-modal {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.setting-section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.setting-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.setting-section h3 {
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.radio-option,
|
||||
.checkbox-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.radio-option:hover,
|
||||
.checkbox-option:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.radio-option:has(input:checked),
|
||||
.checkbox-option:has(input:checked) {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.radio-option input[type="radio"],
|
||||
.checkbox-option input[type="checkbox"] {
|
||||
margin-top: 0.25rem;
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.radio-option div,
|
||||
.checkbox-option div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.radio-option strong,
|
||||
.checkbox-option strong {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.radio-option p,
|
||||
.checkbox-option p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.settings-footer .btn-primary {
|
||||
padding: var(--spacing-sm) var(--spacing-xl);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './Settings.css';
|
||||
|
||||
const STORAGE_KEY = 'ptt-live-settings';
|
||||
|
||||
const defaultSettings = {
|
||||
vibrationEnabled: true
|
||||
};
|
||||
|
||||
/**
|
||||
* Charge les paramètres depuis localStorage
|
||||
*/
|
||||
export function loadSettings() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return { ...defaultSettings, ...JSON.parse(stored) };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement paramètres:', error);
|
||||
}
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde les paramètres dans localStorage
|
||||
*/
|
||||
export function saveSettings(settings) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
console.error('Erreur sauvegarde paramètres:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composant modal de paramètres
|
||||
*/
|
||||
export default function Settings({ isOpen, onClose }) {
|
||||
const [settings, setSettings] = useState(defaultSettings);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSettings(loadSettings());
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleChange = (key, value) => {
|
||||
const newSettings = { ...settings, [key]: value };
|
||||
setSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="settings-overlay" onClick={onClose}>
|
||||
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="settings-header">
|
||||
<h2>Paramètres</h2>
|
||||
<button className="close-btn" onClick={onClose}>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
<div className="setting-section">
|
||||
<h3>Feedback</h3>
|
||||
|
||||
<label className="checkbox-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.vibrationEnabled}
|
||||
onChange={(e) => handleChange('vibrationEnabled', e.target.checked)}
|
||||
/>
|
||||
<div>
|
||||
<strong>Vibrations</strong>
|
||||
<p>Activer le retour haptique lors du verrouillage PTT</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-footer">
|
||||
<button className="btn-primary" onClick={onClose}>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,32 @@
|
||||
.user-list {
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
max-height: 180px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
/* Améliorer le scroll sur mobile */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* Style de scrollbar personnalisé */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||
}
|
||||
|
||||
/* Scrollbar personnalisée (webkit) */
|
||||
.user-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.user-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.user-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.user-list::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.user-list.empty {
|
||||
@@ -36,7 +60,10 @@
|
||||
}
|
||||
|
||||
.user-list-items {
|
||||
padding: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Item utilisateur */
|
||||
@@ -57,6 +84,20 @@
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
/* Canal virtuel */
|
||||
.user-item.virtual-channel {
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.user-item.virtual-channel.muted {
|
||||
opacity: 0.5;
|
||||
border-left-color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.user-avatar.channel {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Avatar */
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
@@ -131,7 +172,7 @@
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.user-list {
|
||||
max-height: 150px;
|
||||
max-height: 250px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
@@ -148,3 +189,59 @@
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Portrait mobile - encore plus d'espace */
|
||||
@media (max-width: 640px) and (orientation: portrait) {
|
||||
.user-list {
|
||||
max-height: 200px;
|
||||
min-height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Paysage - réduire un peu pour laisser place au PTT */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.user-list {
|
||||
max-height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bouton mute/unmute */
|
||||
.mute-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.mute-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.mute-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.mute-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.user-item.muted .mute-button {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.channel-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,69 @@
|
||||
import './UserList.css';
|
||||
|
||||
/**
|
||||
* Liste des participants connectés
|
||||
* Liste des participants connectés (utilisateurs + canaux virtuels)
|
||||
*/
|
||||
export default function UserList({ participants }) {
|
||||
export default function UserList({ participants, onToggleMute }) {
|
||||
if (participants.length === 0) {
|
||||
return (
|
||||
<div className="user-list empty">
|
||||
<p className="empty-message">Aucun autre participant</p>
|
||||
<p className="empty-message">Aucun participant ou canal</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Séparer canaux virtuels et utilisateurs
|
||||
const virtualChannels = participants.filter(p => p.isVirtual);
|
||||
const users = participants.filter(p => !p.isVirtual);
|
||||
|
||||
return (
|
||||
<div className="user-list">
|
||||
<div className="user-list-header">
|
||||
<span className="user-count">
|
||||
{participants.length} participant{participants.length > 1 ? 's' : ''}
|
||||
{virtualChannels.length > 0 && `${virtualChannels.length} canal${virtualChannels.length > 1 ? 'aux' : ''}`}
|
||||
{virtualChannels.length > 0 && users.length > 0 && ' • '}
|
||||
{users.length > 0 && `${users.length} utilisateur${users.length > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="user-list-items">
|
||||
{participants.map((participant) => (
|
||||
{/* Canaux virtuels en premier */}
|
||||
{virtualChannels.map((participant) => (
|
||||
<div
|
||||
key={participant.identity}
|
||||
className={`user-item virtual-channel ${participant.isSpeaking ? 'speaking' : ''} ${participant.isMuted ? 'muted' : ''}`}
|
||||
>
|
||||
<div className="user-avatar channel">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
|
||||
<path d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21c2.31 0 4.2-1.75 4.45-4H15V6h4V3h-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="user-info">
|
||||
<span className="user-name">{participant.name}</span>
|
||||
<span className="user-status channel-label">Canal audio</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="mute-button"
|
||||
onClick={() => onToggleMute(participant.identity, participant.isVirtual)}
|
||||
title={participant.isMuted ? 'Activer' : 'Désactiver'}
|
||||
>
|
||||
{participant.isMuted ? (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Utilisateurs WebRTC */}
|
||||
{users.map((participant) => (
|
||||
<div
|
||||
key={participant.identity}
|
||||
className={`user-item ${participant.isSpeaking ? 'speaking' : ''}`}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/* VU-mètre version mini (pour matrice routing) */
|
||||
.vu-meter-mini {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vu-meter-mini.clipping {
|
||||
box-shadow: 0 0 4px rgba(255, 68, 68, 0.8);
|
||||
}
|
||||
|
||||
.vu-meter-mini-bar {
|
||||
height: 100%;
|
||||
transition: width 50ms linear;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* VU-mètre horizontal */
|
||||
.vu-meter-horizontal {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vu-meter-horizontal.small {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.vu-meter-horizontal.medium {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.vu-meter-horizontal.large {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.vu-meter-horizontal .vu-meter-bar-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vu-meter-horizontal .vu-meter-bar-rms {
|
||||
height: 100%;
|
||||
transition: width 50ms linear;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.vu-meter-horizontal .vu-meter-bar-peak {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 2px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
transition: left 50ms linear;
|
||||
}
|
||||
|
||||
.vu-meter-horizontal.clipping .vu-meter-bar-container {
|
||||
box-shadow: 0 0 6px rgba(255, 68, 68, 0.8);
|
||||
animation: clipping-pulse 200ms ease-in-out;
|
||||
}
|
||||
|
||||
/* VU-mètre vertical */
|
||||
.vu-meter-vertical {
|
||||
height: 100px;
|
||||
width: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vu-meter-vertical.small {
|
||||
height: 60px;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.vu-meter-vertical.medium {
|
||||
height: 100px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.vu-meter-vertical.large {
|
||||
height: 150px;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.vu-meter-vertical .vu-meter-bar-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.vu-meter-vertical .vu-meter-bar-rms {
|
||||
width: 100%;
|
||||
transition: height 50ms linear;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.vu-meter-vertical .vu-meter-bar-peak {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
transition: bottom 50ms linear;
|
||||
}
|
||||
|
||||
.vu-meter-vertical.clipping .vu-meter-bar-container {
|
||||
box-shadow: 0 0 6px rgba(255, 68, 68, 0.8);
|
||||
animation: clipping-pulse 200ms ease-in-out;
|
||||
}
|
||||
|
||||
/* Animation clipping */
|
||||
@keyframes clipping-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 6px rgba(255, 68, 68, 0.8);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 12px rgba(255, 68, 68, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* VUMeter.jsx
|
||||
* Composant VU-mètre minimaliste pour affichage niveaux audio temps réel
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import './VUMeter.css';
|
||||
|
||||
/**
|
||||
* Convertit une valeur dBFS en pourcentage pour affichage
|
||||
* -120dBFS = 0%, 0dBFS = 100%
|
||||
*/
|
||||
function dbToPercent(dbFS) {
|
||||
const min = -60; // On affiche à partir de -60dBFS
|
||||
const max = 0;
|
||||
|
||||
if (dbFS <= min) return 0;
|
||||
if (dbFS >= max) return 100;
|
||||
|
||||
return ((dbFS - min) / (max - min)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine la couleur selon le niveau (style VU professionnel)
|
||||
*/
|
||||
function getLevelColor(dbFS) {
|
||||
if (dbFS >= -3) return '#ff4444'; // Rouge (clipping proche)
|
||||
if (dbFS >= -12) return '#ffaa00'; // Orange (niveau élevé)
|
||||
return '#44ff44'; // Vert (niveau nominal)
|
||||
}
|
||||
|
||||
function VUMeter({ level, size = 'small', orientation = 'vertical' }) {
|
||||
if (!level) {
|
||||
level = { rms: -120, peak: 0, clipping: false };
|
||||
}
|
||||
|
||||
const rmsPercent = dbToPercent(level.rms);
|
||||
const peakPercent = (level.peak || 0) * 100;
|
||||
|
||||
const color = getLevelColor(level.rms);
|
||||
const isClipping = level.clipping || level.peak >= 0.99;
|
||||
|
||||
if (size === 'mini') {
|
||||
// Version ultra-compacte pour matrice routing
|
||||
return (
|
||||
<div className={`vu-meter-mini ${isClipping ? 'clipping' : ''}`}>
|
||||
<div
|
||||
className="vu-meter-mini-bar"
|
||||
style={{
|
||||
width: `${rmsPercent}%`,
|
||||
backgroundColor: color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (orientation === 'horizontal') {
|
||||
return (
|
||||
<div className={`vu-meter-horizontal ${size} ${isClipping ? 'clipping' : ''}`}>
|
||||
<div className="vu-meter-bar-container">
|
||||
<div
|
||||
className="vu-meter-bar-rms"
|
||||
style={{
|
||||
width: `${rmsPercent}%`,
|
||||
backgroundColor: color
|
||||
}}
|
||||
/>
|
||||
{level.peak > 0 && (
|
||||
<div
|
||||
className="vu-meter-bar-peak"
|
||||
style={{ left: `${peakPercent}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Vertical (défaut)
|
||||
return (
|
||||
<div className={`vu-meter-vertical ${size} ${isClipping ? 'clipping' : ''}`}>
|
||||
<div className="vu-meter-bar-container">
|
||||
<div
|
||||
className="vu-meter-bar-rms"
|
||||
style={{
|
||||
height: `${rmsPercent}%`,
|
||||
backgroundColor: color
|
||||
}}
|
||||
/>
|
||||
{level.peak > 0 && (
|
||||
<div
|
||||
className="vu-meter-bar-peak"
|
||||
style={{ bottom: `${peakPercent}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VUMeter;
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* useAudioLevels.js
|
||||
* Hook React pour recevoir les niveaux audio temps réel via WebSocket
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
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
|
||||
*/
|
||||
export function useAudioLevels() {
|
||||
const [levels, setLevels] = useState({
|
||||
inputs: {},
|
||||
groups: {},
|
||||
outputs: {},
|
||||
routing: {
|
||||
activeInputs: [],
|
||||
activeGroups: [],
|
||||
activeOutputs: []
|
||||
}
|
||||
});
|
||||
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef(null);
|
||||
const reconnectTimeoutRef = useRef(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const connect = () => {
|
||||
try {
|
||||
console.log('Connexion au WebSocket audio-levels...');
|
||||
const ws = new WebSocket(WS_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket audio-levels connecté');
|
||||
setConnected(true);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
// Ping périodique pour maintenir la connexion
|
||||
const pingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
ws.pingInterval = pingInterval;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
switch (message.type) {
|
||||
case 'initial':
|
||||
case 'levels':
|
||||
setLevels(message.data);
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
// Pong reçu, connexion active
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Message WebSocket inconnu:', message.type);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur parsing message WebSocket:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('Erreur WebSocket audio-levels:', error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket audio-levels déconnecté');
|
||||
setConnected(false);
|
||||
|
||||
if (ws.pingInterval) {
|
||||
clearInterval(ws.pingInterval);
|
||||
}
|
||||
|
||||
// Reconnexion automatique avec backoff
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
|
||||
console.log(`Reconnexion dans ${delay}ms...`);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectAttemptsRef.current++;
|
||||
connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
} catch (error) {
|
||||
console.error('Erreur création WebSocket:', error);
|
||||
setConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
if (wsRef.current.pingInterval) {
|
||||
clearInterval(wsRef.current.pingInterval);
|
||||
}
|
||||
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
setConnected(false);
|
||||
};
|
||||
|
||||
const setUpdateRate = (rateMs) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'setUpdateRate',
|
||||
rateMs
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
levels,
|
||||
connected,
|
||||
setUpdateRate
|
||||
};
|
||||
}
|
||||
|
||||
export default useAudioLevels;
|
||||
@@ -16,6 +16,8 @@ export default function useLiveKit() {
|
||||
const analyserRef = useRef(null);
|
||||
const animationFrameRef = useRef(null);
|
||||
const isAudioUnlockedRef = useRef(false);
|
||||
const virtualChannelsRef = useRef([]);
|
||||
const mutedChannelsRef = useRef(new Set()); // IDs des canaux muted
|
||||
|
||||
// Analyseur audio pour pistes distantes (audio entrant)
|
||||
const remoteAudioContextRef = useRef(null);
|
||||
@@ -25,8 +27,11 @@ export default function useLiveKit() {
|
||||
/**
|
||||
* Connexion à la room LiveKit
|
||||
*/
|
||||
const connect = useCallback(async (url, token) => {
|
||||
const connect = useCallback(async (url, token, virtualChannels = []) => {
|
||||
try {
|
||||
// Stocker les canaux virtuels
|
||||
virtualChannelsRef.current = virtualChannels;
|
||||
|
||||
// Créer room
|
||||
const room = new Room({
|
||||
adaptiveStream: true,
|
||||
@@ -154,7 +159,7 @@ export default function useLiveKit() {
|
||||
/**
|
||||
* Changer de groupe (reconnexion à une nouvelle room)
|
||||
*/
|
||||
const switchGroup = useCallback(async (url, token) => {
|
||||
const switchGroup = useCallback(async (url, token, virtualChannels = []) => {
|
||||
console.log('🔄 Changement de groupe...');
|
||||
|
||||
// Déconnexion propre
|
||||
@@ -167,8 +172,11 @@ export default function useLiveKit() {
|
||||
setIsConnected(false);
|
||||
setParticipants([]);
|
||||
|
||||
// Reset canaux muted
|
||||
mutedChannelsRef.current.clear();
|
||||
|
||||
// Reconnexion avec nouveau token
|
||||
await connect(url, token);
|
||||
await connect(url, token, virtualChannels);
|
||||
}, [connect]);
|
||||
|
||||
/**
|
||||
@@ -252,15 +260,30 @@ export default function useLiveKit() {
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Mise à jour liste participants
|
||||
* Mise à jour liste participants (inclut canaux virtuels)
|
||||
*/
|
||||
const updateParticipants = () => {
|
||||
const updateParticipants = useCallback(() => {
|
||||
if (!roomRef.current) return;
|
||||
|
||||
const room = roomRef.current;
|
||||
const participantsList = [];
|
||||
|
||||
// Participants distants
|
||||
// Canaux virtuels (affichés en premier)
|
||||
virtualChannelsRef.current.forEach((channel) => {
|
||||
participantsList.push({
|
||||
identity: channel.id,
|
||||
name: channel.name,
|
||||
isLocal: false,
|
||||
isVirtual: true,
|
||||
isSpeaking: false, // TODO: détection audio depuis bridge
|
||||
hasAudio: true,
|
||||
isMuted: mutedChannelsRef.current.has(channel.id),
|
||||
audioInput: channel.audioInput,
|
||||
audioOutput: channel.audioOutput
|
||||
});
|
||||
});
|
||||
|
||||
// Participants distants (utilisateurs WebRTC)
|
||||
room.remoteParticipants.forEach((participant) => {
|
||||
const audioTracks = participant.audioTracks ? Array.from(participant.audioTracks.values()) : [];
|
||||
const audioPublication = audioTracks[0];
|
||||
@@ -270,13 +293,63 @@ export default function useLiveKit() {
|
||||
identity: participant.identity,
|
||||
name: participant.name || participant.identity,
|
||||
isLocal: false,
|
||||
isVirtual: false,
|
||||
isSpeaking,
|
||||
hasAudio: audioPublication?.isSubscribed || false
|
||||
hasAudio: audioPublication?.isSubscribed || false,
|
||||
isMuted: false
|
||||
});
|
||||
});
|
||||
|
||||
setParticipants(participantsList);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Toggle mute/unmute d'un participant (canal virtuel ou utilisateur)
|
||||
*/
|
||||
const toggleParticipantMute = useCallback((participantId, isVirtual) => {
|
||||
if (isVirtual) {
|
||||
// Canal virtuel : toggle dans l'état local
|
||||
const isMuted = mutedChannelsRef.current.has(participantId);
|
||||
|
||||
if (isMuted) {
|
||||
mutedChannelsRef.current.delete(participantId);
|
||||
console.log('🔊 Canal virtuel unmuted:', participantId);
|
||||
} else {
|
||||
mutedChannelsRef.current.add(participantId);
|
||||
console.log('🔇 Canal virtuel muted:', participantId);
|
||||
}
|
||||
|
||||
// TODO Phase 3: Envoyer commande au bridge audio via DataChannel
|
||||
// pour vraiment muter/unmuter le canal physique
|
||||
|
||||
// Mettre à jour l'affichage
|
||||
updateParticipants();
|
||||
} else {
|
||||
// Utilisateur WebRTC : muter localement la lecture audio
|
||||
if (!roomRef.current) return;
|
||||
|
||||
const participant = roomRef.current.remoteParticipants.get(participantId);
|
||||
if (!participant) return;
|
||||
|
||||
const audioTracks = Array.from(participant.audioTracks.values());
|
||||
const audioPublication = audioTracks[0];
|
||||
|
||||
if (audioPublication && audioPublication.audioTrack) {
|
||||
const track = audioPublication.audioTrack;
|
||||
const newMutedState = !track.isMuted;
|
||||
|
||||
if (newMutedState) {
|
||||
track.mute();
|
||||
console.log('🔇 Participant muted:', participantId);
|
||||
} else {
|
||||
track.unmute();
|
||||
console.log('🔊 Participant unmuted:', participantId);
|
||||
}
|
||||
|
||||
updateParticipants();
|
||||
}
|
||||
}
|
||||
}, [updateParticipants]);
|
||||
|
||||
/**
|
||||
* Setup analyseur audio pour VU-mètre (micro local)
|
||||
@@ -412,6 +485,7 @@ export default function useLiveKit() {
|
||||
disconnect,
|
||||
switchGroup,
|
||||
startTalking,
|
||||
stopTalking
|
||||
stopTalking,
|
||||
toggleParticipantMute
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Hook pour gérer les notifications Web Push
|
||||
* Utilisé pour les appels privés et notifications de groupe
|
||||
*/
|
||||
export default function usePush() {
|
||||
const [isSupported, setIsSupported] = useState(false);
|
||||
const [isPermissionGranted, setIsPermissionGranted] = useState(false);
|
||||
const [subscription, setSubscription] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Vérifier si les notifications sont supportées
|
||||
const supported = 'Notification' in window && 'serviceWorker' in navigator;
|
||||
setIsSupported(supported);
|
||||
|
||||
if (supported) {
|
||||
// Vérifier la permission actuelle
|
||||
setIsPermissionGranted(Notification.permission === 'granted');
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Demander la permission pour les notifications
|
||||
*/
|
||||
const requestPermission = useCallback(async () => {
|
||||
if (!isSupported) {
|
||||
console.warn('Notifications non supportées sur ce navigateur');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
const granted = permission === 'granted';
|
||||
setIsPermissionGranted(granted);
|
||||
|
||||
if (granted) {
|
||||
console.log('Permission notifications accordée');
|
||||
} else {
|
||||
console.warn('Permission notifications refusée');
|
||||
}
|
||||
|
||||
return granted;
|
||||
} catch (error) {
|
||||
console.error('Erreur demande permission notifications:', error);
|
||||
return false;
|
||||
}
|
||||
}, [isSupported]);
|
||||
|
||||
/**
|
||||
* S'abonner aux notifications push (via service worker)
|
||||
*/
|
||||
const subscribeToPush = useCallback(async () => {
|
||||
if (!isSupported || !isPermissionGranted) {
|
||||
console.warn('Impossible de s\'abonner : permission non accordée');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Attendre que le service worker soit prêt
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
// Créer l'abonnement push
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(
|
||||
// TODO: Remplacer par la vraie clé VAPID du serveur
|
||||
import.meta.env.VITE_VAPID_PUBLIC_KEY || ''
|
||||
)
|
||||
});
|
||||
|
||||
console.log('Abonnement push créé:', sub);
|
||||
setSubscription(sub);
|
||||
|
||||
return sub;
|
||||
} catch (error) {
|
||||
console.error('Erreur abonnement push:', error);
|
||||
return null;
|
||||
}
|
||||
}, [isSupported, isPermissionGranted]);
|
||||
|
||||
/**
|
||||
* Se désabonner des notifications push
|
||||
*/
|
||||
const unsubscribeFromPush = useCallback(async () => {
|
||||
if (!subscription) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await subscription.unsubscribe();
|
||||
console.log('Désabonnement push réussi');
|
||||
setSubscription(null);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erreur désabonnement push:', error);
|
||||
return false;
|
||||
}
|
||||
}, [subscription]);
|
||||
|
||||
/**
|
||||
* Envoyer une notification locale (sans push serveur)
|
||||
*/
|
||||
const showNotification = useCallback(async (title, options = {}) => {
|
||||
if (!isSupported || !isPermissionGranted) {
|
||||
console.warn('Impossible d\'afficher la notification : permission non accordée');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await registration.showNotification(title, {
|
||||
icon: '/icon-192x192.png',
|
||||
badge: '/badge-72x72.png',
|
||||
vibrate: [200, 100, 200],
|
||||
...options
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur affichage notification:', error);
|
||||
}
|
||||
}, [isSupported, isPermissionGranted]);
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isPermissionGranted,
|
||||
subscription,
|
||||
requestPermission,
|
||||
subscribeToPush,
|
||||
unsubscribeFromPush,
|
||||
showNotification
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir une clé VAPID base64 en Uint8Array
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/\-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-danger: #ef4444;
|
||||
--color-accent: #8b5cf6;
|
||||
--color-error: #ef4444;
|
||||
|
||||
/* PTT States */
|
||||
--color-ptt-idle: #374151;
|
||||
|
||||
+5
-1
@@ -1,10 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import Admin from './Admin.jsx';
|
||||
import './index.css';
|
||||
|
||||
// Simple routing basé sur le path
|
||||
const isAdminPage = window.location.pathname.startsWith('/admin');
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
{isAdminPage ? <Admin /> : <App />}
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
+17
-4
@@ -1,14 +1,26 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import fs from 'fs';
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Charger les variables d'environnement
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
|
||||
// Déterminer l'URL de l'API (utilise variable d'environnement ou fallback localhost)
|
||||
const apiUrl = env.VITE_API_URL || 'http://localhost:3000';
|
||||
const livekitUrl = env.VITE_LIVEKIT_URL || 'ws://localhost:7880';
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
|
||||
injectRegister: 'auto',
|
||||
devOptions: {
|
||||
enabled: true
|
||||
},
|
||||
manifest: {
|
||||
name: 'PTT Live',
|
||||
short_name: 'PTT Live',
|
||||
@@ -64,12 +76,12 @@ export default defineConfig({
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
target: apiUrl.startsWith('/') ? 'http://localhost:3000' : apiUrl,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
},
|
||||
'/livekit': {
|
||||
target: 'ws://10.1.1.111:7880',
|
||||
target: livekitUrl,
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/livekit/, '')
|
||||
@@ -80,4 +92,5 @@ export default defineConfig({
|
||||
outDir: 'dist',
|
||||
sourcemap: true
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
# PTT Live - Script d'installation multi-OS
|
||||
# Détecte automatiquement le système et lance l'installeur approprié
|
||||
|
||||
set -e
|
||||
|
||||
# Couleurs
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${BLUE}=================================="
|
||||
echo "🚀 PTT Live - Installation"
|
||||
echo "==================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Détection du système d'exploitation
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
echo -e "${GREEN}📱 Système détecté : macOS${NC}"
|
||||
echo ""
|
||||
exec ./install/macos.sh
|
||||
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
echo -e "${GREEN}🐧 Système détecté : Linux${NC}"
|
||||
echo ""
|
||||
exec ./install/linux.sh
|
||||
elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "win32" ]]; then
|
||||
echo -e "${YELLOW}🪟 Système détecté : Windows${NC}"
|
||||
echo ""
|
||||
echo -e "${RED}❌ Windows n'est pas encore supporté (Phase 3)${NC}"
|
||||
echo ""
|
||||
echo "Plateformes supportées :"
|
||||
echo " • macOS (via Homebrew)"
|
||||
echo " • Linux (Debian/Ubuntu/Fedora)"
|
||||
echo ""
|
||||
exit 1
|
||||
else
|
||||
echo -e "${RED}❌ Système non reconnu : $OSTYPE${NC}"
|
||||
echo ""
|
||||
echo "Plateformes supportées :"
|
||||
echo " • macOS"
|
||||
echo " • Linux"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
Executable
+363
@@ -0,0 +1,363 @@
|
||||
#!/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 \
|
||||
pulseaudio-utils
|
||||
|
||||
# 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.12.0"
|
||||
LIVEKIT_VERSION_NUM="${LIVEKIT_VERSION#v}" # Retire le 'v' pour le nom du fichier
|
||||
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_NUM}_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 réseau et génération .env
|
||||
configure_network() {
|
||||
echo ""
|
||||
echo "Configuration réseau..."
|
||||
|
||||
# Détection IP réseau
|
||||
NETWORK_IP=$(hostname -I | awk '{print $1}')
|
||||
|
||||
if [ -z "$NETWORK_IP" ]; then
|
||||
echo "⚠️ IP réseau non détectée, utilisation localhost"
|
||||
NETWORK_IP="localhost"
|
||||
else
|
||||
echo "✓ IP réseau détectée : ${NETWORK_IP}"
|
||||
fi
|
||||
|
||||
# Générer .env serveur
|
||||
echo "Génération configuration serveur..."
|
||||
|
||||
cat > "$PROJECT_ROOT/server/.env" << EOF
|
||||
# Configuration PTT Live Server
|
||||
# Généré automatiquement par install/linux.sh
|
||||
|
||||
USE_LOCAL_LIVEKIT=true
|
||||
|
||||
# LiveKit Configuration
|
||||
# AUTO = détection automatique IP réseau au démarrage
|
||||
LIVEKIT_URL=AUTO
|
||||
# En mode --dev, LiveKit utilise ces clés par défaut
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
EOF
|
||||
|
||||
echo "✓ Configuration serveur générée (server/.env)"
|
||||
|
||||
# Générer .env client
|
||||
echo "Génération configuration client..."
|
||||
|
||||
cat > "$PROJECT_ROOT/client/.env" << EOF
|
||||
# Configuration PTT Live Client
|
||||
# Généré automatiquement par install/linux.sh
|
||||
|
||||
# En développement local, utilise le proxy Vite
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Pour accès réseau (autres devices), décommentez et mettez l'IP du serveur :
|
||||
# VITE_API_URL=http://${NETWORK_IP}:3000
|
||||
EOF
|
||||
|
||||
echo "✓ Configuration client générée (client/.env)"
|
||||
}
|
||||
|
||||
# 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 "🚀 Démarrage rapide :"
|
||||
echo ""
|
||||
echo " # Mode développement (recommandé)"
|
||||
echo " ./start.sh --dev"
|
||||
echo ""
|
||||
echo " # Mode production"
|
||||
echo " ./start.sh"
|
||||
echo ""
|
||||
echo "📝 OU manuellement (deux terminaux) :"
|
||||
echo ""
|
||||
echo " Terminal 1 : cd $PROJECT_ROOT/server && npm run dev"
|
||||
echo " Terminal 2 : cd $PROJECT_ROOT/client && npm run dev"
|
||||
echo ""
|
||||
echo "🌐 Accès après démarrage :"
|
||||
echo " • Développement local : https://localhost:5173"
|
||||
echo " • Depuis smartphone (WiFi) : https://${NETWORK_IP}:5173"
|
||||
echo " • Admin : https://${NETWORK_IP}:5173/admin"
|
||||
echo ""
|
||||
echo "💡 Configuration réseau :"
|
||||
echo " IP serveur détectée : ${NETWORK_IP}"
|
||||
echo " LiveKit URL : AUTO (détection dynamique)"
|
||||
echo ""
|
||||
echo "📖 Documentation :"
|
||||
echo " • README.md - Guide complet"
|
||||
echo " • README-PORTABLE.md - Déploiement portable"
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Script principal
|
||||
main() {
|
||||
detect_distro
|
||||
install_system_deps
|
||||
install_livekit_server
|
||||
install_node_deps
|
||||
configure_network
|
||||
configure_audio
|
||||
print_summary
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+68
-12
@@ -51,6 +51,16 @@ fi
|
||||
echo -e "${GREEN}✅ Homebrew $(brew --version | head -n 1)${NC}"
|
||||
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
|
||||
echo "📥 Installation LiveKit Server..."
|
||||
if command -v livekit-server &> /dev/null; then
|
||||
@@ -72,7 +82,7 @@ echo ""
|
||||
|
||||
# Installer dépendances serveur
|
||||
echo "📦 Installation dépendances serveur..."
|
||||
cd ../server
|
||||
cd ./server
|
||||
npm install
|
||||
echo -e "${GREEN}✅ Dépendances serveur installées${NC}"
|
||||
echo ""
|
||||
@@ -86,14 +96,30 @@ echo ""
|
||||
|
||||
cd ..
|
||||
|
||||
# Créer fichier .env
|
||||
echo "🔑 Génération configuration LiveKit..."
|
||||
# Détecter l'IP réseau locale
|
||||
echo "🌐 Détection configuration réseau..."
|
||||
NETWORK_IP=$(ifconfig | grep "inet " | grep -v 127.0.0.1 | awk '{print $2}' | head -n 1)
|
||||
|
||||
if [ -z "$NETWORK_IP" ]; then
|
||||
echo -e "${YELLOW}⚠️ IP réseau non détectée, utilisation localhost${NC}"
|
||||
NETWORK_IP="localhost"
|
||||
else
|
||||
echo -e "${GREEN}✅ IP réseau détectée : ${NETWORK_IP}${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Créer fichier .env serveur
|
||||
echo "🔑 Génération configuration serveur..."
|
||||
|
||||
cat > server/.env << EOF
|
||||
# Configuration PTT Live Server
|
||||
# Généré automatiquement par install/macos.sh
|
||||
|
||||
USE_LOCAL_LIVEKIT=true
|
||||
|
||||
# LiveKit Configuration
|
||||
LIVEKIT_URL=ws://localhost:7880
|
||||
# AUTO = détection automatique IP réseau au démarrage
|
||||
LIVEKIT_URL=AUTO
|
||||
# En mode --dev, LiveKit utilise ces clés par défaut
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
@@ -103,22 +129,52 @@ PORT=3000
|
||||
NODE_ENV=development
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}✅ Clés API générées (server/.env)${NC}"
|
||||
echo -e "${GREEN}✅ Configuration serveur générée (server/.env)${NC}"
|
||||
|
||||
# Créer fichier .env client
|
||||
echo "🔑 Génération configuration client..."
|
||||
|
||||
cat > client/.env << EOF
|
||||
# Configuration PTT Live Client
|
||||
# Généré automatiquement par install/macos.sh
|
||||
|
||||
# En développement local, utilise le proxy Vite
|
||||
VITE_API_URL=/api
|
||||
|
||||
# Pour accès réseau (autres devices), décommentez et mettez l'IP du serveur :
|
||||
# VITE_API_URL=http://${NETWORK_IP}:3000
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}✅ Configuration client générée (client/.env)${NC}"
|
||||
echo ""
|
||||
|
||||
# Message final
|
||||
echo "=================================="
|
||||
echo -e "${GREEN}✅ Installation terminée !${NC}"
|
||||
echo ""
|
||||
echo "📝 Prochaines étapes :"
|
||||
echo "🚀 Démarrage rapide :"
|
||||
echo ""
|
||||
echo " 1. Démarrer le serveur :"
|
||||
echo " cd server && npm run dev"
|
||||
echo " # Mode développement (recommandé)"
|
||||
echo " ./start.sh --dev"
|
||||
echo ""
|
||||
echo " 2. Démarrer le client (nouveau terminal) :"
|
||||
echo " cd client && npm run dev"
|
||||
echo " # Mode production"
|
||||
echo " ./start.sh"
|
||||
echo ""
|
||||
echo " 3. Ouvrir https://localhost:5173 dans votre navigateur"
|
||||
echo "📝 OU manuellement (deux terminaux) :"
|
||||
echo ""
|
||||
echo "📖 Documentation : README.md"
|
||||
echo " Terminal 1 : cd server && npm run dev"
|
||||
echo " Terminal 2 : cd client && npm run dev"
|
||||
echo ""
|
||||
echo "🌐 Accès après démarrage :"
|
||||
echo " • Développement local : https://localhost:5173"
|
||||
echo " • Depuis smartphone (WiFi) : https://${NETWORK_IP}:5173"
|
||||
echo " • Admin : https://${NETWORK_IP}:5173/admin"
|
||||
echo ""
|
||||
echo "💡 Configuration réseau :"
|
||||
echo " IP serveur détectée : ${NETWORK_IP}"
|
||||
echo " LiveKit URL : AUTO (détection dynamique)"
|
||||
echo ""
|
||||
echo "📖 Documentation :"
|
||||
echo " • README.md - Guide complet"
|
||||
echo " • README-PORTABLE.md - Déploiement portable"
|
||||
echo ""
|
||||
|
||||
+18
-12
@@ -1,17 +1,23 @@
|
||||
# PTT Live - Configuration environnement serveur
|
||||
# Configuration PTT Live Server
|
||||
# Copiez ce fichier en .env et adaptez selon votre environnement
|
||||
|
||||
# LiveKit API Keys
|
||||
# En dev, utilise les valeurs par défaut si non définies
|
||||
# Mode LiveKit
|
||||
USE_LOCAL_LIVEKIT=true # true = LiveKit local, false = LiveKit Cloud
|
||||
|
||||
# LiveKit Configuration
|
||||
# Mode local : AUTO détecte automatiquement l'IP réseau
|
||||
# Mode cloud : URL complète wss://votre-projet.livekit.cloud
|
||||
LIVEKIT_URL=AUTO
|
||||
|
||||
# Clés API LiveKit
|
||||
# Mode local --dev : devkey/secret (par défaut)
|
||||
# Mode cloud : récupérez vos clés sur https://cloud.livekit.io
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
# URL LiveKit pour les clients
|
||||
# Pour permettre les connexions réseau, utilisez l'IP locale du serveur
|
||||
# Exemples :
|
||||
# - Local uniquement : ws://localhost:7880
|
||||
# - Réseau local : ws://192.168.1.100:7880 (remplacer par votre IP)
|
||||
# - Utiliser AUTO pour détecter automatiquement l'IP réseau
|
||||
LIVEKIT_URL=AUTO
|
||||
# Configuration serveur
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Mode LiveKit local (démarre livekit-server automatiquement)
|
||||
USE_LOCAL_LIVEKIT=true
|
||||
# Logging (optionnel)
|
||||
# LOG_LEVEL=debug
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
/**
|
||||
* API Admin - Gestion groupes, utilisateurs, monitoring
|
||||
* Phase 2.3
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import { CoreAudioBackend } from '../bridge/backends/CoreAudioBackend.js';
|
||||
import configManager from '../config/ConfigManager.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Génère un ID slug à partir d'un nom
|
||||
*/
|
||||
function slugify(text) {
|
||||
return text
|
||||
.toString()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w-]+/g, '')
|
||||
.replace(/--+/g, '-');
|
||||
}
|
||||
|
||||
// État en mémoire des utilisateurs connectés
|
||||
const connectedUsers = new Map(); // identity -> { username, groupId, roomName, connectedAt, lastActivity }
|
||||
|
||||
// Stats monitoring
|
||||
const stats = {
|
||||
totalConnections: 0,
|
||||
activeConnections: 0,
|
||||
audioStats: [],
|
||||
logs: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Ajoute un log au système
|
||||
*/
|
||||
export function addLog(level, message, meta = {}) {
|
||||
const log = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
meta
|
||||
};
|
||||
|
||||
stats.logs.unshift(log);
|
||||
|
||||
// Garder max 1000 logs en mémoire
|
||||
if (stats.logs.length > 1000) {
|
||||
stats.logs = stats.logs.slice(0, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre une connexion utilisateur
|
||||
*/
|
||||
export function registerUser(identity, username, groupId, roomName) {
|
||||
connectedUsers.set(identity, {
|
||||
username,
|
||||
groupId,
|
||||
roomName,
|
||||
connectedAt: new Date().toISOString(),
|
||||
lastActivity: new Date().toISOString()
|
||||
});
|
||||
|
||||
stats.totalConnections++;
|
||||
stats.activeConnections = connectedUsers.size;
|
||||
|
||||
addLog('info', `User connected: ${username}`, { groupId, identity });
|
||||
}
|
||||
|
||||
/**
|
||||
* Déconnecte un utilisateur
|
||||
*/
|
||||
export function unregisterUser(identity) {
|
||||
const user = connectedUsers.get(identity);
|
||||
if (user) {
|
||||
connectedUsers.delete(identity);
|
||||
stats.activeConnections = connectedUsers.size;
|
||||
addLog('info', `User disconnected: ${user.username}`, { groupId: user.groupId, identity });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour l'activité d'un utilisateur
|
||||
*/
|
||||
export function updateUserActivity(identity) {
|
||||
const user = connectedUsers.get(identity);
|
||||
if (user) {
|
||||
user.lastActivity = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute des statistiques audio
|
||||
*/
|
||||
export function addAudioStats(data) {
|
||||
const stat = {
|
||||
timestamp: new Date().toISOString(),
|
||||
...data
|
||||
};
|
||||
|
||||
stats.audioStats.unshift(stat);
|
||||
|
||||
// Garder max 100 stats
|
||||
if (stats.audioStats.length > 100) {
|
||||
stats.audioStats = stats.audioStats.slice(0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Routes Admin ==========
|
||||
|
||||
/**
|
||||
* GET /admin/groups
|
||||
* Liste tous les groupes avec détails
|
||||
*/
|
||||
router.get('/groups', (req, res) => {
|
||||
try {
|
||||
const config = configManager.get();
|
||||
res.json({
|
||||
groups: config.groups
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur GET /admin/groups:', error);
|
||||
res.status(500).json({ error: 'Failed to load groups' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /admin/groups
|
||||
* Crée un nouveau groupe
|
||||
* Body: { name, audioBitrate? }
|
||||
* L'ID est généré automatiquement à partir du nom
|
||||
*/
|
||||
router.post('/groups', (req, res) => {
|
||||
try {
|
||||
const { name, audioBitrate } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required field: name'
|
||||
});
|
||||
}
|
||||
|
||||
const config = configManager.get();
|
||||
|
||||
// Générer l'ID à partir du nom
|
||||
const id = slugify(name);
|
||||
|
||||
// Vérifier que l'ID n'existe pas déjà
|
||||
if (config.groups.find(g => g.id === id)) {
|
||||
return res.status(409).json({
|
||||
error: `Group "${name}" already exists (ID: ${id})`
|
||||
});
|
||||
}
|
||||
|
||||
// Créer le nouveau groupe
|
||||
const newGroup = {
|
||||
name,
|
||||
...(audioBitrate && { audioBitrate })
|
||||
};
|
||||
|
||||
config.groups.push(newGroup);
|
||||
configManager.save(config);
|
||||
|
||||
addLog('info', `Group created: ${name}`, { id });
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Group created',
|
||||
group: { ...newGroup, id }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur POST /admin/groups:', error);
|
||||
res.status(500).json({ error: 'Failed to create group' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /admin/groups/:id
|
||||
* Modifie un groupe existant
|
||||
* Body: { name?, audioBitrate? }
|
||||
* Note: l'ID est un slug généré, on cherche le groupe par nom dans le YAML
|
||||
*/
|
||||
router.put('/groups/:id', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, audioBitrate } = req.body;
|
||||
|
||||
const config = configManager.get();
|
||||
|
||||
// Chercher le groupe par son nom (qui correspond à l'ID slugifié)
|
||||
const groupIndex = config.groups.findIndex(g => slugify(g.name) === id);
|
||||
|
||||
if (groupIndex === -1) {
|
||||
return res.status(404).json({
|
||||
error: `Group ${id} not found`
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre à jour les champs fournis
|
||||
if (name !== undefined) config.groups[groupIndex].name = name;
|
||||
if (audioBitrate !== undefined) config.groups[groupIndex].audioBitrate = audioBitrate;
|
||||
|
||||
configManager.save(config);
|
||||
|
||||
addLog('info', `Group updated: ${config.groups[groupIndex].name}`, { id });
|
||||
|
||||
// Récupérer la config à jour avec les IDs générés
|
||||
const updatedConfig = configManager.get();
|
||||
const updatedGroupIndex = updatedConfig.groups.findIndex(g => slugify(g.name) === id || slugify(g.name) === slugify(name));
|
||||
const updatedGroup = updatedGroupIndex !== -1 ? updatedConfig.groups[updatedGroupIndex] : null;
|
||||
|
||||
res.json({
|
||||
message: 'Group updated',
|
||||
group: updatedGroup
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur PUT /admin/groups:', error);
|
||||
res.status(500).json({ error: 'Failed to update group' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /admin/groups/:id
|
||||
* Supprime un groupe
|
||||
* Note: l'ID est un slug généré, on cherche le groupe par nom dans le YAML
|
||||
*/
|
||||
router.delete('/groups/:id', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const config = configManager.get();
|
||||
const groupIndex = config.groups.findIndex(g => slugify(g.name) === id);
|
||||
|
||||
if (groupIndex === -1) {
|
||||
return res.status(404).json({
|
||||
error: `Group ${id} not found`
|
||||
});
|
||||
}
|
||||
|
||||
const groupName = config.groups[groupIndex].name;
|
||||
config.groups.splice(groupIndex, 1);
|
||||
configManager.save(config);
|
||||
|
||||
addLog('info', `Group deleted: ${groupName}`, { id });
|
||||
|
||||
res.json({
|
||||
message: 'Group deleted',
|
||||
id
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur DELETE /admin/groups:', error);
|
||||
res.status(500).json({ error: 'Failed to delete group' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/users
|
||||
* Liste tous les utilisateurs connectés
|
||||
*/
|
||||
router.get('/users', (req, res) => {
|
||||
try {
|
||||
const users = Array.from(connectedUsers.entries()).map(([identity, data]) => ({
|
||||
identity,
|
||||
...data
|
||||
}));
|
||||
|
||||
res.json({
|
||||
users,
|
||||
count: users.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur GET /admin/users:', error);
|
||||
res.status(500).json({ error: 'Failed to load users' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /admin/users/:identity
|
||||
* Déconnecte un utilisateur (force disconnect)
|
||||
*/
|
||||
router.delete('/users/:identity', (req, res) => {
|
||||
try {
|
||||
const { identity } = req.params;
|
||||
|
||||
const user = connectedUsers.get(identity);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
error: `User ${identity} not found`
|
||||
});
|
||||
}
|
||||
|
||||
unregisterUser(identity);
|
||||
addLog('warn', `User force disconnected: ${user.username}`, { identity });
|
||||
|
||||
res.json({
|
||||
message: 'User disconnected',
|
||||
identity
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur DELETE /admin/users:', error);
|
||||
res.status(500).json({ error: 'Failed to disconnect user' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/stats
|
||||
* Statistiques temps réel
|
||||
*/
|
||||
router.get('/stats', (req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
totalConnections: stats.totalConnections,
|
||||
activeConnections: stats.activeConnections,
|
||||
audioStats: stats.audioStats.slice(0, 20), // 20 dernières stats
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur GET /admin/stats:', error);
|
||||
res.status(500).json({ error: 'Failed to load stats' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/logs
|
||||
* Logs serveur
|
||||
* Query params: ?limit=100&level=info
|
||||
*/
|
||||
router.get('/logs', (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const level = req.query.level;
|
||||
|
||||
let logs = stats.logs;
|
||||
|
||||
// Filtrer par niveau si spécifié
|
||||
if (level) {
|
||||
logs = logs.filter(log => log.level === level);
|
||||
}
|
||||
|
||||
// Limiter le nombre
|
||||
logs = logs.slice(0, limit);
|
||||
|
||||
res.json({
|
||||
logs,
|
||||
total: stats.logs.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur GET /admin/logs:', error);
|
||||
res.status(500).json({ error: 'Failed to load logs' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/config
|
||||
* Configuration serveur complète
|
||||
*/
|
||||
router.get('/config', (req, res) => {
|
||||
try {
|
||||
const config = configManager.get();
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
console.error('Erreur GET /admin/config:', error);
|
||||
res.status(500).json({ error: 'Failed to load config' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /admin/config/audio
|
||||
* Met à jour la configuration audio globale
|
||||
* Body: { sampleRate?, defaultBitrate?, jitterBufferMs? }
|
||||
*/
|
||||
router.put('/config/audio', (req, res) => {
|
||||
try {
|
||||
const { sampleRate, defaultBitrate, jitterBufferMs } = req.body;
|
||||
|
||||
const config = configManager.get();
|
||||
|
||||
if (sampleRate !== undefined) config.audio.sampleRate = sampleRate;
|
||||
if (defaultBitrate !== undefined) config.audio.defaultBitrate = defaultBitrate;
|
||||
if (jitterBufferMs !== undefined) config.audio.jitterBufferMs = jitterBufferMs;
|
||||
|
||||
configManager.save(config);
|
||||
|
||||
addLog('info', 'Audio config updated', { sampleRate, defaultBitrate, jitterBufferMs });
|
||||
|
||||
res.json({
|
||||
message: 'Audio config updated',
|
||||
audio: config.audio
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur PUT /admin/config/audio:', error);
|
||||
res.status(500).json({ error: 'Failed to update audio config' });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Routes Audio Devices (Phase 2.5) ==========
|
||||
|
||||
/**
|
||||
* GET /admin/audio/devices
|
||||
* Énumération de toutes les cartes son disponibles
|
||||
*/
|
||||
router.get('/audio/devices', (req, res) => {
|
||||
try {
|
||||
const devices = CoreAudioBackend.getDevices();
|
||||
const defaultInput = CoreAudioBackend.getDefaultInputDevice();
|
||||
const defaultOutput = CoreAudioBackend.getDefaultOutputDevice();
|
||||
|
||||
res.json({
|
||||
devices,
|
||||
defaultInput,
|
||||
defaultOutput
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur GET /admin/audio/devices:', error);
|
||||
res.status(500).json({ error: 'Failed to enumerate audio devices' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/audio/device
|
||||
* Récupère la configuration actuelle de la carte son sélectionnée
|
||||
*/
|
||||
router.get('/audio/device', (req, res) => {
|
||||
try {
|
||||
const config = configManager.get();
|
||||
const audioDevice = config.audio?.device || {};
|
||||
|
||||
// Enrichir avec les infos réelles de la carte si configurée
|
||||
const devices = CoreAudioBackend.getDevices();
|
||||
let deviceInfo = { ...audioDevice };
|
||||
|
||||
if (audioDevice.inputDeviceId) {
|
||||
const inputDev = devices.find(d => d.id === audioDevice.inputDeviceId);
|
||||
if (inputDev) {
|
||||
deviceInfo.inputChannels = inputDev.maxInputChannels;
|
||||
deviceInfo.inputDeviceName = inputDev.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (audioDevice.outputDeviceId) {
|
||||
const outputDev = devices.find(d => d.id === audioDevice.outputDeviceId);
|
||||
if (outputDev) {
|
||||
deviceInfo.outputChannels = outputDev.maxOutputChannels;
|
||||
deviceInfo.outputDeviceName = outputDev.name;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
device: deviceInfo
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur GET /admin/audio/device:', error);
|
||||
res.status(500).json({ error: 'Failed to load audio device config' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/audio/channels/names
|
||||
* Récupère les noms personnalisés des canaux physiques
|
||||
*/
|
||||
router.get('/audio/channels/names', (req, res) => {
|
||||
try {
|
||||
const config = configManager.get();
|
||||
const channelNames = config.audio?.channelNames || { inputs: {}, outputs: {} };
|
||||
|
||||
res.json({
|
||||
channelNames
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur GET /admin/audio/channels/names:', error);
|
||||
res.status(500).json({ error: 'Failed to load channel names' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /admin/audio/channels/names
|
||||
* Sauvegarde les noms personnalisés des canaux physiques
|
||||
* Body: { inputs: { "0": "Micro Principal", ... }, outputs: { "0": "Retour Scène", ... } }
|
||||
*/
|
||||
router.put('/audio/channels/names', (req, res) => {
|
||||
try {
|
||||
const { inputs, outputs } = req.body;
|
||||
|
||||
if (!inputs && !outputs) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: inputs or outputs'
|
||||
});
|
||||
}
|
||||
|
||||
const config = configManager.get();
|
||||
|
||||
if (!config.audio.channelNames) {
|
||||
config.audio.channelNames = { inputs: {}, outputs: {} };
|
||||
}
|
||||
|
||||
if (inputs) {
|
||||
config.audio.channelNames.inputs = inputs;
|
||||
}
|
||||
|
||||
if (outputs) {
|
||||
config.audio.channelNames.outputs = outputs;
|
||||
}
|
||||
|
||||
configManager.save(config);
|
||||
|
||||
addLog('info', 'Channel names updated', { inputCount: Object.keys(inputs || {}).length, outputCount: Object.keys(outputs || {}).length });
|
||||
|
||||
res.json({
|
||||
message: 'Channel names updated',
|
||||
channelNames: config.audio.channelNames
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur PUT /admin/audio/channels/names:', error);
|
||||
res.status(500).json({ error: 'Failed to update channel names' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/audio/routing
|
||||
* Récupère la configuration de routing actuelle
|
||||
* Format: { inputToGroup: { "0": ["production"], "1": ["technique"] }, groupToOutput: { "production": ["0", "1"] } }
|
||||
*/
|
||||
router.get('/audio/routing', (req, res) => {
|
||||
try {
|
||||
const config = configManager.get();
|
||||
const routing = config.audio?.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} };
|
||||
|
||||
res.json({
|
||||
routing
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur GET /admin/audio/routing:', error);
|
||||
res.status(500).json({ error: 'Failed to load routing' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /audio/routing
|
||||
* Sauvegarde la configuration de routing
|
||||
* Body: { inputToGroup: {...}, groupToOutput: {...}, gains: {...} }
|
||||
*/
|
||||
router.post('/audio/routing', (req, res) => {
|
||||
try {
|
||||
const { inputToGroup, groupToOutput, gains } = req.body;
|
||||
|
||||
const config = configManager.get();
|
||||
|
||||
if (!config.audio.routing) {
|
||||
config.audio.routing = { inputToGroup: {}, groupToOutput: {}, gains: {} };
|
||||
}
|
||||
|
||||
if (inputToGroup !== undefined) {
|
||||
config.audio.routing.inputToGroup = inputToGroup;
|
||||
}
|
||||
|
||||
if (groupToOutput !== undefined) {
|
||||
config.audio.routing.groupToOutput = groupToOutput;
|
||||
}
|
||||
|
||||
if (gains !== undefined) {
|
||||
config.audio.routing.gains = gains;
|
||||
}
|
||||
|
||||
configManager.save(config);
|
||||
|
||||
addLog('info', 'Audio routing updated');
|
||||
|
||||
res.json({
|
||||
message: 'Audio routing updated',
|
||||
routing: config.audio.routing
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur POST /admin/audio/routing:', error);
|
||||
res.status(500).json({ error: 'Failed to update routing' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /admin/audio/device
|
||||
* Sélectionne et configure une carte son
|
||||
* Body: { inputDeviceId?, outputDeviceId?, sampleRate?, bufferSize? }
|
||||
*/
|
||||
router.post('/audio/device', (req, res) => {
|
||||
try {
|
||||
const { inputDeviceId, outputDeviceId, sampleRate, bufferSize } = req.body;
|
||||
|
||||
// Utiliser le ConfigManager pour mettre à jour et émettre l'événement
|
||||
const deviceConfig = configManager.updateAudioDevice({
|
||||
inputDeviceId,
|
||||
outputDeviceId,
|
||||
sampleRate,
|
||||
bufferSize
|
||||
});
|
||||
|
||||
addLog('info', 'Audio device configured', { inputDeviceId, outputDeviceId, sampleRate, bufferSize });
|
||||
|
||||
res.json({
|
||||
message: 'Audio device configured (bridge audio sera rechargé)',
|
||||
device: deviceConfig
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur POST /admin/audio/device:', error);
|
||||
res.status(500).json({ error: 'Failed to configure audio device' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/devices/list
|
||||
* Liste tous les devices audio disponibles (auto-détection)
|
||||
* Supporte macOS (CoreAudio), Linux (JACK/PipeWire), Windows (WASAPI)
|
||||
*/
|
||||
router.get('/devices/list', async (req, res) => {
|
||||
try {
|
||||
const devices = {
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
platform: process.platform
|
||||
};
|
||||
|
||||
// Détection selon la plateforme
|
||||
if (process.platform === 'darwin') {
|
||||
// macOS : utiliser CoreAudioBackend.getDevices()
|
||||
try {
|
||||
const coreAudioDevices = CoreAudioBackend.getDevices();
|
||||
|
||||
// Séparer inputs et outputs
|
||||
coreAudioDevices.forEach(device => {
|
||||
if (device.maxInputChannels > 0) {
|
||||
devices.inputs.push({
|
||||
id: device.name, // Utiliser le nom comme ID (compatible avec inputDeviceName)
|
||||
name: device.name,
|
||||
channels: device.maxInputChannels,
|
||||
sampleRate: device.defaultSampleRate,
|
||||
isDefault: device.isDefault?.input || false
|
||||
});
|
||||
}
|
||||
|
||||
if (device.maxOutputChannels > 0) {
|
||||
devices.outputs.push({
|
||||
id: device.name, // Utiliser le nom comme ID (compatible avec outputDeviceName)
|
||||
name: device.name,
|
||||
channels: device.maxOutputChannels,
|
||||
sampleRate: device.defaultSampleRate,
|
||||
isDefault: device.isDefault?.output || false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback si aucun device trouvé
|
||||
if (devices.inputs.length === 0) {
|
||||
devices.inputs.push({ id: 'builtin-mic', name: 'Built-in Microphone', isDefault: true });
|
||||
}
|
||||
if (devices.outputs.length === 0) {
|
||||
devices.outputs.push({ id: 'builtin-output', name: 'Built-in Output', isDefault: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Détection CoreAudio échouée:', error.message);
|
||||
|
||||
// Fallback : devices par défaut macOS
|
||||
devices.inputs.push({ id: 'builtin-mic', name: 'Built-in Microphone', isDefault: true });
|
||||
devices.outputs.push({ id: 'builtin-output', name: 'Built-in Output', isDefault: true });
|
||||
}
|
||||
|
||||
} else if (process.platform === 'linux') {
|
||||
// Linux : JACK ou PipeWire
|
||||
const { exec } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execPromise = promisify(exec);
|
||||
|
||||
try {
|
||||
// Essayer JACK d'abord
|
||||
const { stdout: jackPorts } = await execPromise('jack_lsp 2>/dev/null || echo ""');
|
||||
|
||||
if (jackPorts.trim()) {
|
||||
// Parser les ports JACK
|
||||
const ports = jackPorts.split('\n').filter(Boolean);
|
||||
|
||||
ports.forEach(port => {
|
||||
if (port.includes('capture')) {
|
||||
devices.inputs.push({ id: port, name: port });
|
||||
} else if (port.includes('playback')) {
|
||||
devices.outputs.push({ id: port, name: port });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback : PipeWire/PulseAudio via pactl
|
||||
const { stdout: paDevices } = await execPromise('pactl list short sources 2>/dev/null || echo ""');
|
||||
const { stdout: paSinks } = await execPromise('pactl list short sinks 2>/dev/null || echo ""');
|
||||
|
||||
// Helper pour obtenir une description lisible
|
||||
const getDeviceDescription = (deviceId) => {
|
||||
// Extraire une description plus lisible du nom technique
|
||||
if (deviceId.includes('alsa_input')) return deviceId.replace('alsa_input.', 'Input: ');
|
||||
if (deviceId.includes('alsa_output')) return deviceId.replace('alsa_output.', 'Output: ');
|
||||
return deviceId;
|
||||
};
|
||||
|
||||
if (paDevices.trim()) {
|
||||
paDevices.split('\n').filter(Boolean).forEach((line) => {
|
||||
const parts = line.split('\t');
|
||||
const deviceId = parts[1]; // Nom du device (ex: alsa_input.pci-...)
|
||||
if (deviceId && !deviceId.includes('.monitor')) { // Ignorer les monitors
|
||||
devices.inputs.push({
|
||||
id: deviceId,
|
||||
name: getDeviceDescription(deviceId)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (paSinks.trim()) {
|
||||
paSinks.split('\n').filter(Boolean).forEach((line) => {
|
||||
const parts = line.split('\t');
|
||||
const deviceId = parts[1]; // Nom du device (ex: alsa_output.pci-...)
|
||||
if (deviceId) {
|
||||
devices.outputs.push({
|
||||
id: deviceId,
|
||||
name: getDeviceDescription(deviceId)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (linuxError) {
|
||||
console.warn('⚠️ Détection devices Linux échouée:', linuxError.message);
|
||||
devices.inputs.push({ id: 0, name: 'Default Input', isDefault: true });
|
||||
devices.outputs.push({ id: 0, name: 'Default Output', isDefault: true });
|
||||
}
|
||||
|
||||
} else if (process.platform === 'win32') {
|
||||
// Windows : WASAPI (Phase 3)
|
||||
// TODO: implémenter détection WASAPI
|
||||
devices.inputs.push({ id: 0, name: 'Default Input (Windows)', isDefault: true });
|
||||
devices.outputs.push({ id: 0, name: 'Default Output (Windows)', isDefault: true });
|
||||
}
|
||||
|
||||
addLog('info', 'Audio devices listed', {
|
||||
inputsCount: devices.inputs.length,
|
||||
outputsCount: devices.outputs.length
|
||||
});
|
||||
|
||||
res.json(devices);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur GET /admin/devices/list:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to list audio devices',
|
||||
message: error.message,
|
||||
platform: process.platform
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
+516
-77
@@ -13,9 +13,12 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { platform } from 'os';
|
||||
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 JitterBuffer, { JitterBufferPresets } from './JitterBuffer.js';
|
||||
import LiveKitClient from './LiveKitClient.js';
|
||||
import GroupAudioRouter from './GroupAudioRouter.js';
|
||||
|
||||
export class AudioBridge extends EventEmitter {
|
||||
constructor(options = {}) {
|
||||
@@ -51,12 +54,27 @@ export class AudioBridge extends EventEmitter {
|
||||
this.opusEncoder = null;
|
||||
this.opusDecoder = null;
|
||||
this.jitterBuffer = null;
|
||||
this.liveKitClient = null;
|
||||
this.liveKitClients = new Map(); // Map<groupName, LiveKitClient> - un client par groupe
|
||||
this.groupAudioRouter = null;
|
||||
|
||||
// État
|
||||
this.isRunning = false;
|
||||
this.backendType = null;
|
||||
|
||||
// Buffers pour routing multi-canaux
|
||||
this.inputChannelBuffers = new Map(); // Map<channelId, Float32Array>
|
||||
this.groupBuffersFromLiveKit = new Map(); // Map<groupName, Float32Array>
|
||||
|
||||
// Frame accumulators pour LiveKit (240 samples → 960 samples)
|
||||
this.liveKitFrameAccumulators = new Map(); // Map<groupName, { buffer: Float32Array, offset: number }>
|
||||
|
||||
// Pool de buffers pré-alloués pour éviter allocations répétées
|
||||
this.bufferPool = {
|
||||
float32: [], // Pool de Float32Array réutilisables
|
||||
pcm: [] // Pool de Buffer PCM réutilisables
|
||||
};
|
||||
this.maxPoolSize = 50; // Limite du pool (adapté pour 30+ clients)
|
||||
|
||||
// Statistiques
|
||||
this.stats = {
|
||||
startTime: null,
|
||||
@@ -96,10 +114,13 @@ export class AudioBridge extends EventEmitter {
|
||||
// 3. Initialisation du jitter buffer
|
||||
this._initJitterBuffer();
|
||||
|
||||
// 4. Connexion à LiveKit
|
||||
// 4. Initialisation du GroupAudioRouter
|
||||
this._initGroupAudioRouter();
|
||||
|
||||
// 5. Connexion à LiveKit
|
||||
await this._initLiveKit();
|
||||
|
||||
// 5. Démarrage du routing audio
|
||||
// 6. Démarrage du routing audio
|
||||
await this._startAudioRouting();
|
||||
|
||||
this.isRunning = true;
|
||||
@@ -123,27 +144,52 @@ export class AudioBridge extends EventEmitter {
|
||||
*/
|
||||
async _initAudioBackend() {
|
||||
const os = platform();
|
||||
let BackendClass = null;
|
||||
let devices = [];
|
||||
|
||||
// macOS : CoreAudio prioritaire
|
||||
if (os === 'darwin') {
|
||||
if (CoreAudioBackend.isAvailable()) {
|
||||
this.backendType = 'CoreAudio';
|
||||
this.audioBackend = new CoreAudioBackend({
|
||||
sampleRate: this.options.sampleRate,
|
||||
channels: this.options.channels,
|
||||
framesPerBuffer: this.options.frameSize,
|
||||
inputDeviceId: this.options.inputDeviceId,
|
||||
outputDeviceId: this.options.outputDeviceId
|
||||
});
|
||||
|
||||
BackendClass = CoreAudioBackend;
|
||||
console.log('✓ Backend audio : CoreAudio (macOS natif)');
|
||||
} else {
|
||||
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') {
|
||||
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)
|
||||
else if (os === 'win32') {
|
||||
@@ -153,8 +199,48 @@ export class AudioBridge extends EventEmitter {
|
||||
throw new Error(`Plateforme non supportée : ${os}`);
|
||||
}
|
||||
|
||||
// Résoudre les device IDs vers les noms pour CoreAudio/sox
|
||||
let inputDeviceName = null;
|
||||
let outputDeviceName = null;
|
||||
|
||||
if (this.options.inputDeviceId) {
|
||||
const inputDevice = BackendClass.getDevices().find(d => d.id === this.options.inputDeviceId);
|
||||
inputDeviceName = inputDevice ? inputDevice.name : this.options.inputDeviceId;
|
||||
console.log(`📥 Input device: "${inputDeviceName}" (ID: ${this.options.inputDeviceId})`);
|
||||
}
|
||||
|
||||
if (this.options.outputDeviceId) {
|
||||
const outputDevice = BackendClass.getDevices().find(d => d.id === this.options.outputDeviceId);
|
||||
outputDeviceName = outputDevice ? outputDevice.name : this.options.outputDeviceId;
|
||||
console.log(`📤 Output device: "${outputDeviceName}" (ID: ${this.options.outputDeviceId})`);
|
||||
}
|
||||
|
||||
// Initialisation du backend sélectionné
|
||||
const backendOptions = {
|
||||
sampleRate: this.options.sampleRate,
|
||||
channels: this.options.channels,
|
||||
framesPerBuffer: this.options.frameSize,
|
||||
latency: this.options.latency || 20
|
||||
};
|
||||
|
||||
// PipeWire utilise targetDevice, CoreAudio utilise inputDeviceName/outputDeviceName
|
||||
if (this.backendType === 'PipeWire') {
|
||||
// Pour PipeWire, on utilise inputDeviceId directement comme targetDevice
|
||||
// (startCapture et startPlayback peuvent avoir des targets différents)
|
||||
backendOptions.inputTargetDevice = this.options.inputDeviceId;
|
||||
backendOptions.outputTargetDevice = this.options.outputDeviceId;
|
||||
} else {
|
||||
// CoreAudio et autres backends
|
||||
backendOptions.inputDeviceId = this.options.inputDeviceId;
|
||||
backendOptions.inputDeviceName = inputDeviceName;
|
||||
backendOptions.outputDeviceId = this.options.outputDeviceId;
|
||||
backendOptions.outputDeviceName = outputDeviceName;
|
||||
}
|
||||
|
||||
this.audioBackend = new BackendClass(backendOptions);
|
||||
|
||||
// Liste des devices disponibles
|
||||
const devices = CoreAudioBackend.getDevices();
|
||||
devices = BackendClass.getDevices();
|
||||
console.log(`📻 Devices audio détectés : ${devices.length}`);
|
||||
devices.forEach(d => {
|
||||
console.log(` - ${d.name} (in:${d.maxInputChannels}, out:${d.maxOutputChannels})`);
|
||||
@@ -215,102 +301,440 @@ export class AudioBridge extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise la connexion LiveKit
|
||||
* Initialise le GroupAudioRouter pour le routing multi-canaux
|
||||
* @private
|
||||
*/
|
||||
async _initLiveKit() {
|
||||
if (!this.options.liveKitToken) {
|
||||
throw new Error('Token LiveKit requis');
|
||||
_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);
|
||||
}
|
||||
|
||||
this.liveKitClient = new LiveKitClient({
|
||||
url: this.options.liveKitUrl,
|
||||
token: this.options.liveKitToken,
|
||||
roomName: this.options.roomName,
|
||||
participantName: 'AudioBridge',
|
||||
audioBitrate: this.opusEncoder.options.bitrate
|
||||
// Events du router
|
||||
this.groupAudioRouter.on('configured', (stats) => {
|
||||
console.log(`✓ GroupAudioRouter configuré : ${stats.routesActive} routes`);
|
||||
});
|
||||
|
||||
// Events LiveKit
|
||||
this.liveKitClient.on('connected', () => {
|
||||
console.log('✓ LiveKit connecté');
|
||||
});
|
||||
|
||||
this.liveKitClient.on('disconnected', ({ reason }) => {
|
||||
console.warn('⚠️ LiveKit déconnecté:', reason);
|
||||
this.stats.errors.network++;
|
||||
});
|
||||
|
||||
this.liveKitClient.on('reconnecting', () => {
|
||||
console.log('🔄 LiveKit reconnexion...');
|
||||
});
|
||||
|
||||
this.liveKitClient.on('audioTrackSubscribed', ({ track, participant }) => {
|
||||
console.log(`🎵 Nouveau track audio : ${participant.identity}`);
|
||||
this._handleRemoteAudioTrack(track);
|
||||
});
|
||||
|
||||
await this.liveKitClient.connect();
|
||||
console.log('✓ GroupAudioRouter initialisé');
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre le routing audio bidirectionnel
|
||||
* Initialise les connexions LiveKit (une par groupe)
|
||||
* @private
|
||||
*/
|
||||
async _initLiveKit() {
|
||||
if (!this.options.liveKitTokens || !Array.isArray(this.options.liveKitTokens)) {
|
||||
throw new Error('liveKitTokens requis (tableau d\'objets { groupName, groupId, token })');
|
||||
}
|
||||
|
||||
console.log(`🔌 Initialisation ${this.options.liveKitTokens.length} connexions LiveKit (une par groupe)...`);
|
||||
|
||||
// Créer un LiveKitClient pour chaque groupe
|
||||
for (const { groupName, groupId, token } of this.options.liveKitTokens) {
|
||||
const roomName = groupId; // La room porte le nom du groupId (slugifié)
|
||||
|
||||
const client = new LiveKitClient({
|
||||
url: this.options.liveKitUrl,
|
||||
token,
|
||||
roomName,
|
||||
participantName: `AudioBridge-${groupId}`,
|
||||
sampleRate: this.options.sampleRate,
|
||||
channels: this.options.channels,
|
||||
audioBitrate: this.opusEncoder.options.bitrate
|
||||
});
|
||||
|
||||
// Events LiveKit pour ce groupe
|
||||
client.on('connected', () => {
|
||||
console.log(`✓ LiveKit connecté pour groupe "${groupName}" (room: ${roomName})`);
|
||||
});
|
||||
|
||||
client.on('disconnected', (data) => {
|
||||
const reason = data?.reason || 'unknown';
|
||||
console.warn(`⚠️ LiveKit déconnecté pour groupe "${groupName}":`, reason);
|
||||
this.stats.errors.network++;
|
||||
});
|
||||
|
||||
client.on('reconnecting', () => {
|
||||
console.log(`🔄 LiveKit reconnexion pour groupe "${groupName}"...`);
|
||||
});
|
||||
|
||||
client.on('audioTrackSubscribed', ({ track, participant }) => {
|
||||
console.log(`🎵 Nouveau track audio dans groupe "${groupName}": ${participant.identity}`);
|
||||
});
|
||||
|
||||
// Réception audio depuis les clients LiveKit de ce groupe
|
||||
client.on('audioData', ({ participantName, pcmData, sampleRate, channels }) => {
|
||||
// Router vers le bon groupe
|
||||
this.emit('groupAudioIn', { groupName: groupId, pcmBuffer: pcmData });
|
||||
});
|
||||
|
||||
// Connexion
|
||||
await client.connect();
|
||||
|
||||
// Stocker le client par groupId
|
||||
this.liveKitClients.set(groupId, client);
|
||||
}
|
||||
|
||||
console.log(`✓ ${this.liveKitClients.size} connexions LiveKit établies`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre le routing audio bidirectionnel complet
|
||||
* @private
|
||||
*/
|
||||
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) => {
|
||||
try {
|
||||
// Encodage PCM → Opus
|
||||
const opusData = this.opusEncoder.encode(pcmData);
|
||||
// Convertir PCM Buffer → Float32Array (pour GroupAudioRouter)
|
||||
const float32Data = this._bufferToFloat32(pcmData);
|
||||
|
||||
if (opusData) {
|
||||
this.stats.framesCapture++;
|
||||
this.stats.bytesEncoded += opusData.length;
|
||||
// Séparer les canaux si audio multi-canaux (entrelacé)
|
||||
const numChannels = this.options.channels || 1;
|
||||
|
||||
// TODO: Envoyer à LiveKit via track custom ou DataChannel
|
||||
// Pour l'instant, LiveKit gère l'audio via MediaStream natif
|
||||
// Cette partie sera complétée en fonction de l'architecture finale
|
||||
if (numChannels === 1) {
|
||||
// Mono : un seul canal
|
||||
const channelId = this.options.inputDeviceChannel || 0;
|
||||
this.inputChannelBuffers.set(channelId, float32Data);
|
||||
} else {
|
||||
this.stats.errors.encode++;
|
||||
// Multi-canaux : dé-entrelacer les samples
|
||||
// Format entrelacé : [L0, R0, L1, R1, ...] → [L0, L1, ...] et [R0, R1, ...]
|
||||
const samplesPerChannel = float32Data.length / numChannels;
|
||||
|
||||
for (let ch = 0; ch < numChannels; ch++) {
|
||||
const channelBuffer = new Float32Array(samplesPerChannel);
|
||||
|
||||
for (let i = 0; i < samplesPerChannel; i++) {
|
||||
channelBuffer[i] = float32Data[i * numChannels + ch];
|
||||
}
|
||||
|
||||
// Mapper canal hardware → canal logique (peut être configuré)
|
||||
const logicalChannelId = this.options.channelMapping?.[ch] ?? ch;
|
||||
this.inputChannelBuffers.set(logicalChannelId, channelBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
// ÉTAPE 1 : Inputs physiques → Groupes (via GroupAudioRouter)
|
||||
const groupBuffers = this.groupAudioRouter.processInputsToGroups(
|
||||
this.inputChannelBuffers
|
||||
);
|
||||
|
||||
if (this.stats.framesCapture % 100 === 0) {
|
||||
// Détecter si l'audio est du silence (toutes les samples < 0.001)
|
||||
let totalEnergy = 0;
|
||||
this.inputChannelBuffers.forEach((buffer) => {
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
totalEnergy += Math.abs(buffer[i]);
|
||||
}
|
||||
});
|
||||
const avgEnergy = totalEnergy / (this.inputChannelBuffers.size * (this.options.frameSize || 960));
|
||||
console.log(`[AudioBridge] Frame ${this.stats.framesCapture}: ${this.inputChannelBuffers.size} inputs → ${groupBuffers.size} groupes | Énergie audio: ${avgEnergy.toFixed(6)}`);
|
||||
}
|
||||
|
||||
// ÉTAPE 2 : Pour chaque groupe, envoyer vers le LiveKitClient correspondant
|
||||
groupBuffers.forEach((groupBuffer, groupName) => {
|
||||
// Les groupes sont MONO (Float32Array de N samples)
|
||||
// Mais la config globale peut être STÉRÉO (channels=2)
|
||||
// → Adapter selon la configuration
|
||||
|
||||
let pcmBuffer;
|
||||
const configChannels = this.options.channels || 1;
|
||||
|
||||
if (configChannels === 1) {
|
||||
// Config MONO : envoyer directement
|
||||
pcmBuffer = this._float32ToBuffer(groupBuffer);
|
||||
} else if (configChannels === 2) {
|
||||
// Config STÉRÉO : dupliquer le canal mono
|
||||
const samplesPerChannel = groupBuffer.length;
|
||||
const stereoBuffer = new Float32Array(samplesPerChannel * 2);
|
||||
|
||||
// Entrelacer : [M0, M1, M2, ...] → [M0, M0, M1, M1, M2, M2, ...]
|
||||
for (let i = 0; i < samplesPerChannel; i++) {
|
||||
stereoBuffer[i * 2] = groupBuffer[i]; // Canal gauche
|
||||
stereoBuffer[i * 2 + 1] = groupBuffer[i]; // Canal droit (dupliqué)
|
||||
}
|
||||
|
||||
pcmBuffer = this._float32ToBuffer(stereoBuffer);
|
||||
} else {
|
||||
console.error(`❌ Nombre de canaux non supporté: ${configChannels}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer le client LiveKit pour ce groupe
|
||||
const client = this.liveKitClients.get(groupName);
|
||||
|
||||
// Envoi vers LiveKit via sendAudioData (prend du PCM 16-bit)
|
||||
// Note: LiveKit gère lui-même l'encodage Opus en interne
|
||||
if (client && client.isConnected) {
|
||||
client.sendAudioData(pcmBuffer);
|
||||
if (this.stats.framesCapture % 100 === 0) {
|
||||
const channelLabel = configChannels === 1 ? 'mono' : `${configChannels}ch`;
|
||||
console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes (${channelLabel})`);
|
||||
}
|
||||
} else {
|
||||
if (this.stats.framesCapture % 100 === 0) {
|
||||
console.log(`[AudioBridge] ⚠️ LiveKit non connecté pour groupe "${groupName}", audio non envoyé`);
|
||||
}
|
||||
}
|
||||
|
||||
// Émettre aussi pour monitoring/debug
|
||||
this.emit('groupAudioOut', { groupName, pcmBuffer });
|
||||
});
|
||||
|
||||
// ÉTAPE 3 : Loopback local - Groupes → Outputs physiques (sans passer par LiveKit)
|
||||
const outputBuffers = this.groupAudioRouter.processGroupsToOutputs(groupBuffers);
|
||||
|
||||
if (this.stats.framesCapture % 100 === 0) {
|
||||
console.log(`[AudioBridge] Loopback local: ${groupBuffers.size} groupes → ${outputBuffers.size} outputs`);
|
||||
}
|
||||
|
||||
// ÉTAPE 4 : Envoyer chaque output à la carte son
|
||||
const numOutputChannels = this.options.channels || 1;
|
||||
|
||||
if (numOutputChannels === 1) {
|
||||
// Mono : un seul output
|
||||
if (outputBuffers.size > 0) {
|
||||
const [firstChannelId, outputBuffer] = outputBuffers.entries().next().value;
|
||||
const pcmBuffer = this._float32ToBuffer(outputBuffer);
|
||||
this.audioBackend.queueAudio(pcmBuffer);
|
||||
|
||||
if (this.stats.framesCapture % 100 === 0) {
|
||||
console.log(`[AudioBridge] → Output mono (canal ${firstChannelId}): ${pcmBuffer.length} bytes`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multi-canaux : entrelacer les samples
|
||||
// Récupérer les buffers dans l'ordre des canaux hardware
|
||||
const channelBuffers = [];
|
||||
const samplesPerChannel = this.options.frameSize;
|
||||
|
||||
for (let ch = 0; ch < numOutputChannels; ch++) {
|
||||
const logicalChannelId = this.options.channelMapping?.[ch] ?? ch;
|
||||
const buffer = outputBuffers.get(logicalChannelId);
|
||||
|
||||
if (buffer && buffer.length === samplesPerChannel) {
|
||||
channelBuffers.push(buffer);
|
||||
} else {
|
||||
// Canal absent ou taille incorrecte : silence
|
||||
channelBuffers.push(new Float32Array(samplesPerChannel));
|
||||
}
|
||||
}
|
||||
|
||||
// Entrelacer : [L0, L1, ...] et [R0, R1, ...] → [L0, R0, L1, R1, ...]
|
||||
const interleavedBuffer = new Float32Array(samplesPerChannel * numOutputChannels);
|
||||
|
||||
for (let i = 0; i < samplesPerChannel; i++) {
|
||||
for (let ch = 0; ch < numOutputChannels; ch++) {
|
||||
interleavedBuffer[i * numOutputChannels + ch] = channelBuffers[ch][i];
|
||||
}
|
||||
}
|
||||
|
||||
const pcmBuffer = this._float32ToBuffer(interleavedBuffer);
|
||||
this.audioBackend.queueAudio(pcmBuffer);
|
||||
|
||||
if (this.stats.framesCapture % 100 === 0) {
|
||||
console.log(`[AudioBridge] → Output multi-canaux (${numOutputChannels}ch): ${pcmBuffer.length} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
this.stats.framesCapture++;
|
||||
this.stats.framesPlayback++;
|
||||
} catch (error) {
|
||||
console.error('Erreur routing capture:', error);
|
||||
this.stats.errors.capture++;
|
||||
}
|
||||
});
|
||||
|
||||
// Démarrage capture
|
||||
await this.audioBackend.startCapture();
|
||||
// ===== FLUX 2 : LECTURE (Clients → LiveKit → Groupes → Carte Son) =====
|
||||
|
||||
// ===== ROUTING LECTURE : LiveKit → Opus → CoreAudio =====
|
||||
// La lecture sera démarrée une fois qu'on reçoit des tracks distants
|
||||
// Écouter l'audio entrant de LiveKit (sera connecté par LiveKitServerBridge)
|
||||
this.on('groupAudioIn', ({ groupName, pcmBuffer }) => {
|
||||
try {
|
||||
// Convertir PCM Buffer → Float32Array
|
||||
const float32Data = this._bufferToFloat32(pcmBuffer);
|
||||
const samplesReceived = float32Data.length;
|
||||
|
||||
// Initialiser l'accumulateur pour ce groupe si nécessaire
|
||||
if (!this.liveKitFrameAccumulators.has(groupName)) {
|
||||
this.liveKitFrameAccumulators.set(groupName, {
|
||||
buffer: new Float32Array(960), // Frame size attendu par GroupRouter
|
||||
offset: 0
|
||||
});
|
||||
}
|
||||
|
||||
const accumulator = this.liveKitFrameAccumulators.get(groupName);
|
||||
|
||||
// Vérifier que le buffer ne débordera pas
|
||||
const availableSpace = 960 - accumulator.offset;
|
||||
const samplesToCopy = Math.min(samplesReceived, availableSpace);
|
||||
|
||||
// Copier les samples dans l'accumulateur
|
||||
if (samplesToCopy > 0) {
|
||||
accumulator.buffer.set(float32Data.subarray(0, samplesToCopy), accumulator.offset);
|
||||
accumulator.offset += samplesToCopy;
|
||||
}
|
||||
|
||||
// Si on a accumulé assez de samples (960), router vers les outputs
|
||||
if (accumulator.offset >= 960) {
|
||||
// Vérifier que le backend est toujours actif (évite crash pendant shutdown)
|
||||
if (!this.audioBackend) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stocker le buffer complet pour le routing
|
||||
this.groupBuffersFromLiveKit.set(groupName, accumulator.buffer);
|
||||
|
||||
// É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);
|
||||
this.audioBackend.queueAudio(pcmBuffer);
|
||||
});
|
||||
|
||||
// Réinitialiser l'accumulateur
|
||||
accumulator.offset = 0;
|
||||
accumulator.buffer.fill(0);
|
||||
|
||||
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();
|
||||
|
||||
console.log('✓ Routing audio bidirectionnel actif');
|
||||
console.log(' → Carte Son → GroupRouter → LiveKit → Clients');
|
||||
console.log(' ← Carte Son ← GroupRouter ← LiveKit ← Clients');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère l'arrivée d'un track audio distant
|
||||
* @param {RemoteAudioTrack} track - Track LiveKit
|
||||
* Acquiert un Float32Array depuis le pool ou en crée un nouveau
|
||||
* @param {number} size - Taille du buffer
|
||||
* @returns {Float32Array}
|
||||
* @private
|
||||
*/
|
||||
_handleRemoteAudioTrack(track) {
|
||||
// Récupération du MediaStream du track
|
||||
const mediaStream = new MediaStream([track.mediaStreamTrack]);
|
||||
_acquireFloat32Buffer(size) {
|
||||
const pooled = this.bufferPool.float32.find(b => b.length === size);
|
||||
if (pooled) {
|
||||
this.bufferPool.float32.splice(this.bufferPool.float32.indexOf(pooled), 1);
|
||||
return pooled;
|
||||
}
|
||||
return new Float32Array(size);
|
||||
}
|
||||
|
||||
// Note: Pour décoder Opus côté serveur, on aurait besoin d'accéder
|
||||
// aux données brutes via DataChannel ou API bas niveau
|
||||
// LiveKit gère nativement le décodage WebRTC → PCM dans le navigateur
|
||||
/**
|
||||
* Retourne un Float32Array au pool pour réutilisation
|
||||
* @param {Float32Array} buffer
|
||||
* @private
|
||||
*/
|
||||
_releaseFloat32Buffer(buffer) {
|
||||
if (this.bufferPool.float32.length < this.maxPoolSize) {
|
||||
this.bufferPool.float32.push(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
// Pour un vrai bridge serveur, il faudrait :
|
||||
// 1. Recevoir les paquets Opus via DataChannel ou API custom
|
||||
// 2. Décoder avec opusDecoder
|
||||
// 3. Envoyer au jitterBuffer
|
||||
// 4. Lire depuis jitterBuffer vers CoreAudio
|
||||
/**
|
||||
* Acquiert un Buffer PCM depuis le pool ou en crée un nouveau
|
||||
* @param {number} size - Taille du buffer
|
||||
* @returns {Buffer}
|
||||
* @private
|
||||
*/
|
||||
_acquirePcmBuffer(size) {
|
||||
const pooled = this.bufferPool.pcm.find(b => b.length === size);
|
||||
if (pooled) {
|
||||
this.bufferPool.pcm.splice(this.bufferPool.pcm.indexOf(pooled), 1);
|
||||
return pooled;
|
||||
}
|
||||
return Buffer.alloc(size);
|
||||
}
|
||||
|
||||
// TODO: Implémenter réception bas niveau Opus depuis LiveKit
|
||||
console.warn('Réception track distant : implémentation complète en cours');
|
||||
/**
|
||||
* Retourne un Buffer PCM au pool pour réutilisation
|
||||
* @param {Buffer} buffer
|
||||
* @private
|
||||
*/
|
||||
_releasePcmBuffer(buffer) {
|
||||
if (this.bufferPool.pcm.length < this.maxPoolSize) {
|
||||
this.bufferPool.pcm.push(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit Buffer/Int16Array PCM 16-bit → Float32Array [-1.0, 1.0]
|
||||
* @param {Buffer|Int16Array|Uint8Array} buffer - Buffer PCM 16-bit signed
|
||||
* @returns {Float32Array}
|
||||
* @private
|
||||
*/
|
||||
_bufferToFloat32(buffer) {
|
||||
let samples;
|
||||
let float32;
|
||||
|
||||
// Cas 1 : Int16Array (LiveKit Node SDK format)
|
||||
if (buffer instanceof Int16Array) {
|
||||
samples = buffer.length;
|
||||
float32 = this._acquireFloat32Buffer(samples);
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
float32[i] = buffer[i] / 32768.0;
|
||||
}
|
||||
return float32;
|
||||
}
|
||||
|
||||
// Cas 2 : Buffer/Uint8Array (format classique)
|
||||
if (!(buffer instanceof Buffer)) {
|
||||
buffer = Buffer.from(buffer);
|
||||
}
|
||||
|
||||
samples = buffer.length / 2; // 2 bytes per sample (16-bit)
|
||||
float32 = this._acquireFloat32Buffer(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 = this._acquirePcmBuffer(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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,9 +753,16 @@ export class AudioBridge extends EventEmitter {
|
||||
this.audioBackend = null;
|
||||
}
|
||||
|
||||
if (this.liveKitClient) {
|
||||
await this.liveKitClient.destroy();
|
||||
this.liveKitClient = null;
|
||||
// Déconnecter tous les clients LiveKit
|
||||
for (const [groupName, client] of this.liveKitClients.entries()) {
|
||||
console.log(`🔌 Déconnexion LiveKit groupe "${groupName}"...`);
|
||||
await client.destroy();
|
||||
}
|
||||
this.liveKitClients.clear();
|
||||
|
||||
if (this.groupAudioRouter) {
|
||||
this.groupAudioRouter.destroy();
|
||||
this.groupAudioRouter = null;
|
||||
}
|
||||
|
||||
if (this.jitterBuffer) {
|
||||
@@ -349,6 +780,14 @@ export class AudioBridge extends EventEmitter {
|
||||
this.opusDecoder = null;
|
||||
}
|
||||
|
||||
// Nettoyer les buffers
|
||||
this.inputChannelBuffers.clear();
|
||||
this.groupBuffersFromLiveKit.clear();
|
||||
|
||||
// Nettoyer le pool de buffers
|
||||
this.bufferPool.float32 = [];
|
||||
this.bufferPool.pcm = [];
|
||||
|
||||
this.isRunning = false;
|
||||
|
||||
console.log('✓ AudioBridge arrêté');
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* AudioBridgeManager.js
|
||||
* Gestionnaire du bridge audio avec support hot-reload
|
||||
* Phase 2.5
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import configManager from '../config/ConfigManager.js';
|
||||
|
||||
class AudioBridgeManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.bridge = null;
|
||||
this.isRunning = false;
|
||||
|
||||
// Écouter les événements de configuration
|
||||
configManager.on('audio-device-updated', this.handleDeviceUpdate.bind(this));
|
||||
configManager.on('config-updated', this.handleConfigUpdate.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre le bridge audio avec la configuration actuelle
|
||||
* @param {Object} options - Options de démarrage
|
||||
* @param {string} options.liveKitUrl - URL LiveKit résolue (déjà avec IP si AUTO)
|
||||
*/
|
||||
async start(options = {}) {
|
||||
if (this.isRunning) {
|
||||
console.warn('⚠️ AudioBridge déjà démarré');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = configManager.get();
|
||||
console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio);
|
||||
|
||||
// Générer un token JWT par groupe
|
||||
const liveKitTokens = [];
|
||||
|
||||
// Fonction pour slugifier le nom (identique à admin.js)
|
||||
const slugify = (text) => {
|
||||
return text
|
||||
.toString()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w-]+/g, '')
|
||||
.replace(/--+/g, '-');
|
||||
};
|
||||
|
||||
for (const group of config.groups || []) {
|
||||
const groupId = slugify(group.name);
|
||||
const groupName = group.name;
|
||||
|
||||
const token = new AccessToken(
|
||||
config.server?.livekit?.apiKey || 'devkey',
|
||||
config.server?.livekit?.apiSecret || 'secret',
|
||||
{
|
||||
identity: `AudioBridge-${groupId}`,
|
||||
name: `Audio Bridge - ${groupName}`,
|
||||
metadata: JSON.stringify({
|
||||
role: 'bridge',
|
||||
group: groupId,
|
||||
capabilities: ['audio-routing', 'monitoring']
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
// Permissions complètes pour ce groupe
|
||||
token.addGrant({
|
||||
room: groupId, // Chaque groupe a sa propre room
|
||||
roomJoin: true,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true
|
||||
});
|
||||
|
||||
const jwt = await token.toJwt();
|
||||
liveKitTokens.push({ groupName, groupId, token: jwt });
|
||||
|
||||
console.log(`✓ Token JWT généré pour groupe "${groupName}" (room: ${groupId})`);
|
||||
}
|
||||
|
||||
if (liveKitTokens.length === 0) {
|
||||
console.warn('⚠️ Aucun groupe configuré, AudioBridge ne pourra pas démarrer');
|
||||
this.isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Extraire les device IDs depuis le sous-objet device
|
||||
const inputDeviceId = audioConfig.device?.inputDeviceId || null;
|
||||
const outputDeviceId = audioConfig.device?.outputDeviceId || null;
|
||||
|
||||
// Utiliser l'URL résolue passée en option, sinon fallback config
|
||||
const liveKitUrl = options.liveKitUrl || config.server?.livekit?.url || 'ws://localhost:7880';
|
||||
|
||||
// Créer l'instance avec la config
|
||||
this.bridge = new AudioBridge({
|
||||
...audioConfig,
|
||||
// Options LiveKit (multi-rooms)
|
||||
liveKitUrl,
|
||||
liveKitTokens, // Tableau de { groupName, groupId, token }
|
||||
// Options de routing
|
||||
routing: config.audio?.routing || {},
|
||||
groups: config.groups || [],
|
||||
maxInputChannels: 32,
|
||||
maxOutputChannels: 32,
|
||||
// Device IDs extraits
|
||||
inputDeviceId,
|
||||
outputDeviceId
|
||||
});
|
||||
|
||||
// Démarrer le bridge
|
||||
await this.bridge.start();
|
||||
|
||||
this.isRunning = true;
|
||||
console.log('✓ AudioBridge démarré avec succès');
|
||||
|
||||
this.emit('started');
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur démarrage AudioBridge:', 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrête le bridge audio
|
||||
*/
|
||||
async stop() {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('⏹ Arrêt AudioBridge...');
|
||||
|
||||
if (this.bridge) {
|
||||
await this.bridge.stop();
|
||||
this.bridge = null;
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
console.log('✓ AudioBridge arrêté');
|
||||
|
||||
this.emit('stopped');
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur arrêt AudioBridge:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recharge le bridge avec la nouvelle configuration
|
||||
*/
|
||||
async reload() {
|
||||
try {
|
||||
console.log('🔄 Rechargement AudioBridge...');
|
||||
|
||||
await this.stop();
|
||||
await this.start();
|
||||
|
||||
console.log('✓ AudioBridge rechargé avec succès');
|
||||
this.emit('reloaded');
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur rechargement AudioBridge:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire événement mise à jour device audio
|
||||
*/
|
||||
async handleDeviceUpdate(deviceConfig) {
|
||||
console.log('🔧 Device audio mis à jour:', deviceConfig);
|
||||
console.log('→ Rechargement AudioBridge requis...');
|
||||
|
||||
// Auto-reload du bridge
|
||||
if (this.isRunning) {
|
||||
await this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire événement mise à jour configuration
|
||||
*/
|
||||
handleConfigUpdate(config) {
|
||||
console.log('🔧 Configuration mise à jour');
|
||||
// Peut déclencher un reload si nécessaire
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'état actuel du bridge
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
running: this.isRunning,
|
||||
config: configManager.get().audio
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
const audioBridgeManager = new AudioBridgeManager();
|
||||
|
||||
export default audioBridgeManager;
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* GroupAudioRouter.js
|
||||
* Gestion du routing audio multi-canaux entre entrées physiques, groupes LiveKit et sorties physiques
|
||||
*
|
||||
* Architecture :
|
||||
* - Mix de plusieurs canaux physiques vers un groupe (avec gains individuels)
|
||||
* - Distribution d'un groupe vers plusieurs canaux physiques (avec gains individuels)
|
||||
* - Support canaux partagés (mixage additif)
|
||||
* - Gestion gains par route (-120dB à +6dB)
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { getLogger } from '../utils/Logger.js';
|
||||
|
||||
const logger = getLogger('Routing');
|
||||
|
||||
/**
|
||||
* Représente une route audio avec gain
|
||||
*/
|
||||
class AudioRoute {
|
||||
constructor(source, destination, gain = 0.0) {
|
||||
this.source = source; // Numéro de canal ou nom de groupe
|
||||
this.destination = destination; // Nom de groupe ou numéro de canal
|
||||
this.gain = gain; // Gain en dB (-120 à +6)
|
||||
this.linearGain = this._dbToLinear(gain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le gain en dB
|
||||
*/
|
||||
setGain(gainDb) {
|
||||
this.gain = Math.max(-120, Math.min(6, gainDb));
|
||||
this.linearGain = this._dbToLinear(this.gain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit dB en gain linéaire
|
||||
*/
|
||||
_dbToLinear(db) {
|
||||
if (db <= -120) return 0.0;
|
||||
return Math.pow(10, db / 20);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Router audio principal
|
||||
*/
|
||||
export class GroupAudioRouter extends EventEmitter {
|
||||
constructor(config = {}) {
|
||||
super();
|
||||
|
||||
this.config = {
|
||||
sampleRate: config.sampleRate || 48000,
|
||||
frameSize: config.frameSize || 960, // 20ms à 48kHz
|
||||
maxInputChannels: config.maxInputChannels || 32,
|
||||
maxOutputChannels: config.maxOutputChannels || 32,
|
||||
groups: config.groups || []
|
||||
};
|
||||
|
||||
// Routes : input -> group
|
||||
this.inputToGroupRoutes = new Map(); // Map<string, AudioRoute[]>
|
||||
// Routes : group -> output
|
||||
this.groupToOutputRoutes = new Map(); // Map<string, AudioRoute[]>
|
||||
|
||||
// Buffers audio
|
||||
this.inputBuffers = new Map(); // Map<number, Float32Array>
|
||||
this.groupBuffers = new Map(); // Map<string, Float32Array>
|
||||
this.outputBuffers = new Map(); // Map<number, Float32Array>
|
||||
|
||||
// Statistiques
|
||||
this.stats = {
|
||||
framesProcessed: 0,
|
||||
clippingEvents: 0,
|
||||
routesActive: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure le routing depuis la config YAML
|
||||
*/
|
||||
configure(routingConfig) {
|
||||
logger.info('Configuration du routing audio...');
|
||||
logger.debug(' Groupes disponibles:', this.config.groups.map(g => `${g.name || g} (id: ${g.id || g})`).join(', '));
|
||||
logger.debug(' inputToGroup:', JSON.stringify(routingConfig.inputToGroup || {}));
|
||||
logger.debug(' groupToOutput:', JSON.stringify(routingConfig.groupToOutput || {}));
|
||||
|
||||
// Réinitialise les routes
|
||||
this.inputToGroupRoutes.clear();
|
||||
this.groupToOutputRoutes.clear();
|
||||
|
||||
// Configure input -> group
|
||||
if (routingConfig.inputToGroup) {
|
||||
Object.entries(routingConfig.inputToGroup).forEach(([channelId, groups]) => {
|
||||
const channel = parseInt(channelId);
|
||||
|
||||
groups.forEach(groupName => {
|
||||
this.addInputToGroupRoute(channel, groupName, this._getGain(routingConfig.gains, `in_${channel}_${groupName}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Configure group -> output
|
||||
if (routingConfig.groupToOutput) {
|
||||
Object.entries(routingConfig.groupToOutput).forEach(([groupName, channels]) => {
|
||||
channels.forEach(channelId => {
|
||||
const channel = parseInt(channelId);
|
||||
this.addGroupToOutputRoute(groupName, channel, this._getGain(routingConfig.gains, `${groupName}_out_${channel}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this._updateStatsActiveRoutes();
|
||||
logger.success(`Routing configuré : ${this.stats.routesActive} routes actives`);
|
||||
this.emit('configured', this.stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le gain depuis la config
|
||||
*/
|
||||
_getGain(gainsConfig, routeKey) {
|
||||
return gainsConfig && gainsConfig[routeKey] ? gainsConfig[routeKey] : 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une route input -> group
|
||||
*/
|
||||
addInputToGroupRoute(inputChannel, groupName, gainDb = 0.0) {
|
||||
const key = `in_${inputChannel}`;
|
||||
|
||||
if (!this.inputToGroupRoutes.has(key)) {
|
||||
this.inputToGroupRoutes.set(key, []);
|
||||
}
|
||||
|
||||
const route = new AudioRoute(inputChannel, groupName, gainDb);
|
||||
this.inputToGroupRoutes.get(key).push(route);
|
||||
|
||||
logger.info(`Input ${inputChannel} → Group "${groupName}" (${gainDb}dB)`);
|
||||
this._updateStatsActiveRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une route group -> output
|
||||
*/
|
||||
addGroupToOutputRoute(groupName, outputChannel, gainDb = 0.0) {
|
||||
const key = groupName;
|
||||
|
||||
if (!this.groupToOutputRoutes.has(key)) {
|
||||
this.groupToOutputRoutes.set(key, []);
|
||||
}
|
||||
|
||||
const route = new AudioRoute(groupName, outputChannel, gainDb);
|
||||
this.groupToOutputRoutes.get(key).push(route);
|
||||
|
||||
logger.info(`Group "${groupName}" → Output ${outputChannel} (${gainDb}dB)`);
|
||||
this._updateStatsActiveRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime toutes les routes d'une entrée
|
||||
*/
|
||||
removeInputRoutes(inputChannel) {
|
||||
this.inputToGroupRoutes.delete(`in_${inputChannel}`);
|
||||
this._updateStatsActiveRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime toutes les routes d'un groupe vers les sorties
|
||||
*/
|
||||
removeGroupOutputRoutes(groupName) {
|
||||
this.groupToOutputRoutes.delete(groupName);
|
||||
this._updateStatsActiveRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le gain d'une route spécifique
|
||||
*/
|
||||
setRouteGain(source, destination, gainDb) {
|
||||
// Cherche dans input -> group
|
||||
const inputKey = typeof source === 'number' ? `in_${source}` : null;
|
||||
if (inputKey && this.inputToGroupRoutes.has(inputKey)) {
|
||||
const routes = this.inputToGroupRoutes.get(inputKey);
|
||||
const route = routes.find(r => r.destination === destination);
|
||||
if (route) {
|
||||
route.setGain(gainDb);
|
||||
console.log(`Gain modifié : Input ${source} -> Group "${destination}" = ${gainDb}dB`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Cherche dans group -> output
|
||||
if (typeof source === 'string' && this.groupToOutputRoutes.has(source)) {
|
||||
const routes = this.groupToOutputRoutes.get(source);
|
||||
const route = routes.find(r => r.destination === destination);
|
||||
if (route) {
|
||||
route.setGain(gainDb);
|
||||
console.log(`Gain modifié : Group "${source}" -> Output ${destination} = ${gainDb}dB`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* ÉTAPE 1 : Traite les entrées audio physiques vers les buffers de groupe
|
||||
* Mixe plusieurs canaux d'entrée vers chaque groupe (avec gains individuels)
|
||||
*
|
||||
* @param {Map<number, Float32Array>} inputChannelsData - Données PCM par canal d'entrée
|
||||
*/
|
||||
processInputsToGroups(inputChannelsData) {
|
||||
// Réinitialise les buffers de groupe
|
||||
this.groupBuffers.clear();
|
||||
this.config.groups.forEach(group => {
|
||||
// Utiliser l'ID (slugifié) plutôt que le nom pour correspondre au routing
|
||||
const groupId = group.id || group.name || group;
|
||||
this.groupBuffers.set(groupId, new Float32Array(this.config.frameSize));
|
||||
});
|
||||
|
||||
// Compter le nombre de sources par groupe pour normalisation
|
||||
const groupSourceCount = new Map();
|
||||
inputChannelsData.forEach((_, channelId) => {
|
||||
const key = `in_${channelId}`;
|
||||
const routes = this.inputToGroupRoutes.get(key);
|
||||
if (routes) {
|
||||
routes.forEach(route => {
|
||||
groupSourceCount.set(
|
||||
route.destination,
|
||||
(groupSourceCount.get(route.destination) || 0) + 1
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Pour chaque canal d'entrée
|
||||
inputChannelsData.forEach((pcmData, channelId) => {
|
||||
const key = `in_${channelId}`;
|
||||
const routes = this.inputToGroupRoutes.get(key);
|
||||
|
||||
if (!routes || routes.length === 0) return;
|
||||
|
||||
// Stocke le buffer d'entrée
|
||||
this.inputBuffers.set(channelId, pcmData);
|
||||
|
||||
// Applique chaque route (mixage additif vers les groupes)
|
||||
routes.forEach(route => {
|
||||
const groupBuffer = this.groupBuffers.get(route.destination);
|
||||
if (!groupBuffer) {
|
||||
logger.warn(`Buffer groupe "${route.destination}" introuvable pour routing depuis Input ${channelId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mixage avec gain + atténuation par nombre de sources
|
||||
const sourceCount = groupSourceCount.get(route.destination) || 1;
|
||||
const mixGain = route.linearGain / sourceCount;
|
||||
|
||||
for (let i = 0; i < pcmData.length && i < groupBuffer.length; i++) {
|
||||
groupBuffer[i] += pcmData[i] * mixGain;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Normalisation anti-clipping (soft limiter simple)
|
||||
this.groupBuffers.forEach((buffer, groupName) => {
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
if (Math.abs(buffer[i]) > 1.0) {
|
||||
this.stats.clippingEvents++;
|
||||
if (this.stats.clippingEvents % 1000 === 1) {
|
||||
logger.warn(`Clipping détecté sur groupe "${groupName}" (${this.stats.clippingEvents} événements)`);
|
||||
}
|
||||
buffer[i] = Math.sign(buffer[i]) * 1.0; // Hard clipping
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.stats.framesProcessed++;
|
||||
return this.groupBuffers;
|
||||
}
|
||||
|
||||
/**
|
||||
* ÉTAPE 2 : Traite les buffers de groupe vers les sorties audio physiques
|
||||
* Distribue chaque groupe vers plusieurs canaux de sortie (avec gains individuels)
|
||||
* Support du mixage additif si plusieurs groupes vont vers la même sortie
|
||||
*
|
||||
* @param {Map<string, Float32Array>} groupBuffersData - Données PCM par groupe (depuis LiveKit)
|
||||
* @returns {Map<number, Float32Array>} Buffers de sortie par canal physique
|
||||
*/
|
||||
processGroupsToOutputs(groupBuffersData) {
|
||||
// Réinitialise les buffers de sortie
|
||||
this.outputBuffers.clear();
|
||||
|
||||
// Pour chaque groupe
|
||||
groupBuffersData.forEach((pcmData, groupName) => {
|
||||
const routes = this.groupToOutputRoutes.get(groupName);
|
||||
|
||||
if (!routes || routes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Applique chaque route vers les sorties
|
||||
routes.forEach(route => {
|
||||
const outputChannel = route.destination;
|
||||
|
||||
// Crée le buffer de sortie si nécessaire
|
||||
if (!this.outputBuffers.has(outputChannel)) {
|
||||
this.outputBuffers.set(outputChannel, new Float32Array(this.config.frameSize));
|
||||
}
|
||||
|
||||
const outputBuffer = this.outputBuffers.get(outputChannel);
|
||||
|
||||
// Mixage avec gain (additif si canal partagé)
|
||||
for (let i = 0; i < pcmData.length && i < outputBuffer.length; i++) {
|
||||
outputBuffer[i] += pcmData[i] * route.linearGain;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Normalisation anti-clipping sur les sorties
|
||||
this.outputBuffers.forEach((buffer, channelId) => {
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
if (Math.abs(buffer[i]) > 1.0) {
|
||||
this.stats.clippingEvents++;
|
||||
buffer[i] = Math.sign(buffer[i]) * 1.0; // Hard clipping
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.outputBuffers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le buffer d'un groupe spécifique
|
||||
*/
|
||||
getGroupBuffer(groupName) {
|
||||
return this.groupBuffers.get(groupName) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le buffer d'une sortie spécifique
|
||||
*/
|
||||
getOutputBuffer(channelId) {
|
||||
return this.outputBuffers.get(channelId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère toutes les routes configurées
|
||||
*/
|
||||
getRoutingConfig() {
|
||||
const inputToGroup = {};
|
||||
const groupToOutput = {};
|
||||
const gains = {};
|
||||
|
||||
// Input -> Group
|
||||
this.inputToGroupRoutes.forEach((routes, key) => {
|
||||
const inputChannel = key.replace('in_', '');
|
||||
inputToGroup[inputChannel] = routes.map(r => r.destination);
|
||||
|
||||
routes.forEach(route => {
|
||||
if (route.gain !== 0.0) {
|
||||
gains[`in_${inputChannel}_${route.destination}`] = route.gain;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Group -> Output
|
||||
this.groupToOutputRoutes.forEach((routes, groupName) => {
|
||||
groupToOutput[groupName] = routes.map(r => r.destination);
|
||||
|
||||
routes.forEach(route => {
|
||||
if (route.gain !== 0.0) {
|
||||
gains[`${groupName}_out_${route.destination}`] = route.gain;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { inputToGroup, groupToOutput, gains };
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les statistiques
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
framesProcessed: this.stats.framesProcessed,
|
||||
clippingEvents: this.stats.clippingEvents,
|
||||
routesActive: this.stats.routesActive,
|
||||
inputToGroupRoutes: this.inputToGroupRoutes.size,
|
||||
groupToOutputRoutes: this.groupToOutputRoutes.size,
|
||||
activeGroups: this.groupBuffers.size,
|
||||
activeOutputs: this.outputBuffers.size
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le compteur de routes actives
|
||||
*/
|
||||
_updateStatsActiveRoutes() {
|
||||
let count = 0;
|
||||
this.inputToGroupRoutes.forEach(routes => count += routes.length);
|
||||
this.groupToOutputRoutes.forEach(routes => count += routes.length);
|
||||
this.stats.routesActive = count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détruit le router et libère les ressources
|
||||
*/
|
||||
destroy() {
|
||||
this.inputToGroupRoutes.clear();
|
||||
this.groupToOutputRoutes.clear();
|
||||
this.inputBuffers.clear();
|
||||
this.groupBuffers.clear();
|
||||
this.outputBuffers.clear();
|
||||
this.removeAllListeners();
|
||||
logger.info('GroupAudioRouter détruit');
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupAudioRouter;
|
||||
+194
-106
@@ -1,23 +1,16 @@
|
||||
/**
|
||||
* 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"
|
||||
* - Publication de track audio (Opus depuis carte son)
|
||||
* - Publication de tracks audio (PCM depuis carte son)
|
||||
* - Souscription aux tracks des autres participants (clients PWA)
|
||||
* - Gestion audio bas niveau (AudioSource/AudioStream)
|
||||
* - Reconnexion automatique
|
||||
*/
|
||||
|
||||
import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
RemoteTrack,
|
||||
RemoteParticipant,
|
||||
LocalAudioTrack,
|
||||
TrackPublishOptions,
|
||||
AudioPresets
|
||||
} from 'livekit-client';
|
||||
import { Room, RoomEvent, AudioSource, AudioFrame, LocalAudioTrack, TrackSource, AudioStream, TrackKind } from '@livekit/rtc-node';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export class LiveKitClient extends EventEmitter {
|
||||
@@ -30,11 +23,13 @@ export class LiveKitClient extends EventEmitter {
|
||||
participantName: options.participantName || 'AudioBridge',
|
||||
token: options.token || null,
|
||||
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
|
||||
};
|
||||
|
||||
this.room = null;
|
||||
this.audioSource = null;
|
||||
this.localAudioTrack = null;
|
||||
this.isConnected = false;
|
||||
this.reconnecting = false;
|
||||
@@ -58,13 +53,8 @@ export class LiveKitClient extends EventEmitter {
|
||||
}
|
||||
|
||||
try {
|
||||
this.room = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
reconnectionPolicy: {
|
||||
nextRetryDelayInMs: (retryCount) => Math.min(1000 * Math.pow(2, retryCount), 10000)
|
||||
}
|
||||
});
|
||||
// Création room
|
||||
this.room = new Room();
|
||||
|
||||
// Configuration des event listeners
|
||||
this._setupEventListeners();
|
||||
@@ -79,6 +69,10 @@ export class LiveKitClient extends EventEmitter {
|
||||
roomName: this.options.roomName,
|
||||
participantName: this.options.participantName
|
||||
});
|
||||
|
||||
// Création de l'AudioSource pour pouvoir publier de l'audio
|
||||
await this._createAudioSource();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur connexion LiveKit:', error);
|
||||
this.emit('error', error);
|
||||
@@ -86,6 +80,41 @@ export class LiveKitClient extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une AudioSource pour la publication audio
|
||||
* @private
|
||||
*/
|
||||
async _createAudioSource() {
|
||||
try {
|
||||
// Conversion explicite en int32 pour l'API LiveKit
|
||||
const sampleRate = parseInt(this.options.sampleRate, 10);
|
||||
const channels = parseInt(this.options.channels, 10);
|
||||
|
||||
// Création de l'AudioSource
|
||||
this.audioSource = new AudioSource(sampleRate, channels);
|
||||
|
||||
// Création du LocalAudioTrack depuis l'AudioSource
|
||||
const localTrack = LocalAudioTrack.createAudioTrack('bridge-audio', this.audioSource);
|
||||
|
||||
// Publication du track
|
||||
const options = {
|
||||
source: TrackSource.SOURCE_MICROPHONE // Simule un microphone pour les clients
|
||||
};
|
||||
|
||||
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
|
||||
* @private
|
||||
@@ -93,33 +122,33 @@ export class LiveKitClient extends EventEmitter {
|
||||
_setupEventListeners() {
|
||||
if (!this.room) return;
|
||||
|
||||
// Connexion/déconnexion
|
||||
// Connexion
|
||||
this.room.on(RoomEvent.Connected, () => {
|
||||
console.log('✓ Room connectée');
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
// Déconnexion
|
||||
this.room.on(RoomEvent.Disconnected, (reason) => {
|
||||
console.log('⚠ Room déconnectée:', reason);
|
||||
this.isConnected = false;
|
||||
this.emit('disconnected', { reason });
|
||||
});
|
||||
|
||||
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');
|
||||
this.emit('disconnected', { reason: reason || 'unknown' });
|
||||
});
|
||||
|
||||
// Participants
|
||||
this.room.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||
this.room.on(RoomEvent.ParticipantConnected, async (participant) => {
|
||||
console.log(`➕ Participant connecté: ${participant.identity}`);
|
||||
|
||||
// Parcourir les tracks publiés par ce participant et s'y abonner manuellement
|
||||
for (const [trackSid, publication] of participant.trackPublications) {
|
||||
console.log(` 📝 Track disponible: ${publication.kind} (${trackSid}), muted: ${publication.muted}`);
|
||||
|
||||
if (publication.kind === TrackKind.KIND_AUDIO && publication.track) {
|
||||
console.log(` ⚡ Souscription manuelle au track audio ${trackSid}...`);
|
||||
await this._handleAudioTrack(publication.track, publication, participant);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('participantConnected', participant);
|
||||
});
|
||||
|
||||
@@ -129,97 +158,143 @@ export class LiveKitClient extends EventEmitter {
|
||||
this.emit('participantDisconnected', participant);
|
||||
});
|
||||
|
||||
// Tracks
|
||||
this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||
if (track.kind === 'audio') {
|
||||
console.log(`🎵 Track audio souscrit de ${participant.identity}`);
|
||||
this.remoteParticipants.set(participant.sid, {
|
||||
participant,
|
||||
track,
|
||||
publication
|
||||
// Tracks - Debug tous les événements
|
||||
this.room.on(RoomEvent.TrackPublished, async (publication, participant) => {
|
||||
console.log(`📢 Track publié par ${participant.identity}: ${publication.kind} (${publication.sid}), muted: ${publication.muted}`);
|
||||
|
||||
// Si c'est un track audio, s'y abonner immédiatement
|
||||
if (publication.kind === TrackKind.KIND_AUDIO && publication.track) {
|
||||
console.log(` ⚡ Track audio détecté, souscription...`);
|
||||
await this._handleAudioTrack(publication.track, publication, participant);
|
||||
} else if (publication.kind === TrackKind.KIND_AUDIO && !publication.track) {
|
||||
console.log(` ⚠️ Track audio publié mais track object non disponible encore`);
|
||||
}
|
||||
});
|
||||
this.emit('audioTrackSubscribed', { track, participant });
|
||||
|
||||
this.room.on(RoomEvent.TrackSubscribed, async (track, publication, participant) => {
|
||||
console.log(`🎵 Track souscrit de ${participant.identity}: ${track.kind} (${publication.sid})`);
|
||||
|
||||
if (track.kind === TrackKind.KIND_AUDIO) {
|
||||
console.log(`🎵 Track AUDIO souscrit de ${participant.identity} (événement TrackSubscribed)`);
|
||||
await this._handleAudioTrack(track, publication, participant);
|
||||
}
|
||||
});
|
||||
|
||||
this.room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
||||
if (track.kind === 'audio') {
|
||||
if (track.kind === TrackKind.KIND_AUDIO) {
|
||||
console.log(`🔇 Track audio désouscrit de ${participant.identity}`);
|
||||
this.remoteParticipants.delete(participant.sid);
|
||||
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
|
||||
* Note: Pour un bridge serveur, on utilise plutôt publishData pour envoyer Opus directement
|
||||
* @param {MediaStreamTrack} mediaStreamTrack - Track audio du microphone
|
||||
* @returns {Promise<void>}
|
||||
* Gère un track audio (création AudioStream et lecture)
|
||||
* @private
|
||||
*/
|
||||
async publishAudioTrack(mediaStreamTrack) {
|
||||
if (!this.isConnected) {
|
||||
throw new Error('Pas connecté à LiveKit');
|
||||
async _handleAudioTrack(track, publication, participant) {
|
||||
console.log(`🎧 Création AudioStream pour ${participant.identity}...`);
|
||||
|
||||
// Création d'un AudioStream pour recevoir les données PCM
|
||||
const stream = new AudioStream(
|
||||
track,
|
||||
this.options.sampleRate,
|
||||
this.options.channels
|
||||
);
|
||||
|
||||
this.remoteParticipants.set(participant.sid, {
|
||||
participant,
|
||||
track,
|
||||
publication,
|
||||
stream
|
||||
});
|
||||
|
||||
// Lecture des frames audio
|
||||
this._startAudioReceive(participant.sid, stream);
|
||||
|
||||
this.emit('audioTrackSubscribed', { track, participant });
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre la réception audio d'un participant
|
||||
* @private
|
||||
*/
|
||||
async _startAudioReceive(participantSid, stream) {
|
||||
try {
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (!this.isConnected || !this.localAudioTrack) {
|
||||
// Silently drop frames si pas encore connecté
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Options de publication
|
||||
const options = {
|
||||
name: 'bridge-audio',
|
||||
source: 'microphone',
|
||||
audioBitrate: this.options.audioBitrate
|
||||
};
|
||||
|
||||
this.localAudioTrack = await this.room.localParticipant.publishTrack(
|
||||
mediaStreamTrack,
|
||||
options
|
||||
// AudioFrame attend Int16Array, pas Buffer
|
||||
// Convertir Buffer → Int16Array (éviter .slice, utiliser .subarray selon doc)
|
||||
const int16Array = new Int16Array(
|
||||
pcmData.buffer,
|
||||
pcmData.byteOffset,
|
||||
pcmData.length / 2 // length en samples, pas en bytes
|
||||
);
|
||||
|
||||
console.log('✓ Track audio local publié');
|
||||
this.emit('trackPublished', this.localAudioTrack);
|
||||
const samplesPerChannel = Math.floor(int16Array.length / this.options.channels);
|
||||
|
||||
const frame = new AudioFrame(
|
||||
int16Array,
|
||||
parseInt(this.options.sampleRate, 10),
|
||||
parseInt(this.options.channels, 10),
|
||||
samplesPerChannel
|
||||
);
|
||||
|
||||
// Envoi via AudioSource
|
||||
await this.audioSource.captureFrame(frame);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur publication track:', error);
|
||||
this.emit('error', error);
|
||||
throw error;
|
||||
// Ne logger que les erreurs non-InvalidState pour éviter le spam
|
||||
if (!error.message.includes('InvalidState')) {
|
||||
console.error('Erreur envoi audio:', 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
|
||||
* @returns {Array<Object>} Liste des tracks avec métadonnées
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
getRemoteAudioTracks() {
|
||||
return Array.from(this.remoteParticipants.values()).map(({ participant, track, publication }) => ({
|
||||
@@ -234,7 +309,7 @@ export class LiveKitClient extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Récupère un participant distant par son SID
|
||||
* @param {string} sid - SID du participant
|
||||
* @param {string} sid
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
getRemoteParticipant(sid) {
|
||||
@@ -261,15 +336,14 @@ export class LiveKitClient extends EventEmitter {
|
||||
localParticipant: {
|
||||
sid: localParticipant?.sid,
|
||||
identity: localParticipant?.identity,
|
||||
tracksPublished: localParticipant?.trackPublications.size || 0
|
||||
tracksPublished: localParticipant?.trackPublications?.size || 0
|
||||
},
|
||||
remoteParticipants: {
|
||||
count: participants.size,
|
||||
list: Array.from(participants.values()).map(p => ({
|
||||
sid: p.sid,
|
||||
identity: p.identity,
|
||||
audioTracks: Array.from(p.audioTrackPublications.values()).length,
|
||||
connectionQuality: p.connectionQuality
|
||||
audioTracks: Array.from(p.audioTrackPublications?.values() || []).length
|
||||
}))
|
||||
}
|
||||
};
|
||||
@@ -280,9 +354,23 @@ export class LiveKitClient extends EventEmitter {
|
||||
*/
|
||||
async disconnect() {
|
||||
if (this.room) {
|
||||
await this.unpublishAudioTrack();
|
||||
this.room.disconnect();
|
||||
// Unpublish track
|
||||
if (this.localAudioTrack) {
|
||||
try {
|
||||
await this.room.localParticipant.unpublishTrack(this.localAudioTrack.sid);
|
||||
} catch (error) {
|
||||
// Ignorer l'erreur si le track n'existe plus (shutdown rapide)
|
||||
if (!error.message?.includes('track not found')) {
|
||||
console.warn('⚠️ Erreur unpublish track:', error.message);
|
||||
}
|
||||
}
|
||||
this.localAudioTrack = null;
|
||||
}
|
||||
|
||||
// Déconnexion
|
||||
await this.room.disconnect();
|
||||
this.room = null;
|
||||
this.audioSource = null;
|
||||
this.isConnected = false;
|
||||
this.remoteParticipants.clear();
|
||||
console.log('✓ Déconnecté de LiveKit');
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
/**
|
||||
* 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 :
|
||||
* - Énumération des devices audio
|
||||
* - Capture audio (microphone/carte son)
|
||||
* - Lecture audio (speakers/sortie audio)
|
||||
* - Énumération des devices audio via system_profiler
|
||||
* - Capture audio via sox (rec)
|
||||
* - Lecture audio via sox (play)
|
||||
* - Buffer circulaire pour flux continu
|
||||
*/
|
||||
|
||||
import portAudio from 'naudiodon';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export class CoreAudioBackend extends EventEmitter {
|
||||
@@ -18,41 +21,124 @@ export class CoreAudioBackend extends EventEmitter {
|
||||
|
||||
this.options = {
|
||||
sampleRate: options.sampleRate || 48000,
|
||||
channels: options.channels || 1, // Mono par défaut
|
||||
framesPerBuffer: options.framesPerBuffer || 960, // 20ms à 48kHz
|
||||
inputDeviceId: options.inputDeviceId || null,
|
||||
outputDeviceId: options.outputDeviceId || null,
|
||||
channels: options.channels || 1,
|
||||
framesPerBuffer: options.framesPerBuffer || 960,
|
||||
inputDeviceName: options.inputDeviceName || null,
|
||||
outputDeviceName: options.outputDeviceName || null,
|
||||
...options
|
||||
};
|
||||
|
||||
this.inputStream = null;
|
||||
this.outputStream = null;
|
||||
this.captureProcess = null;
|
||||
this.playbackProcess = null;
|
||||
this.isCapturing = false;
|
||||
this.isPlaying = false;
|
||||
this.shuttingDown = false;
|
||||
|
||||
// Buffer d'accumulation pour la capture (sox envoie des chunks de taille variable)
|
||||
this.captureAccumulator = Buffer.alloc(0);
|
||||
this.targetCaptureBytes = this.options.framesPerBuffer * 2 * this.options.channels; // 2 bytes per sample
|
||||
|
||||
// Buffer circulaire pour la lecture
|
||||
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
|
||||
*/
|
||||
static getDevices() {
|
||||
try {
|
||||
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
|
||||
}));
|
||||
const output = execSync('system_profiler SPAudioDataType -json', { encoding: 'utf8' });
|
||||
const data = JSON.parse(output);
|
||||
|
||||
const devices = [];
|
||||
|
||||
// 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;
|
||||
|
||||
// Utiliser le UID CoreAudio comme ID (unique et stable)
|
||||
const deviceUID = device._uniqueID || device.coreaudio_device_uid || name;
|
||||
|
||||
// Ignorer les devices sans input ni output
|
||||
if (inputChannels === 0 && outputChannels === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
devices.push({
|
||||
id: deviceUID,
|
||||
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: 'builtin-mic',
|
||||
name: 'Built-in Microphone',
|
||||
maxInputChannels: 1,
|
||||
maxOutputChannels: 0,
|
||||
defaultSampleRate: 48000,
|
||||
hostAPIName: 'Core Audio'
|
||||
},
|
||||
{
|
||||
id: 'builtin-output',
|
||||
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);
|
||||
return [];
|
||||
|
||||
// Fallback : devices par défaut
|
||||
return [
|
||||
{
|
||||
id: 'builtin-mic',
|
||||
name: 'Built-in Microphone',
|
||||
maxInputChannels: 1,
|
||||
maxOutputChannels: 0,
|
||||
defaultSampleRate: 48000,
|
||||
hostAPIName: 'Core Audio'
|
||||
},
|
||||
{
|
||||
id: 'builtin-output',
|
||||
name: 'Built-in Output',
|
||||
maxInputChannels: 0,
|
||||
maxOutputChannels: 2,
|
||||
defaultSampleRate: 48000,
|
||||
hostAPIName: 'Core Audio'
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +147,17 @@ export class CoreAudioBackend extends EventEmitter {
|
||||
* @returns {Object|null} Device d'entrée par défaut
|
||||
*/
|
||||
static getDefaultInputDevice() {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,12 +165,21 @@ export class CoreAudioBackend extends EventEmitter {
|
||||
* @returns {Object|null} Device de sortie par défaut
|
||||
*/
|
||||
static getDefaultOutputDevice() {
|
||||
try {
|
||||
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>}
|
||||
*/
|
||||
async startCapture() {
|
||||
@@ -85,36 +189,65 @@ export class CoreAudioBackend extends EventEmitter {
|
||||
}
|
||||
|
||||
try {
|
||||
const inputConfig = {
|
||||
channelCount: this.options.channels,
|
||||
sampleFormat: portAudio.SampleFormat16Bit,
|
||||
sampleRate: this.options.sampleRate,
|
||||
deviceId: this.options.inputDeviceId ?? undefined,
|
||||
closeOnError: true
|
||||
};
|
||||
// Commande sox pour capturer audio sur macOS
|
||||
// Sur macOS, sox utilise CoreAudio par défaut via 'rec' (alias de sox -d)
|
||||
// Format: sox -d [options] output
|
||||
// -d = default input device OU -t coreaudio "Device Name"
|
||||
|
||||
this.inputStream = new portAudio.AudioIO({
|
||||
inOptions: inputConfig
|
||||
const args = [];
|
||||
|
||||
// Spécifier le device d'entrée (CoreAudio capture en 32-bit natif)
|
||||
if (this.options.inputDeviceName) {
|
||||
args.push('-t', 'coreaudio', this.options.inputDeviceName);
|
||||
} else {
|
||||
args.push('-d');
|
||||
}
|
||||
|
||||
// Format de sortie (stdout) - convertir 32→16 bit
|
||||
args.push(
|
||||
'-t', 'raw', // Format sortie raw PCM
|
||||
'-b', '16', // Convertir vers 16-bit
|
||||
'-e', 'signed-integer',
|
||||
'-c', String(this.options.channels),
|
||||
'-r', String(this.options.sampleRate),
|
||||
'-' // Stdout
|
||||
);
|
||||
|
||||
console.log(`🎤 Démarrage capture sox: ${args.join(' ')}`);
|
||||
this.captureProcess = spawn('sox', args);
|
||||
|
||||
this.captureProcess.stdout.on('data', (audioData) => {
|
||||
// Accumuler les données jusqu'à avoir un frame complet
|
||||
this.captureAccumulator = Buffer.concat([this.captureAccumulator, audioData]);
|
||||
|
||||
// Émettre des frames de taille fixe
|
||||
while (this.captureAccumulator.length >= this.targetCaptureBytes) {
|
||||
const frame = this.captureAccumulator.subarray(0, this.targetCaptureBytes);
|
||||
this.emit('audioData', Buffer.from(frame)); // Copier pour éviter les références
|
||||
|
||||
// Garder le reste pour la prochaine frame
|
||||
this.captureAccumulator = this.captureAccumulator.subarray(this.targetCaptureBytes);
|
||||
}
|
||||
});
|
||||
|
||||
this.inputStream.on('data', (audioData) => {
|
||||
// Émet les données audio capturées (Buffer PCM 16-bit)
|
||||
this.emit('audioData', audioData);
|
||||
this.captureProcess.stderr.on('data', (data) => {
|
||||
const msg = data.toString();
|
||||
if (!msg.includes('sox WARN')) {
|
||||
console.error('sox capture stderr:', msg);
|
||||
}
|
||||
});
|
||||
|
||||
this.inputStream.on('error', (error) => {
|
||||
console.error('Erreur stream capture:', error);
|
||||
this.captureProcess.on('error', (error) => {
|
||||
console.error('Erreur processus sox capture:', error);
|
||||
this.emit('error', error);
|
||||
});
|
||||
|
||||
this.inputStream.on('close', () => {
|
||||
console.log('Stream capture fermé');
|
||||
this.captureProcess.on('close', (code) => {
|
||||
console.log(`Sox capture fermé (code ${code})`);
|
||||
this.isCapturing = false;
|
||||
});
|
||||
|
||||
this.inputStream.start();
|
||||
this.isCapturing = true;
|
||||
|
||||
console.log(`✓ Capture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
|
||||
} catch (error) {
|
||||
console.error('Erreur démarrage capture:', error);
|
||||
@@ -126,54 +259,109 @@ export class CoreAudioBackend extends EventEmitter {
|
||||
* Arrête la capture audio
|
||||
*/
|
||||
stopCapture() {
|
||||
if (this.inputStream && this.isCapturing) {
|
||||
this.inputStream.quit();
|
||||
this.inputStream = null;
|
||||
if (this.captureProcess && this.isCapturing) {
|
||||
this.captureProcess.kill('SIGTERM');
|
||||
this.captureProcess = null;
|
||||
this.isCapturing = false;
|
||||
this.captureAccumulator = Buffer.alloc(0); // Reset accumulator
|
||||
console.log('✓ Capture audio arrêtée');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre la lecture audio
|
||||
* Démarre la lecture audio via sox (play)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async startPlayback() {
|
||||
console.log('🔊 Démarrage playback sox...');
|
||||
|
||||
if (this.isPlaying) {
|
||||
console.warn('Lecture déjà active');
|
||||
console.warn('⚠️ Lecture déjà active');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const outputConfig = {
|
||||
channelCount: this.options.channels,
|
||||
sampleFormat: portAudio.SampleFormat16Bit,
|
||||
sampleRate: this.options.sampleRate,
|
||||
deviceId: this.options.outputDeviceId ?? undefined,
|
||||
closeOnError: true
|
||||
};
|
||||
// Commande sox pour lecture audio sur macOS
|
||||
// Format: sox [options] input output
|
||||
// Input = stdin (-)
|
||||
// Output = -d (default) OU -t coreaudio "Device Name"
|
||||
|
||||
this.outputStream = new portAudio.AudioIO({
|
||||
outOptions: outputConfig
|
||||
const args = [
|
||||
'--buffer', '65536', // Buffer 64k (évite EOF prématuré)
|
||||
'-t', 'raw',
|
||||
'-b', '16',
|
||||
'-e', 'signed-integer',
|
||||
'-c', String(this.options.channels),
|
||||
'-r', String(this.options.sampleRate),
|
||||
'-' // Input = stdin
|
||||
];
|
||||
|
||||
// Spécifier le device de sortie
|
||||
if (this.options.outputDeviceName) {
|
||||
// Utiliser le device spécifié par son nom
|
||||
args.push('-t', 'coreaudio', this.options.outputDeviceName);
|
||||
} else {
|
||||
// Device par défaut
|
||||
args.push('-d');
|
||||
}
|
||||
|
||||
console.log(`🔊 Démarrage playback sox: ${args.join(' ')}`);
|
||||
this.playbackProcess = spawn('sox', args, {
|
||||
stdio: ['pipe', 'ignore', 'pipe'] // stdin=pipe, stdout=ignore, stderr=pipe
|
||||
});
|
||||
|
||||
this.outputStream.on('error', (error) => {
|
||||
console.error('Erreur stream lecture:', error);
|
||||
// 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.playbackProcess.stderr.on('data', (data) => {
|
||||
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.outputStream.on('close', () => {
|
||||
console.log('Stream lecture fermé');
|
||||
this.playbackProcess.on('close', (code) => {
|
||||
const uptime = ((Date.now() - this.playbackStartTime) / 1000).toFixed(1);
|
||||
console.log(`⚠️ Sox playback fermé (code ${code}) après ${uptime}s`);
|
||||
this.isPlaying = false;
|
||||
|
||||
// Redémarrer automatiquement (sox se ferme quand le buffer stdin se vide)
|
||||
if (!this.shuttingDown) {
|
||||
console.log('🔄 Redémarrage automatique du playback...');
|
||||
setTimeout(() => {
|
||||
if (!this.shuttingDown) {
|
||||
this.startPlayback().catch(err => {
|
||||
console.error('Erreur redémarrage playback:', err);
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Démarrage du stream de lecture
|
||||
this.outputStream.start();
|
||||
this.playbackStartTime = Date.now();
|
||||
this.isPlaying = true;
|
||||
|
||||
// Boucle de lecture du buffer circulaire
|
||||
this._startPlaybackLoop();
|
||||
|
||||
// Envoyer immédiatement du silence pour démarrer sox
|
||||
const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (this.playbackProcess.stdin.writable) {
|
||||
this.playbackProcess.stdin.write(silenceBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ Lecture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
|
||||
} catch (error) {
|
||||
console.error('Erreur démarrage lecture:', error);
|
||||
@@ -185,9 +373,14 @@ export class CoreAudioBackend extends EventEmitter {
|
||||
* Arrête la lecture audio
|
||||
*/
|
||||
stopPlayback() {
|
||||
if (this.outputStream && this.isPlaying) {
|
||||
this.outputStream.quit();
|
||||
this.outputStream = null;
|
||||
if (this.playbackInterval) {
|
||||
clearInterval(this.playbackInterval);
|
||||
this.playbackInterval = null;
|
||||
}
|
||||
|
||||
if (this.playbackProcess && this.isPlaying) {
|
||||
this.playbackProcess.kill('SIGTERM');
|
||||
this.playbackProcess = null;
|
||||
this.isPlaying = false;
|
||||
this.playbackBuffer = [];
|
||||
console.log('✓ Lecture audio arrêtée');
|
||||
@@ -200,10 +393,16 @@ export class CoreAudioBackend extends EventEmitter {
|
||||
*/
|
||||
queueAudio(audioData) {
|
||||
if (!this.isPlaying) {
|
||||
console.warn('Tentative ajout audio alors que lecture inactive');
|
||||
// Ne logger qu'une fois pour éviter le spam
|
||||
if (!this.playbackInactiveWarned) {
|
||||
console.warn('⚠️ Tentative ajout audio alors que lecture inactive (message unique)');
|
||||
this.playbackInactiveWarned = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.playbackInactiveWarned = false;
|
||||
|
||||
// Limite la taille du buffer pour éviter la latence excessive
|
||||
if (this.playbackBuffer.length < this.maxBufferSize) {
|
||||
this.playbackBuffer.push(audioData);
|
||||
@@ -218,31 +417,55 @@ export class CoreAudioBackend extends EventEmitter {
|
||||
* @private
|
||||
*/
|
||||
_startPlaybackLoop() {
|
||||
const playNextChunk = () => {
|
||||
if (!this.isPlaying) return;
|
||||
// Calculer l'intervalle en ms (ex: 960 frames à 48kHz = 20ms)
|
||||
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
|
||||
|
||||
if (this.playbackBuffer.length > 0) {
|
||||
const chunk = this.playbackBuffer.shift();
|
||||
this.outputStream.write(chunk);
|
||||
} else {
|
||||
// Buffer vide : underrun (on envoie du silence)
|
||||
const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
|
||||
this.outputStream.write(silenceBuffer);
|
||||
this.emit('bufferUnderrun');
|
||||
console.log(`🔁 Boucle playback démarrée (intervalle: ${intervalMs}ms)`);
|
||||
|
||||
// Utiliser setInterval pour garantir un flux continu
|
||||
this.playbackInterval = setInterval(() => {
|
||||
if (!this.isPlaying || !this.playbackProcess || !this.playbackProcess.stdin) {
|
||||
if (this.playbackInterval) {
|
||||
clearInterval(this.playbackInterval);
|
||||
this.playbackInterval = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Rappel à intervalle régulier (20ms pour 960 frames à 48kHz)
|
||||
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
|
||||
setTimeout(playNextChunk, intervalMs);
|
||||
};
|
||||
let chunk;
|
||||
if (this.playbackBuffer.length > 0) {
|
||||
chunk = this.playbackBuffer.shift();
|
||||
} else {
|
||||
// Buffer vide : underrun (envoyer du silence)
|
||||
chunk = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
|
||||
}
|
||||
|
||||
playNextChunk();
|
||||
// Toujours écrire quelque chose pour garder sox actif
|
||||
try {
|
||||
if (this.playbackProcess.stdin.writable) {
|
||||
this.playbackProcess.stdin.write(chunk);
|
||||
} else {
|
||||
console.warn('⚠️ Sox stdin non writable, arrêt boucle');
|
||||
this.isPlaying = false;
|
||||
clearInterval(this.playbackInterval);
|
||||
this.playbackInterval = null;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'EPIPE') {
|
||||
console.error('Erreur écriture stdin sox:', error);
|
||||
}
|
||||
this.isPlaying = false;
|
||||
clearInterval(this.playbackInterval);
|
||||
this.playbackInterval = null;
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrête tous les streams
|
||||
*/
|
||||
destroy() {
|
||||
this.shuttingDown = true;
|
||||
this.stopCapture();
|
||||
this.stopPlayback();
|
||||
this.removeAllListeners();
|
||||
@@ -250,14 +473,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}
|
||||
*/
|
||||
static isAvailable() {
|
||||
try {
|
||||
const devices = portAudio.getDevices();
|
||||
return devices.length > 0;
|
||||
// Vérifier si sox est installé
|
||||
execSync('which sox', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch (error) {
|
||||
// sox n'est pas installé
|
||||
console.warn('sox non installé. Installer avec : brew install sox');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* 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.shuttingDown = false;
|
||||
|
||||
// Buffer d'accumulation pour la capture (JACK peut envoyer des chunks de taille variable)
|
||||
this.captureAccumulator = Buffer.alloc(0);
|
||||
this.targetCaptureBytes = this.options.framesPerBuffer * 2 * this.options.channels; // 2 bytes per sample
|
||||
|
||||
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) => {
|
||||
// Accumuler les données jusqu'à avoir un frame complet
|
||||
this.captureAccumulator = Buffer.concat([this.captureAccumulator, audioData]);
|
||||
|
||||
// Émettre des frames de taille fixe
|
||||
while (this.captureAccumulator.length >= this.targetCaptureBytes) {
|
||||
const frame = this.captureAccumulator.subarray(0, this.targetCaptureBytes);
|
||||
this.emit('audioData', Buffer.from(frame)); // Copier pour éviter les références
|
||||
|
||||
// Garder le reste pour la prochaine frame
|
||||
this.captureAccumulator = this.captureAccumulator.subarray(this.targetCaptureBytes);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
this.captureAccumulator = Buffer.alloc(0); // Reset accumulator
|
||||
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.shuttingDown = true;
|
||||
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,430 @@
|
||||
/**
|
||||
* 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,
|
||||
inputTargetDevice: options.inputTargetDevice || null,
|
||||
outputTargetDevice: options.outputTargetDevice || null,
|
||||
latency: options.latency || 20, // ms
|
||||
...options
|
||||
};
|
||||
|
||||
this.captureProcess = null;
|
||||
this.playbackProcess = null;
|
||||
this.isCapturing = false;
|
||||
this.isPlaying = false;
|
||||
this.shuttingDown = false;
|
||||
|
||||
// Buffer d'accumulation pour la capture (pw-cat envoie des chunks de taille variable)
|
||||
this.captureAccumulator = Buffer.alloc(0);
|
||||
this.targetCaptureBytes = this.options.framesPerBuffer * 2 * this.options.channels; // 2 bytes per sample
|
||||
|
||||
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
|
||||
// Chemin absolu pour éviter les problèmes de PATH avec /bin/sh
|
||||
const pactlCmd = '/usr/bin/pactl';
|
||||
const sourcesOutput = execSync(`${pactlCmd} list sources short`, { encoding: 'utf8' });
|
||||
const sinksOutput = execSync(`${pactlCmd} 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 pactlCmd = '/usr/bin/pactl';
|
||||
const output = execSync(`${pactlCmd} 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 pactlCmd = '/usr/bin/pactl';
|
||||
const output = execSync(`${pactlCmd} 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.inputTargetDevice) {
|
||||
args.push(`--target=${this.options.inputTargetDevice}`);
|
||||
}
|
||||
|
||||
this.captureProcess = spawn('pw-cat', args);
|
||||
|
||||
this.captureProcess.stdout.on('data', (audioData) => {
|
||||
// Accumuler les données jusqu'à avoir un frame complet
|
||||
this.captureAccumulator = Buffer.concat([this.captureAccumulator, audioData]);
|
||||
|
||||
// Émettre des frames de taille fixe
|
||||
while (this.captureAccumulator.length >= this.targetCaptureBytes) {
|
||||
const frame = this.captureAccumulator.subarray(0, this.targetCaptureBytes);
|
||||
this.emit('audioData', Buffer.from(frame)); // Copier pour éviter les références
|
||||
|
||||
// Garder le reste pour la prochaine frame
|
||||
this.captureAccumulator = this.captureAccumulator.subarray(this.targetCaptureBytes);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
this.captureAccumulator = Buffer.alloc(0); // Reset accumulator
|
||||
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.outputTargetDevice) {
|
||||
args.push(`--target=${this.options.outputTargetDevice}`);
|
||||
}
|
||||
|
||||
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.shuttingDown = true;
|
||||
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;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* ConfigManager.js
|
||||
* Gestionnaire centralisé de configuration avec support événements
|
||||
* Phase 2.5
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import YAML from 'yaml';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const configPath = join(__dirname, 'config.yaml');
|
||||
|
||||
/**
|
||||
* Génère un ID slug à partir d'un nom
|
||||
*/
|
||||
function slugify(text) {
|
||||
return text
|
||||
.toString()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w-]+/g, '')
|
||||
.replace(/--+/g, '-');
|
||||
}
|
||||
|
||||
class ConfigManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.config = null;
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge la configuration depuis le fichier YAML
|
||||
*/
|
||||
load() {
|
||||
try {
|
||||
const configFile = readFileSync(configPath, 'utf8');
|
||||
this.config = YAML.parse(configFile);
|
||||
|
||||
// Générer les IDs pour les groupes et canaux
|
||||
this.config.groups = this.config.groups.map(group => {
|
||||
const groupId = slugify(group.name);
|
||||
return {
|
||||
...group,
|
||||
id: groupId,
|
||||
channels: group.channels ? group.channels.map(channel => ({
|
||||
...channel,
|
||||
id: channel.id || `${groupId}-${slugify(channel.name)}`
|
||||
})) : []
|
||||
};
|
||||
});
|
||||
|
||||
return this.config;
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement configuration:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la configuration actuelle
|
||||
*/
|
||||
get() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde la configuration dans le fichier YAML
|
||||
* Ne sauvegarde PAS les IDs (ils sont générés dynamiquement)
|
||||
*/
|
||||
save(config) {
|
||||
try {
|
||||
// Nettoyer les IDs avant de sauvegarder
|
||||
const cleanConfig = {
|
||||
...config,
|
||||
groups: config.groups.map(group => {
|
||||
const { id, ...groupWithoutId } = group;
|
||||
return {
|
||||
...groupWithoutId,
|
||||
channels: group.channels ? group.channels.map(channel => {
|
||||
const { id: channelId, ...channelWithoutId } = channel;
|
||||
return channelWithoutId;
|
||||
}) : []
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
const yamlContent = YAML.stringify(cleanConfig);
|
||||
writeFileSync(configPath, yamlContent, 'utf8');
|
||||
|
||||
// Recharger pour synchroniser
|
||||
this.load();
|
||||
|
||||
// Émettre événement de changement
|
||||
this.emit('config-updated', this.config);
|
||||
|
||||
return this.config;
|
||||
} catch (error) {
|
||||
console.error('Erreur sauvegarde configuration:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la configuration audio device
|
||||
*/
|
||||
updateAudioDevice(deviceConfig) {
|
||||
try {
|
||||
console.log('📝 ConfigManager.updateAudioDevice:', deviceConfig);
|
||||
|
||||
if (!this.config.audio) {
|
||||
this.config.audio = {};
|
||||
}
|
||||
|
||||
if (!this.config.audio.device) {
|
||||
this.config.audio.device = {};
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
console.log('💾 Sauvegarde configuration...');
|
||||
this.save(this.config);
|
||||
|
||||
// É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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la configuration audio globale
|
||||
*/
|
||||
updateAudioConfig(audioConfig) {
|
||||
if (!this.config.audio) {
|
||||
this.config.audio = {};
|
||||
}
|
||||
|
||||
if (audioConfig.sampleRate !== undefined) {
|
||||
this.config.audio.sampleRate = audioConfig.sampleRate;
|
||||
}
|
||||
if (audioConfig.defaultBitrate !== undefined) {
|
||||
this.config.audio.defaultBitrate = audioConfig.defaultBitrate;
|
||||
}
|
||||
if (audioConfig.jitterBufferMs !== undefined) {
|
||||
this.config.audio.jitterBufferMs = audioConfig.jitterBufferMs;
|
||||
}
|
||||
|
||||
this.save(this.config);
|
||||
|
||||
return this.config.audio;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
const configManager = new ConfigManager();
|
||||
|
||||
export default configManager;
|
||||
+50
-68
@@ -1,78 +1,60 @@
|
||||
# PTT Live - Configuration
|
||||
# Phase 1: Configuration basique (1 groupe, support multi-canaux)
|
||||
|
||||
# Configuration audio globale
|
||||
audio:
|
||||
sampleRate: 48000
|
||||
frameSize: 20 # ms
|
||||
|
||||
# Qualité Opus configurable
|
||||
# Voix économique: 32-64 kbps (WiFi limité)
|
||||
# Voix standard: 96 kbps (défaut)
|
||||
# Voix HD: 128-192 kbps
|
||||
# Musique: 256-320 kbps
|
||||
defaultBitrate: 96 # kbps
|
||||
|
||||
# Jitter buffer
|
||||
channels: 2
|
||||
frameSize: 20
|
||||
defaultBitrate: 96
|
||||
jitterBufferMs: 40
|
||||
|
||||
# Configuration des groupes
|
||||
device:
|
||||
inputDeviceId: Loopback Audio 4
|
||||
outputDeviceId: Haut-parleurs MacBook Pro
|
||||
sampleRate: 48000
|
||||
routing:
|
||||
inputToGroup:
|
||||
"0":
|
||||
- default
|
||||
"1": []
|
||||
"2": []
|
||||
"4":
|
||||
- technique
|
||||
"5":
|
||||
- technique
|
||||
groupToOutput:
|
||||
technique:
|
||||
- "1"
|
||||
production:
|
||||
- "0"
|
||||
- "1"
|
||||
default:
|
||||
- "0"
|
||||
gains: {}
|
||||
channelNames:
|
||||
inputs:
|
||||
"0": Mac
|
||||
"1": Talkback FOH
|
||||
"2": Retour Console
|
||||
"3": Liaison Scène
|
||||
"4": Monitor Mix
|
||||
"5": Spare 1
|
||||
outputs:
|
||||
"0": L
|
||||
"1": R
|
||||
"2": Talkback Console
|
||||
groups:
|
||||
- id: production
|
||||
name: "Équipe Production"
|
||||
description: "Réalisateur, cadreurs, régisseur"
|
||||
|
||||
# Qualité audio spécifique (optionnel, sinon utilise defaultBitrate)
|
||||
- name: Default
|
||||
audioBitrate: 96
|
||||
|
||||
# Canaux audio associés
|
||||
channels:
|
||||
- id: prod-main
|
||||
name: "Production principale"
|
||||
audioInput: 0 # Index device CoreAudio/JACK
|
||||
audioOutput: 0
|
||||
|
||||
- id: prod-backup
|
||||
name: "Production backup"
|
||||
audioInput: 1
|
||||
audioOutput: 1
|
||||
|
||||
- id: technique
|
||||
name: "Équipe Technique"
|
||||
description: "Techniciens, électriciens, machinistes"
|
||||
channels: []
|
||||
- name: Production
|
||||
audioBitrate: 96
|
||||
channels:
|
||||
- id: tech-main
|
||||
name: "Technique général"
|
||||
audioInput: 2
|
||||
audioOutput: 2
|
||||
|
||||
- id: sonorisation
|
||||
name: "Équipe Sonorisation"
|
||||
description: "Ingénieurs son, retours"
|
||||
audioBitrate: 128 # Qualité supérieure pour les ingénieurs son
|
||||
channels:
|
||||
- id: son-main
|
||||
name: "Son principal"
|
||||
audioInput: 3
|
||||
audioOutput: 3
|
||||
- id: son-retours
|
||||
name: "Retours scène"
|
||||
audioInput: 4
|
||||
audioOutput: 4
|
||||
|
||||
# Configuration serveur
|
||||
channels: []
|
||||
- name: Technique
|
||||
audioBitrate: 96
|
||||
channels: []
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
host: 0.0.0.0
|
||||
port: 3000
|
||||
|
||||
# LiveKit
|
||||
livekit:
|
||||
url: "ws://localhost:7880"
|
||||
# API key/secret dans .env (LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
|
||||
|
||||
# Logging
|
||||
url: AUTO
|
||||
logging:
|
||||
level: "debug" # debug, info, warn, error
|
||||
logLatency: true
|
||||
logAudioStats: true
|
||||
level: debug
|
||||
logLatency: false
|
||||
logAudioStats: false
|
||||
|
||||
+188
-32
@@ -2,20 +2,32 @@
|
||||
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import https from 'https';
|
||||
import http from 'http';
|
||||
import { spawn } from 'child_process';
|
||||
import { readFileSync } from 'fs';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { networkInterfaces } from 'os';
|
||||
import YAML from 'yaml';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import adminRouter, { registerUser, addLog } from './api/admin.js';
|
||||
import configManager from './config/ConfigManager.js';
|
||||
import audioBridgeManager from './bridge/AudioBridgeManager.js';
|
||||
import AudioLevelsServer from './websocket/AudioLevelsServer.js';
|
||||
import { setGlobalLogLevel } from './utils/Logger.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Chargement configuration
|
||||
const configPath = join(__dirname, 'config', 'config.yaml');
|
||||
const configFile = readFileSync(configPath, 'utf8');
|
||||
const config = YAML.parse(configFile);
|
||||
// Chargement configuration via ConfigManager
|
||||
const config = configManager.get();
|
||||
|
||||
// Configure le niveau de log
|
||||
const logLevel = config.logging?.level?.toUpperCase() || 'INFO';
|
||||
setGlobalLogLevel(logLevel);
|
||||
console.log(`📊 Niveau de log: ${logLevel}`);
|
||||
|
||||
// Note: Les IDs sont maintenant générés automatiquement par le ConfigManager
|
||||
|
||||
/**
|
||||
* Détecte l'IP réseau locale (WiFi/Ethernet)
|
||||
@@ -57,6 +69,7 @@ const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || 'secret';
|
||||
const USE_LOCAL_LIVEKIT = process.env.USE_LOCAL_LIVEKIT === 'true';
|
||||
const SERVER_PORT = parseInt(process.env.PORT || config.server.port, 10);
|
||||
const SERVER_HOST = config.server.host;
|
||||
const ENABLE_HTTPS = process.env.ENABLE_HTTPS === 'true';
|
||||
|
||||
// Configuration URL LiveKit
|
||||
let LIVEKIT_URL = process.env.LIVEKIT_URL || config.server.livekit.url;
|
||||
@@ -83,6 +96,9 @@ function log(level, ...args) {
|
||||
if (msgLevel >= configLevel) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}] [${level.toUpperCase()}]`, ...args);
|
||||
|
||||
// Ajouter au système de logs admin
|
||||
addLog(level, args.join(' '));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +108,11 @@ let livekitProcess = null;
|
||||
|
||||
function startLiveKitServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Utiliser le binaire Homebrew (dans PATH)
|
||||
const livekitBinary = 'livekit-server';
|
||||
// Détection du binaire LiveKit :
|
||||
// 1. Binaire local (Linux après install.sh) : server/bin/livekit-server
|
||||
// 2. Binaire Homebrew (macOS) : livekit-server dans PATH
|
||||
const localBinary = join(__dirname, 'bin', 'livekit-server');
|
||||
const livekitBinary = existsSync(localBinary) ? localBinary : 'livekit-server';
|
||||
|
||||
log('info', 'Démarrage LiveKit Server...');
|
||||
log('debug', 'Commande:', livekitBinary);
|
||||
@@ -110,16 +129,27 @@ function startLiveKitServer() {
|
||||
|
||||
livekitProcess = spawn(livekitBinary, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
env: {
|
||||
...process.env,
|
||||
LIVEKIT_LOG_LEVEL: 'info' // Réduit les logs LiveKit (debug → info)
|
||||
},
|
||||
shell: true // Permet de trouver le binaire dans PATH
|
||||
});
|
||||
|
||||
livekitProcess.stdout.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
log('debug', '[LiveKit]', output);
|
||||
if (!output) return;
|
||||
|
||||
// Filtrer les logs trop verbeux
|
||||
if (output.includes('DEBUG') ||
|
||||
output.includes('received signal request') ||
|
||||
output.includes('sending signal response') ||
|
||||
output.includes('handling signal request')) {
|
||||
return; // Ignorer ces logs
|
||||
}
|
||||
|
||||
log('debug', '[LiveKit]', output);
|
||||
|
||||
// Détection démarrage réussi
|
||||
if (output.includes('starting server') || output.includes('rtc server')) {
|
||||
resolve();
|
||||
@@ -128,9 +158,14 @@ function startLiveKitServer() {
|
||||
|
||||
livekitProcess.stderr.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
log('warn', '[LiveKit Error]', output);
|
||||
if (!output) return;
|
||||
|
||||
// Filtrer les logs DEBUG de stderr aussi
|
||||
if (output.includes('DEBUG')) {
|
||||
return;
|
||||
}
|
||||
|
||||
log('warn', '[LiveKit Error]', output);
|
||||
});
|
||||
|
||||
livekitProcess.on('error', (error) => {
|
||||
@@ -168,29 +203,56 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Middleware redirection HTTP → HTTPS (si activé)
|
||||
app.use((req, res, next) => {
|
||||
// Si HTTPS activé et requête en HTTP, rediriger
|
||||
if (ENABLE_HTTPS && req.protocol === 'http' && req.hostname !== 'localhost') {
|
||||
const httpsUrl = `https://${req.hostname}:${SERVER_PORT}${req.url}`;
|
||||
log('debug', `↪️ Redirection HTTPS: ${httpsUrl}`);
|
||||
return res.redirect(301, httpsUrl);
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Middleware logging
|
||||
app.use((req, res, next) => {
|
||||
log('debug', `${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// ========== Servir fichiers statiques client (production) ==========
|
||||
|
||||
// En production, servir le build client depuis ../client/dist
|
||||
const clientDistPath = join(__dirname, '..', 'client', 'dist');
|
||||
|
||||
if (existsSync(clientDistPath)) {
|
||||
log('info', `📦 Serveur statique activé : ${clientDistPath}`);
|
||||
app.use(express.static(clientDistPath));
|
||||
} else {
|
||||
log('debug', '📦 Pas de build client (mode dev)');
|
||||
}
|
||||
|
||||
// ========== Routes Admin ==========
|
||||
|
||||
// Monter les routes admin sous /admin
|
||||
app.use('/admin', adminRouter);
|
||||
|
||||
// ========== Routes API ==========
|
||||
|
||||
// Créer un router pour les routes API
|
||||
const apiRouter = express.Router();
|
||||
|
||||
/**
|
||||
* GET /config
|
||||
* Retourne la configuration des groupes
|
||||
*/
|
||||
app.get('/config', (req, res) => {
|
||||
apiRouter.get('/config', (req, res) => {
|
||||
try {
|
||||
const clientConfig = {
|
||||
groups: config.groups.map(g => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
channels: g.channels.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name
|
||||
}))
|
||||
name: g.name
|
||||
})),
|
||||
audio: {
|
||||
sampleRate: config.audio.sampleRate,
|
||||
@@ -209,12 +271,11 @@ app.get('/config', (req, res) => {
|
||||
* GET /groups
|
||||
* Retourne la liste des groupes disponibles (simplifié)
|
||||
*/
|
||||
app.get('/groups', (req, res) => {
|
||||
apiRouter.get('/groups', (req, res) => {
|
||||
try {
|
||||
const groups = config.groups.map(g => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description
|
||||
name: g.name
|
||||
}));
|
||||
|
||||
res.json({ groups });
|
||||
@@ -229,7 +290,7 @@ app.get('/groups', (req, res) => {
|
||||
* Génère un token LiveKit pour un client
|
||||
* Body: { username: string, groupId: string }
|
||||
*/
|
||||
app.post('/token', async (req, res) => {
|
||||
apiRouter.post('/token', async (req, res) => {
|
||||
try {
|
||||
const { username, groupId } = req.body;
|
||||
|
||||
@@ -269,11 +330,33 @@ app.post('/token', async (req, res) => {
|
||||
|
||||
log('info', `Token généré: ${username} → ${groupId}`);
|
||||
|
||||
// Enregistrer l'utilisateur dans le système admin
|
||||
registerUser(participantIdentity, username, groupId, roomName);
|
||||
|
||||
// Générer les canaux virtuels depuis le routing (inputs uniquement)
|
||||
const virtualChannels = [];
|
||||
const inputToGroup = config.audio?.routing?.inputToGroup || {};
|
||||
const channelNames = config.audio?.channelNames?.inputs || {};
|
||||
|
||||
// Trouver tous les canaux physiques routés vers ce groupe
|
||||
for (const [inputChannel, groups] of Object.entries(inputToGroup)) {
|
||||
if (groups.includes(groupId)) {
|
||||
const channelName = channelNames[inputChannel] || `Canal ${inputChannel}`;
|
||||
virtualChannels.push({
|
||||
id: `input-${inputChannel}`,
|
||||
name: channelName,
|
||||
isVirtual: true,
|
||||
audioInput: parseInt(inputChannel, 10)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
token,
|
||||
url: LIVEKIT_URL,
|
||||
roomName,
|
||||
participantIdentity
|
||||
participantIdentity,
|
||||
virtualChannels
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -286,7 +369,7 @@ app.post('/token', async (req, res) => {
|
||||
* GET /health
|
||||
* Health check
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
apiRouter.get('/health', (req, res) => {
|
||||
const isLivekitRunning = livekitProcess !== null;
|
||||
res.json({
|
||||
status: isLivekitRunning ? 'ok' : 'degraded',
|
||||
@@ -295,22 +378,34 @@ app.get('/health', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Monter le router API sous /api ET à la racine (rétrocompatibilité)
|
||||
app.use('/api', apiRouter);
|
||||
app.use(apiRouter); // Routes accessibles aussi sans préfixe /api
|
||||
|
||||
/**
|
||||
* GET /
|
||||
* Info serveur
|
||||
* Info serveur OU client PWA (si build existe)
|
||||
*/
|
||||
app.get('/', (req, res) => {
|
||||
// Si build client existe, servir index.html
|
||||
const indexPath = join(clientDistPath, 'index.html');
|
||||
if (existsSync(indexPath)) {
|
||||
res.sendFile(indexPath);
|
||||
} else {
|
||||
// Sinon, afficher info API
|
||||
res.json({
|
||||
name: 'PTT Live Server',
|
||||
version: '0.1.0',
|
||||
phase: 'Phase 1 - MVP',
|
||||
version: '0.2.0',
|
||||
mode: 'development',
|
||||
endpoints: [
|
||||
'GET /config - Configuration groupes',
|
||||
'GET /groups - Liste des groupes',
|
||||
'POST /token - Générer token client',
|
||||
'GET /health - Health check'
|
||||
'GET /health - Health check',
|
||||
'GET /admin - Interface administration'
|
||||
]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========== Démarrage ==========
|
||||
@@ -338,13 +433,68 @@ async function start() {
|
||||
log('warn', '⚠️ Pour utiliser LiveKit local, définir USE_LOCAL_LIVEKIT=true dans .env');
|
||||
}
|
||||
|
||||
// 2. Démarrer API REST
|
||||
const server = app.listen(SERVER_PORT, SERVER_HOST, () => {
|
||||
// 2. Démarrer API REST (HTTP ou HTTPS selon config)
|
||||
let server;
|
||||
|
||||
if (ENABLE_HTTPS) {
|
||||
// Charger certificats SSL (mêmes que Vite)
|
||||
const certPath = join(__dirname, '..', 'client');
|
||||
const httpsOptions = {
|
||||
key: readFileSync(join(certPath, 'localhost+3-key.pem')),
|
||||
cert: readFileSync(join(certPath, 'localhost+3.pem'))
|
||||
};
|
||||
|
||||
server = https.createServer(httpsOptions, app);
|
||||
server.listen(SERVER_PORT, SERVER_HOST, () => {
|
||||
log('info', `✓ API REST démarrée sur https://${SERVER_HOST}:${SERVER_PORT}`);
|
||||
log('info', '');
|
||||
log('info', 'Serveur prêt !');
|
||||
log('info', `Groupes configurés: ${config.groups.map(g => g.name).join(', ')}`);
|
||||
log('info', '');
|
||||
|
||||
// Afficher URLs d'accès
|
||||
if (networkIP && networkIP !== 'localhost') {
|
||||
const prodUrl = `https://${networkIP}:${SERVER_PORT}`;
|
||||
log('info', '📱 Accès réseau WiFi :');
|
||||
log('info', '');
|
||||
log('info', ` Prod : ${prodUrl}`);
|
||||
log('info', '');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
server = http.createServer(app);
|
||||
server.listen(SERVER_PORT, SERVER_HOST, () => {
|
||||
log('info', `✓ API REST démarrée sur http://${SERVER_HOST}:${SERVER_PORT}`);
|
||||
log('info', '');
|
||||
log('info', 'Serveur prêt !');
|
||||
log('info', `Groupes configurés: ${config.groups.map(g => g.name).join(', ')}`);
|
||||
log('info', '');
|
||||
|
||||
// Afficher URLs d'accès
|
||||
if (networkIP && networkIP !== 'localhost') {
|
||||
const clientUrl = `https://${networkIP}:5173`; // Dev mode
|
||||
const prodUrl = `http://${networkIP}:${SERVER_PORT}`; // Prod mode HTTP
|
||||
|
||||
log('info', '📱 Accès réseau WiFi :');
|
||||
log('info', '');
|
||||
log('info', ` Dev : ${clientUrl}`);
|
||||
log('info', ` Prod : ${prodUrl}`);
|
||||
log('info', '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2.5 Démarrer WebSocket Audio Levels (même port que l'API)
|
||||
const audioLevelsServer = new AudioLevelsServer({ server });
|
||||
audioLevelsServer.start();
|
||||
const wsProtocol = ENABLE_HTTPS ? 'wss' : 'ws';
|
||||
log('info', `✓ WebSocket Audio Levels démarré sur ${wsProtocol}://${SERVER_HOST}:${SERVER_PORT}`);
|
||||
|
||||
// 3. Démarrer Audio Bridge Manager (Phase 2.5)
|
||||
log('info', '');
|
||||
log('info', '🎵 Démarrage Audio Bridge Manager...');
|
||||
await audioBridgeManager.start({ liveKitUrl: LIVEKIT_URL });
|
||||
log('info', '✓ Audio Bridge Manager prêt (mode placeholder)');
|
||||
|
||||
// Gérer erreur port déjà utilisé
|
||||
server.on('error', (error) => {
|
||||
@@ -365,9 +515,15 @@ async function start() {
|
||||
|
||||
// ========== Cleanup ==========
|
||||
|
||||
function cleanup() {
|
||||
async function cleanup() {
|
||||
log('info', 'Arrêt du serveur...');
|
||||
|
||||
// Arrêter l'audio bridge
|
||||
if (audioBridgeManager) {
|
||||
log('info', 'Arrêt Audio Bridge Manager...');
|
||||
await audioBridgeManager.stop();
|
||||
}
|
||||
|
||||
if (livekitProcess) {
|
||||
log('info', 'Arrêt LiveKit Server...');
|
||||
livekitProcess.kill('SIGTERM');
|
||||
|
||||
+2
-2
@@ -19,12 +19,12 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@livekit/rtc-node": "^0.13.28",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.19.2",
|
||||
"livekit-client": "^2.19.0",
|
||||
"livekit-server-sdk": "^2.6.0",
|
||||
"naudiodon": "^2.3.6",
|
||||
"opusscript": "^0.1.1",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"ws": "^8.17.0",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Logger.js
|
||||
* Système de logging centralisé avec niveaux configurables
|
||||
*/
|
||||
|
||||
const LOG_LEVELS = {
|
||||
ERROR: 0,
|
||||
WARN: 1,
|
||||
INFO: 2,
|
||||
DEBUG: 3,
|
||||
TRACE: 4
|
||||
};
|
||||
|
||||
class Logger {
|
||||
constructor(category = 'default', level = 'INFO') {
|
||||
this.category = category;
|
||||
this.level = LOG_LEVELS[level] ?? LOG_LEVELS.INFO;
|
||||
}
|
||||
|
||||
setLevel(level) {
|
||||
this.level = LOG_LEVELS[level] ?? LOG_LEVELS.INFO;
|
||||
}
|
||||
|
||||
error(message, ...args) {
|
||||
if (this.level >= LOG_LEVELS.ERROR) {
|
||||
console.error(`[${this.category}] ❌`, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
warn(message, ...args) {
|
||||
if (this.level >= LOG_LEVELS.WARN) {
|
||||
console.warn(`[${this.category}] ⚠️ `, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
info(message, ...args) {
|
||||
if (this.level >= LOG_LEVELS.INFO) {
|
||||
console.log(`[${this.category}] ℹ️ `, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
success(message, ...args) {
|
||||
if (this.level >= LOG_LEVELS.INFO) {
|
||||
console.log(`[${this.category}] ✓`, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
debug(message, ...args) {
|
||||
if (this.level >= LOG_LEVELS.DEBUG) {
|
||||
console.log(`[${this.category}] 🔍`, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
trace(message, ...args) {
|
||||
if (this.level >= LOG_LEVELS.TRACE) {
|
||||
console.log(`[${this.category}] 🔬`, message, ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration globale depuis env ou config
|
||||
const globalLevel = process.env.LOG_LEVEL || 'INFO';
|
||||
|
||||
// Loggers par catégorie
|
||||
const loggers = new Map();
|
||||
|
||||
export function getLogger(category) {
|
||||
if (!loggers.has(category)) {
|
||||
loggers.set(category, new Logger(category, globalLevel));
|
||||
}
|
||||
return loggers.get(category);
|
||||
}
|
||||
|
||||
export function setGlobalLogLevel(level) {
|
||||
loggers.forEach(logger => logger.setLevel(level));
|
||||
}
|
||||
|
||||
export default { getLogger, setGlobalLogLevel };
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* AudioLevelsServer.js
|
||||
* WebSocket server pour streaming des niveaux audio temps réel
|
||||
*
|
||||
* Permet à l'interface admin de visualiser :
|
||||
* - Niveaux d'entrée physiques (VU-mètres)
|
||||
* - Niveaux de groupes LiveKit
|
||||
* - Niveaux de sortie physiques
|
||||
* - Détection de clipping
|
||||
* - État des routes actives
|
||||
*/
|
||||
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/**
|
||||
* Calcule le niveau RMS d'un buffer audio (dBFS)
|
||||
*/
|
||||
function calculateRMS(buffer) {
|
||||
if (!buffer || buffer.length === 0) return -120; // Silence
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
sum += buffer[i] * buffer[i];
|
||||
}
|
||||
|
||||
const rms = Math.sqrt(sum / buffer.length);
|
||||
|
||||
// Conversion en dBFS (0dBFS = niveau max)
|
||||
if (rms === 0) return -120;
|
||||
const dbFS = 20 * Math.log10(rms);
|
||||
|
||||
return Math.max(-120, Math.min(0, dbFS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le peak d'un buffer audio
|
||||
*/
|
||||
function calculatePeak(buffer) {
|
||||
if (!buffer || buffer.length === 0) return 0;
|
||||
|
||||
let peak = 0;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
peak = Math.max(peak, Math.abs(buffer[i]));
|
||||
}
|
||||
|
||||
return peak;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serveur WebSocket pour monitoring audio
|
||||
*/
|
||||
export class AudioLevelsServer extends EventEmitter {
|
||||
constructor(options = {}) {
|
||||
super();
|
||||
|
||||
this.options = {
|
||||
port: options.port || 3001,
|
||||
server: options.server || null, // Serveur HTTP existant
|
||||
updateRateMs: options.updateRateMs || 50, // 20 fois/sec
|
||||
...options
|
||||
};
|
||||
|
||||
this.wss = null;
|
||||
this.clients = new Set();
|
||||
this.updateInterval = null;
|
||||
|
||||
// Données à broadcaster
|
||||
this.levels = {
|
||||
inputs: {}, // { channelId: { rms: -12, peak: 0.5, clipping: false } }
|
||||
groups: {}, // { groupName: { rms: -8, peak: 0.7, clipping: false } }
|
||||
outputs: {}, // { channelId: { rms: -10, peak: 0.6, clipping: false } }
|
||||
routing: {
|
||||
activeInputs: [],
|
||||
activeGroups: [],
|
||||
activeOutputs: []
|
||||
}
|
||||
};
|
||||
|
||||
this.stats = {
|
||||
connectedClients: 0,
|
||||
messagesSent: 0,
|
||||
errors: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre le serveur WebSocket
|
||||
*/
|
||||
start() {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// 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._handleNewConnection(ws, req);
|
||||
});
|
||||
|
||||
this.wss.on('error', (error) => {
|
||||
console.error('Erreur WebSocket server:', error);
|
||||
this.stats.errors++;
|
||||
this.emit('error', error);
|
||||
});
|
||||
|
||||
// Démarrage du broadcast périodique
|
||||
this._startBroadcast();
|
||||
|
||||
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');
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère une nouvelle connexion client
|
||||
*/
|
||||
_handleNewConnection(ws, req) {
|
||||
const clientId = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
|
||||
console.log(`Nouveau client audio-levels: ${clientId}`);
|
||||
|
||||
this.clients.add(ws);
|
||||
this.stats.connectedClients = this.clients.size;
|
||||
|
||||
// Envoi des données actuelles immédiatement
|
||||
this._sendToClient(ws, {
|
||||
type: 'initial',
|
||||
data: this.levels
|
||||
});
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
this._handleClientMessage(ws, data);
|
||||
} catch (error) {
|
||||
console.error('Erreur parsing message client:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log(`Client déconnecté: ${clientId}`);
|
||||
this.clients.delete(ws);
|
||||
this.stats.connectedClients = this.clients.size;
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error(`Erreur client ${clientId}:`, error);
|
||||
this.clients.delete(ws);
|
||||
this.stats.connectedClients = this.clients.size;
|
||||
});
|
||||
|
||||
this.emit('clientConnected', { clientId, totalClients: this.clients.size });
|
||||
}
|
||||
|
||||
/**
|
||||
* Gère les messages entrants des clients
|
||||
*/
|
||||
_handleClientMessage(ws, message) {
|
||||
switch (message.type) {
|
||||
case 'ping':
|
||||
this._sendToClient(ws, { type: 'pong', timestamp: Date.now() });
|
||||
break;
|
||||
|
||||
case 'setUpdateRate':
|
||||
// Permet au client de modifier le taux de rafraîchissement
|
||||
if (message.rateMs >= 20 && message.rateMs <= 1000) {
|
||||
this._restartBroadcast(message.rateMs);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Message client inconnu:', message.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre le broadcast périodique
|
||||
*/
|
||||
_startBroadcast() {
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
}
|
||||
|
||||
this.updateInterval = setInterval(() => {
|
||||
this._broadcastLevels();
|
||||
}, this.options.updateRateMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redémarre le broadcast avec un nouveau taux
|
||||
*/
|
||||
_restartBroadcast(newRateMs) {
|
||||
this.options.updateRateMs = newRateMs;
|
||||
this._startBroadcast();
|
||||
console.log(`Taux de rafraîchissement modifié: ${newRateMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast les niveaux à tous les clients connectés
|
||||
*/
|
||||
_broadcastLevels() {
|
||||
if (this.clients.size === 0) return;
|
||||
|
||||
const message = {
|
||||
type: 'levels',
|
||||
timestamp: Date.now(),
|
||||
data: this.levels
|
||||
};
|
||||
|
||||
this._broadcast(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un message à tous les clients
|
||||
*/
|
||||
_broadcast(message) {
|
||||
const payload = JSON.stringify(message);
|
||||
|
||||
this.clients.forEach(ws => {
|
||||
if (ws.readyState === 1) { // OPEN
|
||||
try {
|
||||
ws.send(payload);
|
||||
this.stats.messagesSent++;
|
||||
} catch (error) {
|
||||
console.error('Erreur envoi message:', error);
|
||||
this.stats.errors++;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un message à un client spécifique
|
||||
*/
|
||||
_sendToClient(ws, message) {
|
||||
if (ws.readyState === 1) {
|
||||
try {
|
||||
ws.send(JSON.stringify(message));
|
||||
this.stats.messagesSent++;
|
||||
} catch (error) {
|
||||
console.error('Erreur envoi message client:', error);
|
||||
this.stats.errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour les niveaux d'entrée
|
||||
* Appelé par le GroupAudioRouter après processInputsToGroups()
|
||||
*/
|
||||
updateInputLevels(inputBuffers) {
|
||||
inputBuffers.forEach((buffer, channelId) => {
|
||||
const rms = calculateRMS(buffer);
|
||||
const peak = calculatePeak(buffer);
|
||||
const clipping = peak >= 0.99;
|
||||
|
||||
this.levels.inputs[channelId] = { rms, peak, clipping };
|
||||
});
|
||||
|
||||
this.levels.routing.activeInputs = Array.from(inputBuffers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour les niveaux de groupe
|
||||
* Appelé par le GroupAudioRouter après processInputsToGroups()
|
||||
*/
|
||||
updateGroupLevels(groupBuffers) {
|
||||
groupBuffers.forEach((buffer, groupName) => {
|
||||
const rms = calculateRMS(buffer);
|
||||
const peak = calculatePeak(buffer);
|
||||
const clipping = peak >= 0.99;
|
||||
|
||||
this.levels.groups[groupName] = { rms, peak, clipping };
|
||||
});
|
||||
|
||||
this.levels.routing.activeGroups = Array.from(groupBuffers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour les niveaux de sortie
|
||||
* Appelé par le GroupAudioRouter après processGroupsToOutputs()
|
||||
*/
|
||||
updateOutputLevels(outputBuffers) {
|
||||
outputBuffers.forEach((buffer, channelId) => {
|
||||
const rms = calculateRMS(buffer);
|
||||
const peak = calculatePeak(buffer);
|
||||
const clipping = peak >= 0.99;
|
||||
|
||||
this.levels.outputs[channelId] = { rms, peak, clipping };
|
||||
});
|
||||
|
||||
this.levels.routing.activeOutputs = Array.from(outputBuffers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialise tous les niveaux (silence)
|
||||
*/
|
||||
resetLevels() {
|
||||
this.levels = {
|
||||
inputs: {},
|
||||
groups: {},
|
||||
outputs: {},
|
||||
routing: {
|
||||
activeInputs: [],
|
||||
activeGroups: [],
|
||||
activeOutputs: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les statistiques
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
...this.stats,
|
||||
updateRateMs: this.options.updateRateMs,
|
||||
port: this.options.port
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrête le serveur
|
||||
*/
|
||||
async stop() {
|
||||
console.log('Arrêt AudioLevelsServer...');
|
||||
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
this.updateInterval = null;
|
||||
}
|
||||
|
||||
if (this.wss) {
|
||||
// Ferme toutes les connexions clients
|
||||
this.clients.forEach(ws => {
|
||||
ws.close(1000, 'Server shutdown');
|
||||
});
|
||||
|
||||
this.clients.clear();
|
||||
|
||||
// Ferme le serveur
|
||||
await new Promise((resolve) => {
|
||||
this.wss.close(() => {
|
||||
console.log('WebSocket AudioLevels arrêté');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
this.wss = null;
|
||||
}
|
||||
|
||||
this.emit('stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Détruit le serveur
|
||||
*/
|
||||
async destroy() {
|
||||
await this.stop();
|
||||
this.removeAllListeners();
|
||||
console.log('AudioLevelsServer détruit');
|
||||
}
|
||||
}
|
||||
|
||||
export default AudioLevelsServer;
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
|
||||
# PTT Live - Affichage QR Code
|
||||
# Génère et affiche le QR code pour connexion smartphone
|
||||
|
||||
set -e
|
||||
|
||||
# Couleurs
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Détection IP réseau
|
||||
get_network_ip() {
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS
|
||||
ifconfig | grep "inet " | grep -v 127.0.0.1 | awk '{print $2}' | head -n 1
|
||||
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
# Linux
|
||||
hostname -I | awk '{print $1}'
|
||||
else
|
||||
echo "localhost"
|
||||
fi
|
||||
}
|
||||
|
||||
NETWORK_IP=$(get_network_ip)
|
||||
|
||||
# Déterminer l'URL selon mode dev ou prod
|
||||
if [ -d "client/dist" ] && [ "$1" != "--dev" ]; then
|
||||
# Mode production (HTTPS)
|
||||
URL="https://${NETWORK_IP}:3000"
|
||||
MODE="production"
|
||||
else
|
||||
# Mode dev (HTTPS)
|
||||
URL="https://${NETWORK_IP}:5173"
|
||||
MODE="dev"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}=================================="
|
||||
echo "📱 QR Code PTT Live ($MODE)"
|
||||
echo "==================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Générer le QR code avec le package installé dans server/
|
||||
(cd server && node -e "
|
||||
const qrcode = require('qrcode-terminal');
|
||||
qrcode.generate('$URL', { small: true });
|
||||
")
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}🔗 URL : $URL${NC}"
|
||||
echo ""
|
||||
echo "📱 Scannez ce QR code depuis votre smartphone"
|
||||
echo " pour vous connecter instantanément"
|
||||
echo ""
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/bin/bash
|
||||
|
||||
# PTT Live - Script de démarrage unifié
|
||||
# Lance le serveur et le client en mode production
|
||||
|
||||
set -e
|
||||
|
||||
# Couleurs
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Détection IP réseau
|
||||
get_network_ip() {
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS
|
||||
ifconfig | grep "inet " | grep -v 127.0.0.1 | awk '{print $2}' | head -n 1
|
||||
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
# Linux
|
||||
hostname -I | awk '{print $1}'
|
||||
else
|
||||
echo "localhost"
|
||||
fi
|
||||
}
|
||||
|
||||
NETWORK_IP=$(get_network_ip)
|
||||
|
||||
echo -e "${BLUE}=================================="
|
||||
echo "🚀 PTT Live - Démarrage"
|
||||
echo "==================================${NC}"
|
||||
echo ""
|
||||
|
||||
echo -e "${GREEN}📡 IP réseau détectée : ${NETWORK_IP}${NC}"
|
||||
echo ""
|
||||
|
||||
# Vérifier que les dépendances sont installées
|
||||
if [ ! -d "server/node_modules" ]; then
|
||||
echo -e "${RED}❌ Dépendances serveur manquantes${NC}"
|
||||
echo " Exécutez d'abord : ./install/macos.sh (ou linux.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "client/node_modules" ]; then
|
||||
echo -e "${RED}❌ Dépendances client manquantes${NC}"
|
||||
echo " Exécutez d'abord : ./install/macos.sh (ou linux.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Créer fichier PID pour cleanup
|
||||
PID_FILE="/tmp/ptt-live.pid"
|
||||
|
||||
# Fonction cleanup
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}⏹ Arrêt PTT Live...${NC}"
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
while read -r pid; do
|
||||
if ps -p "$pid" > /dev/null 2>&1; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done < "$PID_FILE"
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Arrêté${NC}"
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGINT SIGTERM EXIT
|
||||
|
||||
# Afficher le QR code AVANT de lancer le serveur
|
||||
if [ "$1" == "--dev" ]; then
|
||||
./show-qr.sh --dev
|
||||
else
|
||||
./show-qr.sh
|
||||
fi
|
||||
|
||||
# Démarrer le serveur (silencieux, logs dans fichier)
|
||||
echo -e "${BLUE}🔧 Démarrage serveur...${NC}"
|
||||
echo ""
|
||||
|
||||
cd server
|
||||
|
||||
# En mode production (pas --dev), activer HTTPS
|
||||
if [ "$1" != "--dev" ]; then
|
||||
export ENABLE_HTTPS=true
|
||||
fi
|
||||
|
||||
# Lancer le serveur en background silencieux
|
||||
npm start > ../server.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
echo "$SERVER_PID" > "$PID_FILE"
|
||||
cd ..
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Attendre que le serveur soit prêt
|
||||
echo ""
|
||||
echo -e "${YELLOW}⏳ Attente démarrage serveur...${NC}"
|
||||
|
||||
# Déterminer le protocole selon le mode
|
||||
if [ "$1" != "--dev" ]; then
|
||||
HEALTH_URL="https://localhost:3000/health"
|
||||
else
|
||||
HEALTH_URL="http://localhost:3000/health"
|
||||
fi
|
||||
|
||||
for i in {1..30}; do
|
||||
if curl -kssf "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ Serveur prêt${NC}"
|
||||
break
|
||||
fi
|
||||
|
||||
if [ $i -eq 30 ]; then
|
||||
echo -e "${RED}❌ Timeout : le serveur n'a pas démarré${NC}"
|
||||
echo " Consultez server.log pour plus de détails"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# Build client si pas déjà fait ou mode dev
|
||||
if [ "$1" == "--dev" ]; then
|
||||
echo -e "${BLUE}🎨 Démarrage client (dev)...${NC}"
|
||||
cd client
|
||||
npm run dev &
|
||||
CLIENT_PID=$!
|
||||
echo "$CLIENT_PID" >> "$PID_FILE"
|
||||
cd ..
|
||||
|
||||
echo -e "${GREEN}✓ Client dev démarré${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}=================================="
|
||||
echo "✅ PTT Live démarré (mode dev)"
|
||||
echo "==================================${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}🌐 ACCÈS CLIENT (HTTPS) :${NC}"
|
||||
echo -e "${GREEN} 👉 Local : https://localhost:5173${NC}"
|
||||
echo -e "${GREEN} 👉 Réseau : https://${NETWORK_IP}:5173${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚠️ Acceptez le certificat auto-signé dans votre navigateur${NC}"
|
||||
echo -e "${YELLOW} (Cliquez sur 'Avancé' puis 'Continuer')${NC}"
|
||||
echo ""
|
||||
echo "📊 API serveur (HTTP uniquement) : http://localhost:3000"
|
||||
echo "🎛️ Interface admin : https://localhost:5173/admin"
|
||||
echo ""
|
||||
echo "📝 Logs serveur : tail -f server.log"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Appuyez sur Ctrl+C pour arrêter${NC}"
|
||||
echo ""
|
||||
|
||||
# Attendre indéfiniment
|
||||
wait
|
||||
|
||||
else
|
||||
# Mode production : build et serve
|
||||
echo -e "${BLUE}🎨 Build client production...${NC}"
|
||||
cd client
|
||||
|
||||
if [ ! -d "dist" ] || [ "$1" == "--rebuild" ]; then
|
||||
npm run build
|
||||
echo -e "${GREEN}✓ Client buildé${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ Build existant utilisé (--rebuild pour forcer)${NC}"
|
||||
fi
|
||||
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}=================================="
|
||||
echo "✅ PTT Live démarré (production)"
|
||||
echo "==================================${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}🌐 ACCÈS CLIENT (HTTPS) :${NC}"
|
||||
echo -e "${GREEN} 👉 Local : https://localhost:3000${NC}"
|
||||
echo -e "${GREEN} 👉 Réseau : https://${NETWORK_IP}:3000${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚠️ Acceptez le certificat auto-signé dans votre navigateur${NC}"
|
||||
echo -e "${YELLOW} (Cliquez sur 'Avancé' puis 'Continuer')${NC}"
|
||||
echo ""
|
||||
echo "🎛️ Interface admin : https://localhost:3000/admin"
|
||||
echo "📊 API serveur : https://localhost:3000/api"
|
||||
echo ""
|
||||
echo "📝 Logs serveur : tail -f server.log"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Appuyez sur Ctrl+C pour arrêter${NC}"
|
||||
echo ""
|
||||
|
||||
# Attendre indéfiniment
|
||||
wait
|
||||
fi
|
||||
Reference in New Issue
Block a user