Compare commits

..

18 Commits

Author SHA1 Message Date
benoit 49df6bd44c fix: refonte mode PTT continu avec activation par drag vertical
CORRECTIONS :
- Fix son qui ne restait pas actif en mode lock (utilisation de ref au lieu de state)
- Les event handlers utilisent isLockModeRef.current pour accès immédiat

NOUVELLE ACTIVATION :
- Remplacement appui long 3s par drag vertical (glisser vers le haut)
- Drag de 80px vers le haut active le mode lock
- Indicateur visuel avec flèche + texte "Glissez pour verrouiller"
- Bouton suit le doigt pendant le drag (transform translateY)
- Feedback haptique à l'activation (triple vibration)

DÉSACTIVATION :
- Tap/clic sur le bouton en mode lock désactive le mode
- Feedback haptique simple à la désactivation

UX AMÉLIORÉE :
- Plus intuitif qu'un appui long
- Retour visuel immédiat du drag
- Compatible touch (mobile) et mouse (desktop)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-23 09:43:49 +02:00
benoit 0b0e998b55 docs: mise à jour TODO.md - Phase 2.1 et 2.2 complétées
- Phase 2.1 : Support multi-groupes ✓
- Phase 2.2 : Mode PTT continu ✓
- Mise à jour statut : Phase 2 en cours
- Ajout section "Prochaines actions" avec roadmap Phase 2/3

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-23 09:35:03 +02:00
benoit 78e9a32e12 feat: mode PTT continu avec activation par appui long (Phase 2.2)
- Nouveau mode PTT continu (lock) activé par appui long de 3s
- Barre de progression visuelle pendant l'appui (0-100%)
- Badge cadenas en haut à droite quand mode actif
- Animation pulsante distinctive pour mode lock
- Feedback haptique à l'activation (triple vibration)
- Désactivation par simple tap quand mode lock actif
- Indication textuelle claire de l'état (normal/lock)
- Styles responsifs mobile + accessibilité (prefers-reduced-motion)

Mode d'emploi :
- Normal : Maintenir pour parler, relâcher pour arrêter
- Lock : Maintenir 3s → mode continu → tap pour désactiver

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-23 09:34:30 +02:00
benoit 3181c62e57 feat: support multi-groupes avec sélection dynamique (Phase 2.1)
- Ajout de 3 groupes dans config.yaml : Production, Technique, Sonorisation
- Nouvel endpoint API GET /groups pour lister les groupes disponibles
- Composant GroupSelector.jsx pour changer de groupe pendant la session
- Hook useLiveKit étendu avec fonction switchGroup() pour reconnexion
- Intégration dans App.jsx avec gestion du changement de groupe
- Chaque groupe = 1 room LiveKit distincte
- Qualité audio configurable par groupe (96-128 kbps)
2026-05-23 09:32:51 +02:00
benoit c863f045ae feat: VU-mètre audio entrant fonctionnel + simplification UI
Modifications :
- Ajout analyseur audio pour pistes distantes (remoteAnalyserRef)
- setupRemoteAudioAnalyser() appelé sur TrackSubscribed
- analyseAudioLevel() alterne automatiquement entre micro local et audio entrant
- useEffect redémarre analyse quand isTalking change
- Cleanup complet des 2 contextes audio (local + remote)

UI VU-mètre :
- Suppression jauge redondante (gardé uniquement barres)
- Barres uniformes (même hauteur) au lieu d'effet égaliseur
- Couleurs distinctes : vert (audio entrant) vs bleu/rouge (micro)
- Jaune > 75%, rouge clignotant > 90%

Tests validés :
 VU-mètre micro local : fonctionne
 VU-mètre audio entrant : fonctionne (fix principal)
 Alternance automatique talking/listening

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-22 23:16:48 +02:00
benoit ed22e6d878 fix: support complet iOS/mobile pour PTT (audio + HTTPS/WSS)
Modifications majeures :
- HTTPS obligatoire pour getUserMedia sur iOS (certificats mkcert)
- Proxy WSS Vite pour LiveKit (contourner mixed content HTTPS→WS)
- Audio unlock explicite iOS dans useLiveKit (AudioContext)
- Demande permission microphone avant connexion LiveKit
- Touch optimizations CSS (touch-action, tap-highlight)
- Meta iOS PWA (apple-mobile-web-app-capable)
- Logs debug pour troubleshooting mobile
- Attente publication track audio avant utilisation PTT

Tests validés :
 iPhone Safari : émission + réception audio OK
 Desktop Chrome : fonctionne toujours
 3+ devices simultanés

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-22 23:05:44 +02:00
benoit 24edf36d3c fix: détection automatique IP réseau pour connexions multi-appareils
Problème :
Le serveur retournait ws://localhost:7880 aux clients, empêchant
les connexions depuis d'autres appareils sur le réseau.

Solution :
- Ajout détection automatique IP réseau (WiFi/Ethernet)
- Variable LIVEKIT_URL=AUTO pour mode auto-détection
- Fonction getNetworkIP() avec priorité interfaces (en0, en1, eth0, wlan0)
- Affichage IP détectée au démarrage du serveur
- Fallback vers localhost si IP non détectée

Modifications :
- server/index.js : ajout getNetworkIP() et détection AUTO
- server/.env : LIVEKIT_URL=AUTO par défaut
- server/.env.example : documentation modes configuration
- NETWORK_SETUP.md : guide complet configuration réseau et dépannage

Les clients reçoivent maintenant ws://IP_RESEAU:7880 et peuvent
se connecter depuis n'importe quel appareil sur le même réseau WiFi.

Ports utilisés :
- 3000 : API REST
- 7880 : LiveKit WebSocket
- 7882 : LiveKit UDP (RTP)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-22 22:27:34 +02:00
benoit efd697a9d3 feat: implémentation complète du bridge audio serveur (Phase 1.3)
Composants créés :
- CoreAudioBackend.js : Backend audio macOS natif (naudiodon/PortAudio)
  - Énumération et sélection devices audio
  - Capture audio 48kHz mono/stereo
  - Lecture audio avec buffer circulaire
  - Gestion underrun/overrun

- OpusCodec.js : Encodeur/décodeur Opus
  - Support 32-320 kbps configurable
  - Présets voix (économique, standard, HD) et musique
  - Frame 20ms (960 samples à 48kHz)
  - Statistiques encode/decode

- JitterBuffer.js : Buffer FIFO adaptatif
  - Cible 40ms (2 frames)
  - Détection underrun/overrun
  - Mode adaptatif pour conditions réseau variables
  - Statistiques latence et santé buffer

- LiveKitClient.js : Client LiveKit pour bridge
  - Connexion room en tant que participant "AudioBridge"
  - Publication/souscription tracks audio
  - Reconnexion automatique
  - Gestion événements participants

- AudioBridge.js : Classe principale orchestration
  - Détection automatique backend (CoreAudio macOS)
  - Routing bidirectionnel CoreAudio ↔ Opus ↔ LiveKit
  - Configuration via présets ou custom
  - Logs détaillés et statistiques temps réel

Dépendances ajoutées :
- opusscript : Codec Opus JavaScript
- naudiodon : Bindings natifs PortAudio/CoreAudio
- livekit-client : SDK LiveKit côté serveur

TODO.md mis à jour avec tâches Phase 1.3 complétées.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-22 22:18:18 +02:00
benoit 8bae2f03bf feat: install LiveKit via Homebrew et simplifie configuration
- Remplace téléchargement binaire par installation Homebrew
- Utilise clés par défaut devkey/secret en mode --dev
- Supprime flags incompatibles (--rtc-port-range-*, --port)
- Ajoute détection/mise à jour LiveKit existant
- Simplifie lancement automatique depuis Node.js

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-22 22:10:37 +02:00
benoit 3afb82355e docs: add comprehensive setup guide and improve README
Documentation complète pour démarrage rapide :

README.md :
- Section démarrage rapide en 5 minutes
- Guide dépannage "Connexion impossible"
- Instructions claires pour LiveKit Cloud
- Liens vers documentation détaillée

docs/SETUP_LIVEKIT.md :
- Guide complet configuration LiveKit Cloud
- Guide installation LiveKit Server local
- Instructions par OS (macOS, Linux)
- Section dépannage détaillée
- Checklist validation tests

Résout le problème de première connexion en guidant l'utilisateur
vers la configuration LiveKit Cloud (gratuit).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-21 15:26:40 +02:00
benoit 652384708e fix: amélioration gestion erreur port déjà utilisé
- Support variable PORT en environnement
- Gestion propre de l'erreur EADDRINUSE
- Message d'aide pour utiliser un port alternatif

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-21 15:21:21 +02:00
benoit 0640a9f0b6 feat: implement complete React PWA client with LiveKit integration
Client React complet avec intégration LiveKit et interface PTT professionnelle :

Infrastructure :
- Configuration Vite avec plugin PWA (Service Worker auto-généré)
- Proxy API vers serveur backend
- Build optimisé et PWA manifest

Composants UI :
- App.jsx : écran connexion + interface principale PTT
- PTTButton : bouton push-to-talk avec gestion touch/mouse events
- UserList : liste participants temps réel avec indicateurs
- AudioIndicator : VU-mètre avec visualisation niveau audio

Fonctionnalités WebRTC :
- Hook useLiveKit : connexion room, publish/subscribe, events
- Gestion micro avec mute/unmute (mode PTT)
- Auto-play audio participants distants
- Analyseur audio pour VU-mètre
- Feedback haptique (vibrations)

Design :
- Mode sombre par défaut
- Responsive mobile-first
- Animations fluides et accessibles
- Support paysage mobile

Phase 1.4 complétée : Client PWA opérationnel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-21 14:48:18 +02:00
benoit 5e74f0dcdf feat: implement LiveKit server API with REST endpoints
Implémentation du serveur Node.js avec intégration LiveKit et API REST complète :

- Serveur Express avec API REST (endpoints /config, /token, /health)
- Génération tokens JWT LiveKit pour authentification clients
- Configuration YAML pour groupes et canaux audio
- Support mode cloud LiveKit (pas de binaire requis pour Phase 1)
- Logging structuré avec niveaux configurables
- Gestion propre du lifecycle (SIGTERM, SIGINT)

Endpoints fonctionnels :
- GET /config : retourne configuration groupes/canaux
- POST /token : génère token LiveKit pour un utilisateur/groupe
- GET /health : health check du serveur
- GET / : informations serveur

Phase 1.2 complétée : Infrastructure serveur opérationnelle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-21 14:36:32 +02:00
benoit 47db08fff7 Update Claude 2026-05-21 14:19:28 +02:00
benoit fca3c82ad7 docs: mark Phase 1.1 infrastructure tasks as completed 2026-05-21 14:18:11 +02:00
benoit 08426970b2 feat: setup project infrastructure
- Create folder structure (server, client, install)
- Add server package.json with LiveKit SDK, Express, Opus
- Add client package.json with React, Vite, livekit-client
- Add macOS installation script with LiveKit binary download
- Add basic YAML config (1 group, 2 channels, audio quality settings)
- Add .gitignore for dependencies and binaries
2026-05-21 14:13:19 +02:00
benoit a65296221a docs: add project documentation and development plan
- Add CLAUDE.md with architecture, decisions, and workflow
- Add TODO.md with detailed Phase 1-3 roadmap
- Add audio quality configuration (32-320 kbps Opus)
- Add strict commit workflow and validation rules
2026-05-21 14:10:50 +02:00
benoit 55c2e41107 Suppression PTT verrouillé dans readme 2026-05-21 13:58:08 +02:00
35 changed files with 5733 additions and 5 deletions
+45
View File
@@ -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*
+330
View File
@@ -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)
+150
View File
@@ -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
+99 -5
View File
@@ -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)
```
+270
View File
@@ -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)
+7
View File
@@ -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
+21
View File
@@ -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>
+28
View File
@@ -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-----
+27
View File
@@ -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-----
+31
View File
@@ -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"
}
}
+173
View File
@@ -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);
}
}
+258
View File
@@ -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;
+123
View File
@@ -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);
}
}
+36
View File
@@ -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>
);
}
+110
View File
@@ -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;
}
}
+118
View File
@@ -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;
+250
View File
@@ -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;
}
}
+308
View File
@@ -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>
);
}
+150
View File
@@ -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;
}
}
+68
View File
@@ -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>
);
}
+417
View File
@@ -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
};
}
+166
View File
@@ -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);
}
+10
View File
@@ -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>
);
+83
View File
@@ -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
}
});
+185
View File
@@ -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.
+124
View File
@@ -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 ""
+17
View File
@@ -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
+414
View File
@@ -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;
+323
View File
@@ -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;
+319
View File
@@ -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;
+293
View File
@@ -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;
+281
View File
@@ -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;
+78
View File
@@ -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
View File
@@ -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();
+37
View File
@@ -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"
}
}