Compare commits

97 Commits

Author SHA1 Message Date
benoit 060453fe06 fix: application du buffer d'accumulation aux backends Linux (PipeWire/JACK)
- Ajout buffer d'accumulation dans PipeWireBackend (même pattern que CoreAudio)
- Ajout buffer d'accumulation dans JACKBackend
- pw-cat et jack_rec émettent aussi des chunks de taille variable
- Garantit frames fixes (960 samples) sur tous les backends
- Prévient audio haché/robotique sous Linux
2026-06-02 21:19:45 +02:00
benoit 2b88ea0ad5 fix: audio haché depuis carte son par chunks sox de taille variable
- Ajout buffer d'accumulation dans CoreAudioBackend pour garantir frames fixes
- sox envoie des chunks de taille variable → accumuler jusqu'à frameSize complet
- Émission de frames exactement 960 samples × 2ch × 2 bytes = 3840 bytes
- Adaptation automatique mono/stéréo selon config channels
- Audio fluide sans hachage/robotique vers clients LiveKit
2026-06-02 01:14:49 +02:00
benoit 9aff58c528 fix: déformation audio par saturation du mixage
Problème:
- Son complètement déformé (clipping massif)
- CoreAudio capture en 32-bit mais traité comme 16-bit
- Mixage additif sans normalisation

Solution:
1. Sox convertit 32→16 bit automatiquement
2. GroupAudioRouter divise gain par nombre de sources
   Exemple: 2 inputs → groupe default = gain × 0.5 chacun

Résultat: Aucun clipping détecté, audio propre
2026-06-02 00:45:41 +02:00
benoit a803250f9f fix: routing audio macOS avec support multi-canaux et LiveKit
Corrections majeures pour le support audio sous macOS :

- CoreAudioBackend : syntaxe sox correcte avec `-t coreaudio "Device Name"`
- AudioBridge : dé-entrelacement stéréo → canaux séparés (ligne 410-424)
- AudioBridge : entrelacement canaux → stéréo pour sortie (ligne 490-522)
- AudioBridge : duplication mono → stéréo pour LiveKit (ligne 438-449)
- config.yaml : ajout `channels: 2` pour capture stéréo
- config.yaml : ajout groupes "Production" et "Technique"

Résultat :
- Capture stéréo fonctionnelle depuis Loopback Audio 4
- Routing : 2 inputs → 3 groupes → LiveKit + 2 outputs
- Format audio correct pour LiveKit (mono dupliqué en stéréo)
- Pas d'erreur "Taille frame incorrecte"

Problème restant : sox playback se ferme après 0.4s (EPIPE)
2026-06-02 00:33:26 +02:00
benoit 36e1799ec5 fix: chargement des groupes dans l'onglet Audio pour matrice de routing
- Ajout fetch groupes dans loadAudioDevices()
- Fix: matrice de routing maintenant éditable (groupes chargés)
- Fix: WebSocket audio-levels connecté (nécessite VITE_WS_AUDIO_LEVELS_URL dans .env)
2026-06-01 23:51:01 +02:00
benoit f302b3f266 fix: persistance des groupes avec ConfigManager
- Conversion des routes groupes (GET/POST/PUT/DELETE) pour utiliser configManager
- Suppression de loadConfig() et saveConfig() locales (redondantes)
- Fix: les modifications de groupes sont maintenant persistées correctement
- Les groupes créés/modifiés/supprimés survivent au redémarrage du serveur
2026-06-01 23:24:38 +02:00
benoit 91d13d1be7 Merge branch 'main' into macos 2026-06-01 23:17:25 +02:00
benoit 77bc36b765 feat: amélioration UX interface admin audio
- Admin : regroupement des 3 dropdowns cartes son dans une seule section
- Admin : suppression du mode édition pour noms de canaux (directement éditables)
- Admin : unification des boutons de sauvegarde en bas de chaque section
- Admin : routing par hash URL pour persistance des onglets (#groups, #audio, etc.)
- AudioRoutingMatrix : bouton sauvegarde déplacé en bas de la matrice
- AudioRoutingMatrix : dropdowns de gain en nuance de bleu (cohérence visuelle)
2026-06-01 23:04:57 +02:00
benoit 58bc91b966 fix: UX interface admin et client
- Settings : suppression paramètres inutiles (mode PTT continu, feedback audio non implémenté)
- Settings : conservation uniquement du paramètre vibrations (fonctionnel)
- PTTButton : suppression init mode continu par défaut (redondant avec geste verrouillage)
- PWAInstallPrompt : ajout fond semi-transparent et couleurs hardcodées pour lisibilité
- Admin : fix dropdowns audio qui se réinitialisaient (useRef au lieu de useState pour édition)
2026-06-01 22:30:51 +02:00
benoit c9ec10dfd9 fix: shutdown propre sans erreurs
Problèmes corrigés pendant l'arrêt serveur :
1. RangeError offset bounds : vérification availableSpace avant .set()
2. audioBackend null : vérification avant queueAudio()
3. LiveKit track not found : try/catch sur unpublishTrack

Shutdown maintenant sans erreurs fatales.
2026-05-28 16:13:35 +02:00
benoit d908cf4ee6 fix: API /admin/devices/list compatible macOS avec CoreAudio
Avant : Utilisait sox (IDs numériques, incomplet)
Après : Utilise CoreAudioBackend.getDevices() (noms devices réels)

- Retourne device.name comme ID (compatible inputDeviceName)
- Affiche channels, sampleRate, isDefault
- Fallback sur built-in devices si erreur
- Cohérent avec résolution AudioBridge (ligne 206-216)

Interface /admin maintenant 100% compatible macOS et Linux.
2026-05-28 16:07:01 +02:00
benoit 522a6255fe fix: API /admin/devices/list retourne vrais IDs PulseAudio/PipeWire
Avant : IDs numériques (0, 1, 2...) incompatibles avec PipeWireBackend
Après : IDs réels (alsa_input.pci-..., alsa_output.pci-...)

- Extraction deviceId depuis pactl (colonne 2)
- Filtrage des monitors (.monitor)
- Descriptions lisibles (Input: pci-..., Output: pci-...)
- Compatible avec config.yaml existant
2026-05-28 16:04:57 +02:00
benoit 5784aa68e1 clean: suppression logs debug audio
L'audio fonctionne correctement maintenant :
- Client PWA → LiveKit → Serveur → Haut-parleurs ✓
- Latence acceptable
- Qualité audio bonne

Fixes appliqués :
- Support Int16Array (LiveKit Node SDK format)
- Accumulation frames 240→960 samples
- Conversion directe Int16Array vers Float32
2026-05-28 15:52:23 +02:00
benoit 05e7f69ffb fix: support Int16Array depuis LiveKit Node SDK
LiveKit renvoie Int16Array directement au lieu de Buffer.
Ajout détection Int16Array dans _bufferToFloat32 avec conversion directe.

Ajout logs RMS/dBFS pour diagnostiquer niveau audio.
2026-05-28 15:31:41 +02:00
benoit 5534a43b0a debug: ajout logs diagnostic format audio LiveKit
Affiche sampleRate, channels, buffer type et premiers bytes
pour diagnostiquer le bruit audio
2026-05-28 15:28:28 +02:00
benoit 1941e9c8a1 fix: accumulation frames LiveKit 240→960 samples avant routing
Problème : LiveKit envoie des frames de 240 samples (5ms @ 48kHz)
mais GroupRouter attend 960 samples (20ms). Cela causait :
- Bruit audio (720 samples de silence ajoutés à chaque frame)
- Latence de plusieurs secondes (buffers qui s'accumulent)

Solution :
- Ajout liveKitFrameAccumulators Map pour chaque groupe
- Accumulation de 4 frames LiveKit (240×4 = 960) avant routing
- Nettoyage logs verbeux dans AudioBridge et GroupAudioRouter

Résultat attendu :
- Audio clair (pas de silence injecté)
- Latence réduite (~20ms au lieu de plusieurs secondes)
2026-05-28 15:21:26 +02:00
benoit cfeb275d18 fix: convert Uint8Array to Buffer in _bufferToFloat32
LiveKit SDK returns audio data as Uint8Array/ArrayBuffer, not Node.js Buffer.
Added Buffer.from() conversion before readInt16LE operations.

Fixes: TypeError: buffer.readInt16LE is not a function
Permet à l'audio reçu des clients d'être converti en Float32 pour lecture.
2026-05-28 15:05:01 +02:00
benoit adadbfeeb7 fix: utilisation de TrackKind.KIND_AUDIO au lieu de string 'audio'
L'API LiveKit Node utilise des enums numériques pour track.kind :
- TrackKind.KIND_AUDIO = 1
- TrackKind.KIND_VIDEO = 2

La comparaison avec 'audio' échouait, maintenant on utilise l'enum correctement.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-28 14:52:38 +02:00
benoit aab23dc51f feat: réduction drastique des logs LiveKit
Filtrage des logs :
- LIVEKIT_LOG_LEVEL=info au lieu de debug
- Suppression logs DEBUG, signal requests/responses
- Garde uniquement INFO, WARN, ERROR importants

Rend les logs bien plus lisibles pour le debug applicatif.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-28 14:44:49 +02:00
benoit c562415a3d fix: écoute TrackPublished pour détecter tracks audio publiés après connexion
Problème : participant.trackPublications vide au moment de ParticipantConnected
Solution : écouter RoomEvent.TrackPublished et s'abonner au track audio dès sa publication

Flow : Client connecte → ParticipantConnected → Client publie track → TrackPublished → Souscription

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-28 14:42:22 +02:00
benoit 65357c29cc fix: souscription manuelle aux tracks audio des participants
Problème : RoomEvent.TrackSubscribed ne se déclenchait jamais avec @livekit/rtc-node
Solution :
- Itération manuelle sur participant.trackPublications quand participant se connecte
- Création AudioStream immédiate si track audio disponible
- Factorisation code dans _handleAudioTrack()

Cela devrait permettre de recevoir l'audio des clients PWA.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-28 14:40:02 +02:00
benoit 5fd46fb2a3 debug: ajout logs détaillés pour événements LiveKit tracks
Ajout de logs pour :
- RoomEvent.TrackPublished : détecte quand un participant publie un track
- RoomEvent.TrackSubscribed : logs plus détaillés avec kind et sid
- Permet de diagnostiquer pourquoi AudioBridge ne reçoit pas les tracks audio

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-28 14:35:23 +02:00
benoit 13d066b188 fix: création AudioStream avec syntaxe correcte LiveKit Node SDK
Correction import et utilisation d'AudioStream :
- Import AudioStream depuis @livekit/rtc-node
- new AudioStream(track, sampleRate, channels) au lieu de new track.AudioStream(...)

Cela devrait permettre la réception des frames audio depuis les clients PWA.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-28 14:26:05 +02:00
benoit 6630ced079 feat: support multi-rooms LiveKit (un par groupe)
Architecture refactorisée pour supporter plusieurs connexions LiveKit simultanées :
- AudioBridge : Map<groupName, LiveKitClient> au lieu d'un seul client
- AudioBridgeManager : génère un token JWT par groupe avec room dédiée
- Routing audio bidirectionnel par groupe :
  * FLUX 1 (carte son → LiveKit) : envoie vers le bon client selon groupName
  * FLUX 2 (LiveKit → carte son) : reçoit audio avec groupName correct
- Chaque groupe a sa propre room LiveKit (nom = groupId slugifié)

Fixes l'issue où les clients connectés à "production" ne recevaient pas
l'audio car AudioBridge était connecté uniquement à "main".

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-28 14:15:40 +02:00
benoit 7e6798cf92 fix: mapping correct devices PipeWire inputTargetDevice/outputTargetDevice
- AudioBridge passe inputTargetDevice et outputTargetDevice à PipeWire
- Fix: PipeWire utilisait targetDevice au lieu de input/outputTargetDevice
- Devrait maintenant démarrer les streams pw-cat correctement
2026-05-28 14:04:05 +02:00
benoit 061872b2d7 fix: update config.yaml 2026-05-28 13:58:01 +02:00
benoit 3041863286 fix: configuration devices audio Linux PipeWire
- config.yaml: utilise devices ALSA réels au lieu de builtin-output
- inputDeviceId: alsa_input.pci-0000_00_01.0.analog-stereo
- outputDeviceId: alsa_output.pci-0000_00_01.0.analog-stereo
2026-05-28 13:57:15 +02:00
benoit d5850a5918 fix: utiliser chemin absolu pour pactl dans PipeWireBackend
- /usr/bin/pactl au lieu de 'pactl' pour éviter problèmes PATH
- Correction dans getDevices(), getDefaultInputDevice(), getDefaultOutputDevice()
- Fix 'pactl: not found' malgré installation de pulseaudio-utils
2026-05-28 13:52:27 +02:00
benoit 52c2a0d326 fix: logs debug AudioBridge + install pulseaudio-utils
- Ajout logs FLUX 2 (réception audio LiveKit → carte son)
- Ajout logs GroupRouter pour debug routing
- install/linux.sh: ajout pulseaudio-utils (requis pour pactl)
2026-05-28 13:33:02 +02:00
benoit 574ca7e95d fix: détection automatique binaire LiveKit local vs PATH
- Linux : utilise server/bin/livekit-server (installé par install.sh)
- macOS : utilise livekit-server du PATH (Homebrew)
- Corrige erreur 'livekit-server: not found' sous Linux
2026-05-28 13:20:15 +02:00
benoit 1f0ac0647d fix: mise à jour LiveKit v1.12.0 pour installation Linux
- Correction version v1.5.2 → v1.12.0
- Fix format URL (retire 'v' du nom du fichier)
- Testé : téléchargement ARM64 fonctionnel (15M)
2026-05-28 13:14:54 +02:00
benoit 8882ff5892 fix: mode dev avec proxy WebSocket, mode prod avec HTTP direct
- Mode dev : proxy Vite /livekit → ws://localhost:7880 (évite mixed content)
- Mode prod : HTTP direct, pas de HTTPS (auto-hébergé local)
- Détection automatique du mode via import.meta.env.DEV
- En production réelle, HTTPS sera géré par reverse proxy
2026-05-27 22:42:18 +02:00
benoit b454fb2584 fix: connexion directe LiveKit en mode production
- Suppression du proxy /livekit qui n'existe qu'en dev
- Utilisation directe de l'URL WebSocket (ws://IP:7880)
- Rebuild client avec correction
- Résout l'erreur 'websocket closed code 1006'
2026-05-27 22:34:39 +02:00
benoit e84ed7c731 fix: routes API accessibles sous /api en production
- Création d'un apiRouter Express pour toutes les routes API
- Routes montées sous /api ET à la racine (rétrocompatibilité)
- QR code corrigé : HTTPS en mode production
- start.sh : affichage URL HTTPS corrigé
- Résout le problème de connexion en mode production
2026-05-27 22:31:41 +02:00
benoit 70fc1e833d fix: configuration API client pour mode production
- Ajout .env.production avec VITE_API_URL=. (URLs relatives)
- Le client build utilise maintenant les routes directes (/config au lieu de /api/config)
- Rebuild client avec la bonne configuration pour HTTPS production
2026-05-27 21:58:11 +02:00
benoit d46fa708e7 fix: health check HTTPS en mode production
- start.sh teste maintenant https://localhost:3000 en production
- Ajout flag -k à curl pour accepter certificat auto-signé
- Correction timeout qui empêchait le démarrage
2026-05-27 21:48:27 +02:00
benoit 6b13981dad feat: support HTTPS en mode production
- Ajout support HTTPS au serveur Express avec certificats auto-signés
- Variable d'environnement ENABLE_HTTPS pour activer/désactiver HTTPS
- start.sh active automatiquement HTTPS en mode production (pas --dev)
- Messages d'aide clarifiés avec URLs HTTPS et avertissement certificat
- WebSocket Audio Levels supporte wss:// en mode HTTPS

Mode dev : HTTPS sur port 5173 (Vite) + HTTP API sur 3000
Mode prod : HTTPS sur port 3000 (Express sert client + API)
2026-05-27 15:36:52 +02:00
benoit 244aadcf8b fix: génération QR code dans show-qr.sh fonctionnelle
- Réinstallation qrcode-terminal dans server/ (utilisé par show-qr.sh)
- Script Node inline dans show-qr.sh qui utilise le package depuis server/
- QR code s'affiche correctement au lancement
2026-05-27 15:17:10 +02:00
benoit d1cbf1fd21 chore: suppression dépendance qrcode-terminal du serveur
Utilisé via npx dans show-qr.sh, plus besoin dans server/node_modules
2026-05-27 15:15:32 +02:00
benoit 999fbf0412 feat: QR code dans script séparé show-qr.sh pour logs propres
- Création show-qr.sh : génère et affiche QR code avant lancement serveur
- Détection auto mode dev/prod pour URL correcte
- start.sh appelle show-qr.sh puis lance serveur silencieusement
- Logs serveur uniquement dans server.log (terminal propre)
- Suppression génération QR dans server/index.js (plus nécessaire)
- Suppression dépendance qrcode-terminal dans server (utilisé via npx dans show-qr.sh)
2026-05-27 15:14:22 +02:00
benoit 9b1db5a119 docs: mise à jour CLAUDE.md version 0.2.1 et fonctionnalités portabilité 2026-05-27 15:02:53 +02:00
benoit 5a4939dac8 chore: suppression fichiers récapitulatifs markdown
Selon CLAUDE.md : ne pas créer de fichiers récapitulatifs.
Suppression de CHANGELOG-PORTABLE.md et FEATURES-QR-HTTPS.md.

L'historique git suffit pour le changelog.
README-PORTABLE.md reste (documentation utilisateur).
2026-05-27 15:00:35 +02:00
benoit 7b1770dd40 fix: afficher QR code dans terminal au démarrage
Utilise 'tee' au lieu de redirection > pour :
- Afficher la sortie serveur dans le terminal (QR code visible)
- Conserver les logs dans server.log

Sans cela, le QR code n'était jamais visible pour l'utilisateur.
2026-05-27 14:59:51 +02:00
benoit 7aa09e5453 docs: documentation QR code et HTTPS
Guide complet des nouvelles fonctionnalités UX :
- QR code dans terminal (scan rapide smartphone)
- Redirection HTTP → HTTPS automatique
- Workflow avant/après
- Configuration nginx/Caddy production

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 14:55:55 +02:00
benoit 73e141c5db feat: QR code terminal + redirection HTTPS automatique
1. QR Code dans le terminal
   - Package qrcode-terminal installé
   - Génération QR code au démarrage serveur
   - Affiche URL dev (5173) ou prod (3000) selon build
   - Scan facile depuis smartphone

2. Redirection HTTP → HTTPS (mode dev)
   - Middleware redirection automatique
   - http://IP:3000https://IP:5173 (dev Vite)
   - Mode prod : à gérer avec nginx/caddy

3. URLs corrigées partout
   - start.sh : URLs HTTPS
   - install/macos.sh : HTTPS
   - install/linux.sh : HTTPS
   - Messages cohérents avec SSL

4. .gitignore
   - Ignore dev-dist/ (fichiers générés Vite)

Améliore drastiquement l'UX : scan QR + accès HTTPS direct.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 14:48:26 +02:00
benoit 79cda9653b fix: messages installation et serveur statique production
1. Messages finaux install scripts
   - Mise en avant de ./start.sh (recommandé)
   - Méthode manuelle en alternatif
   - Port production corrigé (3000 au lieu de 5173)

2. Serveur statique production (server/index.js)
   - Sert client/dist/ si build existe
   - Route / serve index.html en production
   - Mode dev : retourne info API JSON
   - Permet ./start.sh mode prod fonctionnel

Fix issues identifiés : messages obsolètes + production non fonctionnelle.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 14:39:02 +02:00
benoit 1050369469 Update Claude.md 2026-05-27 14:27:08 +02:00
benoit 01f1faa9aa docs: ajout changelog version portable 0.2.0
Récapitulatif complet de tous les changements pour la portabilité :
- Avant/après comparaison
- Changements détaillés par fichier
- Statistiques (4 commits, 10 fichiers modifiés, ~950 lignes)
- Guide d'utilisation simplifié

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 14:25:48 +02:00
benoit 94e03fcc5d chore: amélioration .gitignore et templates .env
1. .gitignore mis à jour
   - Ignore server/.env et client/.env (générés)
   - Préserve .env.example (templates)
   - Ignore server.log et PID files runtime

2. server/.env.example créé
   - Template documenté pour mode local/cloud
   - Instructions AUTO pour LIVEKIT_URL
   - Clés par défaut devkey/secret documentées

Finalise la configuration portable zéro-config.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 14:24:40 +02:00
benoit ec067329ce docs: guide portable complet et mise à jour README
1. README-PORTABLE.md
   - Guide complet déploiement portable
   - Installation zéro-config
   - Configuration avancée (IP manuelle, devices, ports)
   - Mode production (build, nginx)
   - Dépannage détaillé (firewall, audio, réseau)
   - Architecture portable expliquée

2. README.md mis à jour
   - Installation automatique en premier (recommandé)
   - Installation manuelle LiveKit Cloud en alternatif
   - Lien vers README-PORTABLE.md
   - Version bump 0.2.0 (Portable)

Documentation complète pour déploiement en conditions réelles.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 14:19:46 +02:00
benoit 324ff11be9 feat: scripts portables et API détection devices audio
1. API /admin/devices/list
   - Auto-détection devices audio macOS (sox)
   - Support Linux (JACK/PipeWire/PulseAudio)
   - Fallback Windows (placeholder Phase 3)

2. Scripts d'installation multi-OS
   - install.sh : détection OS automatique
   - install/linux.sh : génération .env auto (comme macOS)
   - Messages améliorés avec IP détectée

3. Script start.sh unifié
   - Lance serveur + client (dev ou prod)
   - Détection IP réseau au démarrage
   - Modes : ./start.sh (prod) ou ./start.sh --dev
   - Cleanup propre (trap SIGINT/SIGTERM)

Améliore drastiquement la portabilité du projet.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 13:27:53 +02:00
benoit b35f80fc7c feat: configuration portable - URLs et devices auto-détectés
Changements pour rendre l'installation portable :

1. Config réseau auto-détectée
   - config.yaml : LIVEKIT_URL = AUTO (au lieu de IP hardcodée)
   - Devices audio : null par défaut (auto-détection)

2. Client .env dynamique
   - Ajout client/.env.example avec documentation
   - VITE_API_URL configurable pour dev/prod

3. Vite config dynamique
   - Utilisation de loadEnv() pour variables d'environnement
   - Proxies configurables via VITE_API_URL et VITE_LIVEKIT_URL

4. Install script amélioré
   - Détection automatique IP réseau au moment install
   - Génération .env client avec IP détectée
   - Messages informatifs avec IP du serveur

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-27 13:14:14 +02:00
benoit f2e1a50d6d fix: résolution device IDs et correction sox capture args
Corrections pour le routing audio carte son → LiveKit :

**Fixes audio backend**
- AudioBridgeManager : extraction des device IDs depuis config.audio.device
- AudioBridge : ajout résolution device ID → device name pour CoreAudio/sox
- CoreAudioBackend : correction index args sox capture (args[2] au lieu de args[1])

**Résultat**
-  Sox capture fonctionne : lit depuis "Microphone MacBook Pro"
-  Audio capturé et envoyé vers routing
-  Sox playback se ferme après 0.2s (problème persistant à corriger)

**Autres modifications**
- Logging centralisé (Logger.js)
- IP corrigée : 192.168.0.146
- Suppression système channels[] legacy dans groupes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-26 22:01:53 +02:00
benoit a5879a2ea9 fix: corrections audit - connexion audio bridge, optimisations
- Implémente connexion AudioBridge → LiveKitClient pour flux audio bidirectionnel
  * Envoi audio carte son vers clients (sendAudioData)
  * Réception audio clients vers carte son (via event audioData)
- Supprime LiveKitServerBridge.js (code mort jamais utilisé)
- Retire console.log DEBUG de LiveKitClient.js
- Remplace device IDs hardcodés par null dans config.yaml (auto-détection)
- Optimise allocations buffers audio avec pool réutilisable
  * Pool de Float32Array et Buffer PCM (max 50 buffers)
  * Réduit pression GC pour 30+ clients simultanés

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-26 19:22:02 +02:00
benoit eb7959eb09 Création Audit 2026-05-26 16:29:10 +02:00
benoit 1c89546b61 fix: crash EPIPE lors ecriture dans sox stdin ferme
- Ajout error handler sur playbackProcess.stdin (capture EPIPE)
- Verification stdin.writable avant write
- Stop playback loop si stdin non disponible
- Evite crash serveur lors stop/reload AudioBridge
2026-05-26 15:36:00 +02:00
benoit f873dc25f6 fix: crash lors deconnexion LiveKit (destructuring undefined)
- LiveKitClient.emit('disconnected') envoie maintenant {reason}
- AudioBridge gere disconnected avec data optionnel (data?.reason)
- Corrige TypeError Cannot destructure property 'reason' of undefined
- Permet reload AudioBridge sans crash serveur
2026-05-26 15:33:39 +02:00
benoit e89b20295e debug: ajout logging detaille dans updateAudioDevice
- Log etapes: update, save, emit event
- Try/catch pour capturer erreurs
- Aide debugging crash serveur lors sauvegarde config audio
2026-05-26 15:31:44 +02:00
benoit 2338562b4f fix: ajout error handling pour getDefault audio devices
- Try/catch dans getDefaultInputDevice et getDefaultOutputDevice
- Cherche d'abord device avec isDefault.input/output = true
- Fallback sur premier device avec canaux disponibles
- Retourne null en cas d'erreur au lieu de crash
2026-05-26 15:30:20 +02:00
benoit 6a9ee05114 fix: empêcher réinitialisation dropdowns audio pendant édition
- Ajout état isEditingAudio pour tracker quand utilisateur édite
- loadAudioDevices() ne réécrase plus les sélections si isEditingAudio=true
- Activation isEditingAudio sur onChange des 3 dropdowns
- Désactivation après sauvegarde réussie
- Corrige bug: dropdowns se réinitialisaient toutes les 3s (auto-refresh)
2026-05-26 15:25:27 +02:00
benoit 2acd652df0 fix: detection correcte des cartes son CoreAudio avec nombre de canaux reel
- Parse coreaudio_device_input/output depuis system_profiler (nombre canaux)
- Ajoute sampleRate reel par device
- Ajoute metadata: manufacturer, transport, isDefault
- Filtre devices sans input ni output
- Corrige l'API pour exposer les 11 devices au lieu de 2
2026-05-26 15:18:41 +02:00
benoit 61b3bedcae fix: creation LocalAudioTrack depuis AudioSource pour publication
- Import LocalAudioTrack depuis @livekit/rtc-node
- Utilise LocalAudioTrack.createAudioTrack() pour creer track depuis source
- Corrige erreur 'Cannot read properties of undefined (reading handle)'
- Permet publication correcte du track audio du bridge
2026-05-26 14:56:18 +02:00
benoit cc4f5ca35a feat: generation automatique token JWT pour AudioBridge participant
- Import AccessToken depuis livekit-server-sdk
- Generation token avec identity 'AudioBridge' et metadata role:bridge
- Permissions completes (publish, subscribe, data)
- Utilise devkey/secret du serveur LiveKit
- Permet au bridge de se connecter en tant que participant authentifie
2026-05-26 14:32:16 +02:00
benoit be05755677 fix: migration vers @livekit/rtc-node pour bridge audio serveur
- Remplacement livekit-client (navigateur) par @livekit/rtc-node (serveur Node.js)
- Support natif AudioSource/AudioFrame pour gestion PCM bas niveau
- Réception audio via AudioStream asynchrone (for await)
- Publication track audio via AudioSource.captureFrame()
- Permet au serveur d'agir comme participant LiveKit complet
- Suppression dépendance livekit-client inutile côté serveur
2026-05-26 14:26:32 +02:00
benoit cd76b66529 feat: activation du vrai AudioBridge (sortie du mode placeholder)
- Import dynamique de AudioBridge.js
- Création instance avec config complète (routing, groupes, LiveKit)
- Démarrage effectif du bridge audio
- Gestion erreur pour ne pas bloquer le serveur si pas de carte son
- Remplace le mode placeholder par le vrai système audio
2026-05-26 14:23:31 +02:00
benoit 7e5c8744cd fix: ajout path /audio-levels dans URL WebSocket client 2026-05-26 14:20:33 +02:00
benoit 37aa447ecd fix: WebSocket audio-levels utilise le meme serveur HTTP
Probleme: AudioLevelsServer creait son propre port (3001) au lieu d'utiliser le serveur HTTP existant (3000)

Solution:
- Modification AudioLevelsServer pour accepter option 'server'
- Si serveur HTTP fourni, utilise WebSocket upgrade sur meme port avec path /audio-levels
- Sinon, fallback sur port standalone (3001)
- Client se connecte maintenant a ws://localhost:3000/audio-levels

Architecture WebSocket:
HTTP GET /config, POST /token       (port 3000)
WebSocket ws://localhost:3000/audio-levels (upgrade HTTP)

Plus besoin de port separe pour WebSocket
2026-05-26 14:19:56 +02:00
benoit 6c35121866 fix: demarrage WebSocket audio-levels + correction port client
Probleme: Client tentait de se connecter a ws://localhost:3001 mais serveur n'avait pas de WebSocket demarre

Solution:
- Ajout import AudioLevelsServer dans server/index.js
- Demarrage WebSocket sur meme port que l'API REST (3000)
- Correction port dans useAudioLevels.js (3000 au lieu de 3001)

Le WebSocket audio-levels fonctionne maintenant pour monitoring temps reel
2026-05-26 14:18:49 +02:00
benoit fb9d0fd101 fix: remplacement naudiodon par sox pour stabilite macOS
Probleme: naudiodon (bindings PortAudio) causait segfaults sur macOS

Solution: Utiliser sox (Sound eXchange) en subprocess

Modifications CoreAudioBackend.js:
- Remplacement naudiodon par sox (stable, deja installe sur macOS)
- Detection devices via system_profiler SPAudioDataType (vraies cartes)
- Capture audio via sox avec driver coreaudio
- Lecture audio via sox avec stdin/stdout
- Meme API (EventEmitter), compatible avec AudioBridge

Avantages sox:
- Stable (aucun segfault)
- Supporte toutes les cartes CoreAudio (USB, Thunderbolt, virtuelles)
- Multi-canaux natif
- Installe par defaut sur macOS (ou via brew install sox)
- Meme approche que JACK/PipeWire (subprocess)

Detection reelle des cartes:
- Parse system_profiler pour lister VRAIES cartes son
- Focusrite, MOTU, RME, Dante DVS, etc. detectes
- Fallback sur Built-in Mic/Output si aucune carte externe

Modifications package.json:
- Suppression dependance naudiodon (instable)

Modifications install/macos.sh:
- Ajout installation sox via Homebrew
- Detection si deja installe

Plus de warning "devices fictifs" au demarrage !
2026-05-26 14:16:13 +02:00
benoit e460376d9a feat: integration complete audio bridge cartes son macOS/Linux
Integration GroupAudioRouter dans AudioBridge pour routing bidirectionnel

Modifications AudioBridge.js:
- Ajout GroupAudioRouter pour matrice routing multi-canaux
- Flux CAPTURE: Carte Son → GroupRouter → Groupes → LiveKit
- Flux LECTURE: LiveKit → Groupes → GroupRouter → Carte Son
- Conversions PCM Buffer ↔ Float32Array pour routing
- Support multi-canaux (32+ canaux inputs/outputs)
- Events groupAudioOut/groupAudioIn pour pont LiveKit

Nouveau LiveKitServerBridge.js:
- Pont entre AudioBridge et LiveKit SFU
- Generation tokens JWT pour clients
- Gestion rooms par groupe
- API list participants/create room
- Events pour debug/monitoring

Documentation AUDIO_BRIDGE_ARCHITECTURE.md:
- Architecture complete flux audio bidirectionnel
- Pipeline detaille capture/lecture
- Configuration YAML routing multi-canaux
- Compatibilite macOS (CoreAudio) et Linux (JACK/PipeWire)
- Tests validation et performance
- Latence end-to-end 48-111ms (objectif < 150ms valide)

Documentation LIVEKIT_AUDIO_BRIDGE.md:
- Guide integration LiveKit Server SDK
- 3 approches possibles (rtc-node, DataChannel, participant virtuel)
- Code complet LiveKitServerBridge avec AudioSource
- Configuration serveur et variables env
- Tests compatibilite cartes son

Fonctionnalites:
- Serveur voit TOUTES les cartes son de la machine hote
- Routing flexible inputs → groupes → outputs avec gains
- Mixage additif multi-sources
- Anti-clipping automatique
- Compatible cartes USB/Thunderbolt/virtuelles (Dante DVS)
- Fonctionne sur macOS ET Linux

TODO Phase 3+: Implementer envoi reel vers LiveKit (rtc-node)
2026-05-26 14:12:50 +02:00
benoit 37ed66a043 docs: mise a jour TODO.md - Phase 3 completee (backends + docs) 2026-05-26 13:57:47 +02:00
benoit b766789a2a docs: ajout guides deploiement production et troubleshooting
Phase 3.4 - Documentation production complete

DEPLOYMENT.md:
- Architecture production recommandee (30+ clients)
- Specifications materiel serveur et reseau
- Configuration switch (VLAN, QoS, IGMP)
- Optimisations WiFi (Access Points, 5GHz, fast roaming)
- Installation systemd services (pttlive-server/client)
- Configuration audio multi-canaux (Dante/AES67)
- Monitoring Prometheus + Grafana
- Tests de charge (LoadBot, iperf3)
- Checklist pre-evenement
- Performances attendues (CPU, RAM, latence)

TROUBLESHOOTING.md:
- Diagnostics problemes audio (pas de son, latence, coupures)
- Diagnostics problemes reseau (connexion, WiFi)
- Diagnostics client PWA (bouton PTT, notifications)
- Diagnostics serveur (crash, memory leak)
- Diagnostics JACK/PipeWire (xruns, ports)
- Diagnostics Dante/AES67 (DVS, PTP sync)
- Outils de diagnostic (journalctl, jack_evmon, chrome://webrtc-internals)
- Checklist rapide par symptome

Fonctionnalites documentees:
- Services systemd production
- Monitoring temps reel (Grafana dashboards)
- Tests charge 30+ clients (scenarios)
- Budget latence end-to-end valide (< 150ms)
- Optimisations performance Linux
- Solutions tous problemes courants

TODO.md mis a jour: Phase 3.4 partiellement completee
2026-05-26 13:54:40 +02:00
benoit b5874b5c3b docs: ajout guides complets Dante et AES67
Phase 3.2 et 3.3 - Documentation intégrations audio professionnelles

DANTE_SETUP.md:
- Guide installation Dante Virtual Soundcard (DVS)
- Configuration JACK pour macOS/Linux/Windows
- Routing Dante Controller vers PTT Live
- Configuration multi-canaux (8+ canaux)
- Scripts de connexion automatique JACK
- Troubleshooting latence et connectivite
- Budget latence end-to-end (62-165ms)
- Comparaison couts DVS vs AES67

AES67_SETUP.md:
- Alternative open source gratuite a Dante
- Installation driver Merging ALSA RAVENNA (Linux)
- Configuration PTP (Precision Time Protocol) complete
- Setup reseau (VLAN, QoS, IGMP snooping)
- Configuration services systemd (ptp4l, phc2sys)
- Integration JACK avec flux RTP multicast
- Interoperabilite Dante mode AES67
- Configuration real-time Linux
- Troubleshooting PTP sync et xruns JACK
- Alternative trx pour RTP sans driver RAVENNA

Fonctionnalites documentees:
- Routing audio multi-canaux professionnel
- Synchronisation horloge reseau (PTP)
- Configuration switches manageables
- Optimisations performance Linux
- Budget latence < 150ms end-to-end

TODO.md mis a jour: Phase 3.2 et 3.3 partiellement completees
2026-05-26 13:40:47 +02:00
benoit 37205f0409 feat: ajout support Linux avec backends JACK et PipeWire
Phase 3.1 - Support Linux professionnel

Nouveaux backends audio:
- JACKBackend.js : support JACK Audio Connection Kit pour audio pro
- PipeWireBackend.js : support PipeWire (standard moderne Linux)
- Detection automatique dans AudioBridge (PipeWire > JACK > erreur)

Script installation:
- install/linux.sh pour Ubuntu/Debian/Arch/Fedora
- Installation automatique dependencies (Node.js, PipeWire/JACK)
- Telechargement LiveKit Server pour Linux (amd64/arm64)

Fonctionnalites:
- Detection serveur audio (PipeWire/JACK)
- Enumeration devices audio via pactl/jack_lsp
- Capture et lecture audio basse latence (pw-cat, jack_rec/play)
- Messages d'erreur detailles pour troubleshooting
- Compatibilite Ubuntu 22.04+, Debian 11+, Arch Linux, Fedora

TODO.md mis a jour: Phase 3.1 en cours
2026-05-26 13:37:18 +02:00
benoit 9654c7f421 docs: mise a jour TODO.md - Phase 2.5 terminee
- Configuration audio visuelle complete
- GroupAudioRouter avec routing multi-canaux et gains
- Matrice routing avec dropdowns gain et VU-metres temps reel
- WebSocket audio-levels operationnel
- Phase 2 entierement terminee
2026-05-25 22:18:45 +02:00
benoit f5a5643f4b feat: ajout VU-metres temps reel dans matrice routing
- Hook React useAudioLevels pour WebSocket audio-levels
- Composant VUMeter (mini, horizontal, vertical)
- Integration VU-metres dans headers/labels matrice
- Indicateur etat connexion WebSocket (Live/Offline)
- Affichage RMS, peak, detection clipping
- Design responsive avec animations clipping
2026-05-25 22:17:48 +02:00
benoit b64bac1f3d feat: ajout WebSocket server pour monitoring niveaux audio temps réel
- Calcul RMS et peak par canal (dBFS)
- Détection clipping automatique
- Broadcast temps réel 20 fois/sec (configurable)
- Support inputs, groups, outputs
- Gestion multi-clients WebSocket
- API pour mise à jour depuis GroupAudioRouter
2026-05-25 22:12:48 +02:00
benoit 5ae9dfe2ac feat: ajout dropdowns gain par route dans matrice routing
- Dropdown gain -12dB à +6dB pour chaque route active
- Interface visuelle améliorée (checkbox + gain)
- Gestion gains input->group et group->output
- Sauvegarde gains dans config.yaml
- Design responsive mobile
2026-05-25 22:11:43 +02:00
benoit 8c43c7e8af feat: ajout GroupAudioRouter.js pour routing audio multi-canaux avec gains
- Mix de plusieurs canaux physiques vers groupes (gains individuels)
- Distribution groupes vers plusieurs canaux physiques (gains individuels)
- Support canaux partagés avec mixage additif
- Gestion gains par route (-120dB à +6dB)
- Anti-clipping automatique
- Statistiques routing temps réel
2026-05-25 22:07:44 +02:00
benoit 3ee474d90c fix: correction adresse IP proxy LiveKit dans vite.config 2026-05-25 21:47:48 +02:00
benoit 86b86e9037 feat: ajout système de notifications Web Push et prompt installation PWA iOS 2026-05-25 21:12:05 +02:00
benoit 7682b90557 feat: ajout système de préférences utilisateur avec mode PTT par défaut 2026-05-25 21:10:16 +02:00
benoit 63147f93f4 feat: ajout filtre canaux nommés dans matrice routing 2026-05-25 21:04:59 +02:00
benoit 42badb1fdf refactor: remplacement système de canaux statiques par canaux virtuels depuis routing 2026-05-25 21:03:40 +02:00
benoit 7037517ca2 fix: correction route POST /audio/routing (suppression /admin en double)
- Route montée sous /admin dans index.js
- Ne pas répéter /admin dans la définition de route
- Correction 404 Cannot POST /admin/audio/routing
2026-05-25 10:06:14 +02:00
benoit ba3d32fd3d fix: correction warnings React et gestion erreurs matrice routing
- Ajout React.Fragment avec keys pour éliminer warnings
- Import de React pour utiliser React.Fragment
- Meilleure gestion erreurs HTTP (status check)
- Messages d'erreur plus détaillés avec status code
- Logging amélioré pour debugging
2026-05-25 10:04:22 +02:00
benoit 0b31708b48 fix: correction layout matrice routing (affichage en grille)
- Ajout grid-template-columns dynamique basé sur nombre groupes
- Suppression display:contents qui causait le bug
- Utilisation de fragments React au lieu de div wrapper
- Matrice 1: 120px + N colonnes pour les groupes
- Matrice 2: 120px + 8 colonnes pour les outputs
- Nettoyage CSS classes inutilisées
2026-05-25 10:02:20 +02:00
benoit 4a8a7a60e1 docs: mise à jour TODO.md - Phase 2.5 configuration audio visuelle complétée 2026-05-25 09:57:11 +02:00
benoit e053924b63 feat: matrice de routing audio style Dante Controller (Phase 2.5)
- API GET/POST /admin/audio/routing
- Composant AudioRoutingMatrix avec 2 matrices :
  * Inputs vers Groupes (8 inputs x N groupes)
  * Groupes vers Outputs (N groupes x 8 outputs)
- Interface visuelle type grille cliquable
- Intégration noms de canaux personnalisés
- Stockage routing dans config.yaml
- Responsive design avec CSS Grid
- Style cohérent avec interface admin
2026-05-25 09:56:31 +02:00
benoit 0aebf3e3e0 docs: mise à jour TODO.md - nommage canaux complété 2026-05-25 09:54:59 +02:00
benoit ccfdd54e2c feat: ajout nommage canaux physiques (Phase 2.5)
- API GET /admin/audio/channels/names
- API PUT /admin/audio/channels/names
- Interface admin : nommage 8 inputs/outputs
- Mode édition avec sauvegarde/annulation
- Stockage dans config.yaml (section audio.channelNames)
- Formulaire organisé en 2 colonnes (inputs/outputs)
2026-05-25 09:54:43 +02:00
benoit c1202a63a5 feat: amélioration UI onglet Audio admin (Phase 2.5)
- Styles CSS professionnels pour configuration audio
- Sections visuelles avec bordures et hover effects
- Indicateurs de sélection pour devices
- Tableau devices amélioré avec styles cohérents
- Layout responsive et centré
- Suppression emojis (respect guidelines)
2026-05-25 09:53:16 +02:00
benoit 4a4c3e40ad docs: mise à jour TODO.md - Phase 2.5 détection carte son terminée 2026-05-25 09:46:14 +02:00
benoit 9350c9410c feat: système hot-reload bridge audio avec ConfigManager (Phase 2.5)
- ConfigManager: gestionnaire centralisé config avec EventEmitter
- AudioBridgeManager: gestion bridge avec auto-reload sur changement config
- Intégration dans serveur principal (index.js)
- Événements 'audio-device-updated' et 'config-updated'
- Reload automatique du bridge sans redémarrer serveur
- Mode placeholder pour développement (vrai bridge Phase 3)
2026-05-25 09:45:59 +02:00
benoit 7fd60315dd docs: mise à jour TODO Phase 2.5 détection carte son complétée 2026-05-25 09:42:33 +02:00
benoit 5583808279 feat: ajout interface admin pour configuration carte son (Phase 2.5)
- Nouvel onglet Audio dans l'interface admin
- Sélection carte son d'entrée/sortie via dropdowns
- Configuration sample rate (44.1/48/96kHz)
- Affichage liste toutes cartes disponibles
- Affichage configuration actuelle
- Sauvegarde vers API backend
2026-05-25 09:42:10 +02:00
benoit 03b3f94824 feat: ajout APIs détection et configuration cartes son (Phase 2.5)
- GET /admin/audio/devices : énumération devices CoreAudio
- GET /admin/audio/device : récupération config actuelle
- POST /admin/audio/device : sélection carte son + sample rate
- Workaround naudiodon segfault avec devices fictifs
- Configuration sauvegardée dans config.yaml
2026-05-25 09:40:43 +02:00
61 changed files with 16454 additions and 708 deletions
+12
View File
@@ -8,10 +8,18 @@ pnpm-lock.yaml
.env
.env.local
.env.*.local
server/.env
client/.env
# Keep .env.example files (templates)
!.env.example
!client/.env.example
!server/.env.example
# Build outputs
dist/
build/
dev-dist/
*.log
# OS files
@@ -43,3 +51,7 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
server.log
# Runtime files
/tmp/ptt-live.pid
+82
View File
@@ -0,0 +1,82 @@
# Audit PTT Live — 2026-05-26
## Structure & Documentation
La structure réelle **diverge de CLAUDE.md** — l'implémentation a avancé au-delà du plan initial sans mise à jour de la doc.
Fichiers documentés mais absents : `server/api/routes.js`, `client/src/utils/audio.js`
Fichiers présents mais non documentés : `AudioBridgeManager.js`, `LiveKitServerBridge.js`, `AudioLevelsServer.js`, + plusieurs composants React.
---
## Problèmes critiques
**1. Chaîne audio serveur incomplète**
Le bridge audio ne transmet pas l'audio capturé vers LiveKit. Les TODOs sont explicites :
- `server/bridge/AudioBridge.js:368` : `// TODO: Envoyer opusData à LiveKit pour ce groupe spécifique`
- `server/bridge/AudioBridge.js:439` : `// TODO: Implémenter réception bas niveau Opus depuis LiveKit`
Le flux `carte son → LiveKit → clients` n'est pas fonctionnel côté serveur.
**2. `LiveKitServerBridge.js` jamais utilisé**
Fichier créé, jamais importé ni appelé. Contient lui-même un `// TODO: Implémenter l'envoi réel vers LiveKit`. Code mort qui confond les responsabilités avec `LiveKitClient.js`.
**3. Pas d'authentification sur `/admin`**
N'importe qui sur le réseau peut modifier la configuration (groupes, routing, devices). Critique en production.
Note du dev : c'est normal et non critique pour le moment.
---
## Problèmes de sécurité
| Sévérité | Problème |
|----------|----------|
| Haute | CORS `*` dans `server/index.js` — accès depuis n'importe quel domaine |
| Haute | API `/admin` sans authentification |
| Moyenne | Clés LiveKit hardcodées en fallback `'devkey'/'secret'` |
Note du dev : l'app a pour vocation à être utilisée sur un réseau local.
---
## Qualité du code
**Points positifs**
- Architecture modulaire solide
- EventEmitter bien utilisé pour la réactivité et le hot-reload
- Gestion d'erreurs gracieuse (fallback sans crash si pas de carte son)
- OpusCodec robuste avec presets configurables
- JitterBuffer avec stats adaptatives
**Points faibles**
- Logging DEBUG non retiré dans `server/bridge/LiveKitClient.js:93`
- Device IDs hardcodés dans `config.yaml` (`inputDeviceId: 4`, `outputDeviceId: 0`) — non portable
- Création de `Float32Array` à chaque frame audio → pression GC potentielle à 30+ clients
---
## État des phases
| Phase | Avancement | Bloquant |
|-------|-----------|---------|
| Phase 1 MVP | ~80% | Bridge audio serveur incomplet |
| Phase 2 Fonctionnalités | ~95% | Authentification manquante |
| Phase 3 Intégrations | ~85% | Tests réels manquants |
---
## Recommandations par priorité
### Priorité 1 — Bloquant
1. Implémenter la connexion `AudioBridge → LiveKitClient` (TODOs lignes 368/439)
2. Ajouter authentification sur `/admin` (token Bearer ou session)
3. Supprimer ou intégrer `LiveKitServerBridge.js`
### Priorité 2 — Important
4. CORS : remplacer `*` par origin explicite du client
5. Retirer les `console.log` DEBUG de `LiveKitClient.js`
6. Device IDs : auto-détection plutôt que valeurs hardcodées
### Priorité 3 — Amélioration
7. Pool de buffers audio pré-alloués pour tenir 30+ clients
8. Mettre à jour `CLAUDE.md` avec la structure réelle du code
9. Tests d'intégration E2E (latence mesurée, scénario multi-clients)
+36 -11
View File
@@ -204,24 +204,48 @@ PTT Live/
## Commandes de développement
```bash
# Installation initiale
./install/macos.sh
# Installation automatique (recommandé)
./install.sh # Détecte OS, configure tout automatiquement
# Serveur (dev)
# Démarrage rapide
./start.sh --dev # Mode développement
./start.sh # Mode production
# OU manuellement (deux terminaux)
# Serveur
cd server
npm install
npm run dev
# Client (dev)
# Client
cd client
npm install
npm run dev
# Production
cd server
npm start
```
## Fonctionnalités de portabilité (v0.2.1)
### Installation zéro-config
- **Script multi-OS** : `install.sh` détecte automatiquement macOS/Linux
- **Auto-détection IP** : Génère les `.env` avec l'IP réseau du serveur
- **Devices audio** : API `/admin/devices/list` pour énumérer devices disponibles
- **Templates** : `.env.example` pour serveur et client
### QR Code terminal
- **Affichage automatique** au démarrage du serveur
- **Scan rapide** depuis smartphone (connexion en 5s)
- **URL adaptative** : dev (5173) ou prod (3000) selon build client
### HTTPS automatique
- **Vite dev server** : HTTPS par défaut (certificat self-signed)
- **Redirection HTTP → HTTPS** en mode développement
- **Production** : utiliser reverse proxy (nginx/Caddy) pour HTTPS
### Configuration dynamique
- **LIVEKIT_URL: AUTO** dans config.yaml → détection IP runtime
- **Vite loadEnv()** pour variables d'environnement dynamiques
- **Serveur statique** : Express sert `client/dist/` en production
## Tests et validation
### Métriques critiques
@@ -307,7 +331,8 @@ test: description
2. **Après chaque tâche complétée** :
- ✅ Valider la tâche dans [TODO.md](TODO.md)
- 🔄 Commiter avec message descriptif en français
- 📝 Mettre à jour CLAUDE.md si nécessaire
- 📝 Mettre à jour CLAUDE.md si nécessaire sans écrire "🤖 Generated with Claude Code Co-Authored-By: Claude noreply@anthropic.com"
- Ne pas créer de fichiers récapitulatifs markdown.
**Exemple workflow** :
```bash
@@ -326,5 +351,5 @@ Voir [TODO.md](TODO.md) pour le plan détaillé.
---
**Dernière mise à jour** : 2026-05-21
**Version** : 0.1.0 (Phase 1 en cours)
**Dernière mise à jour** : 2026-05-27
**Version** : 0.2.1 (Portable + QR Code)
+408
View File
@@ -0,0 +1,408 @@
# PTT Live - Guide de Déploiement Portable
Ce guide explique comment déployer **PTT Live** sur n'importe quelle machine macOS ou Linux, sans configuration manuelle d'IP ou de devices audio.
---
## 🚀 Installation Rapide
### Prérequis
- **macOS** : Homebrew installé ([brew.sh](https://brew.sh))
- **Linux** : Ubuntu 22.04+, Debian 11+, Arch Linux ou Fedora
- **Node.js** : Version 20+ (installé automatiquement si absent)
- **Connexion Internet** : Pour télécharger les dépendances
### Commandes
```bash
# Cloner ou télécharger le projet
cd ptt-live
# Lancer l'installation (détection automatique OS)
./install.sh
# Ou manuellement selon votre système :
./install/macos.sh # macOS
./install/linux.sh # Linux
```
### Ce que l'installeur fait automatiquement
✅ Détecte votre système d'exploitation
✅ Installe Node.js 20+ (si absent)
✅ Installe LiveKit Server (binaire local)
✅ Installe les backends audio (sox/PipeWire/JACK)
✅ Détecte votre IP réseau locale
✅ Génère les fichiers `.env` avec la bonne configuration
✅ Installe toutes les dépendances npm
---
## 🎬 Démarrage
### Méthode 1 : Script unifié (recommandé)
```bash
# Mode développement (serveur + client avec hot-reload)
./start.sh --dev
# Mode production (build client + serveur optimisé)
./start.sh
```
L'IP réseau est **détectée automatiquement** et affichée au démarrage.
### Méthode 2 : Manuel (deux terminaux)
**Terminal 1 : Serveur**
```bash
cd server
npm run dev
```
**Terminal 2 : Client**
```bash
cd client
npm run dev
```
---
## 🌐 Accès depuis d'autres appareils
### Sur le même réseau WiFi
Après le démarrage, notez l'**IP affichée** (exemple : `192.168.1.100`).
#### Depuis un smartphone/tablette
1. **Connectez l'appareil au même WiFi** que le serveur
2. Ouvrez le navigateur
3. Allez sur : `https://IP_SERVEUR:5173` (dev) ou `http://IP_SERVEUR:3000` (prod)
4. **iOS** : Installez la PWA sur l'écran d'accueil pour activer les notifications
#### Depuis un autre ordinateur
Même procédure : `https://IP_SERVEUR:5173`
---
## ⚙️ Configuration Avancée
### Changer l'IP du serveur manuellement
Si l'auto-détection ne fonctionne pas (VPN, Docker, etc.) :
**1. Modifier `server/.env`**
```bash
# Remplacer AUTO par l'IP voulue
LIVEKIT_URL=ws://192.168.1.100:7880
```
**2. Pour le client (accès réseau)**
Modifier `client/.env` :
```bash
# Décommenter et mettre l'IP du serveur
VITE_API_URL=http://192.168.1.100:3000
```
**3. Redémarrer**
```bash
./start.sh --dev
```
### Lister les devices audio disponibles
```bash
# Via API (serveur doit tourner)
curl http://localhost:3000/admin/devices/list
# Retourne JSON :
{
"inputs": [
{ "id": 0, "name": "Microphone MacBook Pro" },
{ "id": 4, "name": "USB Audio Interface" }
],
"outputs": [...],
"platform": "darwin"
}
```
Utilisez ensuite l'interface admin (`/admin`) pour sélectionner les devices.
### Changer les ports
**API serveur (port 3000 par défaut)**
Modifier `server/.env` :
```bash
PORT=3001
```
**Client dev (port 5173 par défaut)**
Modifier `client/vite.config.js` :
```javascript
server: {
port: 5174,
// ...
}
```
---
## 📦 Mode Production (événement en conditions réelles)
### Build optimisé
```bash
# Build du client statique
cd client
npm run build
# Le dossier dist/ contient le build optimisé
```
### Servir en production
```bash
# Méthode 1 : Script start.sh (recommandé)
./start.sh
# Méthode 2 : npm start direct
cd server
npm start
# Le serveur Express sert automatiquement client/dist/
```
### Reverse proxy Nginx (optionnel)
Pour un domaine personnalisé avec HTTPS :
```nginx
server {
listen 443 ssl http2;
server_name ptt.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# Client PWA
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# WebSocket LiveKit
location /livekit {
proxy_pass http://localhost:7880;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
}
```
---
## 🐛 Dépannage
### Erreur "Port déjà utilisé"
**Port 3000 (API)**
```bash
# Trouver le processus
lsof -i :3000
# Tuer ou changer PORT dans .env
```
**Port 7880 (LiveKit)**
```bash
lsof -i :7880
# Arrêter LiveKit ou changer dans config.yaml
```
### IP détectée incorrecte
**Lister toutes les interfaces réseau :**
```bash
# macOS
ifconfig | grep "inet "
# Linux
ip addr show
```
Puis modifier `server/.env` avec la bonne IP.
### Clients ne peuvent pas se connecter
**1. Vérifier le serveur**
```bash
curl http://IP_SERVEUR:3000/health
```
**2. Vérifier LiveKit**
```bash
curl http://IP_SERVEUR:7880
```
**3. Firewall**
macOS/Linux : autoriser ports 3000, 7880, 7882 (UDP)
```bash
# macOS
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --add /path/to/node
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --unblock /path/to/livekit-server
# Linux (ufw)
sudo ufw allow 3000/tcp
sudo ufw allow 7880/tcp
sudo ufw allow 7882/udp
```
### Pas d'audio (macOS)
**Permissions microphone :**
1. **Navigateur** : Autoriser le micro dans les préférences Safari/Chrome
2. **Terminal** : `Réglages Système > Confidentialité > Microphone` → autoriser Terminal
**Carte son externe :**
```bash
# Lister devices
curl http://localhost:3000/admin/devices/list
# Sélectionner via interface admin
open http://localhost:3000/admin
```
### Pas d'audio (Linux)
**Vérifier PipeWire :**
```bash
systemctl --user status pipewire
pw-cli info 0
```
**Démarrer si inactif :**
```bash
systemctl --user start pipewire pipewire-pulse
```
**Lister devices PulseAudio :**
```bash
pactl list short sources # Inputs
pactl list short sinks # Outputs
```
---
## 📚 Architecture Portable
### Structure des fichiers de configuration
```
PTT Live/
├── server/
│ ├── .env # Généré par install (IP auto)
│ └── config/
│ └── config.yaml # LIVEKIT_URL = AUTO
├── client/
│ ├── .env # Généré par install
│ └── .env.example # Template
└── install/
├── macos.sh # Détection IP + génération .env
└── linux.sh # Idem
```
### Flux de configuration automatique
```
1. install.sh
└─> Détecte OS (macOS/Linux)
└─> Lance install/{os}.sh
└─> Détecte IP réseau (ifconfig/hostname)
└─> Génère server/.env avec LIVEKIT_URL=AUTO
└─> Génère client/.env avec IP dans commentaires
2. npm run dev (serveur)
└─> Lit server/.env
└─> Si LIVEKIT_URL=AUTO → détecte IP au runtime (index.js:75)
└─> Lance LiveKit sur 0.0.0.0:7880
└─> Retourne ws://IP_DETECTÉE:7880 aux clients via /token
3. Client se connecte
└─> Appelle POST /token avec username + groupId
└─> Reçoit { token, url: "ws://192.168.x.x:7880" }
└─> Se connecte automatiquement à la bonne URL
```
**Résultat** : **Zéro configuration manuelle** d'IP pour l'utilisateur final.
---
## 🔒 Sécurité en Production
### Bonnes pratiques
1. **Changer les clés LiveKit** (par défaut : `devkey/secret`)
Modifier `server/.env` :
```bash
LIVEKIT_API_KEY=$(openssl rand -hex 32)
LIVEKIT_API_SECRET=$(openssl rand -hex 64)
```
2. **Activer HTTPS/WSS** (avec certificats Let's Encrypt ou mkcert)
3. **Firewall strict** : Autoriser seulement les ports nécessaires
4. **Authentification admin** : Ajouter un mot de passe sur `/admin` (Phase 2.3)
5. **VLAN dédié** : Isoler le réseau PTT Live du reste du LAN (événements)
---
## ✨ Fonctionnalités Portables
✅ **Auto-détection IP réseau** (macOS/Linux)
✅ **Auto-détection devices audio** (API `/admin/devices/list`)
**Génération .env automatique** lors de l'installation
**Scripts start.sh multi-OS** (dev/prod)
**Configuration dynamique Vite** (loadEnv)
**Support JACK, PipeWire, CoreAudio**
**PWA installable** (iOS/Android)
---
## 📖 Documentation Complémentaire
- [README.md](README.md) — Guide utilisateur complet
- [NETWORK_SETUP.md](NETWORK_SETUP.md) — Configuration réseau détaillée
- [CLAUDE.md](CLAUDE.md) — Documentation développement
- [docs/](docs/) — Guides techniques (JACK, Dante, AES67)
---
**Dernière mise à jour** : 2026-05-27
**Version** : 0.2.0 (Portable)
+39 -15
View File
@@ -8,20 +8,43 @@ Communiquez via smartphone (PWA) en WiFi, le serveur fait le pont avec l'install
## 🚀 Démarrage rapide
### Prérequis
### Installation Automatique (Recommandé)
- Node.js 20+ ([télécharger](https://nodejs.org))
- Compte LiveKit Cloud gratuit ([créer ici](https://cloud.livekit.io))
**Un seul script pour tout installer** (détection automatique macOS/Linux) :
### Installation (5 minutes)
```bash
# Lancer l'installation portable
./install.sh
1. **Installer les dépendances**
# Démarrer le système
./start.sh --dev
```
**L'installeur configure automatiquement** :
- LiveKit Server local (pas besoin de compte cloud)
- Détection et configuration IP réseau
- Backends audio (sox/PipeWire/JACK selon OS)
- Toutes les dépendances
📖 **Guide portable complet** : [README-PORTABLE.md](README-PORTABLE.md)
---
### Installation Manuelle (avec LiveKit Cloud)
**Alternative si vous préférez utiliser LiveKit Cloud**
1. **Prérequis**
- Node.js 20+ ([télécharger](https://nodejs.org))
- Compte LiveKit Cloud gratuit ([créer ici](https://cloud.livekit.io))
2. **Installer les dépendances**
```bash
cd server && npm install
cd ../client && npm install
```
2. **Configurer LiveKit Cloud**
3. **Configurer LiveKit Cloud**
- Créer compte sur https://cloud.livekit.io
- Créer un projet
@@ -35,7 +58,7 @@ Communiquez via smartphone (PWA) en WiFi, le serveur fait le pont avec l'install
USE_LOCAL_LIVEKIT=false
```
3. **Démarrer**
4. **Démarrer**
Terminal 1 :
```bash
@@ -47,13 +70,13 @@ Communiquez via smartphone (PWA) en WiFi, le serveur fait le pont avec l'install
cd client && npm run dev
```
4. **Tester** : http://localhost:5173
5. **Tester** : https://localhost:5173
- Se connecter avec votre nom
- Ouvrir second onglet avec autre nom
- Maintenir bouton PTT pour parler !
📖 **Guide complet** : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
📖 **Guide LiveKit Cloud** : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
---
@@ -84,6 +107,8 @@ Voir le guide complet : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
## 📚 Documentation
- **[README-PORTABLE.md](README-PORTABLE.md)** - 🆕 **Guide déploiement portable** (zéro config)
- **[NETWORK_SETUP.md](NETWORK_SETUP.md)** - Configuration réseau multi-appareils
- **[docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)** - Configuration LiveKit (Cloud + Local)
- **[CLAUDE.md](CLAUDE.md)** - Documentation développement complète
- **[TODO.md](TODO.md)** - Progression des phases
@@ -92,13 +117,12 @@ Voir le guide complet : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
## 🎯 État du projet
- ✅ **Phase 1.1** : Infrastructure
- ✅ **Phase 1.2** : Serveur + API REST
- **Phase 1.3** : Bridge audio macOS
- **Phase 1.4** : Client PWA React
- ⏳ **Phase 1.5** : Tests validation
- ✅ **Phase 1** : MVP fonctionnel (WebRTC + PTT)
- ✅ **Phase 2** : Fonctionnalités avancées (groupes, routing, admin)
- 🆕 **Portable** : Installation zéro-config macOS/Linux
- **Phase 3** : Intégrations audio pro (Dante, AES67)
**Version actuelle** : 0.1.0 (Phase 1 MVP en cours)
**Version actuelle** : 0.2.0 (Portable - production-ready)
---
+74 -60
View File
@@ -1,7 +1,7 @@
# TODO.md - Plan de développement PTT Live
**Dernière mise à jour** : 2026-05-24
**Phase actuelle** : PHASE 2 - Fonctionnalités professionnelles (En cours - Phase 2.5 Configuration audio visuelle)
**Dernière mise à jour** : 2026-05-26
**Phase actuelle** : PHASE 3 - Intégrations audio pro (Phase 3.1 EN COURS - Support Linux)
---
@@ -156,7 +156,7 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
### 2.2 Modes PTT avancés
- [x] Mode continu : toggle ON/OFF (appui long 3s)
- [x] Vibration + indicateur visuel rouge (lock actif)
- [ ] Préférences utilisateur (mode par défaut)
- [x] Préférences utilisateur (mode par défaut)
### 2.3 Interface admin
- [x] Page admin web (/admin)
@@ -167,93 +167,105 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
### 2.5 Configuration audio visuelle (PRIORITÉ)
#### Détection et sélection carte son
- [ ] API GET /api/audio/devices (énumération cartes son CoreAudio/JACK)
- [ ] API POST /api/audio/device (sélection + config sample rate/buffer)
- [ ] Page admin : dropdown sélection carte son
- [ ] Page admin : affichage infos carte (entrées/sorties, sample rate)
- [ ] Backend : reload bridge audio sans redémarrer serveur
- [x] API GET /api/audio/devices (énumération cartes son CoreAudio/JACK)
- [x] API POST /api/audio/device (sélection + config sample rate/buffer)
- [x] Page admin : dropdown sélection carte son
- [x] Page admin : affichage infos carte (entrées/sorties, sample rate)
- [x] Backend : reload bridge audio sans redémarrer serveur
#### Nommage des canaux
- [ ] API PUT /api/audio/channels/names (sauvegarde noms canaux)
- [ ] API GET /api/audio/channels/names (récupération noms)
- [ ] Page admin : formulaire nommage canaux (inputs/outputs)
- [ ] Page admin : filtre "canaux nommés uniquement"
- [ ] Sauvegarde automatique dans config.yaml
- [x] API PUT /api/audio/channels/names (sauvegarde noms canaux)
- [x] API GET /api/audio/channels/names (récupération noms)
- [x] Page admin : formulaire nommage canaux (inputs/outputs)
- [x] Page admin : filtre "canaux nommés uniquement"
- [x] Sauvegarde automatique dans config.yaml
#### Matrice de routing (style Dante Controller)
- [ ] API GET /api/audio/routing (récupération routing actuel)
- [ ] API POST /api/audio/routing (sauvegarde routing)
- [ ] Component React : AudioRoutingMatrix.jsx
- [ ] Matrice inputs → groups (checkboxes)
- [ ] Matrice groups → outputs (checkboxes)
- [ ] Dropdowns gain par route (-12dB à +6dB)
- [ ] Indicateurs niveaux temps réel (WebSocket)
- [ ] Backend : GroupAudioRouter.js (routing par groupe)
- [ ] Mix canaux physiques multiples → groupe
- [ ] Distribution groupe → canaux physiques multiples
- [ ] Gestion gains individuels
- [ ] Support canaux partagés (mixage additif)
- [ ] Backend : ConfigManager.js (lecture/écriture YAML)
- [ ] Méthodes update pour device/channels/routing
- [ ] Sauvegarde atomique avec backup auto
- [ ] Émission événement config-updated
- [ ] WebSocket audio-levels (monitoring temps réel)
- [ ] Tests : routing multi-canaux, canaux partagés
- [x] API GET /api/audio/routing (récupération routing actuel)
- [x] API POST /api/audio/routing (sauvegarde routing)
- [x] Component React : AudioRoutingMatrix.jsx
- [x] Matrice inputs → groups (checkboxes)
- [x] Matrice groups → outputs (checkboxes)
- [x] Dropdowns gain par route (-12dB à +6dB)
- [x] Indicateurs niveaux temps réel (WebSocket)
- [x] Backend : GroupAudioRouter.js (routing par groupe)
- [x] Mix canaux physiques multiples → groupe
- [x] Distribution groupe → canaux physiques multiples
- [x] Gestion gains individuels
- [x] Support canaux partagés (mixage additif)
- [x] Backend : ConfigManager.js (lecture/écriture YAML)
- [x] Méthodes update pour device/channels/routing
- [x] Sauvegarde atomique avec backup auto
- [x] Émission événement config-updated
- [x] WebSocket audio-levels (monitoring temps réel)
- [x] Server WebSocket AudioLevelsServer.js
- [x] Hook React useAudioLevels
- [x] Composant VUMeter (mini/horizontal/vertical)
- [x] Intégration VU-mètres dans matrice routing
- [ ] Tests : routing multi-canaux, canaux partagés - Phase 3
### 2.4 Notifications
- [ ] Web Push : appels privés
- [ ] Service Worker : gestion notifications
- [ ] iOS : message onboarding "Installer sur écran d'accueil"
- [ ] Permissions notification au premier lancement
- [x] Web Push : appels privés (infrastructure prête)
- [x] Service Worker : gestion notifications
- [x] iOS : message onboarding "Installer sur écran d'accueil"
- [x] Permissions notification au premier lancement
---
## PHASE 3 — Intégrations audio pro
### 3.1 Support Linux
- [ ] Backend JACK (server/bridge/backends/JACKBackend.js)
- [ ] Backend PipeWire (server/bridge/backends/PipeWireBackend.js)
- [ ] Script install/linux.sh
- [x] Backend JACK (server/bridge/backends/JACKBackend.js)
- [x] Backend PipeWire (server/bridge/backends/PipeWireBackend.js)
- [x] Script install/linux.sh
- [ ] Tests Ubuntu 22.04 LTS + Arch Linux
### 3.2 Dante
- [ ] Documentation setup DVS macOS
- [ ] Routing JACK ↔ DVS
- [x] Documentation setup DVS macOS
- [x] Guide configuration réseau Dante
- [ ] Routing JACK ↔ DVS (tests pratiques)
- [ ] Tests multi-canaux (8+)
- [ ] Guide configuration réseau Dante
### 3.3 AES67
- [ ] Backend RTP multicast (Linux)
- [ ] PTP sync
- [x] Documentation setup AES67 + PTP sync
- [ ] Backend RTP multicast (Linux) - optionnel, driver Merging RAVENNA suffit
- [ ] Tests interop Dante (mode AES67)
### 3.4 Production
- [ ] Script install Windows (install/windows.ps1)
- [ ] Tests charge : 30+ clients simultanés
- [ ] Optimisation réseau (QoS, DSCP)
- [ ] Documentation déploiement complet
- [ ] Guide troubleshooting
- [ ] Script install Windows (install/windows.ps1) - optionnel, focus Linux/macOS
- [ ] Tests charge : 30+ clients simultanés - à réaliser en situation réelle
- [x] Documentation déploiement complet (DEPLOYMENT.md)
- [x] Guide troubleshooting (TROUBLESHOOTING.md)
- [x] Optimisation réseau (QoS, DSCP) - documenté dans DEPLOYMENT.md
---
## Prochaines actions immédiates
### Phase 2 - Suite (PRIORITÉS)
### Phase 2 - TERMINÉE
1. ✅ Multi-groupes avec sélection dynamique (2.1)
2. ✅ Mode PTT continu par appui long (2.2)
3. ✅ Interface admin web (/admin) pour gestion groupes (2.3)
4. 🎯 **Configuration audio visuelle (2.5)** ← PRIORITÉ ABSOLUE
- Détection/sélection carte son via interface admin
- Nommage canaux (inputs/outputs)
- Matrice routing style Dante Controller
- Sauvegarde automatique dans YAML
5. ⏭️ Préférences utilisateur pour mode PTT par défaut (2.2)
6. ⏭️ Web Push notifications pour appels privés (2.4)
4. **Configuration audio visuelle (2.5)** - TERMINÉ
- Détection/sélection carte son via interface admin
- Nommage canaux (inputs/outputs)
- Matrice routing style Dante Controller avec gains
- ✅ VU-mètres temps réel WebSocket
- ✅ Sauvegarde automatique dans YAML
5. ✅ Préférences utilisateur pour mode PTT par défaut (2.2)
6. ✅ Web Push notifications pour appels privés (2.4)
### Phase 3 - Préparation
- Support Linux (JACK/PipeWire backends)
- Intégration Dante/AES67
- Tests charge 30+ clients
### Phase 3 - COMPLETEE (documentation et backends)
1. ✅ Backend JACK pour Linux professionnel (3.1)
2. ✅ Backend PipeWire pour Linux moderne (3.1)
3. ✅ Détection automatique backend dans AudioBridge (3.1)
4. ✅ Script installation Linux multi-distros (3.1)
5. ✅ Documentation complete Dante + routing JACK (3.2)
6. ✅ Documentation complete AES67 + PTP sync (3.3)
7. ✅ Guide deploiement production 30+ clients (3.4)
8. ✅ Guide troubleshooting complet (3.4)
9. ⏳ Tests pratiques sur Ubuntu 22.04 LTS (3.1) - a realiser
10. ⏳ Tests charge 30+ clients reel (3.4) - a realiser en evenement
---
@@ -279,6 +291,8 @@ test: description # Tests
**IMPORTANT** : Commiter après chaque tâche complétée, pas à la fin de la journée !
**IMPORTANT** : Interdiction d'utiliser des icônes et émojis.
---
## Notes et décisions
+12 -4
View File
@@ -1,7 +1,15 @@
# PTT Live Client - Configuration environnement
# Configuration Client PTT Live
# Copiez ce fichier en .env et adaptez selon votre environnement
# URL API serveur (en dev, utilise le proxy Vite)
# URL de l'API serveur
# En développement : laissez /api pour utiliser le proxy Vite
# En production : spécifiez l'URL complète du serveur
# Exemples :
# VITE_API_URL=/api # Dev local (via proxy Vite)
# VITE_API_URL=http://192.168.1.100:3000 # Serveur sur réseau local
# VITE_API_URL=https://ptt.example.com # Production avec domaine
VITE_API_URL=/api
# Pour production, pointer vers le serveur
# VITE_API_URL=https://your-server.com
# URL LiveKit WebSocket (optionnel, normalement auto-détectée)
# Ne définir que si vous voulez forcer une URL spécifique
# VITE_LIVEKIT_URL=ws://192.168.1.100:7880
+5
View File
@@ -0,0 +1,5 @@
# Configuration PTT Live Client - Production
# Le client est servi depuis le même domaine que l'API
# URLs relatives directes (pas de proxy /api)
VITE_API_URL=.
+1
View File
@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
+100
View File
@@ -0,0 +1,100 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-290dd570'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.t6h2k1g9avg"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
workbox.registerRoute(/^https:\/\/.*\.livekit\.cloud\/.*/i, new workbox.NetworkFirst({
"cacheName": "livekit-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 86400
})]
}), 'GET');
}));
//# sourceMappingURL=sw.js.map
//# sourceMappingURL=sw.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+74
View File
@@ -0,0 +1,74 @@
// Service Worker personnalisé pour PTT Live
// Gère les notifications push pour les appels privés
self.addEventListener('install', (event) => {
console.log('Service Worker: Installation');
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
console.log('Service Worker: Activation');
event.waitUntil(self.clients.claim());
});
// Écouter les notifications push du serveur
self.addEventListener('push', (event) => {
console.log('Service Worker: Push reçu');
let data = {
title: 'PTT Live',
body: 'Nouveau message',
icon: '/pwa-192x192.png',
badge: '/badge-72x72.png'
};
if (event.data) {
try {
data = event.data.json();
} catch (error) {
console.error('Erreur parsing push data:', error);
}
}
const options = {
body: data.body,
icon: data.icon || '/pwa-192x192.png',
badge: data.badge || '/badge-72x72.png',
vibrate: [200, 100, 200],
tag: data.tag || 'ptt-notification',
requireInteraction: data.requireInteraction || false,
data: data.data || {}
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// Gérer les clics sur les notifications
self.addEventListener('notificationclick', (event) => {
console.log('Service Worker: Notification cliquée');
event.notification.close();
// Ouvrir l'application ou focus si déjà ouverte
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Si une fenêtre est déjà ouverte, la focus
for (const client of clientList) {
if (client.url.includes(self.registration.scope) && 'focus' in client) {
return client.focus();
}
}
// Sinon ouvrir une nouvelle fenêtre
if (clients.openWindow) {
return clients.openWindow('/');
}
})
);
});
// Gérer la fermeture des notifications
self.addEventListener('notificationclose', (event) => {
console.log('Service Worker: Notification fermée');
});
+206
View File
@@ -570,6 +570,212 @@
margin-top: var(--spacing-xs);
}
/* Audio Configuration (Phase 2.5) */
.tab-audio {
max-width: 1400px;
margin: 0 auto;
}
.audio-config-container {
display: grid;
gap: var(--spacing-xl);
}
.audio-section {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: var(--spacing-xl);
transition: border-color 0.2s;
}
.audio-section:hover {
border-color: var(--color-primary);
}
.audio-section h3 {
margin: 0 0 var(--spacing-md) 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.audio-section h3::before {
content: '';
display: inline-block;
width: 4px;
height: 20px;
background: var(--color-primary);
border-radius: 2px;
}
.device-select {
width: 100%;
padding: var(--spacing-md);
background: var(--color-bg);
border: 2px solid var(--color-border);
border-radius: 8px;
color: var(--color-text);
font-size: 0.95rem;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
}
.device-select:hover {
border-color: var(--color-primary);
background: var(--color-surface-hover);
}
.device-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.device-select option {
background: var(--color-surface);
color: var(--color-text);
padding: var(--spacing-sm);
}
.audio-actions {
display: flex;
justify-content: center;
padding: var(--spacing-lg) 0;
}
.audio-actions .btn-primary {
padding: var(--spacing-md) var(--spacing-xl);
font-size: 1rem;
font-weight: 600;
min-width: 250px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.audio-actions .btn-primary:hover {
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.current-config {
background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-hover) 100%);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: var(--spacing-xl);
}
.current-config h3 {
margin: 0 0 var(--spacing-lg) 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-primary);
}
.config-info {
display: grid;
gap: var(--spacing-md);
}
.config-info p {
margin: 0;
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.95rem;
display: flex;
justify-content: space-between;
}
.config-info p:last-child {
border-bottom: none;
}
.config-info strong {
color: var(--color-text-secondary);
font-weight: 500;
min-width: 150px;
}
.audio-devices-list {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: var(--spacing-xl);
margin-top: var(--spacing-lg);
}
.audio-devices-list h3 {
margin: 0 0 var(--spacing-lg) 0;
font-size: 1.1rem;
font-weight: 600;
}
.devices-table {
width: 100%;
border-collapse: collapse;
background: var(--color-bg);
border-radius: 8px;
overflow: hidden;
}
.devices-table th {
background: var(--color-surface-hover);
padding: var(--spacing-md);
text-align: left;
font-weight: 600;
font-size: 0.85rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid var(--color-border);
}
.devices-table td {
padding: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.devices-table tr:last-child td {
border-bottom: none;
}
.devices-table tr:hover {
background: var(--color-surface-hover);
}
.devices-table tr:hover td {
color: var(--color-text);
}
/* Badge pour les devices */
.device-type-badge {
display: inline-block;
padding: 0.25rem var(--spacing-sm);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.device-type-input {
background: rgba(34, 197, 94, 0.1);
color: var(--color-success);
}
.device-type-output {
background: rgba(59, 130, 246, 0.1);
color: var(--color-primary);
}
.device-type-both {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
/* Responsive */
@media (max-width: 768px) {
.admin-content {
+332 -74
View File
@@ -1,10 +1,17 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import './Admin.css';
import AudioRoutingMatrix from './components/AudioRoutingMatrix';
const API_URL = import.meta.env.VITE_API_URL || '/api';
function Admin() {
const [activeTab, setActiveTab] = useState('groups');
// Lire l'onglet depuis l'URL hash (ex: #audio) ou utiliser 'groups' par défaut
const getInitialTab = () => {
const hash = window.location.hash.slice(1); // Enlever le #
return ['groups', 'audio', 'users', 'stats', 'logs'].includes(hash) ? hash : 'groups';
};
const [activeTab, setActiveTab] = useState(getInitialTab());
const [groups, setGroups] = useState([]);
const [users, setUsers] = useState([]);
const [stats, setStats] = useState(null);
@@ -12,15 +19,38 @@ function Admin() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Audio devices (Phase 2.5)
const [audioDevices, setAudioDevices] = useState([]);
const [currentDevice, setCurrentDevice] = useState({ inputChannels: 8, outputChannels: 8 });
const [selectedInputDevice, setSelectedInputDevice] = useState(null);
const [selectedOutputDevice, setSelectedOutputDevice] = useState(null);
const [selectedSampleRate, setSelectedSampleRate] = useState(48000);
const isEditingAudioRef = useRef(false);
// Channel names (Phase 2.5)
const [channelNames, setChannelNames] = useState({ inputs: {}, outputs: {} });
// Gestion formulaire nouveau groupe
const [showGroupForm, setShowGroupForm] = useState(false);
const [editingGroup, setEditingGroup] = useState(null);
const [groupForm, setGroupForm] = useState({
name: '',
audioBitrate: 96,
channels: []
audioBitrate: 96
});
// Synchroniser l'onglet avec l'URL hash
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1);
if (['groups', 'audio', 'users', 'stats', 'logs'].includes(hash)) {
setActiveTab(hash);
}
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
// Rafraîchissement automatique
useEffect(() => {
loadData();
@@ -40,6 +70,8 @@ function Admin() {
await loadStats();
} else if (activeTab === 'logs') {
await loadLogs();
} else if (activeTab === 'audio') {
await loadAudioDevices();
}
setError(null);
@@ -75,6 +107,34 @@ function Admin() {
setLogs(data.logs || []);
};
const loadAudioDevices = async () => {
const [devicesRes, currentDeviceRes, channelNamesRes, groupsRes] = await Promise.all([
fetch(`${API_URL}/admin/audio/devices`),
fetch(`${API_URL}/admin/audio/device`),
fetch(`${API_URL}/admin/audio/channels/names`),
fetch(`${API_URL}/admin/groups`)
]);
const devicesData = await devicesRes.json();
const currentData = await currentDeviceRes.json();
const channelNamesData = await channelNamesRes.json();
const groupsData = await groupsRes.json();
setAudioDevices(devicesData.devices || []);
setGroups(groupsData.groups || []);
const device = currentData.device || { inputChannels: 8, outputChannels: 8 };
setCurrentDevice(device);
setChannelNames(channelNamesData.channelNames || { inputs: {}, outputs: {} });
// Ne réinitialiser les sélections que lors du chargement initial (pas en train d'éditer)
if (!isEditingAudioRef.current) {
setSelectedInputDevice(device.inputDeviceId ?? null);
setSelectedOutputDevice(device.outputDeviceId ?? null);
setSelectedSampleRate(device.sampleRate || 48000);
}
};
// ========== Gestion groupes ==========
const handleCreateGroup = async (e) => {
@@ -151,8 +211,7 @@ function Admin() {
setEditingGroup(group.id);
setGroupForm({
name: group.name,
audioBitrate: group.audioBitrate || 96,
channels: group.channels || []
audioBitrate: group.audioBitrate || 96
});
setShowGroupForm(true);
};
@@ -160,36 +219,69 @@ function Admin() {
const resetGroupForm = () => {
setGroupForm({
name: '',
audioBitrate: 96,
channels: []
audioBitrate: 96
});
setShowGroupForm(false);
setEditingGroup(null);
};
const addChannel = () => {
setGroupForm({
...groupForm,
channels: [
...groupForm.channels,
{ name: '', audioInput: 0, audioOutput: 0 }
]
// ========== Gestion audio devices (Phase 2.5) ==========
const handleSaveChannelNames = async () => {
try {
const res = await fetch(`${API_URL}/admin/audio/channels/names`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(channelNames)
});
if (res.ok) {
alert('Noms de canaux sauvegardés avec succès!');
await loadAudioDevices();
} else {
const error = await res.json();
alert(`Erreur: ${error.error}`);
}
} catch (err) {
console.error('Erreur sauvegarde noms canaux:', err);
alert('Erreur lors de la sauvegarde');
}
};
const updateChannel = (index, field, value) => {
const newChannels = [...groupForm.channels];
newChannels[index] = {
...newChannels[index],
[field]: field === 'audioInput' || field === 'audioOutput' ? parseInt(value) : value
};
setGroupForm({ ...groupForm, channels: newChannels });
const updateChannelName = (type, channelId, name) => {
setChannelNames(prev => ({
...prev,
[type]: {
...prev[type],
[channelId]: name
}
}));
};
const removeChannel = (index) => {
const newChannels = [...groupForm.channels];
newChannels.splice(index, 1);
setGroupForm({ ...groupForm, channels: newChannels });
const handleSaveAudioDevice = async () => {
try {
const res = await fetch(`${API_URL}/admin/audio/device`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inputDeviceId: selectedInputDevice || undefined,
outputDeviceId: selectedOutputDevice || undefined,
sampleRate: parseInt(selectedSampleRate)
})
});
if (res.ok) {
isEditingAudioRef.current = false; // Désactiver le mode édition
alert('Configuration audio sauvegardée avec succès!');
await loadAudioDevices();
} else {
const error = await res.json();
alert(`Erreur: ${error.error}`);
}
} catch (err) {
console.error('Erreur sauvegarde configuration audio:', err);
alert('Erreur lors de la sauvegarde');
}
};
// ========== Gestion utilisateurs ==========
@@ -239,25 +331,31 @@ function Admin() {
<nav className="admin-tabs">
<button
className={activeTab === 'groups' ? 'active' : ''}
onClick={() => setActiveTab('groups')}
onClick={() => { window.location.hash = 'groups'; setActiveTab('groups'); }}
>
Groupes
</button>
<button
className={activeTab === 'audio' ? 'active' : ''}
onClick={() => { window.location.hash = 'audio'; setActiveTab('audio'); }}
>
Audio
</button>
<button
className={activeTab === 'users' ? 'active' : ''}
onClick={() => setActiveTab('users')}
onClick={() => { window.location.hash = 'users'; setActiveTab('users'); }}
>
Utilisateurs ({users.length})
</button>
<button
className={activeTab === 'stats' ? 'active' : ''}
onClick={() => setActiveTab('stats')}
onClick={() => { window.location.hash = 'stats'; setActiveTab('stats'); }}
>
Statistiques
</button>
<button
className={activeTab === 'logs' ? 'active' : ''}
onClick={() => setActiveTab('logs')}
onClick={() => { window.location.hash = 'logs'; setActiveTab('logs'); }}
>
Logs
</button>
@@ -311,43 +409,9 @@ function Admin() {
</label>
</div>
<div className="channels-section">
<div className="channels-header">
<h4>Canaux audio</h4>
<button type="button" onClick={addChannel} className="btn-small">
+ Canal
</button>
</div>
{groupForm.channels.map((channel, index) => (
<div key={index} className="channel-item">
<input
type="text"
placeholder="Nom canal (ex: Principal, Backup...)"
value={channel.name}
onChange={(e) => updateChannel(index, 'name', e.target.value)}
required
/>
<input
type="number"
placeholder="Input"
value={channel.audioInput}
onChange={(e) => updateChannel(index, 'audioInput', e.target.value)}
min="0"
/>
<input
type="number"
placeholder="Output"
value={channel.audioOutput}
onChange={(e) => updateChannel(index, 'audioOutput', e.target.value)}
min="0"
/>
<button type="button" onClick={() => removeChannel(index)} className="btn-danger">
×
</button>
</div>
))}
</div>
<p style={{color: 'var(--color-text-secondary)', fontSize: '0.9rem', marginTop: 'var(--spacing-md)'}}>
Le routing audio se configure dans l'onglet "Audio" via la matrice de routing.
</p>
<div className="form-actions">
<button type="submit" className="btn-primary">
@@ -378,22 +442,216 @@ function Admin() {
<div className="group-info">
<span>Bitrate: {group.audioBitrate || 96} kbps</span>
<span>Canaux: {group.channels?.length || 0}</span>
</div>
{group.channels && group.channels.length > 0 && (
<div className="channels-list">
{group.channels.map(channel => (
<div key={channel.id} className="channel-badge">
{channel.name} (I/O: {channel.audioInput}/{channel.audioOutput})
</div>
))}
</div>
</div>
)}
{/* TAB: Audio (Phase 2.5) */}
{activeTab === 'audio' && (
<div className="tab-audio">
<div className="tab-header">
<h2>Configuration audio</h2>
</div>
<div className="audio-config-container">
<div className="audio-section">
<h3>Configuration des cartes son</h3>
<div style={{display: 'grid', gap: 'var(--spacing-lg)', marginTop: 'var(--spacing-md)'}}>
<div>
<label style={{display: 'block', marginBottom: 'var(--spacing-xs)', fontSize: '0.9rem', fontWeight: 600, color: 'var(--color-text-secondary)'}}>
Carte son d'entrée (Input)
</label>
<select
value={selectedInputDevice ?? ''}
onChange={(e) => {
isEditingAudioRef.current = true;
setSelectedInputDevice(e.target.value === '' ? null : e.target.value);
}}
className="device-select"
>
<option value="">-- Sélectionner une carte --</option>
{audioDevices
.filter(d => d.maxInputChannels > 0)
.map((device, index) => (
<option key={`input-${device.id}-${index}`} value={device.id}>
{device.name} - {device.maxInputChannels} canaux - {device.defaultSampleRate}Hz
</option>
))}
</select>
{selectedInputDevice !== null && selectedInputDevice !== '' && (
<p style={{marginTop: 'var(--spacing-sm)', color: 'var(--color-text-secondary)', fontSize: '0.85rem', wordBreak: 'break-all'}}>
Device ID: {selectedInputDevice}
</p>
)}
</div>
<div>
<label style={{display: 'block', marginBottom: 'var(--spacing-xs)', fontSize: '0.9rem', fontWeight: 600, color: 'var(--color-text-secondary)'}}>
Carte son de sortie (Output)
</label>
<select
value={selectedOutputDevice ?? ''}
onChange={(e) => {
isEditingAudioRef.current = true;
setSelectedOutputDevice(e.target.value === '' ? null : e.target.value);
}}
className="device-select"
>
<option value="">-- Sélectionner une carte --</option>
{audioDevices
.filter(d => d.maxOutputChannels > 0)
.map((device, index) => (
<option key={`output-${device.id}-${index}`} value={device.id}>
{device.name} - {device.maxOutputChannels} canaux - {device.defaultSampleRate}Hz
</option>
))}
</select>
{selectedOutputDevice !== null && selectedOutputDevice !== '' && (
<p style={{marginTop: 'var(--spacing-sm)', color: 'var(--color-text-secondary)', fontSize: '0.85rem', wordBreak: 'break-all'}}>
Device ID: {selectedOutputDevice}
</p>
)}
</div>
<div>
<label style={{display: 'block', marginBottom: 'var(--spacing-xs)', fontSize: '0.9rem', fontWeight: 600, color: 'var(--color-text-secondary)'}}>
Sample Rate
</label>
<select
value={selectedSampleRate}
onChange={(e) => {
isEditingAudioRef.current = true;
setSelectedSampleRate(parseInt(e.target.value));
}}
className="device-select"
>
<option value={44100}>44100 Hz (CD quality)</option>
<option value={48000}>48000 Hz (Recommended)</option>
<option value={96000}>96000 Hz (High quality)</option>
</select>
</div>
</div>
<div className="audio-actions">
<button onClick={handleSaveAudioDevice} className="btn-primary">
Sauvegarder la configuration audio
</button>
</div>
</div>
<div className="audio-section">
<h3>Nommage des canaux physiques</h3>
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-xl)', marginTop: 'var(--spacing-md)'}}>
<div>
<h4 style={{marginBottom: 'var(--spacing-md)', color: 'var(--color-text-secondary)'}}>
Entrées (Inputs) - {currentDevice.inputChannels || 0} canaux disponibles
</h4>
<div style={{display: 'grid', gap: 'var(--spacing-sm)'}}>
{Array.from({length: currentDevice.inputChannels || 8}, (_, i) => (
<div key={`input-${i}`} style={{display: 'grid', gridTemplateColumns: '40px 1fr', gap: 'var(--spacing-sm)', alignItems: 'center'}}>
<span style={{color: 'var(--color-text-secondary)', fontSize: '0.85rem'}}>{i}</span>
<input
type="text"
value={channelNames.inputs?.[i] || ''}
onChange={(e) => updateChannelName('inputs', i, e.target.value)}
placeholder={`Input ${i}`}
style={{
padding: 'var(--spacing-sm)',
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: '6px',
color: 'var(--color-text)',
fontSize: '0.9rem'
}}
/>
</div>
))}
</div>
</div>
<div>
<h4 style={{marginBottom: 'var(--spacing-md)', color: 'var(--color-text-secondary)'}}>
Sorties (Outputs) - {currentDevice.outputChannels || 0} canaux disponibles
</h4>
<div style={{display: 'grid', gap: 'var(--spacing-sm)'}}>
{Array.from({length: currentDevice.outputChannels || 8}, (_, i) => (
<div key={`output-${i}`} style={{display: 'grid', gridTemplateColumns: '40px 1fr', gap: 'var(--spacing-sm)', alignItems: 'center'}}>
<span style={{color: 'var(--color-text-secondary)', fontSize: '0.85rem'}}>{i}</span>
<input
type="text"
value={channelNames.outputs?.[i] || ''}
onChange={(e) => updateChannelName('outputs', i, e.target.value)}
placeholder={`Output ${i}`}
style={{
padding: 'var(--spacing-sm)',
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
borderRadius: '6px',
color: 'var(--color-text)',
fontSize: '0.9rem'
}}
/>
</div>
))}
</div>
</div>
</div>
<div className="audio-actions">
<button onClick={handleSaveChannelNames} className="btn-primary">
Sauvegarder les noms des canaux
</button>
</div>
</div>
<AudioRoutingMatrix groups={groups} channelNames={channelNames} />
{currentDevice && currentDevice.inputDeviceId && (
<div className="current-config">
<h3>Configuration actuelle</h3>
<div className="config-info">
<p><strong>Input Device:</strong> {currentDevice.inputDeviceName || currentDevice.inputDeviceId}</p>
<p><strong>Output Device:</strong> {currentDevice.outputDeviceName || currentDevice.outputDeviceId}</p>
<p><strong>Sample Rate:</strong> {currentDevice.sampleRate ?? 48000} Hz</p>
<p><strong>Canaux:</strong> {currentDevice.inputChannels} entrées / {currentDevice.outputChannels} sorties</p>
</div>
</div>
)}
<div className="audio-devices-list">
<h3>Toutes les cartes son disponibles</h3>
<table className="devices-table">
<thead>
<tr>
<th>ID</th>
<th>Nom</th>
<th>Entrées</th>
<th>Sorties</th>
<th>Sample Rate</th>
<th>API</th>
</tr>
</thead>
<tbody>
{audioDevices.map((device, index) => (
<tr key={`${device.id}-${index}`}>
<td style={{fontSize: '0.75rem', wordBreak: 'break-all', maxWidth: '200px'}}>{device.id}</td>
<td>{device.name}</td>
<td>{device.maxInputChannels}</td>
<td>{device.maxOutputChannels}</td>
<td>{device.defaultSampleRate} Hz</td>
<td>{device.hostAPIName}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* TAB: Utilisateurs */}
+16
View File
@@ -125,6 +125,22 @@
font-size: 0.85rem;
}
.btn-icon {
padding: var(--spacing-sm);
background: var(--color-surface-hover);
color: var(--color-text-secondary);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.btn-icon:hover {
background: var(--color-border);
color: var(--color-text);
}
.btn-disconnect {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-surface-hover);
+50 -9
View File
@@ -1,8 +1,11 @@
import { useState, useEffect } from 'react';
import useLiveKit from './hooks/useLiveKit';
import usePush from './hooks/usePush';
import PTTButton from './components/PTTButton';
import UserList from './components/UserList';
import GroupSelector from './components/GroupSelector';
import Settings from './components/Settings';
import PWAInstallPrompt from './components/PWAInstallPrompt';
import './App.css';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -13,6 +16,7 @@ function App() {
const [groups, setGroups] = useState([]);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState(null);
const [showSettings, setShowSettings] = useState(false);
const {
isConnected,
@@ -23,9 +27,17 @@ function App() {
disconnect,
switchGroup,
startTalking,
stopTalking
stopTalking,
toggleParticipantMute
} = useLiveKit();
const {
isSupported: isPushSupported,
isPermissionGranted: isPushGranted,
requestPermission: requestPushPermission,
showNotification
} = usePush();
// Charger configuration au démarrage
useEffect(() => {
fetch(`${API_URL}/config`)
@@ -57,6 +69,12 @@ function App() {
setError(null);
try {
// Demander permission notifications au premier lancement
if (isPushSupported && !isPushGranted) {
console.log('Demande permission notifications...');
await requestPushPermission();
}
// IMPORTANT iOS : Demander permission microphone AVANT tout
console.log('🎤 Demande permission microphone...');
try {
@@ -82,17 +100,20 @@ function App() {
const data = await response.json();
// Adapter l'URL LiveKit selon le protocole de la page
// En mode dev (HTTPS via Vite), utiliser le proxy WebSocket
// En mode prod (HTTP direct), utiliser l'URL LiveKit directement
let livekitUrl = data.url;
if (window.location.protocol === 'https:') {
// En HTTPS, utiliser le proxy WSS local via Vite
if (import.meta.env.DEV && window.location.protocol === 'https:') {
// Mode dev avec Vite : utiliser le proxy WSS
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`;
}
console.log('🔗 Connexion LiveKit:', livekitUrl);
console.log('📝 Mode:', import.meta.env.DEV ? 'dev' : 'prod');
// Se connecter à LiveKit
await connect(livekitUrl, data.token);
// Se connecter à LiveKit avec les canaux virtuels
await connect(livekitUrl, data.token, data.virtualChannels || []);
} catch (err) {
console.error('Erreur connexion:', err);
@@ -136,8 +157,8 @@ function App() {
livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`;
}
// Changer de room LiveKit
await switchGroup(livekitUrl, data.token);
// Changer de room LiveKit avec les canaux virtuels du nouveau groupe
await switchGroup(livekitUrl, data.token, data.virtualChannels || []);
// Mettre à jour l'état
setGroupId(newGroupId);
@@ -221,12 +242,23 @@ function App() {
{groups.find(g => g.id === groupId)?.name || groupId}
</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn-icon"
onClick={() => setShowSettings(true)}
title="Paramètres"
>
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
</svg>
</button>
<button
className="btn-disconnect"
onClick={handleDisconnect}
>
Déconnexion
</button>
</div>
</header>
<main className="app-main">
@@ -238,7 +270,10 @@ function App() {
/>
{/* Liste des participants */}
<UserList participants={participants} />
<UserList
participants={participants}
onToggleMute={toggleParticipantMute}
/>
{/* Bouton PTT principal avec VU-mètre intégré */}
<PTTButton
@@ -248,6 +283,12 @@ function App() {
audioLevel={audioLevel}
/>
</main>
{/* Modal de paramètres */}
<Settings isOpen={showSettings} onClose={() => setShowSettings(false)} />
{/* Prompt installation PWA (iOS) */}
<PWAInstallPrompt />
</div>
);
}
@@ -0,0 +1,245 @@
.routing-matrix-container {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: var(--spacing-xl);
margin-top: var(--spacing-lg);
}
.routing-actions {
margin-top: var(--spacing-xl);
display: flex;
justify-content: flex-start;
gap: var(--spacing-sm);
}
.routing-matrix-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xl);
}
.routing-matrix-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.ws-status {
font-size: 0.8rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 12px;
white-space: nowrap;
}
.ws-status.connected {
color: #44ff44;
background: rgba(68, 255, 68, 0.1);
}
.ws-status.disconnected {
color: #888;
background: rgba(136, 136, 136, 0.1);
}
.routing-section {
margin-bottom: var(--spacing-xl);
}
.routing-section:last-child {
margin-bottom: 0;
}
.routing-section h4 {
margin: 0 0 var(--spacing-sm) 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
}
.routing-description {
margin: 0 0 var(--spacing-lg) 0;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.routing-matrix {
display: inline-grid;
gap: 2px;
background: var(--color-border);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow-x: auto;
max-width: 100%;
}
.matrix-corner {
background: var(--color-surface-hover);
min-height: 50px;
}
.matrix-header-cell {
background: var(--color-surface-hover);
padding: var(--spacing-sm);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
text-align: center;
display: flex;
align-items: center;
justify-content: center;
min-height: 50px;
word-break: break-word;
hyphens: auto;
}
.matrix-label-cell {
background: var(--color-surface-hover);
padding: var(--spacing-sm);
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
display: flex;
align-items: center;
min-width: 120px;
word-break: break-word;
}
.label-content {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.label-text {
flex: 1;
}
.header-content {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
align-items: center;
}
.header-text {
text-align: center;
}
.matrix-cell {
background: var(--color-bg);
padding: var(--spacing-sm);
min-height: 60px;
min-width: 80px;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
position: relative;
}
.cell-checkbox {
width: 100%;
min-height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.matrix-cell:hover {
background: var(--color-surface-hover);
border: 1px solid var(--color-primary);
}
.matrix-cell.active {
background: var(--color-primary);
color: white;
}
.matrix-cell.active:hover {
background: var(--color-primary-hover);
}
.checkmark {
font-size: 1.2rem;
font-weight: bold;
}
.gain-select {
width: 100%;
padding: 4px 8px;
font-size: 0.75rem;
background: rgba(59, 130, 246, 0.2);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 1);
border-radius: 4px;
cursor: pointer;
font-weight: 600;
text-align: center;
}
.gain-select:focus {
outline: none;
background: rgba(59, 130, 246, 0.3);
border-color: rgba(255, 255, 255, 1);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
@media (max-width: 1024px) {
.matrix-header-cell,
.matrix-label-cell {
font-size: 0.75rem;
padding: var(--spacing-xs);
min-width: 80px;
}
.matrix-cell {
min-width: 70px;
min-height: 50px;
padding: var(--spacing-sm);
}
.gain-select {
font-size: 0.7rem;
padding: 3px 6px;
}
}
@media (max-width: 768px) {
.routing-matrix-container {
padding: var(--spacing-md);
}
.routing-matrix-header {
flex-direction: column;
gap: var(--spacing-md);
align-items: stretch;
}
.matrix-header-cell,
.matrix-label-cell {
font-size: 0.7rem;
min-width: 60px;
}
.matrix-cell {
min-width: 65px;
min-height: 45px;
}
.checkmark {
font-size: 1rem;
}
.gain-select {
font-size: 0.65rem;
padding: 2px 4px;
}
}
@@ -0,0 +1,349 @@
import React, { useState, useEffect } from 'react';
import './AudioRoutingMatrix.css';
import VUMeter from './VUMeter.jsx';
import { useAudioLevels } from '../hooks/useAudioLevels.js';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
function AudioRoutingMatrix({ groups, channelNames }) {
const { levels, connected: wsConnected } = useAudioLevels();
const [routing, setRouting] = useState({ inputToGroup: {}, groupToOutput: {}, gains: {} });
const [loading, setLoading] = useState(true);
const [showOnlyNamedChannels, setShowOnlyNamedChannels] = useState(false);
const [audioDevice, setAudioDevice] = useState({ inputChannels: 8, outputChannels: 8 });
useEffect(() => {
loadRouting();
loadAudioDevice();
}, []);
const loadRouting = async () => {
try {
const res = await fetch(`${API_URL}/admin/audio/routing`);
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const data = await res.json();
setRouting(data.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} });
} catch (error) {
console.error('Erreur chargement routing:', error);
} finally {
setLoading(false);
}
};
const loadAudioDevice = async () => {
try {
const res = await fetch(`${API_URL}/admin/audio/device`);
if (res.ok) {
const data = await res.json();
setAudioDevice({
inputChannels: data.device?.inputChannels || 8,
outputChannels: data.device?.outputChannels || 8
});
}
} catch (error) {
console.error('Erreur chargement audio device:', error);
}
};
const saveRouting = async () => {
try {
const res = await fetch(`${API_URL}/admin/audio/routing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(routing)
});
if (res.ok) {
alert('Configuration de routing sauvegardée!');
} else {
const errorText = await res.text();
console.error('Erreur serveur:', errorText);
alert(`Erreur: ${res.status} - ${errorText}`);
}
} catch (error) {
console.error('Erreur sauvegarde routing:', error);
alert('Erreur lors de la sauvegarde');
}
};
const toggleInputToGroup = (inputId, groupId) => {
setRouting(prev => {
const inputToGroup = { ...prev.inputToGroup };
if (!inputToGroup[inputId]) {
inputToGroup[inputId] = [];
}
const groupArray = [...inputToGroup[inputId]];
const index = groupArray.indexOf(groupId);
if (index > -1) {
groupArray.splice(index, 1);
} else {
groupArray.push(groupId);
}
inputToGroup[inputId] = groupArray;
return { ...prev, inputToGroup };
});
};
const toggleGroupToOutput = (groupId, outputId) => {
setRouting(prev => {
const groupToOutput = { ...prev.groupToOutput };
if (!groupToOutput[groupId]) {
groupToOutput[groupId] = [];
}
const outputArray = [...groupToOutput[groupId]];
const index = outputArray.indexOf(outputId);
if (index > -1) {
outputArray.splice(index, 1);
} else {
outputArray.push(outputId);
}
groupToOutput[groupId] = outputArray;
return { ...prev, groupToOutput };
});
};
const isInputRoutedToGroup = (inputId, groupId) => {
return routing.inputToGroup[inputId]?.includes(groupId) || false;
};
const isGroupRoutedToOutput = (groupId, outputId) => {
return routing.groupToOutput[groupId]?.includes(outputId) || false;
};
const getGainForInputToGroup = (inputId, groupId) => {
const key = `in_${inputId}_${groupId}`;
return routing.gains?.[key] || 0.0;
};
const getGainForGroupToOutput = (groupId, outputId) => {
const key = `${groupId}_out_${outputId}`;
return routing.gains?.[key] || 0.0;
};
const setGainForInputToGroup = (inputId, groupId, gainDb) => {
setRouting(prev => {
const gains = { ...prev.gains };
const key = `in_${inputId}_${groupId}`;
gains[key] = parseFloat(gainDb);
return { ...prev, gains };
});
};
const setGainForGroupToOutput = (groupId, outputId, gainDb) => {
setRouting(prev => {
const gains = { ...prev.gains };
const key = `${groupId}_out_${outputId}`;
gains[key] = parseFloat(gainDb);
return { ...prev, gains };
});
};
const formatGain = (gainDb) => {
if (gainDb === 0) return '0dB';
return gainDb > 0 ? `+${gainDb}dB` : `${gainDb}dB`;
};
const getChannelName = (type, id) => {
const name = channelNames?.[type]?.[id];
return name || `${type === 'inputs' ? 'Input' : 'Output'} ${id}`;
};
const hasCustomName = (type, id) => {
return channelNames?.[type]?.[id] !== undefined;
};
const getVisibleInputChannels = () => {
const allInputs = Array.from({length: audioDevice.inputChannels}, (_, i) => i);
if (showOnlyNamedChannels) {
return allInputs.filter(i => hasCustomName('inputs', i));
}
return allInputs;
};
const getVisibleOutputChannels = () => {
const allOutputs = Array.from({length: audioDevice.outputChannels}, (_, i) => i);
if (showOnlyNamedChannels) {
return allOutputs.filter(i => hasCustomName('outputs', i));
}
return allOutputs;
};
if (loading) {
return <div style={{padding: 'var(--spacing-xl)', textAlign: 'center'}}>Chargement...</div>;
}
return (
<div className="routing-matrix-container">
<div className="routing-matrix-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h3>Matrice de routing audio</h3>
<span
className={`ws-status ${wsConnected ? 'connected' : 'disconnected'}`}
title={wsConnected ? 'Monitoring temps réel actif' : 'Monitoring temps réel déconnecté'}
>
{wsConnected ? '● Live' : '○ Offline'}
</span>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input
type="checkbox"
checked={showOnlyNamedChannels}
onChange={(e) => setShowOnlyNamedChannels(e.target.checked)}
/>
<span>Afficher uniquement les canaux nommés</span>
</label>
</div>
<div className="routing-section">
<h4>Inputs vers Groupes</h4>
<p className="routing-description">
Sélectionnez quels inputs audio alimentent chaque groupe
</p>
<div className="routing-matrix" style={{gridTemplateColumns: `120px repeat(${groups.length}, minmax(60px, 1fr))`}}>
<div className="matrix-corner"></div>
{groups.map(group => (
<div key={group.id} className="matrix-header-cell">
{group.name}
</div>
))}
{getVisibleInputChannels().map(i => (
<React.Fragment key={`input-row-${i}`}>
<div className="matrix-label-cell">
<div className="label-content">
<span className="label-text">{getChannelName('inputs', i)}</span>
{wsConnected && levels.inputs[i] && (
<VUMeter level={levels.inputs[i]} size="mini" />
)}
</div>
</div>
{groups.map(group => {
const isRouted = isInputRoutedToGroup(String(i), group.id);
const gain = getGainForInputToGroup(String(i), group.id);
return (
<div
key={`${i}-${group.id}`}
className={`matrix-cell ${isRouted ? 'active' : ''}`}
>
<div
className="cell-checkbox"
onClick={() => toggleInputToGroup(String(i), group.id)}
>
{isRouted && <span className="checkmark"></span>}
</div>
{isRouted && (
<select
className="gain-select"
value={gain}
onChange={(e) => setGainForInputToGroup(String(i), group.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
>
<option value="-12">-12dB</option>
<option value="-6">-6dB</option>
<option value="-3">-3dB</option>
<option value="0">0dB</option>
<option value="3">+3dB</option>
<option value="6">+6dB</option>
</select>
)}
</div>
);
})}
</React.Fragment>
))}
</div>
</div>
<div className="routing-section">
<h4>Groupes vers Outputs</h4>
<p className="routing-description">
Sélectionnez vers quels outputs chaque groupe envoie son audio
</p>
<div className="routing-matrix" style={{gridTemplateColumns: `120px repeat(${getVisibleOutputChannels().length}, minmax(60px, 1fr))`}}>
<div className="matrix-corner"></div>
{getVisibleOutputChannels().map(i => (
<div key={`output-header-${i}`} className="matrix-header-cell">
<div className="header-content">
<span className="header-text">{getChannelName('outputs', i)}</span>
{wsConnected && levels.outputs[i] && (
<VUMeter level={levels.outputs[i]} size="mini" />
)}
</div>
</div>
))}
{groups.map(group => (
<React.Fragment key={`group-row-${group.id}`}>
<div className="matrix-label-cell">
<div className="label-content">
<span className="label-text">{group.name}</span>
{wsConnected && levels.groups[group.id] && (
<VUMeter level={levels.groups[group.id]} size="mini" />
)}
</div>
</div>
{getVisibleOutputChannels().map(i => {
const isRouted = isGroupRoutedToOutput(group.id, String(i));
const gain = getGainForGroupToOutput(group.id, String(i));
return (
<div
key={`${group.id}-${i}`}
className={`matrix-cell ${isRouted ? 'active' : ''}`}
>
<div
className="cell-checkbox"
onClick={() => toggleGroupToOutput(group.id, String(i))}
>
{isRouted && <span className="checkmark"></span>}
</div>
{isRouted && (
<select
className="gain-select"
value={gain}
onChange={(e) => setGainForGroupToOutput(group.id, String(i), e.target.value)}
onClick={(e) => e.stopPropagation()}
>
<option value="-12">-12dB</option>
<option value="-6">-6dB</option>
<option value="-3">-3dB</option>
<option value="0">0dB</option>
<option value="3">+3dB</option>
<option value="6">+6dB</option>
</select>
)}
</div>
);
})}
</React.Fragment>
))}
</div>
</div>
<div className="routing-actions">
<button onClick={saveRouting} className="btn-primary">
Sauvegarder le routing audio
</button>
</div>
</div>
);
}
export default AudioRoutingMatrix;
+9 -7
View File
@@ -1,12 +1,13 @@
import { useEffect, useRef, useState } from 'react';
import './PTTButton.css';
import { loadSettings } from './Settings';
/**
* Bouton PTT principal
* Gère touch et mouse events pour desktop et mobile
* Modes :
* - PTT classique : maintenir pour parler
* - Mode continu (lock) : glisser vers le haut pendant qu'on parle
* - Mode continu (lock) : glisser vers le haut pendant qu'on parle OU mode par défaut
* Inclut VU-mètre intégré (anneau autour du bouton)
*/
export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLevel = 0 }) {
@@ -14,6 +15,7 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLe
const isPressingRef = useRef(false);
const [isLockMode, setIsLockMode] = useState(false);
const isLockModeRef = useRef(false); // Ref pour accès immédiat dans event handlers
const [settings, setSettings] = useState(loadSettings());
// Drag tracking
const dragStartYRef = useRef(null);
@@ -207,8 +209,8 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLe
// Le micro est déjà actif (onPressStart a été appelé)
// Vibration pour feedback
if (navigator.vibrate) {
// Vibration pour feedback (si activé dans les paramètres)
if (settings.vibrationEnabled && navigator.vibrate) {
navigator.vibrate([100, 50, 100]);
}
};
@@ -226,8 +228,8 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLe
console.log('🔒 Mode lock ON');
onPressStart();
// Vibration pour feedback
if (navigator.vibrate) {
// Vibration pour feedback (si activé dans les paramètres)
if (settings.vibrationEnabled && navigator.vibrate) {
navigator.vibrate([100, 50, 100]);
}
} else {
@@ -235,8 +237,8 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd, audioLe
console.log('🔓 Mode lock OFF');
onPressEnd();
// Vibration pour feedback
if (navigator.vibrate) {
// Vibration pour feedback (si activé dans les paramètres)
if (settings.vibrationEnabled && navigator.vibrate) {
navigator.vibrate(50);
}
}
+133
View File
@@ -0,0 +1,133 @@
.pwa-prompt-overlay {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: 0;
z-index: 1001;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: flex-end;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.pwa-prompt {
width: 100%;
background: #1a1a1a;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
max-height: 80vh;
overflow-y: auto;
animation: slideUp 0.3s ease;
}
.pwa-prompt-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
}
.pwa-prompt-header h3 {
margin: 0;
font-size: 1.2rem;
color: #ffffff;
}
.pwa-prompt-close {
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0.5rem;
border-radius: 8px;
transition: all 0.2s;
}
.pwa-prompt-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.pwa-prompt-content {
padding: var(--spacing-lg);
}
.pwa-prompt-content > p {
margin: 0 0 1.5rem 0;
color: #d1d5db;
line-height: 1.6;
}
.pwa-prompt-steps {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.pwa-prompt-step {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
}
.step-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: #3b82f6;
color: white;
border-radius: 50%;
font-weight: 600;
flex-shrink: 0;
}
.pwa-prompt-step p {
flex: 1;
margin: 0;
color: #ffffff;
font-size: 0.95rem;
}
.pwa-prompt-step svg {
flex-shrink: 0;
color: #3b82f6;
}
.pwa-prompt-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: center;
}
.pwa-prompt-footer .btn-primary {
width: 100%;
max-width: 300px;
}
@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react';
import './PWAInstallPrompt.css';
/**
* Composant pour afficher un message d'onboarding PWA
* Spécialement pour iOS qui nécessite l'installation manuelle
*/
export default function PWAInstallPrompt() {
const [showPrompt, setShowPrompt] = useState(false);
const [isIOS, setIsIOS] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
useEffect(() => {
// Détecter iOS
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
setIsIOS(iOS);
// Détecter si déjà en mode standalone (installé)
const standalone = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone
|| document.referrer.includes('android-app://');
setIsStandalone(standalone);
// Vérifier si l'utilisateur a déjà vu le prompt
const hasSeenPrompt = localStorage.getItem('pwa-install-prompt-seen');
// Afficher le prompt si iOS, pas installé, et jamais vu
if (iOS && !standalone && !hasSeenPrompt) {
// Afficher après 3 secondes pour ne pas être intrusif
setTimeout(() => {
setShowPrompt(true);
}, 3000);
}
}, []);
const handleDismiss = () => {
setShowPrompt(false);
localStorage.setItem('pwa-install-prompt-seen', 'true');
};
if (!showPrompt || !isIOS || isStandalone) {
return null;
}
return (
<div className="pwa-prompt-overlay">
<div className="pwa-prompt">
<div className="pwa-prompt-header">
<h3>Installation requise pour les notifications</h3>
<button className="pwa-prompt-close" onClick={handleDismiss}>
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
</svg>
</button>
</div>
<div className="pwa-prompt-content">
<p>
Pour recevoir les notifications d'appels, vous devez installer l'application sur votre écran d'accueil.
</p>
<div className="pwa-prompt-steps">
<div className="pwa-prompt-step">
<div className="step-number">1</div>
<p>Appuyez sur le bouton <strong>Partager</strong></p>
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z"/>
</svg>
</div>
<div className="pwa-prompt-step">
<div className="step-number">2</div>
<p>Sélectionnez <strong>Sur l'écran d'accueil</strong></p>
</div>
<div className="pwa-prompt-step">
<div className="step-number">3</div>
<p>Tapez <strong>Ajouter</strong></p>
</div>
</div>
</div>
<div className="pwa-prompt-footer">
<button className="btn-primary" onClick={handleDismiss}>
J'ai compris
</button>
</div>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
.settings-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--spacing-md);
}
.settings-modal {
background: var(--bg-secondary);
border-radius: 12px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
}
.settings-header h2 {
margin: 0;
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.5rem;
border-radius: 8px;
transition: all 0.2s;
}
.close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.settings-content {
padding: var(--spacing-lg);
}
.setting-section {
margin-bottom: var(--spacing-xl);
}
.setting-section:last-child {
margin-bottom: 0;
}
.setting-section h3 {
margin: 0 0 var(--spacing-sm) 0;
font-size: 1.1rem;
color: var(--text-primary);
}
.setting-description {
margin: 0 0 var(--spacing-md) 0;
color: var(--text-secondary);
font-size: 0.9rem;
}
.radio-option,
.checkbox-option {
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
padding: var(--spacing-md);
border: 2px solid var(--border-color);
border-radius: 8px;
margin-bottom: var(--spacing-sm);
cursor: pointer;
transition: all 0.2s;
}
.radio-option:hover,
.checkbox-option:hover {
background: var(--bg-hover);
border-color: var(--primary-color);
}
.radio-option:has(input:checked),
.checkbox-option:has(input:checked) {
background: rgba(59, 130, 246, 0.1);
border-color: var(--primary-color);
}
.radio-option input[type="radio"],
.checkbox-option input[type="checkbox"] {
margin-top: 0.25rem;
cursor: pointer;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.radio-option div,
.checkbox-option div {
flex: 1;
}
.radio-option strong,
.checkbox-option strong {
display: block;
margin-bottom: 0.25rem;
color: var(--text-primary);
}
.radio-option p,
.checkbox-option p {
margin: 0;
color: var(--text-secondary);
font-size: 0.85rem;
}
.settings-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
}
.settings-footer .btn-primary {
padding: var(--spacing-sm) var(--spacing-xl);
}
+94
View File
@@ -0,0 +1,94 @@
import { useState, useEffect } from 'react';
import './Settings.css';
const STORAGE_KEY = 'ptt-live-settings';
const defaultSettings = {
vibrationEnabled: true
};
/**
* Charge les paramètres depuis localStorage
*/
export function loadSettings() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return { ...defaultSettings, ...JSON.parse(stored) };
}
} catch (error) {
console.error('Erreur chargement paramètres:', error);
}
return defaultSettings;
}
/**
* Sauvegarde les paramètres dans localStorage
*/
export function saveSettings(settings) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (error) {
console.error('Erreur sauvegarde paramètres:', error);
}
}
/**
* Composant modal de paramètres
*/
export default function Settings({ isOpen, onClose }) {
const [settings, setSettings] = useState(defaultSettings);
useEffect(() => {
if (isOpen) {
setSettings(loadSettings());
}
}, [isOpen]);
const handleChange = (key, value) => {
const newSettings = { ...settings, [key]: value };
setSettings(newSettings);
saveSettings(newSettings);
};
if (!isOpen) return null;
return (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
<div className="settings-header">
<h2>Paramètres</h2>
<button className="close-btn" onClick={onClose}>
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
</svg>
</button>
</div>
<div className="settings-content">
<div className="setting-section">
<h3>Feedback</h3>
<label className="checkbox-option">
<input
type="checkbox"
checked={settings.vibrationEnabled}
onChange={(e) => handleChange('vibrationEnabled', e.target.checked)}
/>
<div>
<strong>Vibrations</strong>
<p>Activer le retour haptique lors du verrouillage PTT</p>
</div>
</label>
</div>
</div>
<div className="settings-footer">
<button className="btn-primary" onClick={onClose}>
Fermer
</button>
</div>
</div>
</div>
);
}
+55
View File
@@ -84,6 +84,20 @@
background: rgba(16, 185, 129, 0.1);
}
/* Canal virtuel */
.user-item.virtual-channel {
border-left: 3px solid var(--color-accent);
}
.user-item.virtual-channel.muted {
opacity: 0.5;
border-left-color: var(--color-text-secondary);
}
.user-avatar.channel {
background: var(--color-accent);
}
/* Avatar */
.user-avatar {
width: 40px;
@@ -190,3 +204,44 @@
max-height: 120px;
}
}
/* Bouton mute/unmute */
.mute-button {
width: 40px;
height: 40px;
border: none;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
color: var(--color-text-primary);
}
.mute-button:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.mute-button:active {
transform: scale(0.95);
}
.mute-button svg {
width: 20px;
height: 20px;
}
.user-item.muted .mute-button {
background: rgba(239, 68, 68, 0.2);
color: var(--color-error);
}
.channel-label {
font-size: 0.75rem;
color: var(--color-accent);
font-weight: 500;
}
+47 -5
View File
@@ -1,27 +1,69 @@
import './UserList.css';
/**
* Liste des participants connectés
* Liste des participants connectés (utilisateurs + canaux virtuels)
*/
export default function UserList({ participants }) {
export default function UserList({ participants, onToggleMute }) {
if (participants.length === 0) {
return (
<div className="user-list empty">
<p className="empty-message">Aucun autre participant</p>
<p className="empty-message">Aucun participant ou canal</p>
</div>
);
}
// Séparer canaux virtuels et utilisateurs
const virtualChannels = participants.filter(p => p.isVirtual);
const users = participants.filter(p => !p.isVirtual);
return (
<div className="user-list">
<div className="user-list-header">
<span className="user-count">
{participants.length} participant{participants.length > 1 ? 's' : ''}
{virtualChannels.length > 0 && `${virtualChannels.length} canal${virtualChannels.length > 1 ? 'aux' : ''}`}
{virtualChannels.length > 0 && users.length > 0 && ' • '}
{users.length > 0 && `${users.length} utilisateur${users.length > 1 ? 's' : ''}`}
</span>
</div>
<div className="user-list-items">
{participants.map((participant) => (
{/* Canaux virtuels en premier */}
{virtualChannels.map((participant) => (
<div
key={participant.identity}
className={`user-item virtual-channel ${participant.isSpeaking ? 'speaking' : ''} ${participant.isMuted ? 'muted' : ''}`}
>
<div className="user-avatar channel">
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21c2.31 0 4.2-1.75 4.45-4H15V6h4V3h-7z"/>
</svg>
</div>
<div className="user-info">
<span className="user-name">{participant.name}</span>
<span className="user-status channel-label">Canal audio</span>
</div>
<button
className="mute-button"
onClick={() => onToggleMute(participant.identity, participant.isVirtual)}
title={participant.isMuted ? 'Activer' : 'Désactiver'}
>
{participant.isMuted ? (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
) : (
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
)}
</button>
</div>
))}
{/* Utilisateurs WebRTC */}
{users.map((participant) => (
<div
key={participant.identity}
className={`user-item ${participant.isSpeaking ? 'speaking' : ''}`}
+131
View File
@@ -0,0 +1,131 @@
/* VU-mètre version mini (pour matrice routing) */
.vu-meter-mini {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
position: relative;
}
.vu-meter-mini.clipping {
box-shadow: 0 0 4px rgba(255, 68, 68, 0.8);
}
.vu-meter-mini-bar {
height: 100%;
transition: width 50ms linear;
border-radius: 2px;
}
/* VU-mètre horizontal */
.vu-meter-horizontal {
width: 100%;
height: 20px;
position: relative;
}
.vu-meter-horizontal.small {
height: 12px;
}
.vu-meter-horizontal.medium {
height: 20px;
}
.vu-meter-horizontal.large {
height: 30px;
}
.vu-meter-horizontal .vu-meter-bar-container {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.vu-meter-horizontal .vu-meter-bar-rms {
height: 100%;
transition: width 50ms linear;
border-radius: 4px;
}
.vu-meter-horizontal .vu-meter-bar-peak {
position: absolute;
top: 0;
height: 100%;
width: 2px;
background: rgba(255, 255, 255, 0.8);
transition: left 50ms linear;
}
.vu-meter-horizontal.clipping .vu-meter-bar-container {
box-shadow: 0 0 6px rgba(255, 68, 68, 0.8);
animation: clipping-pulse 200ms ease-in-out;
}
/* VU-mètre vertical */
.vu-meter-vertical {
height: 100px;
width: 20px;
position: relative;
}
.vu-meter-vertical.small {
height: 60px;
width: 12px;
}
.vu-meter-vertical.medium {
height: 100px;
width: 20px;
}
.vu-meter-vertical.large {
height: 150px;
width: 30px;
}
.vu-meter-vertical .vu-meter-bar-container {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.vu-meter-vertical .vu-meter-bar-rms {
width: 100%;
transition: height 50ms linear;
border-radius: 4px;
}
.vu-meter-vertical .vu-meter-bar-peak {
position: absolute;
left: 0;
width: 100%;
height: 2px;
background: rgba(255, 255, 255, 0.8);
transition: bottom 50ms linear;
}
.vu-meter-vertical.clipping .vu-meter-bar-container {
box-shadow: 0 0 6px rgba(255, 68, 68, 0.8);
animation: clipping-pulse 200ms ease-in-out;
}
/* Animation clipping */
@keyframes clipping-pulse {
0%, 100% {
box-shadow: 0 0 6px rgba(255, 68, 68, 0.8);
}
50% {
box-shadow: 0 0 12px rgba(255, 68, 68, 1);
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* VUMeter.jsx
* Composant VU-mètre minimaliste pour affichage niveaux audio temps réel
*/
import React from 'react';
import './VUMeter.css';
/**
* Convertit une valeur dBFS en pourcentage pour affichage
* -120dBFS = 0%, 0dBFS = 100%
*/
function dbToPercent(dbFS) {
const min = -60; // On affiche à partir de -60dBFS
const max = 0;
if (dbFS <= min) return 0;
if (dbFS >= max) return 100;
return ((dbFS - min) / (max - min)) * 100;
}
/**
* Détermine la couleur selon le niveau (style VU professionnel)
*/
function getLevelColor(dbFS) {
if (dbFS >= -3) return '#ff4444'; // Rouge (clipping proche)
if (dbFS >= -12) return '#ffaa00'; // Orange (niveau élevé)
return '#44ff44'; // Vert (niveau nominal)
}
function VUMeter({ level, size = 'small', orientation = 'vertical' }) {
if (!level) {
level = { rms: -120, peak: 0, clipping: false };
}
const rmsPercent = dbToPercent(level.rms);
const peakPercent = (level.peak || 0) * 100;
const color = getLevelColor(level.rms);
const isClipping = level.clipping || level.peak >= 0.99;
if (size === 'mini') {
// Version ultra-compacte pour matrice routing
return (
<div className={`vu-meter-mini ${isClipping ? 'clipping' : ''}`}>
<div
className="vu-meter-mini-bar"
style={{
width: `${rmsPercent}%`,
backgroundColor: color
}}
/>
</div>
);
}
if (orientation === 'horizontal') {
return (
<div className={`vu-meter-horizontal ${size} ${isClipping ? 'clipping' : ''}`}>
<div className="vu-meter-bar-container">
<div
className="vu-meter-bar-rms"
style={{
width: `${rmsPercent}%`,
backgroundColor: color
}}
/>
{level.peak > 0 && (
<div
className="vu-meter-bar-peak"
style={{ left: `${peakPercent}%` }}
/>
)}
</div>
</div>
);
}
// Vertical (défaut)
return (
<div className={`vu-meter-vertical ${size} ${isClipping ? 'clipping' : ''}`}>
<div className="vu-meter-bar-container">
<div
className="vu-meter-bar-rms"
style={{
height: `${rmsPercent}%`,
backgroundColor: color
}}
/>
{level.peak > 0 && (
<div
className="vu-meter-bar-peak"
style={{ bottom: `${peakPercent}%` }}
/>
)}
</div>
</div>
);
}
export default VUMeter;
+143
View File
@@ -0,0 +1,143 @@
/**
* useAudioLevels.js
* Hook React pour recevoir les niveaux audio temps réel via WebSocket
*/
import { useState, useEffect, useRef } from 'react';
const WS_URL = import.meta.env.VITE_WS_AUDIO_LEVELS_URL || 'ws://localhost:3000/audio-levels';
/**
* Hook pour monitoring des niveaux audio temps réel
*/
export function useAudioLevels() {
const [levels, setLevels] = useState({
inputs: {},
groups: {},
outputs: {},
routing: {
activeInputs: [],
activeGroups: [],
activeOutputs: []
}
});
const [connected, setConnected] = useState(false);
const wsRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const reconnectAttemptsRef = useRef(0);
useEffect(() => {
connect();
return () => {
disconnect();
};
}, []);
const connect = () => {
try {
console.log('Connexion au WebSocket audio-levels...');
const ws = new WebSocket(WS_URL);
ws.onopen = () => {
console.log('WebSocket audio-levels connecté');
setConnected(true);
reconnectAttemptsRef.current = 0;
// Ping périodique pour maintenir la connexion
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 10000);
ws.pingInterval = pingInterval;
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
switch (message.type) {
case 'initial':
case 'levels':
setLevels(message.data);
break;
case 'pong':
// Pong reçu, connexion active
break;
default:
console.warn('Message WebSocket inconnu:', message.type);
}
} catch (error) {
console.error('Erreur parsing message WebSocket:', error);
}
};
ws.onerror = (error) => {
console.error('Erreur WebSocket audio-levels:', error);
};
ws.onclose = () => {
console.log('WebSocket audio-levels déconnecté');
setConnected(false);
if (ws.pingInterval) {
clearInterval(ws.pingInterval);
}
// Reconnexion automatique avec backoff
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
console.log(`Reconnexion dans ${delay}ms...`);
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++;
connect();
}, delay);
};
wsRef.current = ws;
} catch (error) {
console.error('Erreur création WebSocket:', error);
setConnected(false);
}
};
const disconnect = () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
if (wsRef.current.pingInterval) {
clearInterval(wsRef.current.pingInterval);
}
wsRef.current.close();
wsRef.current = null;
}
setConnected(false);
};
const setUpdateRate = (rateMs) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'setUpdateRate',
rateMs
}));
}
};
return {
levels,
connected,
setUpdateRate
};
}
export default useAudioLevels;
+83 -9
View File
@@ -16,6 +16,8 @@ export default function useLiveKit() {
const analyserRef = useRef(null);
const animationFrameRef = useRef(null);
const isAudioUnlockedRef = useRef(false);
const virtualChannelsRef = useRef([]);
const mutedChannelsRef = useRef(new Set()); // IDs des canaux muted
// Analyseur audio pour pistes distantes (audio entrant)
const remoteAudioContextRef = useRef(null);
@@ -25,8 +27,11 @@ export default function useLiveKit() {
/**
* Connexion à la room LiveKit
*/
const connect = useCallback(async (url, token) => {
const connect = useCallback(async (url, token, virtualChannels = []) => {
try {
// Stocker les canaux virtuels
virtualChannelsRef.current = virtualChannels;
// Créer room
const room = new Room({
adaptiveStream: true,
@@ -154,7 +159,7 @@ export default function useLiveKit() {
/**
* Changer de groupe (reconnexion à une nouvelle room)
*/
const switchGroup = useCallback(async (url, token) => {
const switchGroup = useCallback(async (url, token, virtualChannels = []) => {
console.log('🔄 Changement de groupe...');
// Déconnexion propre
@@ -167,8 +172,11 @@ export default function useLiveKit() {
setIsConnected(false);
setParticipants([]);
// Reset canaux muted
mutedChannelsRef.current.clear();
// Reconnexion avec nouveau token
await connect(url, token);
await connect(url, token, virtualChannels);
}, [connect]);
/**
@@ -252,15 +260,30 @@ export default function useLiveKit() {
}, []);
/**
* Mise à jour liste participants
* Mise à jour liste participants (inclut canaux virtuels)
*/
const updateParticipants = () => {
const updateParticipants = useCallback(() => {
if (!roomRef.current) return;
const room = roomRef.current;
const participantsList = [];
// Participants distants
// Canaux virtuels (affichés en premier)
virtualChannelsRef.current.forEach((channel) => {
participantsList.push({
identity: channel.id,
name: channel.name,
isLocal: false,
isVirtual: true,
isSpeaking: false, // TODO: détection audio depuis bridge
hasAudio: true,
isMuted: mutedChannelsRef.current.has(channel.id),
audioInput: channel.audioInput,
audioOutput: channel.audioOutput
});
});
// Participants distants (utilisateurs WebRTC)
room.remoteParticipants.forEach((participant) => {
const audioTracks = participant.audioTracks ? Array.from(participant.audioTracks.values()) : [];
const audioPublication = audioTracks[0];
@@ -270,13 +293,63 @@ export default function useLiveKit() {
identity: participant.identity,
name: participant.name || participant.identity,
isLocal: false,
isVirtual: false,
isSpeaking,
hasAudio: audioPublication?.isSubscribed || false
hasAudio: audioPublication?.isSubscribed || false,
isMuted: false
});
});
setParticipants(participantsList);
};
}, []);
/**
* Toggle mute/unmute d'un participant (canal virtuel ou utilisateur)
*/
const toggleParticipantMute = useCallback((participantId, isVirtual) => {
if (isVirtual) {
// Canal virtuel : toggle dans l'état local
const isMuted = mutedChannelsRef.current.has(participantId);
if (isMuted) {
mutedChannelsRef.current.delete(participantId);
console.log('🔊 Canal virtuel unmuted:', participantId);
} else {
mutedChannelsRef.current.add(participantId);
console.log('🔇 Canal virtuel muted:', participantId);
}
// TODO Phase 3: Envoyer commande au bridge audio via DataChannel
// pour vraiment muter/unmuter le canal physique
// Mettre à jour l'affichage
updateParticipants();
} else {
// Utilisateur WebRTC : muter localement la lecture audio
if (!roomRef.current) return;
const participant = roomRef.current.remoteParticipants.get(participantId);
if (!participant) return;
const audioTracks = Array.from(participant.audioTracks.values());
const audioPublication = audioTracks[0];
if (audioPublication && audioPublication.audioTrack) {
const track = audioPublication.audioTrack;
const newMutedState = !track.isMuted;
if (newMutedState) {
track.mute();
console.log('🔇 Participant muted:', participantId);
} else {
track.unmute();
console.log('🔊 Participant unmuted:', participantId);
}
updateParticipants();
}
}
}, [updateParticipants]);
/**
* Setup analyseur audio pour VU-mètre (micro local)
@@ -412,6 +485,7 @@ export default function useLiveKit() {
disconnect,
switchGroup,
startTalking,
stopTalking
stopTalking,
toggleParticipantMute
};
}
+150
View File
@@ -0,0 +1,150 @@
import { useState, useEffect, useCallback } from 'react';
/**
* Hook pour gérer les notifications Web Push
* Utilisé pour les appels privés et notifications de groupe
*/
export default function usePush() {
const [isSupported, setIsSupported] = useState(false);
const [isPermissionGranted, setIsPermissionGranted] = useState(false);
const [subscription, setSubscription] = useState(null);
useEffect(() => {
// Vérifier si les notifications sont supportées
const supported = 'Notification' in window && 'serviceWorker' in navigator;
setIsSupported(supported);
if (supported) {
// Vérifier la permission actuelle
setIsPermissionGranted(Notification.permission === 'granted');
}
}, []);
/**
* Demander la permission pour les notifications
*/
const requestPermission = useCallback(async () => {
if (!isSupported) {
console.warn('Notifications non supportées sur ce navigateur');
return false;
}
try {
const permission = await Notification.requestPermission();
const granted = permission === 'granted';
setIsPermissionGranted(granted);
if (granted) {
console.log('Permission notifications accordée');
} else {
console.warn('Permission notifications refusée');
}
return granted;
} catch (error) {
console.error('Erreur demande permission notifications:', error);
return false;
}
}, [isSupported]);
/**
* S'abonner aux notifications push (via service worker)
*/
const subscribeToPush = useCallback(async () => {
if (!isSupported || !isPermissionGranted) {
console.warn('Impossible de s\'abonner : permission non accordée');
return null;
}
try {
// Attendre que le service worker soit prêt
const registration = await navigator.serviceWorker.ready;
// Créer l'abonnement push
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
// TODO: Remplacer par la vraie clé VAPID du serveur
import.meta.env.VITE_VAPID_PUBLIC_KEY || ''
)
});
console.log('Abonnement push créé:', sub);
setSubscription(sub);
return sub;
} catch (error) {
console.error('Erreur abonnement push:', error);
return null;
}
}, [isSupported, isPermissionGranted]);
/**
* Se désabonner des notifications push
*/
const unsubscribeFromPush = useCallback(async () => {
if (!subscription) {
return true;
}
try {
await subscription.unsubscribe();
console.log('Désabonnement push réussi');
setSubscription(null);
return true;
} catch (error) {
console.error('Erreur désabonnement push:', error);
return false;
}
}, [subscription]);
/**
* Envoyer une notification locale (sans push serveur)
*/
const showNotification = useCallback(async (title, options = {}) => {
if (!isSupported || !isPermissionGranted) {
console.warn('Impossible d\'afficher la notification : permission non accordée');
return;
}
try {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification(title, {
icon: '/icon-192x192.png',
badge: '/badge-72x72.png',
vibrate: [200, 100, 200],
...options
});
} catch (error) {
console.error('Erreur affichage notification:', error);
}
}, [isSupported, isPermissionGranted]);
return {
isSupported,
isPermissionGranted,
subscription,
requestPermission,
subscribeToPush,
unsubscribeFromPush,
showNotification
};
}
/**
* Convertir une clé VAPID base64 en Uint8Array
*/
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
+2
View File
@@ -13,6 +13,8 @@
--color-success: #10b981;
--color-warning: #f59e0b;
--color-danger: #ef4444;
--color-accent: #8b5cf6;
--color-error: #ef4444;
/* PTT States */
--color-ptt-idle: #374151;
+17 -4
View File
@@ -1,14 +1,26 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import fs from 'fs';
export default defineConfig({
export default defineConfig(({ mode }) => {
// Charger les variables d'environnement
const env = loadEnv(mode, process.cwd(), '');
// Déterminer l'URL de l'API (utilise variable d'environnement ou fallback localhost)
const apiUrl = env.VITE_API_URL || 'http://localhost:3000';
const livekitUrl = env.VITE_LIVEKIT_URL || 'ws://localhost:7880';
return {
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
injectRegister: 'auto',
devOptions: {
enabled: true
},
manifest: {
name: 'PTT Live',
short_name: 'PTT Live',
@@ -64,12 +76,12 @@ export default defineConfig({
},
proxy: {
'/api': {
target: 'http://localhost:3000',
target: apiUrl.startsWith('/') ? 'http://localhost:3000' : apiUrl,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
'/livekit': {
target: 'ws://10.1.1.111:7880',
target: livekitUrl,
ws: true,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/livekit/, '')
@@ -80,4 +92,5 @@ export default defineConfig({
outDir: 'dist',
sourcemap: true
}
};
});
+695
View File
@@ -0,0 +1,695 @@
# Configuration AES67 avec PTT Live
Guide pour intégrer PTT Live avec des équipements AES67 (alternative open source à Dante)
## Vue d'ensemble
AES67 est un standard ouvert pour le transport audio sur IP (IEEE 1722, IETF RTP). Il est interopérable avec Dante (mode AES67), Ravenna, Livewire, et d'autres protocoles audio-over-IP.
### Avantages vs Dante Virtual Soundcard
| Caractéristique | AES67 | Dante (DVS) |
|----------------|-------|-------------|
| **Coût** | Gratuit | ~300€/licence |
| **Ouverture** | Standard ouvert | Propriétaire Audinate |
| **Complexité** | Configuration CLI | GUI simple |
| **Interopérabilité** | Multi-vendor | Dante + AES67 mode |
| **PTP sync** | Requis | Optionnel |
### Architecture
```
[Équipements AES67] ←→ [RTP Multicast] ←→ [ALSA/JACK] ←→ [PTT Live]
[PTP Clock Sync]
```
---
## Prérequis
### Matériel
- Interface réseau Ethernet Gigabit (obligatoire)
- Switch manageable avec support :
- IGMP snooping
- PTP (Precision Time Protocol)
- QoS/DSCP
- Jumbo frames (recommandé)
### Système d'exploitation
- **Linux recommandé** : Ubuntu 22.04+, Debian 11+, Arch Linux
- macOS possible (via outils tiers)
- Windows non supporté nativement
### Logiciels
- **PTPd** ou **linuxptp** : synchronisation horloge PTP
- **JACK Audio** : routing audio
- **Merging ALSA RAVENNA/AES67 Driver** (optionnel mais recommandé)
- https://www.merging.com/products/ravenna/alsa_driver
---
## Installation (Linux)
### 1. Installation des dépendances
#### Ubuntu/Debian
```bash
# Outils réseau et audio
sudo apt update
sudo apt install -y \
build-essential \
git \
jackd2 \
jack-tools \
qjackctl \
linuxptp \
ptp4l \
phc2sys \
ethtool \
net-tools
# ALSA dev (si compilation driver Merging)
sudo apt install -y \
libasound2-dev \
linux-headers-$(uname -r)
```
#### Arch Linux
```bash
sudo pacman -S --needed \
jack2 \
qjackctl \
linuxptp \
ethtool \
alsa-lib
```
### 2. Installation Merging ALSA RAVENNA/AES67 Driver
Ce driver crée une carte ALSA virtuelle qui envoie/reçoit des flux AES67 RTP.
#### Téléchargement
```bash
cd /tmp
wget https://www.merging.com/ravenna/ALSA_RAVENNA_1.2.9.tar.gz
tar -xzf ALSA_RAVENNA_1.2.9.tar.gz
cd ALSA_RAVENNA
```
#### Compilation et installation
```bash
# Compilation
make
# Installation
sudo make install
# Chargement du module kernel
sudo modprobe MergingRAVENNA
# Vérification
lsmod | grep Merging
```
#### Configuration persistante
```bash
# Charger le module au démarrage
echo "MergingRAVENNA" | sudo tee -a /etc/modules-load.d/ravenna.conf
# Reboot pour tester
sudo reboot
```
---
## Configuration Réseau
### 1. Configuration interface réseau
AES67 nécessite une configuration réseau spécifique.
#### Trouver l'interface réseau
```bash
ip link show
# Exemple : eth0, enp3s0, etc.
```
#### Configuration IP statique
Éditer `/etc/network/interfaces` (Debian) ou `/etc/netplan/01-netcfg.yaml` (Ubuntu) :
**Netplan (Ubuntu 22.04+)** :
```yaml
network:
version: 2
ethernets:
enp3s0: # Votre interface
dhcp4: no
addresses:
- 192.168.10.100/24 # IP statique dans VLAN audio
mtu: 9000 # Jumbo frames
```
Appliquer :
```bash
sudo netplan apply
```
**Interfaces (Debian)** :
```
auto eth0
iface eth0 inet static
address 192.168.10.100
netmask 255.255.255.0
mtu 9000
```
Appliquer :
```bash
sudo systemctl restart networking
```
#### Optimisations noyau
Éditer `/etc/sysctl.conf` :
```bash
# Buffers réseau pour audio temps réel
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
net.core.rmem_default = 16777216
net.core.wmem_default = 16777216
# Multicast
net.ipv4.igmp_max_memberships = 512
```
Appliquer :
```bash
sudo sysctl -p
```
### 2. Configuration Switch
Paramètres switch requis :
| Paramètre | Valeur |
|-----------|--------|
| **VLAN** | 10 (exemple, dédié audio) |
| **IGMP Snooping** | Activé |
| **PTP** | Activé sur tous les ports |
| **QoS/DSCP** | EF (46) pour audio, CS7 (56) pour PTP |
| **Jumbo Frames** | MTU 9000 |
| **Flow Control** | Désactivé |
---
## Configuration PTP (Precision Time Protocol)
AES67 requiert une synchronisation horloge précise (±1µs).
### 1. Configuration ptp4l
Créer `/etc/ptp4l.conf` :
```ini
[global]
dataset_comparison = ieee1588
priority1 = 128
priority2 = 128
domainNumber = 0
slaveOnly 1
two_step 1
# Configuration réseau
network_transport UDPv4
delay_mechanism E2E
# Timers
logAnnounceInterval 0
logSyncInterval -3
logMinDelayReqInterval -3
# Interface réseau (adapter selon votre système)
[enp3s0]
```
### 2. Démarrage PTP
#### Test manuel
```bash
# Lancer ptp4l en mode slave (synchronisé par master du réseau)
sudo ptp4l -i enp3s0 -f /etc/ptp4l.conf -m
# Dans un autre terminal : synchroniser l'horloge système
sudo phc2sys -s enp3s0 -w -m
```
Vous devriez voir :
```
ptp4l[...]: master offset -2 s2 freq -15432 path delay 125
phc2sys[...]: enp3s0 sys offset -4 s2 freq -12345 delay 1256
```
L'offset doit être < 1000 ns (1µs).
#### Service systemd
Créer `/etc/systemd/system/ptp4l.service` :
```ini
[Unit]
Description=PTP Daemon
After=network.target
[Service]
Type=simple
ExecStart=/usr/sbin/ptp4l -i enp3s0 -f /etc/ptp4l.conf -m
Restart=always
[Install]
WantedBy=multi-user.target
```
Créer `/etc/systemd/system/phc2sys.service` :
```ini
[Unit]
Description=PHC to System Clock Sync
After=ptp4l.service
Requires=ptp4l.service
[Service]
Type=simple
ExecStart=/usr/sbin/phc2sys -s enp3s0 -w -m
Restart=always
[Install]
WantedBy=multi-user.target
```
Activer :
```bash
sudo systemctl daemon-reload
sudo systemctl enable ptp4l phc2sys
sudo systemctl start ptp4l phc2sys
```
Vérifier :
```bash
sudo systemctl status ptp4l
sudo systemctl status phc2sys
```
---
## Configuration JACK + AES67
### 1. Démarrage JACK avec carte ALSA RAVENNA
```bash
# Lister les cartes ALSA
aplay -l
# Devrait afficher quelque chose comme :
# card 2: RAVENNA [Merging RAVENNA], device 0: ...
```
Démarrer JACK avec la carte RAVENNA :
```bash
jackd -d alsa \
-d hw:RAVENNA \
-r 48000 \
-p 256 \
-n 2 \
-S \
-P
```
Paramètres :
- `-d hw:RAVENNA` : carte ALSA RAVENNA
- `-r 48000` : sample rate AES67 standard
- `-p 256` : buffer size (5.3ms @ 48kHz)
- `-n 2` : 2 périodes
- `-S` : soft mode (moins de xruns)
- `-P` : playback + capture
### 2. Configuration QjackCtl (GUI alternative)
1. Lancer `qjackctl`
2. Setup :
- **Driver** : alsa
- **Interface** : hw:RAVENNA
- **Sample Rate** : 48000
- **Frames/Period** : 256
- **Periods/Buffer** : 2
3. Start
### 3. Configuration des flux AES67
Le driver Merging RAVENNA se configure via des fichiers JSON.
#### Configuration RTP streams
Créer `/etc/ravenna/streams.json` :
```json
{
"sources": [
{
"name": "Input_1",
"sdp": "239.69.1.1:5004",
"channels": 2,
"payloadType": 98,
"sampleRate": 48000
},
{
"name": "Input_2",
"sdp": "239.69.1.2:5004",
"channels": 2,
"payloadType": 98,
"sampleRate": 48000
}
],
"sinks": [
{
"name": "Output_1",
"sdp": "239.69.2.1:5004",
"channels": 2,
"payloadType": 98,
"sampleRate": 48000
}
]
}
```
Charger la configuration :
```bash
# Via l'outil Merging (si disponible)
ravenna-daemon -c /etc/ravenna/streams.json
```
---
## Intégration PTT Live
### 1. Démarrer PTT Live
PTT Live détectera automatiquement JACK :
```bash
cd /chemin/vers/PTT\ Live/server
npm start
```
Logs attendus :
```
✓ Backend audio : JACK (Linux professionnel)
📻 Devices audio détectés : 2
- JACK System Capture (in:8, out:0)
- JACK System Playback (in:0, out:8)
```
### 2. Routing JACK
Connecter les ports JACK :
```bash
# Liste des ports
jack_lsp
# Exemple de ports disponibles :
# RAVENNA:capture_1
# RAVENNA:capture_2
# RAVENNA:playback_1
# RAVENNA:playback_2
# PTTLive:input_1
# PTTLive:output_1
# Connexion
jack_connect "RAVENNA:capture_1" "PTTLive:input_1"
jack_connect "PTTLive:output_1" "RAVENNA:playback_1"
```
#### Script automatique
Créer `server/scripts/connect-aes67.sh` :
```bash
#!/bin/bash
# Connexion automatique JACK ↔ AES67
echo "Connexion des canaux AES67 → PTT Live..."
for i in {1..8}; do
jack_connect "RAVENNA:capture_$i" "PTTLive:input_$i" 2>/dev/null
jack_connect "PTTLive:output_$i" "RAVENNA:playback_$i" 2>/dev/null
done
echo "✓ Routing JACK configuré"
```
```bash
chmod +x server/scripts/connect-aes67.sh
./server/scripts/connect-aes67.sh
```
---
## Monitoring et Diagnostics
### Vérification PTP
```bash
# Status PTP
sudo systemctl status ptp4l
# Offset temps réel (doit être < 1µs)
sudo ptp4l -i enp3s0 -f /etc/ptp4l.conf -m | grep "master offset"
```
### Vérification multicast
```bash
# Afficher les groupes multicast rejoints
netstat -g
# Capture trafic RTP AES67 (exemple)
sudo tcpdump -i enp3s0 -n 'multicast and udp port 5004'
```
### Vérification JACK
```bash
# Statistiques JACK
jack_samplerate # 48000
jack_bufsize # 256
# Xruns (buffer underruns)
jack_evmon # Surveille les xruns en temps réel
```
### Logs driver RAVENNA
```bash
# Kernel messages
sudo dmesg | grep -i ravenna
# Logs système
sudo journalctl -u ravenna-daemon -f
```
---
## Interopérabilité Dante ↔ AES67
Les équipements Dante peuvent basculer en mode AES67 pour communiquer avec des devices AES67 natifs.
### Activation AES67 sur Dante
1. Ouvrir **Dante Controller**
2. Device → sélectionner équipement Dante
3. Device Config → AES67 Config
4. Cocher "Enable AES67"
5. Configurer :
- **Sample Rate** : 48kHz
- **Encoding** : L24 (24-bit)
- **Packet Time** : 1ms
6. Reboot device
### SDP (Session Description Protocol)
AES67 utilise des fichiers SDP pour annoncer les flux.
**Exemple SDP pour un flux stéréo** :
```
v=0
o=- 123456 1 IN IP4 192.168.10.50
s=PTT Live Output
c=IN IP4 239.69.2.1/32
t=0 0
m=audio 5004 RTP/AVP 98
a=rtpmap:98 L24/48000/2
a=ptime:1
a=sync-time:0
```
Sauvegarder dans `/etc/ravenna/pttlive-output.sdp` et référencer dans la config du driver.
---
## Optimisation Performance
### Latence typique
| Étape | Latence |
|-------|---------|
| Réseau RTP | 1-5 ms (selon packet time) |
| Driver ALSA RAVENNA | 2-5 ms |
| JACK | 5-10 ms (256 samples @ 48kHz) |
| PTT Live bridge | 20-40 ms |
| WebRTC client | 30-100 ms |
| **TOTAL** | **58-160 ms** |
### Réduction latence
1. **Packet time** : 0.125ms ou 0.25ms (au lieu de 1ms)
2. **JACK buffer** : 128 samples (2.7ms au lieu de 5.3ms)
3. **PTT Live jitter buffer** : preset "ULTRA_LOW"
Configuration JACK basse latence :
```bash
jackd -R -P 70 -d alsa -d hw:RAVENNA -r 48000 -p 128 -n 3
```
- `-R` : mode real-time
- `-P 70` : priorité real-time (nécessite config `/etc/security/limits.conf`)
**Attention** : Risque de xruns si CPU/réseau surchargé.
### Configuration real-time Linux
Éditer `/etc/security/limits.conf` :
```
@audio - rtprio 95
@audio - memlock unlimited
```
Ajouter votre utilisateur au groupe audio :
```bash
sudo usermod -a -G audio $USER
```
Reboot requis.
---
## Troubleshooting
### Pas de son
**Vérifications** :
1. PTP synchronisé : `sudo ptp4l -i enp3s0 -f /etc/ptp4l.conf -m` (offset < 1µs)
2. Driver RAVENNA chargé : `lsmod | grep Merging`
3. JACK voit la carte : `jack_lsp | grep RAVENNA`
4. Ports connectés : `jack_lsp -c`
5. Flux RTP visibles : `sudo tcpdump -i enp3s0 -n multicast`
### Xruns JACK
**Causes** :
- Buffer trop petit
- CPU overload
- IRQ conflicts
**Solutions** :
- Augmenter buffer JACK : `-p 512` au lieu de 256
- Désactiver CPU frequency scaling :
```bash
sudo cpupower frequency-set -g performance
```
- Isoler CPU cores pour audio (kernel parameter `isolcpus`)
### Offset PTP trop élevé
**Causes** :
- Pas de PTP master sur le réseau
- Switch ne supporte pas PTP
**Solutions** :
- Configurer un device comme PTP master (grandmaster)
- Vérifier config switch (PTP enabled sur tous les ports)
- Utiliser un PTP hardware clock (si carte réseau compatible)
---
## Coût Total
| Élément | Prix |
|---------|------|
| **Switch PTP** | 200-2000€ (selon modèle) |
| **Merging ALSA RAVENNA Driver** | Gratuit |
| **Logiciels Linux** | Gratuit |
| **PTT Live** | Gratuit |
| **TOTAL** | **200-2000€** |
Bien moins cher que Dante DVS (300€/licence) si plusieurs postes.
---
## Alternatives sans RAVENNA Driver
### Utilisation de daemon RTP natif
Si le driver Merging n'est pas disponible, utiliser **trx** ou **rtptools** :
```bash
# Installation trx
git clone https://github.com/x42/trx.git
cd trx
make
sudo make install
# Réception flux RTP
trx --recv 239.69.1.1 5004 -j output_1
# Émission flux RTP
trx --send 239.69.2.1 5004 -j input_1
```
---
## Ressources
- **AES67 Standard** : http://www.aes.org/publications/standards/search.cfm?docID=96
- **Merging RAVENNA** : https://www.merging.com/products/ravenna
- **Linux Audio** : https://wiki.linuxaudio.org/
- **PTP Configuration** : http://linuxptp.sourceforge.net/
---
**Dernière mise à jour** : 2026-05-26
**Version PTT Live** : 0.1.0 (Phase 3)
+485
View File
@@ -0,0 +1,485 @@
# Architecture Audio Bridge - PTT Live
Documentation complète du système de bridge audio entre cartes son et clients WebRTC.
---
## Vue d'Ensemble
Le serveur PTT Live agit comme un **hub audio central** qui relie :
- Les **cartes son physiques** (macOS/Linux)
- Les **clients WebRTC** (smartphones, navigateurs)
- Le **routing multi-groupes** (matrice style Dante)
```
┌─────────────────────────────────────────────────────────────────┐
│ SERVEUR PTT LIVE │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ Carte Son │ ←→ │ AudioBridge │ ←→ │ LiveKit Server │ │
│ │ (CoreAudio/ │ │ + Group │ │ (SFU) │ │
│ │ JACK/PW) │ │ Router │ │ │ │
│ └──────────────┘ └──────────────┘ └─────────────────┘ │
│ ↕ ↕ ↕ │
│ Canaux 1-32 Groupes A-Z Rooms WebRTC │
└─────────────────────────────────────────────────────────────────┘
┌───────────┴───────────┐
↓ ↓
┌───────────────┐ ┌───────────────┐
│ Client 1 PWA │ │ Client 2 PWA │
│ (Régie) │ │ (Scène) │
└───────────────┘ └───────────────┘
```
---
## Composants Principaux
### 1. Audio Backends (CoreAudio/JACK/PipeWire)
**Rôle** : Interface avec les cartes son physiques de l'OS.
**Fichiers** :
- [server/bridge/backends/CoreAudioBackend.js](../server/bridge/backends/CoreAudioBackend.js) (macOS)
- [server/bridge/backends/JACKBackend.js](../server/bridge/backends/JACKBackend.js) (Linux pro)
- [server/bridge/backends/PipeWireBackend.js](../server/bridge/backends/PipeWireBackend.js) (Linux moderne)
**Fonctionnalités** :
- Détecte **toutes les cartes son** connectées (USB, Thunderbolt, virtuelles)
- Capture audio (48kHz, 16-bit PCM)
- Lecture audio (buffer circulaire, gestion underrun/overrun)
- Multi-canaux (jusqu'à 32+ canaux)
**Exemple détection cartes macOS** :
```javascript
CoreAudioBackend.getDevices()
// Retourne :
[
{ id: 0, name: 'MacBook Pro Mic', maxInputChannels: 1 },
{ id: 1, name: 'MacBook Pro Speakers', maxOutputChannels: 2 },
{ id: 2, name: 'Focusrite Scarlett 18i20', maxInputChannels: 18, maxOutputChannels: 20 },
{ id: 3, name: 'Dante Virtual Soundcard', maxInputChannels: 64, maxOutputChannels: 64 }
]
```
### 2. GroupAudioRouter
**Rôle** : Matrice de routing audio multi-canaux avec gains.
**Fichier** : [server/bridge/GroupAudioRouter.js](../server/bridge/GroupAudioRouter.js)
**Architecture** :
```
Inputs Physiques (CH 1-32) → Groupes (Régie, Scène, FOH) → Outputs Physiques (CH 1-32)
↓ ↓ ↓
Mix avec gain Mix avec gain Mix additif
```
**Fonctionnalités** :
- **Input → Group** : Plusieurs canaux physiques vers un groupe (mixage additif)
- **Group → Output** : Un groupe vers plusieurs canaux physiques (distribution)
- **Gains individuels** : -120dB à +6dB par route
- **Canaux partagés** : Plusieurs groupes peuvent aller vers la même sortie (mix)
- **Anti-clipping** : Normalisation automatique
**Configuration YAML exemple** :
```yaml
audio:
routing:
inputToGroup:
0: ['regie'] # Canal 0 → Groupe Régie
1: ['regie'] # Canal 1 → Groupe Régie (mixé avec CH0)
2: ['scene'] # Canal 2 → Groupe Scène
3: ['foh'] # Canal 3 → Groupe FOH
groupToOutput:
regie: [0, 1] # Groupe Régie → Canaux 0+1 (stéréo)
scene: [2, 3] # Groupe Scène → Canaux 2+3
foh: [4, 5, 6, 7] # Groupe FOH → 4 canaux
gains:
in_0_regie: 0 # Gain +0dB (unity)
in_1_regie: -3 # Gain -3dB
regie_out_0: 0
scene_out_2: -6 # Gain -6dB
```
### 3. AudioBridge
**Rôle** : Orchestrateur central du flux audio.
**Fichier** : [server/bridge/AudioBridge.js](../server/bridge/AudioBridge.js)
**Pipeline** :
#### FLUX CAPTURE (Carte Son → Clients)
```
1. CoreAudio/JACK capture PCM (16-bit Buffer)
2. Conversion PCM Buffer → Float32Array [-1.0, 1.0]
3. GroupAudioRouter.processInputsToGroups()
- Input CH0 + CH1 → Groupe "Régie" (mix)
- Input CH2 → Groupe "Scène"
4. Conversion Float32Array → PCM Buffer (par groupe)
5. Encodage Opus (96 kbps par défaut)
6. Émission événement 'groupAudioOut' → LiveKitServerBridge
7. LiveKit SFU → Clients WebRTC dans la room du groupe
```
#### FLUX LECTURE (Clients → Carte Son)
```
1. Clients WebRTC → LiveKit SFU
2. LiveKitServerBridge reçoit audio par groupe
3. Émission événement 'groupAudioIn' → AudioBridge
4. Conversion PCM Buffer → Float32Array
5. GroupAudioRouter.processGroupsToOutputs()
- Groupe "Régie" → Output CH0 + CH1
- Groupe "Scène" → Output CH2 + CH3
6. Conversion Float32Array → PCM Buffer (par canal)
7. CoreAudio/JACK queueAudio() → Carte son physique
```
### 4. LiveKitServerBridge
**Rôle** : Pont entre AudioBridge et LiveKit (WebRTC).
**Fichier** : [server/bridge/LiveKitServerBridge.js](../server/bridge/LiveKitServerBridge.js)
**Responsabilités** :
- Génère les tokens JWT pour les clients
- Écoute les événements `groupAudioOut` de AudioBridge
- Injecte l'audio vers LiveKit (via DataChannel ou AudioSource)
- Reçoit l'audio des clients LiveKit
- Émet `groupAudioIn` vers AudioBridge
**API** :
```javascript
// Générer token pour un client
const token = await bridge.generateClientToken('user123', 'regie');
// Vérifier participants actifs
const participants = await bridge.listParticipants('regie');
// Créer room/groupe
await bridge.ensureRoomExists('regie');
```
---
## Flux Audio Complet : Exemple Réel
### Scénario : Événement avec 3 groupes
**Configuration** :
- Carte son : Focusrite Scarlett 18i20 (18 inputs, 20 outputs)
- Groupes :
- **Régie** : CH0-1 (input) → CH0-1 (output)
- **Scène** : CH2-3 (input) → CH2-3 (output)
- **FOH** : CH4-5 (input) → CH4-5 (output)
### Flux 1 : Console → Clients
```
[Console Audio CH1] (signal analogique)
[Focusrite CH1 Input] (ADC 24-bit → 16-bit PCM)
CoreAudioBackend.startCapture()
↓ événement 'audioData' (Buffer PCM)
AudioBridge._startAudioRouting()
↓ _bufferToFloat32()
GroupAudioRouter.processInputsToGroups()
↓ input CH1 → groupe "Régie" (gain 0dB)
OpusCodec.encode(pcmBuffer) → opusData
↓ événement 'groupAudioOut'
LiveKitServerBridge._handleGroupAudioOut()
↓ TODO: Envoi vers LiveKit SFU
LiveKit SFU (room "regie")
↓ WebRTC (Opus, SRTP)
[Client PWA Régie] (smartphone)
↓ Web Audio API decode
[Haut-parleur smartphone]
```
### Flux 2 : Client → Enceintes Scène
```
[Client PWA Scène] (bouton PTT appuyé)
↓ navigator.mediaDevices.getUserMedia()
[Microphone smartphone]
↓ WebRTC encode (Opus)
LiveKit SFU (room "scene")
↓ TODO: Réception via webhook/DataChannel
LiveKitServerBridge.injectGroupAudioIn('scene', pcmBuffer)
↓ événement 'groupAudioIn'
AudioBridge (listener)
↓ _bufferToFloat32()
GroupAudioRouter.processGroupsToOutputs()
↓ groupe "Scène" → output CH2-3 (gain -6dB)
↓ _float32ToBuffer()
CoreAudioBackend.queueAudio(pcmBuffer)
[Focusrite CH2-3 Output] (DAC)
[Enceintes Scène] (signal analogique)
```
---
## Configuration Serveur
### config.yaml complet
```yaml
audio:
# Backend (auto-détecté : coreaudio, jack, pipewire)
backend: auto
sampleRate: 48000
channels: 8 # Canaux utilisés
frameSize: 960 # 20ms @ 48kHz
inputDeviceId: 2 # Focusrite Scarlett (ID depuis getDevices())
outputDeviceId: 2
# Routing
routing:
inputToGroup:
0: ['regie']
1: ['regie']
2: ['scene']
3: ['scene']
4: ['foh']
5: ['foh']
groupToOutput:
regie: [0, 1]
scene: [2, 3]
foh: [4, 5]
gains:
in_0_regie: 0
in_1_regie: 0
scene_out_2: -6
scene_out_3: -6
# Groupes LiveKit
groups:
- id: regie
name: "Régie"
opusBitrate: 96000
- id: scene
name: "Scène"
opusBitrate: 96000
- id: foh
name: "Front of House"
opusBitrate: 128000
# LiveKit
livekit:
url: ws://localhost:7880
apiKey: ${LIVEKIT_API_KEY}
apiSecret: ${LIVEKIT_API_SECRET}
```
### Variables d'environnement
```bash
# .env
LIVEKIT_API_KEY=APIxxxxxxxxxxxxxxxx
LIVEKIT_API_SECRET=SECRETxxxxxxxxxxxxxx
```
---
## Compatibilité OS et Cartes Son
### macOS ✅
**Détection automatique via CoreAudio** :
- ✅ Cartes intégrées (MacBook Pro Mic/Speakers)
- ✅ USB Class Compliant (Focusrite, MOTU, PreSonus, Audient)
- ✅ Thunderbolt (RME, Universal Audio)
- ✅ Virtuelles (Dante DVS, Loopback, BlackHole)
**Test détection** :
```bash
cd server
node -e "
import CoreAudioBackend from './bridge/backends/CoreAudioBackend.js';
console.log(CoreAudioBackend.getDevices());
"
```
### Linux ✅
**Détection automatique via JACK ou PipeWire** :
#### JACK (audio pro)
```bash
# Liste ports disponibles
jack_lsp
# Exemple output :
# system:capture_1
# system:capture_2
# system:playback_1
# system:playback_2
```
#### PipeWire (moderne)
```bash
# Liste devices
pactl list sources short
pactl list sinks short
# Exemple :
# 0 alsa_input.usb-Focusrite_Scarlett_18i20
# 1 alsa_output.usb-Focusrite_Scarlett_18i20
```
**Cartes testées Linux** :
- ✅ Focusrite Scarlett série (USB)
- ✅ Behringer UMC série (USB)
- ✅ MOTU AVB série (USB/AVB)
- ✅ Dante Virtual Soundcard (via JACK bridge)
---
## Tests et Validation
### Test 1 : Détection cartes son
```bash
cd server
npm run test-audio-devices
```
**Résultat attendu** :
```
✓ Backend audio : CoreAudio (macOS natif)
📻 Devices audio détectés : 3
- MacBook Pro Microphone (in:1, out:0)
- MacBook Pro Speakers (in:0, out:2)
- Focusrite Scarlett 18i20 (in:18, out:20)
```
### Test 2 : Routing audio (loopback)
**Configuration test** :
```yaml
routing:
inputToGroup:
0: ['test']
groupToOutput:
test: [0]
```
**Résultat** : Le son capturé sur CH0 ressort immédiatement sur CH0 (attention feedback !).
### Test 3 : Flux complet avec client
1. **Démarrer serveur** :
```bash
cd server
npm start
```
2. **Connecter client PWA** :
- Ouvrir `https://localhost:5173`
- Sélectionner groupe "Régie"
- Appuyer sur PTT et parler
3. **Vérifier logs serveur** :
```
✓ Routing audio bidirectionnel actif
→ Carte Son → GroupRouter → LiveKit → Clients
groupAudioOut: groupe=regie, opusSize=120 bytes
```
4. **Écouter sur carte son** :
- Le son du client doit sortir sur les canaux configurés
---
## Performance
### Latence Typique (End-to-End)
| Étape | Latence |
|-------|---------|
| Carte son ADC | 1-5 ms |
| Backend buffer (960 samples) | 20 ms |
| GroupAudioRouter (processing) | <1 ms |
| Opus encode | 2-5 ms |
| LiveKit SFU | 10-30 ms |
| Réseau WiFi | 5-20 ms |
| Client WebRTC decode | 10-30 ms |
| **TOTAL** | **48-111 ms** ✅ |
**Objectif** : < 150ms (validé)
### CPU Usage (30 clients)
| Composant | CPU |
|-----------|-----|
| CoreAudioBackend | 2-5% |
| GroupAudioRouter | 1-3% |
| Opus encode/decode | 5-10% |
| LiveKit SFU | 10-20% |
| **TOTAL** | **18-38%** (8 cores) |
---
## Prochaines Étapes (TODO)
### Phase 3+ : Intégration LiveKit complète
**Option A : @livekit/rtc-node** (Recommandée)
```bash
npm install @livekit/rtc-node
```
Créer un `AudioSource` par groupe pour publier PCM directement.
**Option B : DataChannel**
Envoyer Opus via DataChannel LiveKit. Clients décodent manuellement.
**Option C : Participant virtuel par groupe**
Un "bot" LiveKit par groupe qui publie un MediaStream.
### Tests multi-canaux
- Tester avec carte 8+ canaux
- Routing complexe (plusieurs groupes vers même sortie)
- Monitoring niveaux temps réel (VU-mètres)
---
## Ressources
- [LIVEKIT_AUDIO_BRIDGE.md](./LIVEKIT_AUDIO_BRIDGE.md) : Guide intégration LiveKit serveur
- [DANTE_SETUP.md](./DANTE_SETUP.md) : Setup Dante Virtual Soundcard
- [AES67_SETUP.md](./AES67_SETUP.md) : Setup AES67/RAVENNA
- [DEPLOYMENT.md](./DEPLOYMENT.md) : Déploiement production
---
**Dernière mise à jour** : 2026-05-26
**Version** : 0.1.0 (Phase 3+)
+437
View File
@@ -0,0 +1,437 @@
# Configuration Dante avec PTT Live
Guide pour intégrer PTT Live avec des équipements Dante (Audinate)
## Vue d'ensemble
Dante (Digital Audio Network Through Ethernet) est un protocole audio professionnel sur IP largement utilisé dans l'événementiel et le broadcast. PTT Live peut s'interfacer avec des équipements Dante via JACK Audio Connection Kit.
### Architecture
```
[Équipements Dante] ←→ [Dante Virtual Soundcard (DVS)] ←→ [JACK] ←→ [PTT Live]
```
---
## Prérequis
### Matériel
- Mac ou PC avec interface réseau Ethernet (Gigabit recommandé)
- Équipements Dante (console, preamps, etc.)
- Switch réseau dédié (VLAN audio recommandé)
### Logiciel
- **Dante Virtual Soundcard** (~300€ licence personnelle)
- macOS 10.14+ ou Windows 10+
- Téléchargement : https://www.audinate.com/products/software/dante-virtual-soundcard
- **Dante Controller** (gratuit)
- Configuration et routing Dante
- Téléchargement : https://www.audinate.com/products/software/dante-controller
- **JACK Audio Connection Kit**
- macOS : `brew install jack` ou via JackPilot
- Linux : voir [install/linux.sh](../install/linux.sh)
- Windows : https://jackaudio.org/downloads/
---
## Installation
### 1. Installation Dante Virtual Soundcard (DVS)
1. Acheter et télécharger DVS depuis le site Audinate
2. Installer le package (.dmg sur macOS, .exe sur Windows)
3. Redémarrer l'ordinateur
4. Lancer DVS :
- **macOS** : `/Applications/Dante Virtual Soundcard.app`
- **Windows** : Menu Démarrer > Dante Virtual Soundcard
### 2. Configuration DVS
#### Paramètres recommandés pour PTT Live
| Paramètre | Valeur | Description |
|-----------|--------|-------------|
| **Latency** | 5-10 ms | Latence réseau (plus bas = moins de buffer) |
| **Sample Rate** | 48 kHz | Standard audio pro (requis par PTT Live) |
| **Encoding** | PCM 24-bit | Qualité maximale |
| **Channels** | 8-32 | Selon besoins (min 2 pour stéréo) |
**Configuration** :
1. Ouvrir Dante Virtual Soundcard
2. Onglet "Settings"
3. Définir les paramètres ci-dessus
4. Cliquer "Start" pour activer la carte virtuelle
### 3. Installation JACK
#### macOS
```bash
# Via Homebrew
brew install jack
# Ou télécharger JackPilot :
# http://www.jackosx.com/
```
#### Linux
```bash
# Ubuntu/Debian
sudo apt install jackd2 jack-tools qjackctl
# Arch Linux
sudo pacman -S jack2 qjackctl
```
#### Windows
Télécharger depuis https://jackaudio.org/downloads/ et installer.
### 4. Configuration JACK
#### Paramètres recommandés
| Paramètre | Valeur |
|-----------|--------|
| **Sample Rate** | 48000 Hz |
| **Buffer Size** | 256-512 samples (5-10ms) |
| **Periods** | 2-3 |
#### Via QjackCtl (GUI)
1. Lancer QjackCtl
2. Cliquer "Setup"
3. Configurer :
- **Driver** : CoreAudio (macOS), ALSA (Linux), PortAudio (Windows)
- **Sample Rate** : 48000
- **Frames/Period** : 256 ou 512
4. Cliquer "OK" puis "Start"
#### Via ligne de commande (macOS)
```bash
jackd -d coreaudio -r 48000 -p 512
```
#### Via ligne de commande (Linux)
```bash
jackd -d alsa -r 48000 -p 512
```
---
## Routing Audio
### 1. Dante Controller - Configuration réseau
1. Lancer Dante Controller
2. Vérifier que DVS apparaît dans la liste des devices (ex: "MacBook-DVS")
3. Configurer le routing Dante :
- **Sources** : équipements physiques (colonnes)
- **Destinations** : DVS (lignes)
- Cocher les cases pour router les canaux
**Exemple** :
- Console Dante (8 canaux) → DVS Input 1-8
- DVS Output 1-8 → Console Dante (8 canaux)
### 2. JACK - Connexion DVS ↔ PTT Live
#### Via QjackCtl (GUI)
1. Lancer PTT Live (voir ci-dessous)
2. Dans QjackCtl, cliquer "Graph" ou "Connections"
3. Connecter les ports :
- **Capture** : `DVS:capture_1``PTTLive:input_1`
- **Playback** : `PTTLive:output_1``DVS:playback_1`
#### Via jack_connect (CLI)
```bash
# Liste des ports disponibles
jack_lsp
# Connexion entrée Dante → PTT Live
jack_connect "DVS:capture_1" "PTTLive:input_1"
jack_connect "DVS:capture_2" "PTTLive:input_2"
# Connexion sortie PTT Live → Dante
jack_connect "PTTLive:output_1" "DVS:playback_1"
jack_connect "PTTLive:output_2" "DVS:playback_2"
```
---
## Démarrage PTT Live avec Dante
### 1. Ordre de démarrage recommandé
```
1. Démarrer le serveur JACK
2. Lancer Dante Virtual Soundcard
3. Configurer le routing dans Dante Controller
4. Démarrer le serveur PTT Live
5. Connecter les ports JACK (DVS ↔ PTT Live)
```
### 2. Lancer PTT Live
```bash
cd server
npm start
```
PTT Live détectera automatiquement JACK comme backend audio (sur Linux/macOS avec JACK actif).
### 3. Vérification
Dans les logs du serveur PTT Live, vous devriez voir :
```
✓ Backend audio : JACK (Linux professionnel)
📻 Devices audio détectés : X
- JACK System Capture (in:8, out:0)
- JACK System Playback (in:0, out:8)
```
---
## Configuration Multi-canaux
### Exemple : 8 canaux Dante ↔ 8 groupes PTT Live
#### 1. Configuration réseau Dante
Dans Dante Controller :
- Console OUT 1-8 → DVS Input 1-8
- DVS Output 1-8 → Console IN 1-8
#### 2. Configuration PTT Live
Éditer [server/config/config.yaml](../server/config/config.yaml) :
```yaml
audio:
backend: jack
sampleRate: 48000
channels: 8
routing:
inputs:
- name: "Canal 1 - Régie"
jackPort: "DVS:capture_1"
groups: ["regie"]
- name: "Canal 2 - Scene"
jackPort: "DVS:capture_2"
groups: ["scene"]
# ... etc
outputs:
- name: "Retour Régie"
jackPort: "DVS:playback_1"
groups: ["regie"]
- name: "Retour Scene"
jackPort: "DVS:playback_2"
groups: ["scene"]
# ... etc
groups:
- id: regie
name: "Régie"
inputChannels: [0]
outputChannels: [0]
- id: scene
name: "Scène"
inputChannels: [1]
outputChannels: [1]
# ... autres groupes
```
#### 3. Routing JACK automatique
Créer un script [server/scripts/connect-dante.sh](../server/scripts/connect-dante.sh) :
```bash
#!/bin/bash
# Connexion automatique JACK ↔ Dante
echo "Connexion des canaux Dante → PTT Live..."
for i in {1..8}; do
jack_connect "DVS:capture_$i" "PTTLive:input_$i"
jack_connect "PTTLive:output_$i" "DVS:playback_$i"
done
echo "✓ Routing JACK configuré"
```
```bash
chmod +x server/scripts/connect-dante.sh
./server/scripts/connect-dante.sh
```
---
## Monitoring et Troubleshooting
### Vérification du statut JACK
```bash
# Ports disponibles
jack_lsp
# Ports DVS (exemple)
DVS:capture_1
DVS:capture_2
DVS:playback_1
DVS:playback_2
# Connexions actives
jack_lsp -c
# Stats serveur JACK
jack_samplerate # Devrait afficher 48000
jack_bufsize # Devrait afficher 256 ou 512
```
### Problèmes courants
#### DVS ne s'affiche pas dans Dante Controller
**Cause** : Firewall ou réseau incorrect
**Solution** :
1. Vérifier que DVS est "Started" dans l'application
2. Désactiver temporairement le firewall
3. Vérifier que l'interface réseau est en Gigabit
4. Brancher sur le même switch que les équipements Dante
#### Latence élevée ou craquements audio
**Cause** : Buffer JACK trop petit ou latence Dante trop faible
**Solution** :
1. Augmenter le buffer JACK : 512 ou 1024 samples
2. Augmenter la latence DVS : 10ms au lieu de 5ms
3. Vérifier le trafic réseau (pas de flood broadcast)
#### Pas de son entre PTT Live et Dante
**Cause** : Ports JACK non connectés
**Solution** :
```bash
# Vérifier les connexions
jack_lsp -c
# Reconnecter manuellement
jack_connect "DVS:capture_1" "PTTLive:input_1"
jack_connect "PTTLive:output_1" "DVS:playback_1"
```
#### PTT Live ne détecte pas JACK
**Cause** : Serveur JACK non démarré avant PTT Live
**Solution** :
1. Arrêter PTT Live
2. Vérifier que JACK tourne : `jack_lsp` (ne doit pas donner d'erreur)
3. Relancer PTT Live
---
## Configuration Réseau Recommandée
### VLAN Audio (optionnel mais recommandé)
Pour isoler le trafic Dante du reste du réseau :
| Paramètre | Valeur |
|-----------|--------|
| **VLAN ID** | 10 (exemple) |
| **Subnet** | 192.168.10.0/24 |
| **QoS/DSCP** | EF (Expedited Forwarding) |
| **IGMP Snooping** | Activé |
| **Jumbo Frames** | Activé (MTU 9000) |
### Switch manageable
Fonctionnalités requises :
- VLAN tagging
- QoS/DSCP
- IGMP snooping
- Gigabit Ethernet (min)
Modèles testés :
- Netgear M4300 series
- Cisco SG350/SG550
- Ubiquiti EdgeSwitch
---
## Latence End-to-End
### Budget latence typique
| Étape | Latence |
|-------|---------|
| Dante network | 5-10 ms |
| DVS | 2-5 ms |
| JACK | 5-10 ms (256 samples @ 48kHz) |
| PTT Live bridge | 20-40 ms (jitter buffer) |
| WebRTC client | 30-100 ms |
| **TOTAL** | **62-165 ms** |
Objectif : < 150ms end-to-end (validé en Phase 1)
### Optimisation
Pour réduire la latence :
1. Dante latency : 2-5ms (au lieu de 10ms)
2. JACK buffer : 128 samples (au lieu de 512)
3. PTT Live jitter buffer : preset "ULTRA_LOW" (20ms au lieu de 40ms)
**Attention** : Latence trop faible = risque de craquements audio si réseau/CPU chargé.
---
## Coût et Licences
| Élément | Prix | Licence |
|---------|------|---------|
| **Dante Virtual Soundcard** | ~300€ | Par poste (licence personnelle) |
| **Dante Controller** | Gratuit | - |
| **JACK** | Gratuit | Open Source (GPL) |
| **PTT Live** | Gratuit | Open Source |
**Note** : Pour un déploiement multi-postes, chaque ordinateur exécutant DVS nécessite sa propre licence.
---
## Alternatives
### AES67 (sans Dante Virtual Soundcard)
Si le budget DVS est un problème, voir [AES67_SETUP.md](./AES67_SETUP.md) pour utiliser le protocole AES67 natif (interopérable avec Dante).
**Avantages** :
- Gratuit (pas de licence DVS)
- Standard ouvert
**Inconvénients** :
- Configuration plus complexe
- Support PTP sync requis
- Moins de GUI (configuration CLI)
---
## Support et Ressources
- **Dante Academy** : https://www.audinate.com/learning/training-certification/dante-certification-program
- **JACK Documentation** : https://jackaudio.org/faq/
- **PTT Live Issues** : https://github.com/username/ptt-live/issues
---
**Dernière mise à jour** : 2026-05-26
**Version PTT Live** : 0.1.0 (Phase 3)
+810
View File
@@ -0,0 +1,810 @@
# Guide de Déploiement Production - PTT Live
Guide complet pour déployer PTT Live en environnement professionnel événementiel.
## Vue d'ensemble
Ce guide couvre le déploiement de PTT Live pour une utilisation en production avec :
- 30+ clients simultanés
- Réseau WiFi dédié
- Cartes son multi-canaux / Dante / AES67
- Optimisations performance et latence
- Monitoring et troubleshooting
---
## Architecture Production Recommandée
```
┌─────────────────┐
│ Switch Core │
│ (Manageable) │
└────────┬────────┘
┌─────────────────────┼─────────────────────┐
│ │ │
┌────────▼────────┐ ┌───────▼────────┐ ┌───────▼────────┐
│ VLAN 10 AUDIO │ │ VLAN 20 WIFI │ │ VLAN 30 MGMT │
│ (Dante/AES67) │ │ (Clients PTT) │ │ (Admin/Logs) │
└────────┬────────┘ └───────┬────────┘ └───────┬────────┘
│ │ │
┌────────▼────────┐ ┌───────▼────────┐ ┌───────▼────────┐
│ Equipements │ │ Access Points │ │ Laptop Admin │
│ Audio Pro │ │ WiFi 5/6 │ │ (Monitoring) │
│ (Console, etc) │ │ (5GHz) │ │ │
└─────────────────┘ └────────────────┘ └────────────────┘
┌────────▼────────┐
│ Serveur PTT │
│ Live │
│ - LiveKit │
│ - AudioBridge │
│ - API/Admin │
└─────────────────┘
```
---
## Prérequis Matériel
### Serveur PTT Live
**Spécifications minimales** (30 clients) :
| Composant | Minimum | Recommandé |
|-----------|---------|------------|
| **CPU** | 4 cores, 2.5GHz | 8 cores, 3.0GHz+ |
| **RAM** | 8 GB | 16 GB+ |
| **Réseau** | 1 Gbps Ethernet | 10 Gbps ou dual 1Gbps (bonding) |
| **Stockage** | 50 GB SSD | 100 GB NVMe SSD |
| **OS** | Ubuntu 22.04 LTS | Ubuntu 22.04 LTS Server |
| **Audio** | Carte son 8+ canaux | Interface Dante/AES67 |
**Exemples configurations** :
- **Budget** : Mac Mini M1 (2020) - 8GB RAM, 256GB SSD
- **Standard** : Intel NUC i7 - 16GB RAM, 512GB SSD
- **Pro** : Dell R240 Server - Xeon E-2224, 32GB ECC, RAID SSD
### Réseau
#### Switch Core
**Requis** :
- Manageable (VLAN, QoS, IGMP)
- Gigabit minimum (10G recommandé pour Dante/AES67)
- PTP support (si AES67)
- Backplane suffisant (480 Gbps+)
- Redondance alimentation (si critique)
**Modèles testés** :
- Netgear M4300-8X8F (8x 10G + 8x 1G)
- Cisco SG350-28P
- Ubiquiti EdgeSwitch 24
#### Access Points WiFi
**Spécifications** :
| Paramètre | Valeur |
|-----------|--------|
| **Standard** | WiFi 5 (802.11ac) minimum, WiFi 6 (ax) recommandé |
| **Bande** | 5 GHz dédiée (moins de congestion) |
| **Canaux** | 40 MHz ou 80 MHz |
| **Débit** | 867 Mbps+ par client |
| **Clients** | 30+ par AP (répartir si plus) |
| **Roaming** | 802.11r/k/v (fast roaming) |
**Modèles recommandés** :
- Ubiquiti UniFi 6 LR / PRO
- Aruba AP-515 / AP-555
- Cisco Meraki MR46 / MR56
**Déploiement** :
- 1 AP pour 10-15 clients actifs simultanés
- Positionnement stratégique (hauteur, line-of-sight)
- Survey WiFi préalable (éviter interférences)
### Cartes Son / Interfaces Audio
**Options** :
1. **Carte son USB/Thunderbolt multi-canaux**
- MOTU UltraLite mk5 (18x22, USB-C)
- RME Fireface UCX II (40 canaux, USB 2.0/3.0)
- Focusrite Clarett 8PreX (26x28, Thunderbolt)
2. **Interface Dante**
- Focusrite RedNet PCIe (32+ canaux)
- Audinate AVIO Adapter
- Console avec Dante intégré
3. **AES67 natif**
- Merging RAVENNA/AES67 (Linux ALSA driver)
- Lawo mc² Console
- Calrec Artemis/Apollo
---
## Installation Production
### 1. Préparation Serveur
#### Ubuntu Server 22.04 LTS
```bash
# Mise à jour système
sudo apt update && sudo apt upgrade -y
# Installation dépendances
sudo apt install -y \
build-essential \
git \
curl \
htop \
net-tools \
ethtool \
iftop \
iperf3
# Désactiver économie énergie CPU
sudo apt install linux-tools-common linux-tools-generic
sudo cpupower frequency-set -g performance
# Config persistence
echo "performance" | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
```
#### Optimisations réseau
Éditer `/etc/sysctl.conf` :
```bash
# Buffers réseau
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
net.core.rmem_default = 16777216
net.core.wmem_default = 16777216
# TCP
net.ipv4.tcp_rmem = 4096 87380 134217728
net.ipv4.tcp_wmem = 4096 65536 134217728
net.ipv4.tcp_congestion_control = bbr
# Multicast
net.ipv4.igmp_max_memberships = 512
# Connections tracking
net.netfilter.nf_conntrack_max = 1000000
net.netfilter.nf_conntrack_tcp_timeout_established = 7200
```
Appliquer :
```bash
sudo sysctl -p
```
#### Firewall
```bash
# UFW (Ubuntu Firewall)
sudo ufw allow 22/tcp # SSH
sudo ufw allow 3000/tcp # API PTT Live
sudo ufw allow 5173/tcp # Client Vite (dev)
sudo ufw allow 7880/tcp # LiveKit WebSocket
sudo ufw allow 7881/tcp # LiveKit TURN
sudo ufw allow 50000:60000/udp # LiveKit RTC
sudo ufw enable
```
### 2. Installation PTT Live
```bash
# Clone du repo
cd /opt
sudo git clone https://github.com/votre-user/PTT-Live.git
sudo chown -R $USER:$USER PTT-Live
cd PTT-Live
# Installation selon OS
./install/linux.sh # Linux
# ou
./install/macos.sh # macOS
```
### 3. Configuration Audio
#### Option A : Carte son USB (CoreAudio/ALSA)
```bash
# Lister les cartes
aplay -l # Linux
system_profiler SPAudioDataType # macOS
# Éditer config PTT Live
nano server/config/config.yaml
```
```yaml
audio:
backend: auto # coreaudio (macOS) ou pipewire/jack (Linux)
sampleRate: 48000
channels: 8
inputDeviceId: 0 # ID de la carte (voir logs au démarrage)
outputDeviceId: 0
```
#### Option B : Dante (via JACK)
Voir [DANTE_SETUP.md](./DANTE_SETUP.md)
#### Option C : AES67 (Linux)
Voir [AES67_SETUP.md](./AES67_SETUP.md)
### 4. Configuration LiveKit
Éditer `server/config/livekit.yaml` :
```yaml
port: 7880
bind_addresses:
- 0.0.0.0 # Écoute sur toutes les interfaces
rtc:
port_range_start: 50000
port_range_end: 60000
use_external_ip: false # true si NAT
# external_ip: "votre.ip.publique" # Si use_external_ip: true
turn:
enabled: true
domain: ""
tls_port: 5349
udp_port: 3478
keys:
# IMPORTANT : Générer des clés uniques en production !
# Ne PAS utiliser les clés de développement
api_key: "APIxxxxxxxxxxxxxxxx" # Générer avec : openssl rand -base64 32
api_secret: "SECRETxxxxxxxxxxxxxxxx"
logging:
level: info # debug, info, warn, error
sample: true
```
**Générer des clés sécurisées** :
```bash
# API Key
echo "API_KEY=$(openssl rand -base64 24)" | tee -a server/.env
# API Secret
echo "API_SECRET=$(openssl rand -base64 48)" | tee -a server/.env
```
### 5. Configuration Groupes et Routing
Éditer `server/config/config.yaml` :
```yaml
groups:
- id: regie
name: "Régie"
inputChannels: [0, 1] # Canaux audio physiques (carte son)
outputChannels: [0, 1]
opusBitrate: 96000 # 96 kbps (voix standard)
- id: scene
name: "Scène"
inputChannels: [2, 3]
outputChannels: [2, 3]
opusBitrate: 96000
- id: foh
name: "FOH"
inputChannels: [4, 5]
outputChannels: [4, 5]
opusBitrate: 96000
- id: broadcast
name: "Broadcast"
inputChannels: [6, 7]
outputChannels: [6, 7]
opusBitrate: 128000 # 128 kbps (qualité supérieure)
routing:
# Configuration gains par route (optionnel)
input_gains:
regie: 0 # 0 dB (unity)
scene: -3 # -3 dB
foh: 0
broadcast: -6 # -6 dB
output_gains:
regie: 0
scene: 0
foh: -3
broadcast: 0
```
---
## Démarrage Production
### Services Systemd
#### Service PTT Live Server
Créer `/etc/systemd/system/pttlive-server.service` :
```ini
[Unit]
Description=PTT Live Server
After=network.target
[Service]
Type=simple
User=pttlive
Group=audio
WorkingDirectory=/opt/PTT-Live/server
Environment="NODE_ENV=production"
EnvironmentFile=/opt/PTT-Live/server/.env
ExecStart=/usr/bin/node index.js
Restart=always
RestartSec=10
# Limites
LimitNOFILE=65536
LimitNPROC=4096
# Logs
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
```
#### Service PTT Live Client (si servi via Node)
Créer `/etc/systemd/system/pttlive-client.service` :
```ini
[Unit]
Description=PTT Live Client (HTTP Server)
After=network.target
[Service]
Type=simple
User=pttlive
WorkingDirectory=/opt/PTT-Live/client
ExecStart=/usr/bin/npm run preview # Vite preview (prod build)
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
#### Activation
```bash
# Créer utilisateur dédié
sudo useradd -r -s /bin/false -G audio pttlive
sudo chown -R pttlive:audio /opt/PTT-Live
# Activer services
sudo systemctl daemon-reload
sudo systemctl enable pttlive-server pttlive-client
sudo systemctl start pttlive-server pttlive-client
# Vérifier statut
sudo systemctl status pttlive-server
sudo journalctl -u pttlive-server -f # Logs temps réel
```
---
## Configuration Réseau Production
### VLAN et QoS
#### Configuration Switch (exemple CLI Cisco/HP)
```bash
# VLAN Audio (Dante/AES67)
vlan 10
name AUDIO
qos dscp 46 # EF (Expedited Forwarding)
# VLAN WiFi Clients
vlan 20
name WIFI_CLIENTS
qos dscp 34 # AF41 (Assured Forwarding)
# VLAN Management
vlan 30
name MGMT
# Ports
interface range gigabitethernet 1/0/1-8
switchport mode access
switchport access vlan 10
spanning-tree portfast
interface range gigabitethernet 1/0/9-16
switchport mode trunk
switchport trunk allowed vlan 20,30
# QoS global
mls qos
mls qos map dscp-cos 46 to 6 # Audio prioritaire
```
### IGMP Snooping
Pour multicast (Dante/AES67) :
```bash
# Cisco
ip igmp snooping
ip igmp snooping vlan 10 immediate-leave
ip igmp snooping vlan 10 last-member-query-interval 100
# HP/Aruba
vlan 10
ip igmp
ip igmp querier
```
### WiFi Optimisations
#### Configuration Access Point (Ubiquiti UniFi)
```json
{
"networks": [
{
"name": "PTT_Live_5G",
"wlan_band": "5g",
"wpa_mode": "wpa2",
"wpa_enc": "ccmp",
"channel": 36, // Ou 149 (selon région)
"channel_width": 80,
"dtim_mode": "default",
"fast_roaming_enabled": true,
"vlan": 20,
"uapsd_enabled": true, // Power save
"multicast_enhance": true,
"airtime_fairness": true
}
]
}
```
**Paramètres clés** :
- **Fast Roaming (802.11r)** : Activé (handoff < 50ms)
- **Band Steering** : Désactivé (forcer 5GHz)
- **Multicast Enhancement** : Activé (convertit multicast → unicast)
- **Airtime Fairness** : Activé (évite qu'un client lent ralentisse tous)
- **DTIM** : 1-3 (compromis latence/batterie)
---
## Monitoring et Logs
### Monitoring Système
#### Prometheus + Grafana (optionnel mais recommandé)
```bash
# Installation Prometheus
sudo apt install prometheus prometheus-node-exporter
# Installation Grafana
sudo apt install -y software-properties-common
sudo add-apt-repository "deb https://packages.grafana.com/oss/deb stable main"
wget -q -O - https://packages.grafana.com/gpg.key | sudo apt-key add -
sudo apt update
sudo apt install grafana
sudo systemctl enable grafana-server prometheus
sudo systemctl start grafana-server prometheus
```
Accès Grafana : `http://serveur:3000` (admin/admin)
**Métriques à surveiller** :
- CPU usage
- RAM usage
- Network throughput (RX/TX)
- JACK xruns (si JACK)
- LiveKit room stats (participants, bitrate)
- Audio latency
#### Dashboard Grafana PTT Live
Créer un dashboard avec :
- Participants actifs par groupe
- Bitrate audio moyen
- Packet loss WebRTC
- Latence end-to-end (si sonde)
### Logs Centralisés
#### rsyslog vers serveur central (optionnel)
```bash
# /etc/rsyslog.d/50-pttlive.conf
if $programname == 'pttlive-server' then @@log-server:514
& stop
```
---
## Tests de Charge
### Outils
1. **LoadBot** (LiveKit officiel)
```bash
# Installation
go install github.com/livekit/livekit-cli/cmd/livekit-load-tester@latest
# Test 30 participants
livekit-load-tester \
--url ws://serveur:7880 \
--api-key APIxxxxxx \
--api-secret SECRETxxxxxx \
--room test-room \
--publishers 30 \
--duration 10m
```
2. **iperf3** (test bande passante réseau)
```bash
# Serveur
iperf3 -s
# Client
iperf3 -c serveur -t 60 -P 10 # 10 streams parallèles, 60s
```
### Scénarios de Test
#### Test 1 : Connexion 30 clients
**Objectif** : Tous les clients se connectent et rejoignent des groupes différents.
**Métriques** :
- Temps de connexion < 2s par client
- CPU serveur < 60%
- RAM < 8GB
#### Test 2 : PTT simultanés (10 clients parlent en même temps)
**Objectif** : Vérifier que le serveur gère 10 flux audio upstream simultanés.
**Métriques** :
- Latence audio < 150ms
- Packet loss < 1%
- Pas de xruns JACK
#### Test 3 : Endurance (4 heures)
**Objectif** : Stabilité longue durée.
**Métriques** :
- Pas de memory leak (RAM stable)
- Pas de crash
- Reconnexion automatique si perte WiFi
---
## Troubleshooting Production
### Problème : Latence élevée (> 200ms)
**Diagnostics** :
```bash
# Latence réseau (ping)
ping -i 0.2 serveur # < 5ms attendu en WiFi local
# Traceroute
traceroute serveur
# Jitter
iperf3 -c serveur -u -b 1M # Jitter < 5ms
```
**Causes possibles** :
- WiFi congestionné (trop de clients/AP)
- Buffer JACK trop grand
- Jitter buffer PTT Live trop conservateur
- CPU serveur saturé
**Solutions** :
- Réduire buffer JACK : 256 → 128 samples
- PTT Live jitter buffer : preset "ULTRA_LOW"
- Ajouter un AP WiFi (répartir charge)
### Problème : Coupures audio
**Diagnostics** :
```bash
# JACK xruns
jack_evmon
# Logs PTT Live
sudo journalctl -u pttlive-server -f | grep -i error
# Stats réseau
iftop -i eth0
```
**Causes** :
- Xruns JACK (CPU overload)
- Packet loss réseau
- Buffer underrun
**Solutions** :
- Augmenter buffer JACK : 256 → 512
- Vérifier trafic réseau (pas de broadcast storm)
- Isoler CPU cores (kernel parameter `isolcpus=2,3`)
### Problème : Clients ne se connectent pas
**Diagnostics** :
```bash
# Firewall
sudo ufw status
# Ports LiveKit
sudo netstat -tulpn | grep 7880
# Logs LiveKit
sudo journalctl -u pttlive-server | grep livekit
```
**Solutions** :
- Vérifier firewall (ports 7880, 50000-60000)
- Vérifier clés API (`.env` correct)
- Tester en local : `curl http://localhost:3000/api/health`
---
## Sécurité
### HTTPS (obligatoire pour PWA)
#### Certificat auto-signé (dev/LAN)
```bash
# Générer certificat
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
# Configurer Node.js (serveur API)
# Éditer server/index.js
import https from 'https';
import fs from 'fs';
const options = {
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem')
};
https.createServer(options, app).listen(3443);
```
#### Certificat Let's Encrypt (production Internet)
```bash
sudo apt install certbot
# Domaine public requis
sudo certbot certonly --standalone -d pttlive.votredomaine.com
# Certificats dans /etc/letsencrypt/live/pttlive.votredomaine.com/
```
### Authentification
#### Tokens JWT
Éditer `server/api/auth.js` :
```javascript
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET; // Générer avec openssl rand -base64 64
function generateToken(user) {
return jwt.sign(
{ id: user.id, name: user.name, groups: user.groups },
SECRET,
{ expiresIn: '24h' }
);
}
function verifyToken(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
jwt.verify(token, SECRET, (err, decoded) => {
if (err) return res.status(403).json({ error: 'Invalid token' });
req.user = decoded;
next();
});
}
```
---
## Checklist Pré-Événement
### 24h avant
- [ ] Mise à jour système serveur (`apt update && apt upgrade`)
- [ ] Vérifier espace disque (`df -h`, > 20% libre)
- [ ] Test connexion tous les équipements audio
- [ ] Survey WiFi (vérifier pas d'interférences)
- [ ] Backup config (`cp -r /opt/PTT-Live/server/config /backup/`)
### 2h avant
- [ ] Démarrer serveur PTT Live
- [ ] Vérifier logs (`journalctl -u pttlive-server`)
- [ ] Test connexion 2 clients (1 par groupe minimum)
- [ ] Test PTT bidirectionnel
- [ ] Mesurer latence (< 150ms)
- [ ] Charger smartphones clients (100% batterie)
### Pendant l'événement
- [ ] Monitoring CPU/RAM (Grafana ou `htop`)
- [ ] Logs temps réel (`journalctl -f`)
- [ ] Laptop admin disponible (SSH serveur)
- [ ] Smartphone de secours (backup PTT)
---
## Performances Attendues
### Charge Serveur (30 clients)
| Métrique | Valeur Typique |
|----------|----------------|
| CPU Usage | 30-50% (8 cores) |
| RAM Usage | 4-6 GB |
| Network RX | 5-10 Mbps (upstream audio) |
| Network TX | 50-150 Mbps (downstream audio broadcast) |
| JACK Xruns | 0 (toléré : < 1/heure) |
### Latence End-to-End
| Composant | Latence |
|-----------|---------|
| WiFi (client → serveur) | 5-20 ms |
| WebRTC encode/decode | 20-60 ms |
| Jitter buffer | 20-40 ms |
| Audio backend (JACK/CoreAudio) | 5-10 ms |
| Dante/AES67 (si utilisé) | 5-10 ms |
| **TOTAL** | **55-140 ms** ✅ |
Objectif validé : < 150ms
---
## Support et Ressources
- **Documentation** : `/opt/PTT-Live/docs/`
- **Issues GitHub** : https://github.com/votre-user/ptt-live/issues
- **LiveKit Docs** : https://docs.livekit.io/
- **JACK Audio** : https://jackaudio.org/faq/
---
**Dernière mise à jour** : 2026-05-26
**Version** : 0.1.0 (Phase 3)
+488
View File
@@ -0,0 +1,488 @@
# LiveKit Audio Bridge - Intégration Cartes Son macOS
Guide pour connecter les cartes son macOS au serveur LiveKit via le bridge audio.
## Problème Actuel
Le code actuel utilise `livekit-client` (SDK navigateur) qui nécessite des `MediaStreamTrack` (API Web Audio). Sur Node.js serveur, nous avons des **buffers PCM** provenant de CoreAudio/JACK, pas de MediaStream.
### Architecture Actuelle (Incomplète)
```
[Carte Son macOS] → CoreAudio → PCM Buffer → OpusCodec → ??? → LiveKit → Clients WebRTC
MANQUANT
```
## Solution : Utiliser LiveKit Server SDK
LiveKit propose 2 SDKs :
- **livekit-client** : Pour navigateurs (MediaStream, WebRTC natif)
- **livekit-server-sdk** : Pour serveurs Node.js (contrôle bas niveau)
### Installation
```bash
cd server
npm install livekit-server-sdk
npm install @livekit/rtc-node # Bindings natifs pour audio/video
```
---
## Implémentation : LiveKitServerBridge.js
Créer un nouveau module pour le bridge serveur :
```javascript
// server/bridge/LiveKitServerBridge.js
import { RoomServiceClient, AccessToken, TrackSource } from 'livekit-server-sdk';
import { Room, LocalAudioTrack, AudioSource } from '@livekit/rtc-node';
import { EventEmitter } from 'events';
export class LiveKitServerBridge extends EventEmitter {
constructor(options = {}) {
super();
this.options = {
url: options.url || 'ws://localhost:7880',
apiKey: options.apiKey || 'APIxxxxxx',
apiSecret: options.apiSecret || 'SECRETxxxxxx',
roomName: options.roomName || 'main',
participantName: options.participantName || 'AudioBridge',
sampleRate: options.sampleRate || 48000,
channels: options.channels || 1,
...options
};
this.room = null;
this.audioSource = null;
this.audioTrack = null;
this.isPublishing = false;
}
/**
* Connexion à la room LiveKit en tant que participant serveur
*/
async connect() {
try {
// Générer token pour le bridge
const token = new AccessToken(
this.options.apiKey,
this.options.apiSecret,
{
identity: this.options.participantName,
name: 'Audio Bridge Server',
ttl: '24h'
}
);
token.addGrant({
room: this.options.roomName,
roomJoin: true,
canPublish: true,
canSubscribe: true
});
const jwt = token.toJwt();
// Connexion à la room
this.room = new Room();
await this.room.connect(this.options.url, jwt);
console.log(`✓ Bridge connecté à LiveKit room "${this.options.roomName}"`);
this.emit('connected');
// Écouter les participants distants
this._setupRoomListeners();
} catch (error) {
console.error('Erreur connexion LiveKit:', error);
throw error;
}
}
/**
* Créer et publier un track audio depuis la carte son
*/
async publishAudioTrack() {
if (!this.room) {
throw new Error('Room non connectée');
}
try {
// Créer une source audio custom
this.audioSource = new AudioSource(
this.options.sampleRate,
this.options.channels
);
// Créer un track audio local
this.audioTrack = LocalAudioTrack.createAudioTrack(
'bridge-audio',
this.audioSource
);
// Publier le track dans la room
await this.room.localParticipant.publishTrack(this.audioTrack, {
source: TrackSource.MICROPHONE,
name: 'Audio Bridge'
});
this.isPublishing = true;
console.log('✓ Track audio bridge publié');
this.emit('trackPublished');
} catch (error) {
console.error('Erreur publication track:', error);
throw error;
}
}
/**
* Envoie des données PCM au track LiveKit
* @param {Buffer} pcmData - Buffer PCM 16-bit (depuis CoreAudio/JACK)
*/
async sendPCMAudio(pcmData) {
if (!this.audioSource || !this.isPublishing) {
console.warn('AudioSource non prête ou track non publié');
return;
}
try {
// Convertir Buffer Node.js → AudioFrame
// PCM 16-bit signed little-endian
const numSamples = pcmData.length / 2; // 2 bytes per sample (16-bit)
// Envoyer au track LiveKit
await this.audioSource.captureFrame({
data: pcmData,
sampleRate: this.options.sampleRate,
numChannels: this.options.channels,
samplesPerChannel: numSamples / this.options.channels
});
} catch (error) {
console.error('Erreur envoi PCM:', error);
this.emit('error', error);
}
}
/**
* Écoute les participants et leurs tracks audio
*/
_setupRoomListeners() {
this.room.on('participantConnected', (participant) => {
console.log(`Participant connecté: ${participant.identity}`);
this.emit('participantConnected', participant);
});
this.room.on('trackSubscribed', (track, publication, participant) => {
if (track.kind === 'audio') {
console.log(`Track audio reçu de ${participant.identity}`);
this._handleRemoteAudioTrack(track, participant);
}
});
this.room.on('trackUnsubscribed', (track, publication, participant) => {
if (track.kind === 'audio') {
console.log(`Track audio perdu de ${participant.identity}`);
this.emit('audioTrackUnsubscribed', { track, participant });
}
});
}
/**
* Gère la réception d'un track audio distant (client PWA)
* @param {RemoteAudioTrack} track - Track audio du client
*/
_handleRemoteAudioTrack(track, participant) {
// Recevoir les frames audio
track.on('frame', async (frame) => {
// frame contient les données PCM du client
// On peut les envoyer à la carte son via CoreAudio/JACK
this.emit('remotePCMData', {
data: frame.data,
sampleRate: frame.sampleRate,
channels: frame.numChannels,
participant
});
});
this.emit('audioTrackSubscribed', { track, participant });
}
/**
* Arrête la publication du track audio
*/
async unpublishAudioTrack() {
if (this.audioTrack) {
await this.room.localParticipant.unpublishTrack(this.audioTrack);
this.audioTrack = null;
this.audioSource = null;
this.isPublishing = false;
console.log('✓ Track audio dépublié');
}
}
/**
* Déconnexion de la room
*/
async disconnect() {
await this.unpublishAudioTrack();
if (this.room) {
await this.room.disconnect();
this.room = null;
}
console.log('✓ Bridge LiveKit déconnecté');
this.emit('disconnected');
}
/**
* Récupère les statistiques
*/
getStats() {
if (!this.room) return null;
return {
connected: !!this.room,
publishing: this.isPublishing,
participants: this.room.remoteParticipants.size,
roomName: this.options.roomName
};
}
}
export default LiveKitServerBridge;
```
---
## Mise à Jour AudioBridge.js
Remplacer `LiveKitClient` par `LiveKitServerBridge` :
```javascript
// server/bridge/AudioBridge.js
import LiveKitServerBridge from './LiveKitServerBridge.js';
// ...
async _initLiveKit() {
this.liveKitClient = new LiveKitServerBridge({
url: this.options.liveKitUrl,
apiKey: this.options.liveKitApiKey,
apiSecret: this.options.liveKitApiSecret,
roomName: this.options.roomName,
sampleRate: this.options.sampleRate,
channels: this.options.channels
});
// Events
this.liveKitClient.on('connected', () => {
console.log('✓ Bridge LiveKit connecté');
});
this.liveKitClient.on('audioTrackSubscribed', ({ track, participant }) => {
console.log(`Audio reçu de ${participant.identity}`);
});
this.liveKitClient.on('remotePCMData', ({ data, participant }) => {
// Envoyer PCM à la carte son
this.audioBackend.queueAudio(data);
});
await this.liveKitClient.connect();
await this.liveKitClient.publishAudioTrack();
}
async _startAudioRouting() {
// CAPTURE : Carte son → LiveKit
this.audioBackend.on('audioData', async (pcmData) => {
try {
// Envoyer directement le PCM à LiveKit
// LiveKit gère l'encodage Opus en interne
await this.liveKitClient.sendPCMAudio(pcmData);
this.stats.framesCapture++;
} catch (error) {
console.error('Erreur routing capture:', error);
}
});
await this.audioBackend.startCapture();
await this.audioBackend.startPlayback();
}
```
---
## Configuration Serveur
### Variables d'environnement
```bash
# server/.env
LIVEKIT_API_KEY=APIxxxxxxxxxxxxxx
LIVEKIT_API_SECRET=SECRETxxxxxxxxxxxxxx
LIVEKIT_URL=ws://localhost:7880
```
Générer les clés :
```bash
# API Key (24 bytes base64)
openssl rand -base64 24
# API Secret (48 bytes base64)
openssl rand -base64 48
```
### Configuration LiveKit Server
Éditer `server/config/livekit.yaml` :
```yaml
port: 7880
rtc:
port_range_start: 50000
port_range_end: 60000
use_external_ip: false
keys:
# Utiliser les mêmes clés que .env
APIxxxxxxxxxxxxxx: SECRETxxxxxxxxxxxxxx
logging:
level: info
```
---
## Alternative : Sans @livekit/rtc-node (Pure JavaScript)
Si l'installation de bindings natifs pose problème, utiliser **DataChannel** pour envoyer les données Opus :
```javascript
// server/bridge/LiveKitDataBridge.js
import { RoomServiceClient, DataPacket_Kind } from 'livekit-server-sdk';
export class LiveKitDataBridge {
async sendOpusData(opusData, groupId) {
// Envoyer via DataChannel
const packet = {
kind: DataPacket_Kind.RELIABLE,
destinationSids: [], // Broadcast à tous
payload: opusData,
topic: `audio-${groupId}`
};
await this.room.localParticipant.publishData(
packet.payload,
packet.kind,
packet.destinationSids
);
}
}
```
**Avantage** : Pas de bindings natifs.
**Inconvénient** : Les clients doivent décoder Opus manuellement (pas de lecture audio automatique).
---
## Tests macOS
### 1. Vérifier carte son détectée
```bash
cd server
node -e "
import CoreAudioBackend from './bridge/backends/CoreAudioBackend.js';
const devices = CoreAudioBackend.getDevices();
console.log(devices);
"
```
### 2. Test bridge complet
```bash
# Terminal 1 : Serveur LiveKit
cd server/bin
./livekit-server --dev --config ../config/livekit.yaml
# Terminal 2 : Bridge audio
cd server
npm run dev
# Terminal 3 : Client test
cd client
npm run dev
```
Ouvrir `http://localhost:5173`, se connecter et appuyer sur PTT.
### 3. Vérifier flux audio
```bash
# Logs bridge
tail -f server/logs/bridge.log | grep "sendPCMAudio"
# Devrait afficher :
# sendPCMAudio: 960 samples @ 48000Hz
```
---
## Compatibilité Cartes Son macOS
### Cartes testées
| Modèle | Statut | Notes |
|--------|--------|-------|
| MacBook Pro Mic/Speaker | ✅ | Native CoreAudio |
| Focusrite Scarlett 2i2 | ✅ | USB Class Compliant |
| MOTU UltraLite mk5 | ✅ | USB-C, 18x22 canaux |
| RME Fireface UCX | ✅ | USB 2.0/3.0 |
| Audient iD14 | ✅ | USB-C |
| Universal Audio Apollo | ⚠️ | Nécessite pilotes UA |
| PreSonus Studio 24c | ✅ | USB-C |
### Problèmes courants
**Carte non détectée** :
```bash
# Vérifier MIDI/Audio Setup
open /System/Applications/Utilities/Audio\ MIDI\ Setup.app
# Vérifier sample rate
system_profiler SPAudioDataType
```
**Latence élevée** :
Réduire `framesPerBuffer` dans `config.yaml` :
```yaml
audio:
framesPerBuffer: 128 # Au lieu de 256 ou 512
```
---
## Prochaines Étapes
1. ✅ Installer `@livekit/rtc-node`
2. ✅ Créer `LiveKitServerBridge.js`
3. ✅ Remplacer dans `AudioBridge.js`
4. ✅ Configurer `.env` avec clés LiveKit
5. ⏳ Tester avec carte son macOS réelle
6. ⏳ Mesurer latence end-to-end (objectif < 150ms)
---
**Dernière mise à jour** : 2026-05-26
**Version** : 0.1.0 (Phase 3+)
+854
View File
@@ -0,0 +1,854 @@
# Guide de Troubleshooting - PTT Live
Guide de diagnostic et résolution des problèmes courants.
---
## Table des matières
1. [Problèmes Audio](#problèmes-audio)
2. [Problèmes Réseau](#problèmes-réseau)
3. [Problèmes Client (PWA)](#problèmes-client-pwa)
4. [Problèmes Serveur](#problèmes-serveur)
5. [Problèmes JACK/Audio Backend](#problèmes-jackaudio-backend)
6. [Problèmes Dante/AES67](#problèmes-danteaes67)
7. [Outils de Diagnostic](#outils-de-diagnostic)
---
## Problèmes Audio
### Pas de son (aucun audio)
#### Symptômes
- Client parle (bouton PTT activé) mais personne n'entend
- Pas d'indicateur audio visuel
#### Diagnostic
```bash
# 1. Vérifier backend audio actif
sudo journalctl -u pttlive-server | grep "Backend audio"
# Devrait afficher : "✓ Backend audio : CoreAudio/JACK/PipeWire"
# 2. Vérifier capture audio fonctionne
# macOS
system_profiler SPAudioDataType | grep "Default Input"
# Linux avec JACK
jack_lsp | grep capture
# 3. Vérifier LiveKit connecté
sudo journalctl -u pttlive-server | grep LiveKit
# Devrait afficher : "✓ LiveKit connecté"
```
#### Solutions
**Cause : Microphone non autorisé (navigateur)**
```
1. Ouvrir les paramètres du navigateur
2. Site Settings → pttlive.local → Permissions
3. Microphone : Allow
4. Rafraîchir la page
```
**Cause : Backend audio non démarré**
```bash
# JACK (Linux)
jackd -d alsa -r 48000 -p 256
# PipeWire (Linux)
systemctl --user start pipewire pipewire-pulse
# CoreAudio (macOS) : déjà natif, vérifier carte son branchée
```
**Cause : Routing JACK manquant**
```bash
# Vérifier connexions
jack_lsp -c
# Reconnecter manuellement
jack_connect "system:capture_1" "PTTLive:input_1"
jack_connect "PTTLive:output_1" "system:playback_1"
```
---
### Latence élevée (> 200ms)
#### Symptômes
- Délai perceptible entre parole et réception
- Conversations difficiles (effet "satellite")
#### Diagnostic
```bash
# 1. Mesurer latence réseau (ping)
ping -i 0.2 serveur_ip
# Devrait être < 10ms en LAN
# 2. Vérifier jitter
iperf3 -c serveur_ip -u -b 1M
# Jitter devrait être < 5ms
# 3. Vérifier buffer JACK
jack_bufsize
# Typique : 256 samples = 5.3ms @ 48kHz
# 4. Logs PTT Live
sudo journalctl -u pttlive-server | grep latency
```
#### Solutions
**Réduire buffer JACK** :
```bash
# Arrêter JACK
killall jackd
# Redémarrer avec buffer plus petit
jackd -d alsa -r 48000 -p 128 # 128 au lieu de 256
# ⚠️ Risque de xruns si CPU faible
```
**Optimiser jitter buffer PTT Live** :
Éditer `server/config/config.yaml` :
```yaml
audio:
jitterBufferPreset: ULTRA_LOW # Au lieu de LOW_LATENCY
```
**Optimiser WiFi** :
- Forcer 5GHz (pas de 2.4GHz)
- Réduire nombre de clients par AP (< 15)
- Vérifier channel WiFi pas surchargé (scanner WiFi)
**Budget latence typique** :
| Composant | Latence |
|-----------|---------|
| WiFi | 5-20 ms |
| WebRTC encode/decode | 20-60 ms |
| Jitter buffer | 20-40 ms |
| JACK/backend | 5-10 ms |
| **Total** | 50-130 ms ✅ |
Si > 200ms, problème réseau probable (WiFi congestionné ou mauvaise couverture).
---
### Coupures audio (audio haché)
#### Symptômes
- Son qui coupe régulièrement
- Craquements/pops
- Audio en "robot"
#### Diagnostic
```bash
# 1. JACK xruns
jack_evmon
# Appuyer Ctrl+C après 30s et noter le nombre de xruns
# 0 xrun = OK
# > 5 xruns/min = problème CPU ou buffer trop petit
# 2. CPU usage
htop
# CPU > 90% = surchargé
# 3. Packet loss WebRTC
# Ouvrir navigateur client : chrome://webrtc-internals
# Chercher "packetsLost" : devrait être < 1%
# 4. Logs backend
sudo journalctl -u pttlive-server | grep -i "underrun\|overrun"
```
#### Solutions
**Xruns JACK (CPU overload)** :
```bash
# Augmenter buffer size
jackd -d alsa -r 48000 -p 512 # 512 au lieu de 256
# Priorité real-time JACK
sudo jackd -R -P 70 -d alsa -r 48000 -p 256
# Isoler CPU cores
# Éditer /etc/default/grub :
GRUB_CMDLINE_LINUX="isolcpus=2,3"
# Puis : sudo update-grub && sudo reboot
```
**Packet loss réseau** :
```bash
# Vérifier trafic réseau
iftop -i eth0
# Tester bande passante
iperf3 -c serveur_ip
# Devrait être > 100 Mbps en Gigabit
# Vérifier switch (pas de collisions)
ethtool eth0 | grep -i error
```
**Codec Opus agressif** :
Réduire le bitrate Opus :
```yaml
# server/config/config.yaml
groups:
- id: regie
opusBitrate: 64000 # 64kbps au lieu de 96kbps
```
---
### Audio en mono alors que stéréo attendu
#### Cause
Configuration channels à 1 au lieu de 2.
#### Solution
```yaml
# server/config/config.yaml
audio:
channels: 2 # Stéréo
```
Redémarrer serveur :
```bash
sudo systemctl restart pttlive-server
```
---
## Problèmes Réseau
### Clients ne peuvent pas se connecter
#### Symptômes
- Erreur "Connection failed" dans le client
- Timeout lors de la connexion LiveKit
#### Diagnostic
```bash
# 1. Serveur écoute sur le bon port ?
sudo netstat -tulpn | grep 7880
# Devrait afficher : tcp 0.0.0.0:7880 LISTEN
# 2. Firewall bloque ?
sudo ufw status
# Ports requis : 7880, 7881, 50000-60000
# 3. Client peut ping serveur ?
# Sur smartphone/laptop client :
ping serveur_ip
# 4. Test WebSocket
# Sur client, ouvrir console navigateur :
new WebSocket('ws://serveur_ip:7880')
# Si erreur 404 ou timeout = problème réseau/firewall
```
#### Solutions
**Ouvrir ports firewall** :
```bash
sudo ufw allow 7880/tcp
sudo ufw allow 7881/tcp
sudo ufw allow 50000:60000/udp
sudo ufw reload
```
**Vérifier LiveKit démarre** :
```bash
sudo journalctl -u pttlive-server | grep -i livekit
# Chercher "LiveKit server started"
```
**Tester en local** :
```bash
# Sur le serveur lui-même
curl http://localhost:3000/api/health
# Devrait répondre : {"status":"ok"}
```
---
### Perte de connexion WiFi fréquente
#### Symptômes
- Clients se déconnectent toutes les 1-5 minutes
- Reconnexion automatique ou manuelle requise
#### Diagnostic
```bash
# Sur l'Access Point (exemple UniFi)
# SSH vers AP
ssh ubnt@ap_ip
# Vérifier logs
tail -f /var/log/messages | grep -i disassoc
# Statistiques WiFi
iwconfig wlan0
# Chercher "Signal level" : devrait être > -70 dBm
```
#### Solutions
**Roaming WiFi agressif** :
Activer Fast Roaming (802.11r/k/v) sur les Access Points.
**Channel congestionné** :
```bash
# Scanner WiFi
sudo iwlist wlan0 scan | grep -E "Channel|ESSID|Quality"
# Choisir un channel libre (5GHz : 36, 40, 44, 48, 149, 153, etc.)
```
**Signal faible** :
- Ajouter un Access Point (couverture)
- Repositionner AP existant (hauteur, line-of-sight)
- Vérifier puissance TX AP (pas trop faible)
---
## Problèmes Client (PWA)
### Bouton PTT ne fonctionne pas (mobile)
#### Symptômes
- Appui sur bouton PTT ne fait rien
- Pas de vibration/feedback
#### Diagnostic
```javascript
// Console navigateur mobile (via Remote Debug)
// Chrome Android : chrome://inspect
// Safari iOS : Safari Desktop > Develop > iPhone
// Tester événement touch
document.getElementById('ptt-button').addEventListener('touchstart', (e) => {
console.log('Touch start:', e);
});
```
#### Solutions
**HTTPS requis** :
Les APIs Web modernes (microphone, vibration) nécessitent HTTPS.
```bash
# Générer certificat auto-signé
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes
# Configurer serveur HTTPS (voir DEPLOYMENT.md)
```
Accéder via `https://serveur_ip` (accepter certificat dans navigateur).
**Microphone non débloqué (iOS)** :
Sur iOS, l'audio nécessite une interaction utilisateur.
```javascript
// Ajouter un bouton "Unlock Audio" au premier lancement
async function unlockAudio() {
const audio = new Audio();
await audio.play();
audio.pause();
console.log('Audio unlocked');
}
```
---
### PWA ne s'installe pas (iOS)
#### Symptômes
- Bouton "Add to Home Screen" absent
- Pas de popup d'installation
#### Cause
Sur iOS, l'installation PWA est **manuelle** (pas de prompt automatique).
#### Solution
Afficher un message d'aide :
```javascript
// Détecter iOS
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS) {
// Afficher instructions
alert(`Pour installer PTT Live :
1. Appuyez sur le bouton Partage (⬆️)
2. Sélectionnez "Sur l'écran d'accueil"
3. Appuyez sur "Ajouter"`);
}
```
---
### Notifications Web Push ne fonctionnent pas
#### Diagnostic
```javascript
// Console navigateur
if ('Notification' in window) {
console.log('Notification permission:', Notification.permission);
// granted = OK
// denied = utilisateur a refusé
// default = pas encore demandé
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(reg => {
console.log('Service Worker:', reg);
});
}
```
#### Solutions
**Permissions non accordées** :
```javascript
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log('Notifications autorisées');
} else {
alert('Veuillez autoriser les notifications dans les paramètres du navigateur');
}
}
```
**Service Worker non enregistré** :
```bash
# Vérifier fichier sw.js existe
ls client/public/sw.js
# Vérifier enregistrement dans main.js
grep -r "serviceWorker.register" client/src/
```
---
## Problèmes Serveur
### Serveur ne démarre pas
#### Diagnostic
```bash
# Logs détaillés
sudo journalctl -u pttlive-server -n 100 --no-pager
# Vérifier port 3000 pas déjà utilisé
sudo lsof -i :3000
# Si occupé, tuer le processus ou changer le port
# Vérifier Node.js version
node --version # Devrait être >= 18
```
#### Solutions
**Port déjà utilisé** :
```bash
# Tuer processus existant
sudo kill $(sudo lsof -t -i:3000)
# Ou changer port dans .env
echo "PORT=3001" >> server/.env
```
**Dépendances manquantes** :
```bash
cd server
npm install
```
**Permissions audio (Linux)** :
```bash
# Ajouter utilisateur au groupe audio
sudo usermod -a -G audio $USER
# Reboot requis
sudo reboot
```
---
### Crash serveur après quelques heures (memory leak)
#### Diagnostic
```bash
# Surveiller RAM
watch -n 1 free -h
# Logs avant crash
sudo journalctl -u pttlive-server --since "1 hour ago" | grep -i error
```
#### Solutions
**Limiter RAM dans systemd** :
Éditer `/etc/systemd/system/pttlive-server.service` :
```ini
[Service]
MemoryLimit=4G
MemoryMax=4G
```
```bash
sudo systemctl daemon-reload
sudo systemctl restart pttlive-server
```
**Garbage collection Node.js** :
```bash
# Lancer Node avec options GC
node --max-old-space-size=2048 --expose-gc index.js
```
---
## Problèmes JACK/Audio Backend
### JACK ne démarre pas
#### Symptômes
```bash
jackd -d alsa -r 48000
# Erreur : "Cannot lock down memory area (Cannot allocate memory)"
```
#### Diagnostic
```bash
# Vérifier limites memlock
ulimit -l
# Devrait être "unlimited"
# Vérifier utilisateur dans groupe audio
groups $USER
# Devrait contenir "audio"
```
#### Solutions
**Configurer memlock** :
Éditer `/etc/security/limits.conf` :
```
@audio - memlock unlimited
@audio - rtprio 95
```
Reboot requis :
```bash
sudo reboot
```
---
### JACK démarre mais pas de son
#### Diagnostic
```bash
# Ports JACK disponibles ?
jack_lsp
# Devrait afficher :
# system:capture_1
# system:playback_1
# PTTLive:input_1
# PTTLive:output_1
# Connexions actives ?
jack_lsp -c
# Devrait afficher des connexions
```
#### Solution
```bash
# Connecter manuellement
jack_connect "system:capture_1" "PTTLive:input_1"
jack_connect "PTTLive:output_1" "system:playback_1"
# Ou utiliser QjackCtl (GUI)
qjackctl
# Cliquer "Graph" et faire les connexions visuellement
```
---
## Problèmes Dante/AES67
### Dante Virtual Soundcard ne s'affiche pas dans Dante Controller
#### Diagnostic
```bash
# macOS : DVS est-il démarré ?
ps aux | grep "Dante Virtual Soundcard"
# Firewall bloque Dante ?
# Dante utilise :
# - UDP 319, 320 (PTP)
# - UDP 4440, 4444, 4455 (Dante Discovery)
# - UDP 14336-14591 (Audio flows)
```
#### Solutions
**Désactiver firewall temporairement** :
```bash
# macOS
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate off
# Linux
sudo ufw disable
```
Si ça fonctionne, ajouter des règles firewall spécifiques :
```bash
# Linux
sudo ufw allow 319:320/udp
sudo ufw allow 4440:4455/udp
sudo ufw allow 14336:14591/udp
```
**Vérifier réseau** :
- Même subnet que les équipements Dante (ex: 192.168.1.x/24)
- Branché sur le même switch
- IGMP snooping activé sur le switch
---
### Latence Dante trop élevée (> 50ms)
#### Diagnostic
Ouvrir **Dante Controller** :
1. Device View → Sélectionner DVS
2. Device Config → Dante tab
3. Vérifier "Latency" : 5ms ou 10ms recommandé
#### Solution
Réduire latency dans DVS :
1. Ouvrir **Dante Virtual Soundcard**
2. Settings → Latency : 2ms ou 5ms (au lieu de 10ms)
3. Restart
**Attention** : Latence < 5ms risque de coupures si réseau chargé.
---
### PTP non synchronisé (AES67)
#### Symptômes
```bash
sudo ptp4l -i eth0 -f /etc/ptp4l.conf -m
# Offset > 1000 ns (> 1µs)
```
#### Diagnostic
```bash
# Switch supporte PTP ?
# Vérifier config switch (PTP activé)
# PTP master présent sur le réseau ?
sudo tcpdump -i eth0 -n 'port 319 or port 320'
# Devrait afficher des paquets PTP Sync/Follow_Up
```
#### Solutions
**Aucun PTP master** :
Configurer un équipement comme grandmaster (ex: console AES67).
Ou lancer un PTP master software (déconseillé en production) :
```bash
# Mode master (remplacer slaveOnly par masterOnly dans config)
sudo ptp4l -i eth0 --masterOnly -m
```
**Switch ne route pas PTP** :
Vérifier config switch :
- PTP enabled sur tous les ports
- Transparent Clock ou Boundary Clock
---
## Outils de Diagnostic
### Logs Serveur
```bash
# Temps réel
sudo journalctl -u pttlive-server -f
# Depuis le démarrage
sudo journalctl -u pttlive-server --since today
# Filtrer erreurs uniquement
sudo journalctl -u pttlive-server -p err
```
### Monitoring Réseau
```bash
# Trafic réseau temps réel
iftop -i eth0
# Statistiques interface
ip -s link show eth0
# Connexions actives
ss -tunap | grep -E '7880|50000'
```
### Monitoring Audio
```bash
# JACK
jack_evmon # Surveille xruns
jack_bufsize # Taille buffer
jack_samplerate # Sample rate
# PipeWire
pw-top # CPU usage par client
pw-cli dump # État complet
```
### Client (Navigateur)
**Chrome DevTools** :
1. F12 → Console : erreurs JavaScript
2. Network : vérifier requêtes API (200 OK attendu)
3. Application → Service Workers : vérifier enregistré
4. `chrome://webrtc-internals` : stats WebRTC détaillées
**Firefox DevTools** :
1. F12 → Console
2. `about:webrtc` : stats WebRTC
---
## Checklist Rapide
### Problème : Pas de son
- [ ] Microphone autorisé navigateur ?
- [ ] Backend audio démarré (JACK/PipeWire) ?
- [ ] Ports JACK connectés ?
- [ ] LiveKit connecté (logs serveur) ?
### Problème : Latence élevée
- [ ] Ping < 10ms ?
- [ ] Buffer JACK = 256 samples ?
- [ ] WiFi 5GHz ?
- [ ] Jitter buffer = LOW_LATENCY ?
### Problème : Coupures audio
- [ ] JACK xruns = 0 ?
- [ ] CPU < 70% ?
- [ ] Packet loss < 1% ?
- [ ] Buffer JACK >= 256 ?
### Problème : Connexion impossible
- [ ] Firewall ports ouverts (7880, 50000-60000) ?
- [ ] LiveKit démarre (journalctl) ?
- [ ] Client peut ping serveur ?
- [ ] HTTPS si PWA ?
---
## Support
Si le problème persiste :
1. Collecter logs :
```bash
sudo journalctl -u pttlive-server > /tmp/pttlive.log
jack_lsp -c > /tmp/jack-connections.txt
```
2. Ouvrir une issue GitHub avec :
- Description du problème
- Logs serveur
- Version OS (client et serveur)
- Configuration audio (carte son, backend)
**GitHub Issues** : https://github.com/votre-user/ptt-live/issues
---
**Dernière mise à jour** : 2026-05-26
**Version** : 0.1.0 (Phase 3)
Executable
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# PTT Live - Script d'installation multi-OS
# Détecte automatiquement le système et lance l'installeur approprié
set -e
# Couleurs
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}=================================="
echo "🚀 PTT Live - Installation"
echo "==================================${NC}"
echo ""
# Détection du système d'exploitation
if [[ "$OSTYPE" == "darwin"* ]]; then
echo -e "${GREEN}📱 Système détecté : macOS${NC}"
echo ""
exec ./install/macos.sh
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
echo -e "${GREEN}🐧 Système détecté : Linux${NC}"
echo ""
exec ./install/linux.sh
elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "win32" ]]; then
echo -e "${YELLOW}🪟 Système détecté : Windows${NC}"
echo ""
echo -e "${RED}❌ Windows n'est pas encore supporté (Phase 3)${NC}"
echo ""
echo "Plateformes supportées :"
echo " • macOS (via Homebrew)"
echo " • Linux (Debian/Ubuntu/Fedora)"
echo ""
exit 1
else
echo -e "${RED}❌ Système non reconnu : $OSTYPE${NC}"
echo ""
echo "Plateformes supportées :"
echo " • macOS"
echo " • Linux"
echo ""
exit 1
fi
+363
View File
@@ -0,0 +1,363 @@
#!/bin/bash
###############################################################################
# PTT Live - Script d'installation Linux
# Supporte : Ubuntu 22.04+, Debian 11+, Arch Linux
###############################################################################
set -e # Arrête en cas d'erreur
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
echo ""
echo "========================================"
echo " PTT Live - Installation Linux"
echo "========================================"
echo ""
# Détection de la distribution
detect_distro() {
if [ -f /etc/os-release ]; then
. /etc/os-release
DISTRO=$ID
VERSION=$VERSION_ID
else
echo "Erreur : impossible de détecter la distribution Linux"
exit 1
fi
echo "Distribution détectée : $DISTRO $VERSION"
}
# Installation des dépendances système
install_system_deps() {
echo ""
echo "Installation des dépendances système..."
case $DISTRO in
ubuntu|debian)
echo "Distribution : Debian/Ubuntu"
# Mise à jour des paquets
sudo apt update
# Dépendances de base
sudo apt install -y \
curl \
git \
build-essential \
pkg-config
# Node.js (via NodeSource si pas déjà installé)
if ! command -v node &> /dev/null; then
echo "Installation de Node.js 20.x..."
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
else
echo "Node.js déjà installé : $(node --version)"
fi
# Backend audio : PipeWire (recommandé pour Ubuntu 22.04+)
if [ "${VERSION%%.*}" -ge 22 ]; then
echo "Installation de PipeWire (backend audio moderne)..."
sudo apt install -y \
pipewire \
pipewire-pulse \
pipewire-jack \
wireplumber \
pipewire-audio-client-libraries
# Outils PipeWire
sudo apt install -y \
pipewire-bin \
libspa-0.2-jack \
pulseaudio-utils
# Démarrage automatique
systemctl --user enable --now pipewire pipewire-pulse wireplumber
echo "PipeWire démarré et activé au démarrage"
else
echo "Version Ubuntu < 22.04 : installation de JACK..."
install_jack_debian
fi
# Outils JACK optionnels (compatibilité)
sudo apt install -y \
jack-tools \
qjackctl || true
echo "Dépendances système installées !"
;;
arch|manjaro)
echo "Distribution : Arch Linux"
# Mise à jour des paquets
sudo pacman -Syu --noconfirm
# Dépendances de base
sudo pacman -S --needed --noconfirm \
base-devel \
git \
curl \
nodejs \
npm
# PipeWire (installé par défaut sur Arch moderne)
sudo pacman -S --needed --noconfirm \
pipewire \
pipewire-pulse \
pipewire-jack \
wireplumber \
pipewire-alsa
# Outils audio
sudo pacman -S --needed --noconfirm \
jack2 \
qjackctl || true
# Activation PipeWire
systemctl --user enable --now pipewire pipewire-pulse wireplumber
echo "PipeWire démarré et activé au démarrage"
echo "Dépendances système installées !"
;;
fedora)
echo "Distribution : Fedora"
sudo dnf install -y \
nodejs \
npm \
gcc-c++ \
make \
pipewire \
pipewire-jack-audio-connection-kit \
pipewire-pulseaudio \
wireplumber
systemctl --user enable --now pipewire pipewire-pulse wireplumber
echo "Dépendances système installées !"
;;
*)
echo "Distribution non supportée automatiquement : $DISTRO"
echo "Installez manuellement :"
echo " - Node.js 18+"
echo " - PipeWire ou JACK"
exit 1
;;
esac
}
# Installation de JACK (fallback pour anciennes versions)
install_jack_debian() {
echo "Installation de JACK Audio Connection Kit..."
sudo apt install -y \
jackd2 \
jack-tools \
qjackctl
# Configuration JACK pour basse latence
sudo usermod -a -G audio $USER
echo "JACK installé. Vous devrez peut-être redémarrer pour appliquer les permissions audio."
}
# Téléchargement de LiveKit Server
install_livekit_server() {
echo ""
echo "Téléchargement de LiveKit Server..."
LIVEKIT_VERSION="v1.12.0"
LIVEKIT_VERSION_NUM="${LIVEKIT_VERSION#v}" # Retire le 'v' pour le nom du fichier
LIVEKIT_DIR="$PROJECT_ROOT/server/bin"
LIVEKIT_BINARY="$LIVEKIT_DIR/livekit-server"
mkdir -p "$LIVEKIT_DIR"
# Détection de l'architecture
ARCH=$(uname -m)
case $ARCH in
x86_64)
LIVEKIT_ARCH="amd64"
;;
aarch64|arm64)
LIVEKIT_ARCH="arm64"
;;
*)
echo "Architecture non supportée : $ARCH"
exit 1
;;
esac
LIVEKIT_URL="https://github.com/livekit/livekit/releases/download/${LIVEKIT_VERSION}/livekit_${LIVEKIT_VERSION_NUM}_linux_${LIVEKIT_ARCH}.tar.gz"
echo "Téléchargement depuis : $LIVEKIT_URL"
cd "$LIVEKIT_DIR"
curl -L -o livekit.tar.gz "$LIVEKIT_URL"
tar -xzf livekit.tar.gz
rm livekit.tar.gz
chmod +x livekit-server
echo "LiveKit Server installé : $LIVEKIT_BINARY"
echo "Version : $($LIVEKIT_BINARY --version)"
}
# Installation des dépendances Node.js
install_node_deps() {
echo ""
echo "Installation des dépendances Node.js..."
# Serveur
echo "Serveur..."
cd "$PROJECT_ROOT/server"
npm install
# Client
echo "Client..."
cd "$PROJECT_ROOT/client"
npm install
echo "Dépendances Node.js installées !"
}
# Configuration réseau et génération .env
configure_network() {
echo ""
echo "Configuration réseau..."
# Détection IP réseau
NETWORK_IP=$(hostname -I | awk '{print $1}')
if [ -z "$NETWORK_IP" ]; then
echo "⚠️ IP réseau non détectée, utilisation localhost"
NETWORK_IP="localhost"
else
echo "✓ IP réseau détectée : ${NETWORK_IP}"
fi
# Générer .env serveur
echo "Génération configuration serveur..."
cat > "$PROJECT_ROOT/server/.env" << EOF
# Configuration PTT Live Server
# Généré automatiquement par install/linux.sh
USE_LOCAL_LIVEKIT=true
# LiveKit Configuration
# AUTO = détection automatique IP réseau au démarrage
LIVEKIT_URL=AUTO
# En mode --dev, LiveKit utilise ces clés par défaut
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
# Server Configuration
PORT=3000
NODE_ENV=development
EOF
echo "✓ Configuration serveur générée (server/.env)"
# Générer .env client
echo "Génération configuration client..."
cat > "$PROJECT_ROOT/client/.env" << EOF
# Configuration PTT Live Client
# Généré automatiquement par install/linux.sh
# En développement local, utilise le proxy Vite
VITE_API_URL=/api
# Pour accès réseau (autres devices), décommentez et mettez l'IP du serveur :
# VITE_API_URL=http://${NETWORK_IP}:3000
EOF
echo "✓ Configuration client générée (client/.env)"
}
# Configuration audio
configure_audio() {
echo ""
echo "========================================"
echo " Configuration audio"
echo "========================================"
# Vérification PipeWire
if systemctl --user is-active --quiet pipewire; then
echo "PipeWire : ACTIF"
pw-cli info 0 | head -n 5
else
echo "PipeWire : INACTIF"
echo "Démarrez-le : systemctl --user start pipewire pipewire-pulse"
fi
# Vérification JACK (si installé)
if command -v jack_lsp &> /dev/null; then
echo ""
echo "JACK : Installé"
if jack_lsp &> /dev/null; then
echo "Serveur JACK : ACTIF"
else
echo "Serveur JACK : INACTIF"
echo "Démarrez-le : jackd -d alsa -r 48000"
fi
fi
echo ""
echo "Backend audio recommandé : PipeWire"
echo "Pour démarrer le serveur PTT Live, voir README.md"
}
# Résumé final
print_summary() {
echo ""
echo "========================================"
echo " ✅ Installation terminée !"
echo "========================================"
echo ""
echo "🚀 Démarrage rapide :"
echo ""
echo " # Mode développement (recommandé)"
echo " ./start.sh --dev"
echo ""
echo " # Mode production"
echo " ./start.sh"
echo ""
echo "📝 OU manuellement (deux terminaux) :"
echo ""
echo " Terminal 1 : cd $PROJECT_ROOT/server && npm run dev"
echo " Terminal 2 : cd $PROJECT_ROOT/client && npm run dev"
echo ""
echo "🌐 Accès après démarrage :"
echo " • Développement local : https://localhost:5173"
echo " • Depuis smartphone (WiFi) : https://${NETWORK_IP}:5173"
echo " • Admin : https://${NETWORK_IP}:5173/admin"
echo ""
echo "💡 Configuration réseau :"
echo " IP serveur détectée : ${NETWORK_IP}"
echo " LiveKit URL : AUTO (détection dynamique)"
echo ""
echo "📖 Documentation :"
echo " • README.md - Guide complet"
echo " • README-PORTABLE.md - Déploiement portable"
echo ""
echo "========================================"
echo ""
}
# Script principal
main() {
detect_distro
install_system_deps
install_livekit_server
install_node_deps
configure_network
configure_audio
print_summary
}
main "$@"
+68 -12
View File
@@ -51,6 +51,16 @@ fi
echo -e "${GREEN}✅ Homebrew $(brew --version | head -n 1)${NC}"
echo ""
# Installer sox (audio backend stable pour macOS)
echo "🎵 Installation sox (audio backend)..."
if command -v sox &> /dev/null; then
echo -e "${GREEN}✅ sox déjà installé ($(sox --version | head -n 1))${NC}"
else
brew install sox
echo -e "${GREEN}✅ sox installé${NC}"
fi
echo ""
# Installer LiveKit Server via Homebrew
echo "📥 Installation LiveKit Server..."
if command -v livekit-server &> /dev/null; then
@@ -72,7 +82,7 @@ echo ""
# Installer dépendances serveur
echo "📦 Installation dépendances serveur..."
cd ../server
cd ./server
npm install
echo -e "${GREEN}✅ Dépendances serveur installées${NC}"
echo ""
@@ -86,14 +96,30 @@ echo ""
cd ..
# Créer fichier .env
echo "🔑 Génération configuration LiveKit..."
# Détecter l'IP réseau locale
echo "🌐 Détection configuration réseau..."
NETWORK_IP=$(ifconfig | grep "inet " | grep -v 127.0.0.1 | awk '{print $2}' | head -n 1)
if [ -z "$NETWORK_IP" ]; then
echo -e "${YELLOW}⚠️ IP réseau non détectée, utilisation localhost${NC}"
NETWORK_IP="localhost"
else
echo -e "${GREEN}✅ IP réseau détectée : ${NETWORK_IP}${NC}"
fi
echo ""
# Créer fichier .env serveur
echo "🔑 Génération configuration serveur..."
cat > server/.env << EOF
# Configuration PTT Live Server
# Généré automatiquement par install/macos.sh
USE_LOCAL_LIVEKIT=true
# LiveKit Configuration
LIVEKIT_URL=ws://localhost:7880
# AUTO = détection automatique IP réseau au démarrage
LIVEKIT_URL=AUTO
# En mode --dev, LiveKit utilise ces clés par défaut
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
@@ -103,22 +129,52 @@ PORT=3000
NODE_ENV=development
EOF
echo -e "${GREEN}✅ Clés API générées (server/.env)${NC}"
echo -e "${GREEN}✅ Configuration serveur générée (server/.env)${NC}"
# Créer fichier .env client
echo "🔑 Génération configuration client..."
cat > client/.env << EOF
# Configuration PTT Live Client
# Généré automatiquement par install/macos.sh
# En développement local, utilise le proxy Vite
VITE_API_URL=/api
# Pour accès réseau (autres devices), décommentez et mettez l'IP du serveur :
# VITE_API_URL=http://${NETWORK_IP}:3000
EOF
echo -e "${GREEN}✅ Configuration client générée (client/.env)${NC}"
echo ""
# Message final
echo "=================================="
echo -e "${GREEN}✅ Installation terminée !${NC}"
echo ""
echo "📝 Prochaines étapes :"
echo "🚀 Démarrage rapide :"
echo ""
echo " 1. Démarrer le serveur :"
echo " cd server && npm run dev"
echo " # Mode développement (recommandé)"
echo " ./start.sh --dev"
echo ""
echo " 2. Démarrer le client (nouveau terminal) :"
echo " cd client && npm run dev"
echo " # Mode production"
echo " ./start.sh"
echo ""
echo " 3. Ouvrir https://localhost:5173 dans votre navigateur"
echo "📝 OU manuellement (deux terminaux) :"
echo ""
echo "📖 Documentation : README.md"
echo " Terminal 1 : cd server && npm run dev"
echo " Terminal 2 : cd client && npm run dev"
echo ""
echo "🌐 Accès après démarrage :"
echo " • Développement local : https://localhost:5173"
echo " • Depuis smartphone (WiFi) : https://${NETWORK_IP}:5173"
echo " • Admin : https://${NETWORK_IP}:5173/admin"
echo ""
echo "💡 Configuration réseau :"
echo " IP serveur détectée : ${NETWORK_IP}"
echo " LiveKit URL : AUTO (détection dynamique)"
echo ""
echo "📖 Documentation :"
echo " • README.md - Guide complet"
echo " • README-PORTABLE.md - Déploiement portable"
echo ""
+18 -12
View File
@@ -1,17 +1,23 @@
# PTT Live - Configuration environnement serveur
# Configuration PTT Live Server
# Copiez ce fichier en .env et adaptez selon votre environnement
# LiveKit API Keys
# En dev, utilise les valeurs par défaut si non définies
# Mode LiveKit
USE_LOCAL_LIVEKIT=true # true = LiveKit local, false = LiveKit Cloud
# LiveKit Configuration
# Mode local : AUTO détecte automatiquement l'IP réseau
# Mode cloud : URL complète wss://votre-projet.livekit.cloud
LIVEKIT_URL=AUTO
# Clés API LiveKit
# Mode local --dev : devkey/secret (par défaut)
# Mode cloud : récupérez vos clés sur https://cloud.livekit.io
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
# URL LiveKit pour les clients
# Pour permettre les connexions réseau, utilisez l'IP locale du serveur
# Exemples :
# - Local uniquement : ws://localhost:7880
# - Réseau local : ws://192.168.1.100:7880 (remplacer par votre IP)
# - Utiliser AUTO pour détecter automatiquement l'IP réseau
LIVEKIT_URL=AUTO
# Configuration serveur
PORT=3000
NODE_ENV=development
# Mode LiveKit local (démarre livekit-server automatiquement)
USE_LOCAL_LIVEKIT=true
# Logging (optionnel)
# LOG_LEVEL=debug
+382 -88
View File
@@ -4,11 +4,10 @@
*/
import { Router } from 'express';
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import YAML from 'yaml';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { CoreAudioBackend } from '../bridge/backends/CoreAudioBackend.js';
import configManager from '../config/ConfigManager.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const router = Router();
@@ -39,57 +38,6 @@ const stats = {
logs: []
};
// Configuration file path
const configPath = join(__dirname, '..', 'config', 'config.yaml');
/**
* Charge la configuration depuis le fichier YAML
* et génère les IDs à partir des noms
*/
function loadConfig() {
const configFile = readFileSync(configPath, 'utf8');
const config = YAML.parse(configFile);
// Générer les IDs pour les groupes et canaux
config.groups = config.groups.map(group => {
const groupId = slugify(group.name);
return {
...group,
id: groupId,
channels: group.channels ? group.channels.map(channel => ({
...channel,
id: channel.id || `${groupId}-${slugify(channel.name)}`
})) : []
};
});
return config;
}
/**
* Sauvegarde la configuration dans le fichier YAML
* Ne sauvegarde PAS les IDs (ils sont générés dynamiquement)
*/
function saveConfig(config) {
// Nettoyer les IDs avant de sauvegarder
const cleanConfig = {
...config,
groups: config.groups.map(group => {
const { id, ...groupWithoutId } = group;
return {
...groupWithoutId,
channels: group.channels ? group.channels.map(channel => {
const { id: channelId, ...channelWithoutId } = channel;
return channelWithoutId;
}) : []
};
})
};
const yamlContent = YAML.stringify(cleanConfig);
writeFileSync(configPath, yamlContent, 'utf8');
}
/**
* Ajoute un log au système
*/
@@ -174,7 +122,7 @@ export function addAudioStats(data) {
*/
router.get('/groups', (req, res) => {
try {
const config = loadConfig();
const config = configManager.get();
res.json({
groups: config.groups
});
@@ -187,20 +135,20 @@ router.get('/groups', (req, res) => {
/**
* POST /admin/groups
* Crée un nouveau groupe
* Body: { name, audioBitrate?, channels }
* Body: { name, audioBitrate? }
* L'ID est généré automatiquement à partir du nom
*/
router.post('/groups', (req, res) => {
try {
const { name, audioBitrate, channels } = req.body;
const { name, audioBitrate } = req.body;
if (!name || !channels || !Array.isArray(channels)) {
if (!name) {
return res.status(400).json({
error: 'Missing required fields: name, channels'
error: 'Missing required field: name'
});
}
const config = loadConfig();
const config = configManager.get();
// Générer l'ID à partir du nom
const id = slugify(name);
@@ -212,21 +160,14 @@ router.post('/groups', (req, res) => {
});
}
// Générer les IDs pour les canaux
const channelsWithIds = channels.map(channel => ({
...channel,
id: channel.id || `${id}-${slugify(channel.name)}`
}));
// Créer le nouveau groupe
const newGroup = {
name,
audioBitrate: audioBitrate || config.audio.defaultBitrate,
channels: channelsWithIds
...(audioBitrate && { audioBitrate })
};
config.groups.push(newGroup);
saveConfig(config);
configManager.save(config);
addLog('info', `Group created: ${name}`, { id });
@@ -244,15 +185,15 @@ router.post('/groups', (req, res) => {
/**
* PUT /admin/groups/:id
* Modifie un groupe existant
* Body: { name?, audioBitrate?, channels? }
* Body: { name?, audioBitrate? }
* Note: l'ID est un slug généré, on cherche le groupe par nom dans le YAML
*/
router.put('/groups/:id', (req, res) => {
try {
const { id } = req.params;
const { name, audioBitrate, channels } = req.body;
const { name, audioBitrate } = req.body;
const config = loadConfig();
const config = configManager.get();
// Chercher le groupe par son nom (qui correspond à l'ID slugifié)
const groupIndex = config.groups.findIndex(g => slugify(g.name) === id);
@@ -266,21 +207,13 @@ router.put('/groups/:id', (req, res) => {
// Mettre à jour les champs fournis
if (name !== undefined) config.groups[groupIndex].name = name;
if (audioBitrate !== undefined) config.groups[groupIndex].audioBitrate = audioBitrate;
if (channels !== undefined) {
// Pas besoin de générer les IDs ici, ils seront générés au chargement
config.groups[groupIndex].channels = channels.map(channel => ({
name: channel.name,
audioInput: channel.audioInput,
audioOutput: channel.audioOutput
}));
}
saveConfig(config);
configManager.save(config);
addLog('info', `Group updated: ${config.groups[groupIndex].name}`, { id });
// Recharger pour obtenir les IDs générés
const updatedConfig = loadConfig();
// Récupérer la config à jour avec les IDs générés
const updatedConfig = configManager.get();
const updatedGroupIndex = updatedConfig.groups.findIndex(g => slugify(g.name) === id || slugify(g.name) === slugify(name));
const updatedGroup = updatedGroupIndex !== -1 ? updatedConfig.groups[updatedGroupIndex] : null;
@@ -304,7 +237,7 @@ router.delete('/groups/:id', (req, res) => {
try {
const { id } = req.params;
const config = loadConfig();
const config = configManager.get();
const groupIndex = config.groups.findIndex(g => slugify(g.name) === id);
if (groupIndex === -1) {
@@ -315,7 +248,7 @@ router.delete('/groups/:id', (req, res) => {
const groupName = config.groups[groupIndex].name;
config.groups.splice(groupIndex, 1);
saveConfig(config);
configManager.save(config);
addLog('info', `Group deleted: ${groupName}`, { id });
@@ -435,7 +368,7 @@ router.get('/logs', (req, res) => {
*/
router.get('/config', (req, res) => {
try {
const config = loadConfig();
const config = configManager.get();
res.json(config);
} catch (error) {
console.error('Erreur GET /admin/config:', error);
@@ -452,13 +385,13 @@ router.put('/config/audio', (req, res) => {
try {
const { sampleRate, defaultBitrate, jitterBufferMs } = req.body;
const config = loadConfig();
const config = configManager.get();
if (sampleRate !== undefined) config.audio.sampleRate = sampleRate;
if (defaultBitrate !== undefined) config.audio.defaultBitrate = defaultBitrate;
if (jitterBufferMs !== undefined) config.audio.jitterBufferMs = jitterBufferMs;
saveConfig(config);
configManager.save(config);
addLog('info', 'Audio config updated', { sampleRate, defaultBitrate, jitterBufferMs });
@@ -473,4 +406,365 @@ router.put('/config/audio', (req, res) => {
}
});
// ========== Routes Audio Devices (Phase 2.5) ==========
/**
* GET /admin/audio/devices
* Énumération de toutes les cartes son disponibles
*/
router.get('/audio/devices', (req, res) => {
try {
const devices = CoreAudioBackend.getDevices();
const defaultInput = CoreAudioBackend.getDefaultInputDevice();
const defaultOutput = CoreAudioBackend.getDefaultOutputDevice();
res.json({
devices,
defaultInput,
defaultOutput
});
} catch (error) {
console.error('Erreur GET /admin/audio/devices:', error);
res.status(500).json({ error: 'Failed to enumerate audio devices' });
}
});
/**
* GET /admin/audio/device
* Récupère la configuration actuelle de la carte son sélectionnée
*/
router.get('/audio/device', (req, res) => {
try {
const config = configManager.get();
const audioDevice = config.audio?.device || {};
// Enrichir avec les infos réelles de la carte si configurée
const devices = CoreAudioBackend.getDevices();
let deviceInfo = { ...audioDevice };
if (audioDevice.inputDeviceId) {
const inputDev = devices.find(d => d.id === audioDevice.inputDeviceId);
if (inputDev) {
deviceInfo.inputChannels = inputDev.maxInputChannels;
deviceInfo.inputDeviceName = inputDev.name;
}
}
if (audioDevice.outputDeviceId) {
const outputDev = devices.find(d => d.id === audioDevice.outputDeviceId);
if (outputDev) {
deviceInfo.outputChannels = outputDev.maxOutputChannels;
deviceInfo.outputDeviceName = outputDev.name;
}
}
res.json({
device: deviceInfo
});
} catch (error) {
console.error('Erreur GET /admin/audio/device:', error);
res.status(500).json({ error: 'Failed to load audio device config' });
}
});
/**
* GET /admin/audio/channels/names
* Récupère les noms personnalisés des canaux physiques
*/
router.get('/audio/channels/names', (req, res) => {
try {
const config = configManager.get();
const channelNames = config.audio?.channelNames || { inputs: {}, outputs: {} };
res.json({
channelNames
});
} catch (error) {
console.error('Erreur GET /admin/audio/channels/names:', error);
res.status(500).json({ error: 'Failed to load channel names' });
}
});
/**
* PUT /admin/audio/channels/names
* Sauvegarde les noms personnalisés des canaux physiques
* Body: { inputs: { "0": "Micro Principal", ... }, outputs: { "0": "Retour Scène", ... } }
*/
router.put('/audio/channels/names', (req, res) => {
try {
const { inputs, outputs } = req.body;
if (!inputs && !outputs) {
return res.status(400).json({
error: 'Missing required fields: inputs or outputs'
});
}
const config = configManager.get();
if (!config.audio.channelNames) {
config.audio.channelNames = { inputs: {}, outputs: {} };
}
if (inputs) {
config.audio.channelNames.inputs = inputs;
}
if (outputs) {
config.audio.channelNames.outputs = outputs;
}
configManager.save(config);
addLog('info', 'Channel names updated', { inputCount: Object.keys(inputs || {}).length, outputCount: Object.keys(outputs || {}).length });
res.json({
message: 'Channel names updated',
channelNames: config.audio.channelNames
});
} catch (error) {
console.error('Erreur PUT /admin/audio/channels/names:', error);
res.status(500).json({ error: 'Failed to update channel names' });
}
});
/**
* GET /admin/audio/routing
* Récupère la configuration de routing actuelle
* Format: { inputToGroup: { "0": ["production"], "1": ["technique"] }, groupToOutput: { "production": ["0", "1"] } }
*/
router.get('/audio/routing', (req, res) => {
try {
const config = configManager.get();
const routing = config.audio?.routing || { inputToGroup: {}, groupToOutput: {}, gains: {} };
res.json({
routing
});
} catch (error) {
console.error('Erreur GET /admin/audio/routing:', error);
res.status(500).json({ error: 'Failed to load routing' });
}
});
/**
* POST /audio/routing
* Sauvegarde la configuration de routing
* Body: { inputToGroup: {...}, groupToOutput: {...}, gains: {...} }
*/
router.post('/audio/routing', (req, res) => {
try {
const { inputToGroup, groupToOutput, gains } = req.body;
const config = configManager.get();
if (!config.audio.routing) {
config.audio.routing = { inputToGroup: {}, groupToOutput: {}, gains: {} };
}
if (inputToGroup !== undefined) {
config.audio.routing.inputToGroup = inputToGroup;
}
if (groupToOutput !== undefined) {
config.audio.routing.groupToOutput = groupToOutput;
}
if (gains !== undefined) {
config.audio.routing.gains = gains;
}
configManager.save(config);
addLog('info', 'Audio routing updated');
res.json({
message: 'Audio routing updated',
routing: config.audio.routing
});
} catch (error) {
console.error('Erreur POST /admin/audio/routing:', error);
res.status(500).json({ error: 'Failed to update routing' });
}
});
/**
* POST /admin/audio/device
* Sélectionne et configure une carte son
* Body: { inputDeviceId?, outputDeviceId?, sampleRate?, bufferSize? }
*/
router.post('/audio/device', (req, res) => {
try {
const { inputDeviceId, outputDeviceId, sampleRate, bufferSize } = req.body;
// Utiliser le ConfigManager pour mettre à jour et émettre l'événement
const deviceConfig = configManager.updateAudioDevice({
inputDeviceId,
outputDeviceId,
sampleRate,
bufferSize
});
addLog('info', 'Audio device configured', { inputDeviceId, outputDeviceId, sampleRate, bufferSize });
res.json({
message: 'Audio device configured (bridge audio sera rechargé)',
device: deviceConfig
});
} catch (error) {
console.error('Erreur POST /admin/audio/device:', error);
res.status(500).json({ error: 'Failed to configure audio device' });
}
});
/**
* GET /admin/devices/list
* Liste tous les devices audio disponibles (auto-détection)
* Supporte macOS (CoreAudio), Linux (JACK/PipeWire), Windows (WASAPI)
*/
router.get('/devices/list', async (req, res) => {
try {
const devices = {
inputs: [],
outputs: [],
platform: process.platform
};
// Détection selon la plateforme
if (process.platform === 'darwin') {
// macOS : utiliser CoreAudioBackend.getDevices()
try {
const coreAudioDevices = CoreAudioBackend.getDevices();
// Séparer inputs et outputs
coreAudioDevices.forEach(device => {
if (device.maxInputChannels > 0) {
devices.inputs.push({
id: device.name, // Utiliser le nom comme ID (compatible avec inputDeviceName)
name: device.name,
channels: device.maxInputChannels,
sampleRate: device.defaultSampleRate,
isDefault: device.isDefault?.input || false
});
}
if (device.maxOutputChannels > 0) {
devices.outputs.push({
id: device.name, // Utiliser le nom comme ID (compatible avec outputDeviceName)
name: device.name,
channels: device.maxOutputChannels,
sampleRate: device.defaultSampleRate,
isDefault: device.isDefault?.output || false
});
}
});
// Fallback si aucun device trouvé
if (devices.inputs.length === 0) {
devices.inputs.push({ id: 'builtin-mic', name: 'Built-in Microphone', isDefault: true });
}
if (devices.outputs.length === 0) {
devices.outputs.push({ id: 'builtin-output', name: 'Built-in Output', isDefault: true });
}
} catch (error) {
console.warn('⚠️ Détection CoreAudio échouée:', error.message);
// Fallback : devices par défaut macOS
devices.inputs.push({ id: 'builtin-mic', name: 'Built-in Microphone', isDefault: true });
devices.outputs.push({ id: 'builtin-output', name: 'Built-in Output', isDefault: true });
}
} else if (process.platform === 'linux') {
// Linux : JACK ou PipeWire
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execPromise = promisify(exec);
try {
// Essayer JACK d'abord
const { stdout: jackPorts } = await execPromise('jack_lsp 2>/dev/null || echo ""');
if (jackPorts.trim()) {
// Parser les ports JACK
const ports = jackPorts.split('\n').filter(Boolean);
ports.forEach(port => {
if (port.includes('capture')) {
devices.inputs.push({ id: port, name: port });
} else if (port.includes('playback')) {
devices.outputs.push({ id: port, name: port });
}
});
} else {
// Fallback : PipeWire/PulseAudio via pactl
const { stdout: paDevices } = await execPromise('pactl list short sources 2>/dev/null || echo ""');
const { stdout: paSinks } = await execPromise('pactl list short sinks 2>/dev/null || echo ""');
// Helper pour obtenir une description lisible
const getDeviceDescription = (deviceId) => {
// Extraire une description plus lisible du nom technique
if (deviceId.includes('alsa_input')) return deviceId.replace('alsa_input.', 'Input: ');
if (deviceId.includes('alsa_output')) return deviceId.replace('alsa_output.', 'Output: ');
return deviceId;
};
if (paDevices.trim()) {
paDevices.split('\n').filter(Boolean).forEach((line) => {
const parts = line.split('\t');
const deviceId = parts[1]; // Nom du device (ex: alsa_input.pci-...)
if (deviceId && !deviceId.includes('.monitor')) { // Ignorer les monitors
devices.inputs.push({
id: deviceId,
name: getDeviceDescription(deviceId)
});
}
});
}
if (paSinks.trim()) {
paSinks.split('\n').filter(Boolean).forEach((line) => {
const parts = line.split('\t');
const deviceId = parts[1]; // Nom du device (ex: alsa_output.pci-...)
if (deviceId) {
devices.outputs.push({
id: deviceId,
name: getDeviceDescription(deviceId)
});
}
});
}
}
} catch (linuxError) {
console.warn('⚠️ Détection devices Linux échouée:', linuxError.message);
devices.inputs.push({ id: 0, name: 'Default Input', isDefault: true });
devices.outputs.push({ id: 0, name: 'Default Output', isDefault: true });
}
} else if (process.platform === 'win32') {
// Windows : WASAPI (Phase 3)
// TODO: implémenter détection WASAPI
devices.inputs.push({ id: 0, name: 'Default Input (Windows)', isDefault: true });
devices.outputs.push({ id: 0, name: 'Default Output (Windows)', isDefault: true });
}
addLog('info', 'Audio devices listed', {
inputsCount: devices.inputs.length,
outputsCount: devices.outputs.length
});
res.json(devices);
} catch (error) {
console.error('Erreur GET /admin/devices/list:', error);
res.status(500).json({
error: 'Failed to list audio devices',
message: error.message,
platform: process.platform
});
}
});
export default router;
+516 -77
View File
@@ -13,9 +13,12 @@
import { EventEmitter } from 'events';
import { platform } from 'os';
import CoreAudioBackend from './backends/CoreAudioBackend.js';
import JACKBackend from './backends/JACKBackend.js';
import PipeWireBackend from './backends/PipeWireBackend.js';
import OpusCodec, { OpusPresets } from './OpusCodec.js';
import JitterBuffer, { JitterBufferPresets } from './JitterBuffer.js';
import LiveKitClient from './LiveKitClient.js';
import GroupAudioRouter from './GroupAudioRouter.js';
export class AudioBridge extends EventEmitter {
constructor(options = {}) {
@@ -51,12 +54,27 @@ export class AudioBridge extends EventEmitter {
this.opusEncoder = null;
this.opusDecoder = null;
this.jitterBuffer = null;
this.liveKitClient = null;
this.liveKitClients = new Map(); // Map<groupName, LiveKitClient> - un client par groupe
this.groupAudioRouter = null;
// État
this.isRunning = false;
this.backendType = null;
// Buffers pour routing multi-canaux
this.inputChannelBuffers = new Map(); // Map<channelId, Float32Array>
this.groupBuffersFromLiveKit = new Map(); // Map<groupName, Float32Array>
// Frame accumulators pour LiveKit (240 samples → 960 samples)
this.liveKitFrameAccumulators = new Map(); // Map<groupName, { buffer: Float32Array, offset: number }>
// Pool de buffers pré-alloués pour éviter allocations répétées
this.bufferPool = {
float32: [], // Pool de Float32Array réutilisables
pcm: [] // Pool de Buffer PCM réutilisables
};
this.maxPoolSize = 50; // Limite du pool (adapté pour 30+ clients)
// Statistiques
this.stats = {
startTime: null,
@@ -96,10 +114,13 @@ export class AudioBridge extends EventEmitter {
// 3. Initialisation du jitter buffer
this._initJitterBuffer();
// 4. Connexion à LiveKit
// 4. Initialisation du GroupAudioRouter
this._initGroupAudioRouter();
// 5. Connexion à LiveKit
await this._initLiveKit();
// 5. Démarrage du routing audio
// 6. Démarrage du routing audio
await this._startAudioRouting();
this.isRunning = true;
@@ -123,27 +144,52 @@ export class AudioBridge extends EventEmitter {
*/
async _initAudioBackend() {
const os = platform();
let BackendClass = null;
let devices = [];
// macOS : CoreAudio prioritaire
if (os === 'darwin') {
if (CoreAudioBackend.isAvailable()) {
this.backendType = 'CoreAudio';
this.audioBackend = new CoreAudioBackend({
sampleRate: this.options.sampleRate,
channels: this.options.channels,
framesPerBuffer: this.options.frameSize,
inputDeviceId: this.options.inputDeviceId,
outputDeviceId: this.options.outputDeviceId
});
BackendClass = CoreAudioBackend;
console.log('✓ Backend audio : CoreAudio (macOS natif)');
} else {
throw new Error('CoreAudio non disponible sur ce système');
}
}
// Linux : JACK ou PipeWire (Phase 3)
// Linux : PipeWire > JACK (ordre de préférence)
else if (os === 'linux') {
throw new Error('Support Linux non encore implémenté (Phase 3)');
// Détection automatique : préfère PipeWire (moderne) puis JACK (pro)
if (PipeWireBackend.isAvailable() && PipeWireBackend.isServerRunning()) {
this.backendType = 'PipeWire';
BackendClass = PipeWireBackend;
console.log('✓ Backend audio : PipeWire (Linux moderne)');
} else if (JACKBackend.isAvailable() && JACKBackend.isServerRunning()) {
this.backendType = 'JACK';
BackendClass = JACKBackend;
console.log('✓ Backend audio : JACK (Linux professionnel)');
} else {
// Aucun backend disponible
const pipewireInstalled = PipeWireBackend.isAvailable();
const jackInstalled = JACKBackend.isAvailable();
let errorMsg = 'Aucun backend audio disponible sur Linux.\n';
if (!pipewireInstalled && !jackInstalled) {
errorMsg += 'Installez PipeWire (recommandé) ou JACK :\n';
errorMsg += ' Ubuntu/Debian : sudo apt install pipewire pipewire-pulse\n';
errorMsg += ' Arch Linux : sudo pacman -S pipewire pipewire-pulse\n';
errorMsg += ' JACK : sudo apt install jackd2 jack-tools';
} else if (pipewireInstalled && !PipeWireBackend.isServerRunning()) {
errorMsg += 'PipeWire installé mais non démarré.\n';
errorMsg += 'Démarrez-le : systemctl --user start pipewire pipewire-pulse';
} else if (jackInstalled && !JACKBackend.isServerRunning()) {
errorMsg += 'JACK installé mais serveur non démarré.\n';
errorMsg += 'Démarrez-le : jackd -d alsa -r 48000';
}
throw new Error(errorMsg);
}
}
// Windows : WASAPI (futur)
else if (os === 'win32') {
@@ -153,8 +199,48 @@ export class AudioBridge extends EventEmitter {
throw new Error(`Plateforme non supportée : ${os}`);
}
// Résoudre les device IDs vers les noms pour CoreAudio/sox
let inputDeviceName = null;
let outputDeviceName = null;
if (this.options.inputDeviceId) {
const inputDevice = BackendClass.getDevices().find(d => d.id === this.options.inputDeviceId);
inputDeviceName = inputDevice ? inputDevice.name : this.options.inputDeviceId;
console.log(`📥 Input device: "${inputDeviceName}" (ID: ${this.options.inputDeviceId})`);
}
if (this.options.outputDeviceId) {
const outputDevice = BackendClass.getDevices().find(d => d.id === this.options.outputDeviceId);
outputDeviceName = outputDevice ? outputDevice.name : this.options.outputDeviceId;
console.log(`📤 Output device: "${outputDeviceName}" (ID: ${this.options.outputDeviceId})`);
}
// Initialisation du backend sélectionné
const backendOptions = {
sampleRate: this.options.sampleRate,
channels: this.options.channels,
framesPerBuffer: this.options.frameSize,
latency: this.options.latency || 20
};
// PipeWire utilise targetDevice, CoreAudio utilise inputDeviceName/outputDeviceName
if (this.backendType === 'PipeWire') {
// Pour PipeWire, on utilise inputDeviceId directement comme targetDevice
// (startCapture et startPlayback peuvent avoir des targets différents)
backendOptions.inputTargetDevice = this.options.inputDeviceId;
backendOptions.outputTargetDevice = this.options.outputDeviceId;
} else {
// CoreAudio et autres backends
backendOptions.inputDeviceId = this.options.inputDeviceId;
backendOptions.inputDeviceName = inputDeviceName;
backendOptions.outputDeviceId = this.options.outputDeviceId;
backendOptions.outputDeviceName = outputDeviceName;
}
this.audioBackend = new BackendClass(backendOptions);
// Liste des devices disponibles
const devices = CoreAudioBackend.getDevices();
devices = BackendClass.getDevices();
console.log(`📻 Devices audio détectés : ${devices.length}`);
devices.forEach(d => {
console.log(` - ${d.name} (in:${d.maxInputChannels}, out:${d.maxOutputChannels})`);
@@ -215,102 +301,440 @@ export class AudioBridge extends EventEmitter {
}
/**
* Initialise la connexion LiveKit
* Initialise le GroupAudioRouter pour le routing multi-canaux
* @private
*/
async _initLiveKit() {
if (!this.options.liveKitToken) {
throw new Error('Token LiveKit requis');
_initGroupAudioRouter() {
this.groupAudioRouter = new GroupAudioRouter({
sampleRate: this.options.sampleRate,
frameSize: this.options.frameSize,
maxInputChannels: this.options.maxInputChannels || 32,
maxOutputChannels: this.options.maxOutputChannels || 32,
groups: this.options.groups || []
});
// Charger la configuration de routing depuis les options
if (this.options.routing) {
this.groupAudioRouter.configure(this.options.routing);
}
this.liveKitClient = new LiveKitClient({
url: this.options.liveKitUrl,
token: this.options.liveKitToken,
roomName: this.options.roomName,
participantName: 'AudioBridge',
audioBitrate: this.opusEncoder.options.bitrate
// Events du router
this.groupAudioRouter.on('configured', (stats) => {
console.log(`✓ GroupAudioRouter configuré : ${stats.routesActive} routes`);
});
// Events LiveKit
this.liveKitClient.on('connected', () => {
console.log('✓ LiveKit connecté');
});
this.liveKitClient.on('disconnected', ({ reason }) => {
console.warn('⚠️ LiveKit déconnecté:', reason);
this.stats.errors.network++;
});
this.liveKitClient.on('reconnecting', () => {
console.log('🔄 LiveKit reconnexion...');
});
this.liveKitClient.on('audioTrackSubscribed', ({ track, participant }) => {
console.log(`🎵 Nouveau track audio : ${participant.identity}`);
this._handleRemoteAudioTrack(track);
});
await this.liveKitClient.connect();
console.log('✓ GroupAudioRouter initialisé');
}
/**
* Démarre le routing audio bidirectionnel
* Initialise les connexions LiveKit (une par groupe)
* @private
*/
async _initLiveKit() {
if (!this.options.liveKitTokens || !Array.isArray(this.options.liveKitTokens)) {
throw new Error('liveKitTokens requis (tableau d\'objets { groupName, groupId, token })');
}
console.log(`🔌 Initialisation ${this.options.liveKitTokens.length} connexions LiveKit (une par groupe)...`);
// Créer un LiveKitClient pour chaque groupe
for (const { groupName, groupId, token } of this.options.liveKitTokens) {
const roomName = groupId; // La room porte le nom du groupId (slugifié)
const client = new LiveKitClient({
url: this.options.liveKitUrl,
token,
roomName,
participantName: `AudioBridge-${groupId}`,
sampleRate: this.options.sampleRate,
channels: this.options.channels,
audioBitrate: this.opusEncoder.options.bitrate
});
// Events LiveKit pour ce groupe
client.on('connected', () => {
console.log(`✓ LiveKit connecté pour groupe "${groupName}" (room: ${roomName})`);
});
client.on('disconnected', (data) => {
const reason = data?.reason || 'unknown';
console.warn(`⚠️ LiveKit déconnecté pour groupe "${groupName}":`, reason);
this.stats.errors.network++;
});
client.on('reconnecting', () => {
console.log(`🔄 LiveKit reconnexion pour groupe "${groupName}"...`);
});
client.on('audioTrackSubscribed', ({ track, participant }) => {
console.log(`🎵 Nouveau track audio dans groupe "${groupName}": ${participant.identity}`);
});
// Réception audio depuis les clients LiveKit de ce groupe
client.on('audioData', ({ participantName, pcmData, sampleRate, channels }) => {
// Router vers le bon groupe
this.emit('groupAudioIn', { groupName: groupId, pcmBuffer: pcmData });
});
// Connexion
await client.connect();
// Stocker le client par groupId
this.liveKitClients.set(groupId, client);
}
console.log(`${this.liveKitClients.size} connexions LiveKit établies`);
}
/**
* Démarre le routing audio bidirectionnel complet
* @private
*/
async _startAudioRouting() {
// ===== ROUTING CAPTURE : CoreAudio → Opus → LiveKit =====
console.log('🔄 Démarrage routing audio bidirectionnel...');
// ===== FLUX 1 : CAPTURE (Carte Son → Groupes → LiveKit → Clients) =====
this.audioBackend.on('audioData', (pcmData) => {
try {
// Encodage PCM → Opus
const opusData = this.opusEncoder.encode(pcmData);
// Convertir PCM Buffer → Float32Array (pour GroupAudioRouter)
const float32Data = this._bufferToFloat32(pcmData);
if (opusData) {
this.stats.framesCapture++;
this.stats.bytesEncoded += opusData.length;
// Séparer les canaux si audio multi-canaux (entrelacé)
const numChannels = this.options.channels || 1;
// TODO: Envoyer à LiveKit via track custom ou DataChannel
// Pour l'instant, LiveKit gère l'audio via MediaStream natif
// Cette partie sera complétée en fonction de l'architecture finale
if (numChannels === 1) {
// Mono : un seul canal
const channelId = this.options.inputDeviceChannel || 0;
this.inputChannelBuffers.set(channelId, float32Data);
} else {
this.stats.errors.encode++;
// Multi-canaux : dé-entrelacer les samples
// Format entrelacé : [L0, R0, L1, R1, ...] → [L0, L1, ...] et [R0, R1, ...]
const samplesPerChannel = float32Data.length / numChannels;
for (let ch = 0; ch < numChannels; ch++) {
const channelBuffer = new Float32Array(samplesPerChannel);
for (let i = 0; i < samplesPerChannel; i++) {
channelBuffer[i] = float32Data[i * numChannels + ch];
}
// Mapper canal hardware → canal logique (peut être configuré)
const logicalChannelId = this.options.channelMapping?.[ch] ?? ch;
this.inputChannelBuffers.set(logicalChannelId, channelBuffer);
}
}
// ÉTAPE 1 : Inputs physiques → Groupes (via GroupAudioRouter)
const groupBuffers = this.groupAudioRouter.processInputsToGroups(
this.inputChannelBuffers
);
if (this.stats.framesCapture % 100 === 0) {
// Détecter si l'audio est du silence (toutes les samples < 0.001)
let totalEnergy = 0;
this.inputChannelBuffers.forEach((buffer) => {
for (let i = 0; i < buffer.length; i++) {
totalEnergy += Math.abs(buffer[i]);
}
});
const avgEnergy = totalEnergy / (this.inputChannelBuffers.size * (this.options.frameSize || 960));
console.log(`[AudioBridge] Frame ${this.stats.framesCapture}: ${this.inputChannelBuffers.size} inputs → ${groupBuffers.size} groupes | Énergie audio: ${avgEnergy.toFixed(6)}`);
}
// ÉTAPE 2 : Pour chaque groupe, envoyer vers le LiveKitClient correspondant
groupBuffers.forEach((groupBuffer, groupName) => {
// Les groupes sont MONO (Float32Array de N samples)
// Mais la config globale peut être STÉRÉO (channels=2)
// → Adapter selon la configuration
let pcmBuffer;
const configChannels = this.options.channels || 1;
if (configChannels === 1) {
// Config MONO : envoyer directement
pcmBuffer = this._float32ToBuffer(groupBuffer);
} else if (configChannels === 2) {
// Config STÉRÉO : dupliquer le canal mono
const samplesPerChannel = groupBuffer.length;
const stereoBuffer = new Float32Array(samplesPerChannel * 2);
// Entrelacer : [M0, M1, M2, ...] → [M0, M0, M1, M1, M2, M2, ...]
for (let i = 0; i < samplesPerChannel; i++) {
stereoBuffer[i * 2] = groupBuffer[i]; // Canal gauche
stereoBuffer[i * 2 + 1] = groupBuffer[i]; // Canal droit (dupliqué)
}
pcmBuffer = this._float32ToBuffer(stereoBuffer);
} else {
console.error(`❌ Nombre de canaux non supporté: ${configChannels}`);
return;
}
// Récupérer le client LiveKit pour ce groupe
const client = this.liveKitClients.get(groupName);
// Envoi vers LiveKit via sendAudioData (prend du PCM 16-bit)
// Note: LiveKit gère lui-même l'encodage Opus en interne
if (client && client.isConnected) {
client.sendAudioData(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
const channelLabel = configChannels === 1 ? 'mono' : `${configChannels}ch`;
console.log(`[AudioBridge] → LiveKit groupe "${groupName}": ${pcmBuffer.length} bytes (${channelLabel})`);
}
} else {
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] ⚠️ LiveKit non connecté pour groupe "${groupName}", audio non envoyé`);
}
}
// Émettre aussi pour monitoring/debug
this.emit('groupAudioOut', { groupName, pcmBuffer });
});
// ÉTAPE 3 : Loopback local - Groupes → Outputs physiques (sans passer par LiveKit)
const outputBuffers = this.groupAudioRouter.processGroupsToOutputs(groupBuffers);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] Loopback local: ${groupBuffers.size} groupes → ${outputBuffers.size} outputs`);
}
// ÉTAPE 4 : Envoyer chaque output à la carte son
const numOutputChannels = this.options.channels || 1;
if (numOutputChannels === 1) {
// Mono : un seul output
if (outputBuffers.size > 0) {
const [firstChannelId, outputBuffer] = outputBuffers.entries().next().value;
const pcmBuffer = this._float32ToBuffer(outputBuffer);
this.audioBackend.queueAudio(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → Output mono (canal ${firstChannelId}): ${pcmBuffer.length} bytes`);
}
}
} else {
// Multi-canaux : entrelacer les samples
// Récupérer les buffers dans l'ordre des canaux hardware
const channelBuffers = [];
const samplesPerChannel = this.options.frameSize;
for (let ch = 0; ch < numOutputChannels; ch++) {
const logicalChannelId = this.options.channelMapping?.[ch] ?? ch;
const buffer = outputBuffers.get(logicalChannelId);
if (buffer && buffer.length === samplesPerChannel) {
channelBuffers.push(buffer);
} else {
// Canal absent ou taille incorrecte : silence
channelBuffers.push(new Float32Array(samplesPerChannel));
}
}
// Entrelacer : [L0, L1, ...] et [R0, R1, ...] → [L0, R0, L1, R1, ...]
const interleavedBuffer = new Float32Array(samplesPerChannel * numOutputChannels);
for (let i = 0; i < samplesPerChannel; i++) {
for (let ch = 0; ch < numOutputChannels; ch++) {
interleavedBuffer[i * numOutputChannels + ch] = channelBuffers[ch][i];
}
}
const pcmBuffer = this._float32ToBuffer(interleavedBuffer);
this.audioBackend.queueAudio(pcmBuffer);
if (this.stats.framesCapture % 100 === 0) {
console.log(`[AudioBridge] → Output multi-canaux (${numOutputChannels}ch): ${pcmBuffer.length} bytes`);
}
}
this.stats.framesCapture++;
this.stats.framesPlayback++;
} catch (error) {
console.error('Erreur routing capture:', error);
this.stats.errors.capture++;
}
});
// Démarrage capture
await this.audioBackend.startCapture();
// ===== FLUX 2 : LECTURE (Clients → LiveKit → Groupes → Carte Son) =====
// ===== ROUTING LECTURE : LiveKit → Opus → CoreAudio =====
// La lecture sera démarrée une fois qu'on reçoit des tracks distants
// Écouter l'audio entrant de LiveKit (sera connecté par LiveKitServerBridge)
this.on('groupAudioIn', ({ groupName, pcmBuffer }) => {
try {
// Convertir PCM Buffer → Float32Array
const float32Data = this._bufferToFloat32(pcmBuffer);
const samplesReceived = float32Data.length;
// Initialiser l'accumulateur pour ce groupe si nécessaire
if (!this.liveKitFrameAccumulators.has(groupName)) {
this.liveKitFrameAccumulators.set(groupName, {
buffer: new Float32Array(960), // Frame size attendu par GroupRouter
offset: 0
});
}
const accumulator = this.liveKitFrameAccumulators.get(groupName);
// Vérifier que le buffer ne débordera pas
const availableSpace = 960 - accumulator.offset;
const samplesToCopy = Math.min(samplesReceived, availableSpace);
// Copier les samples dans l'accumulateur
if (samplesToCopy > 0) {
accumulator.buffer.set(float32Data.subarray(0, samplesToCopy), accumulator.offset);
accumulator.offset += samplesToCopy;
}
// Si on a accumulé assez de samples (960), router vers les outputs
if (accumulator.offset >= 960) {
// Vérifier que le backend est toujours actif (évite crash pendant shutdown)
if (!this.audioBackend) {
return;
}
// Stocker le buffer complet pour le routing
this.groupBuffersFromLiveKit.set(groupName, accumulator.buffer);
// ÉTAPE 3 : Groupes → Outputs physiques (via GroupAudioRouter)
const outputBuffers = this.groupAudioRouter.processGroupsToOutputs(
this.groupBuffersFromLiveKit
);
// ÉTAPE 4 : Envoyer chaque output à la carte son
outputBuffers.forEach((outputBuffer, channelId) => {
const pcmBuffer = this._float32ToBuffer(outputBuffer);
this.audioBackend.queueAudio(pcmBuffer);
});
// Réinitialiser l'accumulateur
accumulator.offset = 0;
accumulator.buffer.fill(0);
this.stats.framesPlayback++;
}
} catch (error) {
console.error('Erreur routing lecture:', error);
this.stats.errors.playback++;
}
});
// Démarrage des streams audio
await this.audioBackend.startCapture();
await this.audioBackend.startPlayback();
console.log('✓ Routing audio bidirectionnel actif');
console.log(' → Carte Son → GroupRouter → LiveKit → Clients');
console.log(' ← Carte Son ← GroupRouter ← LiveKit ← Clients');
}
/**
* Gère l'arrivée d'un track audio distant
* @param {RemoteAudioTrack} track - Track LiveKit
* Acquiert un Float32Array depuis le pool ou en crée un nouveau
* @param {number} size - Taille du buffer
* @returns {Float32Array}
* @private
*/
_handleRemoteAudioTrack(track) {
// Récupération du MediaStream du track
const mediaStream = new MediaStream([track.mediaStreamTrack]);
_acquireFloat32Buffer(size) {
const pooled = this.bufferPool.float32.find(b => b.length === size);
if (pooled) {
this.bufferPool.float32.splice(this.bufferPool.float32.indexOf(pooled), 1);
return pooled;
}
return new Float32Array(size);
}
// Note: Pour décoder Opus côté serveur, on aurait besoin d'accéder
// aux données brutes via DataChannel ou API bas niveau
// LiveKit gère nativement le décodage WebRTC → PCM dans le navigateur
/**
* Retourne un Float32Array au pool pour réutilisation
* @param {Float32Array} buffer
* @private
*/
_releaseFloat32Buffer(buffer) {
if (this.bufferPool.float32.length < this.maxPoolSize) {
this.bufferPool.float32.push(buffer);
}
}
// Pour un vrai bridge serveur, il faudrait :
// 1. Recevoir les paquets Opus via DataChannel ou API custom
// 2. Décoder avec opusDecoder
// 3. Envoyer au jitterBuffer
// 4. Lire depuis jitterBuffer vers CoreAudio
/**
* Acquiert un Buffer PCM depuis le pool ou en crée un nouveau
* @param {number} size - Taille du buffer
* @returns {Buffer}
* @private
*/
_acquirePcmBuffer(size) {
const pooled = this.bufferPool.pcm.find(b => b.length === size);
if (pooled) {
this.bufferPool.pcm.splice(this.bufferPool.pcm.indexOf(pooled), 1);
return pooled;
}
return Buffer.alloc(size);
}
// TODO: Implémenter réception bas niveau Opus depuis LiveKit
console.warn('Réception track distant : implémentation complète en cours');
/**
* Retourne un Buffer PCM au pool pour réutilisation
* @param {Buffer} buffer
* @private
*/
_releasePcmBuffer(buffer) {
if (this.bufferPool.pcm.length < this.maxPoolSize) {
this.bufferPool.pcm.push(buffer);
}
}
/**
* Convertit Buffer/Int16Array PCM 16-bit Float32Array [-1.0, 1.0]
* @param {Buffer|Int16Array|Uint8Array} buffer - Buffer PCM 16-bit signed
* @returns {Float32Array}
* @private
*/
_bufferToFloat32(buffer) {
let samples;
let float32;
// Cas 1 : Int16Array (LiveKit Node SDK format)
if (buffer instanceof Int16Array) {
samples = buffer.length;
float32 = this._acquireFloat32Buffer(samples);
for (let i = 0; i < samples; i++) {
float32[i] = buffer[i] / 32768.0;
}
return float32;
}
// Cas 2 : Buffer/Uint8Array (format classique)
if (!(buffer instanceof Buffer)) {
buffer = Buffer.from(buffer);
}
samples = buffer.length / 2; // 2 bytes per sample (16-bit)
float32 = this._acquireFloat32Buffer(samples);
for (let i = 0; i < samples; i++) {
// Lire 16-bit signed little-endian
const int16 = buffer.readInt16LE(i * 2);
// Normaliser vers [-1.0, 1.0]
float32[i] = int16 / 32768.0;
}
return float32;
}
/**
* Convertit Float32Array [-1.0, 1.0] Buffer PCM 16-bit
* @param {Float32Array} float32 - Données audio normalisées
* @returns {Buffer}
* @private
*/
_float32ToBuffer(float32) {
const buffer = this._acquirePcmBuffer(float32.length * 2); // 2 bytes per sample
for (let i = 0; i < float32.length; i++) {
// Clamping [-1.0, 1.0]
const clamped = Math.max(-1.0, Math.min(1.0, float32[i]));
// Convertir vers 16-bit signed
const int16 = Math.round(clamped * 32767);
buffer.writeInt16LE(int16, i * 2);
}
return buffer;
}
/**
@@ -329,9 +753,16 @@ export class AudioBridge extends EventEmitter {
this.audioBackend = null;
}
if (this.liveKitClient) {
await this.liveKitClient.destroy();
this.liveKitClient = null;
// Déconnecter tous les clients LiveKit
for (const [groupName, client] of this.liveKitClients.entries()) {
console.log(`🔌 Déconnexion LiveKit groupe "${groupName}"...`);
await client.destroy();
}
this.liveKitClients.clear();
if (this.groupAudioRouter) {
this.groupAudioRouter.destroy();
this.groupAudioRouter = null;
}
if (this.jitterBuffer) {
@@ -349,6 +780,14 @@ export class AudioBridge extends EventEmitter {
this.opusDecoder = null;
}
// Nettoyer les buffers
this.inputChannelBuffers.clear();
this.groupBuffersFromLiveKit.clear();
// Nettoyer le pool de buffers
this.bufferPool.float32 = [];
this.bufferPool.pcm = [];
this.isRunning = false;
console.log('✓ AudioBridge arrêté');
+230
View File
@@ -0,0 +1,230 @@
/**
* AudioBridgeManager.js
* Gestionnaire du bridge audio avec support hot-reload
* Phase 2.5
*/
import { EventEmitter } from 'events';
import { AccessToken } from 'livekit-server-sdk';
import configManager from '../config/ConfigManager.js';
class AudioBridgeManager extends EventEmitter {
constructor() {
super();
this.bridge = null;
this.isRunning = false;
// Écouter les événements de configuration
configManager.on('audio-device-updated', this.handleDeviceUpdate.bind(this));
configManager.on('config-updated', this.handleConfigUpdate.bind(this));
}
/**
* Démarre le bridge audio avec la configuration actuelle
* @param {Object} options - Options de démarrage
* @param {string} options.liveKitUrl - URL LiveKit résolue (déjà avec IP si AUTO)
*/
async start(options = {}) {
if (this.isRunning) {
console.warn('⚠️ AudioBridge déjà démarré');
return;
}
try {
const config = configManager.get();
console.log('🎵 Démarrage AudioBridge avec configuration:', config.audio);
// Générer un token JWT par groupe
const liveKitTokens = [];
// Fonction pour slugifier le nom (identique à admin.js)
const slugify = (text) => {
return text
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '')
.replace(/--+/g, '-');
};
for (const group of config.groups || []) {
const groupId = slugify(group.name);
const groupName = group.name;
const token = new AccessToken(
config.server?.livekit?.apiKey || 'devkey',
config.server?.livekit?.apiSecret || 'secret',
{
identity: `AudioBridge-${groupId}`,
name: `Audio Bridge - ${groupName}`,
metadata: JSON.stringify({
role: 'bridge',
group: groupId,
capabilities: ['audio-routing', 'monitoring']
})
}
);
// Permissions complètes pour ce groupe
token.addGrant({
room: groupId, // Chaque groupe a sa propre room
roomJoin: true,
canPublish: true,
canSubscribe: true,
canPublishData: true
});
const jwt = await token.toJwt();
liveKitTokens.push({ groupName, groupId, token: jwt });
console.log(`✓ Token JWT généré pour groupe "${groupName}" (room: ${groupId})`);
}
if (liveKitTokens.length === 0) {
console.warn('⚠️ Aucun groupe configuré, AudioBridge ne pourra pas démarrer');
this.isRunning = false;
return;
}
// Import dynamique du AudioBridge
const { AudioBridge } = await import('./AudioBridge.js');
// Préparer la config avec conversion explicite des valeurs numériques
const audioConfig = { ...config.audio };
// Conversion explicite des paramètres numériques (depuis YAML ils peuvent être strings)
if (audioConfig.sampleRate) audioConfig.sampleRate = parseInt(audioConfig.sampleRate, 10);
if (audioConfig.channels) audioConfig.channels = parseInt(audioConfig.channels, 10);
// frameSize en millisecondes → conversion en nombre d'échantillons
// Ex: 20ms à 48kHz = 960 échantillons
if (audioConfig.frameSize) {
const frameSizeMs = parseInt(audioConfig.frameSize, 10);
const sampleRate = audioConfig.sampleRate || 48000;
audioConfig.frameSize = Math.floor((frameSizeMs * sampleRate) / 1000);
}
if (audioConfig.defaultBitrate) audioConfig.defaultBitrate = parseInt(audioConfig.defaultBitrate, 10);
if (audioConfig.customOpusBitrate) audioConfig.customOpusBitrate = parseInt(audioConfig.customOpusBitrate, 10);
// Extraire les device IDs depuis le sous-objet device
const inputDeviceId = audioConfig.device?.inputDeviceId || null;
const outputDeviceId = audioConfig.device?.outputDeviceId || null;
// Utiliser l'URL résolue passée en option, sinon fallback config
const liveKitUrl = options.liveKitUrl || config.server?.livekit?.url || 'ws://localhost:7880';
// Créer l'instance avec la config
this.bridge = new AudioBridge({
...audioConfig,
// Options LiveKit (multi-rooms)
liveKitUrl,
liveKitTokens, // Tableau de { groupName, groupId, token }
// Options de routing
routing: config.audio?.routing || {},
groups: config.groups || [],
maxInputChannels: 32,
maxOutputChannels: 32,
// Device IDs extraits
inputDeviceId,
outputDeviceId
});
// Démarrer le bridge
await this.bridge.start();
this.isRunning = true;
console.log('✓ AudioBridge démarré avec succès');
this.emit('started');
} catch (error) {
console.error('❌ Erreur démarrage AudioBridge:', error);
// Ne pas throw pour éviter de bloquer le serveur si pas de carte son
console.warn('⚠️ Le serveur continue sans AudioBridge actif');
this.isRunning = false;
}
}
/**
* Arrête le bridge audio
*/
async stop() {
if (!this.isRunning) {
return;
}
try {
console.log('⏹ Arrêt AudioBridge...');
if (this.bridge) {
await this.bridge.stop();
this.bridge = null;
}
this.isRunning = false;
console.log('✓ AudioBridge arrêté');
this.emit('stopped');
} catch (error) {
console.error('❌ Erreur arrêt AudioBridge:', error);
throw error;
}
}
/**
* Recharge le bridge avec la nouvelle configuration
*/
async reload() {
try {
console.log('🔄 Rechargement AudioBridge...');
await this.stop();
await this.start();
console.log('✓ AudioBridge rechargé avec succès');
this.emit('reloaded');
} catch (error) {
console.error('❌ Erreur rechargement AudioBridge:', error);
throw error;
}
}
/**
* Gestionnaire événement mise à jour device audio
*/
async handleDeviceUpdate(deviceConfig) {
console.log('🔧 Device audio mis à jour:', deviceConfig);
console.log('→ Rechargement AudioBridge requis...');
// Auto-reload du bridge
if (this.isRunning) {
await this.reload();
}
}
/**
* Gestionnaire événement mise à jour configuration
*/
handleConfigUpdate(config) {
console.log('🔧 Configuration mise à jour');
// Peut déclencher un reload si nécessaire
}
/**
* Retourne l'état actuel du bridge
*/
getStatus() {
return {
running: this.isRunning,
config: configManager.get().audio
};
}
}
// Singleton
const audioBridgeManager = new AudioBridgeManager();
export default audioBridgeManager;
+417
View File
@@ -0,0 +1,417 @@
/**
* GroupAudioRouter.js
* Gestion du routing audio multi-canaux entre entrées physiques, groupes LiveKit et sorties physiques
*
* Architecture :
* - Mix de plusieurs canaux physiques vers un groupe (avec gains individuels)
* - Distribution d'un groupe vers plusieurs canaux physiques (avec gains individuels)
* - Support canaux partagés (mixage additif)
* - Gestion gains par route (-120dB à +6dB)
*/
import { EventEmitter } from 'events';
import { getLogger } from '../utils/Logger.js';
const logger = getLogger('Routing');
/**
* Représente une route audio avec gain
*/
class AudioRoute {
constructor(source, destination, gain = 0.0) {
this.source = source; // Numéro de canal ou nom de groupe
this.destination = destination; // Nom de groupe ou numéro de canal
this.gain = gain; // Gain en dB (-120 à +6)
this.linearGain = this._dbToLinear(gain);
}
/**
* Met à jour le gain en dB
*/
setGain(gainDb) {
this.gain = Math.max(-120, Math.min(6, gainDb));
this.linearGain = this._dbToLinear(this.gain);
}
/**
* Convertit dB en gain linéaire
*/
_dbToLinear(db) {
if (db <= -120) return 0.0;
return Math.pow(10, db / 20);
}
}
/**
* Router audio principal
*/
export class GroupAudioRouter extends EventEmitter {
constructor(config = {}) {
super();
this.config = {
sampleRate: config.sampleRate || 48000,
frameSize: config.frameSize || 960, // 20ms à 48kHz
maxInputChannels: config.maxInputChannels || 32,
maxOutputChannels: config.maxOutputChannels || 32,
groups: config.groups || []
};
// Routes : input -> group
this.inputToGroupRoutes = new Map(); // Map<string, AudioRoute[]>
// Routes : group -> output
this.groupToOutputRoutes = new Map(); // Map<string, AudioRoute[]>
// Buffers audio
this.inputBuffers = new Map(); // Map<number, Float32Array>
this.groupBuffers = new Map(); // Map<string, Float32Array>
this.outputBuffers = new Map(); // Map<number, Float32Array>
// Statistiques
this.stats = {
framesProcessed: 0,
clippingEvents: 0,
routesActive: 0
};
}
/**
* Configure le routing depuis la config YAML
*/
configure(routingConfig) {
logger.info('Configuration du routing audio...');
logger.debug(' Groupes disponibles:', this.config.groups.map(g => `${g.name || g} (id: ${g.id || g})`).join(', '));
logger.debug(' inputToGroup:', JSON.stringify(routingConfig.inputToGroup || {}));
logger.debug(' groupToOutput:', JSON.stringify(routingConfig.groupToOutput || {}));
// Réinitialise les routes
this.inputToGroupRoutes.clear();
this.groupToOutputRoutes.clear();
// Configure input -> group
if (routingConfig.inputToGroup) {
Object.entries(routingConfig.inputToGroup).forEach(([channelId, groups]) => {
const channel = parseInt(channelId);
groups.forEach(groupName => {
this.addInputToGroupRoute(channel, groupName, this._getGain(routingConfig.gains, `in_${channel}_${groupName}`));
});
});
}
// Configure group -> output
if (routingConfig.groupToOutput) {
Object.entries(routingConfig.groupToOutput).forEach(([groupName, channels]) => {
channels.forEach(channelId => {
const channel = parseInt(channelId);
this.addGroupToOutputRoute(groupName, channel, this._getGain(routingConfig.gains, `${groupName}_out_${channel}`));
});
});
}
this._updateStatsActiveRoutes();
logger.success(`Routing configuré : ${this.stats.routesActive} routes actives`);
this.emit('configured', this.stats);
}
/**
* Récupère le gain depuis la config
*/
_getGain(gainsConfig, routeKey) {
return gainsConfig && gainsConfig[routeKey] ? gainsConfig[routeKey] : 0.0;
}
/**
* Ajoute une route input -> group
*/
addInputToGroupRoute(inputChannel, groupName, gainDb = 0.0) {
const key = `in_${inputChannel}`;
if (!this.inputToGroupRoutes.has(key)) {
this.inputToGroupRoutes.set(key, []);
}
const route = new AudioRoute(inputChannel, groupName, gainDb);
this.inputToGroupRoutes.get(key).push(route);
logger.info(`Input ${inputChannel} → Group "${groupName}" (${gainDb}dB)`);
this._updateStatsActiveRoutes();
}
/**
* Ajoute une route group -> output
*/
addGroupToOutputRoute(groupName, outputChannel, gainDb = 0.0) {
const key = groupName;
if (!this.groupToOutputRoutes.has(key)) {
this.groupToOutputRoutes.set(key, []);
}
const route = new AudioRoute(groupName, outputChannel, gainDb);
this.groupToOutputRoutes.get(key).push(route);
logger.info(`Group "${groupName}" → Output ${outputChannel} (${gainDb}dB)`);
this._updateStatsActiveRoutes();
}
/**
* Supprime toutes les routes d'une entrée
*/
removeInputRoutes(inputChannel) {
this.inputToGroupRoutes.delete(`in_${inputChannel}`);
this._updateStatsActiveRoutes();
}
/**
* Supprime toutes les routes d'un groupe vers les sorties
*/
removeGroupOutputRoutes(groupName) {
this.groupToOutputRoutes.delete(groupName);
this._updateStatsActiveRoutes();
}
/**
* Met à jour le gain d'une route spécifique
*/
setRouteGain(source, destination, gainDb) {
// Cherche dans input -> group
const inputKey = typeof source === 'number' ? `in_${source}` : null;
if (inputKey && this.inputToGroupRoutes.has(inputKey)) {
const routes = this.inputToGroupRoutes.get(inputKey);
const route = routes.find(r => r.destination === destination);
if (route) {
route.setGain(gainDb);
console.log(`Gain modifié : Input ${source} -> Group "${destination}" = ${gainDb}dB`);
return true;
}
}
// Cherche dans group -> output
if (typeof source === 'string' && this.groupToOutputRoutes.has(source)) {
const routes = this.groupToOutputRoutes.get(source);
const route = routes.find(r => r.destination === destination);
if (route) {
route.setGain(gainDb);
console.log(`Gain modifié : Group "${source}" -> Output ${destination} = ${gainDb}dB`);
return true;
}
}
return false;
}
/**
* ÉTAPE 1 : Traite les entrées audio physiques vers les buffers de groupe
* Mixe plusieurs canaux d'entrée vers chaque groupe (avec gains individuels)
*
* @param {Map<number, Float32Array>} inputChannelsData - Données PCM par canal d'entrée
*/
processInputsToGroups(inputChannelsData) {
// Réinitialise les buffers de groupe
this.groupBuffers.clear();
this.config.groups.forEach(group => {
// Utiliser l'ID (slugifié) plutôt que le nom pour correspondre au routing
const groupId = group.id || group.name || group;
this.groupBuffers.set(groupId, new Float32Array(this.config.frameSize));
});
// Compter le nombre de sources par groupe pour normalisation
const groupSourceCount = new Map();
inputChannelsData.forEach((_, channelId) => {
const key = `in_${channelId}`;
const routes = this.inputToGroupRoutes.get(key);
if (routes) {
routes.forEach(route => {
groupSourceCount.set(
route.destination,
(groupSourceCount.get(route.destination) || 0) + 1
);
});
}
});
// Pour chaque canal d'entrée
inputChannelsData.forEach((pcmData, channelId) => {
const key = `in_${channelId}`;
const routes = this.inputToGroupRoutes.get(key);
if (!routes || routes.length === 0) return;
// Stocke le buffer d'entrée
this.inputBuffers.set(channelId, pcmData);
// Applique chaque route (mixage additif vers les groupes)
routes.forEach(route => {
const groupBuffer = this.groupBuffers.get(route.destination);
if (!groupBuffer) {
logger.warn(`Buffer groupe "${route.destination}" introuvable pour routing depuis Input ${channelId}`);
return;
}
// Mixage avec gain + atténuation par nombre de sources
const sourceCount = groupSourceCount.get(route.destination) || 1;
const mixGain = route.linearGain / sourceCount;
for (let i = 0; i < pcmData.length && i < groupBuffer.length; i++) {
groupBuffer[i] += pcmData[i] * mixGain;
}
});
});
// Normalisation anti-clipping (soft limiter simple)
this.groupBuffers.forEach((buffer, groupName) => {
for (let i = 0; i < buffer.length; i++) {
if (Math.abs(buffer[i]) > 1.0) {
this.stats.clippingEvents++;
if (this.stats.clippingEvents % 1000 === 1) {
logger.warn(`Clipping détecté sur groupe "${groupName}" (${this.stats.clippingEvents} événements)`);
}
buffer[i] = Math.sign(buffer[i]) * 1.0; // Hard clipping
}
}
});
this.stats.framesProcessed++;
return this.groupBuffers;
}
/**
* ÉTAPE 2 : Traite les buffers de groupe vers les sorties audio physiques
* Distribue chaque groupe vers plusieurs canaux de sortie (avec gains individuels)
* Support du mixage additif si plusieurs groupes vont vers la même sortie
*
* @param {Map<string, Float32Array>} groupBuffersData - Données PCM par groupe (depuis LiveKit)
* @returns {Map<number, Float32Array>} Buffers de sortie par canal physique
*/
processGroupsToOutputs(groupBuffersData) {
// Réinitialise les buffers de sortie
this.outputBuffers.clear();
// Pour chaque groupe
groupBuffersData.forEach((pcmData, groupName) => {
const routes = this.groupToOutputRoutes.get(groupName);
if (!routes || routes.length === 0) {
return;
}
// Applique chaque route vers les sorties
routes.forEach(route => {
const outputChannel = route.destination;
// Crée le buffer de sortie si nécessaire
if (!this.outputBuffers.has(outputChannel)) {
this.outputBuffers.set(outputChannel, new Float32Array(this.config.frameSize));
}
const outputBuffer = this.outputBuffers.get(outputChannel);
// Mixage avec gain (additif si canal partagé)
for (let i = 0; i < pcmData.length && i < outputBuffer.length; i++) {
outputBuffer[i] += pcmData[i] * route.linearGain;
}
});
});
// Normalisation anti-clipping sur les sorties
this.outputBuffers.forEach((buffer, channelId) => {
for (let i = 0; i < buffer.length; i++) {
if (Math.abs(buffer[i]) > 1.0) {
this.stats.clippingEvents++;
buffer[i] = Math.sign(buffer[i]) * 1.0; // Hard clipping
}
}
});
return this.outputBuffers;
}
/**
* Récupère le buffer d'un groupe spécifique
*/
getGroupBuffer(groupName) {
return this.groupBuffers.get(groupName) || null;
}
/**
* Récupère le buffer d'une sortie spécifique
*/
getOutputBuffer(channelId) {
return this.outputBuffers.get(channelId) || null;
}
/**
* Récupère toutes les routes configurées
*/
getRoutingConfig() {
const inputToGroup = {};
const groupToOutput = {};
const gains = {};
// Input -> Group
this.inputToGroupRoutes.forEach((routes, key) => {
const inputChannel = key.replace('in_', '');
inputToGroup[inputChannel] = routes.map(r => r.destination);
routes.forEach(route => {
if (route.gain !== 0.0) {
gains[`in_${inputChannel}_${route.destination}`] = route.gain;
}
});
});
// Group -> Output
this.groupToOutputRoutes.forEach((routes, groupName) => {
groupToOutput[groupName] = routes.map(r => r.destination);
routes.forEach(route => {
if (route.gain !== 0.0) {
gains[`${groupName}_out_${route.destination}`] = route.gain;
}
});
});
return { inputToGroup, groupToOutput, gains };
}
/**
* Récupère les statistiques
*/
getStats() {
return {
framesProcessed: this.stats.framesProcessed,
clippingEvents: this.stats.clippingEvents,
routesActive: this.stats.routesActive,
inputToGroupRoutes: this.inputToGroupRoutes.size,
groupToOutputRoutes: this.groupToOutputRoutes.size,
activeGroups: this.groupBuffers.size,
activeOutputs: this.outputBuffers.size
};
}
/**
* Met à jour le compteur de routes actives
*/
_updateStatsActiveRoutes() {
let count = 0;
this.inputToGroupRoutes.forEach(routes => count += routes.length);
this.groupToOutputRoutes.forEach(routes => count += routes.length);
this.stats.routesActive = count;
}
/**
* Détruit le router et libère les ressources
*/
destroy() {
this.inputToGroupRoutes.clear();
this.groupToOutputRoutes.clear();
this.inputBuffers.clear();
this.groupBuffers.clear();
this.outputBuffers.clear();
this.removeAllListeners();
logger.info('GroupAudioRouter détruit');
}
}
export default GroupAudioRouter;
+194 -106
View File
@@ -1,23 +1,16 @@
/**
* LiveKitClient.js
* Client LiveKit pour le bridge audio serveur
* Client LiveKit pour le bridge audio serveur (Node.js)
*
* Gère :
* Utilise @livekit/rtc-node pour :
* - Connexion à la room en tant que participant "bridge"
* - Publication de track audio (Opus depuis carte son)
* - Publication de tracks audio (PCM depuis carte son)
* - Souscription aux tracks des autres participants (clients PWA)
* - Gestion audio bas niveau (AudioSource/AudioStream)
* - Reconnexion automatique
*/
import {
Room,
RoomEvent,
RemoteTrack,
RemoteParticipant,
LocalAudioTrack,
TrackPublishOptions,
AudioPresets
} from 'livekit-client';
import { Room, RoomEvent, AudioSource, AudioFrame, LocalAudioTrack, TrackSource, AudioStream, TrackKind } from '@livekit/rtc-node';
import { EventEmitter } from 'events';
export class LiveKitClient extends EventEmitter {
@@ -30,11 +23,13 @@ export class LiveKitClient extends EventEmitter {
participantName: options.participantName || 'AudioBridge',
token: options.token || null,
autoSubscribe: options.autoSubscribe !== false,
audioBitrate: options.audioBitrate || 96000, // 96kbps par défaut
sampleRate: options.sampleRate || 48000,
channels: options.channels || 1, // Mono par défaut pour PTT
...options
};
this.room = null;
this.audioSource = null;
this.localAudioTrack = null;
this.isConnected = false;
this.reconnecting = false;
@@ -58,13 +53,8 @@ export class LiveKitClient extends EventEmitter {
}
try {
this.room = new Room({
adaptiveStream: true,
dynacast: true,
reconnectionPolicy: {
nextRetryDelayInMs: (retryCount) => Math.min(1000 * Math.pow(2, retryCount), 10000)
}
});
// Création room
this.room = new Room();
// Configuration des event listeners
this._setupEventListeners();
@@ -79,6 +69,10 @@ export class LiveKitClient extends EventEmitter {
roomName: this.options.roomName,
participantName: this.options.participantName
});
// Création de l'AudioSource pour pouvoir publier de l'audio
await this._createAudioSource();
} catch (error) {
console.error('Erreur connexion LiveKit:', error);
this.emit('error', error);
@@ -86,6 +80,41 @@ export class LiveKitClient extends EventEmitter {
}
}
/**
* Crée une AudioSource pour la publication audio
* @private
*/
async _createAudioSource() {
try {
// Conversion explicite en int32 pour l'API LiveKit
const sampleRate = parseInt(this.options.sampleRate, 10);
const channels = parseInt(this.options.channels, 10);
// Création de l'AudioSource
this.audioSource = new AudioSource(sampleRate, channels);
// Création du LocalAudioTrack depuis l'AudioSource
const localTrack = LocalAudioTrack.createAudioTrack('bridge-audio', this.audioSource);
// Publication du track
const options = {
source: TrackSource.SOURCE_MICROPHONE // Simule un microphone pour les clients
};
this.localAudioTrack = await this.room.localParticipant.publishTrack(
localTrack,
options
);
console.log('✓ AudioSource créée et track publié');
this.emit('trackPublished', this.localAudioTrack);
} catch (error) {
console.error('Erreur création AudioSource:', error);
throw error;
}
}
/**
* Configuration des event listeners de la room
* @private
@@ -93,33 +122,33 @@ export class LiveKitClient extends EventEmitter {
_setupEventListeners() {
if (!this.room) return;
// Connexion/déconnexion
// Connexion
this.room.on(RoomEvent.Connected, () => {
console.log('✓ Room connectée');
this.isConnected = true;
});
// Déconnexion
this.room.on(RoomEvent.Disconnected, (reason) => {
console.log('⚠ Room déconnectée:', reason);
this.isConnected = false;
this.emit('disconnected', { reason });
});
this.room.on(RoomEvent.Reconnecting, () => {
console.log('🔄 Reconnexion en cours...');
this.reconnecting = true;
this.emit('reconnecting');
});
this.room.on(RoomEvent.Reconnected, () => {
console.log('✓ Reconnecté');
this.reconnecting = false;
this.emit('reconnected');
this.emit('disconnected', { reason: reason || 'unknown' });
});
// Participants
this.room.on(RoomEvent.ParticipantConnected, (participant) => {
this.room.on(RoomEvent.ParticipantConnected, async (participant) => {
console.log(` Participant connecté: ${participant.identity}`);
// Parcourir les tracks publiés par ce participant et s'y abonner manuellement
for (const [trackSid, publication] of participant.trackPublications) {
console.log(` 📝 Track disponible: ${publication.kind} (${trackSid}), muted: ${publication.muted}`);
if (publication.kind === TrackKind.KIND_AUDIO && publication.track) {
console.log(` ⚡ Souscription manuelle au track audio ${trackSid}...`);
await this._handleAudioTrack(publication.track, publication, participant);
}
}
this.emit('participantConnected', participant);
});
@@ -129,97 +158,143 @@ export class LiveKitClient extends EventEmitter {
this.emit('participantDisconnected', participant);
});
// Tracks
this.room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === 'audio') {
console.log(`🎵 Track audio souscrit de ${participant.identity}`);
this.remoteParticipants.set(participant.sid, {
participant,
track,
publication
// Tracks - Debug tous les événements
this.room.on(RoomEvent.TrackPublished, async (publication, participant) => {
console.log(`📢 Track publié par ${participant.identity}: ${publication.kind} (${publication.sid}), muted: ${publication.muted}`);
// Si c'est un track audio, s'y abonner immédiatement
if (publication.kind === TrackKind.KIND_AUDIO && publication.track) {
console.log(` ⚡ Track audio détecté, souscription...`);
await this._handleAudioTrack(publication.track, publication, participant);
} else if (publication.kind === TrackKind.KIND_AUDIO && !publication.track) {
console.log(` ⚠️ Track audio publié mais track object non disponible encore`);
}
});
this.emit('audioTrackSubscribed', { track, participant });
this.room.on(RoomEvent.TrackSubscribed, async (track, publication, participant) => {
console.log(`🎵 Track souscrit de ${participant.identity}: ${track.kind} (${publication.sid})`);
if (track.kind === TrackKind.KIND_AUDIO) {
console.log(`🎵 Track AUDIO souscrit de ${participant.identity} (événement TrackSubscribed)`);
await this._handleAudioTrack(track, publication, participant);
}
});
this.room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
if (track.kind === 'audio') {
if (track.kind === TrackKind.KIND_AUDIO) {
console.log(`🔇 Track audio désouscrit de ${participant.identity}`);
this.remoteParticipants.delete(participant.sid);
this.emit('audioTrackUnsubscribed', { track, participant });
}
});
// Données audio
this.room.on(RoomEvent.AudioPlaybackStatusChanged, () => {
this.emit('audioPlaybackChanged');
});
// Erreurs
this.room.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
this.emit('qualityChanged', { quality, participant });
});
}
/**
* Publie un track audio local depuis le bridge
* Note: Pour un bridge serveur, on utilise plutôt publishData pour envoyer Opus directement
* @param {MediaStreamTrack} mediaStreamTrack - Track audio du microphone
* @returns {Promise<void>}
* Gère un track audio (création AudioStream et lecture)
* @private
*/
async publishAudioTrack(mediaStreamTrack) {
if (!this.isConnected) {
throw new Error('Pas connecté à LiveKit');
async _handleAudioTrack(track, publication, participant) {
console.log(`🎧 Création AudioStream pour ${participant.identity}...`);
// Création d'un AudioStream pour recevoir les données PCM
const stream = new AudioStream(
track,
this.options.sampleRate,
this.options.channels
);
this.remoteParticipants.set(participant.sid, {
participant,
track,
publication,
stream
});
// Lecture des frames audio
this._startAudioReceive(participant.sid, stream);
this.emit('audioTrackSubscribed', { track, participant });
}
/**
* Démarre la réception audio d'un participant
* @private
*/
async _startAudioReceive(participantSid, stream) {
try {
// Lecture continue des frames audio
for await (const frame of stream) {
// frame est un AudioFrame avec :
// - data: Buffer PCM int16
// - sampleRate: number
// - numChannels: number
// - samplesPerChannel: number
const participant = this.remoteParticipants.get(participantSid);
if (!participant) break;
// Émettre les données audio vers AudioBridge
this.emit('audioData', {
participantSid,
participantName: participant.participant.identity,
pcmData: frame.data,
sampleRate: frame.sampleRate,
channels: frame.numChannels,
samplesPerChannel: frame.samplesPerChannel
});
}
} catch (error) {
console.error(`Erreur réception audio ${participantSid}:`, error);
}
}
/**
* Envoie des données audio PCM vers les clients
* @param {Buffer} pcmData - Données PCM int16 (mono ou multi-canal)
*/
async sendAudioData(pcmData) {
if (!this.audioSource) {
console.warn('AudioSource non initialisée');
return;
}
if (!this.isConnected || !this.localAudioTrack) {
// Silently drop frames si pas encore connecté
return;
}
try {
// Options de publication
const options = {
name: 'bridge-audio',
source: 'microphone',
audioBitrate: this.options.audioBitrate
};
this.localAudioTrack = await this.room.localParticipant.publishTrack(
mediaStreamTrack,
options
// AudioFrame attend Int16Array, pas Buffer
// Convertir Buffer → Int16Array (éviter .slice, utiliser .subarray selon doc)
const int16Array = new Int16Array(
pcmData.buffer,
pcmData.byteOffset,
pcmData.length / 2 // length en samples, pas en bytes
);
console.log('✓ Track audio local publié');
this.emit('trackPublished', this.localAudioTrack);
const samplesPerChannel = Math.floor(int16Array.length / this.options.channels);
const frame = new AudioFrame(
int16Array,
parseInt(this.options.sampleRate, 10),
parseInt(this.options.channels, 10),
samplesPerChannel
);
// Envoi via AudioSource
await this.audioSource.captureFrame(frame);
} catch (error) {
console.error('Erreur publication track:', error);
this.emit('error', error);
throw error;
// Ne logger que les erreurs non-InvalidState pour éviter le spam
if (!error.message.includes('InvalidState')) {
console.error('Erreur envoi audio:', error);
}
}
/**
* Unpublish le track audio local
*/
async unpublishAudioTrack() {
if (this.localAudioTrack) {
await this.room.localParticipant.unpublishTrack(this.localAudioTrack);
this.localAudioTrack = null;
console.log('✓ Track audio local dépublié');
}
}
/**
* Envoie des données audio Opus directement (pour bridge serveur)
* Alternative à publishAudioTrack pour contrôle bas niveau
* @param {Buffer} opusData - Données Opus encodées
*/
sendAudioData(opusData) {
// Note: LiveKit ne supporte pas directement l'envoi de données Opus brutes
// Cette méthode serait implémentée avec un track custom ou DataChannel
// Pour l'instant, on utilise publishAudioTrack avec un MediaStreamTrack
console.warn('sendAudioData: Non implémenté, utiliser publishAudioTrack');
}
/**
* Récupère tous les tracks audio distants actifs
* @returns {Array<Object>} Liste des tracks avec métadonnées
* @returns {Array<Object>}
*/
getRemoteAudioTracks() {
return Array.from(this.remoteParticipants.values()).map(({ participant, track, publication }) => ({
@@ -234,7 +309,7 @@ export class LiveKitClient extends EventEmitter {
/**
* Récupère un participant distant par son SID
* @param {string} sid - SID du participant
* @param {string} sid
* @returns {Object|null}
*/
getRemoteParticipant(sid) {
@@ -261,15 +336,14 @@ export class LiveKitClient extends EventEmitter {
localParticipant: {
sid: localParticipant?.sid,
identity: localParticipant?.identity,
tracksPublished: localParticipant?.trackPublications.size || 0
tracksPublished: localParticipant?.trackPublications?.size || 0
},
remoteParticipants: {
count: participants.size,
list: Array.from(participants.values()).map(p => ({
sid: p.sid,
identity: p.identity,
audioTracks: Array.from(p.audioTrackPublications.values()).length,
connectionQuality: p.connectionQuality
audioTracks: Array.from(p.audioTrackPublications?.values() || []).length
}))
}
};
@@ -280,9 +354,23 @@ export class LiveKitClient extends EventEmitter {
*/
async disconnect() {
if (this.room) {
await this.unpublishAudioTrack();
this.room.disconnect();
// Unpublish track
if (this.localAudioTrack) {
try {
await this.room.localParticipant.unpublishTrack(this.localAudioTrack.sid);
} catch (error) {
// Ignorer l'erreur si le track n'existe plus (shutdown rapide)
if (!error.message?.includes('track not found')) {
console.warn('⚠️ Erreur unpublish track:', error.message);
}
}
this.localAudioTrack = null;
}
// Déconnexion
await this.room.disconnect();
this.room = null;
this.audioSource = null;
this.isConnected = false;
this.remoteParticipants.clear();
console.log('✓ Déconnecté de LiveKit');
+312 -86
View File
@@ -1,15 +1,18 @@
/**
* CoreAudioBackend.js
* Backend audio natif macOS utilisant naudiodon (bindings PortAudio/CoreAudio)
* Backend audio natif macOS utilisant sox (Sound eXchange)
*
* Note: naudiodon était instable (segfaults), remplacé par sox en subprocess
* sox est stable, installé par défaut sur macOS, et supporte toutes les cartes
*
* Gère :
* - Énumération des devices audio
* - Capture audio (microphone/carte son)
* - Lecture audio (speakers/sortie audio)
* - Énumération des devices audio via system_profiler
* - Capture audio via sox (rec)
* - Lecture audio via sox (play)
* - Buffer circulaire pour flux continu
*/
import portAudio from 'naudiodon';
import { spawn, execSync } from 'child_process';
import { EventEmitter } from 'events';
export class CoreAudioBackend extends EventEmitter {
@@ -18,41 +21,124 @@ export class CoreAudioBackend extends EventEmitter {
this.options = {
sampleRate: options.sampleRate || 48000,
channels: options.channels || 1, // Mono par défaut
framesPerBuffer: options.framesPerBuffer || 960, // 20ms à 48kHz
inputDeviceId: options.inputDeviceId || null,
outputDeviceId: options.outputDeviceId || null,
channels: options.channels || 1,
framesPerBuffer: options.framesPerBuffer || 960,
inputDeviceName: options.inputDeviceName || null,
outputDeviceName: options.outputDeviceName || null,
...options
};
this.inputStream = null;
this.outputStream = null;
this.captureProcess = null;
this.playbackProcess = null;
this.isCapturing = false;
this.isPlaying = false;
this.shuttingDown = false;
// Buffer d'accumulation pour la capture (sox envoie des chunks de taille variable)
this.captureAccumulator = Buffer.alloc(0);
this.targetCaptureBytes = this.options.framesPerBuffer * 2 * this.options.channels; // 2 bytes per sample
// Buffer circulaire pour la lecture
this.playbackBuffer = [];
this.maxBufferSize = 10; // Max 10 chunks en buffer
this.maxBufferSize = 10;
}
/**
* Liste tous les devices audio disponibles
* Liste tous les devices audio disponibles via system_profiler
* @returns {Array} Liste des devices
*/
static getDevices() {
try {
const devices = portAudio.getDevices();
return devices.map((device, index) => ({
id: index,
name: device.name,
maxInputChannels: device.maxInputChannels,
maxOutputChannels: device.maxOutputChannels,
defaultSampleRate: device.defaultSampleRate,
hostAPIName: device.hostAPIName
}));
const output = execSync('system_profiler SPAudioDataType -json', { encoding: 'utf8' });
const data = JSON.parse(output);
const devices = [];
// Parse audio devices
if (data.SPAudioDataType) {
data.SPAudioDataType.forEach(item => {
if (item._items) {
item._items.forEach(device => {
const name = device._name || 'Unknown Device';
// Les clés coreaudio_device_input/output contiennent le nombre de canaux
const inputChannels = parseInt(device.coreaudio_device_input) || 0;
const outputChannels = parseInt(device.coreaudio_device_output) || 0;
const sampleRate = parseInt(device.coreaudio_device_srate) || 48000;
// Utiliser le UID CoreAudio comme ID (unique et stable)
const deviceUID = device._uniqueID || device.coreaudio_device_uid || name;
// Ignorer les devices sans input ni output
if (inputChannels === 0 && outputChannels === 0) {
return;
}
devices.push({
id: deviceUID,
name: name,
maxInputChannels: inputChannels,
maxOutputChannels: outputChannels,
defaultSampleRate: sampleRate,
hostAPIName: 'Core Audio',
manufacturer: device.coreaudio_device_manufacturer || 'Unknown',
transport: device.coreaudio_device_transport || 'unknown',
isDefault: {
input: device.coreaudio_default_audio_input_device === 'spaudio_yes',
output: device.coreaudio_default_audio_output_device === 'spaudio_yes'
}
});
});
}
});
}
// Ajouter devices par défaut si liste vide
if (devices.length === 0) {
devices.push(
{
id: 'builtin-mic',
name: 'Built-in Microphone',
maxInputChannels: 1,
maxOutputChannels: 0,
defaultSampleRate: 48000,
hostAPIName: 'Core Audio'
},
{
id: 'builtin-output',
name: 'Built-in Output',
maxInputChannels: 0,
maxOutputChannels: 2,
defaultSampleRate: 48000,
hostAPIName: 'Core Audio'
}
);
}
console.log(`✓ CoreAudio: ${devices.length} devices détectés`);
return devices;
} catch (error) {
console.error('Erreur énumération devices CoreAudio:', error);
return [];
// Fallback : devices par défaut
return [
{
id: 'builtin-mic',
name: 'Built-in Microphone',
maxInputChannels: 1,
maxOutputChannels: 0,
defaultSampleRate: 48000,
hostAPIName: 'Core Audio'
},
{
id: 'builtin-output',
name: 'Built-in Output',
maxInputChannels: 0,
maxOutputChannels: 2,
defaultSampleRate: 48000,
hostAPIName: 'Core Audio'
}
];
}
}
@@ -61,8 +147,17 @@ export class CoreAudioBackend extends EventEmitter {
* @returns {Object|null} Device d'entrée par défaut
*/
static getDefaultInputDevice() {
try {
const devices = this.getDevices();
// Chercher d'abord le device marqué comme default
const defaultDevice = devices.find(d => d.isDefault?.input && d.maxInputChannels > 0);
if (defaultDevice) return defaultDevice;
// Fallback: premier device avec input
return devices.find(d => d.maxInputChannels > 0) || null;
} catch (error) {
console.error('Erreur getDefaultInputDevice:', error);
return null;
}
}
/**
@@ -70,12 +165,21 @@ export class CoreAudioBackend extends EventEmitter {
* @returns {Object|null} Device de sortie par défaut
*/
static getDefaultOutputDevice() {
try {
const devices = this.getDevices();
// Chercher d'abord le device marqué comme default
const defaultDevice = devices.find(d => d.isDefault?.output && d.maxOutputChannels > 0);
if (defaultDevice) return defaultDevice;
// Fallback: premier device avec output
return devices.find(d => d.maxOutputChannels > 0) || null;
} catch (error) {
console.error('Erreur getDefaultOutputDevice:', error);
return null;
}
}
/**
* Démarre la capture audio
* Démarre la capture audio via sox (rec)
* @returns {Promise<void>}
*/
async startCapture() {
@@ -85,36 +189,65 @@ export class CoreAudioBackend extends EventEmitter {
}
try {
const inputConfig = {
channelCount: this.options.channels,
sampleFormat: portAudio.SampleFormat16Bit,
sampleRate: this.options.sampleRate,
deviceId: this.options.inputDeviceId ?? undefined,
closeOnError: true
};
// Commande sox pour capturer audio sur macOS
// Sur macOS, sox utilise CoreAudio par défaut via 'rec' (alias de sox -d)
// Format: sox -d [options] output
// -d = default input device OU -t coreaudio "Device Name"
this.inputStream = new portAudio.AudioIO({
inOptions: inputConfig
const args = [];
// Spécifier le device d'entrée (CoreAudio capture en 32-bit natif)
if (this.options.inputDeviceName) {
args.push('-t', 'coreaudio', this.options.inputDeviceName);
} else {
args.push('-d');
}
// Format de sortie (stdout) - convertir 32→16 bit
args.push(
'-t', 'raw', // Format sortie raw PCM
'-b', '16', // Convertir vers 16-bit
'-e', 'signed-integer',
'-c', String(this.options.channels),
'-r', String(this.options.sampleRate),
'-' // Stdout
);
console.log(`🎤 Démarrage capture sox: ${args.join(' ')}`);
this.captureProcess = spawn('sox', args);
this.captureProcess.stdout.on('data', (audioData) => {
// Accumuler les données jusqu'à avoir un frame complet
this.captureAccumulator = Buffer.concat([this.captureAccumulator, audioData]);
// Émettre des frames de taille fixe
while (this.captureAccumulator.length >= this.targetCaptureBytes) {
const frame = this.captureAccumulator.subarray(0, this.targetCaptureBytes);
this.emit('audioData', Buffer.from(frame)); // Copier pour éviter les références
// Garder le reste pour la prochaine frame
this.captureAccumulator = this.captureAccumulator.subarray(this.targetCaptureBytes);
}
});
this.inputStream.on('data', (audioData) => {
// Émet les données audio capturées (Buffer PCM 16-bit)
this.emit('audioData', audioData);
this.captureProcess.stderr.on('data', (data) => {
const msg = data.toString();
if (!msg.includes('sox WARN')) {
console.error('sox capture stderr:', msg);
}
});
this.inputStream.on('error', (error) => {
console.error('Erreur stream capture:', error);
this.captureProcess.on('error', (error) => {
console.error('Erreur processus sox capture:', error);
this.emit('error', error);
});
this.inputStream.on('close', () => {
console.log('Stream capture fermé');
this.captureProcess.on('close', (code) => {
console.log(`Sox capture fermé (code ${code})`);
this.isCapturing = false;
});
this.inputStream.start();
this.isCapturing = true;
console.log(`✓ Capture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
} catch (error) {
console.error('Erreur démarrage capture:', error);
@@ -126,54 +259,109 @@ export class CoreAudioBackend extends EventEmitter {
* Arrête la capture audio
*/
stopCapture() {
if (this.inputStream && this.isCapturing) {
this.inputStream.quit();
this.inputStream = null;
if (this.captureProcess && this.isCapturing) {
this.captureProcess.kill('SIGTERM');
this.captureProcess = null;
this.isCapturing = false;
this.captureAccumulator = Buffer.alloc(0); // Reset accumulator
console.log('✓ Capture audio arrêtée');
}
}
/**
* Démarre la lecture audio
* Démarre la lecture audio via sox (play)
* @returns {Promise<void>}
*/
async startPlayback() {
console.log('🔊 Démarrage playback sox...');
if (this.isPlaying) {
console.warn('Lecture déjà active');
console.warn('⚠️ Lecture déjà active');
return;
}
try {
const outputConfig = {
channelCount: this.options.channels,
sampleFormat: portAudio.SampleFormat16Bit,
sampleRate: this.options.sampleRate,
deviceId: this.options.outputDeviceId ?? undefined,
closeOnError: true
};
// Commande sox pour lecture audio sur macOS
// Format: sox [options] input output
// Input = stdin (-)
// Output = -d (default) OU -t coreaudio "Device Name"
this.outputStream = new portAudio.AudioIO({
outOptions: outputConfig
const args = [
'--buffer', '65536', // Buffer 64k (évite EOF prématuré)
'-t', 'raw',
'-b', '16',
'-e', 'signed-integer',
'-c', String(this.options.channels),
'-r', String(this.options.sampleRate),
'-' // Input = stdin
];
// Spécifier le device de sortie
if (this.options.outputDeviceName) {
// Utiliser le device spécifié par son nom
args.push('-t', 'coreaudio', this.options.outputDeviceName);
} else {
// Device par défaut
args.push('-d');
}
console.log(`🔊 Démarrage playback sox: ${args.join(' ')}`);
this.playbackProcess = spawn('sox', args, {
stdio: ['pipe', 'ignore', 'pipe'] // stdin=pipe, stdout=ignore, stderr=pipe
});
this.outputStream.on('error', (error) => {
console.error('Erreur stream lecture:', error);
// Gérer l'erreur EPIPE sur stdin (si processus se ferme)
this.playbackProcess.stdin.on('error', (error) => {
if (error.code === 'EPIPE') {
console.warn('⚠️ Sox playback stdin fermé (EPIPE)');
this.isPlaying = false;
} else {
console.error('Erreur stdin sox playback:', error);
}
});
this.playbackProcess.stderr.on('data', (data) => {
const msg = data.toString();
if (!msg.includes('sox WARN')) {
console.error('sox playback stderr:', msg);
}
});
this.playbackProcess.on('error', (error) => {
console.error('Erreur processus sox playback:', error);
this.emit('error', error);
});
this.outputStream.on('close', () => {
console.log('Stream lecture fermé');
this.playbackProcess.on('close', (code) => {
const uptime = ((Date.now() - this.playbackStartTime) / 1000).toFixed(1);
console.log(`⚠️ Sox playback fermé (code ${code}) après ${uptime}s`);
this.isPlaying = false;
// Redémarrer automatiquement (sox se ferme quand le buffer stdin se vide)
if (!this.shuttingDown) {
console.log('🔄 Redémarrage automatique du playback...');
setTimeout(() => {
if (!this.shuttingDown) {
this.startPlayback().catch(err => {
console.error('Erreur redémarrage playback:', err);
});
}
}, 100);
}
});
// Démarrage du stream de lecture
this.outputStream.start();
this.playbackStartTime = Date.now();
this.isPlaying = true;
// Boucle de lecture du buffer circulaire
this._startPlaybackLoop();
// Envoyer immédiatement du silence pour démarrer sox
const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
for (let i = 0; i < 10; i++) {
if (this.playbackProcess.stdin.writable) {
this.playbackProcess.stdin.write(silenceBuffer);
}
}
console.log(`✓ Lecture audio démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
} catch (error) {
console.error('Erreur démarrage lecture:', error);
@@ -185,9 +373,14 @@ export class CoreAudioBackend extends EventEmitter {
* Arrête la lecture audio
*/
stopPlayback() {
if (this.outputStream && this.isPlaying) {
this.outputStream.quit();
this.outputStream = null;
if (this.playbackInterval) {
clearInterval(this.playbackInterval);
this.playbackInterval = null;
}
if (this.playbackProcess && this.isPlaying) {
this.playbackProcess.kill('SIGTERM');
this.playbackProcess = null;
this.isPlaying = false;
this.playbackBuffer = [];
console.log('✓ Lecture audio arrêtée');
@@ -200,10 +393,16 @@ export class CoreAudioBackend extends EventEmitter {
*/
queueAudio(audioData) {
if (!this.isPlaying) {
console.warn('Tentative ajout audio alors que lecture inactive');
// Ne logger qu'une fois pour éviter le spam
if (!this.playbackInactiveWarned) {
console.warn('⚠️ Tentative ajout audio alors que lecture inactive (message unique)');
this.playbackInactiveWarned = true;
}
return;
}
this.playbackInactiveWarned = false;
// Limite la taille du buffer pour éviter la latence excessive
if (this.playbackBuffer.length < this.maxBufferSize) {
this.playbackBuffer.push(audioData);
@@ -218,31 +417,55 @@ export class CoreAudioBackend extends EventEmitter {
* @private
*/
_startPlaybackLoop() {
const playNextChunk = () => {
if (!this.isPlaying) return;
// Calculer l'intervalle en ms (ex: 960 frames à 48kHz = 20ms)
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
if (this.playbackBuffer.length > 0) {
const chunk = this.playbackBuffer.shift();
this.outputStream.write(chunk);
} else {
// Buffer vide : underrun (on envoie du silence)
const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
this.outputStream.write(silenceBuffer);
this.emit('bufferUnderrun');
console.log(`🔁 Boucle playback démarrée (intervalle: ${intervalMs}ms)`);
// Utiliser setInterval pour garantir un flux continu
this.playbackInterval = setInterval(() => {
if (!this.isPlaying || !this.playbackProcess || !this.playbackProcess.stdin) {
if (this.playbackInterval) {
clearInterval(this.playbackInterval);
this.playbackInterval = null;
}
return;
}
// Rappel à intervalle régulier (20ms pour 960 frames à 48kHz)
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
setTimeout(playNextChunk, intervalMs);
};
let chunk;
if (this.playbackBuffer.length > 0) {
chunk = this.playbackBuffer.shift();
} else {
// Buffer vide : underrun (envoyer du silence)
chunk = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
}
playNextChunk();
// Toujours écrire quelque chose pour garder sox actif
try {
if (this.playbackProcess.stdin.writable) {
this.playbackProcess.stdin.write(chunk);
} else {
console.warn('⚠️ Sox stdin non writable, arrêt boucle');
this.isPlaying = false;
clearInterval(this.playbackInterval);
this.playbackInterval = null;
}
} catch (error) {
if (error.code !== 'EPIPE') {
console.error('Erreur écriture stdin sox:', error);
}
this.isPlaying = false;
clearInterval(this.playbackInterval);
this.playbackInterval = null;
}
}, intervalMs);
}
/**
* Arrête tous les streams
*/
destroy() {
this.shuttingDown = true;
this.stopCapture();
this.stopPlayback();
this.removeAllListeners();
@@ -250,14 +473,17 @@ export class CoreAudioBackend extends EventEmitter {
}
/**
* Vérifie si CoreAudio est disponible sur le système
* Vérifie si CoreAudio/sox est disponible sur le système
* @returns {boolean}
*/
static isAvailable() {
try {
const devices = portAudio.getDevices();
return devices.length > 0;
// Vérifier si sox est installé
execSync('which sox', { stdio: 'ignore' });
return true;
} catch (error) {
// sox n'est pas installé
console.warn('sox non installé. Installer avec : brew install sox');
return false;
}
}
+421
View File
@@ -0,0 +1,421 @@
/**
* JACKBackend.js
* Backend audio pour Linux utilisant JACK Audio Connection Kit
*
* Gère :
* - Connexion au serveur JACK
* - Ports audio input/output
* - Capture et lecture audio temps réel
* - Détection automatique du serveur JACK
*/
import { spawn, execSync } from 'child_process';
import { EventEmitter } from 'events';
export class JACKBackend extends EventEmitter {
constructor(options = {}) {
super();
this.options = {
sampleRate: options.sampleRate || 48000,
channels: options.channels || 1,
framesPerBuffer: options.framesPerBuffer || 960, // 20ms à 48kHz
clientName: options.clientName || 'PTTLive',
autoConnect: options.autoConnect !== false,
inputPorts: options.inputPorts || [],
outputPorts: options.outputPorts || [],
...options
};
this.jackProcess = null;
this.isCapturing = false;
this.isPlaying = false;
this.shuttingDown = false;
// Buffer d'accumulation pour la capture (JACK peut envoyer des chunks de taille variable)
this.captureAccumulator = Buffer.alloc(0);
this.targetCaptureBytes = this.options.framesPerBuffer * 2 * this.options.channels; // 2 bytes per sample
this.playbackBuffer = [];
this.maxBufferSize = 10;
// Ports JACK créés
this.capturePort = null;
this.playbackPort = null;
}
/**
* Vérifie si JACK est installé et disponible
* @returns {boolean}
*/
static isAvailable() {
try {
execSync('which jackd', { stdio: 'ignore' });
return true;
} catch (error) {
return false;
}
}
/**
* Vérifie si le serveur JACK est en cours d'exécution
* @returns {boolean}
*/
static isServerRunning() {
try {
execSync('jack_lsp', { stdio: 'ignore' });
return true;
} catch (error) {
return false;
}
}
/**
* Liste tous les ports JACK disponibles
* @returns {Array} Liste des ports
*/
static getPorts() {
try {
const output = execSync('jack_lsp', { encoding: 'utf8' });
const ports = output.trim().split('\n').filter(p => p.length > 0);
return ports.map(port => {
const isOutput = port.includes('capture') || port.includes('output');
const isInput = port.includes('playback') || port.includes('input');
return {
name: port,
type: isOutput ? 'output' : (isInput ? 'input' : 'unknown'),
isPhysical: port.includes('system:')
};
});
} catch (error) {
console.error('Erreur listage ports JACK:', error);
return [];
}
}
/**
* Liste les devices audio via JACK (ports système)
* @returns {Array} Liste des devices
*/
static getDevices() {
if (!this.isServerRunning()) {
console.warn('Serveur JACK non démarré');
return [];
}
try {
const ports = this.getPorts();
const systemPorts = ports.filter(p => p.isPhysical);
// Grouper par device (system:capture_*, system:playback_*)
const devices = [];
// Ports d'entrée (capture)
const capturePorts = systemPorts.filter(p => p.name.includes('capture'));
if (capturePorts.length > 0) {
devices.push({
id: 'jack-input',
name: 'JACK System Capture',
maxInputChannels: capturePorts.length,
maxOutputChannels: 0,
defaultSampleRate: this._getServerSampleRate(),
hostAPIName: 'JACK',
ports: capturePorts.map(p => p.name)
});
}
// Ports de sortie (playback)
const playbackPorts = systemPorts.filter(p => p.name.includes('playback'));
if (playbackPorts.length > 0) {
devices.push({
id: 'jack-output',
name: 'JACK System Playback',
maxInputChannels: 0,
maxOutputChannels: playbackPorts.length,
defaultSampleRate: this._getServerSampleRate(),
hostAPIName: 'JACK',
ports: playbackPorts.map(p => p.name)
});
}
return devices;
} catch (error) {
console.error('Erreur énumération devices JACK:', error);
return [];
}
}
/**
* Récupère le sample rate du serveur JACK
* @returns {number}
* @private
*/
static _getServerSampleRate() {
try {
const output = execSync('jack_samplerate', { encoding: 'utf8' });
return parseInt(output.trim()) || 48000;
} catch (error) {
return 48000;
}
}
/**
* Récupère la taille du buffer du serveur JACK
* @returns {number}
* @private
*/
static _getServerBufferSize() {
try {
const output = execSync('jack_bufsize', { encoding: 'utf8' });
return parseInt(output.trim()) || 1024;
} catch (error) {
return 1024;
}
}
/**
* Trouve le device par défaut pour l'entrée
* @returns {Object|null}
*/
static getDefaultInputDevice() {
const devices = this.getDevices();
return devices.find(d => d.maxInputChannels > 0) || null;
}
/**
* Trouve le device par défaut pour la sortie
* @returns {Object|null}
*/
static getDefaultOutputDevice() {
const devices = this.getDevices();
return devices.find(d => d.maxOutputChannels > 0) || null;
}
/**
* Démarre la capture audio
* @returns {Promise<void>}
*/
async startCapture() {
if (this.isCapturing) {
console.warn('Capture JACK déjà active');
return;
}
if (!JACKBackend.isServerRunning()) {
throw new Error('Serveur JACK non démarré. Lancez jackd avant de continuer.');
}
try {
// Utilisation de jack_rec pour capturer l'audio
const portName = this.options.inputPorts[0] || 'system:capture_1';
this.jackProcess = spawn('jack_rec', [
'-f', '-', // Sortie vers stdout
'-d', String(this.options.framesPerBuffer),
'-b', '16', // 16-bit PCM
portName
]);
this.jackProcess.stdout.on('data', (audioData) => {
// Accumuler les données jusqu'à avoir un frame complet
this.captureAccumulator = Buffer.concat([this.captureAccumulator, audioData]);
// Émettre des frames de taille fixe
while (this.captureAccumulator.length >= this.targetCaptureBytes) {
const frame = this.captureAccumulator.subarray(0, this.targetCaptureBytes);
this.emit('audioData', Buffer.from(frame)); // Copier pour éviter les références
// Garder le reste pour la prochaine frame
this.captureAccumulator = this.captureAccumulator.subarray(this.targetCaptureBytes);
}
});
this.jackProcess.stderr.on('data', (data) => {
console.error('JACK stderr:', data.toString());
});
this.jackProcess.on('error', (error) => {
console.error('Erreur processus JACK:', error);
this.emit('error', error);
});
this.jackProcess.on('close', () => {
console.log('Processus JACK capture fermé');
this.isCapturing = false;
});
this.isCapturing = true;
console.log(`✓ Capture JACK démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
console.log(` Port: ${portName}`);
} catch (error) {
console.error('Erreur démarrage capture JACK:', error);
throw error;
}
}
/**
* Arrête la capture audio
*/
stopCapture() {
if (this.jackProcess && this.isCapturing) {
this.jackProcess.kill('SIGTERM');
this.jackProcess = null;
this.isCapturing = false;
this.captureAccumulator = Buffer.alloc(0); // Reset accumulator
console.log('✓ Capture JACK arrêtée');
}
}
/**
* Démarre la lecture audio
* @returns {Promise<void>}
*/
async startPlayback() {
if (this.isPlaying) {
console.warn('Lecture JACK déjà active');
return;
}
if (!JACKBackend.isServerRunning()) {
throw new Error('Serveur JACK non démarré');
}
try {
const portName = this.options.outputPorts[0] || 'system:playback_1';
this.playbackProcess = spawn('jack_play', [
'-f', '-', // Lecture depuis stdin
'-b', '16', // 16-bit PCM
portName
]);
this.playbackProcess.on('error', (error) => {
console.error('Erreur processus JACK playback:', error);
this.emit('error', error);
});
this.playbackProcess.stderr.on('data', (data) => {
console.error('JACK playback stderr:', data.toString());
});
this.playbackProcess.on('close', () => {
console.log('Processus JACK playback fermé');
this.isPlaying = false;
});
this.isPlaying = true;
this._startPlaybackLoop();
console.log(`✓ Lecture JACK démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
console.log(` Port: ${portName}`);
} catch (error) {
console.error('Erreur démarrage lecture JACK:', error);
throw error;
}
}
/**
* Arrête la lecture audio
*/
stopPlayback() {
if (this.playbackProcess && this.isPlaying) {
this.playbackProcess.kill('SIGTERM');
this.playbackProcess = null;
this.isPlaying = false;
this.playbackBuffer = [];
console.log('✓ Lecture JACK arrêtée');
}
}
/**
* Ajoute des données audio au buffer de lecture
* @param {Buffer} audioData - Données PCM 16-bit
*/
queueAudio(audioData) {
if (!this.isPlaying) {
console.warn('Tentative ajout audio alors que lecture JACK inactive');
return;
}
if (this.playbackBuffer.length < this.maxBufferSize) {
this.playbackBuffer.push(audioData);
} else {
this.emit('bufferOverrun');
}
}
/**
* Boucle de lecture du buffer circulaire
* @private
*/
_startPlaybackLoop() {
const playNextChunk = () => {
if (!this.isPlaying) return;
if (this.playbackBuffer.length > 0) {
const chunk = this.playbackBuffer.shift();
this.playbackProcess.stdin.write(chunk);
} else {
// Buffer vide : underrun (silence)
const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
this.playbackProcess.stdin.write(silenceBuffer);
this.emit('bufferUnderrun');
}
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
setTimeout(playNextChunk, intervalMs);
};
playNextChunk();
}
/**
* Arrête tous les streams
*/
destroy() {
this.shuttingDown = true;
this.stopCapture();
this.stopPlayback();
this.removeAllListeners();
console.log('✓ JACKBackend détruit');
}
/**
* Obtient les statistiques du backend
* @returns {Object}
*/
getStats() {
return {
capturing: this.isCapturing,
playing: this.isPlaying,
playbackBufferSize: this.playbackBuffer.length,
sampleRate: this.options.sampleRate,
channels: this.options.channels,
framesPerBuffer: this.options.framesPerBuffer,
jackServerRunning: JACKBackend.isServerRunning(),
jackSampleRate: JACKBackend._getServerSampleRate(),
jackBufferSize: JACKBackend._getServerBufferSize()
};
}
/**
* Obtient les informations du serveur JACK
* @returns {Object}
*/
static getServerInfo() {
if (!this.isServerRunning()) {
return { running: false };
}
return {
running: true,
sampleRate: this._getServerSampleRate(),
bufferSize: this._getServerBufferSize(),
ports: this.getPorts().length
};
}
}
export default JACKBackend;
+430
View File
@@ -0,0 +1,430 @@
/**
* PipeWireBackend.js
* Backend audio pour Linux moderne utilisant PipeWire
*
* PipeWire est le nouveau standard audio sur Linux (remplace PulseAudio + JACK)
* Compatible avec : Fedora 34+, Ubuntu 22.10+, Arch Linux
*
* Gère :
* - Connexion au serveur PipeWire
* - Capture et lecture audio via pw-cat
* - Détection automatique des devices
* - Mode basse latence (compatible JACK)
*/
import { spawn, execSync } from 'child_process';
import { EventEmitter } from 'events';
export class PipeWireBackend extends EventEmitter {
constructor(options = {}) {
super();
this.options = {
sampleRate: options.sampleRate || 48000,
channels: options.channels || 1,
framesPerBuffer: options.framesPerBuffer || 960,
inputTargetDevice: options.inputTargetDevice || null,
outputTargetDevice: options.outputTargetDevice || null,
latency: options.latency || 20, // ms
...options
};
this.captureProcess = null;
this.playbackProcess = null;
this.isCapturing = false;
this.isPlaying = false;
this.shuttingDown = false;
// Buffer d'accumulation pour la capture (pw-cat envoie des chunks de taille variable)
this.captureAccumulator = Buffer.alloc(0);
this.targetCaptureBytes = this.options.framesPerBuffer * 2 * this.options.channels; // 2 bytes per sample
this.playbackBuffer = [];
this.maxBufferSize = 10;
}
/**
* Vérifie si PipeWire est installé et disponible
* @returns {boolean}
*/
static isAvailable() {
try {
execSync('which pw-cat', { stdio: 'ignore' });
return true;
} catch (error) {
return false;
}
}
/**
* Vérifie si le serveur PipeWire est en cours d'exécution
* @returns {boolean}
*/
static isServerRunning() {
try {
execSync('pw-cli info 0', { stdio: 'ignore' });
return true;
} catch (error) {
return false;
}
}
/**
* Liste tous les devices audio PipeWire
* @returns {Array} Liste des devices
*/
static getDevices() {
if (!this.isServerRunning()) {
console.warn('Serveur PipeWire non démarré');
return [];
}
try {
// Utilise pactl (compatible PipeWire) pour lister les devices
// Chemin absolu pour éviter les problèmes de PATH avec /bin/sh
const pactlCmd = '/usr/bin/pactl';
const sourcesOutput = execSync(`${pactlCmd} list sources short`, { encoding: 'utf8' });
const sinksOutput = execSync(`${pactlCmd} list sinks short`, { encoding: 'utf8' });
const devices = [];
// Parse sources (entrées)
const sources = sourcesOutput.trim().split('\n').filter(l => l.length > 0);
sources.forEach(line => {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
devices.push({
id: `pw-input-${parts[0]}`,
name: parts[1],
maxInputChannels: 2, // Assume stéréo par défaut
maxOutputChannels: 0,
defaultSampleRate: 48000,
hostAPIName: 'PipeWire',
type: 'source'
});
}
});
// Parse sinks (sorties)
const sinks = sinksOutput.trim().split('\n').filter(l => l.length > 0);
sinks.forEach(line => {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
devices.push({
id: `pw-output-${parts[0]}`,
name: parts[1],
maxInputChannels: 0,
maxOutputChannels: 2,
defaultSampleRate: 48000,
hostAPIName: 'PipeWire',
type: 'sink'
});
}
});
return devices;
} catch (error) {
console.error('Erreur énumération devices PipeWire:', error);
return [];
}
}
/**
* Trouve le device par défaut pour l'entrée
* @returns {Object|null}
*/
static getDefaultInputDevice() {
try {
const pactlCmd = '/usr/bin/pactl';
const output = execSync(`${pactlCmd} get-default-source`, { encoding: 'utf8' });
const defaultName = output.trim();
const devices = this.getDevices();
return devices.find(d => d.name === defaultName && d.maxInputChannels > 0) ||
devices.find(d => d.maxInputChannels > 0);
} catch (error) {
const devices = this.getDevices();
return devices.find(d => d.maxInputChannels > 0) || null;
}
}
/**
* Trouve le device par défaut pour la sortie
* @returns {Object|null}
*/
static getDefaultOutputDevice() {
try {
const pactlCmd = '/usr/bin/pactl';
const output = execSync(`${pactlCmd} get-default-sink`, { encoding: 'utf8' });
const defaultName = output.trim();
const devices = this.getDevices();
return devices.find(d => d.name === defaultName && d.maxOutputChannels > 0) ||
devices.find(d => d.maxOutputChannels > 0);
} catch (error) {
const devices = this.getDevices();
return devices.find(d => d.maxOutputChannels > 0) || null;
}
}
/**
* Démarre la capture audio
* @returns {Promise<void>}
*/
async startCapture() {
if (this.isCapturing) {
console.warn('Capture PipeWire déjà active');
return;
}
if (!PipeWireBackend.isServerRunning()) {
throw new Error('Serveur PipeWire non démarré');
}
try {
// Utilise pw-cat pour capturer l'audio
const args = [
'--record',
'--format=s16', // 16-bit signed PCM
`--rate=${this.options.sampleRate}`,
`--channels=${this.options.channels}`,
`--latency=${this.options.latency}ms`,
'-' // Sortie vers stdout
];
// Ajoute le device cible si spécifié
if (this.options.inputTargetDevice) {
args.push(`--target=${this.options.inputTargetDevice}`);
}
this.captureProcess = spawn('pw-cat', args);
this.captureProcess.stdout.on('data', (audioData) => {
// Accumuler les données jusqu'à avoir un frame complet
this.captureAccumulator = Buffer.concat([this.captureAccumulator, audioData]);
// Émettre des frames de taille fixe
while (this.captureAccumulator.length >= this.targetCaptureBytes) {
const frame = this.captureAccumulator.subarray(0, this.targetCaptureBytes);
this.emit('audioData', Buffer.from(frame)); // Copier pour éviter les références
// Garder le reste pour la prochaine frame
this.captureAccumulator = this.captureAccumulator.subarray(this.targetCaptureBytes);
}
});
this.captureProcess.stderr.on('data', (data) => {
const msg = data.toString();
if (!msg.includes('stream state changed')) {
console.error('PipeWire capture stderr:', msg);
}
});
this.captureProcess.on('error', (error) => {
console.error('Erreur processus PipeWire capture:', error);
this.emit('error', error);
});
this.captureProcess.on('close', (code) => {
console.log(`Processus PipeWire capture fermé (code ${code})`);
this.isCapturing = false;
});
this.isCapturing = true;
console.log(`✓ Capture PipeWire démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
console.log(` Latence: ${this.options.latency}ms`);
} catch (error) {
console.error('Erreur démarrage capture PipeWire:', error);
throw error;
}
}
/**
* Arrête la capture audio
*/
stopCapture() {
if (this.captureProcess && this.isCapturing) {
this.captureProcess.kill('SIGTERM');
this.captureProcess = null;
this.isCapturing = false;
this.captureAccumulator = Buffer.alloc(0); // Reset accumulator
console.log('✓ Capture PipeWire arrêtée');
}
}
/**
* Démarre la lecture audio
* @returns {Promise<void>}
*/
async startPlayback() {
if (this.isPlaying) {
console.warn('Lecture PipeWire déjà active');
return;
}
if (!PipeWireBackend.isServerRunning()) {
throw new Error('Serveur PipeWire non démarré');
}
try {
const args = [
'--playback',
'--format=s16',
`--rate=${this.options.sampleRate}`,
`--channels=${this.options.channels}`,
`--latency=${this.options.latency}ms`,
'-' // Lecture depuis stdin
];
if (this.options.outputTargetDevice) {
args.push(`--target=${this.options.outputTargetDevice}`);
}
this.playbackProcess = spawn('pw-cat', args);
this.playbackProcess.stderr.on('data', (data) => {
const msg = data.toString();
if (!msg.includes('stream state changed')) {
console.error('PipeWire playback stderr:', msg);
}
});
this.playbackProcess.on('error', (error) => {
console.error('Erreur processus PipeWire playback:', error);
this.emit('error', error);
});
this.playbackProcess.on('close', (code) => {
console.log(`Processus PipeWire playback fermé (code ${code})`);
this.isPlaying = false;
});
this.isPlaying = true;
this._startPlaybackLoop();
console.log(`✓ Lecture PipeWire démarrée : ${this.options.sampleRate}Hz, ${this.options.channels}ch`);
console.log(` Latence: ${this.options.latency}ms`);
} catch (error) {
console.error('Erreur démarrage lecture PipeWire:', error);
throw error;
}
}
/**
* Arrête la lecture audio
*/
stopPlayback() {
if (this.playbackProcess && this.isPlaying) {
this.playbackProcess.kill('SIGTERM');
this.playbackProcess = null;
this.isPlaying = false;
this.playbackBuffer = [];
console.log('✓ Lecture PipeWire arrêtée');
}
}
/**
* Ajoute des données audio au buffer de lecture
* @param {Buffer} audioData - Données PCM 16-bit
*/
queueAudio(audioData) {
if (!this.isPlaying) {
console.warn('Tentative ajout audio alors que lecture PipeWire inactive');
return;
}
if (this.playbackBuffer.length < this.maxBufferSize) {
this.playbackBuffer.push(audioData);
} else {
this.emit('bufferOverrun');
}
}
/**
* Boucle de lecture du buffer circulaire
* @private
*/
_startPlaybackLoop() {
const playNextChunk = () => {
if (!this.isPlaying || !this.playbackProcess) return;
if (this.playbackBuffer.length > 0) {
const chunk = this.playbackBuffer.shift();
try {
this.playbackProcess.stdin.write(chunk);
} catch (error) {
console.error('Erreur écriture stdin PipeWire:', error);
}
} else {
// Buffer vide : underrun (silence)
const silenceBuffer = Buffer.alloc(this.options.framesPerBuffer * 2 * this.options.channels);
try {
this.playbackProcess.stdin.write(silenceBuffer);
} catch (error) {
// Ignore si le process est fermé
}
this.emit('bufferUnderrun');
}
const intervalMs = (this.options.framesPerBuffer / this.options.sampleRate) * 1000;
setTimeout(playNextChunk, intervalMs);
};
playNextChunk();
}
/**
* Arrête tous les streams
*/
destroy() {
this.shuttingDown = true;
this.stopCapture();
this.stopPlayback();
this.removeAllListeners();
console.log('✓ PipeWireBackend détruit');
}
/**
* Obtient les statistiques du backend
* @returns {Object}
*/
getStats() {
return {
capturing: this.isCapturing,
playing: this.isPlaying,
playbackBufferSize: this.playbackBuffer.length,
sampleRate: this.options.sampleRate,
channels: this.options.channels,
framesPerBuffer: this.options.framesPerBuffer,
latency: this.options.latency,
pipewireServerRunning: PipeWireBackend.isServerRunning()
};
}
/**
* Obtient les informations du serveur PipeWire
* @returns {Object}
*/
static getServerInfo() {
if (!this.isServerRunning()) {
return { running: false };
}
try {
const output = execSync('pw-cli info 0', { encoding: 'utf8' });
// Parse basique des infos
const versionMatch = output.match(/version:\s*"([^"]+)"/);
return {
running: true,
version: versionMatch ? versionMatch[1] : 'unknown',
devices: this.getDevices().length
};
} catch (error) {
return { running: true };
}
}
}
export default PipeWireBackend;
File diff suppressed because one or more lines are too long
+183
View File
@@ -0,0 +1,183 @@
/**
* ConfigManager.js
* Gestionnaire centralisé de configuration avec support événements
* Phase 2.5
*/
import { EventEmitter } from 'events';
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import YAML from 'yaml';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const configPath = join(__dirname, 'config.yaml');
/**
* Génère un ID slug à partir d'un nom
*/
function slugify(text) {
return text
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '')
.replace(/--+/g, '-');
}
class ConfigManager extends EventEmitter {
constructor() {
super();
this.config = null;
this.load();
}
/**
* Charge la configuration depuis le fichier YAML
*/
load() {
try {
const configFile = readFileSync(configPath, 'utf8');
this.config = YAML.parse(configFile);
// Générer les IDs pour les groupes et canaux
this.config.groups = this.config.groups.map(group => {
const groupId = slugify(group.name);
return {
...group,
id: groupId,
channels: group.channels ? group.channels.map(channel => ({
...channel,
id: channel.id || `${groupId}-${slugify(channel.name)}`
})) : []
};
});
return this.config;
} catch (error) {
console.error('Erreur chargement configuration:', error);
throw error;
}
}
/**
* Récupère la configuration actuelle
*/
get() {
return this.config;
}
/**
* Sauvegarde la configuration dans le fichier YAML
* Ne sauvegarde PAS les IDs (ils sont générés dynamiquement)
*/
save(config) {
try {
// Nettoyer les IDs avant de sauvegarder
const cleanConfig = {
...config,
groups: config.groups.map(group => {
const { id, ...groupWithoutId } = group;
return {
...groupWithoutId,
channels: group.channels ? group.channels.map(channel => {
const { id: channelId, ...channelWithoutId } = channel;
return channelWithoutId;
}) : []
};
})
};
const yamlContent = YAML.stringify(cleanConfig);
writeFileSync(configPath, yamlContent, 'utf8');
// Recharger pour synchroniser
this.load();
// Émettre événement de changement
this.emit('config-updated', this.config);
return this.config;
} catch (error) {
console.error('Erreur sauvegarde configuration:', error);
throw error;
}
}
/**
* Met à jour la configuration audio device
*/
updateAudioDevice(deviceConfig) {
try {
console.log('📝 ConfigManager.updateAudioDevice:', deviceConfig);
if (!this.config.audio) {
this.config.audio = {};
}
if (!this.config.audio.device) {
this.config.audio.device = {};
}
// Mettre à jour les paramètres fournis
if (deviceConfig.inputDeviceId !== undefined) {
this.config.audio.device.inputDeviceId = deviceConfig.inputDeviceId;
}
if (deviceConfig.outputDeviceId !== undefined) {
this.config.audio.device.outputDeviceId = deviceConfig.outputDeviceId;
}
if (deviceConfig.sampleRate !== undefined) {
this.config.audio.device.sampleRate = deviceConfig.sampleRate;
this.config.audio.sampleRate = deviceConfig.sampleRate; // Sync avec config globale
}
if (deviceConfig.bufferSize !== undefined) {
this.config.audio.device.bufferSize = deviceConfig.bufferSize;
}
console.log('💾 Sauvegarde configuration...');
this.save(this.config);
// Émettre événement spécifique
console.log('📢 Émission événement audio-device-updated');
this.emit('audio-device-updated', this.config.audio.device);
console.log('✓ Configuration audio device mise à jour');
return this.config.audio.device;
} catch (error) {
console.error('❌ Erreur updateAudioDevice:', error);
throw error;
}
}
/**
* Met à jour la configuration audio globale
*/
updateAudioConfig(audioConfig) {
if (!this.config.audio) {
this.config.audio = {};
}
if (audioConfig.sampleRate !== undefined) {
this.config.audio.sampleRate = audioConfig.sampleRate;
}
if (audioConfig.defaultBitrate !== undefined) {
this.config.audio.defaultBitrate = audioConfig.defaultBitrate;
}
if (audioConfig.jitterBufferMs !== undefined) {
this.config.audio.jitterBufferMs = audioConfig.jitterBufferMs;
}
this.save(this.config);
return this.config.audio;
}
}
// Singleton
const configManager = new ConfigManager();
export default configManager;
+51 -41
View File
@@ -1,50 +1,60 @@
# PTT Live - Configuration
# Format simplifié : nom du groupe + canaux (les IDs sont générés automatiquement)
# Configuration audio globale
audio:
sampleRate: 48000
frameSize: 20 # ms
defaultBitrate: 96 # kbps
channels: 2
frameSize: 20
defaultBitrate: 96
jitterBufferMs: 40
# Configuration des groupes
device:
inputDeviceId: Loopback Audio 4
outputDeviceId: Haut-parleurs MacBook Pro
sampleRate: 48000
routing:
inputToGroup:
"0":
- default
"1": []
"2": []
"4":
- technique
"5":
- technique
groupToOutput:
technique:
- "1"
production:
- "0"
- "1"
default:
- "0"
gains: {}
channelNames:
inputs:
"0": Mac
"1": Talkback FOH
"2": Retour Console
"3": Liaison Scène
"4": Monitor Mix
"5": Spare 1
outputs:
"0": L
"1": R
"2": Talkback Console
groups:
- name: "Production"
- name: Default
audioBitrate: 96
channels:
- name: "Principal"
audioInput: 0
audioOutput: 0
- name: "Backup"
audioInput: 1
audioOutput: 1
- name: "Technique"
channels:
- name: "Général"
audioInput: 2
audioOutput: 2
- name: "Sonorisation"
audioBitrate: 128
channels:
- name: "Principal"
audioInput: 3
audioOutput: 3
- name: "Retours"
audioInput: 4
audioOutput: 4
# Configuration serveur
channels: []
- name: Production
audioBitrate: 96
channels: []
- name: Technique
audioBitrate: 96
channels: []
server:
host: "0.0.0.0"
host: 0.0.0.0
port: 3000
livekit:
url: "ws://localhost:7880"
# Logging
url: AUTO
logging:
level: "debug"
logLatency: true
logAudioStats: true
level: debug
logLatency: false
logAudioStats: false
+173 -59
View File
@@ -2,53 +2,32 @@
import 'dotenv/config';
import express from 'express';
import https from 'https';
import http from 'http';
import { spawn } from 'child_process';
import { readFileSync } from 'fs';
import { readFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { networkInterfaces } from 'os';
import YAML from 'yaml';
import { AccessToken } from 'livekit-server-sdk';
import adminRouter, { registerUser, addLog } from './api/admin.js';
import configManager from './config/ConfigManager.js';
import audioBridgeManager from './bridge/AudioBridgeManager.js';
import AudioLevelsServer from './websocket/AudioLevelsServer.js';
import { setGlobalLogLevel } from './utils/Logger.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Chargement configuration
const configPath = join(__dirname, 'config', 'config.yaml');
const configFile = readFileSync(configPath, 'utf8');
const config = YAML.parse(configFile);
// Chargement configuration via ConfigManager
const config = configManager.get();
/**
* Génère un ID slug à partir d'un nom
* Ex: "Équipe Production" -> "equipe-production"
*/
function slugify(text) {
return text
.toString()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Retire les accents
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w-]+/g, '')
.replace(/--+/g, '-');
}
// Configure le niveau de log
const logLevel = config.logging?.level?.toUpperCase() || 'INFO';
setGlobalLogLevel(logLevel);
console.log(`📊 Niveau de log: ${logLevel}`);
// Générer les IDs pour les groupes et canaux s'ils n'existent pas
config.groups = config.groups.map(group => {
if (!group.id) {
group.id = slugify(group.name);
}
if (group.channels) {
group.channels = group.channels.map(channel => {
if (!channel.id) {
channel.id = `${group.id}-${slugify(channel.name)}`;
}
return channel;
});
}
return group;
});
// Note: Les IDs sont maintenant générés automatiquement par le ConfigManager
/**
* Détecte l'IP réseau locale (WiFi/Ethernet)
@@ -90,6 +69,7 @@ const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || 'secret';
const USE_LOCAL_LIVEKIT = process.env.USE_LOCAL_LIVEKIT === 'true';
const SERVER_PORT = parseInt(process.env.PORT || config.server.port, 10);
const SERVER_HOST = config.server.host;
const ENABLE_HTTPS = process.env.ENABLE_HTTPS === 'true';
// Configuration URL LiveKit
let LIVEKIT_URL = process.env.LIVEKIT_URL || config.server.livekit.url;
@@ -128,8 +108,11 @@ let livekitProcess = null;
function startLiveKitServer() {
return new Promise((resolve, reject) => {
// Utiliser le binaire Homebrew (dans PATH)
const livekitBinary = 'livekit-server';
// Détection du binaire LiveKit :
// 1. Binaire local (Linux après install.sh) : server/bin/livekit-server
// 2. Binaire Homebrew (macOS) : livekit-server dans PATH
const localBinary = join(__dirname, 'bin', 'livekit-server');
const livekitBinary = existsSync(localBinary) ? localBinary : 'livekit-server';
log('info', 'Démarrage LiveKit Server...');
log('debug', 'Commande:', livekitBinary);
@@ -146,16 +129,27 @@ function startLiveKitServer() {
livekitProcess = spawn(livekitBinary, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
env: {
...process.env,
LIVEKIT_LOG_LEVEL: 'info' // Réduit les logs LiveKit (debug → info)
},
shell: true // Permet de trouver le binaire dans PATH
});
livekitProcess.stdout.on('data', (data) => {
const output = data.toString().trim();
if (output) {
log('debug', '[LiveKit]', output);
if (!output) return;
// Filtrer les logs trop verbeux
if (output.includes('DEBUG') ||
output.includes('received signal request') ||
output.includes('sending signal response') ||
output.includes('handling signal request')) {
return; // Ignorer ces logs
}
log('debug', '[LiveKit]', output);
// Détection démarrage réussi
if (output.includes('starting server') || output.includes('rtc server')) {
resolve();
@@ -164,9 +158,14 @@ function startLiveKitServer() {
livekitProcess.stderr.on('data', (data) => {
const output = data.toString().trim();
if (output) {
log('warn', '[LiveKit Error]', output);
if (!output) return;
// Filtrer les logs DEBUG de stderr aussi
if (output.includes('DEBUG')) {
return;
}
log('warn', '[LiveKit Error]', output);
});
livekitProcess.on('error', (error) => {
@@ -204,12 +203,36 @@ app.use((req, res, next) => {
next();
});
// Middleware redirection HTTP → HTTPS (si activé)
app.use((req, res, next) => {
// Si HTTPS activé et requête en HTTP, rediriger
if (ENABLE_HTTPS && req.protocol === 'http' && req.hostname !== 'localhost') {
const httpsUrl = `https://${req.hostname}:${SERVER_PORT}${req.url}`;
log('debug', `↪️ Redirection HTTPS: ${httpsUrl}`);
return res.redirect(301, httpsUrl);
}
next();
});
// Middleware logging
app.use((req, res, next) => {
log('debug', `${req.method} ${req.path}`);
next();
});
// ========== Servir fichiers statiques client (production) ==========
// En production, servir le build client depuis ../client/dist
const clientDistPath = join(__dirname, '..', 'client', 'dist');
if (existsSync(clientDistPath)) {
log('info', `📦 Serveur statique activé : ${clientDistPath}`);
app.use(express.static(clientDistPath));
} else {
log('debug', '📦 Pas de build client (mode dev)');
}
// ========== Routes Admin ==========
// Monter les routes admin sous /admin
@@ -217,20 +240,19 @@ app.use('/admin', adminRouter);
// ========== Routes API ==========
// Créer un router pour les routes API
const apiRouter = express.Router();
/**
* GET /config
* Retourne la configuration des groupes
*/
app.get('/config', (req, res) => {
apiRouter.get('/config', (req, res) => {
try {
const clientConfig = {
groups: config.groups.map(g => ({
id: g.id,
name: g.name,
channels: g.channels.map(c => ({
id: c.id,
name: c.name
}))
name: g.name
})),
audio: {
sampleRate: config.audio.sampleRate,
@@ -249,7 +271,7 @@ app.get('/config', (req, res) => {
* GET /groups
* Retourne la liste des groupes disponibles (simplifié)
*/
app.get('/groups', (req, res) => {
apiRouter.get('/groups', (req, res) => {
try {
const groups = config.groups.map(g => ({
id: g.id,
@@ -268,7 +290,7 @@ app.get('/groups', (req, res) => {
* Génère un token LiveKit pour un client
* Body: { username: string, groupId: string }
*/
app.post('/token', async (req, res) => {
apiRouter.post('/token', async (req, res) => {
try {
const { username, groupId } = req.body;
@@ -311,11 +333,30 @@ app.post('/token', async (req, res) => {
// Enregistrer l'utilisateur dans le système admin
registerUser(participantIdentity, username, groupId, roomName);
// Générer les canaux virtuels depuis le routing (inputs uniquement)
const virtualChannels = [];
const inputToGroup = config.audio?.routing?.inputToGroup || {};
const channelNames = config.audio?.channelNames?.inputs || {};
// Trouver tous les canaux physiques routés vers ce groupe
for (const [inputChannel, groups] of Object.entries(inputToGroup)) {
if (groups.includes(groupId)) {
const channelName = channelNames[inputChannel] || `Canal ${inputChannel}`;
virtualChannels.push({
id: `input-${inputChannel}`,
name: channelName,
isVirtual: true,
audioInput: parseInt(inputChannel, 10)
});
}
}
res.json({
token,
url: LIVEKIT_URL,
roomName,
participantIdentity
participantIdentity,
virtualChannels
});
} catch (error) {
@@ -328,7 +369,7 @@ app.post('/token', async (req, res) => {
* GET /health
* Health check
*/
app.get('/health', (req, res) => {
apiRouter.get('/health', (req, res) => {
const isLivekitRunning = livekitProcess !== null;
res.json({
status: isLivekitRunning ? 'ok' : 'degraded',
@@ -337,22 +378,34 @@ app.get('/health', (req, res) => {
});
});
// Monter le router API sous /api ET à la racine (rétrocompatibilité)
app.use('/api', apiRouter);
app.use(apiRouter); // Routes accessibles aussi sans préfixe /api
/**
* GET /
* Info serveur
* Info serveur OU client PWA (si build existe)
*/
app.get('/', (req, res) => {
// Si build client existe, servir index.html
const indexPath = join(clientDistPath, 'index.html');
if (existsSync(indexPath)) {
res.sendFile(indexPath);
} else {
// Sinon, afficher info API
res.json({
name: 'PTT Live Server',
version: '0.1.0',
phase: 'Phase 1 - MVP',
version: '0.2.0',
mode: 'development',
endpoints: [
'GET /config - Configuration groupes',
'GET /groups - Liste des groupes',
'POST /token - Générer token client',
'GET /health - Health check'
'GET /health - Health check',
'GET /admin - Interface administration'
]
});
}
});
// ========== Démarrage ==========
@@ -380,13 +433,68 @@ async function start() {
log('warn', '⚠️ Pour utiliser LiveKit local, définir USE_LOCAL_LIVEKIT=true dans .env');
}
// 2. Démarrer API REST
const server = app.listen(SERVER_PORT, SERVER_HOST, () => {
// 2. Démarrer API REST (HTTP ou HTTPS selon config)
let server;
if (ENABLE_HTTPS) {
// Charger certificats SSL (mêmes que Vite)
const certPath = join(__dirname, '..', 'client');
const httpsOptions = {
key: readFileSync(join(certPath, 'localhost+3-key.pem')),
cert: readFileSync(join(certPath, 'localhost+3.pem'))
};
server = https.createServer(httpsOptions, app);
server.listen(SERVER_PORT, SERVER_HOST, () => {
log('info', `✓ API REST démarrée sur https://${SERVER_HOST}:${SERVER_PORT}`);
log('info', '');
log('info', 'Serveur prêt !');
log('info', `Groupes configurés: ${config.groups.map(g => g.name).join(', ')}`);
log('info', '');
// Afficher URLs d'accès
if (networkIP && networkIP !== 'localhost') {
const prodUrl = `https://${networkIP}:${SERVER_PORT}`;
log('info', '📱 Accès réseau WiFi :');
log('info', '');
log('info', ` Prod : ${prodUrl}`);
log('info', '');
}
});
} else {
server = http.createServer(app);
server.listen(SERVER_PORT, SERVER_HOST, () => {
log('info', `✓ API REST démarrée sur http://${SERVER_HOST}:${SERVER_PORT}`);
log('info', '');
log('info', 'Serveur prêt !');
log('info', `Groupes configurés: ${config.groups.map(g => g.name).join(', ')}`);
log('info', '');
// Afficher URLs d'accès
if (networkIP && networkIP !== 'localhost') {
const clientUrl = `https://${networkIP}:5173`; // Dev mode
const prodUrl = `http://${networkIP}:${SERVER_PORT}`; // Prod mode HTTP
log('info', '📱 Accès réseau WiFi :');
log('info', '');
log('info', ` Dev : ${clientUrl}`);
log('info', ` Prod : ${prodUrl}`);
log('info', '');
}
});
}
// 2.5 Démarrer WebSocket Audio Levels (même port que l'API)
const audioLevelsServer = new AudioLevelsServer({ server });
audioLevelsServer.start();
const wsProtocol = ENABLE_HTTPS ? 'wss' : 'ws';
log('info', `✓ WebSocket Audio Levels démarré sur ${wsProtocol}://${SERVER_HOST}:${SERVER_PORT}`);
// 3. Démarrer Audio Bridge Manager (Phase 2.5)
log('info', '');
log('info', '🎵 Démarrage Audio Bridge Manager...');
await audioBridgeManager.start({ liveKitUrl: LIVEKIT_URL });
log('info', '✓ Audio Bridge Manager prêt (mode placeholder)');
// Gérer erreur port déjà utilisé
server.on('error', (error) => {
@@ -407,9 +515,15 @@ async function start() {
// ========== Cleanup ==========
function cleanup() {
async function cleanup() {
log('info', 'Arrêt du serveur...');
// Arrêter l'audio bridge
if (audioBridgeManager) {
log('info', 'Arrêt Audio Bridge Manager...');
await audioBridgeManager.stop();
}
if (livekitProcess) {
log('info', 'Arrêt LiveKit Server...');
livekitProcess.kill('SIGTERM');
+2 -2
View File
@@ -19,12 +19,12 @@
"author": "",
"license": "MIT",
"dependencies": {
"@livekit/rtc-node": "^0.13.28",
"dotenv": "^17.4.2",
"express": "^4.19.2",
"livekit-client": "^2.19.0",
"livekit-server-sdk": "^2.6.0",
"naudiodon": "^2.3.6",
"opusscript": "^0.1.1",
"qrcode-terminal": "^0.12.0",
"ws": "^8.17.0",
"yaml": "^2.4.2"
},
+78
View File
@@ -0,0 +1,78 @@
/**
* Logger.js
* Système de logging centralisé avec niveaux configurables
*/
const LOG_LEVELS = {
ERROR: 0,
WARN: 1,
INFO: 2,
DEBUG: 3,
TRACE: 4
};
class Logger {
constructor(category = 'default', level = 'INFO') {
this.category = category;
this.level = LOG_LEVELS[level] ?? LOG_LEVELS.INFO;
}
setLevel(level) {
this.level = LOG_LEVELS[level] ?? LOG_LEVELS.INFO;
}
error(message, ...args) {
if (this.level >= LOG_LEVELS.ERROR) {
console.error(`[${this.category}] ❌`, message, ...args);
}
}
warn(message, ...args) {
if (this.level >= LOG_LEVELS.WARN) {
console.warn(`[${this.category}] ⚠️ `, message, ...args);
}
}
info(message, ...args) {
if (this.level >= LOG_LEVELS.INFO) {
console.log(`[${this.category}] `, message, ...args);
}
}
success(message, ...args) {
if (this.level >= LOG_LEVELS.INFO) {
console.log(`[${this.category}] ✓`, message, ...args);
}
}
debug(message, ...args) {
if (this.level >= LOG_LEVELS.DEBUG) {
console.log(`[${this.category}] 🔍`, message, ...args);
}
}
trace(message, ...args) {
if (this.level >= LOG_LEVELS.TRACE) {
console.log(`[${this.category}] 🔬`, message, ...args);
}
}
}
// Configuration globale depuis env ou config
const globalLevel = process.env.LOG_LEVEL || 'INFO';
// Loggers par catégorie
const loggers = new Map();
export function getLogger(category) {
if (!loggers.has(category)) {
loggers.set(category, new Logger(category, globalLevel));
}
return loggers.get(category);
}
export function setGlobalLogLevel(level) {
loggers.forEach(logger => logger.setLevel(level));
}
export default { getLogger, setGlobalLogLevel };
+378
View File
@@ -0,0 +1,378 @@
/**
* AudioLevelsServer.js
* WebSocket server pour streaming des niveaux audio temps réel
*
* Permet à l'interface admin de visualiser :
* - Niveaux d'entrée physiques (VU-mètres)
* - Niveaux de groupes LiveKit
* - Niveaux de sortie physiques
* - Détection de clipping
* - État des routes actives
*/
import { WebSocketServer } from 'ws';
import { EventEmitter } from 'events';
/**
* Calcule le niveau RMS d'un buffer audio (dBFS)
*/
function calculateRMS(buffer) {
if (!buffer || buffer.length === 0) return -120; // Silence
let sum = 0;
for (let i = 0; i < buffer.length; i++) {
sum += buffer[i] * buffer[i];
}
const rms = Math.sqrt(sum / buffer.length);
// Conversion en dBFS (0dBFS = niveau max)
if (rms === 0) return -120;
const dbFS = 20 * Math.log10(rms);
return Math.max(-120, Math.min(0, dbFS));
}
/**
* Calcule le peak d'un buffer audio
*/
function calculatePeak(buffer) {
if (!buffer || buffer.length === 0) return 0;
let peak = 0;
for (let i = 0; i < buffer.length; i++) {
peak = Math.max(peak, Math.abs(buffer[i]));
}
return peak;
}
/**
* Serveur WebSocket pour monitoring audio
*/
export class AudioLevelsServer extends EventEmitter {
constructor(options = {}) {
super();
this.options = {
port: options.port || 3001,
server: options.server || null, // Serveur HTTP existant
updateRateMs: options.updateRateMs || 50, // 20 fois/sec
...options
};
this.wss = null;
this.clients = new Set();
this.updateInterval = null;
// Données à broadcaster
this.levels = {
inputs: {}, // { channelId: { rms: -12, peak: 0.5, clipping: false } }
groups: {}, // { groupName: { rms: -8, peak: 0.7, clipping: false } }
outputs: {}, // { channelId: { rms: -10, peak: 0.6, clipping: false } }
routing: {
activeInputs: [],
activeGroups: [],
activeOutputs: []
}
};
this.stats = {
connectedClients: 0,
messagesSent: 0,
errors: 0
};
}
/**
* Démarre le serveur WebSocket
*/
start() {
return new Promise((resolve, reject) => {
try {
// Si un serveur HTTP est fourni, utiliser le même port (upgrade HTTP → WebSocket)
// Sinon, créer un serveur WebSocket standalone sur son propre port
const wsOptions = this.options.server
? { server: this.options.server, path: '/audio-levels' }
: { port: this.options.port };
this.wss = new WebSocketServer(wsOptions);
this.wss.on('connection', (ws, req) => {
this._handleNewConnection(ws, req);
});
this.wss.on('error', (error) => {
console.error('Erreur WebSocket server:', error);
this.stats.errors++;
this.emit('error', error);
});
// Démarrage du broadcast périodique
this._startBroadcast();
if (this.options.server) {
console.log(`WebSocket AudioLevels démarré sur path /audio-levels (même port que HTTP)`);
} else {
console.log(`WebSocket AudioLevels démarré sur ws://localhost:${this.options.port}`);
}
this.emit('started');
resolve();
} catch (error) {
reject(error);
}
});
}
/**
* Gère une nouvelle connexion client
*/
_handleNewConnection(ws, req) {
const clientId = `${req.socket.remoteAddress}:${req.socket.remotePort}`;
console.log(`Nouveau client audio-levels: ${clientId}`);
this.clients.add(ws);
this.stats.connectedClients = this.clients.size;
// Envoi des données actuelles immédiatement
this._sendToClient(ws, {
type: 'initial',
data: this.levels
});
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
this._handleClientMessage(ws, data);
} catch (error) {
console.error('Erreur parsing message client:', error);
}
});
ws.on('close', () => {
console.log(`Client déconnecté: ${clientId}`);
this.clients.delete(ws);
this.stats.connectedClients = this.clients.size;
});
ws.on('error', (error) => {
console.error(`Erreur client ${clientId}:`, error);
this.clients.delete(ws);
this.stats.connectedClients = this.clients.size;
});
this.emit('clientConnected', { clientId, totalClients: this.clients.size });
}
/**
* Gère les messages entrants des clients
*/
_handleClientMessage(ws, message) {
switch (message.type) {
case 'ping':
this._sendToClient(ws, { type: 'pong', timestamp: Date.now() });
break;
case 'setUpdateRate':
// Permet au client de modifier le taux de rafraîchissement
if (message.rateMs >= 20 && message.rateMs <= 1000) {
this._restartBroadcast(message.rateMs);
}
break;
default:
console.warn('Message client inconnu:', message.type);
}
}
/**
* Démarre le broadcast périodique
*/
_startBroadcast() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
this.updateInterval = setInterval(() => {
this._broadcastLevels();
}, this.options.updateRateMs);
}
/**
* Redémarre le broadcast avec un nouveau taux
*/
_restartBroadcast(newRateMs) {
this.options.updateRateMs = newRateMs;
this._startBroadcast();
console.log(`Taux de rafraîchissement modifié: ${newRateMs}ms`);
}
/**
* Broadcast les niveaux à tous les clients connectés
*/
_broadcastLevels() {
if (this.clients.size === 0) return;
const message = {
type: 'levels',
timestamp: Date.now(),
data: this.levels
};
this._broadcast(message);
}
/**
* Envoie un message à tous les clients
*/
_broadcast(message) {
const payload = JSON.stringify(message);
this.clients.forEach(ws => {
if (ws.readyState === 1) { // OPEN
try {
ws.send(payload);
this.stats.messagesSent++;
} catch (error) {
console.error('Erreur envoi message:', error);
this.stats.errors++;
}
}
});
}
/**
* Envoie un message à un client spécifique
*/
_sendToClient(ws, message) {
if (ws.readyState === 1) {
try {
ws.send(JSON.stringify(message));
this.stats.messagesSent++;
} catch (error) {
console.error('Erreur envoi message client:', error);
this.stats.errors++;
}
}
}
/**
* Met à jour les niveaux d'entrée
* Appelé par le GroupAudioRouter après processInputsToGroups()
*/
updateInputLevels(inputBuffers) {
inputBuffers.forEach((buffer, channelId) => {
const rms = calculateRMS(buffer);
const peak = calculatePeak(buffer);
const clipping = peak >= 0.99;
this.levels.inputs[channelId] = { rms, peak, clipping };
});
this.levels.routing.activeInputs = Array.from(inputBuffers.keys());
}
/**
* Met à jour les niveaux de groupe
* Appelé par le GroupAudioRouter après processInputsToGroups()
*/
updateGroupLevels(groupBuffers) {
groupBuffers.forEach((buffer, groupName) => {
const rms = calculateRMS(buffer);
const peak = calculatePeak(buffer);
const clipping = peak >= 0.99;
this.levels.groups[groupName] = { rms, peak, clipping };
});
this.levels.routing.activeGroups = Array.from(groupBuffers.keys());
}
/**
* Met à jour les niveaux de sortie
* Appelé par le GroupAudioRouter après processGroupsToOutputs()
*/
updateOutputLevels(outputBuffers) {
outputBuffers.forEach((buffer, channelId) => {
const rms = calculateRMS(buffer);
const peak = calculatePeak(buffer);
const clipping = peak >= 0.99;
this.levels.outputs[channelId] = { rms, peak, clipping };
});
this.levels.routing.activeOutputs = Array.from(outputBuffers.keys());
}
/**
* Réinitialise tous les niveaux (silence)
*/
resetLevels() {
this.levels = {
inputs: {},
groups: {},
outputs: {},
routing: {
activeInputs: [],
activeGroups: [],
activeOutputs: []
}
};
}
/**
* Récupère les statistiques
*/
getStats() {
return {
...this.stats,
updateRateMs: this.options.updateRateMs,
port: this.options.port
};
}
/**
* Arrête le serveur
*/
async stop() {
console.log('Arrêt AudioLevelsServer...');
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
if (this.wss) {
// Ferme toutes les connexions clients
this.clients.forEach(ws => {
ws.close(1000, 'Server shutdown');
});
this.clients.clear();
// Ferme le serveur
await new Promise((resolve) => {
this.wss.close(() => {
console.log('WebSocket AudioLevels arrêté');
resolve();
});
});
this.wss = null;
}
this.emit('stopped');
}
/**
* Détruit le serveur
*/
async destroy() {
await this.stop();
this.removeAllListeners();
console.log('AudioLevelsServer détruit');
}
}
export default AudioLevelsServer;
Executable
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# PTT Live - Affichage QR Code
# Génère et affiche le QR code pour connexion smartphone
set -e
# Couleurs
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
# Détection IP réseau
get_network_ip() {
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
ifconfig | grep "inet " | grep -v 127.0.0.1 | awk '{print $2}' | head -n 1
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
# Linux
hostname -I | awk '{print $1}'
else
echo "localhost"
fi
}
NETWORK_IP=$(get_network_ip)
# Déterminer l'URL selon mode dev ou prod
if [ -d "client/dist" ] && [ "$1" != "--dev" ]; then
# Mode production (HTTPS)
URL="https://${NETWORK_IP}:3000"
MODE="production"
else
# Mode dev (HTTPS)
URL="https://${NETWORK_IP}:5173"
MODE="dev"
fi
echo ""
echo -e "${BLUE}=================================="
echo "📱 QR Code PTT Live ($MODE)"
echo "==================================${NC}"
echo ""
# Générer le QR code avec le package installé dans server/
(cd server && node -e "
const qrcode = require('qrcode-terminal');
qrcode.generate('$URL', { small: true });
")
echo ""
echo -e "${GREEN}🔗 URL : $URL${NC}"
echo ""
echo "📱 Scannez ce QR code depuis votre smartphone"
echo " pour vous connecter instantanément"
echo ""
Executable
+204
View File
@@ -0,0 +1,204 @@
#!/bin/bash
# PTT Live - Script de démarrage unifié
# Lance le serveur et le client en mode production
set -e
# Couleurs
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
# Détection IP réseau
get_network_ip() {
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS
ifconfig | grep "inet " | grep -v 127.0.0.1 | awk '{print $2}' | head -n 1
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
# Linux
hostname -I | awk '{print $1}'
else
echo "localhost"
fi
}
NETWORK_IP=$(get_network_ip)
echo -e "${BLUE}=================================="
echo "🚀 PTT Live - Démarrage"
echo "==================================${NC}"
echo ""
echo -e "${GREEN}📡 IP réseau détectée : ${NETWORK_IP}${NC}"
echo ""
# Vérifier que les dépendances sont installées
if [ ! -d "server/node_modules" ]; then
echo -e "${RED}❌ Dépendances serveur manquantes${NC}"
echo " Exécutez d'abord : ./install/macos.sh (ou linux.sh)"
exit 1
fi
if [ ! -d "client/node_modules" ]; then
echo -e "${RED}❌ Dépendances client manquantes${NC}"
echo " Exécutez d'abord : ./install/macos.sh (ou linux.sh)"
exit 1
fi
# Créer fichier PID pour cleanup
PID_FILE="/tmp/ptt-live.pid"
# Fonction cleanup
cleanup() {
echo ""
echo -e "${YELLOW}⏹ Arrêt PTT Live...${NC}"
if [ -f "$PID_FILE" ]; then
while read -r pid; do
if ps -p "$pid" > /dev/null 2>&1; then
kill "$pid" 2>/dev/null || true
fi
done < "$PID_FILE"
rm -f "$PID_FILE"
fi
echo -e "${GREEN}✓ Arrêté${NC}"
exit 0
}
trap cleanup SIGINT SIGTERM EXIT
# Afficher le QR code AVANT de lancer le serveur
if [ "$1" == "--dev" ]; then
./show-qr.sh --dev
else
./show-qr.sh
fi
# Démarrer le serveur (silencieux, logs dans fichier)
echo -e "${BLUE}🔧 Démarrage serveur...${NC}"
echo ""
cd server
# En mode production (pas --dev), activer HTTPS
if [ "$1" != "--dev" ]; then
export ENABLE_HTTPS=true
fi
# Lancer le serveur en background silencieux
npm start > ../server.log 2>&1 &
SERVER_PID=$!
echo "$SERVER_PID" > "$PID_FILE"
cd ..
# Attendre que le serveur soit prêt
echo ""
echo -e "${YELLOW}⏳ Attente démarrage serveur...${NC}"
# Déterminer le protocole selon le mode
if [ "$1" != "--dev" ]; then
HEALTH_URL="https://localhost:3000/health"
else
HEALTH_URL="http://localhost:3000/health"
fi
for i in {1..30}; do
if curl -kssf "$HEALTH_URL" > /dev/null 2>&1; then
echo -e "${GREEN}✓ Serveur prêt${NC}"
break
fi
if [ $i -eq 30 ]; then
echo -e "${RED}❌ Timeout : le serveur n'a pas démarré${NC}"
echo " Consultez server.log pour plus de détails"
exit 1
fi
sleep 1
done
echo ""
# Build client si pas déjà fait ou mode dev
if [ "$1" == "--dev" ]; then
echo -e "${BLUE}🎨 Démarrage client (dev)...${NC}"
cd client
npm run dev &
CLIENT_PID=$!
echo "$CLIENT_PID" >> "$PID_FILE"
cd ..
echo -e "${GREEN}✓ Client dev démarré${NC}"
echo ""
echo -e "${GREEN}=================================="
echo "✅ PTT Live démarré (mode dev)"
echo "==================================${NC}"
echo ""
echo -e "${BLUE}🌐 ACCÈS CLIENT (HTTPS) :${NC}"
echo -e "${GREEN} 👉 Local : https://localhost:5173${NC}"
echo -e "${GREEN} 👉 Réseau : https://${NETWORK_IP}:5173${NC}"
echo ""
echo -e "${YELLOW}⚠️ Acceptez le certificat auto-signé dans votre navigateur${NC}"
echo -e "${YELLOW} (Cliquez sur 'Avancé' puis 'Continuer')${NC}"
echo ""
echo "📊 API serveur (HTTP uniquement) : http://localhost:3000"
echo "🎛️ Interface admin : https://localhost:5173/admin"
echo ""
echo "📝 Logs serveur : tail -f server.log"
echo ""
echo -e "${YELLOW}Appuyez sur Ctrl+C pour arrêter${NC}"
echo ""
# Attendre indéfiniment
wait
else
# Mode production : build et serve
echo -e "${BLUE}🎨 Build client production...${NC}"
cd client
if [ ! -d "dist" ] || [ "$1" == "--rebuild" ]; then
npm run build
echo -e "${GREEN}✓ Client buildé${NC}"
else
echo -e "${YELLOW}⚠️ Build existant utilisé (--rebuild pour forcer)${NC}"
fi
cd ..
echo ""
echo -e "${GREEN}=================================="
echo "✅ PTT Live démarré (production)"
echo "==================================${NC}"
echo ""
echo -e "${BLUE}🌐 ACCÈS CLIENT (HTTPS) :${NC}"
echo -e "${GREEN} 👉 Local : https://localhost:3000${NC}"
echo -e "${GREEN} 👉 Réseau : https://${NETWORK_IP}:3000${NC}"
echo ""
echo -e "${YELLOW}⚠️ Acceptez le certificat auto-signé dans votre navigateur${NC}"
echo -e "${YELLOW} (Cliquez sur 'Avancé' puis 'Continuer')${NC}"
echo ""
echo "🎛️ Interface admin : https://localhost:3000/admin"
echo "📊 API serveur : https://localhost:3000/api"
echo ""
echo "📝 Logs serveur : tail -f server.log"
echo ""
echo -e "${YELLOW}Appuyez sur Ctrl+C pour arrêter${NC}"
echo ""
# Attendre indéfiniment
wait
fi