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/, '')
}
}
},