add webrtc

This commit is contained in:
2026-09-23 13:58:42 +02:00
parent ab97dcc8d9
commit b234359c4a
8 changed files with 1163 additions and 48 deletions
+360
View File
@@ -0,0 +1,360 @@
<script lang="ts" setup>
import {onUnmounted, ref} from 'vue'
const channelId = ref('')
const logs = ref<string[]>([])
const audioStats = ref<string[]>([])
const connected = ref(false)
const audioFile = ref<File | null>(null)
const sourceLevel = ref(0)
const roomLevel = ref(0)
let socket: WebSocket | null = null
let peer: RTCPeerConnection | null = null
let microphone: MediaStream | null = null
let microphoneContext: AudioContext | null = null
let microphoneAnalyser: AnalyserNode | null = null
let roomContext: AudioContext | null = null
const roomAnalysers = new Map<MediaStreamTrack, AnalyserNode>()
let fileAudio: HTMLAudioElement | null = null
let fileUrl: string | null = null
let fileStream: MediaStream | null = null
let pendingCandidates: string[] = []
let audioStatsTimer: ReturnType<typeof setInterval> | null = null
let levelTimer: ReturnType<typeof setInterval> | null = null
const remoteAudio = ref<HTMLDivElement | null>(null)
const players = new Map<MediaStreamTrack, HTMLAudioElement>()
let roomStream: MediaStream | null = null
const sourceTracks = new Map<string, MediaStreamTrack>()
function rms(analyser: AnalyserNode | null, context: AudioContext | null) {
if (!analyser || context?.state !== 'running') return 0
const samples = new Float32Array(analyser.fftSize)
analyser.getFloatTimeDomainData(samples)
return Math.sqrt(samples.reduce((sum, sample) => sum + sample * sample, 0) / samples.length)
}
function updateLevels() {
sourceLevel.value = Math.min(1, Math.sqrt(rms(microphoneAnalyser, microphoneContext)))
roomLevel.value = Math.min(1, Math.sqrt([...roomAnalysers.values()].reduce((sum, analyser) => sum + rms(analyser, roomContext) ** 2, 0)))
}
function removeRoomTrack(track: MediaStreamTrack) {
roomStream?.removeTrack(track)
const player = players.get(track)
if (player) {
player.pause()
player.srcObject = null
player.remove()
players.delete(track)
}
roomAnalysers.get(track)?.disconnect()
roomAnalysers.delete(track)
roomLevel.value = 0
}
function log(message: string) {
logs.value.push(`${new Date().toLocaleTimeString()} ${message}`)
}
async function logSelectedIcePair(connection: RTCPeerConnection) {
const stats = await connection.getStats()
const pair = [...stats.values()].find(report => report.type === 'candidate-pair' && report.nominated && report.state === 'succeeded')
if (!pair) {
log('Paire ICE sélectionnée introuvable dans les statistiques.')
return
}
const local = stats.get(pair.localCandidateId)
const remote = stats.get(pair.remoteCandidateId)
log(`Paire ICE sélectionnée : local ${local?.address ?? local?.ip ?? '?'}:${local?.port ?? '?'} (${local?.candidateType ?? '?'}) → distant ${remote?.address ?? remote?.ip ?? '?'}:${remote?.port ?? '?'} (${remote?.candidateType ?? '?'})`)
}
async function logAudioStats(connection: RTCPeerConnection) {
const stats = await connection.getStats()
if (peer !== connection) return
const sent = [...stats.values()].find(report => report.type === 'outbound-rtp' && report.kind === 'audio')
const activeTracks = new Set(roomStream?.getAudioTracks().map(track => track.id) ?? [])
const inbound = [...stats.values()].filter(report => report.type === 'inbound-rtp' && report.kind === 'audio' && activeTracks.has(report.trackIdentifier))
const received = inbound.find(report => (report.packetsReceived ?? 0) > 0) ?? inbound[0]
const lines = [`Audio : envoyé ${sent?.packetsSent ?? '?'} paquets / ${sent?.bytesSent ?? '?'} octets ; reçu ${inbound.length ? inbound.reduce((sum, report) => sum + (report.packetsReceived ?? 0), 0) : '?'} paquets / ${inbound.length ? inbound.reduce((sum, report) => sum + (report.bytesReceived ?? 0), 0) : '?'} octets (${inbound.length} piste(s))`]
const level = (value: number | undefined) => typeof value === 'number' ? value.toFixed(3) : '?'
const micLevel = microphoneAnalyser && microphoneContext?.state === 'running' ? rms(microphoneAnalyser, microphoneContext) : undefined
lines.push(`Niveaux audio (0 à 1) : source RMS ${level(micLevel)} ; retour ${level(received?.audioLevel)} ; pertes ${received?.packetsLost ?? '?'} ; gigue ${received?.jitter ?? '?'} s ; échantillons masqués ${received?.concealedSamples ?? '?'}/${received?.totalSamplesReceived ?? '?'}`)
audioStats.value = lines
}
function disconnect() {
if (audioStatsTimer) clearInterval(audioStatsTimer)
audioStatsTimer = null
if (levelTimer) clearInterval(levelTimer)
levelTimer = null
sourceLevel.value = 0
roomLevel.value = 0
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({action: 'leave'}))
socket.close()
} else {
socket?.close()
}
socket = null
peer?.close()
peer = null
microphone?.getTracks().forEach(track => track.stop())
microphone = null
fileAudio?.pause()
fileAudio = null
fileStream?.getTracks().forEach(track => track.stop())
fileStream = null
if (fileUrl) URL.revokeObjectURL(fileUrl)
fileUrl = null
microphoneAnalyser = null
if (microphoneContext) void microphoneContext.close()
microphoneContext = null
roomAnalysers.forEach(analyser => analyser.disconnect())
roomAnalysers.clear()
if (roomContext) void roomContext.close()
roomContext = null
for (const track of players.keys()) removeRoomTrack(track)
roomStream = null
sourceTracks.clear()
pendingCandidates = []
connected.value = false
}
async function connect() {
const channel = channelId.value.trim()
if (!/^[0-9a-fA-F-]{36}$/.test(channel)) {
log('Renseigner un identifiant de canal valide.')
return
}
try {
audioStats.value = []
let stream: MediaStream
if (audioFile.value) {
const context = new AudioContext()
microphoneContext = context
fileUrl = URL.createObjectURL(audioFile.value)
const player = new Audio(fileUrl)
fileAudio = player
player.loop = true
const source = context.createMediaElementSource(player)
const destination = context.createMediaStreamDestination()
const analyser = context.createAnalyser()
analyser.fftSize = 2048
source.connect(destination)
source.connect(analyser)
microphoneAnalyser = analyser
await context.resume()
stream = destination.stream
fileStream = stream
await player.play()
log(`Fichier audio envoyé : ${audioFile.value.name} (en boucle, sans lecture locale)`)
} else {
stream = await navigator.mediaDevices.getUserMedia({audio: true})
microphone = stream
try {
const context = new AudioContext()
microphoneContext = context
const analyser = context.createAnalyser()
analyser.fftSize = 2048
context.createMediaStreamSource(stream).connect(analyser)
microphoneAnalyser = analyser
await context.resume()
} catch (error) {
log(`Mesure du niveau micro indisponible : ${String(error)}`)
}
}
const url = new URL(`/ws/rtc/${encodeURIComponent(channel)}`, window.location.href)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
const connection = new RTCPeerConnection()
const ws = new WebSocket(url)
peer = connection
socket = ws
connected.value = true
stream.getAudioTracks().forEach(track => connection.addTrack(track, stream))
roomStream = new MediaStream()
connection.ontrack = ({streams, track}) => {
if (peer !== connection || players.has(track)) return
const remoteStream = roomStream!
remoteStream.addTrack(track)
if (streams[0]) sourceTracks.set(streams[0].id, track)
track.onended = () => removeRoomTrack(track)
if (remoteAudio.value) {
const player = new Audio()
player.autoplay = true
player.controls = true
player.srcObject = new MediaStream([track])
players.set(track, player)
remoteAudio.value.append(player)
void player.play().catch(error => log(`Lecture audio impossible : ${String(error)}`))
}
if (track.kind === 'audio') {
if (!roomContext) {
roomContext = new AudioContext()
void roomContext.resume().catch(error => log(`Mesure du retour indisponible : ${String(error)}`))
}
const analyser = roomContext.createAnalyser()
analyser.fftSize = 2048
roomContext.createMediaStreamSource(new MediaStream([track])).connect(analyser)
roomAnalysers.set(track, analyser)
}
log('Piste audio distante reçue')
}
connection.onicecandidate = ({candidate}) => {
if (candidate && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({action: 'ice-candidate', candidate: candidate.candidate}))
log('Candidat ICE local envoyé')
}
}
connection.oniceconnectionstatechange = () => {
log(`ICE : ${connection.iceConnectionState}`)
if (connection.iceConnectionState === 'connected' || connection.iceConnectionState === 'completed') {
void logSelectedIcePair(connection).catch(error => log(`Statistiques ICE indisponibles : ${String(error)}`))
}
}
connection.onconnectionstatechange = () => {
log(`Connexion : ${connection.connectionState}`)
if (connection.connectionState === 'connected' && !audioStatsTimer) {
levelTimer = setInterval(updateLevels, 100)
void logAudioStats(connection).catch(error => log(`Statistiques audio indisponibles : ${String(error)}`))
audioStatsTimer = setInterval(() => {
void logAudioStats(connection).catch(error => log(`Statistiques audio indisponibles : ${String(error)}`))
}, 2000)
} else if (connection.connectionState !== 'connected' && audioStatsTimer) {
clearInterval(audioStatsTimer)
audioStatsTimer = null
if (levelTimer) clearInterval(levelTimer)
levelTimer = null
sourceLevel.value = 0
roomLevel.value = 0
}
}
ws.onopen = async () => {
log('WebSocket ouvert')
try {
const offer = await connection.createOffer()
await connection.setLocalDescription(offer)
if (ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({action: 'sdp-offer', sdp: connection.localDescription?.sdp}))
log('Offre SDP envoyée')
} catch (error) {
log(`Offre impossible : ${String(error)}`)
disconnect()
}
}
let messageQueue = Promise.resolve()
ws.onmessage = ({data}) => {
messageQueue = messageQueue.then(async () => {
if (socket !== ws) return
try {
const message = JSON.parse(data)
if (message.action === 'answer') {
await connection.setRemoteDescription({type: 'answer', sdp: message.sdp})
log('Réponse SDP appliquée')
for (const candidate of pendingCandidates) {
await connection.addIceCandidate({candidate, sdpMLineIndex: 0})
}
pendingCandidates = []
} else if (message.action === 'sdp-offer') {
await connection.setRemoteDescription({type: 'offer', sdp: message.sdp})
const answer = await connection.createAnswer()
await connection.setLocalDescription(answer)
ws.send(JSON.stringify({action: 'sdp-answer', sdp: connection.localDescription?.sdp}))
log('Pistes du salon renégociées')
} else if (message.action === 'source-left') {
const track = sourceTracks.get(message.id)
if (track) {
removeRoomTrack(track)
sourceTracks.delete(message.id)
}
log(`Participant parti : ${message.id}`)
} else if (message.action === 'ice-candidate') {
if (connection.remoteDescription) {
await connection.addIceCandidate({candidate: message.candidate, sdpMLineIndex: 0})
} else {
pendingCandidates.push(message.candidate)
}
log(`Candidat ICE distant reçu : ${message.candidate}`)
} else if (message.action === 'error') {
log(`Erreur serveur : ${message.message}`)
} else {
log(`Message inconnu : ${message.action}`)
}
} catch (error) {
log(`Message impossible à traiter : ${String(error)}`)
}
})
}
ws.onerror = () => log('Erreur WebSocket (vérifier le serveur, le canal et la session).')
ws.onclose = ({code}) => {
log(`WebSocket fermé (${code})`)
if (socket === ws) disconnect()
}
} catch (error) {
log(`Connexion impossible : ${String(error)}`)
disconnect()
}
}
onUnmounted(disconnect)
</script>
<template>
<v-main class="rtc-test pa-6">
<div class="rtc-test-content">
<h1 class="text-h5 mb-4">Test RTC (salon)</h1>
<p class="mb-4">Ouvre cette page dans deux onglets avec le même canal. Chaque onglet entend l'autre, sans retour de son propre micro ou fichier audio.</p>
<v-text-field v-model="channelId" label="Identifiant du canal" :disabled="connected" />
<input type="file" accept="audio/*" :disabled="connected" class="d-block mb-4" @change="audioFile = ($event.target as HTMLInputElement).files?.[0] ?? null" />
<v-btn v-if="!connected" color="primary" @click="connect">Connecter</v-btn>
<v-btn v-else color="primary" @click="disconnect">Quitter</v-btn>
<div ref="remoteAudio" class="mt-4" />
<div class="mt-4 rtc-test-panel">
<h2 class="text-h6">Niveaux audio</h2>
<label for="source-level">Ma source (micro ou fichier)</label>
<progress id="source-level" :value="sourceLevel" max="1" class="rtc-test-level" />
<label for="room-level">Son reçu du salon (autres participants)</label>
<progress id="room-level" :value="roomLevel" max="1" class="rtc-test-level" />
</div>
<div class="mt-4 rtc-test-panel">
<h2 class="text-h6">Statistiques WebRTC</h2>
<pre class="rtc-test-text">{{ audioStats.join('\n') || 'En attente de connexion…' }}</pre>
</div>
<div class="mt-4 rtc-test-panel">
<h2 class="text-h6">Journal</h2>
<pre class="rtc-test-text rtc-test-logs">{{ logs.join('\n') }}</pre>
</div>
</div>
</v-main>
</template>
<style scoped>
.rtc-test-content {
max-width: 900px;
margin: 0 auto;
}
.rtc-test-panel {
padding: 16px;
border: 1px solid rgba(128, 128, 128, 0.5);
border-radius: 8px;
}
.rtc-test-level {
display: block;
width: 100%;
height: 20px;
margin-bottom: 12px;
}
.rtc-test-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0;
}
.rtc-test-logs {
max-height: 360px;
overflow-y: auto;
}
</style>
+5
View File
@@ -60,6 +60,11 @@ const router = createRouter({
},
],
},
{
path: 'rtc-test',
name: 'rtc-test',
component: () => import('@/pages/rtc-test.vue'),
},
{
path: 'server/:serverId(default|[0-9a-fA-F-]{36})',
name: 'server-dashboard',
+4
View File
@@ -51,6 +51,10 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
})