add webrtc
This commit is contained in:
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -51,6 +51,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user