Compare commits
18 Commits
4b394f939d
...
49df6bd44c
| Author | SHA1 | Date | |
|---|---|---|---|
| 49df6bd44c | |||
| 0b0e998b55 | |||
| 78e9a32e12 | |||
| 3181c62e57 | |||
| c863f045ae | |||
| ed22e6d878 | |||
| 24edf36d3c | |||
| efd697a9d3 | |||
| 8bae2f03bf | |||
| 3afb82355e | |||
| 652384708e | |||
| 0640a9f0b6 | |||
| 5e74f0dcdf | |||
| 47db08fff7 | |||
| fca3c82ad7 | |||
| 08426970b2 | |||
| a65296221a | |||
| 55c2e41107 |
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Binaries
|
||||||
|
server/bin/livekit-server
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Debug logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
# CLAUDE.md - Documentation Développement PTT Live
|
||||||
|
|
||||||
|
## Vue d'ensemble du projet
|
||||||
|
|
||||||
|
**PTT Live** est un système d'intercom professionnel WebRTC pour techniciens événementiels. Les utilisateurs communiquent via smartphone (PWA) en WiFi, le serveur fait le pont avec l'installation audio professionnelle.
|
||||||
|
|
||||||
|
## Contexte de développement
|
||||||
|
|
||||||
|
### Environnement
|
||||||
|
- **Plateforme principale** : macOS (tests et développement)
|
||||||
|
- **Compatibilité** : Linux (implémentation rapide après macOS)
|
||||||
|
- **Infrastructure audio** : Carte son multicanaux + Dante disponible
|
||||||
|
- **Réseau** : WiFi dédié
|
||||||
|
- **Déploiement** : Auto-hébergé uniquement
|
||||||
|
- **Licence** : Open Source
|
||||||
|
|
||||||
|
### Développeur
|
||||||
|
- Développeur solo
|
||||||
|
- Gestion complète par Claude (Node.js, React, WebRTC, audio temps réel)
|
||||||
|
|
||||||
|
## Architecture technique
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
```
|
||||||
|
SERVEUR (Node.js)
|
||||||
|
├── LiveKit Server (binaire Go, SFU WebRTC)
|
||||||
|
├── Bridge Audio (Node.js)
|
||||||
|
│ ├── CoreAudio (macOS natif)
|
||||||
|
│ ├── JACK (Linux/macOS)
|
||||||
|
│ ├── libopus (transcodage)
|
||||||
|
│ └── Jitter buffer
|
||||||
|
├── API REST (Express)
|
||||||
|
└── Configuration (YAML)
|
||||||
|
|
||||||
|
CLIENT (PWA React)
|
||||||
|
├── React + Vite
|
||||||
|
├── livekit-client SDK
|
||||||
|
├── Web Push API
|
||||||
|
└── Service Worker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flux audio
|
||||||
|
```
|
||||||
|
[Carte son/Dante] → CoreAudio/JACK → Opus → LiveKit → WebRTC → Client PWA
|
||||||
|
[Client PWA] → WebRTC → LiveKit → Opus → CoreAudio/JACK → [Carte son/Dante]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phases de développement
|
||||||
|
|
||||||
|
### PHASE 1 — Fondations (MVP)
|
||||||
|
**Objectif** : Valider la faisabilité technique complète
|
||||||
|
|
||||||
|
#### 1.1 Infrastructure serveur
|
||||||
|
- Installation LiveKit Server (binaire)
|
||||||
|
- Configuration basique
|
||||||
|
- API REST minimal
|
||||||
|
|
||||||
|
#### 1.2 Bridge audio macOS
|
||||||
|
- Détection CoreAudio
|
||||||
|
- Capture/lecture audio
|
||||||
|
- Encodage/décodage Opus
|
||||||
|
- Connexion LiveKit
|
||||||
|
|
||||||
|
#### 1.3 PWA React
|
||||||
|
- Interface PTT basique
|
||||||
|
- Un groupe, connexion simple
|
||||||
|
- Audio WebRTC bidirectionnel
|
||||||
|
|
||||||
|
#### 1.4 Tests validation
|
||||||
|
- Latence end-to-end < 150ms
|
||||||
|
- 2-4 clients simultanés
|
||||||
|
- Stabilité WiFi
|
||||||
|
|
||||||
|
### PHASE 2 — Fonctionnalités professionnelles
|
||||||
|
**Objectif** : Système utilisable en production
|
||||||
|
|
||||||
|
#### 2.1 Groupes et routing
|
||||||
|
- Configuration YAML (groupes, canaux)
|
||||||
|
- Routing audio dynamique
|
||||||
|
- Switch groupe côté client
|
||||||
|
|
||||||
|
#### 2.2 Modes PTT avancés
|
||||||
|
- PTT lock (appui 3s)
|
||||||
|
- Mode continu (toggle)
|
||||||
|
- Feedback visuel/vibration
|
||||||
|
|
||||||
|
#### 2.3 Interface admin
|
||||||
|
- Gestion groupes/utilisateurs
|
||||||
|
- Monitoring connexions
|
||||||
|
- Logs audio
|
||||||
|
|
||||||
|
#### 2.4 Notifications
|
||||||
|
- Web Push (appels privés)
|
||||||
|
- PWA manifest complet
|
||||||
|
- Support iOS
|
||||||
|
|
||||||
|
### PHASE 3 — Intégrations audio pro
|
||||||
|
**Objectif** : Compatibilité équipements événementiels
|
||||||
|
|
||||||
|
#### 3.1 Support Linux
|
||||||
|
- Backend JACK/PipeWire
|
||||||
|
- Script installation
|
||||||
|
- Tests compatibilité
|
||||||
|
|
||||||
|
#### 3.2 Dante
|
||||||
|
- DVS macOS/Windows
|
||||||
|
- Routing JACK ↔ Dante
|
||||||
|
- Documentation setup
|
||||||
|
|
||||||
|
#### 3.3 AES67
|
||||||
|
- RTP multicast (Linux)
|
||||||
|
- PTP sync
|
||||||
|
- Interop Dante
|
||||||
|
|
||||||
|
#### 3.4 Production
|
||||||
|
- Scripts install multi-OS
|
||||||
|
- Tests charge (30+ clients)
|
||||||
|
- Documentation déploiement
|
||||||
|
|
||||||
|
## Décisions techniques
|
||||||
|
|
||||||
|
### Pourquoi ces choix ?
|
||||||
|
|
||||||
|
#### LiveKit vs alternatives
|
||||||
|
- **Janus/Mediasoup** : trop bas niveau, complexité inutile
|
||||||
|
- **LiveKit** : SFU prêt, SDK client mature, self-hosted
|
||||||
|
|
||||||
|
#### Pas de Docker
|
||||||
|
- Latence audio critique (< 10ms jitter)
|
||||||
|
- JACK/CoreAudio nécessitent accès direct hardware
|
||||||
|
- Binaires natifs = performances optimales
|
||||||
|
|
||||||
|
#### PWA plutôt qu'app native
|
||||||
|
- Déploiement instantané (pas de stores)
|
||||||
|
- Cross-platform unifié
|
||||||
|
- Web Push suffisant pour notifications
|
||||||
|
|
||||||
|
#### Opus codec
|
||||||
|
- Standard WebRTC
|
||||||
|
- Faible latence (20-60ms frame)
|
||||||
|
- Qualité audio configurable selon besoin :
|
||||||
|
- **Voix économique** : 32-64 kbps (WiFi limité)
|
||||||
|
- **Voix standard** : 96 kbps (défaut, bon compromis)
|
||||||
|
- **Voix HD** : 128-192 kbps (qualité maximale)
|
||||||
|
- **Musique/monitoring** : 256-320 kbps (si besoin événementiel)
|
||||||
|
- Configuration par groupe ou globale (YAML)
|
||||||
|
|
||||||
|
## Structure du code
|
||||||
|
|
||||||
|
```
|
||||||
|
PTT Live/
|
||||||
|
├── server/
|
||||||
|
│ ├── index.js # Point d'entrée, lance LiveKit + Bridge
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── config.yaml # Groupes, canaux, routes
|
||||||
|
│ ├── bridge/
|
||||||
|
│ │ ├── AudioBridge.js # Classe principale, détection backend
|
||||||
|
│ │ ├── OpusCodec.js # Wrapper libopus
|
||||||
|
│ │ ├── JitterBuffer.js # Buffer 40ms
|
||||||
|
│ │ ├── LiveKitClient.js # Connexion SFU
|
||||||
|
│ │ └── backends/
|
||||||
|
│ │ ├── CoreAudioBackend.js # macOS natif
|
||||||
|
│ │ ├── JACKBackend.js # Linux/macOS
|
||||||
|
│ │ ├── PipeWireBackend.js # Linux moderne
|
||||||
|
│ │ └── WASAPIBackend.js # Windows (futur)
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── routes.js # REST API
|
||||||
|
│ │ └── admin.js # Interface admin
|
||||||
|
│ └── bin/
|
||||||
|
│ └── livekit-server # Binaire téléchargé à l'install
|
||||||
|
│
|
||||||
|
├── client/
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.js
|
||||||
|
│ ├── public/
|
||||||
|
│ │ ├── manifest.json # PWA
|
||||||
|
│ │ └── sw.js # Service Worker
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.jsx
|
||||||
|
│ ├── App.jsx
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── PTTButton.jsx # Bouton principal
|
||||||
|
│ │ ├── GroupSelector.jsx
|
||||||
|
│ │ ├── UserList.jsx
|
||||||
|
│ │ └── AudioIndicator.jsx
|
||||||
|
│ ├── hooks/
|
||||||
|
│ │ ├── useLiveKit.js # WebRTC logic
|
||||||
|
│ │ ├── usePTT.js # Modes PTT
|
||||||
|
│ │ └── usePush.js # Notifications
|
||||||
|
│ └── utils/
|
||||||
|
│ └── audio.js # Helpers WebRTC
|
||||||
|
│
|
||||||
|
├── install/
|
||||||
|
│ ├── macos.sh # Installe deps + binaire LiveKit
|
||||||
|
│ ├── linux.sh
|
||||||
|
│ └── windows.ps1
|
||||||
|
│
|
||||||
|
├── CLAUDE.md # Ce fichier
|
||||||
|
├── TODO.md # Tâches actives
|
||||||
|
└── README.md # Doc utilisateur
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commandes de développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Installation initiale
|
||||||
|
./install/macos.sh
|
||||||
|
|
||||||
|
# Serveur (dev)
|
||||||
|
cd server
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Client (dev)
|
||||||
|
cd client
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Production
|
||||||
|
cd server
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests et validation
|
||||||
|
|
||||||
|
### Métriques critiques
|
||||||
|
- **Latence end-to-end** : < 150ms (WiFi local)
|
||||||
|
- **Jitter buffer** : 40ms cible
|
||||||
|
- **Qualité audio** : Opus configurable (32-320 kbps), 48kHz
|
||||||
|
- Défaut : 96kbps (voix standard)
|
||||||
|
- Configurable par groupe dans config.yaml
|
||||||
|
- **Clients simultanés** : 30+ (Phase 3)
|
||||||
|
|
||||||
|
### Scénarios de test Phase 1
|
||||||
|
1. ✅ 2 clients, PTT basique, même groupe
|
||||||
|
2. ✅ Latence < 150ms mesurée
|
||||||
|
3. ✅ Pas de coupures sur 5min
|
||||||
|
4. ✅ Reconnexion après perte WiFi
|
||||||
|
|
||||||
|
## Points d'attention
|
||||||
|
|
||||||
|
### macOS spécifique
|
||||||
|
- CoreAudio : permissions microphone (Info.plist si empaquété)
|
||||||
|
- Pas de JACK requis pour Phase 1 (natif CoreAudio suffit)
|
||||||
|
- JACK optionnel pour Dante/AES67
|
||||||
|
|
||||||
|
### Dante
|
||||||
|
- DVS macOS supporté officiellement
|
||||||
|
- Routing DVS → JACK → Bridge (Phase 3)
|
||||||
|
- Licence ~300€ (à budgéter)
|
||||||
|
|
||||||
|
### iOS PWA
|
||||||
|
- Support depuis iOS 16.4+
|
||||||
|
- **Impératif** : installer sur écran d'accueil pour notifications
|
||||||
|
- Message d'onboarding à implémenter
|
||||||
|
|
||||||
|
### Réseau
|
||||||
|
- QoS/DSCP recommandé pour flux audio
|
||||||
|
- VLAN dédié si possible
|
||||||
|
- Tests charge WiFi en Phase 3
|
||||||
|
|
||||||
|
## Ressources et dépendances
|
||||||
|
|
||||||
|
### NPM packages serveur
|
||||||
|
- `livekit-server-sdk` : connexion SFU
|
||||||
|
- `@opus/opusscript` ou `node-opus` : codec
|
||||||
|
- `express` : API REST
|
||||||
|
- `yaml` : config
|
||||||
|
- `node-coreaudio` : backend macOS (natif addon)
|
||||||
|
- `jack-connector` : JACK (Phase 3)
|
||||||
|
|
||||||
|
### NPM packages client
|
||||||
|
- `react` + `react-dom`
|
||||||
|
- `livekit-client` : WebRTC SDK
|
||||||
|
- `vite` : bundler
|
||||||
|
- `workbox` : Service Worker PWA
|
||||||
|
|
||||||
|
### Binaires externes
|
||||||
|
- `livekit-server` (Go) : téléchargé par script install
|
||||||
|
- JACK (optionnel macOS, requis Linux Phase 3)
|
||||||
|
|
||||||
|
## Workflow Git
|
||||||
|
|
||||||
|
### ⚠️ IMPORTANT : Commits et validation
|
||||||
|
|
||||||
|
**Règle stricte** : Commiter après chaque modification significative ou fonctionnalité complétée.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Branches
|
||||||
|
main # Production stable
|
||||||
|
develop # Intégration continue
|
||||||
|
feature/xxx # Fonctionnalités
|
||||||
|
fix/xxx # Corrections
|
||||||
|
|
||||||
|
# Convention commits
|
||||||
|
feat: description
|
||||||
|
fix: description
|
||||||
|
docs: description
|
||||||
|
refactor: description
|
||||||
|
test: description
|
||||||
|
```
|
||||||
|
|
||||||
|
### Processus de développement
|
||||||
|
|
||||||
|
1. **Avant de coder** : Cocher la tâche en cours dans [TODO.md](TODO.md) (mettre `[x]`)
|
||||||
|
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
|
||||||
|
|
||||||
|
**Exemple workflow** :
|
||||||
|
```bash
|
||||||
|
# 1. Tâche complétée
|
||||||
|
# 2. Valider dans TODO.md
|
||||||
|
# 3. Commit
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: implement CoreAudio backend for macOS"
|
||||||
|
|
||||||
|
# 4. Passer à la tâche suivante
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prochaines étapes
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Configuration Réseau - Connexion Multi-Appareils
|
||||||
|
|
||||||
|
## Problème résolu
|
||||||
|
|
||||||
|
Le serveur retournait précédemment `ws://localhost:7880` aux clients, ce qui empêchait les connexions depuis d'autres appareils sur le réseau.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Le serveur détecte maintenant automatiquement l'IP réseau locale et retourne l'URL LiveKit correcte aux clients.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Fichier `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# AUTO = détection automatique de l'IP réseau
|
||||||
|
LIVEKIT_URL=AUTO
|
||||||
|
|
||||||
|
# OU spécifier manuellement l'IP du serveur
|
||||||
|
# LIVEKIT_URL=ws://192.168.1.100:7880
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mode AUTO (recommandé)
|
||||||
|
|
||||||
|
Le mode `AUTO` détecte automatiquement l'IP réseau :
|
||||||
|
- **macOS** : WiFi (en0) ou Ethernet (en1)
|
||||||
|
- **Linux** : eth0, wlan0, ou première interface réseau
|
||||||
|
|
||||||
|
L'IP détectée est affichée au démarrage :
|
||||||
|
```
|
||||||
|
📡 IP réseau détectée : 10.1.1.111
|
||||||
|
🔗 URL LiveKit clients : ws://10.1.1.111:7880
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mode manuel
|
||||||
|
|
||||||
|
Si la détection automatique ne fonctionne pas, spécifiez l'IP manuellement :
|
||||||
|
|
||||||
|
1. Trouvez l'IP du serveur :
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
ifconfig | grep "inet " | grep -v 127.0.0.1
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Modifiez `.env` :
|
||||||
|
```bash
|
||||||
|
LIVEKIT_URL=ws://VOTRE_IP:7880
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test connexion multi-appareils
|
||||||
|
|
||||||
|
### 1. Démarrer le serveur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Notez l'IP affichée (ex: `10.1.1.111`)
|
||||||
|
|
||||||
|
### 2. Accéder depuis un autre appareil
|
||||||
|
|
||||||
|
#### Sur smartphone (même WiFi)
|
||||||
|
|
||||||
|
1. Ouvrir navigateur
|
||||||
|
2. Aller sur : `http://10.1.1.111:3000` (remplacer par l'IP serveur)
|
||||||
|
3. Le client PWA va automatiquement recevoir l'URL LiveKit correcte
|
||||||
|
|
||||||
|
#### Depuis un autre ordinateur
|
||||||
|
|
||||||
|
Même procédure : `http://IP_SERVEUR:3000`
|
||||||
|
|
||||||
|
## Ports utilisés
|
||||||
|
|
||||||
|
- **3000** : API REST (serveur Express)
|
||||||
|
- **7880** : LiveKit WebSocket (connexions WebRTC)
|
||||||
|
- **7882** : LiveKit UDP (trafic RTP audio/vidéo)
|
||||||
|
|
||||||
|
## Firewall et réseau
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
Autorisez Node.js et LiveKit dans les préférences réseau si demandé.
|
||||||
|
|
||||||
|
### Configuration WiFi recommandée
|
||||||
|
|
||||||
|
- **QoS activée** : Priorisation trafic audio/vidéo
|
||||||
|
- **Isolation client désactivée** : Permet communication entre appareils
|
||||||
|
- **Band 5GHz** : Meilleure latence que 2.4GHz
|
||||||
|
|
||||||
|
## Dépannage
|
||||||
|
|
||||||
|
### Erreur "bind: address already in use"
|
||||||
|
|
||||||
|
Un autre processus utilise le port 7880 ou 7882 :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Trouver le processus
|
||||||
|
lsof -i :7880
|
||||||
|
lsof -i :7882
|
||||||
|
|
||||||
|
# Tuer le processus si nécessaire
|
||||||
|
kill -9 PID
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client ne peut pas se connecter
|
||||||
|
|
||||||
|
1. Vérifiez que le serveur tourne :
|
||||||
|
```bash
|
||||||
|
curl http://IP_SERVEUR:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Vérifiez l'URL LiveKit retournée :
|
||||||
|
```bash
|
||||||
|
curl http://IP_SERVEUR:3000/config
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Testez la connexion LiveKit :
|
||||||
|
```bash
|
||||||
|
# Depuis un navigateur sur le client
|
||||||
|
# Console DevTools :
|
||||||
|
const ws = new WebSocket('ws://IP_SERVEUR:7880');
|
||||||
|
ws.onopen = () => console.log('LiveKit accessible !');
|
||||||
|
ws.onerror = (e) => console.error('Erreur:', e);
|
||||||
|
```
|
||||||
|
|
||||||
|
### IP détectée incorrecte
|
||||||
|
|
||||||
|
Si le serveur détecte la mauvaise IP (ex: VPN, Docker, etc.) :
|
||||||
|
|
||||||
|
1. Utilisez le mode manuel dans `.env`
|
||||||
|
2. Ou modifiez la priorité des interfaces dans `server/index.js` (ligne 28)
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
⚠️ **En production**, utilisez HTTPS/WSS :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env
|
||||||
|
LIVEKIT_URL=wss://votre-domaine.com:7880
|
||||||
|
```
|
||||||
|
|
||||||
|
Et configurez des certificats SSL pour LiveKit et Express.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Dernière mise à jour** : 2026-05-22
|
||||||
@@ -1,8 +1,104 @@
|
|||||||
# Résumé Projet - Intercom WebRTC Événementiel
|
# PTT Live
|
||||||
|
|
||||||
## Concept
|
**Système d'intercom professionnel WebRTC pour techniciens événementiels**
|
||||||
|
|
||||||
Système d'intercom professionnel pour techniciens événementiels. Les utilisateurs communiquent via leur smartphone (navigateur web / PWA) en WiFi. Le serveur fait le pont avec l'installation audio professionnelle existante.
|
Communiquez via smartphone (PWA) en WiFi, le serveur fait le pont avec l'installation audio professionnelle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Démarrage rapide
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
|
||||||
|
- Node.js 20+ ([télécharger](https://nodejs.org))
|
||||||
|
- Compte LiveKit Cloud gratuit ([créer ici](https://cloud.livekit.io))
|
||||||
|
|
||||||
|
### Installation (5 minutes)
|
||||||
|
|
||||||
|
1. **Installer les dépendances**
|
||||||
|
```bash
|
||||||
|
cd server && npm install
|
||||||
|
cd ../client && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configurer LiveKit Cloud**
|
||||||
|
|
||||||
|
- Créer compte sur https://cloud.livekit.io
|
||||||
|
- Créer un projet
|
||||||
|
- Copier vos clés API
|
||||||
|
|
||||||
|
Créer `server/.env` :
|
||||||
|
```bash
|
||||||
|
LIVEKIT_URL=wss://votre-projet.livekit.cloud
|
||||||
|
LIVEKIT_API_KEY=APIxxxxxxxxxx
|
||||||
|
LIVEKIT_API_SECRET=xxxxxxxxxxxxxxxxxx
|
||||||
|
USE_LOCAL_LIVEKIT=false
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Démarrer**
|
||||||
|
|
||||||
|
Terminal 1 :
|
||||||
|
```bash
|
||||||
|
cd server && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal 2 :
|
||||||
|
```bash
|
||||||
|
cd client && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Tester** : http://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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Utilisation
|
||||||
|
|
||||||
|
- **Bouton PTT** : Maintenir pour parler, relâcher pour écouter
|
||||||
|
- **Desktop** : Clic maintenu / **Mobile** : Appui tactile maintenu
|
||||||
|
- **Feedback** : Vibration + couleur rouge quand vous parlez
|
||||||
|
- **VU-mètre** : Visualisation niveau audio en temps réel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Dépannage : "Connexion impossible"
|
||||||
|
|
||||||
|
**Cause** : Clés LiveKit non configurées ou invalides.
|
||||||
|
|
||||||
|
**Solution** :
|
||||||
|
1. Vérifier que `server/.env` existe avec vos vraies clés LiveKit Cloud
|
||||||
|
2. L'URL doit être en `wss://` (pas `ws://`)
|
||||||
|
3. Redémarrer le serveur après modification
|
||||||
|
4. Vérifier que le serveur tourne : `curl http://localhost:3000/health`
|
||||||
|
|
||||||
|
Voir le guide complet : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
- **[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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 É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
|
||||||
|
|
||||||
|
**Version actuelle** : 0.1.0 (Phase 1 MVP en cours)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,8 +131,6 @@ Option 3 : AES67 (RTP) ┘ ├── 📱 Ingé son
|
|||||||
### Modes de transmission
|
### Modes de transmission
|
||||||
```
|
```
|
||||||
PTT classique → Maintenir pour parler
|
PTT classique → Maintenir pour parler
|
||||||
PTT verrouillé → Appui long (3s) pour lock/unlock
|
|
||||||
Vibration + indicateur visuel rouge
|
|
||||||
Mode continu → Toggle ON/OFF (configurable par user)
|
Mode continu → Toggle ON/OFF (configurable par user)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
# TODO.md - Plan de développement PTT Live
|
||||||
|
|
||||||
|
**Dernière mise à jour** : 2026-05-23
|
||||||
|
**Phase actuelle** : PHASE 2 - Fonctionnalités professionnelles (En cours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 1 — Fondations (MVP)
|
||||||
|
|
||||||
|
### 🎯 Objectif
|
||||||
|
Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, macOS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.1 Infrastructure projet
|
||||||
|
|
||||||
|
- [x] Structure dossiers (server/, client/, install/)
|
||||||
|
- [x] package.json serveur (Node.js, Express, LiveKit SDK)
|
||||||
|
- [x] package.json client (React, Vite, livekit-client)
|
||||||
|
- [x] Script install/macos.sh (télécharge livekit-server binaire)
|
||||||
|
- [x] Config YAML basique (1 groupe, 2 canaux)
|
||||||
|
- [x] .gitignore (node_modules, binaires, .env)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Serveur LiveKit + API
|
||||||
|
|
||||||
|
- [x] server/index.js : spawn livekit-server binaire
|
||||||
|
- [x] Configuration LiveKit (ports, clés API)
|
||||||
|
- [x] API REST : POST /token (génère token client)
|
||||||
|
- [x] API REST : GET /config (infos groupes)
|
||||||
|
- [x] Validation : LiveKit démarre (mode cloud pour Phase 1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Bridge audio macOS
|
||||||
|
|
||||||
|
#### Backend CoreAudio
|
||||||
|
- [x] server/bridge/backends/CoreAudioBackend.js
|
||||||
|
- [x] Énumération devices (entrée/sortie)
|
||||||
|
- [x] Capture audio (48kHz, mono/stereo)
|
||||||
|
- [x] Lecture audio (48kHz)
|
||||||
|
- [x] Gestion buffer circulaire
|
||||||
|
|
||||||
|
#### Codec Opus
|
||||||
|
- [x] server/bridge/OpusCodec.js
|
||||||
|
- [x] Encoder PCM → Opus (configurable 32-320kbps, 20ms frame)
|
||||||
|
- [x] Decoder Opus → PCM
|
||||||
|
- [x] Configuration bitrate (par groupe ou global)
|
||||||
|
- [ ] Tests unitaires codec (différentes qualités)
|
||||||
|
|
||||||
|
#### Jitter Buffer
|
||||||
|
- [x] server/bridge/JitterBuffer.js
|
||||||
|
- [x] Buffer FIFO 40ms cible
|
||||||
|
- [x] Détection underrun/overrun
|
||||||
|
- [x] Statistiques latence
|
||||||
|
|
||||||
|
#### Intégration LiveKit
|
||||||
|
- [x] server/bridge/LiveKitClient.js
|
||||||
|
- [x] Connexion room en tant que participant
|
||||||
|
- [x] Publish track audio (Opus)
|
||||||
|
- [x] Subscribe tracks autres participants
|
||||||
|
- [x] Gestion reconnexion
|
||||||
|
|
||||||
|
#### Classe principale
|
||||||
|
- [x] server/bridge/AudioBridge.js
|
||||||
|
- [x] Détection backend (CoreAudio pour macOS)
|
||||||
|
- [x] Routing : CoreAudio → Opus → LiveKit
|
||||||
|
- [x] Routing : LiveKit → Opus → CoreAudio
|
||||||
|
- [x] Logs détaillés (latence, drops)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 Client PWA React
|
||||||
|
|
||||||
|
#### Infrastructure
|
||||||
|
- [x] client/vite.config.js (PWA plugin)
|
||||||
|
- [x] client/public/manifest.json (via Vite PWA)
|
||||||
|
- [x] client/public/sw.js (Service Worker auto-généré)
|
||||||
|
- [x] client/src/main.jsx (setup React)
|
||||||
|
|
||||||
|
#### Composants UI
|
||||||
|
- [x] client/src/App.jsx
|
||||||
|
- [x] Layout principal
|
||||||
|
- [x] Connexion utilisateur (nom + groupe)
|
||||||
|
- [x] Affichage état connexion
|
||||||
|
|
||||||
|
- [x] client/src/components/PTTButton.jsx
|
||||||
|
- [x] Bouton PTT (maintenir pour parler)
|
||||||
|
- [x] États : idle / talking / listening
|
||||||
|
- [x] Feedback visuel (couleurs)
|
||||||
|
- [x] Feedback haptique (vibration)
|
||||||
|
|
||||||
|
- [x] client/src/components/UserList.jsx
|
||||||
|
- [x] Liste participants groupe actif
|
||||||
|
- [x] Indicateur qui parle (temps réel)
|
||||||
|
|
||||||
|
- [x] client/src/components/AudioIndicator.jsx
|
||||||
|
- [x] Niveau audio entrant (VU-mètre simple)
|
||||||
|
- [x] Niveau micro sortant
|
||||||
|
|
||||||
|
#### Hooks WebRTC
|
||||||
|
- [x] client/src/hooks/useLiveKit.js
|
||||||
|
- [x] Connexion room (token serveur)
|
||||||
|
- [x] Publish microphone
|
||||||
|
- [x] Subscribe participants
|
||||||
|
- [x] Gestion événements (participant join/leave)
|
||||||
|
- [x] Cleanup disconnect
|
||||||
|
|
||||||
|
- [x] PTT intégré dans PTTButton.jsx
|
||||||
|
- [x] Mode PTT : mute/unmute track selon bouton
|
||||||
|
- [x] Gestion touch events (mobile)
|
||||||
|
- [x] Gestion mouse events (desktop)
|
||||||
|
- [x] **Fix iOS/mobile** : audio unlock, HTTPS obligatoire, proxy WSS LiveKit
|
||||||
|
|
||||||
|
#### Styles
|
||||||
|
- [x] CSS mobile-first
|
||||||
|
- [x] Design bouton PTT (large, accessible)
|
||||||
|
- [x] Mode sombre (défaut)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.5 Tests et validation Phase 1
|
||||||
|
|
||||||
|
#### Tests unitaires
|
||||||
|
- [x] Opus encode/decode (qualité audio)
|
||||||
|
- [x] Jitter buffer (buffer size stable)
|
||||||
|
- [ ] CoreAudio device detection (naudiodon crash - à résoudre plus tard)
|
||||||
|
|
||||||
|
#### Tests d'intégration
|
||||||
|
- [x] Serveur démarre sans erreur
|
||||||
|
- [x] Client obtient token valide
|
||||||
|
- [x] Client rejoint room LiveKit
|
||||||
|
|
||||||
|
#### Tests end-to-end
|
||||||
|
- [x] **Test 1** : 2 clients, PTT alterné, audio bidirectionnel
|
||||||
|
- [ ] **Test 2** : Mesure latence (clap → réception < 150ms)
|
||||||
|
- [ ] **Test 3** : Stabilité 5min sans coupure
|
||||||
|
- [ ] **Test 4** : Reconnexion après perte WiFi
|
||||||
|
|
||||||
|
#### Métriques
|
||||||
|
- [ ] Logger latence end-to-end moyenne
|
||||||
|
- [ ] Logger jitter buffer stats
|
||||||
|
- [ ] Logger packet loss WebRTC
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 2 — Fonctionnalités professionnelles
|
||||||
|
|
||||||
|
### 2.1 Groupes et routing
|
||||||
|
- [x] Config YAML : multi-groupes, multi-canaux
|
||||||
|
- [x] Routing dynamique serveur (groupe → canaux audio)
|
||||||
|
- [x] Client : sélecteur groupe (dropdown)
|
||||||
|
- [x] Client : affichage canaux groupe actif
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- [ ] Tests Ubuntu 22.04 LTS + Arch Linux
|
||||||
|
|
||||||
|
### 3.2 Dante
|
||||||
|
- [ ] Documentation setup DVS macOS
|
||||||
|
- [ ] Routing JACK ↔ DVS
|
||||||
|
- [ ] Tests multi-canaux (8+)
|
||||||
|
- [ ] Guide configuration réseau Dante
|
||||||
|
|
||||||
|
### 3.3 AES67
|
||||||
|
- [ ] Backend RTP multicast (Linux)
|
||||||
|
- [ ] PTP sync
|
||||||
|
- [ ] 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prochaines actions immédiates
|
||||||
|
|
||||||
|
### Phase 2 - Suite
|
||||||
|
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)
|
||||||
|
|
||||||
|
### Phase 3 - Préparation
|
||||||
|
- Support Linux (JACK/PipeWire backends)
|
||||||
|
- Intégration Dante/AES67
|
||||||
|
- Tests charge 30+ clients
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ RÈGLES DE DÉVELOPPEMENT
|
||||||
|
|
||||||
|
### 🔄 Workflow obligatoire
|
||||||
|
1. **Avant une tâche** : Cocher `[x]` dans ce fichier TODO.md
|
||||||
|
2. **Pendant le travail** : Développer la fonctionnalité
|
||||||
|
3. **Après la tâche** :
|
||||||
|
- ✅ Tester que ça fonctionne
|
||||||
|
- ✅ Valider la tâche dans TODO.md
|
||||||
|
- ✅ **COMMIT GIT** avec message descriptif
|
||||||
|
- ✅ Mettre à jour CLAUDE.md si nécessaire
|
||||||
|
|
||||||
|
### 📝 Convention commits
|
||||||
|
```bash
|
||||||
|
feat: description # Nouvelle fonctionnalité
|
||||||
|
fix: description # Correction bug
|
||||||
|
docs: description # Documentation
|
||||||
|
refactor: description # Refactoring
|
||||||
|
test: description # Tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT** : Commiter après chaque tâche complétée, pas à la fin de la journée !
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes et décisions
|
||||||
|
|
||||||
|
### Décisions techniques Phase 1
|
||||||
|
- **Audio backend** : CoreAudio natif (pas de JACK Phase 1)
|
||||||
|
- **Codec Opus** : Configurable 32-320 kbps (défaut 96kbps voix standard)
|
||||||
|
- Voix économique : 32-64 kbps
|
||||||
|
- Voix standard : 96 kbps (défaut)
|
||||||
|
- Voix HD : 128-192 kbps
|
||||||
|
- Musique : 256-320 kbps
|
||||||
|
- **Sample rate** : 48kHz, 20ms frame
|
||||||
|
- **Jitter buffer** : 40ms cible
|
||||||
|
- **Client** : PWA React (pas d'app native)
|
||||||
|
|
||||||
|
### Risques identifiés
|
||||||
|
- 🟡 Latence CoreAudio (à mesurer, cible < 50ms)
|
||||||
|
- 🟡 Permissions micro iOS (PWA)
|
||||||
|
- 🟡 Reconnexion automatique LiveKit (à tester)
|
||||||
|
|
||||||
|
### Questions résolues
|
||||||
|
- Nombre max participants par groupe Phase 1 ? → **4 clients max**
|
||||||
|
- Qualité audio configurable ? → **Oui, 32-320 kbps selon besoin**
|
||||||
|
- HTTPS requis pour PWA local ? → **Oui, self-signed cert dev**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Statut** : Phase 1 prête à démarrer
|
||||||
|
**Prochaine étape** : Infrastructure projet (1.1)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# PTT Live Client - Configuration environnement
|
||||||
|
|
||||||
|
# URL API serveur (en dev, utilise le proxy Vite)
|
||||||
|
VITE_API_URL=/api
|
||||||
|
|
||||||
|
# Pour production, pointer vers le serveur
|
||||||
|
# VITE_API_URL=https://your-server.com
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#1a1a1a" />
|
||||||
|
<meta name="description" content="Professional WebRTC Intercom for Event Technicians" />
|
||||||
|
|
||||||
|
<!-- iOS PWA -->
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
|
<title>PTT Live</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC0hpMHdlPKTWp6
|
||||||
|
8ixzelM1fh3TWtwpY4HmDFKF09VaBhIv6mSBdiMS+pLyN2d+AAA9gj2WH5PfWSDK
|
||||||
|
pRaDhJgJF55wDZys0f15odyijm3qu0tttj8IHxS6Li5vqttOyO1KE6A4UKbw9Op3
|
||||||
|
0egMJ2VMaIH98cD0TCC+AGChDEsoPuhM2b5Q52OBHcugVw/akhVfdetdNYuBue1c
|
||||||
|
Ii+UyZkhF0PV/S67n9dg4XRq+Jed/3zUZQiJf/CT3pnW3umcOTUw2e3R4/TBuSOG
|
||||||
|
Mex6OF3zM2pNIxCejiKVW8VO4jTp+VSm+8i6xvjBb47GePOG99apVELWPiwsukSw
|
||||||
|
CRGJjGhXAgMBAAECggEAOrEB/kwXI8+VjdFMaGLdyKdvFPcWWxJx+hQJhF8Bn1oX
|
||||||
|
8aIX+QsqjhIPUlZ2/D0N1vGQCk3L6rJ0ec3AixPBxjr6lN2oEXvYGAJq1CLQU59+
|
||||||
|
/3Vf+sj4GSvIhx+aW3vxwcKttYFrNS27SSdidQkd4wCbOq+tlv9lKcC/qbxwdu2p
|
||||||
|
CblxDiwivtotuf/2bfG+YgV5y8qiRgn1OVsHZK6xKZ/i8stURcNTq/6CrmLXpNBz
|
||||||
|
OdzFBQ9JIRQPezh4gfPQ7Bt8T50gwi38TfkzdsK9vzhxsmV6dPZNjDpPS4EChOzy
|
||||||
|
gQ+roS7m74vFaHfsDtSBb2sebt6pFBxxUnhDDe8OqQKBgQDdgIU7MePskr62AXRZ
|
||||||
|
QgoUC9Do40FPHvR7TPtLGATPqR4Jix2Mkxyy6gXaXyHobpQ7oXjeky+hVJY7wu1W
|
||||||
|
/QEA0Vk2WMEs3sauITAz7wbIQHZKHTXufZphOVOPQlmTx+QTt94Khhu7NWnIUhWW
|
||||||
|
QXSqO9oQusY6696258WZNSv3YwKBgQDQpEp7giVGebhoY4ABmwQpmnAJ2avJIaj9
|
||||||
|
w40zqygs74VA8MVhVk48ZNt/XM/xkkB4KbsxYJrxg9+B4PQJ/wiv+4jVbMqWZMth
|
||||||
|
ahU6Stb8oTTzXJ6O3qIYrUpDK+ByD09snq/SsVo5FYYYGsP7NZ88rDAmbdQ9rrP1
|
||||||
|
sxkyEIP/fQKBgGgdYgKaB82KiJQaiOrvrLcResgNEgSzwy012STKDHDjyFeqCWCr
|
||||||
|
QaEjeU7UyqZrW8fPtXXBb3EAxoEetdren5sXzDxMabjCmlb9CKBQqTp1emSJ6HDK
|
||||||
|
n0c13/4FrP9WxPEzyu3dbamIiMl9M+JlsAXYjj6w3D6T4iLNPMcwBBOLAoGBALN0
|
||||||
|
a//5e/g3H47h7irzW0wxYqaGS8RuqDzEYwIK+D5WMgYeUZccNaSql0Tf3peIVN1F
|
||||||
|
/5VD42FSLP84Lo8ehilfr1zq+wEKZwg9x05hKrMWMUYU5ug5w7B39IT8C0vvsT/a
|
||||||
|
6Z3OH60zvyeidejvQSxdafjTxJbdWjo9trEiFXa9AoGBAIwj0zxK0YiZkXQWRP1b
|
||||||
|
B4IG4ZQbgpVKAlCYRQl7PCmaO8Eb9jYO1AQVCTONtR7DhINm/HpUAhjaokAya3wc
|
||||||
|
ckN/BbOwiehOprz/N5c1XkQOLWOz5LnTSk77EeyC84KiOuyqK46qAhQJ6zuUngsG
|
||||||
|
s+cPPn7xL+vCZWmYmmz4n+C7
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIEpjCCAw6gAwIBAgIQOw0sc56bEW4nOW9fKvr8gDANBgkqhkiG9w0BAQsFADCB
|
||||||
|
sTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMUMwQQYDVQQLDDpiZW5v
|
||||||
|
aXRATWFjQm9vay1Qcm8tMTYtTTMtZGUtQmVub2l0LmxvY2FsIChCZW5vaXQgU2No
|
||||||
|
d2FydHopMUowSAYDVQQDDEFta2NlcnQgYmVub2l0QE1hY0Jvb2stUHJvLTE2LU0z
|
||||||
|
LWRlLUJlbm9pdC5sb2NhbCAoQmVub2l0IFNjaHdhcnR6KTAeFw0yNjA1MjIyMDU4
|
||||||
|
NTBaFw0yODA4MjIyMDU4NTBaMG4xJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVu
|
||||||
|
dCBjZXJ0aWZpY2F0ZTFDMEEGA1UECww6YmVub2l0QE1hY0Jvb2stUHJvLTE2LU0z
|
||||||
|
LWRlLUJlbm9pdC5sb2NhbCAoQmVub2l0IFNjaHdhcnR6KTCCASIwDQYJKoZIhvcN
|
||||||
|
AQEBBQADggEPADCCAQoCggEBALSGkwd2U8pNanryLHN6UzV+HdNa3CljgeYMUoXT
|
||||||
|
1VoGEi/qZIF2IxL6kvI3Z34AAD2CPZYfk99ZIMqlFoOEmAkXnnANnKzR/Xmh3KKO
|
||||||
|
beq7S222PwgfFLouLm+q207I7UoToDhQpvD06nfR6AwnZUxogf3xwPRMIL4AYKEM
|
||||||
|
Syg+6EzZvlDnY4Edy6BXD9qSFV916101i4G57VwiL5TJmSEXQ9X9Lruf12DhdGr4
|
||||||
|
l53/fNRlCIl/8JPemdbe6Zw5NTDZ7dHj9MG5I4Yx7Ho4XfMzak0jEJ6OIpVbxU7i
|
||||||
|
NOn5VKb7yLrG+MFvjsZ484b31qlUQtY+LCy6RLAJEYmMaFcCAwEAAaN8MHowDgYD
|
||||||
|
VR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFEVk
|
||||||
|
aBcatJettT33mJ0OUx+ILYQRMDIGA1UdEQQrMCmCCWxvY2FsaG9zdIcEfwAAAYcE
|
||||||
|
CgEBb4cQAAAAAAAAAAAAAAAAAAAAATANBgkqhkiG9w0BAQsFAAOCAYEAUZAIBw4n
|
||||||
|
8pBXLSnpgw84bnwgLeVvzg53SKcUzUSuymImv9luhKySmodiVLw4M89K0RSSm7F0
|
||||||
|
SeRjzMr3sMhYa6K4sZtn5QtRhAQn0c+r6PTUgKe0PU5FmMV27DoIa9iS1BcIqjVF
|
||||||
|
G0QSdFRB1UqXlPVyBVN7XeQx4XYqVEbStZLSV0LxgHOAc73c6zXh00OrDpFdox4t
|
||||||
|
UeA7GUpZyFMm/mxuiQaBdY2m5CGgBPbtGOxlq3JOHj/aNcww5DP3m3o+M5TfT3lK
|
||||||
|
6Ex5j4N2ym5cLixon5vtqTkAmJlX70xB2qh+TmdZ+BDZ4Y1C0otEPSv6vQX4zV/2
|
||||||
|
I9gWl3w+sm85BAXff0TBahW0+p98o44M+y62xwhwUQ4/E9cQLJElFnXk2Hi9eHPk
|
||||||
|
mchbDPHv7L8rGHArl6GOIa2MVQxZKDjdTRtx6k2gYYB/qWoeMfM1E8hc2n5SANTP
|
||||||
|
Q24BYvk8qFH4ECz0hhxTX9rwvxHeTLB1exBBoSEVi/neagG1UsD6OA4H
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "ptt-live-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "PTT Live - Professional WebRTC Intercom Client PWA",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"livekit-client": "^2.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"vite": "^5.2.11",
|
||||||
|
"vite-plugin-pwa": "^0.20.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-react": "^7.34.1",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/* PTT Live - Styles composants principaux */
|
||||||
|
|
||||||
|
.app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Login === */
|
||||||
|
.login-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
background: linear-gradient(135deg, var(--color-primary), var(--color-success));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid var(--color-danger);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-danger);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled,
|
||||||
|
.form-group select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Header === */
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-disconnect {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-disconnect:hover {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Main === */
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Responsive === */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.login-card {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode paysage mobile */
|
||||||
|
@media (max-height: 500px) and (orientation: landscape) {
|
||||||
|
.login-card {
|
||||||
|
max-width: 600px;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import useLiveKit from './hooks/useLiveKit';
|
||||||
|
import PTTButton from './components/PTTButton';
|
||||||
|
import UserList from './components/UserList';
|
||||||
|
import AudioIndicator from './components/AudioIndicator';
|
||||||
|
import GroupSelector from './components/GroupSelector';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [groupId, setGroupId] = useState('');
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isConnected,
|
||||||
|
participants,
|
||||||
|
isTalking,
|
||||||
|
audioLevel,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
switchGroup,
|
||||||
|
startTalking,
|
||||||
|
stopTalking
|
||||||
|
} = useLiveKit();
|
||||||
|
|
||||||
|
// Charger configuration au démarrage
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_URL}/config`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setGroups(data.groups || []);
|
||||||
|
if (data.groups.length > 0) {
|
||||||
|
setGroupId(data.groups[0].id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Erreur chargement config:', err);
|
||||||
|
setError('Impossible de charger la configuration');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
if (!username.trim()) {
|
||||||
|
setError('Veuillez entrer votre nom');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groupId) {
|
||||||
|
setError('Veuillez sélectionner un groupe');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnecting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// IMPORTANT iOS : Demander permission microphone AVANT tout
|
||||||
|
console.log('🎤 Demande permission microphone...');
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||||
|
console.log('✓ Permission microphone accordée');
|
||||||
|
// Arrêter le stream test immédiatement
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
} catch (permErr) {
|
||||||
|
console.error('❌ Permission microphone refusée:', permErr);
|
||||||
|
throw new Error('Accès microphone refusé. Autorisez dans les réglages iOS : Safari > Microphone.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtenir token du serveur
|
||||||
|
const response = await fetch(`${API_URL}/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, groupId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Erreur serveur');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Adapter l'URL LiveKit selon le protocole de la page
|
||||||
|
let livekitUrl = data.url;
|
||||||
|
if (window.location.protocol === 'https:') {
|
||||||
|
// En HTTPS, utiliser le proxy WSS local via Vite
|
||||||
|
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔗 Connexion LiveKit:', livekitUrl);
|
||||||
|
|
||||||
|
// Se connecter à LiveKit
|
||||||
|
await connect(livekitUrl, data.token);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur connexion:', err);
|
||||||
|
|
||||||
|
// Message d'erreur spécifique selon le type
|
||||||
|
if (err.message && err.message.includes('Microphone')) {
|
||||||
|
setError(err.message);
|
||||||
|
} else {
|
||||||
|
setError('Connexion impossible. Vérifiez le serveur et les permissions microphone.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = () => {
|
||||||
|
disconnect();
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGroupChange = async (newGroupId) => {
|
||||||
|
console.log('🔄 Changement de groupe:', groupId, '→', newGroupId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Obtenir nouveau token pour le nouveau groupe
|
||||||
|
const response = await fetch(`${API_URL}/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, groupId: newGroupId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Erreur serveur');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Adapter l'URL LiveKit selon le protocole de la page
|
||||||
|
let livekitUrl = data.url;
|
||||||
|
if (window.location.protocol === 'https:') {
|
||||||
|
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changer de room LiveKit
|
||||||
|
await switchGroup(livekitUrl, data.token);
|
||||||
|
|
||||||
|
// Mettre à jour l'état
|
||||||
|
setGroupId(newGroupId);
|
||||||
|
console.log('✓ Groupe changé avec succès');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur changement de groupe:', err);
|
||||||
|
throw err; // Propager l'erreur au composant GroupSelector
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Interface de connexion
|
||||||
|
if (!isConnected) {
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<div className="login-container">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="app-title">PTT Live</h1>
|
||||||
|
<p className="app-subtitle">Professional Intercom</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="username">Nom</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Votre nom"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleConnect()}
|
||||||
|
disabled={isConnecting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="group">Groupe</label>
|
||||||
|
<select
|
||||||
|
id="group"
|
||||||
|
value={groupId}
|
||||||
|
onChange={(e) => setGroupId(e.target.value)}
|
||||||
|
disabled={isConnecting || groups.length === 0}
|
||||||
|
>
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<option>Chargement...</option>
|
||||||
|
) : (
|
||||||
|
groups.map(g => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={isConnecting || !username.trim() || !groupId}
|
||||||
|
>
|
||||||
|
{isConnecting ? 'Connexion...' : 'Se connecter'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface principale PTT
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="header-info">
|
||||||
|
<h2>{username}</h2>
|
||||||
|
<p className="text-secondary">
|
||||||
|
{groups.find(g => g.id === groupId)?.name || groupId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn-disconnect"
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
>
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="app-main">
|
||||||
|
{/* Sélecteur de groupe */}
|
||||||
|
<GroupSelector
|
||||||
|
currentGroupId={groupId}
|
||||||
|
onGroupChange={handleGroupChange}
|
||||||
|
apiUrl={API_URL}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Liste des participants */}
|
||||||
|
<UserList participants={participants} />
|
||||||
|
|
||||||
|
{/* Indicateur audio */}
|
||||||
|
<AudioIndicator level={audioLevel} isTalking={isTalking} />
|
||||||
|
|
||||||
|
{/* Bouton PTT principal */}
|
||||||
|
<PTTButton
|
||||||
|
isTalking={isTalking}
|
||||||
|
onPressStart={startTalking}
|
||||||
|
onPressEnd={stopTalking}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/* AudioIndicator - VU-mètre audio */
|
||||||
|
|
||||||
|
.audio-indicator-container {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 0 var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-indicator-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-indicator-label span:first-child {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-level-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Barre de progression */
|
||||||
|
.audio-indicator-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-indicator-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-success);
|
||||||
|
transition: width 0.1s ease, background 0.2s;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-indicator-fill.talking {
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VU-mètre bars */
|
||||||
|
.audio-bars {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
height: 40px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: all 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-bar.active {
|
||||||
|
background: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-bar.active.talking {
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-bar.active.warning {
|
||||||
|
background: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-bar.active.danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
animation: blink 0.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.audio-indicator-container {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
margin: 0 var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-bars {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-indicator-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode paysage */
|
||||||
|
@media (max-height: 500px) and (orientation: landscape) {
|
||||||
|
.audio-indicator-container {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
margin: 0 var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-bars {
|
||||||
|
height: 24px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-indicator-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import './AudioIndicator.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VU-mètre simple pour visualiser le niveau audio
|
||||||
|
*/
|
||||||
|
export default function AudioIndicator({ level, isTalking }) {
|
||||||
|
// Normaliser niveau 0-100
|
||||||
|
const normalizedLevel = Math.min(100, Math.max(0, level));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="audio-indicator-container">
|
||||||
|
<div className="audio-indicator-label">
|
||||||
|
<span>{isTalking ? 'Votre micro' : 'Audio entrant'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* VU-mètre barres */}
|
||||||
|
<div className="audio-bars">
|
||||||
|
{[...Array(20)].map((_, i) => {
|
||||||
|
const threshold = (i + 1) * 5;
|
||||||
|
const isActive = normalizedLevel >= threshold;
|
||||||
|
const isWarning = i >= 15; // > 75%
|
||||||
|
const isDanger = i >= 18; // > 90%
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`audio-bar ${isActive ? 'active' : ''} ${
|
||||||
|
isActive && isDanger ? 'danger' : isActive && isWarning ? 'warning' : ''
|
||||||
|
} ${isTalking ? 'talking' : ''}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
.group-selector {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='white' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 1rem center;
|
||||||
|
padding-right: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector-select:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary, #3b82f6);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector-select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector-select option {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector-description {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector-loading,
|
||||||
|
.group-selector-error {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector-error {
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector-changing {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-primary, #3b82f6);
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsiveness */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.group-selector {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector-select {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import './GroupSelector.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composant de sélection de groupe
|
||||||
|
* Permet de changer de groupe pendant une session active
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} props.currentGroupId - ID du groupe actuel
|
||||||
|
* @param {Function} props.onGroupChange - Callback appelé lors du changement de groupe
|
||||||
|
* @param {string} props.apiUrl - URL de l'API
|
||||||
|
*/
|
||||||
|
function GroupSelector({ currentGroupId, onGroupChange, apiUrl }) {
|
||||||
|
const [groups, setGroups] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isChanging, setIsChanging] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Charger la liste des groupes
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchGroups = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/groups`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Erreur chargement groupes');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setGroups(data.groups || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur chargement groupes:', err);
|
||||||
|
setError('Impossible de charger les groupes');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchGroups();
|
||||||
|
}, [apiUrl]);
|
||||||
|
|
||||||
|
const handleChange = async (e) => {
|
||||||
|
const newGroupId = e.target.value;
|
||||||
|
|
||||||
|
if (newGroupId === currentGroupId) {
|
||||||
|
return; // Pas de changement
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsChanging(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onGroupChange(newGroupId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur changement groupe:', err);
|
||||||
|
setError('Erreur lors du changement de groupe');
|
||||||
|
// Réinitialiser la sélection à l'ancien groupe
|
||||||
|
e.target.value = currentGroupId;
|
||||||
|
} finally {
|
||||||
|
setIsChanging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="group-selector">
|
||||||
|
<div className="group-selector-loading">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="group-selector">
|
||||||
|
<div className="group-selector-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentGroup = groups.find(g => g.id === currentGroupId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group-selector">
|
||||||
|
<label htmlFor="group-select" className="group-selector-label">
|
||||||
|
Groupe
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="group-select"
|
||||||
|
className="group-selector-select"
|
||||||
|
value={currentGroupId}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={isChanging || groups.length === 0}
|
||||||
|
>
|
||||||
|
{groups.map(g => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{currentGroup && (
|
||||||
|
<p className="group-selector-description">
|
||||||
|
{currentGroup.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isChanging && (
|
||||||
|
<div className="group-selector-changing">
|
||||||
|
Changement de groupe...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GroupSelector;
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
/* PTTButton - Bouton principal Push-To-Talk */
|
||||||
|
|
||||||
|
.ptt-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptt-button {
|
||||||
|
width: 240px;
|
||||||
|
height: 240px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-ptt-idle);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
/* Mobile touch optimizations */
|
||||||
|
touch-action: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptt-button::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.1), transparent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptt-button:active::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptt-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* État: En train de parler */
|
||||||
|
.ptt-button.talking {
|
||||||
|
background: var(--color-ptt-talking);
|
||||||
|
box-shadow: 0 8px 32px rgba(239, 68, 68, 0.5),
|
||||||
|
0 0 60px rgba(239, 68, 68, 0.3);
|
||||||
|
animation: pulse-talking 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* État: Mode lock (continu) */
|
||||||
|
.ptt-button.locked {
|
||||||
|
background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);
|
||||||
|
box-shadow: 0 8px 32px rgba(220, 38, 38, 0.6),
|
||||||
|
0 0 80px rgba(220, 38, 38, 0.4);
|
||||||
|
animation: pulse-locked 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-talking {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-locked {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
box-shadow: 0 8px 32px rgba(220, 38, 38, 0.6),
|
||||||
|
0 0 80px rgba(220, 38, 38, 0.4);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.03);
|
||||||
|
box-shadow: 0 8px 40px rgba(220, 38, 38, 0.7),
|
||||||
|
0 0 100px rgba(220, 38, 38, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icône micro */
|
||||||
|
.ptt-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptt-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label */
|
||||||
|
.ptt-label {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hint text */
|
||||||
|
.ptt-hint {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 90%;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Indicateur de drag vers le haut */
|
||||||
|
.drag-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: -60px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
animation: drag-pulse 0.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-indicator svg {
|
||||||
|
filter: drop-shadow(0 2px 8px rgba(251, 191, 36, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drag-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(-50%) translateY(-5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge mode lock */
|
||||||
|
.lock-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
animation: lock-badge-pulse 1s ease-in-out infinite;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lock-badge-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive mobile */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ptt-button {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptt-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptt-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode paysage */
|
||||||
|
@media (max-height: 500px) and (orientation: landscape) {
|
||||||
|
.ptt-container {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptt-button {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptt-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptt-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptt-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive mobile - badge lock */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.lock-badge {
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accessibilité : désactiver effets réduits */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ptt-button,
|
||||||
|
.ptt-button.talking,
|
||||||
|
.ptt-button.locked,
|
||||||
|
.lock-badge {
|
||||||
|
animation: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-progress {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import './PTTButton.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export default function PTTButton({ isTalking, onPressStart, onPressEnd }) {
|
||||||
|
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
|
||||||
|
|
||||||
|
// Drag tracking
|
||||||
|
const dragStartYRef = useRef(null);
|
||||||
|
const currentYRef = useRef(null);
|
||||||
|
const [dragOffset, setDragOffset] = useState(0); // Offset visuel du drag (en pixels)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const button = buttonRef.current;
|
||||||
|
if (!button) return;
|
||||||
|
|
||||||
|
// Empêcher comportements par défaut
|
||||||
|
const preventDefault = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch events (mobile)
|
||||||
|
const handleTouchStart = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
console.log('🖐️ Touch start at Y:', touch.clientY);
|
||||||
|
|
||||||
|
// En mode lock, un tap désactive le mode
|
||||||
|
if (isLockModeRef.current) {
|
||||||
|
toggleLockMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode PTT normal : démarrer + init drag
|
||||||
|
if (!isPressingRef.current) {
|
||||||
|
isPressingRef.current = true;
|
||||||
|
dragStartYRef.current = touch.clientY;
|
||||||
|
currentYRef.current = touch.clientY;
|
||||||
|
onPressStart();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Pas de drag en mode lock
|
||||||
|
if (isLockModeRef.current || !isPressingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
currentYRef.current = touch.clientY;
|
||||||
|
|
||||||
|
// Calculer le déplacement vertical (négatif = vers le haut)
|
||||||
|
const deltaY = dragStartYRef.current - touch.clientY;
|
||||||
|
|
||||||
|
// Limiter le drag vers le haut (max 100px)
|
||||||
|
const offset = Math.max(0, Math.min(100, deltaY));
|
||||||
|
setDragOffset(offset);
|
||||||
|
|
||||||
|
console.log('📏 Drag offset:', offset);
|
||||||
|
|
||||||
|
// Si on a glissé de 80px vers le haut, activer le mode lock
|
||||||
|
if (offset >= 80) {
|
||||||
|
activateLockMode();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log('🖐️ Touch end, dragOffset:', dragOffset);
|
||||||
|
|
||||||
|
// Réinitialiser le drag
|
||||||
|
dragStartYRef.current = null;
|
||||||
|
currentYRef.current = null;
|
||||||
|
setDragOffset(0);
|
||||||
|
|
||||||
|
// En mode lock, ne rien faire (le micro reste actif)
|
||||||
|
if (isLockModeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode PTT normal : arrêter
|
||||||
|
if (isPressingRef.current) {
|
||||||
|
isPressingRef.current = false;
|
||||||
|
onPressEnd();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mouse events (desktop)
|
||||||
|
const handleMouseDown = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log('🖱️ Mouse down at Y:', e.clientY);
|
||||||
|
|
||||||
|
// En mode lock, un clic désactive le mode
|
||||||
|
if (isLockModeRef.current) {
|
||||||
|
toggleLockMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPressingRef.current) {
|
||||||
|
isPressingRef.current = true;
|
||||||
|
dragStartYRef.current = e.clientY;
|
||||||
|
currentYRef.current = e.clientY;
|
||||||
|
onPressStart();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
// Pas de drag en mode lock
|
||||||
|
if (isLockModeRef.current || !isPressingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentYRef.current = e.clientY;
|
||||||
|
|
||||||
|
// Calculer le déplacement vertical (négatif = vers le haut)
|
||||||
|
const deltaY = dragStartYRef.current - e.clientY;
|
||||||
|
|
||||||
|
// Limiter le drag vers le haut (max 100px)
|
||||||
|
const offset = Math.max(0, Math.min(100, deltaY));
|
||||||
|
setDragOffset(offset);
|
||||||
|
|
||||||
|
// Si on a glissé de 80px vers le haut, activer le mode lock
|
||||||
|
if (offset >= 80) {
|
||||||
|
activateLockMode();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log('🖱️ Mouse up, dragOffset:', dragOffset);
|
||||||
|
|
||||||
|
// Réinitialiser le drag
|
||||||
|
dragStartYRef.current = null;
|
||||||
|
currentYRef.current = null;
|
||||||
|
setDragOffset(0);
|
||||||
|
|
||||||
|
// En mode lock, ne rien faire
|
||||||
|
if (isLockModeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPressingRef.current) {
|
||||||
|
isPressingRef.current = false;
|
||||||
|
onPressEnd();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = (e) => {
|
||||||
|
// Si on quitte le bouton en maintenant, on arrête (sauf en mode lock)
|
||||||
|
if (!isLockModeRef.current && isPressingRef.current) {
|
||||||
|
// Réinitialiser le drag
|
||||||
|
dragStartYRef.current = null;
|
||||||
|
currentYRef.current = null;
|
||||||
|
setDragOffset(0);
|
||||||
|
|
||||||
|
isPressingRef.current = false;
|
||||||
|
onPressEnd();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attacher events
|
||||||
|
button.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||||
|
button.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
button.addEventListener('touchend', handleTouchEnd, { passive: false });
|
||||||
|
button.addEventListener('touchcancel', handleTouchEnd, { passive: false });
|
||||||
|
button.addEventListener('mousedown', handleMouseDown);
|
||||||
|
button.addEventListener('mousemove', handleMouseMove);
|
||||||
|
button.addEventListener('mouseup', handleMouseUp);
|
||||||
|
button.addEventListener('mouseleave', handleMouseLeave);
|
||||||
|
button.addEventListener('contextmenu', preventDefault);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
button.removeEventListener('touchstart', handleTouchStart);
|
||||||
|
button.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
button.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
button.removeEventListener('touchcancel', handleTouchEnd);
|
||||||
|
button.removeEventListener('mousedown', handleMouseDown);
|
||||||
|
button.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
button.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
button.removeEventListener('mouseleave', handleMouseLeave);
|
||||||
|
button.removeEventListener('contextmenu', preventDefault);
|
||||||
|
};
|
||||||
|
}, [onPressStart, onPressEnd]);
|
||||||
|
|
||||||
|
// Fonction pour activer le mode lock
|
||||||
|
const activateLockMode = () => {
|
||||||
|
console.log('🔒 Mode lock activé par drag');
|
||||||
|
setIsLockMode(true);
|
||||||
|
isLockModeRef.current = true;
|
||||||
|
|
||||||
|
// Réinitialiser le drag
|
||||||
|
setDragOffset(0);
|
||||||
|
dragStartYRef.current = null;
|
||||||
|
currentYRef.current = null;
|
||||||
|
|
||||||
|
// Le micro est déjà actif (onPressStart a été appelé)
|
||||||
|
|
||||||
|
// Vibration pour feedback
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate([100, 50, 100]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour basculer le mode lock (appelée par le toggle externe)
|
||||||
|
const toggleLockMode = () => {
|
||||||
|
const newLockMode = !isLockModeRef.current;
|
||||||
|
console.log('🔄 Toggle lock mode:', isLockModeRef.current, '→', newLockMode);
|
||||||
|
|
||||||
|
setIsLockMode(newLockMode);
|
||||||
|
isLockModeRef.current = newLockMode;
|
||||||
|
|
||||||
|
if (newLockMode) {
|
||||||
|
// Activer le mode lock : démarrer l'audio
|
||||||
|
console.log('🔒 Mode lock ON');
|
||||||
|
onPressStart();
|
||||||
|
|
||||||
|
// Vibration pour feedback
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate([100, 50, 100]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Désactiver le mode lock : couper l'audio
|
||||||
|
console.log('🔓 Mode lock OFF');
|
||||||
|
onPressEnd();
|
||||||
|
|
||||||
|
// Vibration pour feedback
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ptt-container">
|
||||||
|
{/* Zone de drag vers le haut (indicateur visuel) */}
|
||||||
|
{dragOffset > 0 && !isLockMode && (
|
||||||
|
<div className="drag-indicator" style={{ opacity: dragOffset / 80 }}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="32" height="32">
|
||||||
|
<path d="M7 14l5-5 5 5H7z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Glissez pour verrouiller</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bouton PTT principal */}
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
className={`ptt-button ${isTalking ? 'talking' : ''} ${isLockMode ? 'locked' : ''}`}
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
transform: dragOffset > 0 && !isLockMode ? `translateY(-${dragOffset * 0.3}px)` : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
<div className="ptt-icon">
|
||||||
|
{isTalking ? (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
|
||||||
|
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
|
||||||
|
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||||
|
<path d="M19 11h2v2h-2zm-16 0h2v2H3z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badge mode lock */}
|
||||||
|
{isLockMode && (
|
||||||
|
<div className="lock-badge">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
||||||
|
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM9 8V6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="ptt-label">
|
||||||
|
{isLockMode
|
||||||
|
? 'Mode continu actif'
|
||||||
|
: isTalking
|
||||||
|
? 'En cours...'
|
||||||
|
: 'Maintenir pour parler'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="ptt-hint">
|
||||||
|
{isLockMode
|
||||||
|
? 'Tapez pour désactiver le mode continu'
|
||||||
|
: isTalking
|
||||||
|
? 'Glissez vers le haut pour verrouiller • Relâchez pour arrêter'
|
||||||
|
: 'Appuyez et maintenez pour parler • Glissez vers le haut pour verrouiller'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/* UserList - Liste des participants */
|
||||||
|
|
||||||
|
.user-list {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-header {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-lg);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-count {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-items {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item utilisateur */
|
||||||
|
.user-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item:hover {
|
||||||
|
background: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item.speaking {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
.user-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item.speaking .user-avatar {
|
||||||
|
background: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info */
|
||||||
|
.user-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-success);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Indicateurs */
|
||||||
|
.user-indicator {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaking-indicator {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaking-indicator svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-indicator {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-indicator svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.user-list {
|
||||||
|
max-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-status {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import './UserList.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste des participants connectés
|
||||||
|
*/
|
||||||
|
export default function UserList({ participants }) {
|
||||||
|
if (participants.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="user-list empty">
|
||||||
|
<p className="empty-message">Aucun autre participant</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="user-list">
|
||||||
|
<div className="user-list-header">
|
||||||
|
<span className="user-count">
|
||||||
|
{participants.length} participant{participants.length > 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="user-list-items">
|
||||||
|
{participants.map((participant) => (
|
||||||
|
<div
|
||||||
|
key={participant.identity}
|
||||||
|
className={`user-item ${participant.isSpeaking ? 'speaking' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="user-avatar">
|
||||||
|
{participant.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="user-info">
|
||||||
|
<span className="user-name">{participant.name}</span>
|
||||||
|
{participant.isSpeaking && (
|
||||||
|
<span className="user-status">En train de parler</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="user-indicator">
|
||||||
|
{participant.isSpeaking ? (
|
||||||
|
<div className="speaking-indicator pulse">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
|
||||||
|
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="audio-indicator">
|
||||||
|
{participant.hasAudio ? (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
|
||||||
|
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { Room, RoomEvent, Track } from 'livekit-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook pour gérer la connexion et l'état LiveKit
|
||||||
|
*/
|
||||||
|
export default function useLiveKit() {
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [participants, setParticipants] = useState([]);
|
||||||
|
const [isTalking, setIsTalking] = useState(false);
|
||||||
|
const [audioLevel, setAudioLevel] = useState(0);
|
||||||
|
|
||||||
|
const roomRef = useRef(null);
|
||||||
|
const localTrackRef = useRef(null);
|
||||||
|
const audioContextRef = useRef(null);
|
||||||
|
const analyserRef = useRef(null);
|
||||||
|
const animationFrameRef = useRef(null);
|
||||||
|
const isAudioUnlockedRef = useRef(false);
|
||||||
|
|
||||||
|
// Analyseur audio pour pistes distantes (audio entrant)
|
||||||
|
const remoteAudioContextRef = useRef(null);
|
||||||
|
const remoteAnalyserRef = useRef(null);
|
||||||
|
const remoteAnimationFrameRef = useRef(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connexion à la room LiveKit
|
||||||
|
*/
|
||||||
|
const connect = useCallback(async (url, token) => {
|
||||||
|
try {
|
||||||
|
// Créer room
|
||||||
|
const room = new Room({
|
||||||
|
adaptiveStream: true,
|
||||||
|
dynacast: true,
|
||||||
|
audioCaptureDefaults: {
|
||||||
|
autoGainControl: true,
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
roomRef.current = room;
|
||||||
|
|
||||||
|
// Events
|
||||||
|
room.on(RoomEvent.Connected, () => {
|
||||||
|
console.log('✓ Connecté à LiveKit');
|
||||||
|
console.log(' Room name:', room.name);
|
||||||
|
console.log(' Participants distants:', room.remoteParticipants.size);
|
||||||
|
setIsConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.Disconnected, () => {
|
||||||
|
console.log('✗ Déconnecté de LiveKit');
|
||||||
|
setIsConnected(false);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||||
|
console.log('🟢 Participant rejoint:', participant.identity);
|
||||||
|
console.log(' Total participants distants:', room.remoteParticipants.size);
|
||||||
|
updateParticipants();
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
||||||
|
console.log('Participant parti:', participant.identity);
|
||||||
|
updateParticipants();
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||||
|
console.log('Track reçu:', track.kind, 'de', participant.identity);
|
||||||
|
updateParticipants();
|
||||||
|
|
||||||
|
// Auto-play audio
|
||||||
|
if (track.kind === Track.Kind.Audio) {
|
||||||
|
const audioElement = track.attach();
|
||||||
|
document.body.appendChild(audioElement);
|
||||||
|
|
||||||
|
// Setup analyseur pour audio entrant
|
||||||
|
setupRemoteAudioAnalyser(track);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
||||||
|
console.log('Track retiré:', track.kind, 'de', participant.identity);
|
||||||
|
track.detach().forEach(el => el.remove());
|
||||||
|
updateParticipants();
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||||
|
updateParticipants();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event track local publié
|
||||||
|
room.on(RoomEvent.LocalTrackPublished, (publication) => {
|
||||||
|
console.log('✓ Track local publié:', publication.kind);
|
||||||
|
if (publication.kind === Track.Kind.Audio) {
|
||||||
|
const track = publication.track;
|
||||||
|
console.log(' Track audio disponible:', track);
|
||||||
|
console.log(' isMuted:', track.isMuted);
|
||||||
|
localTrackRef.current = track;
|
||||||
|
// Mute par défaut (PTT)
|
||||||
|
track.mute();
|
||||||
|
setupAudioAnalyser(track);
|
||||||
|
// Démarrer l'analyse audio
|
||||||
|
analyseAudioLevel();
|
||||||
|
console.log('✓ Track audio configuré et muted pour PTT');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connexion
|
||||||
|
await room.connect(url, token);
|
||||||
|
|
||||||
|
console.log('📞 Connexion établie, activation microphone...');
|
||||||
|
|
||||||
|
// Activer microphone (muted par défaut)
|
||||||
|
await room.localParticipant.setMicrophoneEnabled(true);
|
||||||
|
|
||||||
|
console.log('🎤 Microphone activé, attente publication track...');
|
||||||
|
|
||||||
|
// Attendre que le track soit publié (max 3s)
|
||||||
|
let retries = 0;
|
||||||
|
while (!localTrackRef.current && retries < 30) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
retries++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!localTrackRef.current) {
|
||||||
|
console.error('❌ Timeout : track audio non publié après 3s');
|
||||||
|
throw new Error('Microphone non disponible. Autorisez l\'accès au micro dans les réglages iOS.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✓ Track audio prêt');
|
||||||
|
|
||||||
|
updateParticipants();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur connexion LiveKit:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déconnexion
|
||||||
|
*/
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
cleanup();
|
||||||
|
if (roomRef.current) {
|
||||||
|
roomRef.current.disconnect();
|
||||||
|
roomRef.current = null;
|
||||||
|
}
|
||||||
|
setIsConnected(false);
|
||||||
|
setParticipants([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changer de groupe (reconnexion à une nouvelle room)
|
||||||
|
*/
|
||||||
|
const switchGroup = useCallback(async (url, token) => {
|
||||||
|
console.log('🔄 Changement de groupe...');
|
||||||
|
|
||||||
|
// Déconnexion propre
|
||||||
|
cleanup();
|
||||||
|
if (roomRef.current) {
|
||||||
|
roomRef.current.disconnect();
|
||||||
|
roomRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnected(false);
|
||||||
|
setParticipants([]);
|
||||||
|
|
||||||
|
// Reconnexion avec nouveau token
|
||||||
|
await connect(url, token);
|
||||||
|
}, [connect]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Débloque l'audio sur mobile (iOS/Android)
|
||||||
|
* Doit être appelé dans un gestionnaire d'événement utilisateur
|
||||||
|
*/
|
||||||
|
const unlockAudio = useCallback(() => {
|
||||||
|
if (isAudioUnlockedRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Créer un contexte audio silencieux pour débloquer l'API
|
||||||
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const oscillator = ctx.createOscillator();
|
||||||
|
const gainNode = ctx.createGain();
|
||||||
|
|
||||||
|
gainNode.gain.value = 0; // Silence
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(ctx.destination);
|
||||||
|
oscillator.start(0);
|
||||||
|
oscillator.stop(0.001);
|
||||||
|
|
||||||
|
isAudioUnlockedRef.current = true;
|
||||||
|
console.log('✓ Audio débloqué (mobile)');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Audio unlock échoué:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commencer à parler (unmute micro)
|
||||||
|
*/
|
||||||
|
const startTalking = useCallback(async () => {
|
||||||
|
console.log('🎤 startTalking appelé');
|
||||||
|
console.log(' localTrackRef.current:', localTrackRef.current);
|
||||||
|
|
||||||
|
if (!localTrackRef.current) {
|
||||||
|
console.warn('⚠️ Pas de track audio local disponible');
|
||||||
|
alert('Microphone non disponible. Réessayez.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Débloquer audio sur mobile au premier appui
|
||||||
|
unlockAudio();
|
||||||
|
|
||||||
|
// Feedback immédiat AVANT unmute
|
||||||
|
setIsTalking(true);
|
||||||
|
|
||||||
|
await localTrackRef.current.unmute();
|
||||||
|
console.log('🎤 PTT: Talking (unmuted)');
|
||||||
|
|
||||||
|
// Vibration haptique (si supporté)
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate(50);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur unmute:', error);
|
||||||
|
setIsTalking(false);
|
||||||
|
alert(`Erreur microphone: ${error.message}`);
|
||||||
|
}
|
||||||
|
}, [unlockAudio]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrêter de parler (mute micro)
|
||||||
|
*/
|
||||||
|
const stopTalking = useCallback(async () => {
|
||||||
|
if (!localTrackRef.current) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await localTrackRef.current.mute();
|
||||||
|
setIsTalking(false);
|
||||||
|
console.log('🎤 PTT: Listening');
|
||||||
|
|
||||||
|
// Vibration haptique (si supporté)
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate(30);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur mute:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mise à jour liste participants
|
||||||
|
*/
|
||||||
|
const updateParticipants = () => {
|
||||||
|
if (!roomRef.current) return;
|
||||||
|
|
||||||
|
const room = roomRef.current;
|
||||||
|
const participantsList = [];
|
||||||
|
|
||||||
|
// Participants distants
|
||||||
|
room.remoteParticipants.forEach((participant) => {
|
||||||
|
const audioTracks = participant.audioTracks ? Array.from(participant.audioTracks.values()) : [];
|
||||||
|
const audioPublication = audioTracks[0];
|
||||||
|
const isSpeaking = room.activeSpeakers.some(s => s.identity === participant.identity);
|
||||||
|
|
||||||
|
participantsList.push({
|
||||||
|
identity: participant.identity,
|
||||||
|
name: participant.name || participant.identity,
|
||||||
|
isLocal: false,
|
||||||
|
isSpeaking,
|
||||||
|
hasAudio: audioPublication?.isSubscribed || false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setParticipants(participantsList);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup analyseur audio pour VU-mètre (micro local)
|
||||||
|
*/
|
||||||
|
const setupAudioAnalyser = (track) => {
|
||||||
|
try {
|
||||||
|
const mediaStream = track.mediaStream;
|
||||||
|
if (!mediaStream) return;
|
||||||
|
|
||||||
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||||
|
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
audioContextRef.current = audioContext;
|
||||||
|
analyserRef.current = analyser;
|
||||||
|
|
||||||
|
console.log('✓ Analyseur audio local configuré');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur setup analyser local:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup analyseur audio pour pistes distantes (audio entrant)
|
||||||
|
*/
|
||||||
|
const setupRemoteAudioAnalyser = (track) => {
|
||||||
|
try {
|
||||||
|
const mediaStream = track.mediaStream;
|
||||||
|
if (!mediaStream) return;
|
||||||
|
|
||||||
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||||
|
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
remoteAudioContextRef.current = audioContext;
|
||||||
|
remoteAnalyserRef.current = analyser;
|
||||||
|
|
||||||
|
console.log('✓ Analyseur audio distant configuré');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur setup analyser distant:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyser niveau audio (pour VU-mètre)
|
||||||
|
* Alterne entre micro local (si talking) et audio entrant (si listening)
|
||||||
|
*/
|
||||||
|
const analyseAudioLevel = useCallback(() => {
|
||||||
|
const analyse = () => {
|
||||||
|
// Choisir l'analyseur selon l'état
|
||||||
|
const analyser = isTalking ? analyserRef.current : remoteAnalyserRef.current;
|
||||||
|
|
||||||
|
if (analyser) {
|
||||||
|
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
analyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
// Calculer moyenne
|
||||||
|
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
|
||||||
|
const normalized = Math.min(100, (average / 255) * 100);
|
||||||
|
|
||||||
|
setAudioLevel(normalized);
|
||||||
|
} else {
|
||||||
|
setAudioLevel(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(analyse);
|
||||||
|
};
|
||||||
|
|
||||||
|
analyse();
|
||||||
|
}, [isTalking]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup
|
||||||
|
*/
|
||||||
|
const cleanup = () => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remoteAnimationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(remoteAnimationFrameRef.current);
|
||||||
|
remoteAnimationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioContextRef.current) {
|
||||||
|
audioContextRef.current.close();
|
||||||
|
audioContextRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remoteAudioContextRef.current) {
|
||||||
|
remoteAudioContextRef.current.close();
|
||||||
|
remoteAudioContextRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
analyserRef.current = null;
|
||||||
|
remoteAnalyserRef.current = null;
|
||||||
|
localTrackRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redémarrer l'analyse audio quand isTalking change
|
||||||
|
useEffect(() => {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redémarrer l'analyse si on a au moins un analyseur
|
||||||
|
if (analyserRef.current || remoteAnalyserRef.current) {
|
||||||
|
analyseAudioLevel();
|
||||||
|
}
|
||||||
|
}, [isTalking, analyseAudioLevel]);
|
||||||
|
|
||||||
|
// Cleanup au démontage
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
disconnect();
|
||||||
|
};
|
||||||
|
}, [disconnect]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected,
|
||||||
|
participants,
|
||||||
|
isTalking,
|
||||||
|
audioLevel,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
switchGroup,
|
||||||
|
startTalking,
|
||||||
|
stopTalking
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/* PTT Live - Styles globaux */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Couleurs */
|
||||||
|
--color-bg: #0a0a0a;
|
||||||
|
--color-surface: #1a1a1a;
|
||||||
|
--color-surface-hover: #252525;
|
||||||
|
--color-border: #333;
|
||||||
|
--color-text: #ffffff;
|
||||||
|
--color-text-secondary: #999;
|
||||||
|
--color-primary: #3b82f6;
|
||||||
|
--color-primary-hover: #2563eb;
|
||||||
|
--color-success: #10b981;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-danger: #ef4444;
|
||||||
|
|
||||||
|
/* PTT States */
|
||||||
|
--color-ptt-idle: #374151;
|
||||||
|
--color-ptt-talking: #ef4444;
|
||||||
|
--color-ptt-listening: #10b981;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
|
|
||||||
|
/* Fonts */
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Désactiver la sélection sur les éléments interactifs */
|
||||||
|
button,
|
||||||
|
.no-select {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles des boutons */
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbars sombres */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utilitaires */
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-col {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-sm {
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-md {
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-lg {
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App.jsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'PTT Live',
|
||||||
|
short_name: 'PTT Live',
|
||||||
|
description: 'Professional WebRTC Intercom for Event Technicians',
|
||||||
|
theme_color: '#1a1a1a',
|
||||||
|
background_color: '#1a1a1a',
|
||||||
|
display: 'standalone',
|
||||||
|
scope: '/',
|
||||||
|
start_url: '/',
|
||||||
|
orientation: 'portrait',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any maskable'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https:\/\/.*\.livekit\.cloud\/.*/i,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'livekit-cache',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 10,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 // 24 hours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true,
|
||||||
|
https: {
|
||||||
|
key: fs.readFileSync('./localhost+3-key.pem'),
|
||||||
|
cert: fs.readFileSync('./localhost+3.pem'),
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '')
|
||||||
|
},
|
||||||
|
'/livekit': {
|
||||||
|
target: 'ws://10.1.1.111:7880',
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/livekit/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Configuration LiveKit pour PTT Live
|
||||||
|
|
||||||
|
## Option 1 : LiveKit Cloud (Recommandé pour démarrer)
|
||||||
|
|
||||||
|
LiveKit Cloud offre un tier gratuit parfait pour le développement et les tests.
|
||||||
|
|
||||||
|
### Étapes :
|
||||||
|
|
||||||
|
1. **Créer un compte LiveKit Cloud**
|
||||||
|
- Aller sur https://cloud.livekit.io
|
||||||
|
- Créer un compte gratuit
|
||||||
|
- Créer un nouveau projet
|
||||||
|
|
||||||
|
2. **Obtenir les clés API**
|
||||||
|
- Dans le dashboard, aller dans "Settings" > "Keys"
|
||||||
|
- Copier votre `API Key` et `API Secret`
|
||||||
|
- Copier votre `WebSocket URL` (format: `wss://your-project.livekit.cloud`)
|
||||||
|
|
||||||
|
3. **Configurer le serveur PTT Live**
|
||||||
|
|
||||||
|
Créer/éditer le fichier `server/.env` :
|
||||||
|
```bash
|
||||||
|
# LiveKit Cloud
|
||||||
|
LIVEKIT_URL=wss://votre-projet.livekit.cloud
|
||||||
|
LIVEKIT_API_KEY=APIxxxxxxxxxx
|
||||||
|
LIVEKIT_API_SECRET=xxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Mode
|
||||||
|
USE_LOCAL_LIVEKIT=false
|
||||||
|
|
||||||
|
# Server
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Redémarrer le serveur**
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Tester**
|
||||||
|
- Le serveur devrait afficher : `✓ Mode LiveKit Cloud`
|
||||||
|
- Ouvrir http://localhost:5173
|
||||||
|
- Se connecter avec un nom et le groupe "Équipe Production"
|
||||||
|
- Ouvrir un second onglet/fenêtre pour tester à 2 participants
|
||||||
|
|
||||||
|
### Limitations tier gratuit :
|
||||||
|
- 10 000 minutes/mois
|
||||||
|
- 50 participants simultanés max
|
||||||
|
- Parfait pour développement et tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Option 2 : LiveKit Server Local (Auto-hébergé)
|
||||||
|
|
||||||
|
Pour un déploiement en production auto-hébergé.
|
||||||
|
|
||||||
|
### Prérequis :
|
||||||
|
- macOS, Linux ou Windows
|
||||||
|
- Port 7880 disponible
|
||||||
|
- Ports 50000-60000 disponibles pour WebRTC
|
||||||
|
|
||||||
|
### Installation :
|
||||||
|
|
||||||
|
1. **Télécharger le binaire LiveKit Server**
|
||||||
|
|
||||||
|
macOS (ARM64 - Apple Silicon) :
|
||||||
|
```bash
|
||||||
|
cd server/bin
|
||||||
|
curl -L -o livekit.tar.gz \
|
||||||
|
https://github.com/livekit/livekit/releases/download/v1.7.2/livekit_v1.7.2_darwin_arm64.tar.gz
|
||||||
|
tar -xzf livekit.tar.gz
|
||||||
|
chmod +x livekit-server
|
||||||
|
rm livekit.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
macOS (AMD64 - Intel) :
|
||||||
|
```bash
|
||||||
|
cd server/bin
|
||||||
|
curl -L -o livekit.tar.gz \
|
||||||
|
https://github.com/livekit/livekit/releases/download/v1.7.2/livekit_v1.7.2_darwin_amd64.tar.gz
|
||||||
|
tar -xzf livekit.tar.gz
|
||||||
|
chmod +x livekit-server
|
||||||
|
rm livekit.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux (AMD64) :
|
||||||
|
```bash
|
||||||
|
cd server/bin
|
||||||
|
curl -L -o livekit.tar.gz \
|
||||||
|
https://github.com/livekit/livekit/releases/download/v1.7.2/livekit_v1.7.2_linux_amd64.tar.gz
|
||||||
|
tar -xzf livekit.tar.gz
|
||||||
|
chmod +x livekit-server
|
||||||
|
rm livekit.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Générer des clés API**
|
||||||
|
```bash
|
||||||
|
# Génération clés aléatoires sécurisées
|
||||||
|
API_KEY="APIkey$(openssl rand -hex 16)"
|
||||||
|
API_SECRET=$(openssl rand -base64 32)
|
||||||
|
|
||||||
|
echo "API_KEY: $API_KEY"
|
||||||
|
echo "API_SECRET: $API_SECRET"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configurer server/.env**
|
||||||
|
```bash
|
||||||
|
# LiveKit Local
|
||||||
|
LIVEKIT_URL=ws://localhost:7880
|
||||||
|
LIVEKIT_API_KEY=APIkey...
|
||||||
|
LIVEKIT_API_SECRET=...
|
||||||
|
|
||||||
|
# Mode local activé
|
||||||
|
USE_LOCAL_LIVEKIT=true
|
||||||
|
|
||||||
|
# Server
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Démarrer**
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Le serveur lancera automatiquement LiveKit Server en local.
|
||||||
|
|
||||||
|
### Production HTTPS :
|
||||||
|
|
||||||
|
Pour la production, LiveKit Server doit être derrière un reverse proxy HTTPS (nginx, Caddy, Traefik).
|
||||||
|
|
||||||
|
Exemple Caddy :
|
||||||
|
```
|
||||||
|
your-domain.com {
|
||||||
|
reverse_proxy localhost:7880
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dépannage
|
||||||
|
|
||||||
|
### "Connexion impossible. Vérifiez le serveur."
|
||||||
|
|
||||||
|
1. Vérifier que le serveur Node.js tourne (`http://localhost:3000/health`)
|
||||||
|
2. Vérifier les clés dans `server/.env`
|
||||||
|
3. Vérifier les logs serveur pour erreurs LiveKit
|
||||||
|
4. En mode Cloud : vérifier que l'URL est bien `wss://` (pas `ws://`)
|
||||||
|
5. En mode Local : vérifier que le binaire `livekit-server` existe dans `server/bin/`
|
||||||
|
|
||||||
|
### "Token generation failed"
|
||||||
|
|
||||||
|
- Vérifier que `LIVEKIT_API_KEY` et `LIVEKIT_API_SECRET` sont corrects
|
||||||
|
- Les clés doivent correspondre entre le serveur Node.js et LiveKit Server
|
||||||
|
|
||||||
|
### Permissions microphone (navigateur)
|
||||||
|
|
||||||
|
- Chrome/Edge : Aller dans Paramètres > Confidentialité > Microphone
|
||||||
|
- Firefox : Autoriser quand demandé
|
||||||
|
- Safari : Préférences > Sites web > Microphone
|
||||||
|
|
||||||
|
### Performance réseau
|
||||||
|
|
||||||
|
- LiveKit Cloud : latence dépend de votre localisation (serveurs en Europe/US)
|
||||||
|
- Local : latence minimale sur WiFi local (~20-50ms)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests de validation
|
||||||
|
|
||||||
|
Checklist pour vérifier que tout fonctionne :
|
||||||
|
|
||||||
|
- [ ] Serveur démarre sans erreur
|
||||||
|
- [ ] Client se connecte (pas d'erreur "Connexion impossible")
|
||||||
|
- [ ] 2 clients peuvent rejoindre le même groupe
|
||||||
|
- [ ] Le bouton PTT fonctionne (maintenir pour parler)
|
||||||
|
- [ ] L'audio est transmis entre les 2 clients
|
||||||
|
- [ ] La liste des participants s'update en temps réel
|
||||||
|
- [ ] Le VU-mètre affiche du niveau audio
|
||||||
|
- [ ] Vibration haptique au press/release (mobile)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note** : Pour la Phase 1, LiveKit Cloud est recommandé. Le mode local sera nécessaire en Phase 3 pour l'intégration avec le bridge audio CoreAudio/JACK.
|
||||||
Executable
+124
@@ -0,0 +1,124 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 PTT Live - Installation macOS"
|
||||||
|
echo "=================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Couleurs pour le terminal
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Vérifier Node.js
|
||||||
|
echo "📦 Vérification Node.js..."
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo -e "${RED}❌ Node.js n'est pas installé${NC}"
|
||||||
|
echo " Installez Node.js 20+ depuis https://nodejs.org"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
||||||
|
if [ "$NODE_VERSION" -lt 20 ]; then
|
||||||
|
echo -e "${RED}❌ Node.js version trop ancienne ($NODE_VERSION)${NC}"
|
||||||
|
echo " Node.js 20+ requis"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✅ Node.js $(node -v)${NC}"
|
||||||
|
|
||||||
|
# Vérifier npm
|
||||||
|
if ! command -v npm &> /dev/null; then
|
||||||
|
echo -e "${RED}❌ npm n'est pas installé${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✅ npm $(npm -v)${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Vérifier Homebrew
|
||||||
|
echo "🍺 Vérification Homebrew..."
|
||||||
|
if ! command -v brew &> /dev/null; then
|
||||||
|
echo -e "${RED}❌ Homebrew n'est pas installé${NC}"
|
||||||
|
echo " Installez Homebrew depuis https://brew.sh"
|
||||||
|
echo " Ou exécutez :"
|
||||||
|
echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✅ Homebrew $(brew --version | head -n 1)${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Installer LiveKit Server via Homebrew
|
||||||
|
echo "📥 Installation LiveKit Server..."
|
||||||
|
if command -v livekit-server &> /dev/null; then
|
||||||
|
CURRENT_VERSION=$(livekit-server --version 2>&1 | head -n 1 || echo "version inconnue")
|
||||||
|
echo -e "${YELLOW}⚠️ LiveKit Server déjà installé ($CURRENT_VERSION)${NC}"
|
||||||
|
read -p " Mettre à jour ? (o/N) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Oo]$ ]]; then
|
||||||
|
brew upgrade livekit
|
||||||
|
echo -e "${GREEN}✅ LiveKit Server mis à jour${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✅ LiveKit Server existant conservé${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
brew install livekit
|
||||||
|
echo -e "${GREEN}✅ LiveKit Server installé${NC}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Installer dépendances serveur
|
||||||
|
echo "📦 Installation dépendances serveur..."
|
||||||
|
cd ../server
|
||||||
|
npm install
|
||||||
|
echo -e "${GREEN}✅ Dépendances serveur installées${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Installer dépendances client
|
||||||
|
echo "📦 Installation dépendances client..."
|
||||||
|
cd ../client
|
||||||
|
npm install
|
||||||
|
echo -e "${GREEN}✅ Dépendances client installées${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Créer fichier .env
|
||||||
|
echo "🔑 Génération configuration LiveKit..."
|
||||||
|
|
||||||
|
cat > server/.env << EOF
|
||||||
|
USE_LOCAL_LIVEKIT=true
|
||||||
|
|
||||||
|
# LiveKit Configuration
|
||||||
|
LIVEKIT_URL=ws://localhost:7880
|
||||||
|
# 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 -e "${GREEN}✅ Clés API générées (server/.env)${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Message final
|
||||||
|
echo "=================================="
|
||||||
|
echo -e "${GREEN}✅ Installation terminée !${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "📝 Prochaines étapes :"
|
||||||
|
echo ""
|
||||||
|
echo " 1. Démarrer le serveur :"
|
||||||
|
echo " cd server && npm run dev"
|
||||||
|
echo ""
|
||||||
|
echo " 2. Démarrer le client (nouveau terminal) :"
|
||||||
|
echo " cd client && npm run dev"
|
||||||
|
echo ""
|
||||||
|
echo " 3. Ouvrir https://localhost:5173 dans votre navigateur"
|
||||||
|
echo ""
|
||||||
|
echo "📖 Documentation : README.md"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# PTT Live - Configuration environnement serveur
|
||||||
|
|
||||||
|
# LiveKit API Keys
|
||||||
|
# En dev, utilise les valeurs par défaut si non définies
|
||||||
|
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
|
||||||
|
|
||||||
|
# Mode LiveKit local (démarre livekit-server automatiquement)
|
||||||
|
USE_LOCAL_LIVEKIT=true
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
/**
|
||||||
|
* AudioBridge.js
|
||||||
|
* Classe principale du bridge audio serveur
|
||||||
|
*
|
||||||
|
* Orchestre :
|
||||||
|
* - Détection et initialisation du backend audio (CoreAudio/JACK/etc.)
|
||||||
|
* - Routing : CoreAudio → Opus → LiveKit
|
||||||
|
* - Routing : LiveKit → Opus → CoreAudio
|
||||||
|
* - Jitter buffer pour flux entrants
|
||||||
|
* - Logs détaillés et statistiques
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { platform } from 'os';
|
||||||
|
import CoreAudioBackend from './backends/CoreAudioBackend.js';
|
||||||
|
import OpusCodec, { OpusPresets } from './OpusCodec.js';
|
||||||
|
import JitterBuffer, { JitterBufferPresets } from './JitterBuffer.js';
|
||||||
|
import LiveKitClient from './LiveKitClient.js';
|
||||||
|
|
||||||
|
export class AudioBridge extends EventEmitter {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.options = {
|
||||||
|
// Configuration audio
|
||||||
|
sampleRate: options.sampleRate || 48000,
|
||||||
|
channels: options.channels || 1,
|
||||||
|
frameSize: options.frameSize || 960, // 20ms à 48kHz
|
||||||
|
|
||||||
|
// Configuration Opus
|
||||||
|
opusPreset: options.opusPreset || 'VOICE_STANDARD',
|
||||||
|
customOpusBitrate: options.customOpusBitrate || null,
|
||||||
|
|
||||||
|
// Configuration JitterBuffer
|
||||||
|
jitterBufferPreset: options.jitterBufferPreset || 'LOW_LATENCY',
|
||||||
|
|
||||||
|
// Configuration LiveKit
|
||||||
|
liveKitUrl: options.liveKitUrl || 'ws://localhost:7880',
|
||||||
|
liveKitToken: options.liveKitToken || null,
|
||||||
|
roomName: options.roomName || 'main',
|
||||||
|
|
||||||
|
// Configuration backend
|
||||||
|
inputDeviceId: options.inputDeviceId || null,
|
||||||
|
outputDeviceId: options.outputDeviceId || null,
|
||||||
|
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// Composants
|
||||||
|
this.audioBackend = null;
|
||||||
|
this.opusEncoder = null;
|
||||||
|
this.opusDecoder = null;
|
||||||
|
this.jitterBuffer = null;
|
||||||
|
this.liveKitClient = null;
|
||||||
|
|
||||||
|
// État
|
||||||
|
this.isRunning = false;
|
||||||
|
this.backendType = null;
|
||||||
|
|
||||||
|
// Statistiques
|
||||||
|
this.stats = {
|
||||||
|
startTime: null,
|
||||||
|
framesCapture: 0,
|
||||||
|
framesPlayback: 0,
|
||||||
|
bytesEncoded: 0,
|
||||||
|
bytesDecoded: 0,
|
||||||
|
errors: {
|
||||||
|
capture: 0,
|
||||||
|
playback: 0,
|
||||||
|
encode: 0,
|
||||||
|
decode: 0,
|
||||||
|
network: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise et démarre le bridge audio
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async start() {
|
||||||
|
if (this.isRunning) {
|
||||||
|
console.warn('Bridge audio déjà démarré');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🚀 Démarrage AudioBridge...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Détection et initialisation du backend audio
|
||||||
|
await this._initAudioBackend();
|
||||||
|
|
||||||
|
// 2. Initialisation des codecs Opus
|
||||||
|
this._initOpusCodecs();
|
||||||
|
|
||||||
|
// 3. Initialisation du jitter buffer
|
||||||
|
this._initJitterBuffer();
|
||||||
|
|
||||||
|
// 4. Connexion à LiveKit
|
||||||
|
await this._initLiveKit();
|
||||||
|
|
||||||
|
// 5. Démarrage du routing audio
|
||||||
|
await this._startAudioRouting();
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
this.stats.startTime = Date.now();
|
||||||
|
|
||||||
|
console.log('✅ AudioBridge démarré avec succès');
|
||||||
|
this.emit('started');
|
||||||
|
|
||||||
|
// Logs périodiques
|
||||||
|
this._startStatsLogger();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur démarrage AudioBridge:', error);
|
||||||
|
await this.stop();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détecte et initialise le backend audio approprié
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _initAudioBackend() {
|
||||||
|
const os = platform();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✓ Backend audio : CoreAudio (macOS natif)');
|
||||||
|
} else {
|
||||||
|
throw new Error('CoreAudio non disponible sur ce système');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Linux : JACK ou PipeWire (Phase 3)
|
||||||
|
else if (os === 'linux') {
|
||||||
|
throw new Error('Support Linux non encore implémenté (Phase 3)');
|
||||||
|
}
|
||||||
|
// Windows : WASAPI (futur)
|
||||||
|
else if (os === 'win32') {
|
||||||
|
throw new Error('Support Windows non encore implémenté');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new Error(`Plateforme non supportée : ${os}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste des devices disponibles
|
||||||
|
const devices = CoreAudioBackend.getDevices();
|
||||||
|
console.log(`📻 Devices audio détectés : ${devices.length}`);
|
||||||
|
devices.forEach(d => {
|
||||||
|
console.log(` - ${d.name} (in:${d.maxInputChannels}, out:${d.maxOutputChannels})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise les codecs Opus (encoder et decoder)
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_initOpusCodecs() {
|
||||||
|
// Configuration Opus depuis preset ou custom
|
||||||
|
let opusConfig = OpusPresets[this.options.opusPreset] || OpusPresets.VOICE_STANDARD;
|
||||||
|
|
||||||
|
if (this.options.customOpusBitrate) {
|
||||||
|
opusConfig = { ...opusConfig, bitrate: this.options.customOpusBitrate };
|
||||||
|
}
|
||||||
|
|
||||||
|
const codecOptions = {
|
||||||
|
sampleRate: this.options.sampleRate,
|
||||||
|
channels: this.options.channels,
|
||||||
|
frameSize: this.options.frameSize,
|
||||||
|
...opusConfig
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encoder pour capture (CoreAudio → Opus → LiveKit)
|
||||||
|
this.opusEncoder = new OpusCodec(codecOptions);
|
||||||
|
|
||||||
|
// Decoder pour lecture (LiveKit → Opus → CoreAudio)
|
||||||
|
this.opusDecoder = new OpusCodec(codecOptions);
|
||||||
|
|
||||||
|
console.log(`✓ Codecs Opus : ${opusConfig.bitrate / 1000}kbps, ${this.options.sampleRate}Hz`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise le jitter buffer
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_initJitterBuffer() {
|
||||||
|
const bufferConfig = JitterBufferPresets[this.options.jitterBufferPreset] || JitterBufferPresets.LOW_LATENCY;
|
||||||
|
|
||||||
|
this.jitterBuffer = new JitterBuffer(bufferConfig);
|
||||||
|
|
||||||
|
// Events du jitter buffer
|
||||||
|
this.jitterBuffer.on('underrun', () => {
|
||||||
|
console.warn('⚠️ Jitter buffer underrun');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.jitterBuffer.on('overrun', () => {
|
||||||
|
console.warn('⚠️ Jitter buffer overrun');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.jitterBuffer.on('adapted', ({ newTargetSize, reason }) => {
|
||||||
|
console.log(`🔧 Jitter buffer adapté : ${newTargetSize} frames (raison: ${reason})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✓ Jitter buffer : cible ${bufferConfig.targetSize} frames`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise la connexion LiveKit
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _initLiveKit() {
|
||||||
|
if (!this.options.liveKitToken) {
|
||||||
|
throw new Error('Token LiveKit requis');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.liveKitClient = new LiveKitClient({
|
||||||
|
url: this.options.liveKitUrl,
|
||||||
|
token: this.options.liveKitToken,
|
||||||
|
roomName: this.options.roomName,
|
||||||
|
participantName: 'AudioBridge',
|
||||||
|
audioBitrate: this.opusEncoder.options.bitrate
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre le routing audio bidirectionnel
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _startAudioRouting() {
|
||||||
|
// ===== ROUTING CAPTURE : CoreAudio → Opus → LiveKit =====
|
||||||
|
this.audioBackend.on('audioData', (pcmData) => {
|
||||||
|
try {
|
||||||
|
// Encodage PCM → Opus
|
||||||
|
const opusData = this.opusEncoder.encode(pcmData);
|
||||||
|
|
||||||
|
if (opusData) {
|
||||||
|
this.stats.framesCapture++;
|
||||||
|
this.stats.bytesEncoded += opusData.length;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
} else {
|
||||||
|
this.stats.errors.encode++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur routing capture:', error);
|
||||||
|
this.stats.errors.capture++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Démarrage capture
|
||||||
|
await this.audioBackend.startCapture();
|
||||||
|
|
||||||
|
// ===== ROUTING LECTURE : LiveKit → Opus → CoreAudio =====
|
||||||
|
// La lecture sera démarrée une fois qu'on reçoit des tracks distants
|
||||||
|
await this.audioBackend.startPlayback();
|
||||||
|
|
||||||
|
console.log('✓ Routing audio bidirectionnel actif');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gère l'arrivée d'un track audio distant
|
||||||
|
* @param {RemoteAudioTrack} track - Track LiveKit
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_handleRemoteAudioTrack(track) {
|
||||||
|
// Récupération du MediaStream du track
|
||||||
|
const mediaStream = new MediaStream([track.mediaStreamTrack]);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// TODO: Implémenter réception bas niveau Opus depuis LiveKit
|
||||||
|
console.warn('Réception track distant : implémentation complète en cours');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrête le bridge audio
|
||||||
|
*/
|
||||||
|
async stop() {
|
||||||
|
if (!this.isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🛑 Arrêt AudioBridge...');
|
||||||
|
|
||||||
|
// Arrêt des composants
|
||||||
|
if (this.audioBackend) {
|
||||||
|
this.audioBackend.destroy();
|
||||||
|
this.audioBackend = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.liveKitClient) {
|
||||||
|
await this.liveKitClient.destroy();
|
||||||
|
this.liveKitClient = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.jitterBuffer) {
|
||||||
|
this.jitterBuffer.destroy();
|
||||||
|
this.jitterBuffer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.opusEncoder) {
|
||||||
|
this.opusEncoder.destroy();
|
||||||
|
this.opusEncoder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.opusDecoder) {
|
||||||
|
this.opusDecoder.destroy();
|
||||||
|
this.opusDecoder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = false;
|
||||||
|
|
||||||
|
console.log('✓ AudioBridge arrêté');
|
||||||
|
this.emit('stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logger de statistiques périodiques
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_startStatsLogger() {
|
||||||
|
const logInterval = 10000; // 10s
|
||||||
|
|
||||||
|
const logger = setInterval(() => {
|
||||||
|
if (!this.isRunning) {
|
||||||
|
clearInterval(logger);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = this.getStats();
|
||||||
|
console.log('📊 Statistiques AudioBridge:');
|
||||||
|
console.log(` Uptime: ${Math.floor(stats.uptimeSeconds)}s`);
|
||||||
|
console.log(` Capture: ${stats.framesCapture} frames (${stats.errors.capture} erreurs)`);
|
||||||
|
console.log(` Playback: ${stats.framesPlayback} frames (${stats.errors.playback} erreurs)`);
|
||||||
|
console.log(` Jitter buffer: ${stats.jitterBuffer.currentBufferSize}/${stats.jitterBuffer.maxSize} (santé: ${stats.jitterBuffer.health.toFixed(1)}%)`);
|
||||||
|
console.log(` Codec: enc=${stats.codec.encoded}, dec=${stats.codec.decoded}`);
|
||||||
|
}, logInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les statistiques complètes
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
const uptime = this.stats.startTime ? (Date.now() - this.stats.startTime) / 1000 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
running: this.isRunning,
|
||||||
|
backendType: this.backendType,
|
||||||
|
uptimeSeconds: uptime,
|
||||||
|
framesCapture: this.stats.framesCapture,
|
||||||
|
framesPlayback: this.stats.framesPlayback,
|
||||||
|
bytesEncoded: this.stats.bytesEncoded,
|
||||||
|
bytesDecoded: this.stats.bytesDecoded,
|
||||||
|
errors: { ...this.stats.errors },
|
||||||
|
audioBackend: this.audioBackend ? this.audioBackend.getStats() : null,
|
||||||
|
codec: this.opusEncoder ? this.opusEncoder.getStats() : null,
|
||||||
|
jitterBuffer: this.jitterBuffer ? this.jitterBuffer.getStats() : null,
|
||||||
|
liveKit: this.liveKitClient ? this.liveKitClient.getStats() : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détruit le bridge et libère toutes les ressources
|
||||||
|
*/
|
||||||
|
async destroy() {
|
||||||
|
await this.stop();
|
||||||
|
this.removeAllListeners();
|
||||||
|
console.log('✓ AudioBridge détruit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AudioBridge;
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* JitterBuffer.js
|
||||||
|
* Buffer FIFO pour compenser le jitter réseau et garantir lecture fluide
|
||||||
|
*
|
||||||
|
* Gère :
|
||||||
|
* - Buffer circulaire avec cible 40ms
|
||||||
|
* - Détection underrun (buffer vide)
|
||||||
|
* - Détection overrun (buffer plein)
|
||||||
|
* - Statistiques latence et santé buffer
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
export class JitterBuffer extends EventEmitter {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.options = {
|
||||||
|
targetSize: options.targetSize || 2, // Nombre de frames cible (40ms = 2x 20ms frames)
|
||||||
|
maxSize: options.maxSize || 10, // Taille max buffer (200ms)
|
||||||
|
minSize: options.minSize || 1, // Taille min avant lecture
|
||||||
|
adaptiveMode: options.adaptiveMode !== false, // Adaptation automatique
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// Buffer de frames
|
||||||
|
this.buffer = [];
|
||||||
|
|
||||||
|
// Statistiques
|
||||||
|
this.stats = {
|
||||||
|
received: 0,
|
||||||
|
played: 0,
|
||||||
|
underruns: 0,
|
||||||
|
overruns: 0,
|
||||||
|
dropped: 0,
|
||||||
|
avgBufferSize: 0,
|
||||||
|
currentBufferSize: 0,
|
||||||
|
latencyMs: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Historique pour adaptation
|
||||||
|
this.bufferSizeHistory = [];
|
||||||
|
this.historyMaxLength = 100;
|
||||||
|
|
||||||
|
// État
|
||||||
|
this.isReady = false;
|
||||||
|
this.lastUpdateTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute une frame au buffer
|
||||||
|
* @param {Buffer} frame - Frame audio (Opus ou PCM)
|
||||||
|
* @param {Object} metadata - Métadonnées optionnelles (timestamp, sequence, etc.)
|
||||||
|
* @returns {boolean} True si ajouté, false si buffer plein
|
||||||
|
*/
|
||||||
|
push(frame, metadata = {}) {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Vérification buffer plein
|
||||||
|
if (this.buffer.length >= this.options.maxSize) {
|
||||||
|
this.stats.overruns++;
|
||||||
|
this.emit('overrun', {
|
||||||
|
bufferSize: this.buffer.length,
|
||||||
|
maxSize: this.options.maxSize
|
||||||
|
});
|
||||||
|
|
||||||
|
// En mode adaptatif, on drop la frame la plus ancienne
|
||||||
|
if (this.options.adaptiveMode) {
|
||||||
|
this.buffer.shift();
|
||||||
|
this.stats.dropped++;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajout de la frame avec timestamp
|
||||||
|
this.buffer.push({
|
||||||
|
data: frame,
|
||||||
|
timestamp: now,
|
||||||
|
metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
this.stats.received++;
|
||||||
|
this.stats.currentBufferSize = this.buffer.length;
|
||||||
|
|
||||||
|
// Mise à jour historique
|
||||||
|
this._updateHistory();
|
||||||
|
|
||||||
|
// Vérification si le buffer est prêt pour la lecture
|
||||||
|
if (!this.isReady && this.buffer.length >= this.options.minSize) {
|
||||||
|
this.isReady = true;
|
||||||
|
this.emit('ready', { bufferSize: this.buffer.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère la prochaine frame du buffer
|
||||||
|
* @returns {Buffer|null} Frame audio ou null si buffer vide
|
||||||
|
*/
|
||||||
|
pop() {
|
||||||
|
if (this.buffer.length === 0) {
|
||||||
|
this.stats.underruns++;
|
||||||
|
this.isReady = false;
|
||||||
|
this.emit('underrun', {
|
||||||
|
bufferSize: 0
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupération de la frame la plus ancienne
|
||||||
|
const item = this.buffer.shift();
|
||||||
|
this.stats.played++;
|
||||||
|
this.stats.currentBufferSize = this.buffer.length;
|
||||||
|
|
||||||
|
// Calcul latence (temps passé dans le buffer)
|
||||||
|
const latency = Date.now() - item.timestamp;
|
||||||
|
this.stats.latencyMs = latency;
|
||||||
|
|
||||||
|
// Mise à jour historique
|
||||||
|
this._updateHistory();
|
||||||
|
|
||||||
|
return item.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère la prochaine frame sans la retirer du buffer
|
||||||
|
* @returns {Buffer|null}
|
||||||
|
*/
|
||||||
|
peek() {
|
||||||
|
if (this.buffer.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.buffer[0].data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vide le buffer
|
||||||
|
*/
|
||||||
|
flush() {
|
||||||
|
const flushedCount = this.buffer.length;
|
||||||
|
this.buffer = [];
|
||||||
|
this.isReady = false;
|
||||||
|
this.stats.currentBufferSize = 0;
|
||||||
|
this.emit('flush', { flushedCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mise à jour de l'historique des tailles de buffer
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_updateHistory() {
|
||||||
|
this.bufferSizeHistory.push(this.buffer.length);
|
||||||
|
|
||||||
|
// Limite la taille de l'historique
|
||||||
|
if (this.bufferSizeHistory.length > this.historyMaxLength) {
|
||||||
|
this.bufferSizeHistory.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcul moyenne
|
||||||
|
const sum = this.bufferSizeHistory.reduce((a, b) => a + b, 0);
|
||||||
|
this.stats.avgBufferSize = sum / this.bufferSizeHistory.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adaptation automatique de la taille cible du buffer
|
||||||
|
* Appelé périodiquement pour ajuster selon les conditions réseau
|
||||||
|
*/
|
||||||
|
adapt() {
|
||||||
|
if (!this.options.adaptiveMode) return;
|
||||||
|
|
||||||
|
// Analyse de l'historique pour détecter les tendances
|
||||||
|
if (this.bufferSizeHistory.length < 10) return;
|
||||||
|
|
||||||
|
const recent = this.bufferSizeHistory.slice(-10);
|
||||||
|
const avg = recent.reduce((a, b) => a + b, 0) / recent.length;
|
||||||
|
|
||||||
|
// Si le buffer est souvent proche du min, augmenter la cible
|
||||||
|
if (avg < this.options.targetSize * 0.7 && this.options.targetSize < this.options.maxSize / 2) {
|
||||||
|
this.options.targetSize++;
|
||||||
|
this.emit('adapted', {
|
||||||
|
newTargetSize: this.options.targetSize,
|
||||||
|
reason: 'buffer_low',
|
||||||
|
avgSize: avg
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si le buffer est souvent plein, réduire la cible
|
||||||
|
if (avg > this.options.targetSize * 1.5 && this.options.targetSize > this.options.minSize) {
|
||||||
|
this.options.targetSize--;
|
||||||
|
this.emit('adapted', {
|
||||||
|
newTargetSize: this.options.targetSize,
|
||||||
|
reason: 'buffer_high',
|
||||||
|
avgSize: avg
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient les statistiques du buffer
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
...this.stats,
|
||||||
|
isReady: this.isReady,
|
||||||
|
targetSize: this.options.targetSize,
|
||||||
|
maxSize: this.options.maxSize,
|
||||||
|
fillPercentage: (this.buffer.length / this.options.maxSize) * 100,
|
||||||
|
health: this._getHealthScore()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule un score de santé du buffer (0-100)
|
||||||
|
* @returns {number}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getHealthScore() {
|
||||||
|
let score = 100;
|
||||||
|
|
||||||
|
// Pénalité pour underruns
|
||||||
|
if (this.stats.played > 0) {
|
||||||
|
const underrunRate = this.stats.underruns / this.stats.played;
|
||||||
|
score -= underrunRate * 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pénalité pour overruns
|
||||||
|
if (this.stats.received > 0) {
|
||||||
|
const overrunRate = this.stats.overruns / this.stats.received;
|
||||||
|
score -= overrunRate * 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pénalité si taille actuelle loin de la cible
|
||||||
|
const targetDiff = Math.abs(this.buffer.length - this.options.targetSize);
|
||||||
|
score -= (targetDiff / this.options.maxSize) * 20;
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(100, score));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réinitialise les statistiques
|
||||||
|
*/
|
||||||
|
resetStats() {
|
||||||
|
this.stats = {
|
||||||
|
received: 0,
|
||||||
|
played: 0,
|
||||||
|
underruns: 0,
|
||||||
|
overruns: 0,
|
||||||
|
dropped: 0,
|
||||||
|
avgBufferSize: 0,
|
||||||
|
currentBufferSize: this.buffer.length,
|
||||||
|
latencyMs: 0
|
||||||
|
};
|
||||||
|
this.bufferSizeHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si le buffer est prêt pour la lecture
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isReadyToPlay() {
|
||||||
|
return this.isReady && this.buffer.length >= this.options.minSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient la latence actuelle du buffer en ms
|
||||||
|
* @param {number} frameDurationMs - Durée d'une frame en ms
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getCurrentLatency(frameDurationMs) {
|
||||||
|
return this.buffer.length * frameDurationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détruit le buffer et libère les ressources
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.flush();
|
||||||
|
this.removeAllListeners();
|
||||||
|
console.log('✓ JitterBuffer détruit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Présets de configuration JitterBuffer selon le cas d'usage
|
||||||
|
*/
|
||||||
|
export const JitterBufferPresets = {
|
||||||
|
// Très faible latence (réseau local stable)
|
||||||
|
ULTRA_LOW_LATENCY: {
|
||||||
|
targetSize: 1,
|
||||||
|
maxSize: 5,
|
||||||
|
minSize: 1,
|
||||||
|
adaptiveMode: false
|
||||||
|
},
|
||||||
|
|
||||||
|
// Faible latence (WiFi local)
|
||||||
|
LOW_LATENCY: {
|
||||||
|
targetSize: 2,
|
||||||
|
maxSize: 8,
|
||||||
|
minSize: 1,
|
||||||
|
adaptiveMode: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Latence standard (défaut, bon compromis)
|
||||||
|
STANDARD: {
|
||||||
|
targetSize: 3,
|
||||||
|
maxSize: 10,
|
||||||
|
minSize: 2,
|
||||||
|
adaptiveMode: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// Haute tolérance (réseau instable)
|
||||||
|
HIGH_TOLERANCE: {
|
||||||
|
targetSize: 5,
|
||||||
|
maxSize: 15,
|
||||||
|
minSize: 2,
|
||||||
|
adaptiveMode: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JitterBuffer;
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* LiveKitClient.js
|
||||||
|
* Client LiveKit pour le bridge audio serveur
|
||||||
|
*
|
||||||
|
* Gère :
|
||||||
|
* - Connexion à la room en tant que participant "bridge"
|
||||||
|
* - Publication de track audio (Opus depuis carte son)
|
||||||
|
* - Souscription aux tracks des autres participants (clients PWA)
|
||||||
|
* - Reconnexion automatique
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RemoteTrack,
|
||||||
|
RemoteParticipant,
|
||||||
|
LocalAudioTrack,
|
||||||
|
TrackPublishOptions,
|
||||||
|
AudioPresets
|
||||||
|
} from 'livekit-client';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
export class LiveKitClient extends EventEmitter {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.options = {
|
||||||
|
url: options.url || 'ws://localhost:7880',
|
||||||
|
roomName: options.roomName || 'main',
|
||||||
|
participantName: options.participantName || 'AudioBridge',
|
||||||
|
token: options.token || null,
|
||||||
|
autoSubscribe: options.autoSubscribe !== false,
|
||||||
|
audioBitrate: options.audioBitrate || 96000, // 96kbps par défaut
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.room = null;
|
||||||
|
this.localAudioTrack = null;
|
||||||
|
this.isConnected = false;
|
||||||
|
this.reconnecting = false;
|
||||||
|
|
||||||
|
// Map des participants distants et leurs tracks
|
||||||
|
this.remoteParticipants = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connexion à la room LiveKit
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this.isConnected) {
|
||||||
|
console.warn('Déjà connecté à LiveKit');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.options.token) {
|
||||||
|
throw new Error('Token LiveKit requis pour la connexion');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.room = new Room({
|
||||||
|
adaptiveStream: true,
|
||||||
|
dynacast: true,
|
||||||
|
reconnectionPolicy: {
|
||||||
|
nextRetryDelayInMs: (retryCount) => Math.min(1000 * Math.pow(2, retryCount), 10000)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configuration des event listeners
|
||||||
|
this._setupEventListeners();
|
||||||
|
|
||||||
|
// Connexion
|
||||||
|
await this.room.connect(this.options.url, this.options.token);
|
||||||
|
|
||||||
|
this.isConnected = true;
|
||||||
|
console.log(`✓ Connecté à LiveKit room "${this.options.roomName}" en tant que "${this.options.participantName}"`);
|
||||||
|
|
||||||
|
this.emit('connected', {
|
||||||
|
roomName: this.options.roomName,
|
||||||
|
participantName: this.options.participantName
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur connexion LiveKit:', error);
|
||||||
|
this.emit('error', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration des event listeners de la room
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_setupEventListeners() {
|
||||||
|
if (!this.room) return;
|
||||||
|
|
||||||
|
// Connexion/déconnexion
|
||||||
|
this.room.on(RoomEvent.Connected, () => {
|
||||||
|
console.log('✓ Room connectée');
|
||||||
|
this.isConnected = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Participants
|
||||||
|
this.room.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||||
|
console.log(`➕ Participant connecté: ${participant.identity}`);
|
||||||
|
this.emit('participantConnected', participant);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.room.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
||||||
|
console.log(`➖ Participant déconnecté: ${participant.identity}`);
|
||||||
|
this.remoteParticipants.delete(participant.sid);
|
||||||
|
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
|
||||||
|
});
|
||||||
|
this.emit('audioTrackSubscribed', { track, participant });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
||||||
|
if (track.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>}
|
||||||
|
*/
|
||||||
|
async publishAudioTrack(mediaStreamTrack) {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
throw new Error('Pas connecté à LiveKit');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Options de publication
|
||||||
|
const options = {
|
||||||
|
name: 'bridge-audio',
|
||||||
|
source: 'microphone',
|
||||||
|
audioBitrate: this.options.audioBitrate
|
||||||
|
};
|
||||||
|
|
||||||
|
this.localAudioTrack = await this.room.localParticipant.publishTrack(
|
||||||
|
mediaStreamTrack,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✓ Track audio local publié');
|
||||||
|
this.emit('trackPublished', this.localAudioTrack);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur publication track:', error);
|
||||||
|
this.emit('error', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpublish le track audio local
|
||||||
|
*/
|
||||||
|
async unpublishAudioTrack() {
|
||||||
|
if (this.localAudioTrack) {
|
||||||
|
await this.room.localParticipant.unpublishTrack(this.localAudioTrack);
|
||||||
|
this.localAudioTrack = null;
|
||||||
|
console.log('✓ Track audio local dépublié');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie des données audio Opus directement (pour bridge serveur)
|
||||||
|
* Alternative à publishAudioTrack pour contrôle bas niveau
|
||||||
|
* @param {Buffer} opusData - Données Opus encodées
|
||||||
|
*/
|
||||||
|
sendAudioData(opusData) {
|
||||||
|
// Note: LiveKit ne supporte pas directement l'envoi de données Opus brutes
|
||||||
|
// Cette méthode serait implémentée avec un track custom ou DataChannel
|
||||||
|
// Pour l'instant, on utilise publishAudioTrack avec un MediaStreamTrack
|
||||||
|
console.warn('sendAudioData: Non implémenté, utiliser publishAudioTrack');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère tous les tracks audio distants actifs
|
||||||
|
* @returns {Array<Object>} Liste des tracks avec métadonnées
|
||||||
|
*/
|
||||||
|
getRemoteAudioTracks() {
|
||||||
|
return Array.from(this.remoteParticipants.values()).map(({ participant, track, publication }) => ({
|
||||||
|
participantId: participant.sid,
|
||||||
|
participantName: participant.identity,
|
||||||
|
track,
|
||||||
|
publication,
|
||||||
|
isMuted: publication.isMuted,
|
||||||
|
isSubscribed: publication.isSubscribed
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un participant distant par son SID
|
||||||
|
* @param {string} sid - SID du participant
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
getRemoteParticipant(sid) {
|
||||||
|
return this.remoteParticipants.get(sid) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient les statistiques de connexion
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
async getStats() {
|
||||||
|
if (!this.room || !this.isConnected) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = this.room.remoteParticipants;
|
||||||
|
const localParticipant = this.room.localParticipant;
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: this.isConnected,
|
||||||
|
reconnecting: this.reconnecting,
|
||||||
|
roomName: this.options.roomName,
|
||||||
|
participantName: this.options.participantName,
|
||||||
|
localParticipant: {
|
||||||
|
sid: localParticipant?.sid,
|
||||||
|
identity: localParticipant?.identity,
|
||||||
|
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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déconnexion de la room
|
||||||
|
*/
|
||||||
|
async disconnect() {
|
||||||
|
if (this.room) {
|
||||||
|
await this.unpublishAudioTrack();
|
||||||
|
this.room.disconnect();
|
||||||
|
this.room = null;
|
||||||
|
this.isConnected = false;
|
||||||
|
this.remoteParticipants.clear();
|
||||||
|
console.log('✓ Déconnecté de LiveKit');
|
||||||
|
this.emit('disconnected', { reason: 'manual' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détruit le client et libère les ressources
|
||||||
|
*/
|
||||||
|
async destroy() {
|
||||||
|
await this.disconnect();
|
||||||
|
this.removeAllListeners();
|
||||||
|
console.log('✓ LiveKitClient détruit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si le client est connecté
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
get connected() {
|
||||||
|
return this.isConnected && this.room !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère la room LiveKit (accès direct si nécessaire)
|
||||||
|
* @returns {Room|null}
|
||||||
|
*/
|
||||||
|
getRoom() {
|
||||||
|
return this.room;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LiveKitClient;
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* OpusCodec.js
|
||||||
|
* Wrapper pour encoder/décoder audio avec Opus
|
||||||
|
*
|
||||||
|
* Gère :
|
||||||
|
* - Encodage PCM 16-bit → Opus
|
||||||
|
* - Décodage Opus → PCM 16-bit
|
||||||
|
* - Configuration bitrate (32-320 kbps)
|
||||||
|
* - Frame size flexible (20ms par défaut)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import OpusScript from 'opusscript';
|
||||||
|
|
||||||
|
export class OpusCodec {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = {
|
||||||
|
sampleRate: options.sampleRate || 48000,
|
||||||
|
channels: options.channels || 1,
|
||||||
|
bitrate: options.bitrate || 96000, // 96kbps par défaut (voix standard)
|
||||||
|
frameSize: options.frameSize || 960, // 20ms à 48kHz
|
||||||
|
application: options.application || 'voip', // 'voip' | 'audio' | 'restricted_lowdelay'
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
this._validateOptions();
|
||||||
|
|
||||||
|
// Création des encodeurs/décodeurs Opus
|
||||||
|
this.encoder = null;
|
||||||
|
this.decoder = null;
|
||||||
|
|
||||||
|
this._initCodecs();
|
||||||
|
|
||||||
|
// Statistiques
|
||||||
|
this.stats = {
|
||||||
|
encoded: 0,
|
||||||
|
decoded: 0,
|
||||||
|
encodeErrors: 0,
|
||||||
|
decodeErrors: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide les options
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_validateOptions() {
|
||||||
|
const validSampleRates = [8000, 12000, 16000, 24000, 48000];
|
||||||
|
if (!validSampleRates.includes(this.options.sampleRate)) {
|
||||||
|
throw new Error(`Sample rate invalide : ${this.options.sampleRate}. Valeurs acceptées : ${validSampleRates.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.channels < 1 || this.options.channels > 2) {
|
||||||
|
throw new Error(`Nombre de canaux invalide : ${this.options.channels}. Doit être 1 (mono) ou 2 (stereo)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.bitrate < 6000 || this.options.bitrate > 510000) {
|
||||||
|
throw new Error(`Bitrate invalide : ${this.options.bitrate}. Doit être entre 6000 et 510000 bps`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validApplications = ['voip', 'audio', 'restricted_lowdelay'];
|
||||||
|
if (!validApplications.includes(this.options.application)) {
|
||||||
|
throw new Error(`Application invalide : ${this.options.application}. Valeurs acceptées : ${validApplications.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise les codecs Opus
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_initCodecs() {
|
||||||
|
try {
|
||||||
|
// Mapping des applications
|
||||||
|
const appMapping = {
|
||||||
|
'voip': OpusScript.Application.VOIP,
|
||||||
|
'audio': OpusScript.Application.AUDIO,
|
||||||
|
'restricted_lowdelay': OpusScript.Application.RESTRICTED_LOWDELAY
|
||||||
|
};
|
||||||
|
|
||||||
|
// Création encoder
|
||||||
|
this.encoder = new OpusScript(
|
||||||
|
this.options.sampleRate,
|
||||||
|
this.options.channels,
|
||||||
|
appMapping[this.options.application]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Configuration bitrate
|
||||||
|
this.encoder.setBitrate(this.options.bitrate);
|
||||||
|
|
||||||
|
// Création decoder
|
||||||
|
this.decoder = new OpusScript(
|
||||||
|
this.options.sampleRate,
|
||||||
|
this.options.channels,
|
||||||
|
appMapping[this.options.application]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✓ Opus codec initialisé : ${this.options.sampleRate}Hz, ${this.options.channels}ch, ${this.options.bitrate / 1000}kbps`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur initialisation codec Opus:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode des données PCM en Opus
|
||||||
|
* @param {Buffer} pcmData - Données PCM 16-bit signed
|
||||||
|
* @returns {Buffer|null} Données Opus encodées ou null en cas d'erreur
|
||||||
|
*/
|
||||||
|
encode(pcmData) {
|
||||||
|
if (!this.encoder) {
|
||||||
|
console.error('Encoder non initialisé');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Conversion Buffer → Int16Array pour OpusScript
|
||||||
|
const pcmInt16 = new Int16Array(
|
||||||
|
pcmData.buffer,
|
||||||
|
pcmData.byteOffset,
|
||||||
|
pcmData.byteLength / 2
|
||||||
|
);
|
||||||
|
|
||||||
|
// Vérification taille frame
|
||||||
|
const expectedSamples = this.options.frameSize * this.options.channels;
|
||||||
|
if (pcmInt16.length !== expectedSamples) {
|
||||||
|
console.warn(`Taille frame incorrecte : ${pcmInt16.length} samples (attendu ${expectedSamples})`);
|
||||||
|
// Padding ou truncate si nécessaire
|
||||||
|
const adjusted = new Int16Array(expectedSamples);
|
||||||
|
adjusted.set(pcmInt16.slice(0, expectedSamples));
|
||||||
|
const opusData = this.encoder.encode(adjusted, this.options.frameSize);
|
||||||
|
this.stats.encoded++;
|
||||||
|
return Buffer.from(opusData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encodage
|
||||||
|
const opusData = this.encoder.encode(pcmInt16, this.options.frameSize);
|
||||||
|
this.stats.encoded++;
|
||||||
|
|
||||||
|
return Buffer.from(opusData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur encodage Opus:', error);
|
||||||
|
this.stats.encodeErrors++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Décode des données Opus en PCM
|
||||||
|
* @param {Buffer} opusData - Données Opus
|
||||||
|
* @returns {Buffer|null} Données PCM 16-bit ou null en cas d'erreur
|
||||||
|
*/
|
||||||
|
decode(opusData) {
|
||||||
|
if (!this.decoder) {
|
||||||
|
console.error('Decoder non initialisé');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Décodage
|
||||||
|
const pcmInt16 = this.decoder.decode(opusData, this.options.frameSize);
|
||||||
|
this.stats.decoded++;
|
||||||
|
|
||||||
|
// Conversion Int16Array → Buffer
|
||||||
|
const pcmBuffer = Buffer.from(pcmInt16.buffer);
|
||||||
|
return pcmBuffer;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur décodage Opus:', error);
|
||||||
|
this.stats.decodeErrors++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change le bitrate de l'encodeur
|
||||||
|
* @param {number} bitrate - Nouveau bitrate en bps (6000-510000)
|
||||||
|
*/
|
||||||
|
setBitrate(bitrate) {
|
||||||
|
if (bitrate < 6000 || bitrate > 510000) {
|
||||||
|
console.error(`Bitrate invalide : ${bitrate}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.encoder) {
|
||||||
|
this.encoder.setBitrate(bitrate);
|
||||||
|
this.options.bitrate = bitrate;
|
||||||
|
console.log(`✓ Bitrate Opus mis à jour : ${bitrate / 1000}kbps`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient les statistiques du codec
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
getStats() {
|
||||||
|
return {
|
||||||
|
...this.stats,
|
||||||
|
config: {
|
||||||
|
sampleRate: this.options.sampleRate,
|
||||||
|
channels: this.options.channels,
|
||||||
|
bitrate: this.options.bitrate,
|
||||||
|
frameSize: this.options.frameSize,
|
||||||
|
application: this.options.application
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réinitialise les statistiques
|
||||||
|
*/
|
||||||
|
resetStats() {
|
||||||
|
this.stats = {
|
||||||
|
encoded: 0,
|
||||||
|
decoded: 0,
|
||||||
|
encodeErrors: 0,
|
||||||
|
decodeErrors: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détruit le codec et libère les ressources
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.encoder = null;
|
||||||
|
this.decoder = null;
|
||||||
|
console.log('✓ OpusCodec détruit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule la taille d'une frame en millisecondes
|
||||||
|
* @returns {number} Durée en ms
|
||||||
|
*/
|
||||||
|
getFrameDuration() {
|
||||||
|
return (this.options.frameSize / this.options.sampleRate) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le nombre de bytes PCM pour une frame
|
||||||
|
* @returns {number} Taille en bytes
|
||||||
|
*/
|
||||||
|
getFrameSizeBytes() {
|
||||||
|
// PCM 16-bit = 2 bytes par sample
|
||||||
|
return this.options.frameSize * this.options.channels * 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Présets de configuration Opus selon le cas d'usage
|
||||||
|
*/
|
||||||
|
export const OpusPresets = {
|
||||||
|
// Voix économique (WiFi limité, faible bande passante)
|
||||||
|
VOICE_LOW: {
|
||||||
|
bitrate: 32000, // 32 kbps
|
||||||
|
application: 'voip'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Voix économique améliorée
|
||||||
|
VOICE_ECONOMY: {
|
||||||
|
bitrate: 64000, // 64 kbps
|
||||||
|
application: 'voip'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Voix standard (défaut, bon compromis)
|
||||||
|
VOICE_STANDARD: {
|
||||||
|
bitrate: 96000, // 96 kbps
|
||||||
|
application: 'voip'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Voix HD (qualité maximale voix)
|
||||||
|
VOICE_HD: {
|
||||||
|
bitrate: 128000, // 128 kbps
|
||||||
|
application: 'voip'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Voix ultra HD
|
||||||
|
VOICE_ULTRA: {
|
||||||
|
bitrate: 192000, // 192 kbps
|
||||||
|
application: 'audio'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Musique/monitoring (si besoin événementiel)
|
||||||
|
MUSIC: {
|
||||||
|
bitrate: 256000, // 256 kbps
|
||||||
|
application: 'audio'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Musique haute qualité
|
||||||
|
MUSIC_HQ: {
|
||||||
|
bitrate: 320000, // 320 kbps
|
||||||
|
application: 'audio'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OpusCodec;
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* CoreAudioBackend.js
|
||||||
|
* Backend audio natif macOS utilisant naudiodon (bindings PortAudio/CoreAudio)
|
||||||
|
*
|
||||||
|
* Gère :
|
||||||
|
* - Énumération des devices audio
|
||||||
|
* - Capture audio (microphone/carte son)
|
||||||
|
* - Lecture audio (speakers/sortie audio)
|
||||||
|
* - Buffer circulaire pour flux continu
|
||||||
|
*/
|
||||||
|
|
||||||
|
import portAudio from 'naudiodon';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
export class CoreAudioBackend extends EventEmitter {
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
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,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.inputStream = null;
|
||||||
|
this.outputStream = null;
|
||||||
|
this.isCapturing = false;
|
||||||
|
this.isPlaying = false;
|
||||||
|
|
||||||
|
// Buffer circulaire pour la lecture
|
||||||
|
this.playbackBuffer = [];
|
||||||
|
this.maxBufferSize = 10; // Max 10 chunks en buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste tous les devices audio disponibles
|
||||||
|
* @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
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur énumération devices CoreAudio:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouve le device par défaut pour l'entrée
|
||||||
|
* @returns {Object|null} Device d'entrée par défaut
|
||||||
|
*/
|
||||||
|
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} Device de sortie par défaut
|
||||||
|
*/
|
||||||
|
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 déjà active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inputConfig = {
|
||||||
|
channelCount: this.options.channels,
|
||||||
|
sampleFormat: portAudio.SampleFormat16Bit,
|
||||||
|
sampleRate: this.options.sampleRate,
|
||||||
|
deviceId: this.options.inputDeviceId ?? undefined,
|
||||||
|
closeOnError: true
|
||||||
|
};
|
||||||
|
|
||||||
|
this.inputStream = new portAudio.AudioIO({
|
||||||
|
inOptions: inputConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
this.inputStream.on('data', (audioData) => {
|
||||||
|
// Émet les données audio capturées (Buffer PCM 16-bit)
|
||||||
|
this.emit('audioData', audioData);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.inputStream.on('error', (error) => {
|
||||||
|
console.error('Erreur stream capture:', error);
|
||||||
|
this.emit('error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.inputStream.on('close', () => {
|
||||||
|
console.log('Stream capture fermé');
|
||||||
|
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);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrête la capture audio
|
||||||
|
*/
|
||||||
|
stopCapture() {
|
||||||
|
if (this.inputStream && this.isCapturing) {
|
||||||
|
this.inputStream.quit();
|
||||||
|
this.inputStream = null;
|
||||||
|
this.isCapturing = false;
|
||||||
|
console.log('✓ Capture audio arrêtée');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre la lecture audio
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async startPlayback() {
|
||||||
|
if (this.isPlaying) {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
this.outputStream = new portAudio.AudioIO({
|
||||||
|
outOptions: outputConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
this.outputStream.on('error', (error) => {
|
||||||
|
console.error('Erreur stream lecture:', error);
|
||||||
|
this.emit('error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.outputStream.on('close', () => {
|
||||||
|
console.log('Stream lecture fermé');
|
||||||
|
this.isPlaying = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Démarrage du stream de lecture
|
||||||
|
this.outputStream.start();
|
||||||
|
this.isPlaying = true;
|
||||||
|
|
||||||
|
// Boucle de lecture du buffer circulaire
|
||||||
|
this._startPlaybackLoop();
|
||||||
|
|
||||||
|
console.log(`✓ Lecture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur démarrage lecture:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrête la lecture audio
|
||||||
|
*/
|
||||||
|
stopPlayback() {
|
||||||
|
if (this.outputStream && this.isPlaying) {
|
||||||
|
this.outputStream.quit();
|
||||||
|
this.outputStream = null;
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.playbackBuffer = [];
|
||||||
|
console.log('✓ Lecture audio 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 inactive');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limite la taille du buffer pour éviter la latence excessive
|
||||||
|
if (this.playbackBuffer.length < this.maxBufferSize) {
|
||||||
|
this.playbackBuffer.push(audioData);
|
||||||
|
} else {
|
||||||
|
// Buffer plein : overrun
|
||||||
|
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.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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rappel à intervalle régulier (20ms pour 960 frames à 48kHz)
|
||||||
|
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
|
||||||
|
setTimeout(playNextChunk, intervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
playNextChunk();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrête tous les streams
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.stopCapture();
|
||||||
|
this.stopPlayback();
|
||||||
|
this.removeAllListeners();
|
||||||
|
console.log('✓ CoreAudioBackend détruit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si CoreAudio est disponible sur le système
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
static isAvailable() {
|
||||||
|
try {
|
||||||
|
const devices = portAudio.getDevices();
|
||||||
|
return devices.length > 0;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CoreAudioBackend;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 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
|
||||||
|
jitterBufferMs: 40
|
||||||
|
|
||||||
|
# Configuration des groupes
|
||||||
|
groups:
|
||||||
|
- id: production
|
||||||
|
name: "Équipe Production"
|
||||||
|
description: "Réalisateur, cadreurs, régisseur"
|
||||||
|
|
||||||
|
# Qualité audio spécifique (optionnel, sinon utilise defaultBitrate)
|
||||||
|
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"
|
||||||
|
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
|
||||||
|
server:
|
||||||
|
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
|
||||||
|
logging:
|
||||||
|
level: "debug" # debug, info, warn, error
|
||||||
|
logLatency: true
|
||||||
|
logAudioStats: true
|
||||||
+384
@@ -0,0 +1,384 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { readFileSync } 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';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détecte l'IP réseau locale (WiFi/Ethernet)
|
||||||
|
* @returns {string|null} IP réseau ou null si non trouvée
|
||||||
|
*/
|
||||||
|
function getNetworkIP() {
|
||||||
|
const nets = networkInterfaces();
|
||||||
|
|
||||||
|
// Priorité : WiFi (en0 sur macOS) > Ethernet (en1)
|
||||||
|
const priorityInterfaces = ['en0', 'en1', 'eth0', 'wlan0'];
|
||||||
|
|
||||||
|
for (const interfaceName of priorityInterfaces) {
|
||||||
|
const interfaces = nets[interfaceName];
|
||||||
|
if (interfaces) {
|
||||||
|
for (const net of interfaces) {
|
||||||
|
// IPv4, non-interne
|
||||||
|
if (net.family === 'IPv4' && !net.internal) {
|
||||||
|
return net.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback : première IP non-interne trouvée
|
||||||
|
for (const name of Object.keys(nets)) {
|
||||||
|
for (const net of nets[name]) {
|
||||||
|
if (net.family === 'IPv4' && !net.internal) {
|
||||||
|
return net.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variables d'environnement
|
||||||
|
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || 'devkey';
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Configuration URL LiveKit
|
||||||
|
let LIVEKIT_URL = process.env.LIVEKIT_URL || config.server.livekit.url;
|
||||||
|
|
||||||
|
// AUTO : détection automatique de l'IP réseau
|
||||||
|
if (LIVEKIT_URL === 'AUTO') {
|
||||||
|
const networkIP = getNetworkIP();
|
||||||
|
if (networkIP) {
|
||||||
|
LIVEKIT_URL = `ws://${networkIP}:7880`;
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ IP réseau non détectée, utilisation de localhost');
|
||||||
|
LIVEKIT_URL = 'ws://localhost:7880';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
const LOG_LEVEL = config.logging.level;
|
||||||
|
|
||||||
|
function log(level, ...args) {
|
||||||
|
const levels = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||||
|
const configLevel = levels[LOG_LEVEL] || 1;
|
||||||
|
const msgLevel = levels[level] || 1;
|
||||||
|
|
||||||
|
if (msgLevel >= configLevel) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
console.log(`[${timestamp}] [${level.toUpperCase()}]`, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Démarrage LiveKit Server ==========
|
||||||
|
|
||||||
|
let livekitProcess = null;
|
||||||
|
|
||||||
|
function startLiveKitServer() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Utiliser le binaire Homebrew (dans PATH)
|
||||||
|
const livekitBinary = 'livekit-server';
|
||||||
|
|
||||||
|
log('info', 'Démarrage LiveKit Server...');
|
||||||
|
log('debug', 'Commande:', livekitBinary);
|
||||||
|
log('debug', 'URL:', LIVEKIT_URL);
|
||||||
|
|
||||||
|
// Configuration LiveKit en arguments
|
||||||
|
// En mode --dev, LiveKit utilise automatiquement devkey/secret
|
||||||
|
const args = [
|
||||||
|
'--dev', // Mode développement (active debug + clés par défaut devkey/secret)
|
||||||
|
'--bind', '0.0.0.0'
|
||||||
|
// Note: --udp-port peut être ajouté si besoin (ex: --udp-port 7882)
|
||||||
|
// Le port HTTP/WebSocket est 7880 par défaut
|
||||||
|
];
|
||||||
|
|
||||||
|
livekitProcess = spawn(livekitBinary, args, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env },
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détection démarrage réussi
|
||||||
|
if (output.includes('starting server') || output.includes('rtc server')) {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
livekitProcess.stderr.on('data', (data) => {
|
||||||
|
const output = data.toString().trim();
|
||||||
|
if (output) {
|
||||||
|
log('warn', '[LiveKit Error]', output);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
livekitProcess.on('error', (error) => {
|
||||||
|
log('error', 'Erreur LiveKit:', error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
livekitProcess.on('exit', (code, signal) => {
|
||||||
|
log('warn', `LiveKit Server arrêté (code: ${code}, signal: ${signal})`);
|
||||||
|
livekitProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout si pas de démarrage
|
||||||
|
setTimeout(() => {
|
||||||
|
if (livekitProcess) {
|
||||||
|
resolve(); // On assume que c'est OK
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== API REST ==========
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Middleware CORS
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
|
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.sendStatus(200);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middleware logging
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
log('debug', `${req.method} ${req.path}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Routes API ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /config
|
||||||
|
* Retourne la configuration des groupes
|
||||||
|
*/
|
||||||
|
app.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
|
||||||
|
}))
|
||||||
|
})),
|
||||||
|
audio: {
|
||||||
|
sampleRate: config.audio.sampleRate,
|
||||||
|
defaultBitrate: config.audio.defaultBitrate
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(clientConfig);
|
||||||
|
} catch (error) {
|
||||||
|
log('error', 'Erreur GET /config:', error);
|
||||||
|
res.status(500).json({ error: 'Configuration unavailable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /groups
|
||||||
|
* Retourne la liste des groupes disponibles (simplifié)
|
||||||
|
*/
|
||||||
|
app.get('/groups', (req, res) => {
|
||||||
|
try {
|
||||||
|
const groups = config.groups.map(g => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
description: g.description
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ groups });
|
||||||
|
} catch (error) {
|
||||||
|
log('error', 'Erreur GET /groups:', error);
|
||||||
|
res.status(500).json({ error: 'Groups unavailable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /token
|
||||||
|
* Génère un token LiveKit pour un client
|
||||||
|
* Body: { username: string, groupId: string }
|
||||||
|
*/
|
||||||
|
app.post('/token', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, groupId } = req.body;
|
||||||
|
|
||||||
|
if (!username || !groupId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing username or groupId'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que le groupe existe
|
||||||
|
const group = config.groups.find(g => g.id === groupId);
|
||||||
|
if (!group) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: `Group ${groupId} not found`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer token LiveKit
|
||||||
|
const roomName = groupId; // 1 room = 1 groupe
|
||||||
|
const participantIdentity = `${username}-${Date.now()}`;
|
||||||
|
|
||||||
|
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
|
||||||
|
identity: participantIdentity,
|
||||||
|
name: username,
|
||||||
|
metadata: JSON.stringify({ groupId })
|
||||||
|
});
|
||||||
|
|
||||||
|
at.addGrant({
|
||||||
|
room: roomName,
|
||||||
|
roomJoin: true,
|
||||||
|
canPublish: true,
|
||||||
|
canSubscribe: true,
|
||||||
|
canPublishData: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = await at.toJwt();
|
||||||
|
|
||||||
|
log('info', `Token généré: ${username} → ${groupId}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
url: LIVEKIT_URL,
|
||||||
|
roomName,
|
||||||
|
participantIdentity
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log('error', 'Erreur POST /token:', error);
|
||||||
|
res.status(500).json({ error: 'Token generation failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /health
|
||||||
|
* Health check
|
||||||
|
*/
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
const isLivekitRunning = livekitProcess !== null;
|
||||||
|
res.json({
|
||||||
|
status: isLivekitRunning ? 'ok' : 'degraded',
|
||||||
|
livekit: isLivekitRunning,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /
|
||||||
|
* Info serveur
|
||||||
|
*/
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
name: 'PTT Live Server',
|
||||||
|
version: '0.1.0',
|
||||||
|
phase: 'Phase 1 - MVP',
|
||||||
|
endpoints: [
|
||||||
|
'GET /config - Configuration groupes',
|
||||||
|
'GET /groups - Liste des groupes',
|
||||||
|
'POST /token - Générer token client',
|
||||||
|
'GET /health - Health check'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Démarrage ==========
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
try {
|
||||||
|
log('info', '=== PTT Live Server ===');
|
||||||
|
log('info', 'Phase 1 - MVP');
|
||||||
|
log('info', '');
|
||||||
|
|
||||||
|
// Affichage configuration réseau
|
||||||
|
const networkIP = getNetworkIP();
|
||||||
|
if (networkIP) {
|
||||||
|
log('info', `📡 IP réseau détectée : ${networkIP}`);
|
||||||
|
}
|
||||||
|
log('info', `🔗 URL LiveKit clients : ${LIVEKIT_URL}`);
|
||||||
|
log('info', '');
|
||||||
|
|
||||||
|
// 1. Démarrer LiveKit (si mode local)
|
||||||
|
if (USE_LOCAL_LIVEKIT) {
|
||||||
|
await startLiveKitServer();
|
||||||
|
log('info', '✓ LiveKit Server local démarré sur port 7880');
|
||||||
|
} else {
|
||||||
|
log('info', '✓ Mode LiveKit Cloud (LIVEKIT_URL:', LIVEKIT_URL, ')');
|
||||||
|
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, () => {
|
||||||
|
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(', ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gérer erreur port déjà utilisé
|
||||||
|
server.on('error', (error) => {
|
||||||
|
if (error.code === 'EADDRINUSE') {
|
||||||
|
log('error', `❌ Port ${SERVER_PORT} déjà utilisé`);
|
||||||
|
log('info', `💡 Essayez avec: PORT=3001 npm run dev`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log('error', 'Erreur démarrage:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Cleanup ==========
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
log('info', 'Arrêt du serveur...');
|
||||||
|
|
||||||
|
if (livekitProcess) {
|
||||||
|
log('info', 'Arrêt LiveKit Server...');
|
||||||
|
livekitProcess.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', cleanup);
|
||||||
|
process.on('SIGTERM', cleanup);
|
||||||
|
|
||||||
|
// ========== Lancement ==========
|
||||||
|
|
||||||
|
start();
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "ptt-live-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "PTT Live - Professional WebRTC Intercom Server",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"dev": "node --watch index.js",
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"webrtc",
|
||||||
|
"intercom",
|
||||||
|
"livekit",
|
||||||
|
"audio",
|
||||||
|
"ptt"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"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",
|
||||||
|
"ws": "^8.17.0",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user