From ed22e6d878f1b83db3fc81561cae46fcdf7ef266 Mon Sep 17 00:00:00 2001 From: Benoit Date: Fri, 22 May 2026 23:05:44 +0200 Subject: [PATCH] fix: support complet iOS/mobile pour PTT (audio + HTTPS/WSS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modifications majeures : - HTTPS obligatoire pour getUserMedia sur iOS (certificats mkcert) - Proxy WSS Vite pour LiveKit (contourner mixed content HTTPS→WS) - Audio unlock explicite iOS dans useLiveKit (AudioContext) - Demande permission microphone avant connexion LiveKit - Touch optimizations CSS (touch-action, tap-highlight) - Meta iOS PWA (apple-mobile-web-app-capable) - Logs debug pour troubleshooting mobile - Attente publication track audio avant utilisation PTT Tests validés : ✅ iPhone Safari : émission + réception audio OK ✅ Desktop Chrome : fonctionne toujours ✅ 3+ devices simultanés 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- TODO.md | 19 +++--- client/index.html | 7 ++- client/localhost+3-key.pem | 28 +++++++++ client/localhost+3.pem | 27 ++++++++ client/src/App.jsx | 31 +++++++++- client/src/components/PTTButton.css | 6 ++ client/src/components/PTTButton.jsx | 2 + client/src/hooks/useLiveKit.js | 96 +++++++++++++++++++++++++---- client/vite.config.js | 11 ++++ 9 files changed, 202 insertions(+), 25 deletions(-) create mode 100644 client/localhost+3-key.pem create mode 100644 client/localhost+3.pem diff --git a/TODO.md b/TODO.md index 719dcc3..6b44578 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,7 @@ # TODO.md - Plan de développement PTT Live -**Dernière mise à jour** : 2026-05-21 -**Phase actuelle** : PHASE 1 - Fondations +**Dernière mise à jour** : 2026-05-22 +**Phase actuelle** : PHASE 1 - Fondations (Tests finaux en cours) --- @@ -111,6 +111,7 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m - [x] Mode PTT : mute/unmute track selon bouton - [x] Gestion touch events (mobile) - [x] Gestion mouse events (desktop) + - [x] **Fix iOS/mobile** : audio unlock, HTTPS obligatoire, proxy WSS LiveKit #### Styles - [x] CSS mobile-first @@ -122,17 +123,17 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m ### 1.5 Tests et validation Phase 1 #### Tests unitaires -- [ ] Opus encode/decode (qualité audio) -- [ ] Jitter buffer (buffer size stable) -- [ ] CoreAudio device detection +- [x] Opus encode/decode (qualité audio) +- [x] Jitter buffer (buffer size stable) +- [ ] CoreAudio device detection (naudiodon crash - à résoudre plus tard) #### Tests d'intégration -- [ ] Serveur démarre sans erreur -- [ ] Client obtient token valide -- [ ] Client rejoint room LiveKit +- [x] Serveur démarre sans erreur +- [x] Client obtient token valide +- [x] Client rejoint room LiveKit #### Tests end-to-end -- [ ] **Test 1** : 2 clients, PTT alterné, audio bidirectionnel +- [x] **Test 1** : 2 clients, PTT alterné, audio bidirectionnel - [ ] **Test 2** : Mesure latence (clap → réception < 150ms) - [ ] **Test 3** : Stabilité 5min sans coupure - [ ] **Test 4** : Reconnexion après perte WiFi diff --git a/client/index.html b/client/index.html index d2bd804..55caa22 100644 --- a/client/index.html +++ b/client/index.html @@ -3,10 +3,15 @@ - + + + + + + PTT Live diff --git a/client/localhost+3-key.pem b/client/localhost+3-key.pem new file mode 100644 index 0000000..63c6135 --- /dev/null +++ b/client/localhost+3-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC0hpMHdlPKTWp6 +8ixzelM1fh3TWtwpY4HmDFKF09VaBhIv6mSBdiMS+pLyN2d+AAA9gj2WH5PfWSDK +pRaDhJgJF55wDZys0f15odyijm3qu0tttj8IHxS6Li5vqttOyO1KE6A4UKbw9Op3 +0egMJ2VMaIH98cD0TCC+AGChDEsoPuhM2b5Q52OBHcugVw/akhVfdetdNYuBue1c +Ii+UyZkhF0PV/S67n9dg4XRq+Jed/3zUZQiJf/CT3pnW3umcOTUw2e3R4/TBuSOG +Mex6OF3zM2pNIxCejiKVW8VO4jTp+VSm+8i6xvjBb47GePOG99apVELWPiwsukSw +CRGJjGhXAgMBAAECggEAOrEB/kwXI8+VjdFMaGLdyKdvFPcWWxJx+hQJhF8Bn1oX +8aIX+QsqjhIPUlZ2/D0N1vGQCk3L6rJ0ec3AixPBxjr6lN2oEXvYGAJq1CLQU59+ +/3Vf+sj4GSvIhx+aW3vxwcKttYFrNS27SSdidQkd4wCbOq+tlv9lKcC/qbxwdu2p +CblxDiwivtotuf/2bfG+YgV5y8qiRgn1OVsHZK6xKZ/i8stURcNTq/6CrmLXpNBz +OdzFBQ9JIRQPezh4gfPQ7Bt8T50gwi38TfkzdsK9vzhxsmV6dPZNjDpPS4EChOzy +gQ+roS7m74vFaHfsDtSBb2sebt6pFBxxUnhDDe8OqQKBgQDdgIU7MePskr62AXRZ +QgoUC9Do40FPHvR7TPtLGATPqR4Jix2Mkxyy6gXaXyHobpQ7oXjeky+hVJY7wu1W +/QEA0Vk2WMEs3sauITAz7wbIQHZKHTXufZphOVOPQlmTx+QTt94Khhu7NWnIUhWW +QXSqO9oQusY6696258WZNSv3YwKBgQDQpEp7giVGebhoY4ABmwQpmnAJ2avJIaj9 +w40zqygs74VA8MVhVk48ZNt/XM/xkkB4KbsxYJrxg9+B4PQJ/wiv+4jVbMqWZMth +ahU6Stb8oTTzXJ6O3qIYrUpDK+ByD09snq/SsVo5FYYYGsP7NZ88rDAmbdQ9rrP1 +sxkyEIP/fQKBgGgdYgKaB82KiJQaiOrvrLcResgNEgSzwy012STKDHDjyFeqCWCr +QaEjeU7UyqZrW8fPtXXBb3EAxoEetdren5sXzDxMabjCmlb9CKBQqTp1emSJ6HDK +n0c13/4FrP9WxPEzyu3dbamIiMl9M+JlsAXYjj6w3D6T4iLNPMcwBBOLAoGBALN0 +a//5e/g3H47h7irzW0wxYqaGS8RuqDzEYwIK+D5WMgYeUZccNaSql0Tf3peIVN1F +/5VD42FSLP84Lo8ehilfr1zq+wEKZwg9x05hKrMWMUYU5ug5w7B39IT8C0vvsT/a +6Z3OH60zvyeidejvQSxdafjTxJbdWjo9trEiFXa9AoGBAIwj0zxK0YiZkXQWRP1b +B4IG4ZQbgpVKAlCYRQl7PCmaO8Eb9jYO1AQVCTONtR7DhINm/HpUAhjaokAya3wc +ckN/BbOwiehOprz/N5c1XkQOLWOz5LnTSk77EeyC84KiOuyqK46qAhQJ6zuUngsG +s+cPPn7xL+vCZWmYmmz4n+C7 +-----END PRIVATE KEY----- diff --git a/client/localhost+3.pem b/client/localhost+3.pem new file mode 100644 index 0000000..cb439ff --- /dev/null +++ b/client/localhost+3.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEpjCCAw6gAwIBAgIQOw0sc56bEW4nOW9fKvr8gDANBgkqhkiG9w0BAQsFADCB +sTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMUMwQQYDVQQLDDpiZW5v +aXRATWFjQm9vay1Qcm8tMTYtTTMtZGUtQmVub2l0LmxvY2FsIChCZW5vaXQgU2No +d2FydHopMUowSAYDVQQDDEFta2NlcnQgYmVub2l0QE1hY0Jvb2stUHJvLTE2LU0z +LWRlLUJlbm9pdC5sb2NhbCAoQmVub2l0IFNjaHdhcnR6KTAeFw0yNjA1MjIyMDU4 +NTBaFw0yODA4MjIyMDU4NTBaMG4xJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVu +dCBjZXJ0aWZpY2F0ZTFDMEEGA1UECww6YmVub2l0QE1hY0Jvb2stUHJvLTE2LU0z +LWRlLUJlbm9pdC5sb2NhbCAoQmVub2l0IFNjaHdhcnR6KTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBALSGkwd2U8pNanryLHN6UzV+HdNa3CljgeYMUoXT +1VoGEi/qZIF2IxL6kvI3Z34AAD2CPZYfk99ZIMqlFoOEmAkXnnANnKzR/Xmh3KKO +beq7S222PwgfFLouLm+q207I7UoToDhQpvD06nfR6AwnZUxogf3xwPRMIL4AYKEM +Syg+6EzZvlDnY4Edy6BXD9qSFV916101i4G57VwiL5TJmSEXQ9X9Lruf12DhdGr4 +l53/fNRlCIl/8JPemdbe6Zw5NTDZ7dHj9MG5I4Yx7Ho4XfMzak0jEJ6OIpVbxU7i +NOn5VKb7yLrG+MFvjsZ484b31qlUQtY+LCy6RLAJEYmMaFcCAwEAAaN8MHowDgYD +VR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFEVk +aBcatJettT33mJ0OUx+ILYQRMDIGA1UdEQQrMCmCCWxvY2FsaG9zdIcEfwAAAYcE +CgEBb4cQAAAAAAAAAAAAAAAAAAAAATANBgkqhkiG9w0BAQsFAAOCAYEAUZAIBw4n +8pBXLSnpgw84bnwgLeVvzg53SKcUzUSuymImv9luhKySmodiVLw4M89K0RSSm7F0 +SeRjzMr3sMhYa6K4sZtn5QtRhAQn0c+r6PTUgKe0PU5FmMV27DoIa9iS1BcIqjVF +G0QSdFRB1UqXlPVyBVN7XeQx4XYqVEbStZLSV0LxgHOAc73c6zXh00OrDpFdox4t +UeA7GUpZyFMm/mxuiQaBdY2m5CGgBPbtGOxlq3JOHj/aNcww5DP3m3o+M5TfT3lK +6Ex5j4N2ym5cLixon5vtqTkAmJlX70xB2qh+TmdZ+BDZ4Y1C0otEPSv6vQX4zV/2 +I9gWl3w+sm85BAXff0TBahW0+p98o44M+y62xwhwUQ4/E9cQLJElFnXk2Hi9eHPk +mchbDPHv7L8rGHArl6GOIa2MVQxZKDjdTRtx6k2gYYB/qWoeMfM1E8hc2n5SANTP +Q24BYvk8qFH4ECz0hhxTX9rwvxHeTLB1exBBoSEVi/neagG1UsD6OA4H +-----END CERTIFICATE----- diff --git a/client/src/App.jsx b/client/src/App.jsx index d6b546e..db73261 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -56,6 +56,18 @@ function App() { setError(null); try { + // IMPORTANT iOS : Demander permission microphone AVANT tout + console.log('🎤 Demande permission microphone...'); + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + console.log('✓ Permission microphone accordée'); + // Arrêter le stream test immédiatement + stream.getTracks().forEach(track => track.stop()); + } catch (permErr) { + console.error('❌ Permission microphone refusée:', permErr); + throw new Error('Accès microphone refusé. Autorisez dans les réglages iOS : Safari > Microphone.'); + } + // Obtenir token du serveur const response = await fetch(`${API_URL}/token`, { method: 'POST', @@ -69,12 +81,27 @@ function App() { const data = await response.json(); + // Adapter l'URL LiveKit selon le protocole de la page + let livekitUrl = data.url; + if (window.location.protocol === 'https:') { + // En HTTPS, utiliser le proxy WSS local via Vite + livekitUrl = `${window.location.protocol}//${window.location.host}/livekit`; + } + + console.log('🔗 Connexion LiveKit:', livekitUrl); + // Se connecter à LiveKit - await connect(data.url, data.token); + await connect(livekitUrl, data.token); } catch (err) { console.error('Erreur connexion:', err); - setError('Connexion impossible. Vérifiez le serveur.'); + + // Message d'erreur spécifique selon le type + if (err.message && err.message.includes('Microphone')) { + setError(err.message); + } else { + setError('Connexion impossible. Vérifiez le serveur et les permissions microphone.'); + } } finally { setIsConnecting(false); } diff --git a/client/src/components/PTTButton.css b/client/src/components/PTTButton.css index ee331a6..0a5e496 100644 --- a/client/src/components/PTTButton.css +++ b/client/src/components/PTTButton.css @@ -26,6 +26,12 @@ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); position: relative; overflow: hidden; + + /* Mobile touch optimizations */ + touch-action: none; + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; } .ptt-button::before { diff --git a/client/src/components/PTTButton.jsx b/client/src/components/PTTButton.jsx index f7a4f98..37ecc04 100644 --- a/client/src/components/PTTButton.jsx +++ b/client/src/components/PTTButton.jsx @@ -21,6 +21,7 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd }) { // Touch events (mobile) const handleTouchStart = (e) => { e.preventDefault(); + console.log('🖐️ Touch start'); if (!isPressingRef.current) { isPressingRef.current = true; onPressStart(); @@ -29,6 +30,7 @@ export default function PTTButton({ isTalking, onPressStart, onPressEnd }) { const handleTouchEnd = (e) => { e.preventDefault(); + console.log('🖐️ Touch end'); if (isPressingRef.current) { isPressingRef.current = false; onPressEnd(); diff --git a/client/src/hooks/useLiveKit.js b/client/src/hooks/useLiveKit.js index 678fea7..e5063a0 100644 --- a/client/src/hooks/useLiveKit.js +++ b/client/src/hooks/useLiveKit.js @@ -15,6 +15,7 @@ export default function useLiveKit() { const audioContextRef = useRef(null); const analyserRef = useRef(null); const animationFrameRef = useRef(null); + const isAudioUnlockedRef = useRef(false); /** * Connexion à la room LiveKit @@ -37,6 +38,8 @@ export default function useLiveKit() { // Events room.on(RoomEvent.Connected, () => { console.log('✓ Connecté à LiveKit'); + console.log(' Room name:', room.name); + console.log(' Participants distants:', room.remoteParticipants.size); setIsConnected(true); }); @@ -47,7 +50,8 @@ export default function useLiveKit() { }); room.on(RoomEvent.ParticipantConnected, (participant) => { - console.log('Participant rejoint:', participant.identity); + console.log('🟢 Participant rejoint:', participant.identity); + console.log(' Total participants distants:', room.remoteParticipants.size); updateParticipants(); }); @@ -77,20 +81,45 @@ export default function useLiveKit() { updateParticipants(); }); + // Event track local publié + room.on(RoomEvent.LocalTrackPublished, (publication) => { + console.log('✓ Track local publié:', publication.kind); + if (publication.kind === Track.Kind.Audio) { + const track = publication.track; + console.log(' Track audio disponible:', track); + console.log(' isMuted:', track.isMuted); + localTrackRef.current = track; + // Mute par défaut (PTT) + track.mute(); + setupAudioAnalyser(track); + console.log('✓ Track audio configuré et muted pour PTT'); + } + }); + // Connexion await room.connect(url, token); + console.log('📞 Connexion établie, activation microphone...'); + // Activer microphone (muted par défaut) await room.localParticipant.setMicrophoneEnabled(true); - const track = room.localParticipant.audioTracks.values().next().value?.track; - if (track) { - localTrackRef.current = track; - // Mute par défaut (PTT) - track.mute(); - setupAudioAnalyser(track); + console.log('🎤 Microphone activé, attente publication track...'); + + // Attendre que le track soit publié (max 3s) + let retries = 0; + while (!localTrackRef.current && retries < 30) { + await new Promise(resolve => setTimeout(resolve, 100)); + retries++; } + if (!localTrackRef.current) { + console.error('❌ Timeout : track audio non publié après 3s'); + throw new Error('Microphone non disponible. Autorisez l\'accès au micro dans les réglages iOS.'); + } + + console.log('✓ Track audio prêt'); + updateParticipants(); } catch (error) { @@ -112,25 +141,65 @@ export default function useLiveKit() { setParticipants([]); }, []); + /** + * Débloque l'audio sur mobile (iOS/Android) + * Doit être appelé dans un gestionnaire d'événement utilisateur + */ + const unlockAudio = useCallback(() => { + if (isAudioUnlockedRef.current) return; + + try { + // Créer un contexte audio silencieux pour débloquer l'API + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + gainNode.gain.value = 0; // Silence + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + oscillator.start(0); + oscillator.stop(0.001); + + isAudioUnlockedRef.current = true; + console.log('✓ Audio débloqué (mobile)'); + } catch (error) { + console.warn('Audio unlock échoué:', error); + } + }, []); + /** * Commencer à parler (unmute micro) */ const startTalking = useCallback(async () => { - if (!localTrackRef.current) return; + console.log('🎤 startTalking appelé'); + console.log(' localTrackRef.current:', localTrackRef.current); + + if (!localTrackRef.current) { + console.warn('⚠️ Pas de track audio local disponible'); + alert('Microphone non disponible. Réessayez.'); + return; + } try { - await localTrackRef.current.unmute(); + // Débloquer audio sur mobile au premier appui + unlockAudio(); + + // Feedback immédiat AVANT unmute setIsTalking(true); - console.log('🎤 PTT: Talking'); + + await localTrackRef.current.unmute(); + console.log('🎤 PTT: Talking (unmuted)'); // Vibration haptique (si supporté) if (navigator.vibrate) { navigator.vibrate(50); } } catch (error) { - console.error('Erreur unmute:', error); + console.error('❌ Erreur unmute:', error); + setIsTalking(false); + alert(`Erreur microphone: ${error.message}`); } - }, []); + }, [unlockAudio]); /** * Arrêter de parler (mute micro) @@ -163,7 +232,8 @@ export default function useLiveKit() { // Participants distants room.remoteParticipants.forEach((participant) => { - const audioPublication = Array.from(participant.audioTracks.values())[0]; + const audioTracks = participant.audioTracks ? Array.from(participant.audioTracks.values()) : []; + const audioPublication = audioTracks[0]; const isSpeaking = room.activeSpeakers.some(s => s.identity === participant.identity); participantsList.push({ diff --git a/client/vite.config.js b/client/vite.config.js index f312237..3cad8a4 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -1,6 +1,7 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { VitePWA } from 'vite-plugin-pwa'; +import fs from 'fs'; export default defineConfig({ plugins: [ @@ -57,11 +58,21 @@ export default defineConfig({ server: { port: 5173, host: true, + https: { + key: fs.readFileSync('./localhost+3-key.pem'), + cert: fs.readFileSync('./localhost+3.pem'), + }, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') + }, + '/livekit': { + target: 'ws://10.1.1.111:7880', + ws: true, + changeOrigin: true, + rewrite: (path) => path.replace(/^\/livekit/, '') } } },