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
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
|
||||
@@ -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